做信号处理的人,几乎都绕不开离散傅里叶变换(DFT)。我第一次真正啃这个名字,并不是在数学课上,而是在一个嵌入式项目里要对振动传感器数据做频谱分析。当时手头只有一堆时序采样点,脑子里全是“频域、幅度谱、FFT”这些词,翻了一圈资料,发现能把公式一行一行对应到代码的讲解少之又少。后来自己动手,用Python写了一个最朴素的DFT循环,再拿它去核对numpy.fft.fft的结果,才算是把这个概念彻底打通了。这篇文章就从一个最简单的DFT程序开始,把公式拆开揉碎,讲清楚每一行代码在干什么、为什么这么写、参数怎么选、结果怎么看。适合刚接触频谱分析、需要自己实现或读懂FFT/DFT代码的开发者,也适合那些常年用现成库、但一到调参就翻车的工程师。
1. 内容整体设计与思路拆解
1.1 先搞清楚:DFT到底在算什么
离散傅里叶变换做的事情,一句话总结就是:把一段等间隔采样的时域信号,分解成一系列不同频率的正弦波分量。时域信号是“横轴是时间,纵轴是幅值”,DFT输出则是“横轴是频率,纵轴是该频率分量的幅值和相位”。
可以这样类比:假设你手里有一杯混合果汁,里面有苹果味、橙子味、柠檬味。DFT就是在干“用不同味道的试纸去蘸一下,测出每种味道各占多少比例”的活儿。每个频率点就像一种“味道试纸”,如果信号里真的含有这个频率的分量,计算出来的幅值就会很大;如果完全不相关,结果就会接近0。
数学上最常用的定义是:
[ X[k] = \sum_{n=0}^{N-1} x[n] \cdot e^{-j 2\pi k n / N} ]
其中:
- N:采样点的数量,也就是时域序列的长度。
- x[n]:第n个采样点的值。
- k:频点索引,范围从0到N-1,对应不同的频率。
- X[k]:第k个频点的复数结果,它的模表示幅值,幅角表示相位。
这段公式初看吓人,但翻译成人话就是:把原始序列x[n]与一个频率为k的正弦波(其实是复指数)逐点相乘,再累加。如果x[n]里恰好有和这个频率“合拍”的成分,乘出来的结果会呈周期性同向叠加,最终累加值很大;如果没有这种成分,正负交错,累加值趋近于0。
理解了这一点,后面看任何DFT代码都会轻松很多。
1.2 为什么从代码解析入手
很多教材一上来就大讲傅里叶级数、连续傅里叶变换、离散时间傅里叶变换,结果读者连“离散”在哪里都没搞清。我的建议是:先用最直接的循环实现把公式落在代码里,再谈优化。
直接按公式实现DFT,复杂度是O(N^2)。N=1024时,需要约100万次复数乘加;N=100000时,就是100亿次。显然不能用于实时系统,工程里普遍使用的是快速傅里叶变换(FFT),也就是Cooley-Tukey那套分治思路,能把复杂度降到O(N log N)。
但问题在于:FFT的代码经过层层优化,旋转因子、蝶形运算、位反转这些概念一股脑堆上来,初学者很容易看懵。所以我强烈建议,第一步先写一个“笨版本”的直接DFT,哪怕性能很差也没关系,因为它的代码和数学公式一一对应,容错率极高。第二步再用numpy.fft.fft验证结果,确认自己的理解没有偏差。第三步再去看FFT源码,研究它是如何在数学上做减法的。
这篇文章的实操部分,就是沿着这条路径展开的。
2. 核心细节解析与实操要点
2.1 输入输出与参数约定
写DFT代码前,必须先弄清输入和输出各代表什么,否则后面调参全是猜。
输入端,通常有三个信息:
- x:长度为N的时域采样序列。
- Fs:采样率,单位Hz,表示每秒采样多少个点。
- N:参与变换的点数,也就是x的长度。
输出端,DFT的结果是N个复数。第k个点对应的物理频率为:
[ f_k = \frac{k \cdot Fs}{N} ]
这是整篇文章最容易被忽视的公式。很多人拿结果画频谱图,横轴直接写0, 1, 2, 3,却忘了乘上Fs/N,导致频率轴完全错位。
频率范围从0一直到Fs,但真正独立有效的只有前N/2+1个点。因为对于实数信号,DFT结果具有共轭对称性,后半段频谱是前半段关于奈奎斯特频率(Fs/2)的镜像。也就是说,如果采样率是1000 Hz,实际能分析的最高频率是500 Hz,对应索引N/2。
这里有个直观理解:采样率决定了你“看得出来”的最高频率,这个上限就是奈奎斯特频率。如果信号里有超过Fs/2的频率成分,它会发生混叠,折返到低频区域,造成虚假的频谱峰。所以采样之前加抗混叠滤波器,是工程里的常规操作。
2.2 公式到代码的一一对应
直接DFT的核心就是双重循环:
import cmath def dft(x): N = len(x) X = [] for k in range(N): sum_val = 0j for n in range(N): angle = -2 * cmath.pi * k * n / N sum_val += x[n] * cmath.exp(1j * angle) X.append(sum_val) return X拆开看每一部分:
- 外层循环k对应频点索引,也就是“试纸”的频率。
- 内层循环n遍历每一个采样点,对应时域序列的下标。
cmath.exp(1j * angle)就是公式里的e^{-j2πkn/N},1j在Python里表示虚数单位。- 复数乘法再累加,最终结果X[k]是一个复数。
我当年在理解欧拉公式时,卡了很久。后来把复指数展开写:
[ e^{-j\theta} = \cos(\theta) - j\sin(\theta) ]
就明白了。所谓“与复指数相乘”,本质上是在和一对正交的三角函数做相关计算:实部看余弦相关性,虚部看正弦相关性。幅值由两者平方和开根得到,相位由反正切得到。
如果不想依赖cmath,也可以用实数组自己算cos和sin,效果完全一样。但工程上还是建议直接用复数库,语义清晰,也不容易写错。
2.3 幅值、相位和归一化
DFT得到的是复数,要画频谱图通常还需要转换成幅值谱和相位谱:
magnitude = abs(X[k]) phase = cmath.phase(X[k])这里有一个绝大多数新手都会踩的坑:直接对numpy.fft.fft的结果取绝对值,画出来的幅度谱数值是不对的。比如一个幅值为1V、频率50Hz的正弦波,做1024点FFT后,频谱图上50Hz对应的幅值并不是1,而是约512,也就是N/2。
原因在于DFT公式里没有除以N。所以正确的幅值归一化方法是:
- 单边谱:幅值 = |X[k]| / N * 2(k=0和k=N/2这两个特殊点不乘2)。
- 双边谱:幅值 = |X[k]| / N。
直流分量X[0]对应的是信号的平均值乘以N,归一化后就是所有采样点的平均值。
相位谱同理,因为atg2的输出范围是(-π, π],如果信号相位一直在小幅抖动,画出来会出现从π到-π的跳变。此时需要做相位解卷绕(unwrap),把跳变修正成连续曲线。这在分析滤波器相位特性时尤其重要。
3. 实操过程与核心环节实现
3.1 用一个示例信号验证手写DFT
纸上谈兵没有用,现在构造一个已知信号来验证代码。
假设有一个由两个正弦波叠加而成的信号:
- 50Hz,幅值1V
- 120Hz,幅值0.5V
采样率设为1000Hz,采样点数N=1000。生成信号的代码如下:
import numpy as np Fs = 1000 N = 1000 t = np.arange(N) / Fs x = 1.0 * np.sin(2 * np.pi * 50 * t) + 0.5 * np.sin(2 * np.pi * 120 * t)用前面手写的dft函数计算频谱:
X = dft(x) freqs = [k * Fs / N for k in range(N)] magnitudes = [abs(v) / N * 2 for v in X] # 单边谱归一化理论上,freqs数组里会在k=50和k=120附近出现两个明显的峰值,幅值分别接近1.0和0.5。如果你跑出来的结果峰值位置对、幅值也准,说明公式和代码的理解已经过关了。
不过N=1000时,手写双重循环大约需要100万次复数运算,Python跑起来还不至于卡顿,但到了N=10000就会明显变慢。这也是为什么工程实践中几乎都使用FFT的另一个现实原因。
3.2 用NumPy验证并连接工程用法
手写DFT只用来验证理解,实际项目里肯定用numpy.fft.fft。两边的结果可以直接比较,误差应该小到浮点精度范围内。
X_np = np.fft.fft(x) print(np.allclose(X, X_np)) # 应该输出 True接下来是工程中真正高频的代码片段:计算频率轴、画单边频谱、自动找出峰值频率。我一般会封装成这样:
def single_side_spectrum(x, Fs): N = len(x) X = np.fft.fft(x) freqs = np.fft.fftfreq(N, 1 / Fs) mag = np.abs(X) / N # 只保留正频率部分 half = N // 2 freqs = freqs[:half] mag = mag[:half] mag[1:] *= 2 # 除直流分量外,单边谱幅值乘2 return freqs, mag这里用到了np.fft.fftfreq(N, d=1/Fs),它会直接返回每个索引对应的物理频率,省去手动构造频率轴的麻烦。注意它的返回结果包含正负频率,顺序是0, Fs/N, ..., Fs/2, -Fs/2, ...,所以画单边谱时要切片取前N//2个点。
至于为什么单边谱要乘2,是因为负频率部分的能量在实数信号的情况下,正好和正频率部分对称。我们通常只关心正频率,所以要把镜像那一半的能量叠加到正频率上。直流分量和奈奎斯特频率分量是特例,不乘2。
3.3 窗函数:解决频谱泄漏的第一步
直接对截断后的数据做FFT,会带来一个头疼的问题:频谱泄漏。因为DFT假设输入是周期延拓的,而你手里的数据只是从连续信号里截出来的一段。截断导致的不连续性,会在频谱上表现为真实峰两侧出现“裙边”,甚至把小信号淹没。
我以前分析电机振动信号时,明明知道根转子故障频率附近有一个微弱峰,却总是看不出来,后来查清楚就是泄漏把峰糊掉了。
加窗是缓解泄漏的标准做法。最常用的是汉宁窗:
window = np.hanning(N) x_w = x * window freqs, mag = single_side_spectrum(x_w, Fs)加了窗之后,频谱主瓣变宽,但旁瓣大幅降低,弱信号更容易被分辨。注意,加窗会改变信号的总能量,所以如果要恢复真实幅值,需要按窗函数的相干增益修正。比如汉宁窗的相干增益是0.5,那么校正系数就是1/0.5=2。实际处理时,我会先对窗函数求平均,再做归一化。
实践中我的经验是:如果主要关心频率位置,不关心绝对幅值,可以直接加窗;如果关心幅值精度,需要在结果里除以窗函数的相干增益。还有一点要提醒:加窗对频率分辨率是有代价的,主瓣变宽意味着两个相邻频率可能更难区分,所以N不够大时,泄漏和分辨率的矛盾始终存在。
3.4 一个极简的频谱峰值提取函数
工程里经常要做“找出主要频率成分”这件事。下面这个函数是我常用的模板,简化掉了不少边界处理,但核心逻辑很清楚:
def find_peaks(freqs, mag, top_n=3, thresh_ratio=0.1): max_mag = mag.max() idx = np.argsort(mag)[::-1] peaks = [] for i in idx: if mag[i] < thresh_ratio * max_mag: continue freq = freqs[i] if peaks and abs(freq - peaks[-1][0]) < 5: continue # 去掉距离过近的重复峰 peaks.append((round(freq, 2), round(mag[i], 4))) if len(peaks) >= top_n: break return peaks这里有两个细节:一是用“小于主峰一定比例”来过滤噪声基底,二是通过“最小频率间隔”去重。因为FFT出来的峰值往往不止一个,贴得很近的谱线可能是同一个峰的旁瓣。
实际使用时,我会根据数据特点调这两个参数。比如功率谱里噪声比较大,thresh_ratio就调高一点;频率分辨率有限导致峰很宽,就去重间隔改大。这类阈值参数没有标准答案,只能靠对信号的先验理解去试。
4. 常见问题与排查技巧实录
4.1 频谱图看起来全是噪声,根本看不到峰
这是我最常被问到的问题之一。首先要区分的,是“信号本身噪声大”还是“处理流程不对”。最简单的排查办法:先对一个纯净正弦波做同样的FFT流程。如果纯净信号也看不出峰,那问题一定在处理流程里。
常见原因有:
| 可能原因 | 具体表现 | 排查方向 |
|---|---|---|
| 没去直流分量 | 0Hz处有一个巨大的峰,其他频率被压扁 | 先减去均值,再FFT |
| 幅值归一化不对 | 峰的位置对,但高度离谱 | 检查是否除以N,以及单边谱是否乘2 |
| 频率轴构造错 | 峰的横坐标和预期频率差一倍或偏移 | 检查freq = k * Fs / N;优先用fftfreq |
| 采样率填错 | 所有频率整体缩放或折叠 | 确认Fs与实际采样配置一致 |
| 信号截断引发泄漏 | 峰变宽,旁边出现很多小“裙边” | 加窗函数,如汉宁窗 |
特别注意第一项。很多传感器输出带有直流偏置,比如加速度计贴在不平整的表面,静态输出可能不是0。这个直流分量在FFT里就是X[0],它普遍很大,如果不处理,画图时纵轴会被压得看不到其他频率成分。一般做法是x = x - np.mean(x)。
4.2 频率轴偏了,峰的位置不对
曾经有个朋友拿FFT分析音频,明明放的是440Hz的A音,峰值却在430Hz。我一看代码,他是手动构造频率轴时用了np.linspace(0, Fs, N)。
问题出在linspace默认是包含端点的,而FFT的频率轴应该是不包含Fs这个点的。因为第k个频点的实际频率是k*Fs/N,k从0到N-1,所以频率轴是[0, Fs/N, 2*Fs/N, ..., (N-1)*Fs/N],到不了Fs。
如果非要用linspace,必须写np.linspace(0, Fs, N, endpoint=False)。但更推荐直接用np.fft.fftfreq(N, 1/Fs),它还会自动处理负频率排序,少想很多事。
还有一个容易忽略的点:如果采样率是1000Hz,信号本身是50Hz,但采样点数N不是50的整数倍,那么50Hz会落在两个频点之间,无论用哪种频率轴,峰的位置都不会精确到50Hz,而会在附近两个频点之间摊开。这种情况需要增加N来提高频率分辨率,或者对频谱做插值细化。
4.3 幅值始终对不上真实信号值
幅值问题比频率问题更隐蔽,因为它涉及单边谱和双边谱的差异、窗函数的幅值损失、以及FFT输出中的缩放约定。
我来列一个简易对照表,假设原始正弦波幅值为A:
| 处理方式 | 频谱图上对应值 |
|---|---|
| 直接取abs(fft(x)) | A * N / 2 |
| abs(fft(x)) / N,双边谱 | A / 2 |
| 单边谱归一化(正频乘2) | A |
| 加汉宁窗后直接归一化 | 大约0.5A(需补偿相干增益) |
| 加汉宁窗并按相干增益校正 | A |
我最近处理一个超声波信号时,就是因为没做窗函数增益补偿,幅值只有实际值的58%,找了好久原因。后来把所有环节拆开,一个模块一个模块验证,才发现是窗函数在作怪。
另外,如果输入信号不是单一正弦,而是随机振动,用幅值谱观察往往意义不大。这时候更适合用功率谱:
psd = np.abs(X)**2 / (Fs * N)得到的单位是信号振幅的平方除以频率,能更好地反映随机信号在各频段的能量分布。
4.4 手写DFT算得太慢,怎么办
手写双重循环在N=10000时,我本地跑一次要数秒,如果做实时分析完全不可用。现代工程中没人真的用直接DFT做在线处理,都用FFT。
从DFT到FFT的核心优化思想,是利用旋转因子e^{-j2πkn/N}的周期性和对称性,把大型DFT拆成多个小型DFT递归计算。比如N=8的DFT可以拆成两个N=4的DFT,再拆成四个N=2的DFT。这种分治策略让计算量从N^2量级降到N log N量级。
在Python里,直接用np.fft.fft就行,底层实现是经过高度优化的C代码,远比手写循环快。如果你面对的是嵌入式场景,不能上NumPy,则建议直接抄成熟的开源FFT实现,比如KissFFT、PocketFFT、CMSIS-DSP里的FFT函数,不要在性能敏感的代码里自己造轮子。
如果你只是想让教学用的代码计算快一点,可以用Python的numba装饰器加速。我实测用@numba.njit把双重循环编译一下,N=10000时速度能提升几十倍,用来学习验证完全够用。不过numba首次编译有额外开销,真实性能测试别把第一次调用时间算进去。
5. 从DFT到工程:一些容易忽略的展开话题
5.1 同名缩写:这里的DFT是离散傅里叶变换
有朋友一听“DFT”,会条件反射想起Tessent DFT、TestMax DFT、DFT Flow这些工具名词。有必要说明一下,那是数字芯片设计里的“可测试性设计”(Design for Test),和本文讨论的离散傅里叶变换(Discrete Fourier Transform)是两个完全不同的概念,只是缩写恰好相同。做信号处理的同行在技术交流时,遇到“DFT”最好多问一句对方指的是哪个领域,否则很容易闹出“你说芯片测试,我在算频谱”的鸡同鸭讲。
本篇内容全部围绕离散傅里叶变换展开。如果你是从芯片测试热词搜到这里的,那大概率走错门了,不过掌握一些信号处理基础对IC测试里的高速接口分析也有帮助,可以继续往下读。
5.2 实信号优化:rfft为什么省一半
很多信号处理初学者不知道,numpy.fft里还有一个rfft函数,专门针对实数信号做优化。因为实数信号DFT结果具有共轭对称性,负频率部分完全由正频率部分决定,所以np.fft.rfft只返回约N/2+1个有效的频点,计算量和存储量都省了一半。
X_half = np.fft.rfft(x, n=N) freqs_half = np.fft.rfftfreq(N, 1 / Fs)对于采集卡读出来的电压数据、麦克风信号、振动波形这类天然实信号,我习惯直接上rfft。它返回的结果和手动取fft的前半部分一致,使用起来也更不容易被负频率和对称性问题绕晕。
5.3 一个完整的实时频谱分析思路
最后分享一个把上述知识串起来的应用场景。假设你要做一个简易的振动监测小工具,每秒从采集卡拿到1024个采样点,需要实时显示主要频率成分。
整体流程大致是:
- 缓存每次采集的1024个点,可重叠50%以平滑显示。
- 减去均值去除直流偏置。
- 乘以汉宁窗。
- 调用
np.fft.rfft计算频谱。 - 对幅值做相干增益补偿,得到单边谱。
- 找出前几个峰值,绘制到界面上。
我在实际做这类工具时,还会额外保存一份近期频谱做滑动平均,让峰值显示更稳定。这里有个小技巧:如果相邻两次FFT的峰值频率在1~2个频点内抖动,不要直接跳变显示,做个一阶低通滤波,观感会好很多。这些都算是工程细节里最不起眼、但最能影响体验的部分。
5.4 调试DFT代码的经验顺序
根据我的经验,调试DFT相关代码时,不要一股脑地试参数。可以先从一条最干净的信号路径开始,逐步增加复杂度:
首先用标准正弦波验证代码逻辑,确认频率轴、幅值都正确;然后加入直流偏置,检查去直流流程是否生效;再加两个不同频率的正弦波,观察峰值分辨能力;再引入噪声,检查底噪水平;最后再做加窗、功率谱等内容。
这样做的好处是,每个环节出问题都能立即定位。我曾经有一次直接拿现场振动数据分析,频谱乱七八糟,查了半天才发现是采集卡的时钟配置错了,采样率写成了实际值的两倍。如果先用已知信号校准流程,这种基础错误一眼就能发现。
5.5 关于浮点误差的最后一提
DFT代码还有一类问题,就是浮点误差叠加。当N很大,比如上百万点时,直接累加可能会因为舍入误差导致低频部分出现微小漂移。解决方法是尽量使用双精度,不要用float32做大型FFT,除非你非常清楚精度需求。另一个容易被忽略的点是输入信号里如果有很大的直流分量,会在累加时占用大量浮点位数,导致小信号分量误差增大。先减均值再做FFT,不仅能改善画图效果,对数值稳定性也有帮助。这些坑,教科书里很少写,实际踩过才会懂。