用Python代码图解DTFT和DFT:从数学公式到可视化理解的捷径
用Python代码图解DTFT和DFT:从数学公式到可视化理解的捷径
很多朋友在学习数字信号处理时,都会遇到一个共同的困惑:那些复杂的数学公式,比如离散时间傅里叶变换(DTFT)和离散傅里叶变换(DFT),看起来抽象又难以捉摸。我们常常在教科书上看到一堆求和符号和积分符号,却很难在脑海中形成直观的画面。这种感觉就像是在黑暗中摸索,知道前方有路,却看不清具体的轮廓。
实际上,这些变换的核心思想并不像表面看起来那么晦涩。DTFT和DFT本质上都是在做同一件事:把一个信号从时域“翻译”到频域,让我们能够看清这个信号是由哪些不同频率的正弦波组合而成的。DTFT给出的是一个连续的频谱视图,而DFT则是在这个连续视图上进行的“采样”,让我们能够在计算机上实际计算和处理。理解它们之间的关系,就像是理解了地图的绘制原理和实际导航采样点之间的关系。
这篇文章就是为你准备的,如果你是一位正在学习信号处理、机器学习音频分析,或者任何需要频域分析的编程爱好者。我们将完全避开枯燥的纯理论推导,转而采用一种更直接、更符合工程师思维的方式:用代码实现,用图形说话。我会带你一步步在Jupyter Notebook中,用NumPy和Matplotlib从零构建DTFT和DFT的可视化演示。你将亲手编写核心算法,生成动态动画来观察频谱如何随信号变化,甚至用一段真实的声音信号来演示“频谱泄漏”这个经典现象。我们的目标不是成为数学理论家,而是获得一种“肌肉记忆”般的直觉理解,让你下次看到这些变换时,脑海中能立刻浮现出对应的图形和代码逻辑。
1. 环境准备与核心概念速览
在开始动手之前,我们需要确保手头有趁手的工具。整个教程将基于Python的科学计算栈,这几乎是当今数据分析和算法原型的标准配置。
首先,创建一个新的Jupyter Notebook,并安装或导入必要的库。如果你使用Anaconda,这些库通常已经预装好了。
# 核心计算与数组操作
import numpy as np
# 绘图与可视化
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
# 在Notebook内显示交互式图表
%matplotlib widget
# 处理声音文件(如果需要)
import scipy.io.wavfile as wavfile
# 用于生成示例信号
from scipy import signal
提示:如果你在本地运行,
%matplotlib widget可能需要安装ipympl包 (pip install ipympl)。如果遇到问题,可以改用%matplotlib notebook或%matplotlib inline,但交互性会减弱。
接下来,我们用最直白的语言快速回顾一下DTFT和DFT到底是什么,以及它们最根本的区别在哪里。这能帮助我们在写代码时,清楚地知道每一步在计算什么。
离散时间傅里叶变换 (DTFT) 针对的是理论上无限长的离散时间序列 x[n]。它的公式是:
X(ω) = Σ_{n=-∞}^{∞} x[n] * e^{-jωn}
这里的关键点有两个:
- 连续频率 ω:DTFT的结果
X(ω)是一个关于连续角频率ω的复函数。这意味着对于任意一个频率值(比如ω=0.1, 0.1001, 0.1002...),我们理论上都能算出一个对应的频谱值。它的图像是一条连续的曲线。 - 周期性:
X(ω)是以2π为周期的周期函数。这是因为复指数e^{-jωn}本身具有2π的周期性。所以,我们通常只关心ω在[-π, π]或[0, 2π]这一个周期内的频谱。
离散傅里叶变换 (DFT) 针对的是有限长的离散时间序列 x[n],长度为 N。它的公式是:
X[k] = Σ_{n=0}^{N-1} x[n] * e^{-j(2π/N)kn}, k = 0, 1, ..., N-1
DFT的核心特征也有两个:
- 离散频率 k:DFT的结果
X[k]是一个离散的复数序列,长度也是N。频率点k对应的是数字角频率ω_k = (2π/N)*k。你可以把k理解为频率的“索引号”。 - DTFT的采样:这是理解两者关系最关键的一步。DFT序列
X[k]恰好等于同一个有限长信号x[n]的DTFTX(ω)在频率轴ω上,于[0, 2π)区间内进行N点均匀采样所得到的结果。用公式表示就是:X[k] = X(ω) |_{ω = 2πk/N}。
为了更清晰地对比,我们用一个表格来总结:
| 特性 | 离散时间傅里叶变换 (DTFT) | 离散傅里叶变换 (DFT) |
|---|---|---|
| 时域信号 | 无限长序列 (理论上) | 有限长序列 (长度为 N) |
| 频域结果 | 连续函数 X(ω) |
离散序列 X[k] (长度为 N) |
| 频率变量 | 连续角频率 ω (弧度/样本) |
离散频率索引 k,对应 ω_k = 2πk/N |
| 周期性 | X(ω) 以 2π 为周期 |
X[k] 以 N 为周期 (隐含周期性) |
| 可计算性 | 理论上连续,无法在计算机上直接完整计算 | 离散且有限,可通过FFT算法高效计算 |
| 核心关系 | 理论基础,提供连续的频谱视图 | DFT是DTFT在频域的均匀采样 |
有了这个基本认识,我们就可以开始用代码来“看见”这些概念了。
2. 手动实现DTFT:从公式到连续频谱图
虽然DTFT在计算机上无法计算所有连续的ω点,但我们可以通过计算足够密集的采样点来近似描绘出连续的频谱曲线。这正是我们可视化理解的第一步。
让我们从一个简单的有限长序列开始,比如 x[n] = [1, 2, 3, 4],并假设在这个区间之外,信号值为0。我们将手动实现DTFT公式,并绘制其幅度谱和相位谱。
def manual_dtft(x_n, omega):
"""
手动计算有限长序列x_n的DTFT在给定频率点omega处的值。
参数:
x_n: 一维数组,时域序列。
omega: 一维数组或标量,角频率点。
返回:
X_omega: 复数,或与omega同形的复数数组,表示DTFT结果。
"""
# 将输入转换为numpy数组以便广播计算
x_n = np.asarray(x_n)
omega = np.asarray(omega)
# 获取时域索引n。我们假设x_n从n=0开始。
n = np.arange(len(x_n))
# 利用numpy的广播机制进行向量化计算
# 对于每个omega,计算 sum_{n} x[n] * exp(-j*omega*n)
# np.newaxis 用于增加维度以实现广播
X_omega = np.sum(x_n[:, np.newaxis] * np.exp(-1j * omega * n[:, np.newaxis]), axis=0)
return X_omega
# 定义我们的示例信号
x_signal = np.array([1, 2, 3, 4])
N = len(x_signal)
# 生成足够密集的频率点来近似连续频谱
# 我们观察[0, 2π)区间,这是DTFT的一个完整周期
num_points = 1000 # 点数越多,曲线越平滑
omega = np.linspace(0, 2*np.pi, num_points, endpoint=False) # 通常不包括2π端点
# 计算DTFT
X_dtft = manual_dtft(x_signal, omega)
# 绘制结果
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8))
# 幅度谱
ax1.plot(omega, np.abs(X_dtft), linewidth=2)
ax1.set_title(f'DTFT幅度谱 |X(ω)| (信号: {x_signal})')
ax1.set_xlabel('数字角频率 ω [弧度/样本]')
ax1.set_ylabel('幅度')
ax1.grid(True, alpha=0.3)
# 标记π和2π
ax1.axvline(x=np.pi, color='r', linestyle='--', alpha=0.5, label='ω = π')
ax1.axvline(x=2*np.pi, color='g', linestyle='--', alpha=0.5, label='ω = 2π')
ax1.legend()
ax1.set_xlim([0, 2*np.pi])
# 相位谱
ax2.plot(omega, np.angle(X_dtft), linewidth=2)
ax2.set_title('DTFT相位谱 ∠X(ω)')
ax2.set_xlabel('数字角频率 ω [弧度/样本]')
ax2.set_ylabel('相位 [弧度]')
ax2.grid(True, alpha=0.3)
ax2.axvline(x=np.pi, color='r', linestyle='--', alpha=0.5)
ax2.axvline(x=2*np.pi, color='g', linestyle='--', alpha=0.5)
ax2.set_xlim([0, 2*np.pi])
plt.tight_layout()
plt.show()
运行这段代码,你会看到两条光滑的曲线。幅度谱展示了信号在不同频率上的能量分布,而相位谱则展示了各频率分量的初始相位。注意观察曲线在 ω=2π 处与 ω=0 处的值是否衔接平滑?这验证了DTFT的 2π 周期性。
现在,让我们加入一点交互性。我们将创建一个动画,展示当时域信号 x[n] 的长度 N 逐渐增加时,DTFT频谱是如何变化的。这能直观地说明“有限长截断”对频谱的影响。
# 假设我们有一个无限长信号的理想模型(例如,一个正弦波)
# 但我们只能观察到有限长度M
def generate_signal_segment(M, freq=0.2):
"""生成一个长度为M的余弦信号段"""
n = np.arange(M)
return np.cos(2 * np.pi * freq * n)
# 准备绘图
fig, (ax_time, ax_freq) = plt.subplots(2, 1, figsize=(10, 8))
omega_dense = np.linspace(0, 2*np.pi, 500, endpoint=False)
# 初始化线条
line_time, = ax_time.plot([], [], 'bo-', markersize=8, linewidth=1.5, label='观测信号 x[n]')
line_dtft, = ax_freq.plot([], [], 'r-', linewidth=2, label='DTFT幅度谱')
ax_time.set_xlim(-1, 50)
ax_time.set_ylim(-1.5, 1.5)
ax_time.set_xlabel('时间索引 n')
ax_time.set_ylabel('幅度')
ax_time.set_title('时域信号 (长度 M 变化)')
ax_time.legend()
ax_time.grid(True, alpha=0.3)
ax_freq.set_xlim(0, 2*np.pi)
ax_freq.set_ylim(0, 30)
ax_freq.set_xlabel('数字角频率 ω [弧度/样本]')
ax_freq.set_ylabel('|X(ω)|')
ax_freq.set_title('对应的DTFT幅度谱')
ax_freq.legend()
ax_freq.grid(True, alpha=0.3)
ax_freq.axvline(x=2*np.pi*0.2, color='k', linestyle=':', alpha=0.7, label='真实频率成分')
# 动画更新函数
def update(frame):
M = frame + 5 # 信号长度从5开始增加
x_segment = generate_signal_segment(M, freq=0.2)
# 更新时域图
line_time.set_data(np.arange(M), x_segment)
ax_time.set_xlim(-1, max(50, M+5))
# 计算并更新DTFT
X_dtft = manual_dtft(x_segment, omega_dense)
line_dtft.set_data(omega_dense, np.abs(X_dtft))
ax_freq.set_title(f'DTFT幅度谱 (信号长度 M={M})')
return line_time, line_dtft
# 创建动画
ani = FuncAnimation(fig, update, frames=45, interval=300, blit=True) # 从M=5到M=50
plt.tight_layout()
plt.show()
# 注意:在Jupyter中,动画可能需要额外代码保存或显示,这里主要展示逻辑。
观察这个动画,你会发现随着观测窗口 M 变长,DTFT频谱的峰值在真实频率(ω=0.4π)处变得越来越尖锐。这引出了信号处理中一个核心概念:频谱分辨率。观测时间越长,你区分两个很近频率成分的能力就越强。同时,你也会看到所谓的“频谱泄漏”现象——即使信号只有一个频率,由于我们只截取了一段,频谱也会在基频周围铺开,形成旁瓣。
3. 实现DFT并验证其与DTFT的采样关系
接下来,我们手动实现DFT。虽然在实际中我们永远使用高效的FFT库(如np.fft.fft),但自己写一遍有助于深刻理解其本质。
def manual_dft(x_n):
"""
手动计算有限长序列x_n的DFT。
参数:
x_n: 一维数组,时域序列,长度为N。
返回:
X_k: 一维复数数组,频域序列,长度也为N。
"""
N = len(x_n)
X_k = np.zeros(N, dtype=complex)
# 遍历每个频率索引k
for k in range(N):
# 计算求和项
sum_val = 0.0
for n in range(N):
sum_val += x_n[n] * np.exp(-2j * np.pi * k * n / N)
X_k[k] = sum_val
return X_k
# 使用之前的信号
x_signal = np.array([1, 2, 3, 4])
N = len(x_signal)
# 计算手动DFT
X_dft_manual = manual_dft(x_signal)
print("手动计算的DFT结果 (X[k]):")
print(X_dft_manual)
# 使用NumPy的FFT进行验证 (FFT是DFT的高效算法,结果应相同)
X_dft_numpy = np.fft.fft(x_signal)
print("\nNumPy FFT计算结果:")
print(X_dft_numpy)
print(f"\n两者是否接近? {np.allclose(X_dft_manual, X_dft_numpy)}")
现在,最激动人心的部分来了:我们将可视化地验证 “DFT是DTFT的均匀采样” 这一关键论断。
# 使用更复杂的信号以便观察
t = np.linspace(0, 1, 128, endpoint=False) # 1秒时长,128个采样点
# 生成一个包含两个频率成分的信号
freq1, freq2 = 10, 25 # 单位:Hz
x_complex = 0.7 * np.sin(2*np.pi * freq1 * t) + 0.3 * np.cos(2*np.pi * freq2 * t + np.pi/4)
N = len(x_complex)
# 1. 计算该信号的DTFT(密集采样)
omega_dense = np.linspace(0, 2*np.pi, 2000, endpoint=False)
X_dtft_dense = manual_dtft(x_complex, omega_dense)
# 2. 计算该信号的DFT (使用FFT)
X_dft = np.fft.fft(x_complex)
# DFT对应的数字频率点
k = np.arange(N)
omega_dft = 2 * np.pi * k / N # 这就是DFT采样的频率位置
# 3. 在同一张图上绘制DTFT连续曲线和DFT采样点
fig, axes = plt.subplots(2, 1, figsize=(12, 10))
# 幅度谱对比
ax1 = axes[0]
# 绘制DTFT连续幅度谱
ax1.plot(omega_dense, np.abs(X_dtft_dense), 'b-', linewidth=1.5, alpha=0.7, label='DTFT连续谱 |X(ω)|')
# 绘制DFT采样点
ax1.stem(omega_dft, np.abs(X_dft), linefmt='r-', markerfmt='ro', basefmt=' ', label=f'DFT采样点 (N={N})')
ax1.set_xlabel('数字角频率 ω [弧度/样本]')
ax1.set_ylabel('幅度')
ax1.set_title('DFT是DTFT的均匀采样:幅度谱验证')
ax1.legend()
ax1.grid(True, alpha=0.3)
ax1.set_xlim([0, 2*np.pi])
# 相位谱对比
ax2 = axes[1]
# 绘制DTFT连续相位谱 (需要处理相位卷绕)
dtft_phase = np.angle(X_dtft_dense)
ax2.plot(omega_dense, dtft_phase, 'b-', linewidth=1.5, alpha=0.7, label='DTFT连续相位谱 ∠X(ω)')
# 绘制DFT采样点相位
dft_phase = np.angle(X_dft)
ax2.stem(omega_dft, dft_phase, linefmt='r-', markerfmt='ro', basefmt=' ', label=f'DFT采样点相位')
ax2.set_xlabel('数字角频率 ω [弧度/样本]')
ax2.set_ylabel('相位 [弧度]')
ax2.set_title('相位谱验证')
ax2.legend()
ax2.grid(True, alpha=0.3)
ax2.set_xlim([0, 2*np.pi])
plt.tight_layout()
plt.show()
# 数值验证:在DFT频率点处,DTFT的值是否等于DFT的值?
# 计算DTFT在DFT频率点上的精确值
X_dtft_at_dft_points = manual_dtft(x_complex, omega_dft)
print("数值验证:")
print(f"在ω_k = 2πk/N处,DTFT与DFT的幅度最大绝对误差: {np.max(np.abs(np.abs(X_dtft_at_dft_points) - np.abs(X_dft))):.2e}")
print(f"在ω_k = 2πk/N处,DTFT与DFT的相位最大绝对误差: {np.max(np.abs(np.angle(X_dtft_at_dft_points) - np.angle(X_dft))):.2e}")
仔细观察生成的图表。红色的DFT采样点(stem图)完美地落在了蓝色的DTFT连续曲线(plot图)上!这直观地证明了我们的理论:对于一个有限长序列,其DFT结果就是其DTFT频谱在 ω = 0, 2π/N, 4π/N, ..., 2π(N-1)/N 这N个点上的采样值。
这个认识极其重要。它意味着:
- DFT提供了DTFT的一个“快照”。我们无法在计算机上存储或计算连续的DTFT函数,但可以通过DFT获取它在N个等间隔频率点上的值。
- 采样密度由N决定。DFT点数
N越大,在[0, 2π)区间内采样的点就越多,我们看到的频谱“细节”就越接近真实的连续DTFT。这就是为什么在做频谱分析时,我们经常通过“零填充”(Zero-Padding)来增加FFT点数,以获得更光滑的频谱显示——这本质上是增加了对DTFT的采样密度,而非提高了真实分辨率。
4. 实战:用声音信号演示频谱泄漏与窗函数
理论学习最终要落到解决实际问题。频谱泄漏是DFT/FFT应用中一个非常实际且影响重大的现象。我们通过一个声音信号的例子来感受它,并学习如何用“窗函数”来缓解。
首先,我们生成一个理想的理论信号:一个纯净的单一频率正弦波。然后,我们用DFT去分析它。但这里有个陷阱:DFT默认假设我们提供的有限长信号是周期信号的一个完整周期。如果截取的长度不是信号周期的整数倍,就会发生频谱泄漏。
# 生成一个单一频率的模拟信号
fs = 1000 # 采样率 1000 Hz
duration = 0.1 # 信号时长 0.1秒
t = np.arange(0, duration, 1/fs) # 时间点
f0 = 123.4 # 信号频率,故意选一个非整数周期
A = 1.0
pure_tone = A * np.sin(2 * np.pi * f0 * t)
# 对信号进行DFT (FFT)
N_original = len(pure_tone)
freqs = np.fft.fftfreq(N_original, 1/fs) # 得到对应的实际频率轴 (Hz)
fft_result = np.fft.fft(pure_tone)
magnitude = np.abs(fft_result) / N_original * 2 # 转换为幅度谱,乘以2恢复单边谱幅度(除直流和奈奎斯特频率)
# 绘制频谱
fig, ax = plt.subplots(2, 2, figsize=(14, 10))
ax[0, 0].plot(t, pure_tone)
ax[0, 0].set_xlabel('时间 [秒]')
ax[0, 0].set_ylabel('幅度')
ax[0, 0].set_title(f'原始信号 (f={f0} Hz)')
ax[0, 0].grid(True, alpha=0.3)
# 绘制线性坐标下的频谱
ax[0, 1].plot(freqs[:N_original//2], magnitude[:N_original//2], 'b-', marker='o', markersize=4)
ax[0, 1].axvline(x=f0, color='r', linestyle='--', alpha=0.7, label=f'真实频率 {f0} Hz')
ax[0, 1].set_xlabel('频率 [Hz]')
ax[0, 1].set_ylabel('幅度')
ax[0, 1].set_title(f'DFT幅度谱 (N={N_original}) - 线性坐标')
ax[0, 1].legend()
ax[0, 1].grid(True, alpha=0.3)
ax[0, 1].set_xlim([0, 200])
# 绘制对数坐标下的频谱,更能看清泄漏的旁瓣
ax[1, 0].plot(freqs[:N_original//2], 20*np.log10(magnitude[:N_original//2] + 1e-10), 'b-')
ax[1, 0].axvline(x=f0, color='r', linestyle='--', alpha=0.7)
ax[1, 0].set_xlabel('频率 [Hz]')
ax[1, 0].set_ylabel('幅度 [dB]')
ax[1, 0].set_title(f'DFT幅度谱 (N={N_original}) - 对数坐标 (频谱泄漏明显)')
ax[1, 0].grid(True, alpha=0.3)
ax[1, 0].set_xlim([0, 200])
ax[1, 0].set_ylim([-100, 0])
# 为了对比,我们生成一个周期完整的信号(频率是采样时长的整数倍)
# 计算最接近f0的整数周期频率
T_window = duration
f_bin = 1 / T_window # DFT的频率分辨率,这里是 10 Hz
k0 = round(f0 / f_bin) # 最近的DFT bin索引
f0_aligned = k0 * f_bin # 对齐到DFT bin上的频率
print(f"原始频率: {f0:.2f} Hz")
print(f"DFT频率分辨率: {f_bin:.2f} Hz")
print(f"最近的DFT bin索引: {k0}")
print(f"对齐后的频率: {f0_aligned:.2f} Hz")
pure_tone_aligned = A * np.sin(2 * np.pi * f0_aligned * t)
fft_result_aligned = np.fft.fft(pure_tone_aligned)
magnitude_aligned = np.abs(fft_result_aligned) / N_original * 2
ax[1, 1].plot(freqs[:N_original//2], 20*np.log10(magnitude_aligned[:N_original//2] + 1e-10), 'g-')
ax[1, 1].axvline(x=f0_aligned, color='r', linestyle='--', alpha=0.7, label=f'对齐频率 {f0_aligned:.1f} Hz')
ax[1, 1].set_xlabel('频率 [Hz]')
ax[1, 1].set_ylabel('幅度 [dB]')
ax[1, 1].set_title(f'DFT幅度谱 - 频率对齐到DFT bin (无泄漏理想情况)')
ax[1, 1].legend()
ax[1, 1].grid(True, alpha=0.3)
ax[1, 1].set_xlim([0, 200])
ax[1, 1].set_ylim([-100, 0])
plt.tight_layout()
plt.show()
观察左上角的时域信号和右上角的频谱。你会发现,即使信号只有一个频率(123.4 Hz),其DFT频谱(蓝色线)也并非只在123.4 Hz处有一根谱线,而是在周围出现了许多非零的谱线,能量“泄漏”到了其他频率上。这就是频谱泄漏。相比之下,右下角的图显示,当信号频率恰好是DFT频率分辨率的整数倍时(即信号在截取窗口内是完整的周期),能量完美地集中在单个频率点上,没有泄漏。
频谱泄漏的根源在于时域信号的非周期截断。DFT隐含的周期性假设与实际的信号段不匹配,导致在边界处出现不连续(跳变),这种时域的不连续在频域就表现为能量扩散。
如何减轻泄漏?一个常见的方法是使用窗函数。窗函数在时域对信号两端进行平滑衰减,减少边界跳变。
# 应用汉宁窗 (Hanning Window) 来减轻频谱泄漏
window_hanning = np.hanning(N_original)
windowed_signal = pure_tone * window_hanning
# 计算加窗后信号的FFT
fft_result_windowed = np.fft.fft(windowed_signal)
# 注意:加窗后信号总能量改变,需要根据窗函数的相干增益进行幅度补偿
coherent_gain = np.mean(window_hanning) # 汉宁窗的相干增益约为0.5
magnitude_windowed = np.abs(fft_result_windowed) / (N_original * coherent_gain) * 2
fig, ax = plt.subplots(1, 2, figsize=(14, 5))
# 时域加窗效果
ax[0].plot(t, pure_tone, 'b-', alpha=0.5, label='原始信号')
ax[0].plot(t, windowed_signal, 'r-', label='加汉宁窗后信号')
ax[0].plot(t, window_hanning, 'g--', alpha=0.7, label='汉宁窗')
ax[0].set_xlabel('时间 [秒]')
ax[0].set_ylabel('幅度')
ax[0].set_title('时域加窗效果')
ax[0].legend()
ax[0].grid(True, alpha=0.3)
# 频域对比 (对数坐标)
ax[1].plot(freqs[:N_original//2], 20*np.log10(magnitude[:N_original//2] + 1e-10), 'b-', alpha=0.7, label='原始信号频谱')
ax[1].plot(freqs[:N_original//2], 20*np.log10(magnitude_windowed[:N_original//2] + 1e-10), 'r-', label='加窗后频谱')
ax[1].axvline(x=f0, color='k', linestyle='--', alpha=0.5, label=f'真实频率 {f0} Hz')
ax[1].set_xlabel('频率 [Hz]')
ax[1].set_ylabel('幅度 [dB]')
ax[1].set_title('频谱泄漏抑制效果对比 (对数坐标)')
ax[1].legend()
ax[1].grid(True, alpha=0.3)
ax[1].set_xlim([50, 200])
ax[1].set_ylim([-120, 0])
plt.tight_layout()
plt.show()
print("加窗后频谱观察:")
print("- 主瓣宽度增加:加窗使得主峰变宽,频率分辨率略有下降。")
print("- 旁瓣显著降低:泄漏到其他频率的能量大大减少。")
print("- 这是一种权衡:用主瓣宽度(分辨率)换取旁瓣抑制(泄漏减少)。")
通过对比,你可以清晰地看到加窗后(红色曲线),频谱的旁瓣(主峰两侧的小峰)被显著压制了,虽然主峰本身变宽了一些。这就是窗函数在频谱分析中的核心作用:抑制频谱泄漏,提高频谱估计的动态范围,代价是牺牲了一点频率分辨率。在实际的音频分析、振动分析、通信系统等领域,根据不同的需求(如需要精确测量频率幅值,还是需要区分两个很近的频率),会选择不同的窗函数,如汉宁窗、汉明窗、布莱克曼窗等。
5. 从DFT到FFT:效率飞跃的直观感受
我们最后来谈谈FFT。FFT不是一种新的变换,它只是计算DFT的一种极其高效的算法。手动实现的DFT复杂度是 O(N²),而FFT(如最常见的Cooley-Tukey算法)将其降低到了 O(N log₂ N)。当N很大时,这种效率提升是惊人的。
虽然我们不需要手动实现FFT(直接使用np.fft.fft即可),但可以通过一个简单的实验来感受这种速度差异。
import time
def compare_dft_fft_speed():
"""比较手动DFT和NumPy FFT的计算时间"""
sizes = [64, 128, 256, 512, 1024, 2048]
times_dft = []
times_fft = []
for N in sizes:
# 生成随机测试信号
x_test = np.random.randn(N) + 1j * np.random.randn(N) # 复数信号
# 计时手动DFT (仅用于演示,对于大N会非常慢)
if N <= 512: # 手动DFT太慢,只测到512
start = time.perf_counter()
_ = manual_dft(x_test.real) # 我们只取实部测试,简化计算
end = time.perf_counter()
times_dft.append(end - start)
else:
times_dft.append(np.nan) # 对于更大的N,标记为NaN
# 计时NumPy FFT
start = time.perf_counter()
_ = np.fft.fft(x_test)
end = time.perf_counter()
times_fft.append(end - start)
print(f"N={N:4d} | 手动DFT: {times_dft[-1]:.6f} s (if measured) | NumPy FFT: {times_fft[-1]:.6f} s")
# 绘制对比图
fig, ax = plt.subplots(figsize=(10, 6))
valid_idx = ~np.isnan(times_dft)
ax.plot(np.array(sizes)[valid_idx], np.array(times_dft)[valid_idx], 'ro-', linewidth=2, markersize=8, label='手动DFT O(N²)')
ax.plot(sizes, times_fft, 'bs-', linewidth=2, markersize=8, label='NumPy FFT O(N log N)')
ax.set_xlabel('变换点数 N')
ax.set_ylabel('计算时间 [秒]')
ax.set_title('DFT与FFT计算效率对比 (对数坐标)')
ax.set_xscale('log')
ax.set_yscale('log')
ax.grid(True, which='both', alpha=0.3)
ax.legend()
plt.tight_layout()
plt.show()
# 创建一个表格来展示具体数据
print("\n计算时间对比表:")
print("-" * 60)
print(f"{'N':>6} | {'手动DFT (s)':>12} | {'NumPy FFT (s)':>12} | {'速度比 (DFT/FFT)':>15}")
print("-" * 60)
for i, N in enumerate(sizes):
dft_t = times_dft[i]
fft_t = times_fft[i]
if not np.isnan(dft_t):
ratio = dft_t / fft_t
print(f"{N:6d} | {dft_t:12.6f} | {fft_t:12.6f} | {ratio:15.1f}x")
else:
print(f"{N:6d} | {'(too slow)':>12} | {fft_t:12.6f} | {'N/A':>15}")
print("-" * 60)
# 运行比较
compare_dft_fft_speed()
运行这段代码,你会看到一张对数坐标图,清晰地展示出随着N增大,手动DFT的计算时间呈平方级增长(红色线),而FFT的时间增长则缓慢得多(蓝色线)。当N=1024或2048时,手动DFT已经慢到不切实际,而FFT依然瞬间完成。这个实验让我们直观地理解为什么FFT是数字信号处理领域的基石算法——没有它,实时音频处理、图像压缩、无线通信等现代技术都将难以实现。
注意:在实际项目中,你永远应该使用像
np.fft.fft、scipy.fftpack或pyfftw这样经过高度优化的FFT库。自己实现FFT算法更多是出于教学和理解的目的。
走到这里,我们已经完成了一个完整的循环:从DTFT的连续频谱理论,到DFT的离散采样实现,再到用FFT进行高效计算,最后用实际的声音信号案例看到了理论在现实问题(频谱泄漏)中的应用及解决方案(加窗)。希望这些代码和图表能像一幅清晰的地图,帮你建立起对DTFT和DFT之间关系的牢固直觉。下次当你调用 np.fft.fft 时,脑海中浮现的不再是冰冷的公式,而是一幅由连续曲线和其上的采样点构成的生动画面,以及背后关于分辨率、泄漏和效率的权衡智慧。
更多推荐



所有评论(0)