数字信号处理:数字域(时域)与频域的关系与作用——从傅里叶变换到 FFT 的完整推演

摘要 :本文系统梳理数字信号处理中"数字域(时域)"与"频域"两种信号表示之间的数学关系与工程作用。核心结论先行:时域与频域是同一信号在两组不同完备基下的等价展开,二者通过傅里叶变换对构成一一映射,且时域卷积对应频域相乘。围绕这一主线,本文依次推导采样定理、傅里叶家族(FS/CTFT/DTFT/DFT)的对偶结构、DFT 的矩阵形式与 FFT 的分治加速,给出 7 组可复现的 Python 图示与完整 demo 源码,并归纳混叠、频谱泄漏、补零误区、循环卷积陷阱等 8 个工程踩坑点。全部代码基于 NumPy,误差验证达到机器精度(1.3e-15)。


一、介绍:为什么一个信号需要两个"域"

任何工程问题的第一步都是回答:这个信号里有什么?

  • 一个麦克风采集的音频,想知道里面有没有 50 Hz 的工频干扰------这是"频率里有什么"的问题;
  • 一段心电信号,想提取 QRS 波的形态------这是"波形长什么样"的问题;
  • 一个通信接收机,想把不同载波上的多路信号分开------这是"把频率分量分离"的问题。

前者的自然语言是时域(数字域) :以时间 ttt(或采样序号 nnn)为自变量,描述信号幅度随时间的演化 x(t)x(t)x(t);后者的自然语言是频域 :以频率 fff(或角频率 ω\omegaω)为自变量,描述信号在各频率上的复幅度 X(f)X(f)X(f)。

关键事实是:这两个描述所携带的信息量严格相等, neither more nor less。傅里叶变换只是把同一个向量在两组正交基之间做坐标旋转------时域基是不同时移的脉冲,频域基是不同频率的复指数。既然是同一信号的两种坐标表示,就必然存在可逆的双射映射,也就必然存在"一个域做运算、另一个域看效果"的对偶规律。本文的全部内容,都可以归结为下面这张对偶表:

时域操作 频域效果 数学表述
卷积(滤波) 相乘 y=x∗h⇔Y(f)=X(f)H(f)y = x * h \Leftrightarrow Y(f) = X(f)H(f)y=x∗h⇔Y(f)=X(f)H(f)
相乘(调制/加窗) 卷积 y=x⋅w⇔Y(f)=X(f)∗W(f)y = x \cdot w \Leftrightarrow Y(f) = X(f) * W(f)y=x⋅w⇔Y(f)=X(f)∗W(f)
时移 相位线性变化 x(n−n0)⇔X(f)e−j2πfn0x(n-n_0) \Leftrightarrow X(f)e^{-j2\pi f n_0}x(n−n0)⇔X(f)e−j2πfn0
频移(调制) 时域乘复指数 x(n)ej2πfcn⇔X(f−fc)x(n)e^{j2\pi f_c n} \Leftrightarrow X(f - f_c)x(n)ej2πfcn⇔X(f−fc)
时域采样 频域周期化 采样周期 TsT_sTs → 谱以 fs=1/Tsf_s = 1/T_sfs=1/Ts 为周期重复
时域截断(加窗) 频域卷积窗谱 频谱"变毛"------泄漏
微分/差分 乘 j2πfj2\pi fj2πf dx/dt⇔j2πfX(f)dx/dt \Leftrightarrow j2\pi f X(f)dx/dt⇔j2πfX(f)
积分/累加 除 j2πfj2\pi fj2πf ∫x⇔X(f)/(j2πf)\int x \Leftrightarrow X(f)/(j2\pi f)∫x⇔X(f)/(j2πf)

这张表是信号处理的"元素周期表"。滤波器设计、调制解调、频谱分析、采样率转换、图像压缩......全部是表中某几行的组合应用。

为什么工程师偏爱频域? 因为线性时不变(LTI)系统对复指数信号 ej2πfte^{j2\pi ft}ej2πft 的响应恰好还是同频率的复指数,只是幅度和相位被缩放------即复指数是 LTI 系统的特征函数 ,特征值就是 H(f)H(f)H(f)(系统频率响应)。而 LTI 系统在时域的作用是卷积,卷积是个 O(N⋅M)O(N \cdot M)O(N⋅M) 的滑窗求和运算。于是一个划时代的思路出现了:把信号变换到频域,卷积变成逐点相乘 O(N)O(N)O(N),再变换回时域。这就是"频域是 LTI 系统的特征空间"的含义,也是 FFT 被评为 20 世纪十大算法之首的根本原因。


二、原理:从连续到离散,两个域如何一步步"互相塑造"

2.1 信息等价性:变换是坐标旋转,不是信息增删

设信号 xxx 属于 NNN 维空间,傅里叶矩阵 FFF 是可逆的,则

X=Fx,x=1NFHXX = Fx, \qquad x = \frac{1}{N}F^{H}XX=Fx,x=N1FHX

NNN 个时域样点 ⟷ NNN 个频域样点,自由度守恒。所谓"时域看不出、频域看得清",不是频域信息更多,而是不同基下,信号的能量分布形态不同 :一个由少数频率成分合成的信号,在时域基下的表示是"纠缠"的(每个时刻的值都是所有成分叠加),在频域基下的表示是"稀疏"的(能量集中在少数几个 bin 上)。变换域分析的本质是寻找让信号变稀疏的坐标系------这一思想直接通向压缩感知与小波分析。

2.2 采样定理:时域离散化,频域必然周期化

数字系统只能处理离散序列,第一步是把连续信号 xc(t)x_c(t)xc(t) 以周期 TsT_sTs 采样为 xn=xc(nTs)xn = x_c(nT_s)xn=xc(nTs)。采样在数学上等价于 xc(t)x_c(t)xc(t) 乘以冲激串:

xs(t)=xc(t)∑n=−∞∞δ(t−nTs)x_s(t) = x_c(t)\sum_{n=-\infty}^{\infty}\delta(t - nT_s)xs(t)=xc(t)n=−∞∑∞δ(t−nTs)

时域相乘 ⟷ 频域卷积(对偶表第 2 行)。冲激串的频谱是以 fs=1/Tsf_s = 1/T_sfs=1/Ts 为间隔的冲激串,卷积的结果是:

Xs(f)=1Ts∑k=−∞∞Xc ⁣(f−kfs)X_s(f) = \frac{1}{T_s}\sum_{k=-\infty}^{\infty} X_c\!\left(f - k f_s\right)Xs(f)=Ts1k=−∞∑∞Xc(f−kfs)

即采样后的频谱是原始频谱以 fsf_sfs 为周期无限复制叠加。 这一结论是数字信号处理的立身之本,两个直接推论:

  1. 只要 Xc(f)X_c(f)Xc(f) 被限制在 ∣f∣<fs/2|f| < f_s/2∣f∣<fs/2 内,各份复制谱互不重叠,可以用一个理想低通完整恢复原信号 ------这就是奈奎斯特采样定理 fs≥2fmax⁡f_s \ge 2f_{\max}fs≥2fmax;
  2. 若 Xc(f)X_c(f)Xc(f) 存在超过 fs/2f_s/2fs/2 的成分,复制谱会重叠,高频成分折返到低频位置------这就是混叠(aliasing) ,且混叠发生后的信息不可恢复。

B=fs/2B = f_s/2B=fs/2 被称为奈奎斯特频率或折叠频率,它是数字系统能表达的频率上限。混叠的"表观频率"满足折叠公式:

falias=∣f0−round(f0/fs)⋅fs∣f_{alias} = |f_0 - \mathrm{round}(f_0/f_s)\cdot f_s|falias=∣f0−round(f0/fs)⋅fs∣

2.3 傅里叶家族:四种变换与时频对偶律

"时域的离散性对应频域的周期性,时域的周期性对应频域的离散性"------这条对偶律把傅里叶家族四个成员串成一张网:

变换 时域形态 频域形态 适用对象
傅里叶级数 FS 连续、周期 TTT 离散、非周期(谐波间隔 1/T1/T1/T) 周期模拟信号
傅里叶变换 CTFT 连续、非周期 连续、非周期 一般模拟信号
DTFT 离散、非周期 连续、周期 2π2\pi2π 一般数字序列
DFT/DFS 离散、周期 NNN 离散、周期 NNN 计算机唯一能算的

推导链条一句话概括:时域每离散化一次,频域就周期化一次;时域每周期化一次,频域就离散化一次。 DFT 正是 DTFT 在频域上再做 NNN 点采样------因为计算机既不能存储连续量,也不能计算无限和,所以一切实际频谱分析最终都落在 DFT 上。FFT 不是第五种变换,而是 DFT 的 O(Nlog⁡N)O(N\log N)O(NlogN) 快速算法。

2.4 卷积定理:连接两个域的核心桥梁

LTI 系统的输出是输入与冲激响应的卷积 yn=∑mxmhn−myn = \sum_m xmhn-myn=∑mxmhn−m。把 DTFT 代入可直接证明:

Y(ejω)=X(ejω) H(ejω)Y(e^{j\omega}) = X(e^{j\omega})\,H(e^{j\omega})Y(ejω)=X(ejω)H(ejω)

这个定理的工程意义怎么强调都不过分:

  1. 滤波器设计的对象从"波形"变成"频响曲线" :设计一个低通滤波器,就是让 H(f)H(f)H(f) 在通带接近 1、阻带接近 0,而无需关心 hnhnhn 长什么样;
  2. 复杂度换轨 :直接卷积 O(N⋅M)O(N \cdot M)O(N⋅M),频域路径两次 FFT + 一次相乘为 O(Nlog⁡N)O(N\log N)O(NlogN)。当 hhh 较长时(如混响、长 FIR),频域实现快一到两个数量级;
  3. 卷积定理反过来读就是调制定理:时域乘窗 ⟷ 频域卷窗谱,这是理解频谱泄漏的钥匙。

2.5 频率分辨率:由"看了多久"决定,而非"算多少点"决定

DFT 第 kkk 个 bin 对应的物理频率是

fk=kfsN,Δf=fsN=1Tobsf_k = \frac{k f_s}{N}, \qquad \Delta f = \frac{f_s}{N} = \frac{1}{T_{obs}}fk=Nkfs,Δf=Nfs=Tobs1

其中 Tobs=NTsT_{obs} = N T_sTobs=NTs 是实际观测时长 。分辨率 Δf\Delta fΔf 的倒数就是观测时长------这是信息论层面的硬约束:想分辨相距 1 Hz 的两个音,至少要听 1 秒。补零不能提高分辨率,只能对已有 DTFT 做更密的插值(后文图 6 与踩坑点 3 详述)。


三、公式:从傅里叶级数到 FFT 的完整推导链

3.1 傅里叶级数 → 连续傅里叶变换

周期为 TTT 的信号展开为谐波(基频 f1=1/Tf_1 = 1/Tf1=1/T):

x(t)=∑k=−∞∞ck ej2πkf1t,ck=1T∫−T/2T/2x(t) e−j2πkf1t dtx(t) = \sum_{k=-\infty}^{\infty} c_k\, e^{j2\pi k f_1 t}, \qquad c_k = \frac{1}{T}\int_{-T/2}^{T/2} x(t)\,e^{-j2\pi k f_1 t}\,dtx(t)=k=−∞∑∞ckej2πkf1t,ck=T1∫−T/2T/2x(t)e−j2πkf1tdt

令周期 T→∞T \to \inftyT→∞,谱线间隔 f1→dff_1 \to dff1→df,离散系数 ck→c_k \tock→ 连续谱密度,即得 CTFT 正逆变换对:

X(f)=∫−∞∞x(t) e−j2πft dt,x(t)=∫−∞∞X(f) ej2πft dfX(f) = \int_{-\infty}^{\infty} x(t)\,e^{-j2\pi f t}\,dt, \qquad x(t) = \int_{-\infty}^{\infty} X(f)\,e^{j2\pi f t}\,dfX(f)=∫−∞∞x(t)e−j2πftdt,x(t)=∫−∞∞X(f)ej2πftdf

3.2 DTFT 与 DFT

对离散序列 xnxnxn,把 CTFT 中的积分换成求和、ttt 换成 nnn:

X(ejω)=∑n=−∞∞xn e−jωnX(e^{j\omega}) = \sum_{n=-\infty}^{\infty} xn\,e^{-j\omega n}X(ejω)=n=−∞∑∞xne−jωn

X(ejω)X(e^{j\omega})X(ejω) 以 2π2\pi2π 为周期(因为 nnn 为整数时 e−j(ω+2π)n=e−jωne^{-j(\omega+2\pi)n} = e^{-j\omega n}e−j(ω+2π)n=e−jωn)------这就是"时域离散 ⟷ 频域周期"的数学来源 。DTFT 的频率变量连续,仍不可算。对其在一个周期内做 NNN 点均匀采样 ωk=2πk/N\omega_k = 2\pi k/Nωk=2πk/N,得到 DFT:

Xk=∑n=0N−1xn e−j2πNkn,k=0,1,...,N−1Xk = \sum_{n=0}^{N-1} xn\, e^{-j\frac{2\pi}{N}kn}, \qquad k = 0,1,\dots,N-1Xk=n=0∑N−1xne−jN2πkn,k=0,1,...,N−1

xn=1N∑k=0N−1Xk ej2πNkn,n=0,1,...,N−1xn = \frac{1}{N}\sum_{k=0}^{N-1} Xk\, e^{j\frac{2\pi}{N}kn}, \qquad n = 0,1,\dots,N-1xn=N1k=0∑N−1XkejN2πkn,n=0,1,...,N−1

其中旋转因子 WN=e−j2π/NW_N = e^{-j2\pi/N}WN=e−j2π/N。注意 DFT 隐含假设 xnxnxn 以 NNN 为周期------理解这一点是避开循环卷积陷阱(踩坑点 6)的前提。

3.3 DFT 的矩阵形式与 FFT 的分治

DFT 写成矩阵:

X=Wx,Wkn=e−j2πkn/N\mathbf{X} = \mathbf{W}\mathbf{x}, \quad W_{kn} = e^{-j2\pi kn/N}X=Wx,Wkn=e−j2πkn/N

直接计算需 N2N^2N2 次复数乘法。FFT 的核心观察:按 nnn 的奇偶把序列拆成两半,利用 WN2kn=WN/2knW_N^{2kn} = W_{N/2}^{kn}WN2kn=WN/2kn,可得

Xk=Ek+WNk Ok,Xk+N/2=Ek−WNk OkXk = Ek + W_N^k\,Ok, \qquad Xk + N/2 = Ek - W_N^k\,OkXk=Ek+WNkOk,Xk+N/2=Ek−WNkOk

其中 EkEkEk、OkOkOk 分别是偶/奇子序列的 N/2N/2N/2 点 DFT。一个 NNN 点 DFT 被拆成两个 N/2N/2N/2 点 DFT 加 NNN 个蝶形运算,递归展开:

C(N)=2C(N/2)+O(N)  ⇒  C(N)=O(Nlog⁡2N)C(N) = 2C(N/2) + O(N) \;\Rightarrow\; C(N) = O(N\log_2 N)C(N)=2C(N/2)+O(N)⇒C(N)=O(Nlog2N)

N=220≈106N = 2^{20} \approx 10^6N=220≈106 时,N2=1012N^2 = 10^{12}N2=1012 vs Nlog⁡2N≈2×107N\log_2 N \approx 2\times 10^7Nlog2N≈2×107,加速约 5 万倍------这就是"蝴蝶扇一下,算力省一半"的规模。

3.4 卷积定理的证明

对 yn=∑mxmhn−myn = \sum_m xmhn-myn=∑mxmhn−m 两边做 DTFT,交换求和顺序并令 l=n−ml = n - ml=n−m:

Y(ejω)=∑n∑mxmhn−me−jωn=∑mxme−jωm∑lhle−jωl⏟H(ejω)=X(ejω)H(ejω)■Y(e^{j\omega}) = \sum_n \sum_m xmhn-me^{-j\omega n} = \sum_m xme^{-j\omega m}\underbrace{\sum_{l} hle^{-j\omega l}}_{H(e^{j\omega})} = X(e^{j\omega})H(e^{j\omega}) \qquad \blacksquareY(ejω)=n∑m∑xmhn−me−jωn=m∑xme−jωmH(ejω) l∑hle−jωl=X(ejω)H(ejω)■

3.5 帕塞瓦尔定理:能量守恒的时频对偶

∑n=0N−1∣xn∣2=1N∑k=0N−1∣Xk∣2\sum_{n=0}^{N-1} |xn|^2 = \frac{1}{N}\sum_{k=0}^{N-1}|Xk|^2n=0∑N−1∣xn∣2=N1k=0∑N−1∣Xk∣2

时域总能量等于频域各 bin 能量之和(差一个归一化因子)。这是"频谱能量分析"合法性的根基------功率谱、频段能量占比、音频均衡器的增益计算,全部建立在这条守恒律上。

3.6 幅度还原公式(工程上最常用)

单频实正弦 Asin⁡(2πf0n/fs)A\sin(2\pi f_0 n/f_s)Asin(2πf0n/fs) 的 NNN 点 FFT 会在 k0=f0N/fsk_0 = f_0 N/f_sk0=f0N/fs 和 N−k0N - k_0N−k0 两处出现幅度为 AN/2AN/2AN/2 的谱线(能量分居正负频率)。因此单边谱幅度还原公式为:

Ak=2∣Xk∣N,1≤k<N/2;A0=∣X0∣N,AN/2=∣XN/2∣NAk = \frac{2|Xk|}{N}, \quad 1 \le k < N/2; \qquad A0 = \frac{|X0|}{N}, \quad AN/2 = \frac{|XN/2|}{N}Ak=N2∣Xk∣,1≤k<N/2;A0=N∣X0∣,AN/2=N∣XN/2∣

直流域与折叠点不乘 2------这是无数人 FFT 幅度对不上时的最后一个没检查的地方(踩坑点 4/5)。


四、图示:7 张图看懂两个域

以下图示全部由本文附带的 make_figures.py 生成,参数与代码完全可复现。

图 1:同一信号的两种坐标表示------频域把"看不见"变成"看得见"

构造信号:x=1.0sin⁡(2π⋅50t)+0.35sin⁡(2π⋅120t)+0.5 noisex = 1.0\sin(2\pi\cdot 50t) + 0.35\sin(2\pi\cdot 120t) + 0.5\,\text{noise}x=1.0sin(2π⋅50t)+0.35sin(2π⋅120t)+0.5noise。上图中 120 Hz 成分(幅度 0.35)被均值为 0、标准差 0.5 的高斯噪声淹没,时域波形上肉眼完全无法确认它的存在;下图做 FFT 后,50 Hz 与 120 Hz 两根谱线以正确的幅度(1.0 与 0.35)直接立起来。这不是"频域信息更多",而是频域基让这段信号变得稀疏------噪声被摊到所有 bin 上,每个 bin 只分到一点点,而正弦成分的能量全部集中在自己那一个 bin。

图 2:采样与混叠------欠采样时高频会"冒充"低频

对 3 Hz 正弦分别用 12 Hz、6 Hz、2.4 Hz 采样。左:fs=4f0f_s = 4f_0fs=4f0,采样点足够密,线性插值即可还原;中:fs=2f0f_s = 2f_0fs=2f0 临界情形,采样点恰好在过零点附近,稍微偏一点整个相位就翻转,工程上绝不允许贴着定理下限设计;右:fs=2.4f0f_s = 2.4f_0fs=2.4f0 欠采样,3 Hz 的信号在采样点眼中与 0.6 Hz 的信号完全无法区分------红虚线穿过每一个蓝点。混叠不是误差,是信息的永久丢失。

图 3:傅里叶家族关系图------一条对偶律贯穿四种变换

横轴方向是"时域从周期到非周期"(频域从离散到连续),纵轴方向是"时域从连续到离散"(频域从非周期到周期)。DFT 位于图的底部:它是对 DTFT 的频域再采样,是唯一两端都离散、计算机可计算的形态。记住"时域动一下,频域跟着动"这条对偶律,四种变换不用背。

图 4:频谱泄漏与窗函数------加窗是频域的"卷积"副作用

信号是 10.5 Hz 正弦,fs=64f_s = 64fs=64、N=64N = 64N=64,bin 宽 1 Hz------10.5 Hz 恰好落在两个 bin 正中间。截断(等效于时域乘矩形窗)使频谱与矩形窗的 sinc 谱卷积,能量从主瓣甩进旁瓣,污染 ±几十 Hz 的频带。三张子图的对比揭示了窗函数设计的核心权衡:矩形窗主瓣最窄(±0.5 bin)但旁瓣 -13 dB;Hann 窗旁瓣 -31 dB、主瓣展宽到 ±1 bin;Blackman 窗旁瓣 -58 dB、主瓣 ±1.5 bin。主瓣宽度决定"分辨两个相近频率"的能力,旁瓣高度决定"强信号掩盖弱信号"的程度。

图 5:卷积定理实证------时域卷积与频域相乘逐点相等

设计一个 63 阶窗函数法 FIR 低通(截止 20 Hz),分别用 np.convolve(时域,O(NM)O(NM)O(NM))和"FFT 相乘 + IFFT"(频域,O(Nlog⁡N)O(N\log N)O(NlogN))对同一信号滤波。两条曲线在图上完全重合,标题给出定量证据:补零到 L=N+M−1L = N + M - 1L=N+M−1 后,两种途径的逐点最大偏差为 1.33e-15------恰好是双精度浮点的机器精度。右下角频谱对比显示 40 Hz 干扰被压制约 40 dB,8 Hz 有用信号完好保留。

图 6:补零 ≠ 提高分辨率

两个频率 10.0 Hz 与 11.5 Hz、fs=100f_s = 100fs=100。左:64 点采样,Δf=1.5625\Delta f = 1.5625Δf=1.5625 Hz,两峰挤在同一 bin 里,看起来只有一个频率;中:补零到 512 点,谱线变密成光滑曲线,但主瓣轮廓与左图完全一致,仍是单峰 ------补零只是对 DTFT 插值,没有注入任何新信息;右:真实采样 512 点(观测时长 5.12 s),Δf=0.195\Delta f = 0.195Δf=0.195 Hz,两峰被干净分开。分辨两个频率需要的是更长的观测时间,不是更多的补零点数。

图 7:吉布斯现象------有限带宽逼近不连续信号的宿命

用前 KKK 个奇次谐波重构方波。随 KKK 增大,波形整体越来越方,但跳变点两侧始终残留约 9% 的过冲 ,且过冲峰宽度随 KKK 收窄、峰值不降。数学上可证过冲比例收敛到 1π∫0πsin⁡ttdt−12≈8.95%\frac{1}{\pi}\int_0^{\pi}\frac{\sin t}{t}dt - \frac{1}{2} \approx 8.95\%π1∫0πtsintdt−21≈8.95%。这是理想低通处理阶跃边界的固有代价------为什么音频系统要用软限幅、为什么 FIR 设计要加窗抑制理想 sinc 的振铃,根源都在这里。


五、踩坑点:8 个最常见的时频域陷阱

坑 1:先采样、后滤波------抗混叠滤波器放错位置

混叠发生在采样瞬间,之后任何数字滤波都救不回来。抗混叠模拟滤波器(或过采样架构)必须放在 ADC 之前 。工程惯例:fsf_sfs 取信号带宽的 2.56~10 倍,给抗混叠滤波器的过渡带留出空间;或者用过采样 + 数字抽取,把模拟滤波器要求降到极低(Σ-Δ ADC 的思路)。

坑 2:把频谱泄漏当成"噪声"去滤

泄漏不是噪声,是截断与窗谱卷积的确定性副作用 。它有两个典型症状:谱线底部出现 sinc 形"裙边";强信号的旁瓣把邻近弱信号埋掉。对症下药是换窗(图 4),而不是加大 N 或去"平滑"频谱。若信号可以整周期截断(相干采样,f0=m⋅fs/Nf_0 = m \cdot f_s/Nf0=m⋅fs/N),矩形窗反而最优------选窗前先判断信号是否相干。

坑 3:用补零"提高分辨率"

见前文图 6。分辨力由 Tobs=N/fsT_{obs} = N/f_sTobs=N/fs 决定,补零改变的只是频域采样密度。需要真实的高分辨率只有两条路:采更久,或改用参数化谱估计(MUSIC、ESPRIT)在短数据下外推。顺带一提,补零到 2 的幂次让 FFT 更快是有价值的,但动机是计算效率,不是分辨率。

坑 4:幅度还原漏掉直流与折叠点

单边谱乘 2 的公式对 k=0k=0k=0(直流)和 k=N/2k=N/2k=N/2(NNN 为偶数时的折叠点)不适用,这两处没有"正负频率对",不乘 2。漏乘的结果是直流分量差一倍。

坑 5:实信号频谱的共轭对称性没利用

实信号的 DFT 满足 XN−k=X∗kXN-k = X^*kXN−k=X∗k,负频率半边完全冗余。工程上用 rfft 直接输出 N/2+1N/2+1N/2+1 个点,计算量减半。反向用 irfft 而不是对共轭拼接后取 ifft,否则容易因浮点误差残留微小虚部。

坑 6:频域滤波的循环卷积陷阱

DFT 隐含周期延拓假设,X⋅HX \cdot HX⋅H 对应的是循环卷积 。若 hhh 长度为 MMM,直接在原长 NNN 上做频域相乘,输出尾部会"绕回"污染头部。正确做法:补零到 L≥N+M−1L \ge N + M - 1L≥N+M−1 后再相乘(本文图 5 正是这么做的)。流式处理用重叠保留/重叠相加法分段。这是"FFT 滤波结果头尾不对"这一经典问题的唯一根因。

坑 7:fftshift 与频率轴对不上

np.fft.fftfreq(N, 1/fs) 返回的频率轴与 np.fft.fft 输出逐点对应(正频率在前、负频率在后)。画双边谱时必须 np.fft.fftshift 同时 移动谱和频率轴,只 shift 一边就会出现"谱对了轴错了"。单边谱用 rfftfreq 则无此问题。

坑 8:对非平稳信号硬套整段 FFT

整段 FFT 假设信号在观测窗内频率成分不变。语音、振动、通信突发信号直接做整段 FFT 会把时变信息抹平。正确工具是短时傅里叶变换 STFT(滑动窗 + 逐帧 FFT + 谱图),它揭示的是"频率成分随时间如何变化"。STFT 的窗长选择受不确定性原理约束:Δt⋅Δf≥14π\Delta t \cdot \Delta f \ge \frac{1}{4\pi}Δt⋅Δf≥4π1------时频分辨率不可兼得,这是海森堡不等式在信号处理中的化身。


六、源码拆解:从手写 DFT 到工程级 FFT 滤波

以下所有代码可直接保存为 .py 运行,依赖仅 numpy 与 matplotlib。

6.1 Demo 1:手写 DFT,验证与 NumPy 逐位一致

先实现最朴素的 O(N2)O(N^2)O(N2) DFT------不是为了性能,而是为了把公式 3.2 变成可触摸的代码:

python 复制代码
import numpy as np

def naive_dft(x):
    """O(N^2) 离散傅里叶变换,直接对应公式:
       X[k] = sum_n x[n] * exp(-j*2*pi*k*n/N)
    """
    N = len(x)
    n = np.arange(N)
    k = n.reshape((N, 1))                # k 变成列向量,构造 N x N 网格
    # W_N^{kn} 矩阵:一次算出全部旋转因子
    W = np.exp(-2j * np.pi * k * n / N)
    return W @ x                          # 矩阵乘法即求和

# ---- 验证:与 np.fft.fft 对比 ----
rng = np.random.default_rng(0)
x = rng.standard_normal(256) + 1j * rng.standard_normal(256)
err = np.max(np.abs(naive_dft(x) - np.fft.fft(x)))
print(f"DFT vs FFT 逐点最大偏差: {err:.3e}")   # 典型输出 ~1e-12(浮点求和顺序不同)

要点 :矩阵 WWW 就是 3.3 节的傅里叶矩阵------DFT 在数学上就是一次矩阵乘法。这段代码同时解释了为什么 DFT 慢:N2N^2N2 个旋转因子各乘一次。顺带演示了 NumPy 广播的经典技巧:n.reshape((N,1)) 把行向量变列向量,外积构造网格。

6.2 Demo 2:手写 radix-2 FFT,实测加速比

python 复制代码
def fft_recursive(x):
    """radix-2 按时间抽取 FFT(教学版,要求 N 为 2 的幂)
       递推关系:
       X[k]      = E[k] + W^k * O[k]
       X[k+N/2]  = E[k] - W^k * O[k]
    """
    N = len(x)
    if N <= 1:
        return x.copy()
    assert N % 2 == 0, "教学版要求 N 为 2 的幂"
    E = fft_recursive(x[0::2])            # 偶数下标子序列的 N/2 点 DFT
    O = fft_recursive(x[1::2])            # 奇数下标子序列的 N/2 点 DFT
    k = np.arange(N // 2)
    Wk = np.exp(-2j * np.pi * k / N)      # 旋转因子
    # 蝶形:一次乘法同时产生 X[k] 与 X[k+N/2]
    return np.concatenate([E + Wk * O, E - Wk * O])

import time
for N in [2**10, 2**14]:
    x = np.random.default_rng(1).standard_normal(N)
    t0 = time.perf_counter(); [naive_dft(x) for _ in range(3)]
    t_dft = (time.perf_counter() - t0) / 3
    t0 = time.perf_counter(); [fft_recursive(x) for _ in range(3)]
    t_fftr = (time.perf_counter() - t0) / 3
    t0 = time.perf_counter(); [np.fft.fft(x) for _ in range(3)]
    t_np = (time.perf_counter() - t0) / 3
    print(f"N={N:>6}: DFT {t_dft*1e3:8.1f} ms | 递归FFT {t_fftr*1e3:6.2f} ms | "
          f"NumPy {t_np*1e3:6.3f} ms | 加速比 {t_dft/t_fftr:7.1f}x")

实测结果(Windows 11 / Python 3.13 / NumPy 2.x,个人笔记本,供量级参考;随附 demo_naive_dft.py 可复现):

N 朴素 DFT 教学递归 FFT NumPy(C 实现) 递归 FFT 加速比
1024 37.6 ms 8.4 ms 0.048 ms 4.5x
16384 10972 ms 143 ms 0.457 ms 76.5x

两个观察:其一,加速比随 NNN 增大而放大------O(N2)O(N^2)O(N2) 与 O(Nlog⁡N)O(N\log N)O(NlogN) 的差距是渐近性的,NNN 越大越悬殊;其二,教学递归 FFT 与 NumPy 仍差约 300 倍,差距来自递归开销、切片拷贝与旋转因子重复计算,NumPy 底层的 pocketfft(迭代 + 原位蝶形 + 查表旋转因子)不存在这些问题。工程结论:永远用库里的 FFT,手写版的价值在于看懂分治结构。

6.3 Demo 3:完整的"生成→采样→加窗→FFT→读谱"分析范式

python 复制代码
import numpy as np
import matplotlib.pyplot as plt

def analyze_spectrum(x, fs, window="hann"):
    """工程级单边幅度谱分析:加窗 -> rfft -> 幅度还原 -> 频率轴"""
    N = len(x)
    # 1) 加窗:抑制泄漏(坑2)。相干增益要补偿回来,否则幅度偏小
    if window == "hann":
        w = np.hanning(N)
        cg = w.mean()          # 相干增益 = 窗系数均值,Hann 约 0.5
    else:
        w, cg = np.ones(N), 1.0
    xw = x * w
    # 2) rfft:实信号只算一半,利用共轭对称性(坑5)
    X = np.fft.rfft(xw)
    f = np.fft.rfftfreq(N, 1/fs)          # 频率轴与 X 逐点对应(坑7)
    # 3) 幅度还原:2/(N*cg),直流与折叠点不乘 2(坑4)
    A = np.abs(X) / (N * cg)
    A[1:] *= 2
    return f, A

# 场景:检出混在噪声里的 50Hz 工频干扰
fs = 1024
t = np.arange(0, 1, 1/fs)
x = 0.8*np.sin(2*np.pi*50*t) + 0.3*np.sin(2*np.pi*130.5*t) \
    + 0.2*np.random.default_rng(3).standard_normal(len(t))
f, A = analyze_spectrum(x, fs)
peaks = np.argsort(A)[::-1][:4]
for p in peaks:
    print(f"峰值: f = {f[p]:7.2f} Hz, 幅度 = {A[p]:.3f}")

实测输出:50 Hz 幅度读到 0.818(构造值 0.8);130.5 Hz 被读成 131.00 Hz、幅度 0.263(构造值 0.3)------后者偏小约 12% 不是 bug,而是 130.5 Hz 恰好落在两个 bin 之间的扇贝损失(scalloping loss),正是图 4 泄漏现象在实际读谱中的化身:Hann 窗把能量摊到相邻 bin,峰值 bin 读数偏小。需要精确幅度时的对策:加大 N 使 Δf 变细、或对峰值两侧 bin 的能量求和。除这一已知物理现象外,读谱结果与构造参数吻合------这是一套可直接嵌入采集程序的频谱分析仪核心。

6.4 Demo 4:频域快速卷积滤波(完整工程版,含补零与延迟对齐)

python 复制代码
def fir_lowpass(fs, fc, M=63, beta_mode="hann"):
    """窗函数法设计线性相位 FIR 低通
       理想低通的冲激响应是 sinc(公式推导见正文踩坑6/图5说明),
       截断产生吉布斯振铃(图7),加窗抑制旁瓣(图4)。
    """
    m = np.arange(M) - (M - 1) / 2            # 关于中心对称 → 线性相位
    h = 2 * fc / fs * np.sinc(2 * fc * m / fs) # 理想低通 = 截止fc的sinc
    w = np.hanning(M)
    h = h * w
    return h / h.sum()                         # 直流增益归一化

def fft_filter(x, h):
    """频域快速卷积:严格线性卷积的正确实现
       关键:补零到 L >= N + M - 1,避免循环卷积绕回(坑6)
    """
    L = len(x) + len(h) - 1                    # 线性卷积全长
    X = np.fft.rfft(x, L)
    H = np.fft.rfft(h, L)
    y_full = np.fft.irfft(X * H, L)
    # FIR 是线性相位,群延迟 (M-1)/2,截取对齐段使输出与输入时间对齐
    d = (len(h) - 1) // 2
    return y_full[d:d + len(x)]

# ---- 验证与numpy时域卷积严格一致 ----
fs = 512
t = np.arange(0, 2, 1/fs)
x = np.sin(2*np.pi*8*t) + 0.6*np.sin(2*np.pi*40*t) \
    + 0.4*np.random.default_rng(7).standard_normal(len(t))
h = fir_lowpass(fs, fc=20, M=63)

y_freq = fft_filter(x, h)                                # 频域途径
y_conv = np.convolve(x, h)[31:31+len(x)]                 # 时域途径(full取对齐段)
print(f"频域滤波 vs 时域卷积 最大偏差: {np.max(np.abs(y_freq - y_conv)):.2e}")
# 典型输出: ~1.3e-15  ------ 机器精度,卷积定理的数值实证

这份 fft_filter 就是一切"FFT 滤波"代码的正确骨架------网上大量教程在 NNN 长度上直接相乘,输出头尾都是错的,读者可对照坑 6 自查。

6.5 Demo 5:把两个域放在一起看------滤波前后对照图(生成图 5 的代码)

python 复制代码
import matplotlib.pyplot as plt

n = np.arange(len(x)) / fs
f = np.fft.rfftfreq(len(x), 1/fs)
X0 = np.fft.rfft(x)                       # 滤波前谱
X1 = np.fft.rfft(y_freq)                  # 滤波后谱

fig, axes = plt.subplots(2, 2, figsize=(11, 6.5))
axes[0,0].plot(n, x, color="#9CA3AF", lw=0.8)
axes[0,0].set_title("原始信号时域:8Hz + 40Hz干扰 + 噪声")
axes[0,1].plot(f, np.abs(X0)/len(x)*2, color="#2563EB", lw=0.8)
axes[0,1].set_xlim(0, 100); axes[0,1].set_title("原始信号频谱")
axes[1,0].plot(n, y_freq, color="#DC2626", lw=1.0)
axes[1,0].set_title("滤波后时域:40Hz与噪声被剥离")
axes[1,1].plot(f, 20*np.log10(np.abs(X0)/len(x)+1e-9), color="#9CA3AF", label="滤波前")
axes[1,1].plot(f, 20*np.log10(np.abs(X1)/len(x)+1e-9), color="#DC2626", label="滤波后")
axes[1,1].axvline(20, color="#D97706", ls="--", label="截止 20Hz")
axes[1,1].set_xlim(0, 100); axes[1,1].set_ylim(-80, 5)
axes[1,1].set_xlabel("f (Hz)"); axes[1,1].legend()
fig.tight_layout(); plt.show()

七、效果对比:三组定量实验

7.1 滤波前后:时域与频域的双重证据

指标 滤波前 滤波后(fcf_cfc=20 Hz FIR)
8 Hz 分量幅度(频谱读数) 1.00 1.00(通带无衰减)
40 Hz 分量幅度 0.60 ≈0.006(抑制 ~40 dB)
时域波形 RMS(不含 8Hz 理想值 0.707) 0.86 0.71
两途径实现偏差(时域卷积 vs 频域相乘) --- 1.33e-15(机器精度)

时域图的视觉结论(40 Hz 毛刺消失、只剩光滑 8 Hz 慢波)与频域图的定量结论(40 Hz 谱线塌陷 40 dB)互相印证------这才是"两个域联合分析"的标准姿势:频域定量、时域定性。

7.2 窗函数对比:主瓣宽度与旁瓣电平的权衡表

窗 主瓣宽(bin) 最高旁瓣 (dB) 滚降速度 典型用途
矩形 ±0.5 -13 -6 dB/倍频程 相干采样、瞬态捕捉
Hann ±1 -31 -18 dB/倍频程 通用频谱分析(默认选它)
Hamming ±1 -42(近旁瓣) -6 dB/倍频程 需压制近旁瓣的场景
Blackman ±1.5 -58 -18 dB/倍频程 强信号旁侦测弱信号
Kaiser(β=8.6) 可调 -60(可调) 可调 给定指标反推参数

没有"最好的窗",只有"最匹配指标的窗":分辨率优先 → 矩形/短窗;动态范围优先 → Blackman/Kaiser;两眼一抹黑 → Hann。

7.3 分辨率对比:观测时长 vs 补零(图 6 的定量版)

配置 点数 Δf = fs/N 10.0 / 11.5 Hz 两峰是否可分辨 信息量
64 点采样 64 1.5625 Hz 不可分辨(单峰) 64 个原始样点
64 点 + 补零至 512 512 名义 0.195 Hz 仍不可分辨(单峰,只是更光滑) 仍是 64 个样点
真采 512 点 512 0.195 Hz 干净分开 512 个原始样点

补零行的"名义 Δf"变小了,但分辨能力纹丝不动------频域采样密度 ≠ 频域分辨能力 。决定后者的是观测时长 TobsT_{obs}Tobs,即你为这 512 个点付出的真实时间。


八、结论与开放问题

结论:

  1. 时域与频域是同一信号在两组正交基下的等价展开,傅里叶变换是二者之间的可逆双射;"换域分析"的本质是寻找让问题变稀疏/变对角的坐标系------频域让卷积变成乘法,这正是它对 LTI 系统分析的统治力来源。
  2. 一切时频关系由对偶律统摄:时域离散 ⟷ 频域周期,时域截断 ⟷ 频域泄漏,时域卷积 ⟷ 频域相乘。DFT 是这条对偶链上唯一可计算的落点,FFT 让它从理论变成工业。
  3. 工程实践的质量取决于对"隐含假设"的清醒:抗混叠在采样之前、补零不等于分辨率、频域滤波必须补零防绕回、非平稳信号用 STFT。

开放问题(供进阶读者):

  • 时频不确定性原理 ΔtΔf≥1/(4π)\Delta t \Delta f \ge 1/(4\pi)ΔtΔf≥1/(4π) 决定了 STFT 的窗长无法两全------小波变换用可变窗部分突破这一权衡,其数学代价是什么?
  • 超分辨率谱估计 (MUSIC/ESPRIT)在短观测下"突破" Δf=1/T\Delta f = 1/TΔf=1/T,它利用了"信号是少数正弦"这一先验模型------模型失配时会发生什么?
  • 压缩感知 :若信号在频域稀疏,随机时域采样的 M≪NM \ll NM≪N 个点即可重构------这与本文"自由度守恒"的直觉冲突在哪里?(答案:守恒的是变换后的稀疏自由度,不是采样点数。)

三个方向都通向现代信号处理的核心地带,值得各写一篇。


附录:本文全部交付物

文件 说明
make_figures.py 一键生成全部 7 张图
demo_naive_dft.py Demo 6.1/6.2:手写 DFT 与递归 FFT + 加速比实测
demo_spectrum.py Demo 6.3:工程级单边幅度谱分析
demo_fft_filter.py Demo 6.4/6.5:频域快速卷积滤波(含补零与延迟对齐)

运行环境:Python 3.13 + NumPy + Matplotlib,无其他依赖。

声明:本文图示与数据均由附带代码生成,欢迎复现与指正。

  • make_figure.py
python 复制代码
# -*- coding: utf-8 -*-

import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib import font_manager

# 中文字体
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei"]
plt.rcParams["axes.unicode_minus"] = False
plt.rcParams["figure.dpi"] = 150

OUT = r"C:\Users\yance\blog-dsp-时域与频域\figures"

# 统一配色
C_SIG = "#2563EB"   # 主信号 蓝
C_SIG2 = "#DC2626"  # 对比信号 红
C_NOISE = "#9CA3AF" # 噪声 灰
C_ANNO = "#D97706"  # 标注 琥珀

def style_ax(ax):
    ax.grid(True, alpha=0.3, linestyle="--")
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)

# ---------------------------------------------------------------
# 图1:同一信号的时域视角 vs 频域视角
# ---------------------------------------------------------------
def fig1():
    fs = 1000.0
    t = np.arange(0, 1.0, 1/fs)
    # 三成分:50Hz 强 + 120Hz 弱 + 噪声 ------ 时域里 120Hz 成分几乎被噪声淹没
    x = 1.0*np.sin(2*np.pi*50*t) + 0.35*np.sin(2*np.pi*120*t) + 0.5*np.random.default_rng(42).standard_normal(len(t))

    N = len(x)
    X = np.fft.rfft(x)
    f = np.fft.rfftfreq(N, 1/fs)
    mag = np.abs(X)/N*2

    fig, axes = plt.subplots(2, 1, figsize=(9, 6.2))
    axes[0].plot(t[:500], x[:500], color=C_SIG, lw=0.9)
    axes[0].set_title("时域:波形混杂,难以辨认频率成分", fontsize=11)
    axes[0].set_xlabel("时间 t (s)"); axes[0].set_ylabel("幅度")
    axes[0].annotate("肉眼只能看到一团噪声+快变振荡\n无法直接回答"里面有哪些频率?"",
                     xy=(0.25, 1.5), xytext=(0.30, 1.9), fontsize=9, color=C_ANNO,
                     arrowprops=dict(arrowstyle="->", color=C_ANNO))
    style_ax(axes[0])

    axes[1].stem(f, mag, basefmt=" ", linefmt=C_SIG2, markerfmt=".")
    axes[1].set_xlim(0, 200)
    axes[1].set_title("频域:FFT 一锤定音,50Hz 与 120Hz 成分清晰可见", fontsize=11)
    axes[1].set_xlabel("频率 f (Hz)"); axes[1].set_ylabel("幅度")
    axes[1].annotate("50 Hz,幅度≈1.0", xy=(50, 1.0), xytext=(70, 1.1),
                     fontsize=9, color=C_ANNO, arrowprops=dict(arrowstyle="->", color=C_ANNO))
    axes[1].annotate("120 Hz,幅度≈0.35\n时域里被噪声淹没的成分", xy=(120, 0.35), xytext=(135, 0.75),
                     fontsize=9, color=C_ANNO, arrowprops=dict(arrowstyle="->", color=C_ANNO))
    style_ax(axes[1])
    fig.tight_layout()
    fig.savefig(f"{OUT}/fig1_time_vs_freq.png", bbox_inches="tight")
    plt.close(fig)

# ---------------------------------------------------------------
# 图2:采样与混叠
# ---------------------------------------------------------------
def fig2():
    fig, axes = plt.subplots(1, 3, figsize=(12, 3.6))
    f0 = 3.0  # 连续信号 3 Hz
    tc = np.linspace(0, 1, 2000)
    xc = np.sin(2*np.pi*f0*tc)

    cases = [(12, "fs = 12 Hz > 2f0:采样充分\n还原无混叠"),
             (6,  "fs = 6 Hz = 2f0:临界采样\n工程上不可靠"),
             (2.4,"fs = 2.4 Hz < 2f0:欠采样\n3 Hz 冒充 0.6 Hz(混叠)")]
    for ax, (fs, title) in zip(axes, cases):
        ts = np.arange(0, 1, 1/fs)
        xs = np.sin(2*np.pi*f0*ts)
        # 混叠后表观频率
        fa = abs(f0 - round(f0/fs)*fs)
        ax.plot(tc, xc, color=C_NOISE, lw=1.2, label="原始 3 Hz 信号")
        ax.plot(ts, xs, "o", color=C_SIG, ms=7, label="采样点")
        # 用采样点做线性插值,展示"采样点以为自己在跟谁"
        t_dense = np.linspace(0, 1, 500)
        recon = np.sin(2*np.pi*fa*t_dense)
        ax.plot(t_dense, recon, "--", color=C_SIG2, lw=1.5, label=f"采样点眼中的信号 {fa} Hz")
        ax.set_title(title, fontsize=10)
        ax.set_xlabel("t (s)"); ax.set_ylim(-1.5, 1.5)
        style_ax(ax)
    axes[0].legend(fontsize=8, loc="lower left")
    fig.suptitle("采样定理:fs ≥ 2f_max 才能无失真采样;违反则高频"伪装"成低频(混叠)", fontsize=12)
    fig.tight_layout(rect=[0, 0, 1, 0.92])
    fig.savefig(f"{OUT}/fig2_aliasing.png", bbox_inches="tight")
    plt.close(fig)

# ---------------------------------------------------------------
# 图3:傅里叶家族四象限关系
# ---------------------------------------------------------------
def fig3():
    fig, ax = plt.subplots(figsize=(9, 6))
    ax.set_xlim(0, 10); ax.set_ylim(0, 10); ax.axis("off")

    def box(x, y, w, h, text, fc="#EFF6FF", ec="#2563EB"):
        ax.add_patch(plt.Rectangle((x, y), w, h, fc=fc, ec=ec, lw=1.6, zorder=2))
        ax.text(x+w/2, y+h/2, text, ha="center", va="center", fontsize=10, zorder=3)

    def arrow(x1, y1, x2, y2, text, color="#DC2626"):
        ax.annotate("", xy=(x2, y2), xytext=(x1, y1),
                    arrowprops=dict(arrowstyle="<->", color=color, lw=1.6))
        ax.text((x1+x2)/2, (y1+y2)/2 + 0.25, text, ha="center", fontsize=9.5, color=color)

    # 连续/离散 × 周期/非周期
    box(0.6, 5.4, 3.4, 1.6, "连续 + 周期\n傅里叶级数 FS\n(非周期连续谱,谱线间隔 1/T)")
    box(6.0, 5.4, 3.4, 1.6, "连续 + 非周期\n傅里叶变换 CTFT\n(连续谱 X(f))")
    box(0.6, 2.2, 3.4, 1.6, "离散 + 周期\nDFS\n(离散周期谱)")
    box(6.0, 2.2, 3.4, 1.6, "离散 + 非周期\nDTFT\n(连续周期谱,周期 2π)")
    # 时域采样 / 频域采样
    arrow(4.0, 6.2, 6.0, 6.2, "时域截断(趋于非周期)")
    arrow(4.0, 3.0, 6.0, 3.0, "时域加窗截断")
    arrow(2.3, 5.4, 2.3, 3.8, "时域采样(周期化)")
    ax.text(2.9, 4.55, "→ 谱变成周期离散", fontsize=8.5, color="#6B7280")
    arrow(7.7, 5.4, 7.7, 3.8, "时域采样")
    ax.text(8.3, 4.55, "→ 谱变成周期连续", fontsize=8.5, color="#6B7280")
    # DFT 居中
    box(3.2, 0.2, 3.6, 1.3, "DFT / FFT\n(DTFT 在频域采样 N 点,\n计算机唯一能算的)", fc="#FEF3C7", ec="#D97706")
    arrow(2.3, 2.2, 4.2, 1.5, "频域再采样", color="#D97706")
    arrow(7.7, 2.2, 5.8, 1.5, "频域再采样", color="#D97706")
    ax.set_title("傅里叶家族关系图:时域"采样/截断"与频域"周期/离散"的对偶律", fontsize=12)
    fig.savefig(f"{OUT}/fig3_fourier_family.png", bbox_inches="tight")
    plt.close(fig)

# ---------------------------------------------------------------
# 图4:频谱泄漏与窗函数
# ---------------------------------------------------------------
def fig4():
    fs, N = 64, 64
    n = np.arange(N)
    f_bin = np.fft.rfftfreq(N, 1/fs)
    x = np.sin(2*np.pi*10.5*n/fs)   # 10.5 Hz:正好落在两个 bin 中间 → 最严重泄漏

    windows = [("矩形窗", np.ones(N)),
               ("Hann 窗", np.hanning(N)),
               ("Blackman 窗", np.blackman(N))]
    fig, axes = plt.subplots(1, 3, figsize=(12, 3.6), sharey=True)
    for ax, (name, w) in zip(axes, windows):
        xw = x*w
        X = np.abs(np.fft.rfft(xw))/N*2
        ax.stem(f_bin, 20*np.log10(X + 1e-9), basefmt=" ", linefmt=C_SIG, markerfmt=".")
        ax.set_title(f"{name}(相干增益已归一化说明:旁瓣结构不同)", fontsize=9.5)
        ax.set_xlabel("频率 (Hz)")
        ax.set_ylim(-70, 5)
        style_ax(ax)
    axes[0].set_ylabel("幅度 (dB)")
    axes[0].annotate("矩形窗:主瓣最窄(1 bin)\n但旁瓣 -13 dB,\n泄漏污染整个频带", xy=(12, -25), xytext=(20, -10),
                     fontsize=8.5, color=C_ANNO, arrowprops=dict(arrowstyle="->", color=C_ANNO))
    axes[1].annotate("Hann:旁瓣 -31 dB,\n代价是主瓣展宽到 2 bin", xy=(12, -30), xytext=(25, -10),
                     fontsize=8.5, color=C_ANNO, arrowprops=dict(arrowstyle="->", color=C_ANNO))
    axes[2].annotate("Blackman:旁瓣 -58 dB,\n主瓣展宽到 3 bin", xy=(12, -45), xytext=(28, -10),
                     fontsize=8.5, color=C_ANNO, arrowprops=dict(arrowstyle="->", color=C_ANNO))
    fig.suptitle("信号 10.5 Hz 不落在整数 bin 上 → 截断产生频谱泄漏;窗函数以"主瓣变宽"换"旁瓣变低"", fontsize=11.5)
    fig.tight_layout(rect=[0, 0, 1, 0.9])
    fig.savefig(f"{OUT}/fig4_leakage_window.png", bbox_inches="tight")
    plt.close(fig)

# ---------------------------------------------------------------
# 图5:滤波实战------时域卷积 = 频域相乘
# ---------------------------------------------------------------
def fig5():
    rng = np.random.default_rng(7)
    fs, T = 512, 2.0
    t = np.arange(0, T, 1/fs)
    x = 1.0*np.sin(2*np.pi*8*t) + 0.6*np.sin(2*np.pi*40*t) + 0.4*rng.standard_normal(len(t))

    # FIR 低通:截止 20 Hz,汉宁窗法设计
    from numpy import hanning
    M, fc = 63, 20.0
    m = np.arange(M) - (M-1)/2
    h_ideal = 2*fc/fs*np.sinc(2*fc*m/fs)   # 理想低通 = sinc
    h = h_ideal * hanning(M)               # 加窗抑制吉布斯
    h /= h.sum()                           # 直流增益归一

    # 途径A:时域卷积(线性卷积,full 模式)
    y_conv_full = np.convolve(x, h, mode="full")
    # 途径B:频域相乘 ------ 两序列都补零到 L = N + M - 1,
    # 这样循环卷积退化为线性卷积,与途径A严格逐点相等
    L = len(x) + M - 1
    X = np.fft.rfft(x, L)
    H = np.fft.rfft(h, L)
    y_full_freq = np.fft.irfft(X*H, L)
    # 线性卷积引入群延迟 (M-1)/2,画图时截取对齐段
    delay = (M - 1) // 2
    y_conv = y_conv_full[delay:delay + len(x)]
    y_freq = y_full_freq[delay:delay + len(x)]

    n = np.arange(len(x))/fs
    f = np.fft.rfftfreq(len(x), 1/fs)
    X_orig = np.fft.rfft(x)   # 仅用于画原始频谱
    fig, axes = plt.subplots(2, 2, figsize=(11, 6.5))
    axes[0,0].plot(n, x, color=C_NOISE, lw=0.8)
    axes[0,0].set_title("原始信号时域:8Hz 有用信号 + 40Hz 干扰 + 噪声"); style_ax(axes[0,0])
    axes[0,1].plot(f, np.abs(X_orig)/len(x)*2, color=C_SIG, lw=0.8)
    axes[0,1].set_xlim(0, 100); axes[0,1].set_title("原始信号频谱"); style_ax(axes[0,1])
    axes[1,0].plot(n, y_conv, color=C_SIG2, lw=1.0, label="时域卷积结果")
    axes[1,0].plot(n, y_freq, "--", color="#059669", lw=1.2, alpha=0.8, label="频域相乘结果")
    axes[1,0].set_title("滤波后时域:40Hz 干扰与噪声被剥离,最大误差 < 1e-10")
    axes[1,0].set_xlabel("t (s)"); axes[1,0].legend(fontsize=9); style_ax(axes[1,0])
    axes[1,1].plot(f, 20*np.log10(np.abs(X_orig)/len(x)+1e-9), color=C_NOISE, lw=0.8, label="滤波前")
    axes[1,1].plot(f, 20*np.log10(np.abs(np.fft.rfft(y_conv))/len(y_conv)+1e-9), color=C_SIG2, lw=1.0, label="滤波后")
    axes[1,1].axvline(20, color=C_ANNO, ls="--", lw=1, label="截止频率 20 Hz")
    axes[1,1].set_xlim(0, 100); axes[1,1].set_ylim(-80, 5)
    axes[1,1].set_title("滤波前后频谱对比(dB)"); axes[1,1].set_xlabel("f (Hz)")
    axes[1,1].legend(fontsize=8); style_ax(axes[1,1])
    err = np.max(np.abs(y_conv_full - y_full_freq))
    fig.suptitle(f"卷积定理实证:补零后时域卷积与频域相乘逐点相等(max |Δ| = {err:.2e})", fontsize=12)
    fig.tight_layout(rect=[0, 0, 1, 0.94])
    fig.savefig(f"{OUT}/fig5_convolution_filter.png", bbox_inches="tight")
    plt.close(fig)
    return err

# ---------------------------------------------------------------
# 图6:补零 vs 真实采样(分辨率 vs 插值密度)
# ---------------------------------------------------------------
def fig6():
    fs = 100.0
    f0a, f0b = 10.0, 11.5   # 相差 1.5 Hz
    def spec(N, zero=0):
        n = np.arange(N)
        x = np.sin(2*np.pi*f0a*n/fs) + np.sin(2*np.pi*f0b*n/fs)
        X = np.abs(np.fft.rfft(x, N+zero))/(N+zero)*2
        f = np.fft.rfftfreq(N+zero, 1/fs)
        return f, X

    fig, axes = plt.subplots(1, 3, figsize=(12.5, 3.6), sharey=True)
    f, X = spec(64)
    axes[0].stem(f, X, basefmt=" ", linefmt=C_SIG, markerfmt=".")
    axes[0].set_title("64 点采样:Δf = fs/N = 1.5625 Hz\n两个谱峰挤在一个 bin 里,不可分辨", fontsize=9.5)
    f, X = spec(64, zero=448)  # 补零到 512
    axes[1].plot(f, X, color=C_SIG2, lw=1.2)
    axes[1].set_title("补零到 512 点:曲线变密但仍是 1.5625 Hz 的分辨力\n主瓣只是被"插值"得更光滑,仍是单峰", fontsize=9.5)
    f, X = spec(512)           # 真采 512 点
    axes[2].plot(f, X, color="#059669", lw=1.2)
    axes[2].set_title("真采 512 点:Δf = 0.195 Hz\n两个真实谱峰被分开------这才是分辨率提升", fontsize=9.5)
    for ax in axes:
        ax.set_xlabel("频率 (Hz)"); ax.set_xlim(0, 20); style_ax(ax)
    axes[0].set_ylabel("幅度")
    fig.suptitle("补零 ≠ 提高分辨率:补零增加的是频谱"采样密度",真实分辨力由观测时长 T = N/fs 决定", fontsize=11.5)
    fig.tight_layout(rect=[0, 0, 1, 0.9])
    fig.savefig(f"{OUT}/fig6_zero_padding.png", bbox_inches="tight")
    plt.close(fig)

# ---------------------------------------------------------------
# 图7:吉布斯现象(踩坑点素材)
# ---------------------------------------------------------------
def fig7():
    N = 100
    t = np.linspace(-0.5, 0.5, 1000, endpoint=False)
    sq = np.where((t > -0.25) & (t < 0.25), 1.0, 0.0)

    fig, ax = plt.subplots(figsize=(9, 4.2))
    ax.plot(t, sq, color="#6B7280", lw=2, label="原始方波")
    colors = ["#93C5FD", "#3B82F6", "#1D4ED8", "#7C3AED"]
    for i, K in enumerate([5, 11, 31, 101]):
        # 用 K 个谐波重构方波
        rec = np.zeros_like(t)
        for k in range(1, K+1, 2):
            rec += 4/(np.pi*k)*np.sin(2*np.pi*k*t)
        ax.plot(t, rec, color=colors[i], lw=1.2, label=f"前 {K} 次谐波重构")
    ax.set_title("吉布斯现象:有限带宽逼近不连续信号 → 跳变点永远残留 ≈9% 的过冲,加谐波也压不掉", fontsize=10.5)
    ax.set_xlabel("t (s)"); ax.legend(fontsize=9, loc="upper right"); style_ax(ax)
    fig.tight_layout()
    fig.savefig(f"{OUT}/fig7_gibbs.png", bbox_inches="tight")
    plt.close(fig)

if __name__ == "__main__":
    fig1(); fig2(); fig3(); fig4(); err = fig5(); fig6(); fig7()
    print("all figures done, conv-vs-freq max err =", err)
  • demo_naive_dft.py
python 复制代码
# -*- coding: utf-8 -*-
"""Demo:手写 DFT 与递归 radix-2 FFT + 加速比实测
"""
import time
import numpy as np


def naive_dft(x):
    """O(N^2) 离散傅里叶变换,直接对应公式:
       X[k] = sum_n x[n] * exp(-j*2*pi*k*n/N)
    """
    N = len(x)
    n = np.arange(N)
    k = n.reshape((N, 1))                     # k 变成列向量,构造 N x N 网格
    W = np.exp(-2j * np.pi * k * n / N)       # 旋转因子矩阵 W_N^{kn}
    return W @ x                              # 矩阵乘法即求和


def fft_recursive(x):
    """radix-2 按时间抽取 FFT(教学版,要求 N 为 2 的幂)
       递推关系:
       X[k]      = E[k] + W^k * O[k]
       X[k+N/2]  = E[k] - W^k * O[k]
    """
    N = len(x)
    if N <= 1:
        return x.copy()
    assert N % 2 == 0, "教学版要求 N 为 2 的幂"
    E = fft_recursive(x[0::2])                # 偶数下标子序列的 N/2 点 DFT
    O = fft_recursive(x[1::2])                # 奇数下标子序列的 N/2 点 DFT
    k = np.arange(N // 2)
    Wk = np.exp(-2j * np.pi * k / N)          # 旋转因子
    # 蝶形:一次乘法同时产生 X[k] 与 X[k+N/2]
    return np.concatenate([E + Wk * O, E - Wk * O])


if __name__ == "__main__":
    # ---- 1) 正确性验证:DFT / 递归FFT / NumPy 三方对比 ----
    rng = np.random.default_rng(0)
    x = rng.standard_normal(256) + 1j * rng.standard_normal(256)
    err1 = np.max(np.abs(naive_dft(x) - np.fft.fft(x)))
    err2 = np.max(np.abs(fft_recursive(x) - np.fft.fft(x)))
    print(f"朴素 DFT   vs NumPy FFT 最大偏差: {err1:.3e}")
    print(f"递归 FFT   vs NumPy FFT 最大偏差: {err2:.3e}")

    # ---- 2) 加速比实测 ----
    print(f"\n{'N':>8} | {'朴素DFT':>10} | {'递归FFT':>10} | {'NumPy':>10} | 递归加速比")
    print("-" * 60)
    for N in [2**10, 2**14]:
        x = np.random.default_rng(1).standard_normal(N)
        t0 = time.perf_counter(); [naive_dft(x) for _ in range(3)]
        t_dft = (time.perf_counter() - t0) / 3
        t0 = time.perf_counter(); [fft_recursive(x) for _ in range(3)]
        t_fftr = (time.perf_counter() - t0) / 3
        t0 = time.perf_counter(); [np.fft.fft(x) for _ in range(3)]
        t_np = (time.perf_counter() - t0) / 3
        print(f"{N:>8} | {t_dft*1e3:>8.1f}ms | {t_fftr*1e3:>8.2f}ms | "
              f"{t_np*1e3:>8.3f}ms | {t_dft/t_fftr:>8.1f}x")
  • demo_spectrum.py
python 复制代码
# -*- coding: utf-8 -*-
"""Demo:工程级单边幅度谱分析(加窗 -> rfft -> 幅度还原 -> 读谱)
"""
import numpy as np
import matplotlib.pyplot as plt

plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei"]
plt.rcParams["axes.unicode_minus"] = False


def analyze_spectrum(x, fs, window="hann"):
    """工程级单边幅度谱分析:加窗 -> rfft -> 幅度还原 -> 频率轴
       要点:
       1) 加窗抑制泄漏(坑2),相干增益要补偿,否则幅度偏小
       2) rfft 利用实信号共轭对称性,计算量减半(坑5)
       3) 幅度还原 2/(N*cg),直流与折叠点不乘 2(坑4)
    """
    N = len(x)
    if window == "hann":
        w = np.hanning(N)
        cg = w.mean()          # 相干增益 = 窗系数均值,Hann 约 0.5
    else:
        w, cg = np.ones(N), 1.0
    xw = x * w
    X = np.fft.rfft(xw)
    f = np.fft.rfftfreq(N, 1/fs)           # 频率轴与 X 逐点对应(坑7)
    A = np.abs(X) / (N * cg)
    A[1:] *= 2                             # 单边谱还原,k=0 不乘
    return f, A


if __name__ == "__main__":
    # 场景:检出混在噪声里的 50Hz 工频干扰 + 130.5Hz 成分
    fs = 1024
    t = np.arange(0, 1, 1/fs)
    x = 0.8*np.sin(2*np.pi*50*t) + 0.3*np.sin(2*np.pi*130.5*t) \
        + 0.2*np.random.default_rng(3).standard_normal(len(t))

    f, A = analyze_spectrum(x, fs)
    # 峰值搜索(简单版:幅度排序前 4 的局部大点)
    idx = np.argsort(A)[::-1][:4]
    seen = []
    print("检出峰值(去重相邻 bin 后):")
    for p in sorted(idx, key=lambda i: -A[i]):
        if all(abs(p - s) > 3 for s in seen):
            seen.append(p)
            print(f"  f = {f[p]:7.2f} Hz, 幅度 = {A[p]:.3f}")

    fig, ax = plt.subplots(figsize=(9, 4))
    ax.plot(f, A, color="#2563EB", lw=0.8)
    ax.set_xlabel("f (Hz)"); ax.set_ylabel("幅度")
    ax.set_title("加窗 FFT 单边幅度谱(Hann,相干增益已补偿)")
    ax.grid(alpha=0.3, ls="--")
    fig.tight_layout()
    fig.savefig("demo_spectrum_result.png", dpi=150)
    print("图已保存: demo_spectrum_result.png")
  • demo_fft_filter.py
python 复制代码
# -*- coding: utf-8 -*-
"""Demo:频域快速卷积滤波(含补零防绕回 + 群延迟对齐)
运行后输出:两途径偏差验证 + 滤波前后时/频域四宫格对照图
"""
import numpy as np
import matplotlib.pyplot as plt

plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei"]
plt.rcParams["axes.unicode_minus"] = False


def fir_lowpass(fs, fc, M=63):
    """窗函数法设计线性相位 FIR 低通
       理想低通的冲激响应是 sinc,截断产生吉布斯振铃,加窗抑制旁瓣。
       m 关于中心对称 → 线性相位,群延迟恒为 (M-1)/2。
    """
    m = np.arange(M) - (M - 1) / 2             # 关于中心对称
    h = 2 * fc / fs * np.sinc(2 * fc * m / fs)  # 理想低通 = 截止 fc 的 sinc
    h = h * np.hanning(M)                      # 加窗抑制吉布斯
    return h / h.sum()                         # 直流增益归一化


def fft_filter(x, h):
    """频域快速卷积:严格线性卷积的正确实现
       关键:补零到 L >= N + M - 1,避免循环卷积绕回(坑6);
       FIR 线性相位群延迟 (M-1)/2,截取对齐段。
    """
    L = len(x) + len(h) - 1
    X = np.fft.rfft(x, L)
    H = np.fft.rfft(h, L)
    y_full = np.fft.irfft(X * H, L)
    d = (len(h) - 1) // 2
    return y_full[d:d + len(x)]


if __name__ == "__main__":
    fs = 512
    t = np.arange(0, 2, 1/fs)
    x = np.sin(2*np.pi*8*t) + 0.6*np.sin(2*np.pi*40*t) \
        + 0.4*np.random.default_rng(7).standard_normal(len(t))
    h = fir_lowpass(fs, fc=20, M=63)

    y_freq = fft_filter(x, h)                          # 频域途径
    y_conv = np.convolve(x, h)[31:31+len(x)]           # 时域途径(full 取对齐段)
    err = np.max(np.abs(y_freq - y_conv))
    print(f"频域滤波 vs 时域卷积 最大偏差: {err:.2e}")   # ~1e-15 机器精度

    # ---- 时/频域四宫格对照(图5 同款)----
    n = np.arange(len(x)) / fs
    f = np.fft.rfftfreq(len(x), 1/fs)
    X0 = np.fft.rfft(x)
    X1 = np.fft.rfft(y_freq)

    fig, axes = plt.subplots(2, 2, figsize=(11, 6.5))
    axes[0, 0].plot(n, x, color="#9CA3AF", lw=0.8)
    axes[0, 0].set_title("原始信号时域:8Hz + 40Hz干扰 + 噪声")
    axes[0, 1].plot(f, np.abs(X0)/len(x)*2, color="#2563EB", lw=0.8)
    axes[0, 1].set_xlim(0, 100); axes[0, 1].set_title("原始信号频谱")
    axes[1, 0].plot(n, y_freq, color="#DC2626", lw=1.0)
    axes[1, 0].set_title(f"滤波后时域(频域途径,偏差 {err:.1e})")
    axes[1, 0].set_xlabel("t (s)")
    axes[1, 1].plot(f, 20*np.log10(np.abs(X0)/len(x)+1e-9), color="#9CA3AF", label="滤波前")
    axes[1, 1].plot(f, 20*np.log10(np.abs(X1)/len(x)+1e-9), color="#DC2626", label="滤波后")
    axes[1, 1].axvline(20, color="#D97706", ls="--", label="截止 20Hz")
    axes[1, 1].set_xlim(0, 100); axes[1, 1].set_ylim(-80, 5)
    axes[1, 1].set_xlabel("f (Hz)"); axes[1, 1].set_title("滤波前后频谱对比 (dB)")
    axes[1, 1].legend(fontsize=8)
    for ax in axes.flat:
        ax.grid(alpha=0.3, ls="--")
    fig.suptitle("卷积定理实证:时域卷积 = 频域相乘(补零后逐点相等)")
    fig.tight_layout(rect=[0, 0, 1, 0.94])
    fig.savefig("demo_fft_filter_result.png", dpi=150)
    print("图已保存: demo_fft_filter_result.png")
相关推荐
Percep_gan1 小时前
调用天翼云的对象存储ZOS,很详细
java·1024程序员节
JulyLi20191 小时前
yolo检测适配昇腾Atlas300IDuo笔记
yolo·1024程序员节
遇雪长安1 小时前
MCU 固件启动即崩,bootloader 如何发现
1024程序员节
Zevalin爱灰灰1 小时前
Linux入门第一部分(Ubuntu入门篇) 第二章 浅尝Ubuntu(二)
linux·嵌入式·1024程序员节
泡海椒1 小时前
JQuick-SQL REST API 数据接入:应用层调用接口后注册分析表
数据库·sql·1024程序员节
_MH88881 小时前
2026.10.24|距离上一次写 CSDN,刚好过去了一年
1024程序员节
XIU YUAN1 小时前
ESP32-C6 专业游戏合集系统 - 打造你的掌上游戏机
1024程序员节
程序猿乐锅1 小时前
【从0-1学习Python | 第七篇】Python 面向对象基础
开发语言·笔记·后端·python·学习·1024程序员节