简介:面向图像处理初学者与OpenCV实践者的频率域低通滤波练习包,聚焦高斯、理想与巴特沃斯三种滤波器的实现与对比。内容围绕离散傅里叶变换、频域滤波核构造、逆变换及结果归一化展开,可用于理解高频噪声抑制、图像平滑与边缘保持之间的差异,适合课程实验或自学练手。压缩包共8个文件,约86KB,包含cpp源码、dsp/dsw等工程配置及bmp测试图像,工程可直接载入VC环境复现实验。已有2155人学习,读者可据此对照三种滤波器的频域响应与输出效果,掌握在OpenCV中自定义滤波核、完成DFT到IDFT完整链路的思路,并借助测试图观察不同截止频率下的平滑表现。
1. 频率域滤波在 OpenCV 里到底解决什么问题:从一张周期条纹图说起
OpenCV 里的频率域滤波,说白了就是把图像从像素空间搬到频域,在频谱上按距离衰减或保留分量,再搬回来。高斯、理想、巴特沃斯三种低通/高通滤波器,是最常被拿来对比的一组。某图像处理 Demo 里有一张带周期条纹的纹理图,空间域均值滤波一上去,条纹淡了,边缘也糊了;换成频域低通或陷波,只切掉那几个亮点,纹理边缘还能保住。这就是频率域滤波适合的场景:周期噪声、规则纹理、需要按频率而不是按邻域做取舍的图像。读者如果是做缺陷检测、纹理分析、扫描件去网纹,或者只想把 OpenCV 的 dft 用明白,下面这套流程可以直接跑。参数不会一次就准,D0 和阶数得边看频谱边调。
2. 从空间到频域:DFT 填充、频谱中心化与三种滤波器传递函数
频率域滤波的骨架不复杂:图像做二维离散傅里叶变换,得到复数频谱;在频谱上乘一个滤波器 H;再做逆变换回到空域。真正让结果翻车的,往往不是公式,而是填充尺寸、中心化原点和 H 的构造顺序。OpenCV 的cv2.dft输出的是双通道复数,未中心化;很多人直接拿它和以左上角为原点的距离图相乘,最后图像错位还找不到原因。下面先把三个前置动作拆清楚,再给三种滤波器的可执行构造代码。
2.1 为什么先做 getOptimalDFTSize 填充:边界环绕与频谱泄漏
cv2.dft对尺寸不是任意值都高效,常见做法是先算cv2.getOptimalDFTSize(rows)和cv2.getOptimalDFTSize(cols),把图像补到这两个尺寸。填充不只是为了快,更重要的是抑制边界环绕。二维 DFT 默认把图像当成周期信号,左边缘和右边缘会被接在一起;如果原图边缘亮度差很大,频谱里会出现本不属于图像内容的十字亮线,低通滤波后边缘出现一圈伪影。用BORDER_REFLECT做镜像填充,能让边界过渡更自然,逆变换后裁掉填充区即可。
填充宽度和高度要分别记录。裁剪时用原始rows和cols,不要用填充后的尺寸。有人把填充后的图直接保存,看起来没问题,但后续做尺寸对齐或金字塔时全乱。填充还有一个小代价:D0 的单位是填充后图像的像素,不是原图像素。原图短边 300,填充后短边可能到 320,D0 设 20 和设 18 的实际截止圈比例不同。调参时先看中心化频谱上的白色亮圈,再决定 D0,别只盯数字。
2.2 fftshift 之后距离图怎么建:D(u,v) 的原点必须和频谱中心重合
cv2.dft出来的频谱,低频在左上角,高频分散在四角。为了直观地做圆形低通,一般用np.fft.fftshift把零频搬到中心。搬完之后,距离图的原点就必须在(rows//2, cols//2),否则你乘上去的 H 是一个偏心圆。常见翻车现象是:滤波后图像整体变暗、边缘出现单边亮带,或者高频图只剩一个角有内容。原因不是滤波器写错,而是 H 的中心和频谱中心差了几个像素。
我一般单独写一个distance_grid(rows, cols),用行列索引减中心,得到每个频点到中心的欧氏距离。注意rows和cols的顺序:OpenCV 图像是(height, width),频谱也是(rows, cols),别把cols传给rows。距离图用float32就够,后面和双通道复数相乘时保持类型一致。中心化频谱只用于显示和构造 H;逆变换前要用np.fft.ifftshift把零频搬回左上角,再交给cv2.idft。这一对 shift 必须成对出现,少一步就是错位。
2.3 理想、高斯、巴特沃斯低通的传递函数与选型对比
三种低通的差别在截止边缘的陡峭程度。理想低通是硬阈值:距离小于等于 D0 全保留,大于 D0 全砍掉。它的频域边缘是阶跃,空域对应 sinc 函数,所以振铃最明显。高斯低通的传递函数是exp(-D^2/(2*D0^2)),没有硬边缘,过渡平滑,振铃最小,但截止频率附近衰减慢,去周期噪声时可能不够干净。巴特沃斯低通是折中:1/(1+(D/D0)^(2n)),阶数 n 越大越接近理想,n 越小越接近高斯。实际调参时,n 取 2 到 4 最常见,再大就开始出现可见振铃。
下面这段代码把三种低通和高通都封装成同一套接口。make_filter返回低通 H,make_highpass用1-H得到高通。D0 的单位是像素,n 只对巴特沃斯有效。代码里没有做任何归一化,H 的值域保持在[0,1],后面直接和频谱相乘。
import cv2 import numpy as np def distance_grid(rows, cols): # 以频谱中心为原点,构造每个频点到中心的距离 crow, ccol = rows // 2, cols // 2 u = np.arange(rows, dtype=np.float32)[:, None] - crow v = np.arange(cols, dtype=np.float32)[None, :] - ccol return np.sqrt(u * u + v * v) def make_filter(shape, kind="gaussian", d0=30.0, n=2): # shape 是 (rows, cols),必须和 fftshift 后的频谱尺寸一致 rows, cols = shape d = distance_grid(rows, cols) if kind == "ideal": h = (d <= d0).astype(np.float32) elif kind == "gaussian": h = np.exp(-(d * d) / (2.0 * d0 * d0)).astype(np.float32) elif kind == "butterworth": h = 1.0 / (1.0 + (d / d0) ** (2 * n)) h = h.astype(np.float32) else: raise ValueError("kind must be ideal/gaussian/butterworth") return h def make_highpass(shape, kind="gaussian", d0=30.0, n=2): # 高通 = 1 - 低通,注意保持 float32 return (1.0 - make_filter(shape, kind, d0, n)).astype(np.float32)逻辑说明:distance_grid先把行列索引转成以中心为原点的坐标,再算欧氏距离;make_filter根据kind选择理想、高斯或巴特沃斯公式,返回和频谱同尺寸的二维 H;make_highpass用补集得到高通。参数说明:d0是截止频率半径,单位是填充后图像的像素,常用范围是短边的 1% 到 10%,去周期噪声时先看频谱亮点再定;n是巴特沃斯阶数,取 2 到 4 比较稳,n 越大边缘越陡、振铃越明显;shape必须传dft_shift.shape[:2],不要传原图尺寸。
选型上,我一般先用高斯低通看整体趋势,因为它不容易翻车;如果周期噪声去不干净,再换巴特沃斯 n=2 或 n=3;理想低通只在需要做对比实验或做二值化频谱掩膜时用,直接拿它做图像恢复,边缘亮圈很难避免。高通侧同理,高斯高通适合做边缘增强的预处理,理想高通适合做频谱分割的可视化,巴特沃斯高通在两者之间。
| 滤波器 | 传递函数 | 边缘陡峭度 | 振铃风险 | 常见用途 |
|---|---|---|---|---|
| 理想低通 | D <= D0为 1,否则 0 | 最陡 | 最高 | 频谱掩膜实验、对比基线 |
| 高斯低通 | exp(-D^2/(2D0^2)) | 最平缓 | 最低 | 去周期噪声、平滑纹理 |
| 巴特沃斯低通 | 1/(1+(D/D0)^(2n)) | 可调 | 中等 | 需要干净截止又不想振铃 |
| 理想高通 | 1 - 理想低通 | 最陡 | 最高 | 边缘可视化、频域分割 |
| 高斯高通 | 1 - 高斯低通 | 最平缓 | 最低 | 边缘增强预处理 |
| 巴特沃斯高通 | 1 - 巴特沃斯低通 | 可调 | 中等 | 纹理增强、缺陷边缘保留 |
提示:D0 不要设成 0 或负数;巴特沃斯公式里
d/d0会直接报错或产生 inf。D0 很小时,理想低通只剩中心几个点,逆变换后整张图会变成一片灰。
3. 用 OpenCV 写出理想、高斯、巴特沃斯滤波:最小可复现代码与参数表
这一章把流程串成可运行函数。输入一张灰度图,输出滤波后的 uint8 图像;内部完成归一化、填充、cv2.dft、fftshift、乘 H、ifftshift、cv2.idft、裁剪和截断。低通和高通共用一套代码,只改highpass参数。彩色图先转灰度,后面单独说彩色通道的处理边界。参数表放在代码后面,方便直接抄。
3.1 从灰度图到复数频谱:OpenCV dft 的最小调用链
cv2.dft的输入要求是单通道浮点图。常见做法是先把图像转成float32,如果像素值在 0 到 255 之间,就除以 255 落到[0,1]。这一步不是必须,但后面逆变换、裁剪、截断都在[0,1]域里做,参数更直观。然后调用cv2.dft(gray, flags=cv2.DFT_COMPLEX_OUTPUT),得到(rows, cols, 2)的双通道数组,通道 0 是实部,通道 1 是虚部。再用np.fft.fftshift把零频搬到中心,得到dft_shift。
这里有一个容易忽略的点:填充后的尺寸才是dft_shift的尺寸。make_filter的shape必须取dft_shift.shape[:2],不能取原图gray.shape。如果原图 300x400,填充后 320x400,滤波器却按 300x400 构造,相乘时广播会报错,或者更隐蔽地按错误中心对齐。频谱显示也用dft_shift,用cv2.magnitude取幅值,再log1p压缩动态范围,最后归一化到 0 到 255。看频谱时重点看中心亮点和四周的周期亮点,D0 就是围绕中心画圆。
3.2 滤波、逆变换与裁剪:DFT_SCALE、clip 和填充宽高怎么处理
滤波就是dft_shift * h[..., None]。h是二维,频谱是双通道,所以给h补一个通道维,让实部和虚部乘同一个系数。乘完之后先np.fft.ifftshift把零频搬回左上角,再cv2.idft。逆变换的 flags 我一般写cv2.DFT_SCALE | cv2.DFT_REAL_OUTPUT:DFT_SCALE让逆变换自带 1/(rows*cols) 的缩放,DFT_REAL_OUTPUT直接输出单通道实数,省得自己取实部。如果不用DFT_SCALE,就得手动除以像素总数,否则结果整体亮很多倍。
逆变换后得到的是填充尺寸的图,用原始rows和cols裁掉右下角的填充区。裁剪完做np.clip(out, 0, 1),再乘 255 转 uint8。不要先cv2.normalize到 0 到 255 再乘 255,那是重复缩放,结果会全白。也不要对已经 clip 的图再做一次全局归一化,否则低通后本来偏暗的区域会被拉亮,和高通结果没法比。填充方式用BORDER_REFLECT,比默认常量填充更少引入边界亮线;如果原图边缘本身很干净,BORDER_REPLICATE也能用。
3.3 低通与高通共用一套代码:H 与 1-H 的切换及参数表
下面代码把前面的make_filter接进来。freq_filter里先判断通道数,彩色图转灰度;再归一化、填充、dft、shift、构造 H、乘、ifftshift、idft、裁剪、截断。highpass=True时把 H 换成1-H。调用部分用短边 3% 估一个 D0,分别跑三种滤波器,保存低通、高通和中心化频谱。频谱保存那一段没有填充,只用于看中心位置,不影响滤波结果。
def freq_filter(image, kind="gaussian", d0=30.0, n=2, highpass=False, pad=True): # 彩色图先转灰度;灰度图直接进入浮点流程 if image.ndim == 3: gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) else: gray = image gray = gray.astype(np.float32) if gray.max() > 1.0: gray /= 255.0 rows, cols = gray.shape if pad: opt_rows = cv2.getOptimalDFTSize(rows) opt_cols = cv2.getOptimalDFTSize(cols) gray = cv2.copyMakeBorder( gray, 0, opt_rows - rows, 0, opt_cols - cols, cv2.BORDER_REFLECT ) else: opt_rows, opt_cols = rows, cols dft = cv2.dft(gray, flags=cv2.DFT_COMPLEX_OUTPUT) dft_shift = np.fft.fftshift(dft) h = make_filter(dft_shift.shape[:2], kind=kind, d0=d0, n=n) if highpass: h = 1.0 - h filtered_shift = dft_shift * h[..., None] idft_shift = np.fft.ifftshift(filtered_shift) out = cv2.idft(idft_shift, flags=cv2.DFT_SCALE | cv2.DFT_REAL_OUTPUT) out = out[:rows, :cols] out = np.clip(out, 0.0, 1.0) return (out * 255.0).astype(np.uint8) def spectrum_u8(dft_shift): # 用于显示中心化频谱,不参与滤波 mag = cv2.magnitude(dft_shift[:, :, 0], dft_shift[:, :, 1]) mag = np.log1p(mag) mag = cv2.normalize(mag, None, 0, 255, cv2.NORM_MINMAX) return mag.astype(np.uint8) img = cv2.imread("texture.png", cv2.IMREAD_GRAYSCALE) h, w = img.shape d0 = max(8, int(min(h, w) * 0.03)) for kind in ("ideal", "gaussian", "butterworth"): low = freq_filter(img, kind=kind, d0=d0, n=2, highpass=False) high = freq_filter(img, kind=kind, d0=d0, n=2, highpass=True) cv2.imwrite(f"{kind}_low.png", low) cv2.imwrite(f"{kind}_high.png", high) # 单独存一张中心化频谱,方便看截止圈位置 dft_raw = cv2.dft(img.astype(np.float32) / 255.0, flags=cv2.DFT_COMPLEX_OUTPUT) cv2.imwrite("spectrum.png", spectrum_u8(np.fft.fftshift(dft_raw)))逻辑说明:freq_filter把图像统一到[0,1]浮点,按最优尺寸镜像填充,做正变换和中心化;make_filter生成低通 H,highpass为真时用1-H变高通;乘完把零频搬回左上角,用带DFT_SCALE的逆变换回到空域;最后按原始宽高裁剪、截断、转 uint8。spectrum_u8只做显示,log1p是为了压住中心低频的超大幅值,否则四周细节全黑。
参数说明:kind取ideal、gaussian、butterworth;d0是截止半径,单位是填充后像素,示例用短边 3%,去周期噪声时可以看频谱亮点到中心的距离再微调;n只对巴特沃斯生效,建议从 2 开始;highpass控制低通/高通;pad控制是否填充,做对比实验时可以关掉,但边缘伪影会更多。
| 参数 | 含义 | 常用范围 | 调参依据 |
|---|---|---|---|
kind | 滤波器类型 | ideal/gaussian/butterworth | 先高斯看趋势,再巴特沃斯收紧 |
d0 | 截止半径,单位像素 | 短边的 1% 到 10% | 中心亮圈到目标亮点的距离 |
n | 巴特沃斯阶数 | 2 到 4 | 越大越陡,振铃越明显 |
highpass | 低通/高通切换 | False/True | 去低频看高通,去周期噪声看低通 |
pad | 是否镜像填充 | True/False | 边缘亮线明显时保持 True |
彩色图不要直接三通道分别滤波后合并。三个通道的相位和能量变化不一致,合并后容易出现色偏,尤其是低通把某个通道的高频砍多了,边缘会出现彩色描边。常见做法是转 HSV 只处理 V 通道,或者转 Lab 只处理 L 通道,再转回 BGR。如果确实要在 BGR 上做,三个通道必须用同一个 H,并且保留原始相位,不要对每个通道单独调 D0。
4. 频率域滤波的避坑排查:振铃、频谱泄漏、填充与归一化翻车记录
频率域滤波的代码通常不长,但失败现象很集中:全黑、全白、边缘亮圈、图像错位、周期噪声去不干净、彩色图偏色。下面五条是血泪经验里出现频率最高的,每条按现象、原因、解决写。排查时先看中心化频谱,再看距离图,最后查归一化,顺序不要反。
4.1 五条翻车记录:全黑、亮圈、错位、去不净与彩色偏色
第一条:输出全黑或全白。现象是滤波后图像要么一片黑,要么一片白,频谱看起来正常。原因多半是缩放重复:先把图像除以 255 落到[0,1],逆变换用了DFT_SCALE,最后又做了一次cv2.normalize到 0 到 255,再乘 255;或者反过来,没用DFT_SCALE,也没手动除rows*cols,结果整体放大几万倍,clip 后全白。解决是统一量纲:要么全程[0,1]浮点,逆变换带DFT_SCALE,最后只乘一次 255;要么全程 0 到 255 浮点,逆变换后手动除像素总数。不要两种混用。
第二条:边缘出现亮圈或振铃。现象是物体边缘附近出现一圈亮暗相间的条纹,理想低通最明显,D0 越小越严重。原因是理想低通的频域传递函数是硬阶跃,空域对应 sinc 函数,旁瓣在边缘处振铃。解决分三步:优先换高斯低通,或者巴特沃斯 n=2 到 3;如果必须用理想低通,把 D0 稍微放大,让截止圈避开主要能量;填充方式用镜像反射,减少边界突变。振铃不是代码 bug,是硬截止的数学后果,换滤波器比调参数更有效。
第三条:滤波后图像错位或偏移。现象是低通后图像整体往一个方向偏,或者高通后边缘只出现在一侧。原因通常是fftshift和ifftshift没有配对,或者距离图原点还在左上角。cv2.dft输出未中心化,你做了fftshift后乘 H,逆变换前必须ifftshift;如果 H 是用左上角原点构造的,乘上去就是一个偏心圆。解决是检查distance_grid的中心是不是rows//2, cols//2,检查ifftshift是否在idft之前,检查shape是不是dft_shift.shape[:2]。
第四条:周期噪声去不干净。现象是低通后条纹变淡但没有消失,或者条纹消失了,纹理也被抹平。原因有三种:D0 选得比噪声亮点到中心的距离小,噪声还在通带内;D0 选得太大,把纹理高频一起砍了;原图边界突变导致频谱泄漏,噪声亮点被拉成十字。解决是先保存中心化频谱,找到噪声对应的亮点,D0 设成亮点到中心距离的 1.1 到 1.3 倍;如果亮点不在同心圆上,用陷波而不是低通;边界泄漏明显时加 Hann 窗或镜像填充。不要只凭空域结果反复试 D0,频谱图是更直接的黑匣子。
第五条:彩色图三通道分别滤波后偏色。现象是低通后边缘出现彩色描边,或者整体色调变冷变暖。原因是三个通道分别滤波,每个通道的相位变化和能量衰减不同,合并后不再满足原来的通道关系。解决是转 HSV 只处理 V,或转 Lab 只处理 L,保持色度通道不变;如果必须在 BGR 上做,三个通道共用同一个 H,并且不要对某个通道单独改 D0。彩色图的高频信息很多在亮度通道,只处理亮度通常比三通道分别处理更稳。
4.2 排查顺序:先看中心化频谱,再查距离图和归一化
遇到结果不对,我一般按固定顺序排查,避免在代码里乱改。第一步存中心化频谱,看零频是不是在中心,看目标噪声亮点在哪,看有没有异常的十字亮线。第二步把 H 单独显示出来,检查白色通带是不是以中心为圆心的圆,理想低通的边缘是不是太硬,巴特沃斯的 n 是不是过大。第三步检查dft_shift的尺寸和 H 的尺寸是否一致,填充后的rows和cols有没有在裁剪时用错。第四步检查归一化链:输入是不是[0,1],逆变换有没有DFT_SCALE,最后有没有重复乘 255。第五步才看空域结果,对比低通和高通的互补关系。
这个顺序能挡住大部分“看起来像算法错、实际是数据流错”的问题。频谱图是频域滤波的黑匣子,H 是第二个黑匣子,两个都看一眼,比盲调 D0 快得多。还有一个小习惯:每次保存结果时把kind、d0、n、highpass写进文件名或日志,后面复现时不用猜。频率域滤波的参数不多,但组合起来很容易记混,尤其是巴特沃斯的 n 和 D0 同时改的时候。
5. 把频域滤波接进批处理:用频谱能量比验收,别只凭肉眼
批处理时最怕每张图固定一个 D0。我一般把 D0 设成短边比例,再用高频能量比做粗验收。下面这个函数不依赖滤波结果,直接算图像高频能量占总能量的比例,滤波前后各跑一次,低通应该下降,高通应该上升。radius_ratio取 0.1 到 0.2 比较稳,太小会把噪声当高频,太大把纹理当低频。
def high_freq_ratio(gray, radius_ratio=0.15): # 用 FFT 幅值估算高频能量占比,仅做批处理粗筛 f = np.fft.fftshift(np.fft.fft2(gray.astype(np.float32))) mag = np.abs(f) h, w = gray.shape cy, cx = h // 2, w // 2 yy, xx = np.ogrid[:h, :w] d = np.sqrt((yy - cy) ** 2 + (xx - cx) ** 2) mask = d > radius_ratio * min(h, w) return float(mag[mask].sum() / (mag.sum() + 1e-8))逻辑说明:先做二维 FFT 并中心化,取幅值;以中心为原点构造距离掩膜,把大于radius_ratio * min(h,w)的部分视为高频;返回高频幅值总和除以总幅值。参数说明:radius_ratio控制高频边界,0.15 表示短边 15% 以外的频段;这个指标只用于批处理排序和异常筛查,不替代肉眼验收。把滤波前后的比值记进日志,下降幅度突然变小的图,多半是 D0 偏小或噪声不在通带外。
我现在的习惯是先把中心化频谱和截止圈画在一起,再决定 D0;不看频谱直接调参,翻车概率太高。希望帮到你。
本文还有配套的精品资源,点击获取