
做信号处理的人几乎都绕不开离散傅里叶变换DFT。我第一次真正啃这个名字并不是在数学课上而是在一个嵌入式项目里要对振动传感器数据做频谱分析。当时手头只有一堆时序采样点脑子里全是“频域、幅度谱、FFT”这些词翻了一圈资料发现能把公式一行一行对应到代码的讲解少之又少。后来自己动手用Python写了一个最朴素的DFT循环再拿它去核对numpy.fft.fft的结果才算是把这个概念彻底打通了。这篇文章就从一个最简单的DFT程序开始把公式拆开揉碎讲清楚每一行代码在干什么、为什么这么写、参数怎么选、结果怎么看。适合刚接触频谱分析、需要自己实现或读懂FFT/DFT代码的开发者也适合那些常年用现成库、但一到调参就翻车的工程师。1. 内容整体设计与思路拆解1.1 先搞清楚DFT到底在算什么离散傅里叶变换做的事情一句话总结就是把一段等间隔采样的时域信号分解成一系列不同频率的正弦波分量。时域信号是“横轴是时间纵轴是幅值”DFT输出则是“横轴是频率纵轴是该频率分量的幅值和相位”。可以这样类比假设你手里有一杯混合果汁里面有苹果味、橙子味、柠檬味。DFT就是在干“用不同味道的试纸去蘸一下测出每种味道各占多少比例”的活儿。每个频率点就像一种“味道试纸”如果信号里真的含有这个频率的分量计算出来的幅值就会很大如果完全不相关结果就会接近0。数学上最常用的定义是[ X[k] \sum_{n0}^{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)。N1024时需要约100万次复数乘加N100000时就是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/21个点。因为对于实数信号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 * 2k0和kN/2这两个特殊点不乘2。双边谱幅值 |X[k]| / N。直流分量X[0]对应的是信号的平均值乘以N归一化后就是所有采样点的平均值。相位谱同理因为atg2的输出范围是(-π, π]如果信号相位一直在小幅抖动画出来会出现从π到-π的跳变。此时需要做相位解卷绕unwrap把跳变修正成连续曲线。这在分析滤波器相位特性时尤其重要。3. 实操过程与核心环节实现3.1 用一个示例信号验证手写DFT纸上谈兵没有用现在构造一个已知信号来验证代码。假设有一个由两个正弦波叠加而成的信号50Hz幅值1V120Hz幅值0.5V采样率设为1000Hz采样点数N1000。生成信号的代码如下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数组里会在k50和k120附近出现两个明显的峰值幅值分别接近1.0和0.5。如果你跑出来的结果峰值位置对、幅值也准说明公式和代码的理解已经过关了。不过N1000时手写双重循环大约需要100万次复数运算Python跑起来还不至于卡顿但到了N10000就会明显变慢。这也是为什么工程实践中几乎都使用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, d1/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.52。实际处理时我会先对窗函数求平均再做归一化。实践中我的经验是如果主要关心频率位置不关心绝对幅值可以直接加窗如果关心幅值精度需要在结果里除以窗函数的相干增益。还有一点要提醒加窗对频率分辨率是有代价的主瓣变宽意味着两个相邻频率可能更难区分所以N不够大时泄漏和分辨率的矛盾始终存在。3.4 一个极简的频谱峰值提取函数工程里经常要做“找出主要频率成分”这件事。下面这个函数是我常用的模板简化掉了不少边界处理但核心逻辑很清楚def find_peaks(freqs, mag, top_n3, thresh_ratio0.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/Nk从0到N-1所以频率轴是[0, Fs/N, 2*Fs/N, ..., (N-1)*Fs/N]到不了Fs。如果非要用linspace必须写np.linspace(0, Fs, N, endpointFalse)。但更推荐直接用np.fft.fftfreq(N, 1/Fs)它还会自动处理负频率排序少想很多事。还有一个容易忽略的点如果采样率是1000Hz信号本身是50Hz但采样点数N不是50的整数倍那么50Hz会落在两个频点之间无论用哪种频率轴峰的位置都不会精确到50Hz而会在附近两个频点之间摊开。这种情况需要增加N来提高频率分辨率或者对频谱做插值细化。4.3 幅值始终对不上真实信号值幅值问题比频率问题更隐蔽因为它涉及单边谱和双边谱的差异、窗函数的幅值损失、以及FFT输出中的缩放约定。我来列一个简易对照表假设原始正弦波幅值为A处理方式频谱图上对应值直接取abs(fft(x))A * N / 2abs(fft(x)) / N双边谱A / 2单边谱归一化正频乘2A加汉宁窗后直接归一化大约0.5A需补偿相干增益加汉宁窗并按相干增益校正A我最近处理一个超声波信号时就是因为没做窗函数增益补偿幅值只有实际值的58%找了好久原因。后来把所有环节拆开一个模块一个模块验证才发现是窗函数在作怪。另外如果输入信号不是单一正弦而是随机振动用幅值谱观察往往意义不大。这时候更适合用功率谱psd np.abs(X)**2 / (Fs * N)得到的单位是信号振幅的平方除以频率能更好地反映随机信号在各频段的能量分布。4.4 手写DFT算得太慢怎么办手写双重循环在N10000时我本地跑一次要数秒如果做实时分析完全不可用。现代工程中没人真的用直接DFT做在线处理都用FFT。从DFT到FFT的核心优化思想是利用旋转因子e^{-j2πkn/N}的周期性和对称性把大型DFT拆成多个小型DFT递归计算。比如N8的DFT可以拆成两个N4的DFT再拆成四个N2的DFT。这种分治策略让计算量从N^2量级降到N log N量级。在Python里直接用np.fft.fft就行底层实现是经过高度优化的C代码远比手写循环快。如果你面对的是嵌入式场景不能上NumPy则建议直接抄成熟的开源FFT实现比如KissFFT、PocketFFT、CMSIS-DSP里的FFT函数不要在性能敏感的代码里自己造轮子。如果你只是想让教学用的代码计算快一点可以用Python的numba装饰器加速。我实测用numba.njit把双重循环编译一下N10000时速度能提升几十倍用来学习验证完全够用。不过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/21个有效的频点计算量和存储量都省了一半。X_half np.fft.rfft(x, nN) 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不仅能改善画图效果对数值稳定性也有帮助。这些坑教科书里很少写实际踩过才会懂。