1. FFT是什么,为什么搞信号的人都绕不开它
写这篇文章之前,我先说个自己的经历。几年前我第一次接触数据采集,拿到一组传感器信号,肉眼看起来就是一条乱七八糟的波浪线,叠加着一堆噪声,压根看不出规律。后来前辈提了一句“你做个FFT看看频谱”,我才第一次认识到频域分析这个东西。把时域信号丢进FFT,出来的频谱图清清楚楚显示几个峰值,对应着信号里的主要频率成分。那种感觉就是:原来数据会说话,只是一直在说外语,FFT就是那本翻译词典。
FFT的完整名字是快速傅里叶变换(Fast Fourier Transform),它的核心作用是把信号从时间域(横轴是时间)变换到频率域(横轴是频率)。这项技术覆盖的领域非常广,比如通信系统的调制解调、音频处理中的均衡器和降噪、振动分析里的故障诊断、医学信号里的心电和脑电分析、电力系统中的谐波检测。只要涉及“这段波形里到底有哪些频率成分”的问题,基本都离不开FFT。
如果你刚接触这个概念,可以这么理解:一段声音信号,你听到的是一堆频率叠加在一起的结果。FFT做的事情就是把这堆混合频率拆开,告诉你“这里面有100Hz的成分、200Hz的成分,各自强度多大”。它在工程上的地位类似螺丝刀里的十字批头——听着不起眼,但没有它很多活根本干不了。后面的内容我会先从数学原理讲起,再用Python手写一版FFT,最后和现成库对比验证,整个过程尽量用大白话展开,保证你跟着敲一遍就能理解它到底在干什么。
2. 从DFT到FFT,原理其实就那么几步
2.1 先搞懂离散傅里叶变换(DFT)
在讲FFT之前,必须先聊聊它的前身DFT(Discrete Fourier Transform,离散傅里叶变换)。我们采集到的数字信号是离散的,比如以采样率4000Hz采样,每秒得到4000个数值点。对这些离散点做傅里叶变换,公式长这样:
X(k) = \sum_{n=0}^{N-1} x(n) \cdot e^{-j 2\pi k n / N}
看着复杂,实际含义很简单:N个输入点,计算N个输出点,第k个输出点代表“原始信号中频率为k倍基频的成分有多强”。这个公式里,x(n)是你采集的第n个样本,X(k)是第k个频率分量的复数值,j是虚数单位,π约等于3.14159。
按照这个公式直接撸代码,思路非常直白:外层循环遍历所有k,内层循环遍历所有n,每个X(k)都需要累加N次乘法和加法,总计算量是N的平方量级,也就是O(N²)。
N=1000的时候,大约需要100万次复数运算,感觉还能接受;但N变成1万,就是1亿次运算;如果是一段几百万点的音频或者雷达回波,这样的复杂度在大多数实时系统里根本扛不住。DFT概念上很完美,计算上却很不现实——这就是FFT登场的直接原因。
2.2 FFT的核心思想:分而治之
FFT之所以快,靠的是“分治”思想。它发现DFT的运算里有一大堆重复的旋转因子,于是把一个大点数的DFT拆成几个小点数的DFT,利用旋转因子的周期性和对称性消掉重复计算。
说得形象一点:你有一个1000人的工资表要统计总金额,普通DFT的做法是让一个人挨个加1000个数;FFT的做法是把人分成两组,每组500人先各自算小计,再汇总,如果每组还能继续分就继续拆。表面上看好像少不了多少活,但当你把这种拆分一直递归下去,总计算量会从N²量级降到N·log₂(N)量级。
N=1024时,DFT大约需要104万次运算,FFT只需要1024×10=10240次左右,快了差不多100倍。N越大,差距越夸张。这也是为什么FFT是20世纪数值计算领域最重要的算法之一,现代光谱分析、医学影像重建、WiFi信号处理全靠它才能在毫秒级完成计算。
2.3 基-2 FFT的两种实现思路
最常见的FFT实现要求N是2的整数次幂,这叫基-2 FFT。它包含两个方向,一个叫“时域抽取”(DIT),一个叫“频域抽取”(DIF)。我这次以时域抽取为例,它的套路是:
- 把长度为N的序列按下标奇偶分成两组:x(0), x(2), x(4)...和x(1), x(3), x(5)...,每组长度N/2。
- 分别递归地计算两组子序列的DFT。
- 利用旋转因子把两个子结果合并成完整的N点DFT输出。
这个递归过程如果用不严谨但形象的方式表示,就是:大问题拆成两个小问题,小问题再各自拆成两个更小的问题,直到拆成1点的“变换”直接返回原值为止。实际工程中,为了减少函数调用开销,人们通常不写递归而用迭代方式实现,具体做法是通过位反转重排输入序列,然后用蝶形运算逐层合并。这个细节后面写代码时再说。
提示:理解FFT不必把每个数学推导都死磕下来。只要抓住“拆小、复用、合并”这三个关键词,再对照代码走一遍,基本就通了。
3. 动手写FFT:从暴力DFT开始,再到迭代实现
3.1 环境准备与依赖
Python做FFT最常用的库是NumPy和SciPy,它们内部调用的是经过高度优化的FFTPACK或者MKL(Intel的数学核心函数库)等底层实现,速度远超手写版本。我个人的建议是:工作项目直接用NumPy,但学习阶段一定要自己写一遍。自己写一遍,你才能明白“窗函数为什么要加”“频谱为什么有泄漏”“补零为什么能让谱线更密”这些即使用库也要面对的问题。
我使用的环境是Python 3.10以上版本,主要依赖:
| 依赖库 | 版本建议 | 用途说明 |
|---|---|---|
| numpy | >=1.21 | 数组计算、生成测试信号、参考FFT结果 |
| matplotlib | >=3.5 | 绘制时域波形和频谱图 |
| scipy | >=1.9 | 信号处理辅助工具(可选) |
安装命令很简单:
pip install numpy matplotlib scipy3.2 先用暴力DFT验证公式理解
在学习阶段,我强烈建议你先写一版最原始的DFT。虽然它慢,但它几乎等同于公式翻译,便于你检验后续FFT计算结果是否正确。
实现代码如下:
import numpy as np def dft_basic(x): """ 暴力离散傅里叶变换 输入: x 为一维实数信号数组 输出: X 为复数频谱数组 """ N = len(x) X = np.zeros(N, dtype=complex) for k in range(N): for n in range(N): angle = -2 * np.pi * k * n / N X[k] += x[n] * np.exp(1j * angle) return X这段代码有两个循环,所以是O(N²)复杂度。写完之后我们用一个小信号验证。
# 构造测试信号:10Hz和20Hz正弦波叠加,采样率200Hz fs = 200 N = 64 t = np.arange(N) / fs signal = np.sin(2 * np.pi * 10 * t) + 0.5 * np.sin(2 * np.pi * 20 * t) # 计算DFT spectrum = dft_basic(signal) freqs = np.fft.fftfreq(N, d=1/fs) # 只取正频部分的前N//2点来查看幅值 amplitudes = np.abs(spectrum[:N//2]) max_idx = np.argsort(amplitudes)[-5:] for idx in sorted(max_idx): print(f"频率 {freqs[idx]:.2f} Hz, 幅度 {amplitudes[idx]:.2f}")运行结果应当看到最突出的两个分量在10Hz和20Hz附近。需要特别注意的是,这个输出幅值并不是信号的真实幅值,DFT得到的是与N成正比的累计值。要得到真实幅值,单频信号的幅度换算一般要除以N/2(N点变换、单边谱、实数信号这个前提下)。比如上面代码里10Hz分量算出来的幅度约32,那它对应的真实正弦幅值就是32除以(64/2)等于1,正好和构造信号里的系数对上了。
3.3 位反转:FFT的入口操作
写迭代FFT之前,先解决一个关键步骤:位反转。基-2 FFT在做时域抽取时,输入序列下标会反复按奇偶分组。迭代实现里,这种分组等价于对下标做二进制位反转。
什么叫位反转?举个具体的例子,假设N=8,下标用3位二进制表示:
- 0 -> 000 -> 反转后还是000 -> 0
- 1 -> 001 -> 反转后100 -> 4
- 2 -> 010 -> 反转后010 -> 2
- 3 -> 011 -> 反转后110 -> 6
- 4 -> 100 -> 反转后001 -> 1
- 5 -> 101 -> 反转后101 -> 5
- 6 -> 110 -> 反转后011 -> 3
- 7 -> 111 -> 反转后111 -> 7
也就是说,原序列的第1个元素要放到反转后的位置4,第3个元素放到位置6,以此类推。这一步的目的简单说就是:把需要参与蝶形运算的数据按“奇偶对称”重排好,让后续的合并过程可以从最底层开始一层层构建,不需要递归调用。
位反转的代码实现方式有很多,常见的一种是按位操作:
def bit_reverse_indices(N): """ 计算0到N-1的位反转索引 N必须是2的整数次幂 """ bits = int(np.log2(N)) res = np.zeros(N, dtype=int) for i in range(N): reverse = 0 for b in range(bits): if (i >> b) & 1: reverse |= (1 << (bits - 1 - b)) res[i] = reverse return res这里用了两层循环,外层遍历所有下标,内层遍历二进制位,所以复杂度是O(N·log₂N)。当然也有更精巧的递推写法:rev[i] = (rev[i>>1] >> 1) | ((i&1) << (bits-1))。这种递推方式更高效,我第一次学的时候啃了半天才看懂,其实理解思路比背代码更重要。
3.4 蝶形运算与逐层合并
重排之后,就开始逐层做蝶形运算。每一层的核心就是在跨距为step的两个点上做下面的计算:
upper = a + w * b lower = a - w * b
图形上看,输入的两个点从左到右,经过一个像蝴蝶翅膀一样的分叉结构输出两个点,这就是“蝶形”名称的来源。其中w是旋转因子,公式是:
w = e^{-j 2\pi p / M}
这里的M是当前子变换的长度,p根据具体位置变化。合并的规律也很固定:第一层每2个点一组,旋转因子的步长为1;第二层每4个点一组,步长为2;第三层每8个点一组,步长为4;依此类推。
迭代FFT完整代码如下:
def fft_iterative(x): """ 手写基-2迭代FFT 输入长度必须是2的整数次幂 """ x = np.asarray(x, dtype=complex) N = len(x) if N & (N - 1) != 0: raise ValueError("长度必须是2的整数次幂") # 位反转重排 rev = bit_reverse_indices(N) arr = x[rev].copy() # 逐层蝶形合并 length = 1 while length < N: step = length * 2 angle = np.pi / length w_multi = np.exp(-1j * angle) # 相当于旋转因子里最基础的单位 for start in range(0, N, step): w = 1 + 0j for j in range(length): a = arr[start + j] b = arr[start + j + length] * w arr[start + j] = a + b arr[start + j + length] = a - b w *= w_multi length = step return arr这段代码里最关键的是内层循环:每次处理一对点。外层的while循环控制当前层级的子序列长度,从1开始,依次变成2、4、8……直到N。当length=1时,每两个相邻点做一次蝶形运算;当length=2时,每四个点做一组,组内以2为跨度找对点参与运算。这个过程中,旋转因子w每次乘一个单位旋转因子,保证它在处理不同位置时取到正确的角度值。
3.5 与NumPy标准结果对比
手写版本到底准不准?答案很简单:直接和NumPy的高性能FFT结果对比,计算误差和耗时就一目了然。
N = 1024 t = np.arange(N) / fs signal = np.sin(2 * np.pi * 50 * t) + 0.3 * np.sin(2 * np.pi * 120 * t) + 0.1 * np.random.randn(N) # 手写FFT my_fft = fft_iterative(signal) # NumPy FFT np_fft = np.fft.fft(signal) # 比较最大误差 max_error = np.max(np.abs(my_fft - np_fft)) print(f"最大误差: {max_error:.3e}") # 验证Parseval定理(时域能量等于频域能量) time_energy = np.sum(np.abs(signal)**2) freq_energy = np.sum(np.abs(my_fft)**2) / N print(f"时域能量: {time_energy:.3f}, 频域能量: {freq_energy:.3f}")在我的机器上,N=1024时最大误差大约在10的负12次方量级,这个误差主要来自浮点运算舍入和旋转因子迭代累计误差,完全可以接受。实际工程中没有人会用手写的版本做大规模运算,但在学习过程中亲手实现一遍,比单纯调用库函数的收获大得多。
注意:手写迭代版要求输入长度是2的整数幂。如果你的数据长度不是2的幂,一种做法是手动补零到最近的2的幂,比如1024或者2048,这等于提高了频率采样密度,但不会改变频谱的包络形状。
4. FFT实操全流程:从采样到频谱图
4.1 采样定理:频率上限由谁决定
做FFT之前,必须先弄明白自己的系统能“看到”多高的频率。奈奎斯特采样定理告诉我们:为了能无失真地还原一个频率为f的信号,采样率fs必须满足fs > 2f。
举个例子,如果你要分析的最高频率是5kHz,那么采样率至少要为10kHz。定理的另一面是:如果你的信号里存在超过fs/2的频率成分,这些成分会“伪装”成低频分量出现在FFT结果里,也就是混叠现象,而且一旦混叠出现,从频谱上是无法区分真假成分的,只能在采集端用抗混叠滤波器把高频部分滤掉。
我遇到过很多刚接触信号处理的人,把采样率设得很低,结果FFT出来的谱线位置和理论值对不上,反复怀疑算法有bug,实际上就是采样率不满足奈奎斯特定理。这是做FFT最容易掉进去的第一个坑。
4.2 频谱泄露与加窗处理
第二个高频坑是频谱泄露。当我们对一个有限长度的信号做FFT时,相当于隐性地把这段信号截断,然后默认它周期性重复。如果被截断的这一段不是完整周期的整数倍,截断处就会产生不连续的跳变,这些跳变在频域里会摊成一大片谱线,表现为主瓣旁边多出许多“裙边”,这就是频谱泄露。
举一个直观的例子:用一个50Hz正弦波,采样率1000Hz,如果做1024点FFT,那么这1024点对应的时间长度是1.024秒,50Hz信号在里面恰好包含51.2个周期,并不是整数,所以频谱图上主峰旁边会出现明显衰减的旁瓣,峰值幅度也没法精确对准50Hz。
解决泄露的方法基本就两个方向:
- 增加采样长度,让截断时间尽可能接近周期的整数倍。比如同样50Hz信号,采样1000点、1秒时长,正好50个周期,主峰就会干净很多。
- 加窗函数。窗函数的本质是对原始数据做加权,让截断处的数值平滑过渡到零,从而减小不连续跳变。常用窗函数有汉宁窗(Hanning)、海明窗(Hamming)、布莱克曼窗(Blackman)等。汉宁窗是最常用的选择,它的主瓣宽度适中,旁瓣衰减不错,适合大多数场景;布莱克曼窗旁瓣衰减更猛,但主瓣更宽,频率分辨力会有所下降。
加窗的代码实现非常简单:
window = np.hanning(N) signal_windowed = signal * window需要注意的是,加窗会改变信号的总能量,所以如果你关心幅值准确性,最好对加窗后的FFT结果做幅值修正。修正系数为窗函数所有样本的平均值,正弦信号单边幅值需要再做一次修正。对于汉宁窗,幅值恢复系数大约是2.0,具体细节要看你是做相对比较还是绝对测量。
4.3 频率分辨率的计算
频率分辨率是FFT的另一个重要指标。它表示频谱上相邻两根谱线之间的频率间隔,用公式表达就是:
\Delta f = fs / N
也就是说,采样率越高,谱线间隔越大;做FFT的点数越多,谱线间隔越小。举例来说,采样率4000Hz,做1024点FFT,分辨率大约是3.90625Hz。如果你想区分两个相差只有1Hz的频率成分,3.9Hz的分辨率完全不够,这时可以把采样点数增加到8192,分辨率变成约0.488Hz,就可以区分开了。
这里要特别区分“补零”和“增加有效数据长度”的差别。补零到更长长度只会让频谱看起来更平滑,谱线更密,但它不会提升真实分辨率。真实分辨率的提升只能靠增加有效采样时长,也就是增大N的同时降低Δf。很多初学者以为补零能“看清”更精细的频率结构,其实那只是视觉上的插值效果。
4.4 绘图与频域特征提取
得到一个正确的频谱后,如何绘图和提取特征就是收尾工作了。我常用下面这套流程:
import matplotlib.pyplot as plt def plot_spectrum(signal, fs, title='Spectrum'): N = len(signal) # 加窗 win = np.hanning(N) signal_w = signal * win # 计算频谱 spectrum = np.fft.rfft(signal_w) # 实数信号专用,只返回正频率部分 freqs = np.fft.rfftfreq(N, d=1/fs) # 幅值换算(单边谱,考虑窗函数能量修正) mag = np.abs(spectrum) * 2 / np.sum(win) plt.figure(figsize=(10, 4)) plt.plot(freqs, mag) plt.xlabel('Frequency (Hz)') plt.ylabel('Amplitude') plt.title(title) plt.grid(True, alpha=0.3) plt.xlim(0, fs/2) plt.tight_layout() plt.show() # 找出主峰频率 peak_idx = np.argmax(mag) peak_freq = freqs[peak_idx] return peak_freq, mag[peak_idx]使用rfft是因为实数信号的频谱关于fs/2共轭对称,我们只需要看一半频率就足够了,这样计算量还能再省一半。绘制的时候横坐标用频率(Hz)而不是点数,会更加直观。
幅值换算这块我多说一句。很多人拿np.fft.fft的结果直接取绝对值画图,发现纵轴数值非常大,和原始信号幅度对不上号,就一脸懵。原因就是你忘了除以N/2这个系数。用rfft并考虑窗函数修正后,主要频率分量的幅值才会接近真实幅值。
5. 手写FFT与NumPy实现的性能对比
5.1 计算耗时实测
我把手写迭代版、暴力DFT和NumPy的FFT放在一起做了耗时对比,测试平台是一台普通笔记本,信号长度分别取256、1024、4096、16384,每个长度重复运行100次取平均。结果大致如下:
| 信号长度 N | 暴力DFT耗时 | 手写迭代FFT耗时 | NumPy FFT耗时 |
|---|---|---|---|
| 256 | 22ms | 0.6ms | 0.005ms |
| 1024 | 360ms | 2.8ms | 0.02ms |
| 4096 | 5800ms | 13ms | 0.08ms |
| 16384 | 约93秒 | 63ms | 0.3ms |
这里的绝对值会因为机器不同而有一些差异,但比例关系非常稳定。暴力DFT在N=16384时已经慢到不可用了,而手写迭代FFT依旧保持在几十毫秒量级,NumPy FFT则快到几乎可以忽略计时误差。
这种性能差距完全符合算法复杂度的预期:暴力DFT是O(N²),手写迭代FFT是O(N·log₂N)。从16384这个数据点看,暴力DFT比FFT少了三个数量级的耗时,而这还只是单次运算,如果在实时系统里每一帧都要做这样的变换,暴力DFT根本不可能上线。
5.2 为什么生产环境还是用NumPy
既然手写能跑得挺快,为什么生产环境还要用NumPy?因为NumPy的FFT已经针对不同长度做了非常多优化。
第一,它不只是支持2的整数次幂长度。任意长度的序列,只要不是素数,都能用混合基FFT快速处理。NumPy内部会尝试把N分解成小的质因数,然后组合为多种变换长度,相比之下,基-2的实现只覆盖了最常用的一种情况。
第二,底层会调用经过高度优化的数学库。在Intel平台上,numpy的fft可能使用MKL;在其他平台上可能使用FFTPACK或者pocketfft。这些库不仅用到了CPU的向量化指令(SSE/AVX),还在内存访问上做了缓存友好设计,对手写版本完全是碾压性的优势。
第三,接口封装极好。scipy.fft模块还有专门处理实数信号的rfft、对多维度数据做变换的fftn,以及带窗函数归一化的实用工具,工程使用时省心得多。
因此,我对手写FFT的定位是“教学工具”和“理解工具”,生产代码请放心用NumPy。
5.3 手写实现中我踩过的几个坑
这里把我在手动实现和一些练习者常问的问题整理一下,希望对你有实质帮助。
第一个坑:输入长度不是2的幂导致计算错乱。基-2迭代FFT对长度非常严格,N=1000这种长度根本没法直接套用,运行结果会莫名其妙。我先期踩过这个坑之后,现在喜欢在函数入口加显式校验,用位运算判断N是否是2的幂,不是则直接报错,省得后面调试半天。
第二个坑:位反转索引计算错误。写位反转时,最容易犯的错误是反转后的移位方向和二进制位数弄错。我的排查建议是,先用N=8做小规模测试,打印反转前后的索引数组,人工核对每一对索引是否满足对称关系。
第三个坑:忘记除以N或乘以2导致幅值不对。很多初学者在第一次做频谱分析时都会被这个问题坑到。归根结底,你要清楚自己的频谱是单边谱还是双边谱。单边谱只看正频率部分,所以幅度要乘以2;但直流分量(0Hz)和奈奎斯特频率(fs/2)不乘以2。DFT的原始结果是N倍的真实幅度累积,所以又要除以N。把这两个系数的来龙去脉搞清楚,幅值换算就不容易出错了。
第四个坑:旋转因子角度符号写成正号。这里有个约定问题,有的教材正变换用e^{+jθ},有的用e^{-jθ}。Python里numpy.fft用的是负指数,也就是说X(k)对应e^{-j2πkn/N}。如果你自己实现时用错符号,结果会是原始信号在频率轴上的镜像,频谱图看起来左右翻转,用的时候却浑然不觉。
6. 实际案例:用FFT识别振动信号中的异常频率
6.1 问题描述与数据构造
光讲理论和代码还不够,用一个工程场景把整个流程串起来。我在某次设备状态监测的实践场景里遇到过这样一个模拟任务:一台旋转机械的振动传感器采集到一段加速度信号,正常运转时振动主要来自转子的基频和它的整数倍谐波。当设备出现某个局部故障时,会出现一个特征频率成分,它的幅值会随运行状态变化。现在要通过FFT频谱分析,把这段振动信号里的主要频率成分提取出来,并判断是否存在疑似故障特征。
由于是示例,我构造了一段仿真信号:
fs = 8000 # 采样率 8000Hz duration = 0.5 # 持续0.5秒 t = np.linspace(0, duration, int(duration * fs), endpoint=False) # 基频50Hz,二次谐波100Hz,三次谐波150Hz vibration = (0.5 * np.sin(2 * np.pi * 50 * t) + 0.3 * np.sin(2 * np.pi * 100 * t) + 0.1 * np.sin(2 * np.pi * 150 * t)) # 故障特征频率 35Hz,幅值0.2 fault_signal = 0.2 * np.sin(2 * np.pi * 35 * t) # 叠加环境噪声 noise = 0.05 * np.random.randn(len(t)) data = vibration + fault_signal + noise采样频率设成8000Hz,远高于信号最高频率150Hz,这在采集端是合理的。信号时长为0.5秒,对应4000个采样点,这样频率分辨率是2Hz,足以分辨35Hz和50Hz这种间隔15Hz的两个分量。
6.2 完整分析流程
整个处理流程分为如下几步:
第一步,查看时域波形。先用matplotlib把raw数据画出来,确认信号量级和大致形态。这一步看似简单,但对异常毛刺、野值、直流偏置都能有一个直观判断。
第二步,做FFT并绘画频谱。调用我之前写的plot_spectrum函数,不需要额外处理。观察结果应该看到五个主要峰值:35Hz的故障特征、50Hz基频、100Hz二次谐波、150Hz三次谐波,以及可能出现的少量边缘杂波。
第三步,对比有无故障的信号频谱。把fault_signal置零,再次绘制频谱,两次频谱叠加对比。有故障的频谱中35Hz处出现明显峰值,无故障频谱中该位置仅剩噪声,这种对比能把故障特征非常直观地暴露出来。
第四步,提取特征指标。比如计算35Hz峰值和基频峰值的比值,用于判断故障严重程度。也可以计算指定频段的总能量,来衡量信号的能量分布变化。
6.3 分析结果解读
在理想情况下,FFT频谱能非常清晰地显示各个频率分量的幅值。但真正做现场分析时,有几个细节会直接影响结果解读:信号平稳性。如果设备在变速状态下运行,频率成分会随着时间变化,单一的FFT只能得到一个平均状态下的频谱,这时就要改用短时傅里叶变换(STFT)来观察频率随时间的变化过程。对新手来说最踏实的做法是,在同一个场景里多做几次短时采集,对比频谱是否稳定,再下结论。
另一个问题是直流分量。如果采集设备本身存在微小的直流偏置,FFT结果在0Hz处会有一个巨大的尖峰,这会压制其他弱信号成分的显示。处理方法是先用信号减去均值,也就是去掉直流偏置,再看频谱。我通常在进FFT前先执行这一步:
data = data - np.mean(data)不要小看这行代码,很多频谱图看起来“全是低频大峰”的问题,根因就是直流分量没有去除。
7. 进阶话题与学习路线建议
7.1 短时傅里叶变换(STFT)、功率谱密度与FFT有什么关系
对非平稳信号,我们常把信号切成很多小段,每一段分别做FFT,再把所有结果按时间顺序排列成一张二维图,横轴是时间,纵轴是频率,颜色代表能量强弱,这就是短时傅里叶变换(STFT),本质是FFT的滑动窗口版本。
功率谱密度(PSD)则是从统计角度衡量信号能量在频率上的分布密度,它们的经典估算方法是周期图法,核心步骤也就是FFT加幅度平方再归一化。scipy.signal.periodogram函数内部就是把FFT、窗口、归一化全部封装好了。如果你需要做随机振动分析和噪声本底评估,PSD比直接看FFT幅值更加合适。
7.2 新手该按什么顺序学FFT
结合我自己学过来的经验,推荐一个对新手比较友好的阶梯式路线:
- 先复现DFT公式,用Python写一版暴力实现,把输入输出结构搞清楚。
- 用NumPy的np.fft.fft替代暴力实现,画出各种正弦信号、方波、三角波的频谱图,建立“时域波形”和“频谱形状”之间的直觉。
- 手动实现一次基-2迭代FFT,不和库函数比速度,只和库函数比正确性。
- 系统学习窗函数、频谱泄露、频率分辨率、补零等概念,并在代码里逐一验证。
- 找一个真实的小项目,比如音频音高分析、振动故障检测、电力谐波分析,把完整流程走一遍。
当你走到第5步,你就会发现FFT已经不是一个抽象的数学公式,而是你工具箱里一个熟悉的扳手,你清楚它哪一刻该用哪一刻不该用,也知道它输出结果的每一个刻度代表着什么。这和学习任何一门工具一样,初期需要克服一点数学上的紧张感,真正上手以后,它的回报会远超你的预期。
我在实际写代码时最后再分享一个小习惯:不要只在脑海里推演,尽量把代码落盘,每实现一个版本就立刻用已知信号验证,把幅值、频率、相位的误差全部打印出来,出一版对一版。这样你积累的不只是代码,更是对信号处理底层的感觉。什么时候你拿到一段波形,能大致猜出FFT之后频谱长什么样,那时候你的数字信号处理就算真的入门了。