简介:基于 OpenCV 的频率域滤波工程资源,覆盖高斯、理想、巴特沃斯三种低通滤波器的完整实现与应用,适合数字图像处理初学者及需要掌握傅里叶变换、频域滤波原理的开发者参考。压缩包内共 8 个文件,以 C++ 源码、BMP 测试图像和 Visual C++ 工程配置文件为主,其中 cpp 代码为核心滤波算法,dsp/dsw 用于工程构建,ncb/opt/plg 为辅助配置,整体大小仅 86KB,解压后可直接用 VC6 打开运行。已有 2152 人学习浏览,提供 lena 与 test 两幅测试图,代码从离散傅里叶变换出发,依次完成频域中心化、滤波器核构造、滤波运算、逆变换与归一化,并针对理想滤波器振铃、巴特沃斯过渡带、高斯平滑特性进行了实现对比。通过运行示例,可直观观察不同截止频率下三种低通滤波的平滑差异与边缘失真情况,结合源码注释能够快速迁移到图像去噪、频域增强等实际任务,是一份兼顾原理讲解与工程代码的实用参考。
1. 频率域滤波不是玄学:三个距离公式决定了三种低通效果
很多人写了半天空间域平滑,一转到 OpenCV 频率域滤波就卡壳,因为 dft、idft、mulSpectrums 这条链路并不长,真正难的是模板该按什么坐标摆。频率域滤波说白了就是把图像换到频域后乘一个低通模板再换回来:高斯、理想、巴特沃斯三种低通的差别,全部落在模板 H(u,v) 的取值方式上。这篇文章把 dft 前后的细节拆开,适合已经会调用高斯模糊、但不想停留在“调 sigma 靠猜”阶段的同学。理想低通看着最干净,实际最容易出振铃;巴特沃斯参数最多,反而能兼顾边缘和噪声,后面会专门记录这些差异。
2. 从图像到频谱:DFT 输出布局、中心化与 BGR 通道一次讲清
2.1 dft 的输出是复平面:实部、虚部为什么不能当普通图看
OpenCV 里做频率域滤波的第一步不是直接调 dft,而是先把图像类型改成浮点。CV_8U 的灰度范围 0~255 直接塞进 dft 也能跑,但每次乘法后数值精度不够,idft 回来边缘很容易出现一层灰蒙蒙的杂质。我一般先做一次 convertTo:
Mat gray; cvtColor(src, gray, COLOR_BGR2GRAY); gray.convertTo(grayF, CV_32F); // 频域计算必须浮点,CV_8U 直接算会丢精度 Mat complexImg; dft(grayF, complexImg, DFT_COMPLEX_OUTPUT); // 输出两通道:实部 + 虚部这段的逻辑是:dft 只能吃浮点复数,OpenCV 用双通道 Mat 表示一个复数平面。CV_32F是精度起点,DFT_COMPLEX_OUTPUT让 dft 返回一个CV_32FC2的结果,第一通道存实部,第二通道存虚部。如果不用这个 flag,OpenCV 会默认按复数输入处理,单通道输入时反而容易绕晕。
另一个容易被忽略的点:dft()对多通道数组是按通道独立变换的。也就是说,如果你把 BGR 三通道原图直接 dft,跑出来的不是“彩色图像的频域”,而是三个互不相干的灰度频域图。每通道单独滤波再合并,出来的色调偏移特别难看。所以彩色图像我一般先转到 YCrCb,只处理 Y 通道,后面第 4 章会专门说这个坑。
2.2 频谱中心化:显示是一回事,滤波坐标是另一回事
dft 的原始输出里,零频分量在左上角,高频分布在四周。这样看频谱图,中心不是最亮点,反而是一幅“四角对折”的图。想要中心亮,就得做一个象限交换,也就是把左上和右下互换、右上和左下互换:
Mat planes[2], mag; split(complexImg, planes); magnitude(planes[0], planes[1], mag); // 计算 sqrt(re^2 + im^2) mag += Scalar::all(1); // log 前 +1,避免 log(0) 溢出 log(mag, mag); normalize(mag, mag, 0, 1, NORM_MINMAX); fftShift(mag); // 把零频从角落搬到中心再显示fftShift是频率域处理的通用小函数,OpenCV 没有直接内置,但写法很固定。先按行、列切四个 ROI,再把对角区块交换:q0 和 q3 换,q1 和 q2 换。这个函数有一个前提假设:矩阵宽高最好都是偶数,否则中心点会落不到整数位置。所以很多教程在做 dft 之前会先把图 padding 成偶数尺寸,不只是给 getOptimalDFTSize 面子。
这里要强调一点:显示频谱时做 fftShift 只是为了看得清楚,滤波时也必须做同样的移位,否则你造的模板中心位置和频谱中心位置对不上,滤波结果会像贴歪了窗花。
2.3 为什么要扩边:循环卷积的绕卷和 idft 后的裁剪
频率域乘法对应的是循环卷积,不是普通卷积,图像左右边界会互相“绕”过来。直接对原尺寸做 dft 再乘模板,边缘会出现明显的鬼影和横纹。常见的处理是先扩边,滤波完再裁回原大小:
Mat padded; int dftRows = getOptimalDFTSize(src.rows * 2); int dftCols = getOptimalDFTSize(src.cols * 2); copyMakeBorder(grayF, padded, 0, dftRows - grayF.rows, 0, dftCols - grayF.cols, BORDER_CONSTANT, Scalar::all(0));我这里取了getOptimalDFTSize(src.rows * 2),而不是直接取原图尺寸。理由有两个:一是 OpenCV 对 2、3、5 的倍数尺寸运算最快,二是宽高为偶数时,fftShift 的四象限交换不会遇到中心像素归属问题。代价是矩阵变大,dft 耗时略微增加,但对一张普通图片来说差距可以忽略。
idft 回时空域后,左上角才是有效区域,剩下的全是补零区域。裁剪时直接用 Rect 取值,同时要把浮点结果 normalize 到 0~255 范围内再 convertTo CV_8U。这个流程固定下来后,后面三种低通滤波器其实都只改模板生成那一段。
3. 三种低通模板的实现:高斯、理想、巴特沃斯的公式和截止半径
3.1 模板生成的公共部分:先算每个像素到中心的距离
亮度无关,滤波器的核心是一个和频谱同尺寸的灰度模板。模板上每个点都表示“该频率分量要被放大多少倍”,值在 0~1 之间,0 表示完全干掉,1 表示原样保留。
生成模板前先要计算每个点离零频中心的距离 D。中心要放在(dftRows/2, dftCols/2),前提是你已经对频谱做过了 fftShift。这个距离就是滤波器公式里的自变量:
Mat_<float> H(dftRows, dftCols); int cy = dftRows / 2; int cx = dftCols / 2; for (int r = 0; r < dftRows; r++) { for (int c = 0; c < dftCols; c++) { float D = sqrtf((r - cy) * (r - cy) + (c - cx) * (c - cx)); // 三种滤波器都只在这里改 H(r,c) 的赋值 } }这里 D 的单位是像素。D0 也叫截止半径,单位同样是像素,它决定了“多远算低频、多远算高频”。
写完模板后要把它转成复数再和频谱相乘,OpenCV 里最规范的做法是mulSpectrums:
Mat filterComplex; Mat filterPlanes[] = {H, Mat::zeros(H.size(), CV_32F)}; merge(filterPlanes, 2, filterComplex); mulSpectrums(complexImg, filterComplex, complexImg, 0); idft(complexImg, resultImg, DFT_REAL_OUTPUT | DFT_SCALE);merge把单通道模板和全零虚部合成双通道复数。mulSpectrums按复数乘法规则逐点相乘,DFT_SCALE让 idft 自动除以dftRows * dftCols,否则数值会膨胀到没法看。
3.2 理想低通:阈值清晰,但边缘最容易振铃
理想低通的公式最简单:D 小于等于 D0 就保留,大于 D0 就干掉。
H(r, c) = (D <= D0) ? 1.0f : 0.0f;这个模板在截止半径处是不连续的阶跃,对应空域里一个无限延伸的 sinc 核。它带来的问题叫振铃:图像边缘处会出现一圈圈波纹,尤其文字、物体轮廓这类强边缘,滤波后像被水波冲刷过。课程设计里如果只是展示“频率域滤波有效果”,理想低通确实最直观;如果图片里有人脸、文字、建筑这类内容,我一般不建议把 D0 调得太小,否则细节全部变成波纹。
3.3 高斯低通:没有振铃,过渡带最温和
高斯低通把硬切变改成了平滑衰减:
H(r, c) = expf(-D * D / (2 * D0 * D0));高斯模板在截止处没有突变,空域对应的核也是高斯形状,不会出现负瓣,所以几乎不产生振铃。它的缺点是边缘保持能力一般,想压住噪声就得牺牲不少纹理细节。这个 D0 不是严格意义上的“半功率点”,它的含义更接近高斯函数的方差:D0 越大,过渡带越宽,保留的低频越多,图像越接近原图。对比下面三种滤波器的实际输出,高斯低通是最能让人“一眼觉得平滑但又能接受”的。
3.4 巴特沃斯低通:用阶数控制过渡带,和振铃做交易
巴特沃斯在 D0 处永远等于 0.5,这是它和高斯、理想的本质区别。调整阶数 n 可以改变过渡带的陡峭程度:
H(r, c) = 1.0f / (1.0f + powf(D / D0, 2 * n));n 越小,过渡带越宽,振铃越轻;n 越大,模板越接近理想低通,振铃也越明显。实践里常用 n=2,既保留了一定的细节,又不会让边缘出现太夸张的水波纹。如果需要硬朗的截止效果,可以给到 n=4,但此时就要接受轻微振铃。
三种滤波器参数对比如下:
| 滤波器 | 参数 | D0 位置的含义 | 主要缺陷 |
|---|---|---|---|
| 理想低通 | D0 | 硬截止阈值 | 振铃明显 |
| 高斯低通 | D0 | 高斯方差相关参数 | 过渡带宽,边缘略钝 |
| 巴特沃斯低通 | D0, n | 固定半功率点 0.5 | 高速过渡时仍有振铃 |
这三种模板共享同一套 dft、mulSpectrums、idft 流程,唯一要改的就是 H(r,c) 赋值那一行。把模板生成封装成一个函数,只传 D0 和 n,后面做高通、同态滤波都能复用。
4. 频率域滤波踩坑排查:相位、填充、缩放和黑白屏的 5 条记录
4.1 输出图像发灰、暗沉,滤波像没生效
现象是 idft 出来的图整体灰蒙蒙,高频噪声没去掉,反而整幅图对比度下降。原因十有八九是拿幅度谱去乘了滤波模板,把相位信息丢了。dft 的结果是复平面,滤波必须保留实部和虚部两者,只对 magnitude 做乘法再 idft,相位被抹掉,图像自然就“灰掉”了。解决方法是把模板 merge 成双通道,用 mulSpectrums 直接乘 complexImg,不要在 split 出来的实部、虚部上做单通道操作。
4.2 彩色图做完滤波,边缘出现彩色伪影
现象是 RGB 图三个通道分别做 dft 再合并,输出里的物体边缘变成红绿蓝相间的杂色。原因不是代码写错,而是三个通道独立做频域滤波时,各自边缘的过渡不完全一致,叠出来的彩色边就被放大了。解决方法是把 BGR 转成 YCrCb,split 后只对 Y 通道做频率域滤波,Cr 和 Cb 原样保留,最后 merge 再转回 BGR。这样颜色信息没进频域,自然不会有通道互相打架的问题。
4.3 滤波器模板做了 fftShift,输出却像四象限被反着拼起来
现象是滤波结果里图像被拆成了四块,位置错乱。原因是你在 idft 之前忘了把频谱移回原始布局。dft 的输出约定是零频在左上角,你为了显示做了象限交换,乘模板时也在交换后的坐标系里工作,但 idft 不关心你换了没有,它只会按“零频在左上角”的约定反变换。解决方法是做完 mulSpectrums 后,对 complexImg 再执行一次同样的 fftShift,让频谱回到 OpenCV 默认布局,然后再调 idft。
4.4 边缘有横竖条纹,越接近边界越明显
现象是滤波后图像四周出现类似水印的明暗条纹,中央区域正常。这个不是滤波器设计问题,而是没扩边导致的循环卷积绕卷。dft 的乘法本质是循环卷积,图像左边会绕到右边、上边绕到下边。解决方法是滤波前用 getOptimalDFTSize 扩边,滤波后裁回原图大小。扩边后再滤波,边缘效果会自然很多,代价只是多算一点像素。
4.5 idft 后图像一半黑一半白,整体没法看
现象是 imshow 出来的结果不是经典灰阶图像,而是一团刺眼的白块。原因是 idft 输出的浮点值范围远超出 0~255,如果该值是负数,imshow 按浮点图解释时直接截断;值超过 100 的又直接变白。解决办法是先对裁剪后的 result 做 normalize 到 0~255,再 convertTo CV_8U。这一步放在裁剪之后、显示之前,写在函数里就不会每次手动调。
5. 高通、高频强调与同态滤波:离开低通之后,模板还能怎么用
5.1 高通滤波器:直接把低通模板翻转后,直流会被清零
高通模板就是1 - 低通模板。低频被压制,高频被保留,边缘和噪声都会被放大。难点不在生成模板,而在直流分量:高通模板把中心点变成 0,图像均值也会跟着变成 0,idft 回来整幅图灰成一片,像蒙了一层雾。
Mat Hhp; Hhp = 1.0f - Hlow; // Hlow 是前面生成的低通模板 Hhp.at<float>(cy, cx) = 0; // 中心明确置零,即使浮点误差也要清掉处理这种灰雾,常见做法不是直接显示高通结果,而是做高频强调:保留一部分低频做基底,把高频叠加回去。
5.2 高频强调:a 管亮度,b 管锐利度
高频强调模板写成H = a + b * Hhp。当 a 取 0.5~1.5 时,它给图像留了一个底亮度;b 取 0.5~2 控制高频放大量。a 太大会让锐化效果被低频盖住,b 太大则噪声也一起被放大。
Mat Hfe = 1.0f + 0.5f * Hhp; // a=1, b=0.5,轻度锐化这个模板和空间域里的 unsharp mask 效果类似,区别是频率域里能精确控制哪些频段被放大。适合用在低通滤波后觉得画面太软、想补回部分细节的场景。
5.3 同态滤波:log 域分离光照,再用高通压掉不均匀亮度
同态滤波不是新 API,而是把前面手段串起来:先取 log,再做 dft,高通滤波,idft,最后 exp 回来。log 能把乘性的光照分量变成加性信号,高通滤掉低频光照,保留高频反射细节,适合光照不均的文档照片。
Mat logImg; log(grayF + Scalar::all(1), logImg); // 取对数,+1 防止 log(0) dft(logImg, complexImg, DFT_COMPLEX_OUTPUT); // 这里用 D0 偏小的高通模板,比如 D0 = 15~30 mulSpectrums(complexImg, filterComplex, complexImg, 0); idft(complexImg, resultImg, DFT_REAL_OUTPUT | DFT_SCALE); Mat out; exp(resultImg, out); // 反变换回到线性亮度域同态滤波的参数比低通更敏感,D0 放在 15~30 像素区间比较常用。如果 D0 太小,光照不匀消不掉;太大则整幅图变成一片灰白,像被过度提亮。这个流程跑通之后,你等于把频域工具箱里的低通、高通、同态全部联起来了。
6. 验证技巧:先打印模板的径向频率响应,再决定 D0 值
调 D0 最忌讳的是反复重新跑 whole pipeline。眼睛盯着输出图调参,很容易把“看起来顺眼”和“参数合理”混为一谈。我后来习惯在生成模板的循环里顺手统计一个一维径向响应,先确认模板本身符合预期。
以巴特沃斯为例,在生成 H 的同时统计每个整数半径上的平均透过率:
vector<float> radial(256, 0.f); vector<int> count(256, 0); for (int r = 0; r < dftRows; r++) { for (int c = 0; c < dftCols; c++) { float D = sqrtf((r - cy) * (r - cy) + (c - cx) * (c - cx)); int idx = min((int)(D + 0.5f), 255); radial[idx] += H(r, c); count[idx]++; } } for (int i = 0; i < 60; i++) { if (count[i] > 0) printf("%d -> %.3f\n", i, radial[i] / count[i]); }这样调参时可检查三个关键点:D0 位置的透过率是否为对应滤波器应有的值,理想低通应该是跃变点附近从 1 掉到 0,高斯低通在 D=D0 处约等于 0.607,巴特沃斯无论 n 是多少,在 D=D0 处都固定等于 0.5。看到这些数字对得上,模板坐标和公式才算是真用对了,而不是只在图上看着像。
补充一个对照实验:同一张图,分别跑频域高斯低通和空间域 GaussianBlur,选择匹配的 sigma 后,两者的平滑趋势应当基本一致,但边缘会有细微差别。这种差别来自频域乘法对循环边界的处理,以及空间域核的有限窗口截断。如果两者差距大到形状都对不上,那基本可以断定是象限交换或 idft 缩放出了问题,而不是滤波器选型问题。
从那以后,我每次换截止半径都会强制走一遍这个径向响应打印,确认 D0 位置数值落在预期区间,再回去看整图效果。它治好了我很多次“参数玄学”式的乱猜。希望这个检查习惯能帮你也少走几回弯路。
本文还有配套的精品资源,点击获取