傅里叶变换实战:如何用Python快速求解信号频谱(附完整代码)

信号处理的世界里,傅里叶变换就像一把“数学显微镜”,能把一团看似杂乱无章的时域波形,清晰地分解成不同频率的正弦波分量。对于工程师和数据分析师而言,理解其理论固然重要,但更重要的是能快速上手,用它来解决实际问题。你是否曾面对一段采集到的传感器数据感到无从下手?或者想分析一段音频的主频成分,却被复杂的公式和积分符号劝退?今天,我们就抛开厚重的教科书,直接进入Python的实战环境,看看如何利用scipy.fft这把利器,像解构乐高积木一样,轻松拆解信号的频谱结构。本文面向的是希望将理论付诸实践的初学者和需要快速实现原型的工程师,我们将聚焦于几个核心性质在代码中的直观体现,并提供从数据生成、变换、分析到可视化的完整流程代码,让你看完就能用。

1. 环境搭建与核心库速览

在开始频谱探险之前,我们需要一个趁手的工具箱。Python的科学计算生态为此提供了完美的支持。这里,我们主要依赖三个核心库:NumPy用于高效的数组运算和信号生成,SciPyfft模块提供了快速傅里叶变换(FFT)的高性能实现,而Matplotlib则是我们观察信号和频谱的“眼睛”。

首先,确保你的环境已经安装了这些库。如果你使用pip,一行命令即可搞定:

pip install numpy scipy matplotlib

对于追求更佳体验的读者,我强烈推荐使用Jupyter Notebook或JupyterLab进行交互式编程。它能让你实时看到每一行代码产生的图形,对于理解信号变换的每一步都大有裨益。接下来,我们快速认识一下今天的主角——scipy.fft。与numpy.fft相比,scipy.fft的API设计更为现代和一致,并且默认支持更高效的FFT算法后端。

import numpy as np
from scipy.fft import fft, fftfreq, fftshift
import matplotlib.pyplot as plt

# 设置绘图风格,让图表更美观
plt.style.use('seaborn-v0_8-darkgrid')

提示:在导入时,我们特意从scipy.fft中导入了fft(执行变换)、fftfreq(生成频率轴)和fftshift(移动零频到中心)这三个最常用的函数。fftshift在绘制双边频谱时至关重要,它能将零频分量从数组开头移动到中心位置,使频谱图更符合我们的观察习惯。

2. 从时域到频域:一个完整的FFT分析流程

让我们从一个最简单的例子开始:分析一个由两个正弦波叠加而成的信号。假设我们有一个频率为5 Hz和20 Hz的正弦波,混合在一起。我们的目标是,通过FFT,从混合信号中准确地找出这两个频率成分。

2.1 生成合成信号

任何分析的第一步都是准备数据。我们需要定义一些关键参数:采样频率Fs、信号持续时间T和对应的采样点数N。根据奈奎斯特采样定理,Fs必须大于信号最高频率的两倍,否则会发生混叠,导致频率分析出错。

# 定义信号参数
Fs = 1000  # 采样频率,1000 Hz
T = 1.0    # 信号总时长,1秒
N = int(Fs * T)  # 总采样点数
t = np.linspace(0.0, T, N, endpoint=False)  # 时间轴,不包含终点以避免重复

# 生成信号:5 Hz和20 Hz正弦波的叠加,并加入一点随机噪声模拟真实情况
freq1, amp1 = 5, 1.0
freq2, amp2 = 20, 0.5
signal = amp1 * np.sin(2 * np.pi * freq1 * t) + amp2 * np.sin(2 * np.pi * freq2 * t)
signal += 0.1 * np.random.randn(N)  # 加入高斯白噪声

# 绘制时域信号
fig, ax = plt.subplots(2, 1, figsize=(10, 6))
ax[0].plot(t, signal, lw=1.5, color='blue')
ax[0].set_xlabel('时间 [秒]')
ax[0].set_ylabel('幅度')
ax[0].set_title('时域信号 (含5Hz和20Hz正弦波及噪声)')
ax[0].grid(True)

观察上方的时域图,信号呈现出复杂的周期性波动,我们很难直接目视分辨出其中包含的5Hz和20Hz成分。这正是傅里叶变换大显身手的地方。

2.2 执行FFT与计算幅度谱

现在,我们对这个时域信号应用FFT。scipy.fft.fft函数返回的是一个复数数组,包含了频率分量的幅度和相位信息。我们通常更关心幅度谱,即每个频率分量的大小。

# 执行FFT
yf = fft(signal)

# 计算对应的频率轴 (单边频谱,只取正频率部分)
xf = fftfreq(N, 1/Fs)[:N//2]  # fftfreq生成频率轴,1/Fs是采样间隔

# 计算幅度谱。取绝对值得到幅度,乘以2/N进行归一化(因为能量对称分布在正负频率)
magnitude = 2.0/N * np.abs(yf[:N//2])

# 绘制单边幅度谱
ax[1].plot(xf, magnitude, lw=1.5, color='red')
ax[1].set_xlabel('频率 [Hz]')
ax[1].set_ylabel('幅度')
ax[1].set_title('单边幅度谱')
ax[1].grid(True)
ax[1].set_xlim(0, 50)  # 聚焦在0-50Hz范围
plt.tight_layout()
plt.show()

运行这段代码后,你会在频谱图上清晰地看到两个突出的“尖峰”,分别位于5 Hz和20 Hz处,其幅度大致为1.0和0.5,这与我们生成信号时设定的参数完全吻合。噪声则表现为整个频带上的低幅度基底。这个过程直观地展示了FFT如何将时域的混合信号,转换到频域进行“成分分离”。

2.3 理解频谱泄露与加窗

在上面的理想例子中,我们的信号频率恰好是频率分辨率的整数倍。频率分辨率df = Fs / N,这里df=1 Hz,而5 Hz和20 Hz都是1 Hz的整数倍。但在现实中,信号频率往往不是分辨率整数倍,这时就会发生频谱泄露——能量会“泄露”到相邻的频率点上,导致频谱图上的峰值变宽、幅度不准。

为了抑制频谱泄露,我们通常在FFT前对时域信号乘以一个窗函数。常见的窗函数有汉宁窗(Hanning)、汉明窗(Hamming)、布莱克曼窗(Blackman)等。它们通过平滑信号的起始和结束点(使其趋近于零),来减少因信号截断(相当于乘以一个矩形窗)带来的高频分量。

# 生成一个非整数倍频率的信号
freq_real = 5.3  # 不是频率分辨率(1Hz)的整数倍
signal_leak = np.sin(2 * np.pi * freq_real * t)

# 不加窗的FFT
yf_no_window = fft(signal_leak)
magnitude_no_window = 2.0/N * np.abs(yf_no_window[:N//2])

# 加汉宁窗后的FFT
window = np.hanning(N)  # 生成汉宁窗
signal_windowed = signal_leak * window
yf_windowed = fft(signal_windowed)
magnitude_windowed = 2.0/N * np.abs(yf_windowed[:N//2])

# 绘制对比
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
ax1.plot(xf, magnitude_no_window)
ax1.set_title(f'无窗函数 (频率={freq_real}Hz)')
ax1.set_xlabel('频率 [Hz]')
ax1.set_ylabel('幅度')
ax1.grid(True)
ax1.set_xlim(0, 15)

ax2.plot(xf, magnitude_windowed)
ax2.set_title(f'加汉宁窗后')
ax2.set_xlabel('频率 [Hz]')
ax2.set_ylabel('幅度')
ax2.grid(True)
ax2.set_xlim(0, 15)
plt.tight_layout()
plt.show()

通过对比图可以明显看到,不加窗的频谱(左图)主峰很宽,旁边有很多“旁瓣”,能量泄露严重。而加汉宁窗后(右图),主峰变得尖锐,旁瓣被显著抑制,频率定位更准确,但代价是主峰的幅度略有降低(这是窗函数导致的能量损失,在精确测量幅度时需要校正)。

注意:加窗是一把双刃剑。它改善了频率定位精度,但损失了幅度精度并加宽了主瓣。选择哪种窗函数,取决于你的具体需求:是更关心频率定位(如频谱监测),还是更关心幅度精度(如功率测量)。

3. 深入FFT性质:在代码中理解线性与时移

傅里叶变换的诸多性质并非抽象的数学游戏,它们在算法实现和问题求解中有着非常实际的意义。我们通过Python代码来感受其中两个关键性质:线性性质时移性质

3.1 线性性质的直观验证

线性性质是FFT最基础也最重要的性质之一:多个信号之和的FFT,等于各自FFT之和。这意味着我们可以对复杂系统进行分解分析。让我们用代码验证一下。

# 生成两个简单的信号
t = np.linspace(0, 1, 500, endpoint=False)
sig_a = 0.5 * np.sin(2 * np.pi * 10 * t)  # 10Hz信号
sig_b = 0.8 * np.cos(2 * np.pi * 25 * t)  # 25Hz信号

# 分别计算FFT
fft_a = fft(sig_a)
fft_b = fft(sig_b)

# 信号相加后计算FFT
sig_sum = sig_a + sig_b
fft_sum = fft(sig_sum)

# 验证线性性质:fft(sig_a + sig_b) 是否等于 fft(sig_a) + fft(sig_b)
fft_sum_by_property = fft_a + fft_b

# 计算两者差异(应接近零)
difference = np.max(np.abs(fft_sum - fft_sum_by_property))
print(f"线性性质验证:FFT(和) 与 FFT(A)+FFT(B) 的最大差异为 {difference:.2e}")
if difference < 1e-10:
    print("✅ 线性性质成立!")

运行后,你会看到差异在机器精度范围内(例如1e-15量级),验证了线性性质。这个性质非常强大,它允许我们在频域对信号进行“加减法”操作。例如,在通信系统中,我们可以单独分析载波和调制信号的频谱,再通过线性叠加理解合成信号的频谱。

3.2 时移性质与线性调频信号分析

时移性质指出:信号在时域上的平移,会导致其频谱在频域上产生一个线性相移,而幅度谱保持不变。换句话说,你延迟一会儿再播放一首歌,歌曲的频率成分(音高)不会变,只是所有频率分量的相位发生了旋转。

让我们用一个线性调频信号(频率随时间线性变化的信号)来演示。这种信号在雷达、声呐中非常常见。

# 生成一个线性调频信号 (Chirp Signal)
Fs = 1000
T = 1.0
N = int(Fs * T)
t = np.linspace(0, T, N, endpoint=False)
f0, f1 = 5, 50  # 起始频率和结束频率
chirp_signal = np.sin(2 * np.pi * (f0*t + (f1-f0)/(2*T) * t**2))  # 二次相位实现线性频率变化

# 创建一个时移版本(向右平移0.2秒)
delay_samples = int(0.2 * Fs)
# 简单处理:将原信号前面补零,后面截断,模拟延迟。更严谨的做法应用卷积。
chirp_delayed = np.zeros_like(chirp_signal)
chirp_delayed[delay_samples:] = chirp_signal[:-delay_samples]

# 计算两个信号的FFT和幅度谱
fft_original = fft(chirp_signal)
fft_delayed = fft(chirp_delayed)
freqs = fftfreq(N, 1/Fs)[:N//2]
mag_original = 2.0/N * np.abs(fft_original[:N//2])
mag_delayed = 2.0/N * np.abs(fft_delayed[:N//2])

# 绘制幅度谱对比
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8))
ax1.plot(freqs, mag_original, label='原始信号', alpha=0.7)
ax1.plot(freqs, mag_delayed, label='时延信号', alpha=0.7, linestyle='--')
ax1.set_xlabel('频率 [Hz]')
ax1.set_ylabel('幅度')
ax1.set_title('时移性质验证:幅度谱对比 (应基本重合)')
ax1.legend()
ax1.grid(True)
ax1.set_xlim(0, 100)

# 计算并绘制相位差(应是一条直线)
phase_original = np.angle(fft_original[:N//2])
phase_delayed = np.angle(fft_delayed[:N//2])
phase_diff = np.unwrap(phase_delayed - phase_original)  # 解卷绕,得到连续相位

ax2.plot(freqs, phase_diff)
ax2.set_xlabel('频率 [Hz]')
ax2.set_ylabel('相位差 [弧度]')
ax2.set_title('时移导致的相位变化 (应近似为线性)')
ax2.grid(True)
ax2.set_xlim(0, 100)
plt.tight_layout()
plt.show()

在第一幅图中,你会看到原始信号和时延信号的幅度谱几乎完全重合,这验证了“时移不改变幅度谱”的结论。在第二幅图中,相位差随频率呈现出一条倾斜的直线。这正是时移性质的核心体现:时域延迟τ,在频域引入了一个-2πfτ的线性相位偏移。这条直线的斜率就对应着时延量τ。在实际应用中,比如在声源定位或雷达测距中,正是通过测量不同接收器之间信号的相位差(进而推算时延)来确定目标的位置。

4. 实战案例:从音频文件到频谱瀑布图

掌握了基本操作后,我们来看一个更贴近实际的案例:分析一段真实音频的频谱,并绘制其随时间变化的频谱瀑布图(Spectrogram)。频谱瀑布图是语音识别、音乐信息检索等领域不可或缺的工具。

4.1 读取音频文件与预处理

我们将使用scipy.io.wavfile来读取WAV格式的音频文件。如果你没有现成的音频,可以用代码生成一段或从网上下载示例。

from scipy.io import wavfile

# 示例:生成一段包含两个纯音和一段扫频的测试音频
Fs_audio = 44100  # 标准音频采样率
duration = 3.0
t_audio = np.linspace(0, duration, int(Fs_audio * duration), endpoint=False)

# 生成信号:440Hz (A4)、880Hz (A5) 和一个从200Hz到1000Hz的扫频
audio_signal = 0.3 * np.sin(2 * np.pi * 440 * t_audio)  # A4
audio_signal += 0.2 * np.sin(2 * np.pi * 880 * t_audio) # A5
# 扫频信号
chirp_part = 0.4 * np.sin(2 * np.pi * (200*t_audio + (1000-200)/(2*duration) * t_audio**2))
# 将扫频信号放在时间轴的后半段
chirp_start_idx = int(Fs_audio * duration * 0.5)
audio_signal[chirp_start_idx:] += chirp_part[:len(audio_signal)-chirp_start_idx]

# 归一化到[-1, 1]区间,并转换为16位整数格式(模拟WAV文件)
audio_int16 = np.int16(audio_signal / np.max(np.abs(audio_signal)) * 32767)

# 保存为临时WAV文件(用于模拟读取真实文件)
wavfile.write('test_audio.wav', Fs_audio, audio_int16)

# 现在,像读取真实文件一样读取它
sample_rate, data = wavfile.read('test_audio.wav')
# 如果音频是双声道,取左声道或转换为单声道
if data.ndim > 1:
    data = data[:, 0]
# 将整数转换回浮点数
audio_data = data.astype(np.float32) / 32768.0
print(f"音频采样率: {sample_rate} Hz, 总采样点数: {len(audio_data)}")

4.2 计算与绘制频谱瀑布图

频谱瀑布图本质上是将一段长信号分成许多短时段,对每个短时段做FFT,然后将所有时段的频谱按时间顺序排列成一个二维图像。Matplotlib中的specgram函数或scipy.signal中的spectrogram函数可以方便地实现。

from scipy import signal

# 计算频谱图
nperseg = 1024  # 每个短时段的长度(窗口大小)
noverlap = nperseg // 2  # 重叠的样本数,通常为窗口长度的一半
frequencies, times, Sxx = signal.spectrogram(audio_data, fs=sample_rate,
                                             nperseg=nperseg, noverlap=noverlap,
                                             window='hann', scaling='density')

# 绘制频谱瀑布图
plt.figure(figsize=(12, 6))
# 使用pcolormesh绘制,将强度转换为分贝(dB)表示更直观
plt.pcolormesh(times, frequencies, 10 * np.log10(Sxx + 1e-10), shading='gouraud', cmap='inferno')
plt.ylabel('频率 [Hz]')
plt.xlabel('时间 [秒]')
plt.title('音频信号频谱瀑布图 (Spectrogram)')
plt.colorbar(label='强度 [dB]')
plt.ylim(0, 2000)  # 聚焦在0-2000Hz范围
plt.tight_layout()
plt.show()

生成的彩色图中,横轴是时间,纵轴是频率,颜色深浅代表该时间点、该频率成分的强度(能量)。你会清晰地看到:

  1. 两条明亮的水平线:分别对应持续存在的440Hz和880Hz纯音。
  2. 一条斜向上的亮线:从1.5秒左右开始,频率从200Hz线性增加到1000Hz,这正是我们加入的扫频信号。

通过频谱瀑布图,信号的时频特性一目了然。你可以用这段代码分析自己的音乐文件、录音或任何时间序列数据,观察其频率成分如何随时间演变。

4.3 进阶技巧:使用短时傅里叶变换(STFT)进行交互式分析

对于更精细的分析,我们可以直接使用scipy.signal.stft(短时傅里叶变换)函数,它给了我们更多的控制权,并且返回复数结果,方便后续进行滤波或重构等操作。

# 使用STFT
freqs_stft, times_stft, Zxx = signal.stft(audio_data, fs=sample_rate, nperseg=nperseg,
                                          noverlap=noverlap, window='hann')

# Zxx是复数矩阵,我们可以分别查看幅度和相位
magnitude_stft = np.abs(Zxx)
phase_stft = np.angle(Zxx)

# 例如,我们可以尝试一个简单的频域滤波:滤除800Hz以上的高频成分
Zxx_filtered = Zxx.copy()
Zxx_filtered[freqs_stft > 800, :] = 0  # 将800Hz以上的频率分量置零

# 使用逆STFT重构时域信号
_, audio_filtered = signal.istft(Zxx_filtered, fs=sample_rate, nperseg=nperseg,
                                 noverlap=noverlap, window='hann')

# 绘制原始信号与滤波后信号的对比(截取一段)
segment = slice(10000, 11000)  # 取一小段查看
fig, ax = plt.subplots(2, 1, figsize=(12, 6), sharex=True)
ax[0].plot(np.arange(len(audio_data[segment]))/sample_rate, audio_data[segment], label='原始音频')
ax[0].set_ylabel('幅度')
ax[0].legend()
ax[0].grid(True)
ax[0].set_title('原始音频信号片段')

ax[1].plot(np.arange(len(audio_filtered[segment]))/sample_rate, audio_filtered[segment],
           color='orange', label='滤除>800Hz后')
ax[1].set_xlabel('时间 [秒]')
ax[1].set_ylabel('幅度')
ax[1].legend()
ax[1].grid(True)
ax[1].set_title('滤波后音频信号片段')
plt.tight_layout()
plt.show()

这个例子展示了从STFT到滤波再到信号重构的完整流程。通过操作Zxx这个复数矩阵,我们可以在频域进行非常灵活的处理,比如降噪、提取特定频带、变调等,然后再通过逆变换回到时域。这比直接在时域进行卷积滤波要直观和高效得多。

5. 性能优化与常见陷阱规避

当处理大规模数据或实时信号时,FFT的性能和精度至关重要。这里分享几个我在实际项目中积累的经验点。

5.1 选择合适的FFT长度

FFT算法对输入数据的长度有要求,最有效的长度是2的整数次幂(如256, 512, 1024, 2048)。如果输入长度不是2的幂次,scipy.fft会自动进行零填充以达到合适的长度,但这会带来轻微的计算开销和频率分辨率的变化。

import time

# 测试不同长度FFT的计算时间
lengths = [1000, 1024, 2000, 2048, 3000, 4096]
times = []

for n in lengths:
    test_signal = np.random.randn(n)
    start = time.perf_counter()
    for _ in range(1000):  # 重复多次以获取稳定时间
        _ = fft(test_signal)
    end = time.perf_counter()
    times.append((end - start) / 1000 * 1e6)  # 单次计算时间,微秒

# 绘制结果
plt.figure(figsize=(8,5))
plt.plot(lengths, times, 'o-', markersize=8)
plt.xlabel('FFT数据长度 (N)')
plt.ylabel('单次计算时间 (微秒)')
plt.title('FFT计算时间 vs. 数据长度 (2的幂次更高效)')
plt.grid(True)
for (x, y) in zip(lengths, times):
    if x in [1024, 2048, 4096]:
        plt.text(x, y, f'2^{int(np.log2(x))}', ha='center', va='bottom')
plt.show()

你会观察到,长度为1024、2048、4096(2的幂次)的点,其计算时间往往比邻近的非2幂次长度(如1000、2000、3000)要短。在编写高性能代码时,如果可能,尽量将数据长度补零或截断到2的幂次。

5.2 理解实数FFT(rfft)的妙用

如果你的输入信号是实数信号(绝大多数物理信号都是),那么其频谱具有共轭对称性,即负频率部分是正频率部分的复共轭。这意味着有一半的计算和存储是冗余的。scipy.fft提供了rfftirfft函数,专门用于处理实数输入,它们只计算并返回正频率部分(包括零频和奈奎斯特频率),从而将计算量和存储空间几乎减半。

# 比较 fft 和 rfft 的输出
real_signal = np.random.randn(1024)

# 使用标准FFT
full_fft_result = fft(real_signal)
print(f"标准 fft 输出形状: {full_fft_result.shape}")  # 输出 (1024,)

# 使用实数FFT
real_fft_result = np.fft.rfft(real_signal)  # 注意:scipy.fft也有rfft,这里用numpy演示
print(f"实数 rfft 输出形状: {real_fft_result.shape}")  # 输出 (513,)

# 验证从rfft结果可以恢复完整频谱(通过共轭对称)
N = len(real_signal)
reconstructed_full_spectrum = np.zeros(N, dtype=complex)
reconstructed_full_spectrum[:len(real_fft_result)] = real_fft_result
# 构建负频率部分(共轭对称)
reconstructed_full_spectrum[len(real_fft_result):] = np.conj(real_fft_result[1:][::-1])

# 检查重建的频谱与原始FFT结果是否一致(忽略微小的数值误差)
diff = np.max(np.abs(full_fft_result - reconstructed_full_spectrum))
print(f"rfft重建与完整fft的最大差异: {diff:.2e}")

对于只关心幅度谱的分析任务,使用rfft可以显著提升效率。对应的逆变换是irfft,它能从正频率部分完美重构出实数时域信号。

5.3 避免常见的数值与理解陷阱

  1. 频率轴的正确生成:务必使用fftfreq(N, d=1/Fs)来生成频率轴,其中d是采样间隔。手动计算容易出错。对于rfft,使用rfftfreq
  2. 幅度归一化:为了从FFT结果中得到真实的物理幅度,需要根据变换类型进行归一化。对于fft,通常乘以2.0/N(单边谱)或1.0/N(双边谱)。scipy.signal.spectrogram等高级函数通常内置了正确的缩放。
  3. 直流分量(0 Hz)的处理:直流分量代表信号的均值。在单边谱中,直流分量不应乘以2。在绘图时,可以单独处理xf[0]对应的magnitude[0]
  4. 频谱混叠:如果信号中包含高于奈奎斯特频率(Fs/2)的成分,它们会被“折叠”到低频部分,造成混叠失真。这无法通过事后处理消除,必须在采样前用抗混叠滤波器进行预防。
# 一个混叠的例子
Fs_bad = 80  # 过低的采样率
f_high = 70  # 信号实际频率
t_bad = np.linspace(0, 1, int(Fs_bad*1), endpoint=False)
signal_alias = np.sin(2*np.pi * f_high * t_bad)

# 计算频谱
yf_alias = fft(signal_alias)
xf_alias = fftfreq(len(t_bad), 1/Fs_bad)
magnitude_alias = 2.0/len(t_bad) * np.abs(yf_alias)

# 绘制,注意峰值出现在错误的低频处(Fs - f_high)
plt.figure()
plt.plot(xf_alias[:len(xf_alias)//2], magnitude_alias[:len(xf_alias)//2], 'r.-')
plt.axvline(Fs_bad/2, color='k', linestyle='--', label='奈奎斯特频率 (Fs/2)')
plt.xlabel('频率 [Hz]')
plt.ylabel('幅度')
plt.title(f'混叠示例:{f_high}Hz信号用{Fs_bad}Hz采样,峰值出现在{Fs_bad-f_high}Hz处')
plt.legend()
plt.grid(True)
plt.show()

运行这段代码,你会发现一个70Hz的信号,用80Hz采样后,其频谱峰值出现在10Hz(80-70)处,这就是混叠。在项目开始前,务必确认你的采样率满足奈奎斯特准则。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐