摘要 :本文系统梳理数字信号处理中"数字域(时域)"与"频域"两种信号表示之间的数学关系与工程作用。核心结论先行:时域与频域是同一信号在两组不同完备基下的等价展开,二者通过傅里叶变换对构成一一映射,且时域卷积对应频域相乘。围绕这一主线,本文依次推导采样定理、傅里叶家族(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 为周期无限复制叠加。 这一结论是数字信号处理的立身之本,两个直接推论:
- 只要 Xc(f)X_c(f)Xc(f) 被限制在 ∣f∣<fs/2|f| < f_s/2∣f∣<fs/2 内,各份复制谱互不重叠,可以用一个理想低通完整恢复原信号 ------这就是奈奎斯特采样定理 fs≥2fmaxf_s \ge 2f_{\max}fs≥2fmax;
- 若 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(NlogN)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ω)
这个定理的工程意义怎么强调都不过分:
- 滤波器设计的对象从"波形"变成"频响曲线" :设计一个低通滤波器,就是让 H(f)H(f)H(f) 在通带接近 1、阻带接近 0,而无需关心 hnhnhn 长什么样;
- 复杂度换轨 :直接卷积 O(N⋅M)O(N \cdot M)O(N⋅M),频域路径两次 FFT + 一次相乘为 O(NlogN)O(N\log N)O(NlogN)。当 hhh 较长时(如混响、长 FIR),频域实现快一到两个数量级;
- 卷积定理反过来读就是调制定理:时域乘窗 ⟷ 频域卷窗谱,这是理解频谱泄漏的钥匙。
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(Nlog2N)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 Nlog2N≈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(NlogN)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πsinttdt−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(NlogN)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 个点付出的真实时间。
八、结论与开放问题
结论:
- 时域与频域是同一信号在两组正交基下的等价展开,傅里叶变换是二者之间的可逆双射;"换域分析"的本质是寻找让问题变稀疏/变对角的坐标系------频域让卷积变成乘法,这正是它对 LTI 系统分析的统治力来源。
- 一切时频关系由对偶律统摄:时域离散 ⟷ 频域周期,时域截断 ⟷ 频域泄漏,时域卷积 ⟷ 频域相乘。DFT 是这条对偶链上唯一可计算的落点,FFT 让它从理论变成工业。
- 工程实践的质量取决于对"隐含假设"的清醒:抗混叠在采样之前、补零不等于分辨率、频域滤波必须补零防绕回、非平稳信号用 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")