ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写STFT短时傅里叶变换:原理、参数调优与代码实现

手写STFT短时傅里叶变换:原理、参数调优与代码实现 做信号分析的人迟早会遇到一个尴尬时刻对一段非平稳信号直接做FFT出来的频谱图你根本说不清某个频率成分到底出现在哪个时间段。这时候就该STFT短时傅里叶变换上场了。作为时频分析最经典的入门工具STFT的思路简单得让人意外——把长信号切成短段对每一段分别做FFT然后把结果拼成一张“时间-频率-幅度”的三维图谱。这篇博文就把STFT的原理、参数设计和手写实现一次性讲透重点是不调用Matlab的spectrogram这类现成API自己从零搭一套代码逻辑出来顺便把Python和Matlab两种实现都给了。我在实际项目里用STFT处理过振动信号、音频片段和电力谐波踩过的坑不少比如窗函数选不好导致频谱泄露、帧移设置不合理导致时频图出现条纹噪声。这篇文章会把这些经验全部写出来适合刚接触时频分析的学生、需要用STFT做故障诊断的工程师以及想彻底搞懂算法细节而不是只当API调用者的开发者。1. 为什么需要STFT傅里叶变换的“全局视野”困局1.1 傅里叶变换的致命假设先聊一个我早年踩过的坑。本科做实验时拿了一段电机启动过程的电流信号直接做FFT频谱图上确实看到了几个峰值但导师问我“那个高频分量是什么时候出现的”我当场答不上来。这就是傅里叶变换的原生缺陷——它假设信号是平稳的把整个时间域上的能量统统压进频率域时间信息彻底丢失。FFT处理的结果是一个全局频谱相当于把一场两小时的足球比赛压缩成一张球员数据统计表。你知道全场总共有几次射门但不知道哪个时间段进攻最激烈。对于语音、振动、生物电这类非平稳信号这种“平均化”处理会掩盖掉大量瞬态特征。举个例子一段语音里“你好”两个字每个字的基频和共振峰都不同如果对整个信号做FFT得到的是两个字的混合频谱想从里面分离出每个字的特征几乎不可能。这就是为什么时频分析工具必须登场——我们需要一张能同时展示频率和时间两个维度的“图谱”而不是一条只有频率维度的“曲线”。1.2 STFT的核心思想加窗切分STFT的思路简单到让人觉得“就这”——既然整段信号不平稳那就假设它在极短的时间片内是平稳的把长信号切成一小段一小段对每一段单独做FFT再把所有段的结果按照时间顺序拼起来。这个“极短的时间片”就是窗函数的作用范围。窗函数像一个滑动窗口以固定步长在信号上移动每次只截取窗口内的部分进行傅里叶变换。窗口长度决定了一次分析能看到多长的信号片段窗口移动步长决定了时间轴上相邻两次分析的间隔。我习惯用一个比喻来理解STFT就像用一台固定焦距的相机沿着时间轴拍照每张照片只能拍到信号的局部把所有照片排在一起就能拼出全貌。但焦距是固定的拍不清太远的细节也拍不全太近的全景——这个“焦距”对应的就是窗长后面会详细展开它的影响。1.3 STFT能解决哪些实际问题STFT的应用场景多到几乎覆盖所有涉及信号处理的领域。机械故障诊断中齿轮箱的磨损会产生周期性冲击振动STFT时频图上能看到明显的“横线”或“波浪线”故障特征一眼就能分辨语音识别里STFT生成的语谱图是语音特征提取的基础很多语音模型输入的就是STFT谱图经过变换后的特征。电力系统谐波分析也会用到STFT因为电网频率波动和谐波成分会随着负载变化传统FFT无法定位谐波出现的时间段。医疗领域的脑电图EEG和心电图ECG分析同样依赖时频方法癫痫发作时的特征波在STFT谱图上会表现出明显的能量聚集区域。雷达和声呐信号处理中STFT用来分析多普勒频移随时间的变化判断目标的运动状态。我见过一个很有意思的案例有人用STFT分析鸟类鸣叫声通过时频图上的频率调制模式识别不同鸟种效果比单纯听录音可靠得多。2. STFT数学原理与参数设计从公式到工程实践2.1 短时傅里叶变换的数学定义STFT的数学表达式看起来并不复杂X(m, k) Σ_{n0}^{N-1} x(n mH) · w(n) · e^{-j2πkn/N}其中x(n)是输入信号w(n)是窗函数N是窗长H是帧移hop sizem是帧索引k是频率索引。整个过程可以拆成三个动作先把窗函数与信号的对应位置相乘得到加窗后的片段再对这个片段做离散傅里叶变换。这里有个容易被忽略的细节窗函数和信号相乘不是简单的“截断”而是“加权”。窗函数在边缘处会衰减到接近零目的是让截取的片段首尾平滑过渡到零避免因为硬切导致频谱上出现不必要的旁瓣——这个现象叫频谱泄露后面会单独讲。在Python里用NumPy实现上述公式的核心步骤非常直观先用一个循环或者矩阵索引把信号切分成帧每一帧乘以窗函数然后对每一帧调用np.fft.rfft。很多人误以为STFT必须调用专用库其实核心逻辑加起来不到二十行代码。2.2 四个关键参数窗长、帧移、FFT点数、窗函数参数怎么定直接决定STFT效果的好坏。先说窗长N它决定了频率分辨率和时间分辨率的平衡。窗长越长频率分辨率越高能区分更近的频率成分但时间分辨率越差无法准确定位频率变化的时间点窗长越短则相反。这个矛盾是STFT的固有特性不是参数调得不够好。帧移H决定了相邻两帧之间的重叠程度。当H小于N时相邻帧有重叠这样能避免在时间轴上出现“缝隙”让时频图看起来更平滑。我常用的经验值是H N/4也就是75%的重叠率既保证时间轴足够密又不会让计算量暴涨。FFT点数N_fft可以大于窗长N超出部分会自动补零。补零虽然不能提高真实频率分辨率但能让频谱曲线更平滑视觉效果更好。常有人问补零到底有什么用——它相当于在频域做了插值让峰值位置更容易被精确识别。窗函数的选择同样关键。矩形窗频率分辨率最高但旁瓣泄漏最严重汉宁窗和汉明窗是折中方案适合大多数场景布莱克曼窗旁瓣衰减更大但主瓣更宽。实际应用中我优先推荐汉宁窗因为它在分辨率和泄漏抑制之间平衡得最好。2.3 窗函数的特性对比为了让你对不同窗函数的表现有个直观概念我整理了常见窗函数的特性对比。这些数据来自实际测试和官方文档用于参数选择时参考非常方便窗函数主瓣宽度旁瓣衰减频率分辨率适用场景矩形窗最窄-13 dB最高瞬态信号、精确频率测量汉宁窗中等-31 dB中等通用分析、语音处理汉明窗中等-43 dB中等语音处理、通信信号布莱克曼窗较宽-58 dB较低需要极低旁瓣的场景凯塞窗可调可调可调需要灵活控制的场景注意主瓣宽度和旁瓣衰减是互相制约的没有一个窗函数能同时做到主瓣最窄和旁瓣最低。实际选择时要根据信号特点和需求来权衡。比如我处理齿轮箱振动信号时因为故障冲击信号本身很尖锐用汉宁窗就能同时保住时间分辨率又不让泄漏影响判断但处理电力谐波时谐波之间频率间隔很小就得换主瓣更窄的矩形窗或者凯塞窗。2.4 时间分辨率与频率分辨率的“跷跷板效应”STFT最让人头疼的特性就是时间分辨率和频率分辨率无法兼得。这是测不准原理在信号处理中的体现不是代码能解决的。窗长N直接决定了这两个分辨率的数值频率分辨率Hz大约等于 fs / N窗越长越能分辨接近的频率时间分辨率秒大约等于 N / fs窗越短越能精确定位事件发生时刻我实际做语音分析时常把窗长设为25毫秒帧移10毫秒这是语音识别领域的标准配置。25毫秒的窗口能捕捉到音节级别的特征同时保持足够的频率分辨率来区分基频和谐波。如果你的信号是高频振动可能要把窗长缩短到1-2毫秒才能定位到冲击时刻这时候频率分辨率会牺牲不少需要权衡取舍。这里有个我踩过的坑刚开始做轴承故障诊断时我图省事直接用了1024点窗长采样率是48kHz结果频率分辨率确实好但时间分辨率只有21毫秒左右故障冲击的特征在时频图上被抹平了完全看不出周期性。后来把窗长降到256点时间分辨率提升到5毫秒左右故障特征一下清晰起来。3. 不调用API的手写STFT完整代码实现与解析3.1 实现思路分帧、加窗、FFT、拼图写STFT代码之前先在纸上过一遍完整流程。不管用什么编程语言核心就四步把一维信号切分成若干帧每帧长度是N帧与帧的起点相距H对每一帧乘上窗函数对每一帧做FFT把所有帧的频谱按时间顺序组成二维矩阵最后这个二维矩阵就是时频谱横轴是时间帧索引纵轴是频率颜色深浅代表能量大小。画图时把矩阵转置用热力图展示就得到了时频图。如果你调用Matlab的spectrogram函数这一步等于封装在内部了但为了搞懂原理也为了避免以后项目换语言时抓瞎我建议至少手写一次。我当初就是被spectrogram的参数绕晕干脆自己实现了一遍彻底搞明白每一行代码在干什么之后再也没被API文档卡过脖子。3.2 Python手写实现基于NumPy从零构建Python实现STFT我推荐用NumPy简洁且容易理解。下面这段代码是完整的STFT函数注释详细说明每一步的作用可以直接复制到你的项目里import numpy as np def stft_manual(x, fs16000, win_len400, hop_len160, n_fft512, win_typehann): 手工实现短时傅里叶变换不调用librosa/spectrogram 参数: x: 一维输入信号 fs: 采样率 win_len: 窗长采样点 hop_len: 帧移采样点 n_fft: FFT点数可以大于win_len不足部分补零 win_type: 窗函数类型 返回: spec: 复数谱形状为 (n_frames, n_fft//2 1) t_axis: 时间轴秒 f_axis: 频率轴Hz x np.asarray(x, dtypenp.float64) n_frames 1 (len(x) - win_len) // hop_len # 生成窗函数 if win_type hann: win np.hanning(win_len) elif win_type hamming: win np.hamming(win_len) elif win_type blackman: win np.blackman(win_len) else: win np.ones(win_len) # 矩形窗 # 分帧用步进索引构建帧矩阵 frame_idx np.arange(win_len)[None, :] hop_len * np.arange(n_frames)[:, None] frames x[frame_idx] # 形状: (n_frames, win_len) # 加窗 frames_windowed frames * win[None, :] # 对每一帧做FFT取单边谱 spec np.fft.rfft(frames_windowed, nn_fft, axis1) # 时间轴每帧中心对应的时间 t_axis (np.arange(n_frames) * hop_len win_len / 2) / fs # 频率轴 f_axis np.fft.rfftfreq(n_fft, d1/fs) return spec, t_axis, f_axis这段代码的核心是分帧那一步用NumPy的广播机制一次性生成所有帧的索引矩阵避免了Python层面的for循环计算效率高很多。加窗是逐帧乘以窗函数用广播实现也很快。注意返回的spec是复数矩阵画图时通常取幅值或者幅值的平方功率谱。实际使用中我经常取10*log10(amplitude)转成分贝单位这样动态范围会压缩小信号也能在图上显现出来。另外rfft返回的是单边频谱只包含0到奈奎斯特频率的一半对应时频图的纵轴是0到fs/2。3.3 Matlab手写实现矩阵化写法与细节优化Matlab版的实现思路完全一样但Matlab的矩阵操作更灵活可以写出完全不用循环的版本。下面这段代码是等价的STFT实现重点演示了buffer函数的替代方式——用索引矩阵实现分帧function [spec, t_axis, f_axis] stft_manual(x, fs, win_len, hop_len, n_fft, win_type) % 手工实现短时傅里叶变换不调用spectrogram API % 输入: % x: 一维信号列向量 % fs: 采样率 % win_len: 窗长 % hop_len: 帧移 % n_fft: FFT点数 % win_type: hann, hamming, blackman, rect % 输出: spec(复数谱), t_axis, f_axis x x(:); % 确保列向量 n_frames floor((length(x) - win_len) / hop_len) 1; % 构造帧索引矩阵: 第i行是第i帧的采样点索引 idx (0:win_len-1) (0:hop_len:(n_frames-1)*hop_len); % idx 形状: win_len × n_frames % 分帧并转置使每帧占一行 frames x(idx); % win_len × n_frames frames frames.; % n_frames × win_len % 生成窗函数 switch lower(win_type) case hann win hanning(win_len); case hamming win hamming(win_len); case blackman win blackman(win_len); otherwise win ones(win_len, 1); end % 加窗: 每帧乘上窗函数 frames_windowed frames .* win.; % 广播 % FFT按行做 spec fft(frames_windowed, n_fft, 2); % 取单边谱正频率部分 spec spec(:, 1:n_fft/21); % 时间轴和频率轴 t_axis ((0:n_frames-1) * hop_len win_len/2) / fs; f_axis (0:n_fft/2) * fs / n_fft; endMatlab版的实现里有两个小细节值得注意。索引矩阵的构造方式是把窗长方向的索引和帧移方向的索引分别放在两个维度然后相加一步到位生成所有需要的采样点位置。乘窗函数时用点乘加转置来对齐维度如果x是行向量就会出问题所以开头统一变成列向量。很多人在Matlab里写STFT第一反应是循环逐帧处理但用索引矩阵可以用一行代码完成分帧速度提升几十倍。这个技巧在处理长信号时差异尤其明显——我处理过一段10分钟的音频用循环要跑将近一分钟换成矩阵化写法瞬间出结果。3.4 完整测试用线性调频信号验证实现正确性代码写完了怎么验证对不对我建议用线性调频信号chirp来测试这种信号的频率随时间线性变化在时频图上应该呈现一条倾斜的亮线最容易验证STFT是否正常工作。下面这段测试代码生成一个从200Hz扫到2000Hz的chirp信号采样率8kHz持续2秒然后调用手写的STFT函数并绘制时频图import matplotlib.pyplot as plt # 生成线性调频信号 fs 8000 t np.linspace(0, 2, fs * 2, endpointFalse) f0, f1 200, 2000 x_chirp np.sin(2 * np.pi * (f0 * t (f1 - f0) / (2 * 2) * t**2)) # 调用手写STFT spec, t_axis, f_axis stft_manual(x_chirp, fs, win_len256, hop_len64, n_fft512) amp np.abs(spec) # 绘制时频图 plt.figure(figsize(10, 5)) plt.pcolormesh(t_axis, f_axis, amp.T, shadinggouraud, cmapmagma) plt.xlabel(Time [s]) plt.ylabel(Frequency [Hz]) plt.title(STFT of Chirp Signal) plt.colorbar(labelAmplitude) plt.show()运行这段代码后你会看到一条从200Hz逐渐上升到2000Hz的亮线说明STFT成功捕捉到了频率随时间的变化。如果实现有bug这条线会断裂或者出现奇怪的条纹排查起来也很方便。我在实际写代码时还习惯打印一下spec的维度确认帧数和频率点数和预期一致。比如上面的参数n_frames 1 (16000 - 256) // 64 247帧频率点数是257512/21如果维度对不上说明分帧逻辑哪里写错了。3.5 复杂度优化避免循环的技巧STFT计算涉及大量重复的窗口截取操作如果实现得不够高效实时处理时会卡顿。用NumPy或者Matlab的矩阵化写法可以轻松避免Python层面的循环但还有两个细节可以进一步优化。FFT点数n_fft选择2的幂次方能够利用库函数的分治算法显著提升运算速度。比如窗长400n_fft取512而不是400虽然补了112个零但计算速度可能快一倍。有人纠结补零会不会影响结果——不会补零只是频域插值不丢失信息。另一个优化点是缓存。如果对同一段信号需要多次计算不同窗长的STFT可以复用原始信号的副本避免重复做分帧。更高级的做法是在GPU上并行处理多帧FFT不过这是后话普通项目用NumPy已经足够。4. 手写STFT的常见问题与调试技巧4.1 频率轴坐标映射单边谱与双边谱的选择画时频图时经常有人搞混频率轴怎么算。np.fft.rfftfreq(n_fft, d1/fs)给出的是从0到fs/2的n_fft/21个点步长是fs/n_fft。如果你不小心用了np.fft.fftfreq会得到包含负频率的双边谱画图时多出镜像的一半看起来非常怪。Matlab的fft默认输出双边谱频率范围从0到fs所以手写代码时需要用spec[:, 1:n_fft/21]取正频率部分。如果忘了这一步时频图的纵轴会一直延伸到fs实际有效的只有一半白白浪费一半显示空间。一个值得注意的细节是Matlab中fft结果的索引1对应直流分量0Hz索引n_fft/21对应奈奎斯特频率fs/2。很多刚上手的人会把这个索引关系搞混导致频率轴偏移。4.2 频谱泄露的处理窗函数的重要性再强调如果你用矩形窗处理纯正弦信号频谱上除了主峰还会出现一串逐渐衰减的旁瓣这就是频谱泄露。旁瓣会把信号能量泄漏到附近的频段掩盖真实的小幅值信号。我在处理音频信号时遇到过一种情况一段干净的语音信号里叠加了一个极小的噪声分量用矩形窗STFT画出的时频图上一片糊几乎看不出噪声的频率。换成汉宁窗之后旁瓣被压低了20多dB噪声特征清晰可见。窗函数的选择本质上是在“频谱分辨率”和“动态范围”之间做权衡。如果你的应用场景要求能分辨出两个距离很近的峰值矩形窗是唯一选择如果要求能看到小幅值信号就必须牺牲一些分辨率来换旁瓣抑制。4.3 帧移和窗长的调节经验不同场景的参数速查我整理了在不同应用场景下常用的STFT参数范围供你参考。这些数值来自实际项目经验不一定最优但作为起跳点足够靠谱应用场景窗长帧移备注语音分析20-30 ms5-10 ms与音素时长匹配音乐分析40-100 ms10-25 ms兼顾节拍与音高机械振动2-10 ms1-5 ms定位冲击时刻电力谐波50-100 ms10-25 ms保证频率分辨率生物电信号0.5-2 s0.2-0.5 s低频成分为主注意语音分析标准配置是25ms窗长、10ms帧移对应采样率16kHz时是400点窗长、160点帧移。机械振动因为采样率高且信号频率高需要短窗长来保证时间分辨率代价是频率分辨率下降。实际项目中我倾向于先跑一版默认参数看看效果再根据时频图的清晰度微调。如果频率方向糊了加大窗长如果时间方向糊了减小窗长。这个“先看后调”的流程比一开始就纠结参数高效得多。4.4 边界效应的坑首尾帧的处理策略信号开头和结尾的帧因为窗函数覆盖的样本不足频谱会失真。这个问题在使用大窗长时格外明显时频图的左右边缘会出现明显的竖条纹让人误以为信号在开头和结尾有额外的频率成分。解决办法有两个。一是对信号做边缘补零让首尾帧也能取到完整的窗长这样频谱能量会略微下降但不会出现伪峰。二是在画图时直接把首尾各半窗长的区域裁掉只显示有效区域。我倾向于用第一种方法因为同时还能提升频谱的平滑度。补零不会造成信息丢失只是把窗口内的信号截断变成补零截断代价是信号开头和结尾的幅度被低估了但这对大多数分析场景影响不大。5. 高级技巧与工程实践让STFT更好用5.1 从幅值谱到对数功率谱动态范围压缩的细节直接查看STFT的幅值谱时强信号分量会盖住弱信号分量。比如一段音乐里鼓点能量很大人声的细节在时频图上几乎看不见。解决办法是把幅值转换成对数功率谱dB单位公式是20log10(|X|)或者10log10(|X|^2)。使用对数刻度后动态范围通常能覆盖60-100dB弱信号特征就能显现出来。我在做音频分析时习惯用功率谱加对数压缩因为听觉系统本身也是对数量级的敏感度。但要注意完全寂静的时段对应的幅值接近0取对数后会变成负无穷大。所以实际代码里要加一个小的下限值比如1e-10避免出现NaN或者无穷大。5.2 谱图增强归一化与色彩映射技巧画时频图时色彩映射的选择直接影响可读性。我常用的colormap有jet、magma和viridis。jet虽然色彩鲜艳但存在视觉误导——它的颜色变化不是均匀的会让人误读强度差异magma和viridis是感知均匀的colormap更适合数据分析。归一化也很重要。我习惯先把对数谱做min-max归一化到0-1范围再映射到颜色。这样不同信号的时频图对比起来更公平不会因为某个信号的绝对能量大就盖过另一个。Matlab里画时频图用imagesc配合colormapPython用pcolormesh。pcolormesh有个好处是支持非均匀网格配合时间轴使用很方便。如果只是均匀网格imshow更快渲染大图时区别很明显。5.3 实时STFT流式处理的实现思路有些场景需要对连续信号做实时时频分析比如语音实时可视化、机械设备在线监测。流式STFT和离线STFT的区别在于输入信号是持续到达的数据块需要维护一个滑动缓冲区。基本思路是初始化一个长度为窗长的缓冲区每来一块新数据就把它追加到缓冲区尾部去掉头部等长的旧数据然后对缓冲区做一次STFT。这样每次只需要计算一帧FFT计算量很小。Python里可以用collections.deque实现滑动缓冲区固定最大长度append新数据后自动弹出旧数据。实际测试下来16kHz采样率下做256点FFT耗时在亚毫秒级别完全能满足实时显示需求。5.4 与其他时频分析方法的边界什么时候该放弃STFTSTFT虽然经典但不是万能的。当信号包含跨越多倍频程的频率变化时STFT的固定窗口就力不从心了。这时候需要小波变换Wavelet Transform它能根据频率自动调整时间分辨率——高频用窄窗口低频用宽窗口。我在处理雷达回波信号时比较过STFT和小波变换的效果。回波包含很宽的频率范围STFT时频图上高频部分的时间分辨率不够无法区分两个紧邻的脉冲小波变换则能清晰分辨。但如果信号频率范围不宽STFT反而比小波更直观因为它输出的时频图直接对应物理频率解释起来不费劲。另一个思路是Wigner-Ville分布它的时频聚集性比STFT好得多但交叉项干扰严重处理多分量信号时图像会变得混乱。实际工程中STFT依然是最稳健、最好调、最容易解释的时频分析工具这也是它经久不衰的原因。我自己做过一次对比实验同一段语音信号分别用STFT和CWT连续小波变换做时频图STFT在高频段的横条纹更规整基频和谐波的位置一目了然CWT在高频段的时间定位更准但频率轴的标定不如STFT直观。两者各有优势根据需求选型才是正道。写在最后的一点经验做信号处理这些年我最大的体会是工具越底层越值得亲手实现一遍。用spectrogram这类封装好的API两分钟出图很爽但一旦遇到结果不对的情况你都不知道该调哪个参数更不知道是不是API内部某些默认设置坑了你。手写过一次STFT之后窗函数、帧移、补零这些概念就再也不是黑盒了。我在实际项目里遇到时频图异常的情况能快速定位是窗函数没对齐、频率轴映射错了还是信号本身边缘效应这种排查能力比记住任何API的用法都值钱。希望这篇博文能帮你把STFT真正变成得心应手的工具在需要的时候随时可以手写一个而不是只能求助于黑盒函数。
返回列表