简介面向图像处理学习者与OpenCV开发者的一份频率域低通滤波实践代码包聚焦高斯、理想与巴特沃斯三类滤波器的实现与效果对照适合已掌握C基础、希望理解频域滤波流程与参数影响的中级读者。压缩包共8个文件、约86KB以C源码和VC工程配置为主并附带bmp测试图像便于直接编译运行、观察不同截止频率下的平滑效果与振铃差异。已有2155人学习说明其在频域滤波入门与课程实验场景中具有一定参考价值。读者可借助源码梳理DFT、频谱修改、IDFT及归一化显示的完整链路对比理想滤波器的陡峭截断、高斯滤波器的平滑衰减和巴特沃斯滤波器的平坦过渡带进而掌握滤波器构造、频谱中心化与结果可视化等关键细节也可将其作为图像去噪、平滑处理实验的对照模板。1. 一张带周期网纹的扫描件opencv 频率域滤波先解决什么问题扫描件表面那层等间距的网纹用均值滤波和中值滤波试过几轮核小了纹路还在核大了字口糊成一团。这是空域滤波的局限——它按邻域位置做平滑分不清“纹理”和“笔画”只要落在同一个窗口里就一视同仁。opencv 频率域滤波换了个视角周期性网纹在频谱上是一对对称的亮点笔画和背景能量集中在低频用一个只压掉那两个亮点的掩膜纹路消失字口基本不动。日常能碰到的同类问题还有工业相机拍金属件的摩尔纹、遥感图的条带噪声、显微图的背景不均。这篇按“选型—代码—进阶—排查”走一遍把高斯、理想、巴特沃斯三种低通/高通在 OpenCV 里真正跑起来。2. 滤波器形状、截止频率与填充尺寸频域滤波的三个选型问题2.1 为什么卷积能变成乘法OpenCV 的 dft 链路上发生了什么空域里做的是卷积频域里做的是逐点相乘这条等价关系来自卷积定理。把图像 $f(x,y)$ 和滤波器冲激响应 $h(x,y)$ 都做离散傅里叶变换得到 $F(u,v)$ 和 $H(u,v)$那么 $f * h$ 的频谱就是 $F \cdot H$。工程上的意义很直接空域里要设计一个 51×51 的核在整幅图上滑动频域里只需要生成一张和图像同尺寸的掩膜做一次逐元素乘法。滤波器越复杂、核越大频域这条路的优势越明显。但 OpenCV 的 dft 有几件事和 numpy 的习惯不一样第一次上手容易翻车。第一cv2.dft的输入必须是CV_32F或CV_64F读进来的uint8要显式转换第二默认输出是双通道的复数排列实部在[:,:,0]虚部在[:,:,1]需要加flagscv2.DFT_COMPLEX_OUTPUT才拿得到第三正变换不缩放反变换默认也不缩放这意味着idft(dft(x))得到的是原图的 $M \times N$ 倍必须靠cv2.DFT_SCALE或手动除回来。还有一个容易忽略的概念点DFT 隐含的是循环卷积不是线性卷积。图像右下角和左上角在频域里是“接在一起”的如果直接对原图做变换边界内容会绕回来污染对侧。缓解办法是先 padding再在反变换后裁掉。padding 的尺寸不是随便补的OpenCV 提供了getOptimalDFTSize来返回不小于输入的最小快速尺寸这个尺寸的质因子只包含 2、3、5DFT 跑得最快。频谱中心化是另一个必须说清楚的动作。未中心化的频谱低频在四个角上中心化之后低频搬到画面正中圆形和环形滤波器才能用“到中心的距离”来定义。中心化只影响掩膜的设计和频谱的显示不影响变换本身。np.fft.fftshift默认会对数组的所有轴做移位遇到双通道的 dft 结果会把通道维也翻过去必须写成axes[0,1]反变换时用ifftshift配对。2.2 理想、高斯、巴特沃斯三条频响曲线各自的代价三种低通滤波器的差别全在“从通带到阻带的过渡方式”上。理想低通的传递函数是一个硬切的圆$H(u,v)1$ 当 $D(u,v) \le D_0$否则为 0。$D(u,v)$ 是频率点到中心的距离。它在频域是一个矩形窗对应到空域就是二维 sinc 函数sinc 有正负旁瓣和图像卷积之后强边缘两侧会出现过冲和下冲也就是振铃。振铃不是参数没调好是理想低通的数学必然所以它更适合做频谱分析和教学演示成片处理里很少单独用。高斯低通是 $H \exp(-D^2/(2D_0^2))$。它的傅里叶变换还是高斯空域没有旁瓣所以不产生振铃。代价是过渡带最宽同样的截止频率下边缘最软去噪的同时细节也一起被抹掉。它适合放在预处理链的前段或者你只关心低频结构、不关心锐度的场景。巴特沃斯低通是 $H 1/(1(D/D_0)^{2n})$。$n1$ 时它退化成一种很缓的曲线$n$ 越大越逼近理想低通的硬切。$n2$ 到 $4$ 是工程上常用的区间过渡带比高斯窄振铃比理想弱。$D_0$ 在这里就是半功率点$H(D_0)0.5$这一点比高斯方便高斯的 $D_0$ 是标准差半功率点在 $1.177D_0$ 附近。滤波器过渡带空域旁瓣振铃典型用途理想低通无硬切sinc旁瓣大明显频谱分析、掩膜演示高斯低通最宽无无去噪、预处理、背景平滑巴特沃斯 n2中等有衰减快轻微通用低通、去周期性纹理巴特沃斯 n5窄接近 sinc明显需要陡峭截止且能接受振铃选型的判断顺序我一般是这样先看目标是不是“压掉某段频率、保留边缘”是的话优先高斯如果高斯把需要的细节也糊掉了换巴特沃斯 n2 并适当抬高 $D_0$只有在做频谱分析、需要看清楚某个频段被切掉多少时才用理想低通。把理想低通直接上生产图振铃会被误认为“图像质量变差了”然后回头反复调 $D_0$这是很常见的弯路。2.3 截止频率 D0 不能写死成像素值$D_0$ 的单位是频率域的采样点距离它和图像尺寸强耦合。同一张内容、分别缩放到 256×256 和 2048×2048$D_030$ 在两张图里代表的物理截止频率差了八倍。所以代码里不要写d0 30要写归一化比例。常用的归一化方式有两种。一种是按短边比例$D_0 \text{ratio} \times \min(\text{rows}, \text{cols})$ratio 取 0.05 到 0.2具体看你要压掉多粗的纹理。另一种是按图像对角线适合长宽比差异大的图。我一般用短边比例因为它对长条图更稳。参数含义取值建议调大后的影响ratio截止半径占短边比例0.050.2保留更多高频去噪变弱D0截止半径像素ratio × min(H,W)同上n巴特沃斯阶数24过渡带变窄振铃增强sigma高斯标准差与 D0 同量级平滑范围变大borderpadding 模式REFLECT边界接缝减少还有一点$D_0$ 太小时低频被削得太多图像整体亮度会往下掉因为直流分量虽然还在但低频结构被压掉了。判断方法是看输出均值和原图均值差超过 5 个灰度级就说明 $D_0$ 偏小了。3. 用 OpenCV 跑通三种低通从补齐尺寸到反变换的完整脚本3.1 getOptimalDFTSize 和 copyMakeBorder为什么不能直接 dft直接把原图丢给cv2.dft能跑但尺寸不是最优时速度会慢一截而且边界环绕更明显。标准做法是先算出最优尺寸再补边。补边的方向是“右下补”左上保持不动这样反变换后直接取[:rows, :cols]就是原图位置不用做额外的平移。import cv2 import numpy as np def pad_to_optimal(img): 把单通道图补到 DFT 最优尺寸返回补边后的图和原始尺寸 rows, cols img.shape[:2] m cv2.getOptimalDFTSize(rows) n cv2.getOptimalDFTSize(cols) if m ! rows or n ! cols: # 参数顺序top, bottom, left, right只往右下补 img cv2.copyMakeBorder( img, 0, m - rows, 0, n - cols, cv2.BORDER_REFLECT ) return img, rows, colsgetOptimalDFTSize返回的是不小于输入的最小快速尺寸比如 500 会返回 500因为 5002²×5³501 会返回 512。补边模式选BORDER_REFLECT而不是默认的BORDER_CONSTANT原因是常量补零会在边界制造一个从图像灰度到 0 的硬跳变这个跳变在频域里是宽带高频反变换后表现为边界一圈亮线或暗线。反射补边让边界两侧的灰度连续接缝会轻很多。如果图像本身边界 content 差异很大BORDER_REPLICATE也可以作为备选。3.2 三种低通掩膜的生成函数掩膜生成的共同前提是先有一张“到中心距离”的网格。因为已经做了 fftshift低频在(rows//2, cols//2)所以网格的坐标原点是中心而不是左上角。def radius_grid(shape): 生成以频谱中心为原点的距离网格 rows, cols shape u np.arange(rows) - rows // 2 v np.arange(cols) - cols // 2 return np.sqrt(u[:, None] ** 2 v[None, :] ** 2).astype(np.float32) def ideal_lowpass(shape, d0): d radius_grid(shape) return (d d0).astype(np.float32) def gaussian_lowpass(shape, d0): d radius_grid(shape) return np.exp(-(d ** 2) / (2.0 * d0 ** 2)).astype(np.float32) def butterworth_lowpass(shape, d0, order2): d radius_grid(shape) return (1.0 / (1.0 (d / d0) ** (2 * order))).astype(np.float32)三个函数的返回值都是(rows, cols)的float32值域在 0 到 1 之间。d0传进来之前要先按短边比例算好。order只对巴特沃斯有效取 2 到 4写成 5 以上时振铃会明显增强这一点在 2.2 的对比表里已经说过。注意radius_grid里用的是rows // 2而不是(rows-1)/2。当尺寸是偶数时两者一致是奇数时差半个像素对圆形滤波器的影响可以忽略。但如果你的图补边后是奇数尺寸而你又严格要求中心对齐可以改成浮点中心代价是网格计算稍慢。3.3 正变换、中心化、滤波、反变换与裁剪回原尺寸把前面的零件串起来完整链路是六步转浮点、补边、dft、fftshift、乘掩膜、ifftshift idft magnitude 裁剪。def freq_lowpass(gray, kindgaussian, ratio0.1, order2): gray: uint8 单通道kind: ideal/gaussian/butterworth img gray.astype(np.float32) padded, rows, cols pad_to_optimal(img) m, n padded.shape # 正变换输出双通道复数 dft cv2.dft(padded, flagscv2.DFT_COMPLEX_OUTPUT) # 中心化只移前两个轴通道轴不能动 dft_shift np.fft.fftshift(dft, axes[0, 1]) d0 ratio * min(rows, cols) if kind ideal: H ideal_lowpass((m, n), d0) elif kind gaussian: H gaussian_lowpass((m, n), d0) else: H butterworth_lowpass((m, n), d0, order) # 逐点相乘H 是二维dft_shift 是三维扩一维对齐 filtered dft_shift * H[:, :, None] # 反变换先逆中心化再 idftDFT_SCALE 负责 1/(m*n) f_ishift np.fft.ifftshift(filtered, axes[0, 1]) back cv2.idft(f_ishift, flagscv2.DFT_SCALE | cv2.DFT_COMPLEX_OUTPUT) out cv2.magnitude(back[:, :, 0], back[:, :, 1]) # 裁回原尺寸并截断到 uint8 范围 return np.clip(out[:rows, :cols], 0, 255).astype(np.uint8)几个参数和写法值得单独说明。cv2.dft的DFT_COMPLEX_OUTPUT必须加否则拿到的是打包格式取实部虚部会错位。np.fft.fftshift的axes[0,1]是硬性要求不写的话通道维也会被移位后续乘掩膜时实部和虚部会被交换输出颜色和结构全乱。H[:, :, None]是把二维掩膜扩成(m,n,1)靠 numpy 广播和双通道相乘等价于实部虚部分别乘同一个系数。cv2.idft的DFT_SCALE做的是除以元素总数 $m \times n$正好抵消正变换的放大。如果忘了这个 flag输出值会是原图的几万倍astype(np.uint8)之后几乎全白。最后np.clip是保险浮点误差可能让个别像素落到 255 以上或 0 以下。3.4 用合成条纹图做一次自检拿真实图调参之前先用一张合成的、你知道 ground truth 的图跑一遍能快速确认链路没写错。# 造一张带竖条纹的测试图 canvas np.full((512, 512), 200, np.uint8) cv2.rectangle(canvas, (120, 180), (400, 330), 40, -1) for x in range(0, 512, 8): cv2.line(canvas, (x, 0), (x, 511), 120, 1) for kind in [ideal, gaussian, butterworth]: out freq_lowpass(canvas, kindkind, ratio0.08) print(kind, mean, out.mean(), std, out.std())三个输出放在一起看条纹应该都被压掉矩形的边缘保留程度不同。理想低通的矩形边缘两侧会出现明暗相间的细纹这就是振铃高斯边缘最软但没有纹巴特沃斯介于两者之间。如果理想低通的输出没有振铃、或者高斯的输出出现了振铃说明掩膜生成或中心化有问题先回去查radius_grid的中心坐标和fftshift的 axes。均值这一列也有用三种滤波器的输出均值应该接近 200 左右如果某个结果均值掉到 150 以下说明 $D_0$ 太小低频被过度压制。4. 高通、陷波与彩色图频域滤波的三种进阶用法4.1 高通不是简单 1 减低通直流分量要单独处理高通掩膜最直接的写法是 $H_{hp} 1 - H_{lp}$。数学上没错但直接拿去做会得到一个均值接近 0 的输出直流分量对应的 $H_{lp}(0,0)1$减完之后变成 0图像整体亮度被抽走了。astype(np.uint8)会把负值截成 0、正值截成 255看起来就是一张黑白噪点图。处理办法有三种。第一种是滤波后归一化cv2.normalize(out, out, 0, 255, cv2.NORM_MINMAX)适合只关心边缘结构、不关心绝对灰度的场景比如纹理分析。第二种是加偏移out out 128保留直流附近的相对关系适合做锐化预览对比。第三种是保留直流只在 $D D_0$ 的区域做提升中心低频区不置零。def highpass_from_lowpass(H, keep_dcTrue, dc_radius3): hp 1.0 - H if keep_dc: d radius_grid(H.shape) hp[d dc_radius] 1.0 return hp.astype(np.float32)dc_radius给 2 到 4 个像素把中心极小的一块低频保下来输出均值就不会塌。这个参数不需要精确它只影响整体亮度不影响高频增强的效果。高通还有一个边界问题比低通更明显。低通把高频压掉边界跳变本身也被压掉了所以接缝不明显高通反而把 padding 边界处的反射跳变放大了输出图的四条边会出现一圈亮线。解决办法是在 3.1 的补边之后、dft 之前先把边界几个像素用cv2.copyMakeBorder的内侧内容做羽化或者干脆在最终结果上裁掉 5 个像素的边。我一般选后者简单直接。4.2 周期性条纹的去除从频谱亮点到陷波器低通能去掉细密条纹但条纹周期较大时它的能量落在中频低通要压掉它就得以牺牲大量细节为代价。这时候用陷波只在频谱上那几个亮点位置做局部衰减其他频率原样保留。找亮点可以手工看也可以半自动。把中心化后的幅度谱做 log 变换屏蔽掉中心低频区剩下的极大值就是周期性噪声的谱峰。def find_peaks(dft_shift, exclude_ratio0.05, topk6): mag np.log1p(cv2.magnitude(dft_shift[:, :, 0], dft_shift[:, :, 1])) m, n mag.shape mask np.ones_like(mag, np.float32) cv2.circle(mask, (n // 2, m // 2), int(exclude_ratio * min(m, n)), 0, -1) score mag * mask idx np.argsort(score.ravel())[-topk:] return [(i // n, i % n) for i in idx]exclude_ratio屏蔽中心低频避免把直流和主要结构误判成噪声峰topk取 4 到 8因为一对对称亮点会产生两个峰取太多会把真实纹理也陷掉。拿到坐标后构造陷波掩膜def notch_mask(shape, centers, r4.0): H np.ones(shape, np.float32) rows, cols shape for cy, cx in centers: u np.arange(rows)[:, None] - cy v np.arange(cols)[None, :] - cx H * 1.0 - np.exp(-(u ** 2 v ** 2) / (2.0 * r ** 2)) return Hr是陷波半径取 3 到 6。太小压不干净太大会在空域产生新的振铃。陷波器本质上是多个高斯凹陷的乘积所以它比理想陷波温和不会引入明显的环形伪影。如果条纹方向单一只需要陷掉垂直于条纹方向的那一对峰对称位置会自动处理因为实图像的频谱是共轭对称的。4.3 彩色图转 YCrCb 只处理亮度通道三通道 BGR 直接cv2.dft会报错因为 dft 只接受单通道或双通道。拆成三个通道分别滤波再合并能跑但每个通道的低频结构略有差异分别压制之后通道间的比例关系被破坏结果常常发灰或者偏色。更稳的做法是转 YCrCb只对 Y 通道做频域滤波Cr 和 Cb 原样保留。def color_freq_lowpass(bgr, ratio0.1, kindgaussian): ycrcb cv2.cvtColor(bgr, cv2.COLOR_BGR2YCrCb) y, cr, cb cv2.split(ycrcb) y2 freq_lowpass(y, kindkind, ratioratio) merged cv2.merge([y2, cr, cb]) return cv2.cvtColor(merged, cv2.COLOR_YCrCb2BGR)Y 是亮度人眼对它的高频变化最敏感纹理和噪声也主要集中在这里Cr、Cb 是色度分辨率要求低保持原样不会带来视觉上的损失。如果处理的是色彩噪声可以再对 Cr、Cb 做一次小半径的低通但半径要比 Y 通道小一半以上否则会出现色彩渗出。同态滤波也是在这个结构上做的对 log 之后的亮度通道做频域高通再 exp 回来用来压掉光照不均。它的掩膜不是 4.1 里的简单高通而是 $H (\gamma_H - \gamma_L)(1 - \exp(-c D^2/D_0^2)) \gamma_L$$\gamma_H$ 取 1.5 到 2.0$\gamma_L$ 取 0.3 到 0.5。这个公式在 OpenCV 里没有现成函数需要自己写但骨架和 4.1 完全一样。5. 频域滤波的五个高频翻车点排查顺序与常见问题5.1 输出全黑或全白现象滤波后的图几乎全是 0 或者全是 255看不到任何结构。原因有两类。一类是反变换没缩放cv2.idft没加DFT_SCALE输出值域是原图的 $m \times n$ 倍astype(np.uint8)之后全部饱和。另一类是取了实部却忘了取模复数结果的实部有正有负直接截断会丢掉一半信息。解决确认cv2.idft的 flags 里带了cv2.DFT_SCALE或者手动out / (m * n)确认最后一步用的是cv2.magnitude(back[:,:,0], back[:,:,1])而不是back[:,:,0]。排查时先打印out.min()和out.max()正常值域应该在 0 到 255 附近超出这个范围就是缩放问题。5.2 图像中心出现十字亮线或环形伪影现象输出图中心有一条横竖交叉的亮线或者出现同心圆环。原因np.fft.fftshift没有指定axes默认对最后一维也做了移位双通道的实部虚部被交换或者滤波器掩膜的中心坐标和频谱中心不匹配低频被切在了错误的位置。解决正变换和反变换都用axes[0,1]且fftshift和ifftshift配对出现。掩膜生成时打印一下H[rows//2, cols//2]低频处应该是最大值低通或 0高通如果不是说明radius_grid的中心算错了。环形伪影则是理想低通振铃的典型表现换高斯或降巴特沃斯阶数。5.3 图像边缘一圈接缝现象输出图的四条边有明显亮线或暗线越靠边越明显。原因padding 用了BORDER_CONSTANT补零边界处灰度从图像值硬跳到 0这个跳变在频域是宽带高频反变换后回到边界位置。高通滤波会把这个跳变进一步放大。解决补边改用BORDER_REFLECT或BORDER_REPLICATE做完反变换后严格按[:rows, :cols]裁回原尺寸。如果接缝还在检查是不是裁剪时多裁或少裁了几个像素——补边是往右下补的左上不动裁剪也应该是从左上开始。5.4 彩色图分通道处理之后颜色发灰现象三个通道分别滤波再合并结果饱和度明显下降或者整体偏青、偏红。原因BGR 三个通道的低频分布不完全一致分别压制后通道间的比例被改变。人眼对色度变化的容忍度低比例一偏就能看出来。解决转 YCrCb 或 LAB只处理亮度通道色度通道原样保留。如果确实需要处理色度噪声Cr、Cb 的滤波半径取 Y 通道的三分之一到二分之一并且优先用高斯而不是理想低通避免色度通道的振铃在视觉上被放大。5.5 振铃被当成噪声反复调 D0 无效现象强边缘两侧出现明暗相间的细纹调大 $D_0$ 纹路变细但不会消失调小 $D_0$ 纹路变宽更明显。原因这是理想低通或高阶巴特沃斯的吉布斯现象根源是频域的硬切不是参数问题。继续调 $D_0$ 只是在改变振铃的宽度不会消除它。解决换高斯低通或者把巴特沃斯阶数降到 2。如果必须用陡峭的截止可以在滤波前先对图像做一次轻微的高斯模糊把最锐的边削弱再进频域振铃的幅度会小很多。另一个办法是只对需要压制的频段做滤波其余频段保留原频谱用掩膜做局部混合而不是全图统一处理。6. 一次参数扫描把三种低通的截止频率做成可复现的对比表调参最怕凭感觉。$D_0$ 从 0.05 试到 0.2三种滤波器一共十二组肉眼一张张看很容易记混。我的习惯是先跑一遍网格用两个客观指标把结果排出来再挑两三张细看。清晰度用拉普拉斯方差衡量值越大边缘越锐对比度用输出标准差值越大层次越开。这两个指标都不完美但足够把“糊过头”和“没滤干净”这两种极端排掉。import itertools def sweep(gray, ratios(0.05, 0.08, 0.12, 0.2)): rows [] for kind, ratio in itertools.product( [ideal, gaussian, butterworth], ratios): out freq_lowpass(gray, kindkind, ratioratio) lap_var cv2.Laplacian(out, cv2.CV_64F).var() rows.append((kind, ratio, round(out.mean(), 2), round(out.std(), 2), round(lap_var, 2))) return rows跑完之后按lap_var排序通常能看到一条清晰的趋势ratio 越大拉普拉斯方差越高但高频噪声也回来得越多。高斯在同样的 ratio 下方差最低理想低通最高巴特沃斯居中。如果某张图在 ratio0.12 时方差突然跳变多半是振铃开始出现翻回去看那张图就能确认。kindratiomeanstdlap_vargaussian0.05199.842.118.3gaussian0.12200.155.646.7butterworth0.08199.551.261.4ideal0.08198.953.888.2这张表是合成条纹图上的典型量级真实图会不一样但相对关系稳定理想低通的拉普拉斯方差总是最高因为它把边缘过冲也算了进去高斯的均值最稳因为它没有振铃带来的局部极值。我在一个批次两百多张扫描件的项目里最后选的是高斯 ratio0.09 做去网纹再叠一次轻度 unsharp mask 把字口拉回来。这个组合不是理论最优是那张表加上几十张目视挑出来的。血泪经验是别在循环里反复读写磁盘freq_lowpass每次都会重新算radius_grid图多的时候把网格缓存下来能省一半时间。另外记得把中间结果存成 16 位 PNG 再做指标统计存成 JPEG 之后压缩噪声会污染拉普拉斯方差排序结果会失真。希望帮到你。本文还有配套的精品资源点击获取