你手里有一组振动传感器数据,采样率1000Hz,录了5秒,想看看设备有没有周期性故障特征。直接画时域图,只能看到一团抖动的波形;用DFT硬算5000个点的频谱,要2500万次复数乘法,跑起来让人想砸电脑。这就是FFT存在的理由。快速傅里叶变换(Fast Fourier Transform)不是一种新的变换,而是离散傅里叶变换(DFT)的高效算法,把复杂度从O(N²)砍到O(N log N)。对于5000点数据,运算量缩到原来的几千分之一,毫秒级出频谱结果。
这篇文章不是给你递一个现成的np.fft.fft完事,而是从原理到源码,把FFT的来龙去脉拆开讲清楚。我会手写递归版和迭代版两个Python实现,再做信号混合、加窗对比等实操案例,最后把采样率、点数、泄漏、幅值校正这些工程里真正要命的细节一次性说透。适合正在学信号处理的学生、做数据分析想补频谱知识的工程师,以及所有想知道FFT黑盒里到底发生什么的人。
1. 从DFT到FFT:先搞清楚我们在加速什么
1.1 DFT的原始形态与复杂度困境
离散傅里叶变换的数学定义长这样:
[ X[k] = \sum_{n=0}^{N-1} x[n] \cdot e^{-j2\pi kn/N}, \quad k = 0,1,...,N-1 ]
翻译成人话:输入一个长度为N的离散时间序列x[n],输出一个同样长度的复数序列X[k],每个X[k]代表输入信号在频率 ( k \cdot f_s / N ) 处的幅值和相位。但这公式有一个致命问题——每计算一个X[k]都要遍历全部N个输入点,算完所有k就得做N²次复数乘法和加法。当N取1024时,大约是100万次运算,还能接受;当N取10万时,运算量上亿,纯Python写个三重循环能跑一天。
问题的根源在于公式里有大量重复计算。你仔细看旋转因子 ( e^{-j2\pi kn/N} ) ,当n和k以不同组合出现时,很多因子其实是同一个值。比如N=8时,( e^{-j2\pi \cdot 1 \cdot 3 / 8} ) 和 ( e^{-j2\pi \cdot 3 \cdot 1 / 8} ) 完全一样,但DFT笨办法会各算一遍。FFT的核心思想就是把这种重复利用起来。
1.2 分而治之:蝶形运算的由来
FFT最经典的实现是库里-图基算法(Cooley-Tukey),思路极其漂亮:把N点DFT按奇偶下标拆成两个N/2点DFT。
设:
[ even[m] = x[2m], \quad odd[m] = x[2m+1], \quad m = 0,1,...,N/2-1 ]
那么:
[ X[k] = E[k] + e^{-j2\pi k/N} \cdot O[k], \quad k = 0,1,...,N/2-1 ]
其中E[k]和O[k]分别是偶数序列和奇数序列的N/2点DFT。对于k后半段,利用旋转因子的对称性:
[ X[k + N/2] = E[k] - e^{-j2\pi k/N} \cdot O[k] ]
这两条公式合并起来就是著名的蝶形运算:一次复数乘法、一次加法、一次减法,把两个输入变成两个输出。整个过程像蝴蝶翅膀一样从中间展开,所以叫蝶形。
这个拆分可以递归进行。N点DFT拆成2个N/2点,每个N/2点再拆成2个N/4点,一直拆到N=1为止。N=1的DFT就是它本身,不用算。整个递归树有 ( \log_2 N ) 层,每层要做N/2次蝶形运算,总计算量降到 ( (N/2)\log_2 N ) 次复数乘法。对N=1024来说,从约100万次降到约5120次,这就是FFT能秒出频谱的根本原因。
1.3 为什么要求N是2的幂
上面的推导默认每次都能均匀分成两半,N必须是2的整数次幂才能一路拆到底不剩零头。实际用np.fft.fft时传任意长度也能跑,但numpy底层会自动补零到2的幂,或者用混合基算法处理非2幂长度。自己手写FFT时,我建议先保证输入长度是2的幂,否则要么加零填充,要么写朴素的DFT兜底,不然递归到奇数长度会直接出错。
注意:补零不是免费的。虽然补零能增加频域采样点数(也就是让频谱曲线更平滑),但它不增加真实的频率分辨率。频率分辨率只由实际采集时长决定:( \Delta f = f_s / T_{actual} )。
2. Python环境与工具选型:站在巨轮上动手造轮子
2.1 为什么最后还是得用numpy
有人可能会问:既然要手写源码,为什么还用numpy?这不是矛盾吗?
我的观点是:手写FFT是为了搞懂原理,用numpy是为了在实际工程中不翻车。numpy的FFT底层调用了经过高度优化的FFTPACK或 pocketfft,C语言实现、向量化处理、缓存友好,跑起来比纯Python快几十倍。你不可能在大规模数据分析项目里跑自己写的递归版FFT,那是教学玩具,不是生产力工具。
正确姿势是这样:
- 学习阶段:手写递归版和迭代版,对照
np.fft.fft验证正确性,理解到位。 - 工程阶段:直接用
np.fft或scipy.fft,但要理解每个参数的意义,比如n、axis、norm。 - 学术研究阶段:如果需要对FFT做定制(比如split-radix、Winograd傅里叶变换),再去读源码改算法。
numpy的fft模块主要提供这些函数:
| 函数 | 作用 | 典型场景 |
|---|---|---|
np.fft.fft(x, n) | 一维快速傅里叶变换 | 通用频谱分析 |
np.fft.ifft(X) | 逆变换 | 频域滤波后还原信号 |
np.fft.fftfreq(n, d) | 生成频率轴坐标 | 画频谱图时用 |
np.fft.rfft(x) | 实数输入的FFT,只算正频 | 实数信号省一半算力 |
np.fft.fftshift | 把零频挪到中心 | 观察双边谱时用 |
2.2 scipy的补充能力
在信号处理链路里,我们通常不会只用FFT,还得配合scipy.signal。举几个高频组合:
scipy.signal.windows:生成汉宁窗、汉明窗、布莱克曼窗等,解决频谱泄漏。scipy.signal.find_peaks:在频谱图上自动找峰,省得肉眼盯。scipy.signal.savgol_filter:频谱平滑,把毛刺去掉。scipy.signal.stft:短时傅里叶变换,看频率随时间的变化。
我自己处理振动数据时,标准流程就是scipy.signal加窗、np.fft.rfft算频谱、find_peaks找故障特征频率,一套组合拳下来,比单纯调plt.plot(fft_result)省心得多。
3. 手写FFT源码:从递归到迭代的完整实现
3.1 递归版实现:最直观的代码翻译
递归版FFT的代码极度贴合推导公式,几乎没有弯弯绕绕:
import numpy as np def fft_recursive(x): """ 递归实现快速傅里叶变换 输入:x 为复数数组,长度必须是2的幂 输出:对应频域的复数数组 """ n = len(x) # 递归出口:长度为1的DFT就是自身 if n == 1: return x # 按奇偶下标拆分 even = fft_recursive(x[0::2]) odd = fft_recursive(x[1::2]) # 旋转因子,只算前半部分的一半 factor = np.exp(-2j * np.pi * np.arange(n // 2) / n) # 蝶形组合 return np.concatenate([ even + factor * odd, even - factor * odd ])这段代码的每一行都能对应回第1节的公式。比较关键的是factor的计算:np.arange(n//2)生成0到n/2-1的整数,除以n得到归一化频率,乘以-2j * np.pi得到旋转因子的指数部分。这里算的是数量为n/2的复数,用来跟odd进行逐元素乘法。
跑个验证:
# 构造一个长度16的信号:50Hz正弦 + 120Hz正弦 + 噪声 fs = 1024 n = 16 t = np.arange(n) / fs x = 0.8 * np.sin(2 * np.pi * 50 * t) + 0.5 * np.sin(2 * np.pi * 120 * t) X_manual = fft_recursive(x) X_numpy = np.fft.fft(x) print(np.allclose(X_manual, X_numpy)) # 应该输出 True递归版的优点是逻辑透明、与数学推导一一对应。缺点是Python递归开销大、容易栈溢出,N大到几万点就有点心悸。另外每次递归都现算旋转因子,大量重复计算,性能一般。教学可以,工程不行。
3.2 迭代版实现:真正的工程级写法
迭代版(也叫就地算法)的核心思路是:先对输入做位逆序重排,再自底向上逐层合并蝶形。这避开了递归调用,用while循环控制层数,所有中间计算都在原数组上进行,不额外占用O(N)内存。
第一步,位逆序重排。所谓位逆序,就是把下标的二进制位倒过来。N=8时,下标3的二进制是011,倒过来变成110即6,那么原始数据的第3个位置就要移到输出第6位。为什么要这样?因为FFT拆分的递归过程等价于先从二进制最低位开始归类,最终上场顺序恰好是位逆序后的排列。先重排再逐层合并,就可以完全摆脱递归。
def bit_reverse_copy(x): """ 对输入数组进行位逆序重排 返回重排后的新数组,不修改原数据 """ n = len(x) # 当前点数对应的二进制位数 bits = int(np.log2(n)) result = np.zeros_like(x, dtype=complex) for i in range(n): # 把i的二进制位倒序得到新下标 rev = 0 for j in range(bits): if (i >> j) & 1: rev |= 1 << (bits - 1 - j) result[i] = x[rev] return result这段代码用了两个循环来翻转位序,虽然不够优雅,但胜在直观。追求性能的话可以用查表法或倍增法来加速,比如逐位翻转后循环左移:
等一下,实际上有一个更经典的原地位逆序算法,不申请新数组,直接交换原数组的不对称位:
def bit_reverse_inplace(x): n = len(x) bits = int(np.log2(n)) for i in range(n): # 计算i的位逆序 rev = 0 for j in range(bits): if (i >> j) & 1: rev |= 1 << (bits - 1 - j) if rev > i: # 只交换一次,避免来回换 x[i], x[rev] = x[rev], x[i] return x注意交换时只用rev > i判断,否则每个位置会被交换两次等于白换。
第二步,逐层蝶形合并:
def fft_iterative(x): """ 迭代实现快速傅里叶变换 输入:x 为复数数组,长度必须是2的幂 输出:就地修改并返回频域数组 """ n = len(x) x = x.astype(complex).copy() # 位逆序重排 bit_reverse_inplace(x) size = 2 # 当前蝶形宽度,从2点DFT开始 while size <= n: half = size // 2 # 当前层所有蝶形共享的旋转因子表 factor = np.exp(-2j * np.pi * np.arange(half) / size) for start in range(0, n, size): for k in range(half): # 蝶形核心:复乘一次,加减各一次 temp = factor[k] * x[start + k + half] x[start + k + half] = x[start + k] - temp x[start + k] = x[start + k] + temp size *= 2 return x这段代码按块遍历:外层的while循环控制层数,中间层的for start in range(0, n, size)遍历每一块,最内层遍历块内每对点。每一层的旋转因子只算一次,然后被同一层的所有蝶形共享,性能比递归版好得多。
验证:
x_random = np.random.randn(32) # 随机信号,长度32 X_iter = fft_iterative(x_random) X_np = np.fft.fft(x_random) print(np.max(np.abs(X_iter - X_np))) # 应该在1e-12左右3.3 递归版与迭代版的性能对比
我用N=4096的长度做了一次简单对比(模拟项目X,测了三次取均值):
| 实现 | 耗时(毫秒) | 内存占用 | 代码可读性 |
|---|---|---|---|
| 纯Python递归版 | 1450 | 每层申请新数组,总峰值约2N复数 | 极好,跟公式一一对应 |
| 纯Python迭代版 | 680 | 仅一个N长度复数数组 | 较好,需要理解位逆序 |
| numpy FFT | 0.12 | 底层优化,几乎可忽略 | 黑盒 |
结论很清楚:自己实现的迭代版比递归版快一倍左右,但跟numpy的FFT比仍是四位数差距。所以工程上永远用numpy,写源码的目的是弄清楚计算过程、理解边界条件,必要时能自己改出变体算法。
3.4 逆FFT怎么改
逆变换和正变换只有两个区别:旋转因子指数变正号,最后除以N。代码改动非常小:
def ifft_iterative(X): n = len(X) X = X.astype(complex).copy() bit_reverse_inplace(X) size = 2 while size <= n: half = size // 2 # 注意这里用正号 factor = np.exp(2j * np.pi * np.arange(half) / size) for start in range(0, n, size): for k in range(half): temp = factor[k] * X[start + k + half] X[start + k + half] = X[start + k] - temp X[start + k] = X[start + k] + temp size *= 2 return X / n原理上,IDFT本质是对频域转置的共轭对称性做一次正FFT再取共轭,但直接把蝶形因子改号更简单。用的时候可以用np.allclose(ifft_iterative(fft_iterative(x)), x)自检。
4. 完整实操案例:混合信号频谱分析
4.1 生成模拟信号
写一个模拟项目X里典型的场景:设备振动信号包含两个主频率成分加随机噪声。真实世界里信号基本都是正弦叠加噪声,很少是纯净的。
import numpy as np import matplotlib.pyplot as plt from scipy.signal import find_peaks from scipy.signal.windows import hann fs = 1024 # 采样率 1024 Hz N = 1024 # 采样点数,正好1秒 t = np.arange(N) / fs # 构造混合信号:50Hz主频、120Hz谐频、随机噪声 np.random.seed(42) x = (0.8 * np.sin(2 * np.pi * 50 * t) + 0.5 * np.sin(2 * np.pi * 120 * t) + 0.3 * np.random.randn(N))三个成分的幅值分别是0.8、0.5、0.3,对应频谱图上应该出现三条明显谱线。噪声是白噪声,理论上会铺平整个频带形成底噪。
4.2 加窗处理:为什么裸算FFT会泄漏
如果你直接对这些数据算FFT,频谱图上会在50Hz和120Hz附近出现"裙摆"——幅值向旁边频率扩散,峰形变宽,这就是频谱泄漏。根源在于FFT默认输入是周期的,但我们的N点信号截取了任意一段,首尾不连续,相当于在时域乘了一个矩形窗,而矩形窗的频域响应是sinc函数,旁瓣高、衰减慢,会把能量"漏"到相邻频点。
解决办法是给时域数据乘以一个平滑窗函数,让两端逐渐衰减到0。最常用的是汉宁窗:
window = hann(N) x_windowed = x * window加了窗后的频谱主瓣会变宽一点点,但旁瓣被大幅压下去,峰形更干净。代价是幅值会降低(因为窗函数整体缩放),后面要用幅值校正系数补回来。
4.3 计算频谱并做幅值校正
这一步是整个实操的核心,也是最容易出错的地方。我写个带详细注释的完整流程:
# 选择是否加窗 x_processed = x_windowed # 或直接用 x 做对比 # 进行FFT,输出长度N的复数数组 X = np.fft.rfft(x_processed) # 实数输入只管正频部分 # 获取频率轴 freqs = np.fft.fftfreq(N, d=1/fs)[:N//2 + 1] # 等价于 freqs = np.linspace(0, fs/2, N//2 + 1) # 幅值校正:除以N,再乘以2(单边谱),窗函数还要额外补偿 if window is not None: # 汉宁窗的恢复系数是2(也有用1.5的,取决于定义) amp = 2 * np.abs(X) / N * 2 else: amp = 2 * np.abs(X) / N # 忽略零频(直流分量),避免它掩盖低频细节 amp[0] = 0这个代码里最容易搞混的是2 * np.abs(X) / N中的每个因子。我来拆解:
np.abs(X)是复数模值,表示该频率分量的幅值大小(未经归一化)。除以N是因为DFT结果天然放大N倍,归一化后才是真实幅值。乘以2是因为rfft输出只保留正频部分,但实信号的能量均匀分在正负两个频率上,正频只取了一半,所以要加倍还原。- 窗函数恢复系数:加窗会改变信号总能量,汉宁窗等效系数是2,意思是加窗后算出的幅值要再乘2才能接近真实幅值。严格说这个系数跟窗类型有关,汉宁窗和汉明窗用2比较稳,矩形窗(不加窗)系数是1。
4.4 频谱峰自动识别
肉眼找峰不靠谱,尤其噪声大时。用find_peaks自动定位:
# 找峰:高度阈值设为最大幅值的1/10,并且要求峰间距不低于3个点 peaks, properties = find_peaks(amp, height=np.max(amp) * 0.1, distance=3) top_indices = peaks[np.argsort(amp[peaks])[-3:]][::-1] # 按幅值降序取前3 for idx in top_indices: print(f"频率: {freqs[idx]:.2f} Hz, 幅值: {amp[idx]:.3f}")在我的模拟数据上,应该输出类似下面的结果:
频率: 50.00 Hz, 幅值: 0.798 频率: 120.00 Hz, 幅值: 0.498与构造信号的真值0.8和0.5几乎一致,误差主要来自噪声和窗函数。这就完成了"从混合信号里提取频率成分"的完整闭环。
5. 关键细节:频率分辨率、补零与参数选择
5.1 频率分辨率的硬约束
很多人刚开始用FFT时提的第一个问题是:"怎么让频率分辨率更细?"答案是:增加采样时长,而不是增加FFT点数。
频率分辨率的公式是:
[ \Delta f = \frac{f_s}{N} ]
其中N是参与FFT的实际数据长度。如果采样率固定是1024Hz,N=1024点是1秒数据,分辨率为1Hz;想分辨0.5Hz的差异,必须采2秒数据(N=2048点)。如果你的数据本来就有2000个点、5千个点,那N就是实际个数,分辨率按实际时长算。
补零到8192点只会让频谱曲线更平滑(在频域做了插值),但相邻真实谱线的最小可分辨间距依然是 ( f_s / T_{actual} )。这就是所谓的"补零提高不了分辨率,只是画得更细腻"。我在实际项目里见过有人把N补到很大,频谱上看到一个"峰"就以为发现新成分,实际是被插值曲线骗了。
5.2 采样率与奈奎斯特频率
FFT输出频率范围是0到 ( f_s/2 ),这个上限叫奈奎斯特频率。高于它的频率会被混叠到低频区域,产生假峰。比如fs=1024时,信号里有800Hz的成分,它会被错误地映射到 ( 1024 - 800 = 224 )Hz的位置。
这就解释了一个工程硬规则:采样率必须大于信号最高频率的两倍,通常建议5到10倍。如果你怀疑振动信号可能含有200Hz的谐频,采样率至少设500Hz,保险起见最好1024Hz或更高。频谱图上如果看到最高频点附近出现意外能量,第一反应先怀疑混叠,加抗混叠滤波器或提高采样率再验证。
5.3 单边谱和双边谱的选择
np.fft.fft输出的是双边谱,包括正频率和负频率;np.fft.rfft只输出单边谱。对实数信号来说,双边谱的正负两部分关于零频对称,信息冗余,所以工程上几乎总是用np.fft.rfft。幅值归一化后,单边谱的非零频幅值要乘以2,零频(直流)不乘。
有一段新手必踩的坑:直接画np.abs(np.fft.fft(x)),x轴用np.arange(N)而不是频率值,结果横轴是"点数"而不是"Hz"。正确的频谱图横轴一定要用np.fft.fftfreq(N, d=1/fs)换算。我见过太多人把横轴标注成FFT索引,导致两张图对比时对不上频率,排查半天才发现是坐标轴问题。
5.4 窗函数选型对照
不同窗函数在泄漏抑制和主瓣宽度之间做权衡。工程里我常用的几类:
| 窗类型 | 旁瓣衰减 | 主瓣宽度 | 适用场景 |
|---|---|---|---|
| 矩形窗(不加窗) | 差,13dB | 最窄 | 整周期采样或瞬态分析 |
| 汉宁窗 | 较好,31dB | 中等 | 通用首选,应用面最广 |
| 汉明窗 | 较好,43dB | 略宽于汉宁 | 接近汉宁但主瓣尖端高一点 |
| 布莱克曼窗 | 优,58dB | 较宽 | 需要强抑制旁瓣时 |
| 平顶窗 | 一般 | 极宽 | 幅值测量精度优先时 |
如果信号是稳态周期信号,且采样时长是周期的整数倍,矩形窗(不加窗)恰好没有泄漏,这属于理想情况。但大部分实测数据做不做得到整周期采样,所以汉宁窗是最稳妥的默认选择。窗函数的补偿系数也要跟着变,这是被忽略最多的地方。
6. 常见问题与排查技巧实录
6.1 频谱出现明显底噪或毛刺
现象:频谱图上除了主峰,还有一层看不清的"雾状"能量。
排查顺序:
- 先检查时域数据是否有直流偏置。如果信号均值不为0,零频处会有一个大尖峰,而且它会影响附近频率的读数。处理办法是减掉均值再算FFT:
x = x - np.mean(x)。 - 检查是否加了窗。裸算FFT的泄漏会造成谱线周围一圈拖尾,用汉宁窗能压下去。
- 判断是否白噪声。随机噪声在整个频带均匀分布,这是物理限制,只能靠多次平均或提高信噪比改善。
我在某跨平台系统的振动数据分析里遇到过类似情况,最后发现是传感器零漂导致直流偏置,减均值解决后,低频段的读数干净了很多。
6.2 幅值对不上真值
现象:测出来50Hz正弦幅值0.8,但频谱图上显示0.79或0.81。
原因通常是三类:
- 采样时长不是信号周期的整数倍,幅值被低估(尤其是用矩形窗时)。
- 窗函数没有做幅值补偿,汉宁窗下读取的幅值偏低约一半。
- 频率分辨率不够精确,峰所在频点不在整数频点上,能量分散到旁边。
解决办法:
- 优先加汉宁窗并乘恢复系数2。
- 确保N足够大,分频率够细。
- 找峰时采用振幅校正——在峰值两侧用抛物线插值,能进一步逼近真实幅值。
def parabolic_peak(freqs, amp, peak_idx): """三点抛物线插值,获得更精确的峰值频率和幅值""" if peak_idx == 0 or peak_idx == len(amp) - 1: return freqs[peak_idx], amp[peak_idx] y0, y1, y2 = amp[peak_idx - 1], amp[peak_idx], amp[peak_idx + 1] x0, x1, x2 = freqs[peak_idx - 1], freqs[peak_idx], freqs[peak_idx + 1] denom = (x0 - x1) * (x0 - x2) * (x1 - x2) a = (x2 * (y0 - y1) + x1 * (y1 - y2) + x0 * (y2 - y0)) / denom b = (x2**2 * (y0 - y1) + x1**2 * (y1 - y2) + x0**2 * (y2 - y0)) / denom peak_x = -b / (2 * a) peak_y = y1 - 0.25 * (y0 - y2) * (peak_x - x1) / (x1 - x0) return peak_x, peak_y这个抛物线插值代码是我的标配工具,在峰值附近取三个点做二次曲线拟合,比直接取最高点更接近真实值。用了之后,幅值误差能从百分之几降到千分之几。
6.3 输出数据全NaN或爆掉
现象:FFT结果出现NaN或数据极大。
先检查输入x里有没有NaN或无穷值。FFT对NaN极其敏感,一个NaN会让整个结果全变成NaN。排查方法是np.isnan(x).any()和np.isfinite(x).all()。
其次检查是否输入了非数值类型,比如字符串、对象数组。Python列表里混入None或字符串时,np.fft.fft会抛异常或给出疯狂的结果。
最后看N是否是2的幂。手写递归版对非2幂长度会报错或递归卡死,迭代版则可能算出错误结果——因为位逆序逻辑依赖于int(np.log2(n)),N不是2的幂时这里就会取到错误位数,结果就是垃圾。所以自己写FFT之前,最好先断言一下:
assert n & (n - 1) == 0, "输入长度必须是2的幂"6.4 为什么频谱图上频率高端有条纹
现象:接近 ( f_s/2 ) 的频段出现规则条纹,像梳齿。
这通常不是FFT算法的问题,而是时域信号在接近奈奎斯特频率处有较大能量,或采样过程产生了混叠。此时应该怀疑:
- 原始信号是否含有高于 ( f_s/2 ) 的成分,需要加抗混叠滤波器或提高采样率。
- 数据采集是否有周期性丢点或抖动,导致频谱上出现固定间隔的寄生峰。
排查方法:把信号切成长度减半的两段,分别算FFT,看条纹是否跟随数据变化。如果两段频谱的条纹位置漂移,多半是数据里的非平稳成分;如果条纹固定不动,多半是采集系统有固定时钟干扰。
6.5 单边谱幅值乘2的争议
有读者可能在网上看到"单边谱要乘2,但直流和奈奎斯特频率不乘2"的说法。严格讲这是对的:零频(直流)只有一个分量,而奈奎斯特频率处如果信号恰好有能量,由于正负频率在此重合,也不能简单乘2。不过在工程实践中,我们通常只关心非零的正频成分,直接统一乘2,然后单独忽略直流,就足够满足绝大多数需求。如果想严谨处理奈奎斯特频点,np.fft.rfft返回的第N/2个点在乘2时要单独讨论,但那个点很少是关键关注对象。
7. 动手自检与踩坑心得
把整个流程跑通之后,我强烈建议你做一次完整的自检:
- 用你写的迭代版FFT对随机信号计算,再和
np.fft.fft对比,误差小于1e-10算通过。 - 对正弦信号做正变换再逆变换,恢复信号与原始信号误差小于1e-10算通过。
- 用合成信号验证幅值校正:构造幅值为0.8的50Hz正弦,加汉宁窗后读取峰值幅值,校正后接近0.8。
- 用合成双频信号验证分辨率:频率间隔小于 ( f_s/N ) 时,频谱上应只看到一个展宽峰;间隔大于该值时,应看到两个独立峰。
这些测试会暴露出不少细节问题。我第一次手写迭代版时,位逆序的int(np.log2(n))在N=1时会得到0,但循环体根本没执行,结果数组没被重排,直接返回了原数组,程序竟然能跑但结果是错的。后来加了边界条件判断才解决。这就是为什么不要只拿一个case验证——FFT实现必须用多个不同长度、不同信号类型的测试集检验。
另一个难忘的坑是在加窗补偿上。我用汉宁窗处理了一段真实的采集信号,算出峰值幅值后没乘2,结果所有幅值都比标定值低一半。那段时间排除了传感器、放大器、ADC的嫌疑,最后才发现是代码里amp = np.abs(X) / N少了窗恢复系数。所以现在我把幅值校正的步骤做成一个固定函数,每次调用强制传窗类型参数,不再裸写公式。
我个人在实际操作中的体会是:FFT的算法本身并不难,难的是"算出了FFT之后"那堆工程细节。频率分辨率、窗函数、幅值校正、混叠判断,任何一步不严谨,频谱图都会给出误导性结论。把这些细节吃透,比把蝶形公式背得滚瓜烂熟重要得多。
如果你后续想继续扩展,可以从三个方向入手:一是做实时FFT,用滑动窗处理连续数据流;二是做二维FFT,处理图像频谱;三是结合滤波器设计,在频域做自定义滤波后逆变换回时域。这几个方向都会反复用到本文的基础内容,尤其是加窗和幅值校正的思路,是一样的。