MATLAB生成blocks、bumps和doppler标准测试信号
做信号处理、小波分析或者压缩感知的朋友,一定绕不开这几类经典测试信号:blocks、bumps、doppler。它们最早由Donoho和Johnstone在1994年前后提出,用来评估小波去噪、稀疏表示、阈值收缩等算法的性能,这几十年来几乎成了这个领域的“标准benchmark”。不管你是刚入门的学生,还是需要快速验证算法效果的研究者,手头有一套能一键生成这三类信号的MATLAB代码,绝对能省下大量时间。
这篇文章我会先把这三类信号各自的特点和用途讲清楚,再给出一套完整的MATLAB实现,从数学表达式到代码逐行解读,最后配合去噪对比实验,演示怎么用这套信号做标准测试。过程中会用我实际调试中踩过的坑和积累的小技巧,帮助你把代码改到自己项目里直接能用。
1. 内容整体设计与思路拆解
1.1 为什么偏偏是这三类信号
刚接触这个领域时,我也不理解为什么大家都默认用blocks、bumps、doppler,而不是随手造几个正弦波叠加或者白噪声。实际用下来才发现,这三类信号在设计上非常“刁钻”,各自针对算法性能的不同侧面。
blocks是分段常数信号,在少量位置发生突变,其余位置完全平坦。它的特点是:小波域表示非常稀疏,大部分小波系数接近零,只有跨越跳变点的少数系数很大。这正好用来检验算法对“边缘”和“跳变”的保持能力——如果去噪后跳变被磨平了,说明阈值处理过度;如果跳变附近出现振荡,说明噪声没有被有效抑制。
bumps信号由多个不对称的高斯型峰叠加而成,峰的宽度、高度各不相同,叠加后形成一个形态复杂的连续曲线。相比blocks,bumps没有尖锐的跳变,但峰的陡峭程度差异很大,有些峰很窄、有些很宽。它考察的是算法对“局部结构”和“峰值”的恢复能力,特别是在峰值密集区域,算法很容易把两个相邻峰混淆成一个。
doppler信号是一个频率随时间变化的调频信号,起始频率很高、振动密集,后段频率越来越低、振动稀疏。它的麻烦在于,高频段在固定采样率下只有很少几个采样点,信号“振”得很快,在视觉上几乎连成一片。这用来考验算法对“时频局部化”能力,也就是能否在保留高频细节的同时,抑制高频段的噪声。
把这三类放在一起,几乎覆盖了非平稳信号的主要难点:突变边缘、幅值陡变、频率漂移。所以算法在它们上面的表现,可以比较全面地反映实际应用中的鲁棒性。许多论文里展示去噪效果对比图,用的也是这组信号,就是因为标准统一、结论可信。
1.2 这套方案的选型考量
生成这几类信号的实现方法有很多,MATLAB File Exchange上有现成脚本,Wavelab工具箱里也有完整实现。但我建议自己动手写一套函数,原因主要有三个。
第一,现成脚本往往包含大量无关代码。很多工具箱为了通用性,加入了信号增强、边界延拓、多分辨率分析等功能,传到自己项目里既占空间,又容易和已有变量名冲突。自己写一个精简版,只保留核心逻辑,后期维护和调试都轻松。
第二,自己写可以自由调整参数。标准版blocks只有11个跳变点、bumps只有11个峰,但某些实验需要更多突变、更密集的峰,或者更宽的频带。自己实现后,改一个数组就能搞定,不用去翻工具箱内部代码。
第三,生成函数本身就是理解信号结构的捷径。blocks为什么是分段常数?因为它的数学定义就是常数乘以指示函数。bumps为什么看起来高低不平?因为每个峰都是四次有理函数,分母趋近零时峰陡峭上升。这些细节靠“看别人代码”是很难消化的,自己写一遍才能刻进脑子里。
我在下文的实现中,将n(采样点数)作为函数唯一输入参数,输出长度为n的列向量。这样无论做512点还是4096点实验,都是调用一次函数的事,实验对比不同采样率时非常方便。另外,信号都做了一定程度的归一化处理,幅值范围大致在[-6, 6]之间,这个区间在实际实验里比较好用——既不会让信号淹没在噪声里,也不会因为幅值过大导致数值溢出。
2. 核心细节解析与实操要点
2.1 blocks信号的数学原理与代码实现
blocks信号的数学定义是一个加权指示函数之和。首先在[0,1]区间上取一组跳变位置pos_j,每个位置对应一个高度h_j,那么blocks信号在任意点t处的值,等于所有满足t大于等于pos_j的项的高度之和。这里的关键是“指示函数”的思想:位置在跳变点之前,该项为0;位置在跳变点之后,该项为1,再乘以高度。这个过程用MATLAB的sign函数可以非常简洁地实现。
其中pos_j的经典取值如下:
| 跳变点位置 | 对应高度 |
|---|---|
| 0.10 | 4 |
| 0.13 | -5 |
| 0.15 | 3 |
| 0.23 | -4 |
| 0.25 | 5 |
| 0.40 | -4.2 |
| 0.44 | 2.1 |
| 0.65 | 4.3 |
| 0.76 | -3.1 |
| 0.78 | 2.1 |
| 0.81 | -4.2 |
下面给出完整函数代码:
function y = blocks(n) % BLOCKS 生成Donoho-Johnstone blocks标准测试信号 % 输入n为采样点数,输出y为n行1列的列向量 t = (0:n-1)' / n; % 跳变点位置和对应高度 pos = [0.10 0.13 0.15 0.23 0.25 0.40 0.44 0.65 0.76 0.78 0.81]'; hgt = [4 -5 3 -4 5 -4.2 2.1 4.3 -3.1 2.1 -4.2]'; y = zeros(n, 1); for j = 1:length(pos) y = y + hgt(j) * (1 + sign(t - pos(j))) / 2; end end这里最关键的一行是(1 + sign(t - pos(j))) / 2。当t小于pos(j)时,sign返回-1,这一整项为0;当t大于pos(j)时,sign返回1,这一整项为1。在t恰好等于pos(j)时,sign返回0,该项是0.5倍高度,形成一个“半台阶”。实际离散采样中,这种情况极少发生,可以忽略。
运行y = blocks(1024); plot(y);就能看到典型的阶梯状信号。从0.10位置开始抬升,到0.13突然下坠,再到0.15回升,整条曲线由若干个不同高度的平台拼接而成。值得注意的是,最后一个跳变点0.81之后,信号一直保持一个非零的直流分量,这是正常现象,不代表信号有偏置问题。
2.2 bumps信号的数学原理与代码实现
bumps信号是多个四次有理函数的叠加。每个峰的形状由三个参数决定:位置pos_j、高度hgt_j、宽度wth_j。第j个峰的表达式是hgt_j / (1 + ((t - pos_j)/wth_j)^4)。之所以用四次而不是二次,是因为四次函数在远离中心时衰减更快,峰的“尾巴”更窄,彼此之间的重叠比高斯函数少,更容易看到独立的峰形。
经典参数如下:
| 峰位置 | 峰高度 | 峰宽度 |
|---|---|---|
| 0.10 | 4 | 0.005 |
| 0.13 | 5 | 0.005 |
| 0.15 | 3 | 0.006 |
| 0.23 | 4 | 0.010 |
| 0.25 | 5 | 0.010 |
| 0.40 | 4.2 | 0.030 |
| 0.44 | 2.1 | 0.010 |
| 0.65 | 4.3 | 0.010 |
| 0.76 | 3.1 | 0.005 |
| 0.78 | 5.1 | 0.008 |
| 0.81 | 4.2 | 0.005 |
这里要注意峰宽度的取值:0.005和0.010在数值上相差一倍,但实际信号形态差异很大。宽度越小,峰越尖锐,对采样率的要求也越高。如果在n=256时生成bumps信号,最窄的峰可能只占两三个采样点,就会产生明显的走样。我建议做实验时n至少取1024,才能看到平滑完整的峰形。
function y = bumps(n) % BUMPS 生成Donoho-Johnstone bumps标准测试信号 t = (0:n-1)' / n; pos = [0.10 0.13 0.15 0.23 0.25 0.40 0.44 0.65 0.76 0.78 0.81]'; hgt = [4 5 3 4 5 4.2 2.1 4.3 3.1 5.1 4.2]'; wth = [0.005 0.005 0.006 0.010 0.010 0.030 0.010 0.010 0.005 0.008 0.005]'; y = zeros(n, 1); for j = 1:length(pos) y = y + hgt(j) ./ (1 + ((t - pos(j)) / wth(j)).^4); end end这里用了./和.^而不是/和^,是因为t是列向量,要对每个元素做运算。pos数组和t做减法时会自动广播维度,MATLAB R2016b及以后版本都支持这个特性,老版本需要改用bsxfun(@minus, t, pos(j))。
运行y = bumps(1024); plot(y);可以看到约11个高矮不同、宽窄不一的峰,几个宽的峰在0.40附近叠成一个较大的隆起,窄峰则在各自位置画出尖锐的顶点。这个信号不像blocks那样有明显的跳变,但局部梯度的变化非常剧烈,对小波系数来说,相当于在高频子带里布满了能量。
2.3 doppler信号的数学原理与代码实现
doppler信号的定义是:在t从0到1范围内,取sqrt(t .* (1 - t)) .* sin(2 * pi * 1.05 ./ (t + 0.05))。这个式子看着简单,但里面有几个关键设计,不注意很容易在实现时出错。
首先是前面的sqrt(t .* (1 - t)),它是一个在0到1之间呈拱形的包络,在两端趋近于0。没有这个包络的话,doppler信号在高频段会保持恒定的振幅,视觉效果和图谱特征都会变差。有了它,信号在t靠近0时振幅被压得很低,噪声掩盖信号的效果就会更明显——这正是我们想要测试的困难场景。
其次是分母上的t + 0.05。当t趋近0时,分母趋近0.05,频率参数2 * pi * 1.05 / 0.05约为131.9,对应的角频率并不算极度夸张;但如果直接除以t,在t=0处会出现无穷大频率,MATLAB会给出NaN或Inf警告,整个信号就没法用了。加一个很小的偏移量0.05,本质上是一种“截断”策略,让最高频率可控。
标准定义中频率调制系数alpha通常取1.05,这是Donoho论文里的经验值。如果你需要更高频的测试信号,可以把这个系数调大;如果希望低频段更平缓,可以减小它。但改的时候要注意观察图形,不要调到信号在视觉上完全糊成一片。
function y = doppler(n) % DOPPLER 生成Donoho-Johnstone doppler标准测试信号 t = (0:n-1)' / n; alpha = 1.05; % 频率调制系数,经典取值 y = sqrt(t .* (1 - t)) .* sin(2 * pi * alpha ./ (t + 0.05)); end运行y = doppler(1024); plot(y);可以看到,前半段信号振得非常密,几乎是一团黑线,后半段逐渐稀疏,能看出清晰的起伏。如果n取4096,前半段的高频细节会更丰富,但整体形态与1024点版本一致。
2.4 统一封装:一键生成三类信号
实际做实验时,经常需要同时生成多类信号做对比。我习惯把它们封装成一个函数,用字符串指定类型:
function [y, t] = genSignal(type, n) % GENSIGNAL 统一生成blocks、bumps、doppler标准测试信号 % type: 'blocks'、'bumps' 或 'doppler' % n: 采样点数 t = (0:n-1)' / n; switch lower(type) case 'blocks' y = blocks(n); case 'bumps' y = bumps(n); case 'doppler' y = doppler(n); otherwise error('未知信号类型:%s', type); end end我还习惯另加一个addNoise函数,把高斯白噪声按指定信噪比叠加到信号上,这部分在下一节详细展开。这样从信号生成到加噪再到去噪实验,全程只需两三个函数调用,代码非常清爽。
3. 实操过程与核心环节实现
3.1 加噪信号生成:SNR怎么算、噪声怎么加
标准测试流程是先把干净信号加上高斯白噪声,形成含噪观测,再运行待测试的去噪算法。这里最关键的步骤是控制信噪比(SNR),因为不同论文里SNR的定义方式可能不同,有的用dB,有的直接用噪声标准差,如果没对齐,对比实验就不公平。
我习惯用dB作为统一单位,计算公式是SNR_dB = 10 * log10(var(signal) / var(noise))。给定目标SNR_dB,可以反推出噪声标准差:
function noisy = addNoise(y, snr_db, seed) % ADDNOISE 给信号叠加高斯白噪声,返回带噪信号 % snr_db: 目标信噪比,单位dB if nargin >= 3 rng(seed); % 固定随机种子,让实验可复现 end noise = randn(size(y)); noise = noise / std(noise); % 归一化到单位标准差 signal_power = var(y); noise_power = signal_power / (10^(snr_db / 10)); noisy = y + sqrt(noise_power) * noise; end这里先对噪声做了一次归一化,确保std(noise)严格等于1,然后乘以目标标准差。这样写的优点是,无论randn生成多少数据,噪声的实际功率都能精确匹配目标SNR。如果不做这步归一化,直接用randn乘系数,由于有限样本的方差波动,实际SNR可能偏差0.2~0.5dB,在小样本实验里会影响结论。
我在实验里通常生成三组数据:每组先生成干净信号,再用addNoise(clean, 5, 1)、addNoise(clean, 10, 2)分别得到低信噪比和高信噪比的含噪信号。固定随机种子保证了每次运行结果完全一致,这一点在写论文投稿时特别重要——审稿人可以复现你的实验,数据一致性问题会少很多。
3.2 小波去噪实验:从“能跑”到“跑得对”
有了标准测试信号,最经典的验证方式就是做小波去噪对比。MATLAB的Wavelet Toolbox提供了一整套函数,我要演示的是基于Donoho-Johnstone阈值的软阈值去噪流程,这也是该领域最基础的指标性方法。
去噪过程分三步:小波分解、阈值处理、小波重构。
% 以blocks信号为例,SNR=10dB n = 1024; [clean, t] = genSignal('blocks', n); noisy = addNoise(clean, 10, 42); % 第1步:小波分解,使用db4小波,分解层数5 wname = 'db4'; level = 5; [C, L] = wavedec(noisy, level, wname); % 第2步:对每一层细节系数做软阈值处理 sigma = median(abs(C(L(1)+1:L(2)))) / 0.6745; % 噪声标准差估计 thr = sigma * sqrt(2 * log(n)); % 通用阈值 % 提取各层细节系数 for k = 1:level idx = L(k)+1 : L(k+1); % 第k层细节系数的索引范围 C(idx) = wthresh(C(idx), 's', thr); % 软阈值 end % 第3步:重构 denoised = waverec(C, L, wname); % 计算去噪后的SNR snr_out = 10 * log10(var(clean) / mean((denoised - clean).^2)); fprintf('输入SNR: %.2f dB, 去噪后SNR: %.2f dB\n', 10, snr_out);这段代码里有两个细节值得展开说。
第一个是噪声标准差估计。Donoho和Johnstone提出来的做法是取第一层细节系数(即最高频子带)的绝对中位差,再除以0.6745。0.6745是标准正态分布的第75百分位数,这样得到的估计不受信号本身结构的影响,对含有尖锐跳变的blocks信号非常稳健。如果你改用std直接估计,跳变处的系数会被当成噪声,导致阈值偏大,把真实信号细节也一起滤掉。
第二个是软阈值与硬阈值的区别。wthresh(x, 's', thr)返回的是sign(x) * max(abs(x) - thr, 0),也就是把所有系数朝零方向收缩;wthresh(x, 'h', thr)则是保留绝对值大于阈值的原值,其余置零。硬阈值重构出来的信号在跳变附近容易产生小幅振荡,软阈值更光滑,但也会让峰的幅值整体缩小。做实验时两种都跑一下,对比效果有助于理解各自特点。
同样流程可以用在bumps和doppler上。你会发现一个有意思的现象:blocks信号去噪后,跳变处的对比度保持得最好;bumps信号去噪后,窄峰幅值可能被压低,宽峰恢复得比较准;doppler信号去噪后,高频段细节要么保留较好但背景仍有噪声,要么背景干净但高频细节丢失。不同算法在这三类信号上的最优参数往往不一样,这正是它们作为“标准测试信号”的价值——用一套固定参数跑三类信号,谁的表现更均衡,谁就更适合实际应用。
3.3 从对比结果里能读出什么
做完去噪实验不要只看输出SNR数字,我强烈建议把干净信号、含噪信号、去噪信号画在同一张图上对比:
figure; subplot(3,1,1); plot(t, clean); title('Clean'); subplot(3,1,2); plot(t, noisy); title('Noisy'); subplot(3,1,3); plot(t, denoised); title('Denoised');观察的重点有三个位置:blocks的跳变点、bumps的窄峰处、doppler的高频段。跳变点旁边如果出现成对的尖峰,说明阈值偏低、噪声泄漏;窄峰幅值如果明显变矮,说明软阈值收缩过度;doppler高频段如果出现周期性波纹,说明重构时系数截断得太狠。这三类问题分别对应“欠去噪”“过去噪”和“伪影”,是任何去噪算法都会面临的三个基本矛盾。
4. 常见问题与排查技巧实录
4.1 信号形状不对:先检查这四处
我在给学生和同事调试代码时,发现生成信号环节的问题主要集中在四个地方。
第一,采样点n取得太小。blocks信号在n=64时还能看出大台阶,但bumps信号最窄的峰只有0.005宽度,在64个点上根本显示不出来,看起来就像几个离散点。doppler信号如果n小于256,高频段会完全糊成一条线。建议n至少取1024,做精细对比时用4096。
第二,sign函数误用导致台阶位置偏移。blocks里如果写成sign(t - pos(j))而漏掉了除以2,那么跳变前是-hgt/2而不是0,信号整体会有一个偏移。运行后如果发现信号最低点不是0,多半就是这个原因。
第三,doppler的t + 0.05这一项被人为抽掉。有些教程为了“数学严谨”直接除以t,结果在t=0处产生NaN,整个信号全是NaN。如果看见plot出来一片空白,先查这一句。
第四,bumps函数里忘记用./和.^。如果写成1 + ((t - pos(j)) / wth(j))^4,MATLAB会尝试做矩阵除法,报错维度不一致,或者给出一个完全错误的结果。这属于点运算和矩阵运算的混淆,初学者尤其容易犯。
4.2 去噪效果差:别急着调参数
去噪实验效果不理想时,先不要堆参数。我发现很多人第一步就去改阈值倍数,结果调了半天也不知道哪个方向是对的。建议按下面顺序排查:
先确认噪声功率是否正确匹配。打印10*log10(var(noise)),看是否接近10*log10(signal_power) - snr_db。如果偏差超过0.1dB,检查addNoise函数里的归一化步骤有没有生效。
再确认分解层数是否合理。层数太少,高频噪声滤不干净;层数太多,低频逼近系数也被当成噪声处理,信号会被整体削平。对n=1024的信号,db4小波分解5层基本够用;n=4096可以到7层。这个经验值适用于大部分光滑信号。
最后检查阈值是否对每一层都用了同一个值。Donoho-Johnstone的通用阈值sigma * sqrt(2 * log(n))是一个全局阈值,对每一层细节系数都做相同收缩。但在实际信号中,不同层的噪声能量可能不同,同一层内部不同位置的系数也有差异。如果发现某一层噪声残留很重,可以换用层内独立估计的阈值。
4.3 从“能跑”到“跑得对”的几条经验
第一,固定随机种子是实验可复现的生命线。别觉得rng(42)这种写法是小题大做,一旦论文被要求补充实验、或者审稿人要复现你的结果,同样的随机种子会省掉无数解释和纠纷。我在封装addNoise函数时特地设置了第三个可选参数seed,默认不指定时用系统时间,指定时固定结果,兼顾灵活与可复现。
第二,信号长度尽量取2的幂。小波分解对长度为2的幂的信号最友好,wavedec和waverec在边界处理上会更干净。n=1024、2048、4096都是常用值。如果你的实验要求任意长度,wavedec也能处理,但边界延拓方式可能影响首尾效果,需要额外留意。
第三,保存数据时顺便保存参数。别只存一个y.mat,至少要在文件名里带上信号类型、采样点数、信噪比和随机种子。我见过太多次实验数据堆在一起,事后完全想不起某个文件对应哪组参数,最后只能重新跑实验。用genSignal配合命名规则,比如blocks_n1024_snr10_seed42.mat,既清晰又不会出错。
5. 测试信号在更多场景里的扩展用法
如果你觉得只做去噪实验不够过瘾,这三类信号还能用在很多其他场景。我把这几年用到的几个扩展方向列在这里,供你参考。
压缩感知方向:blocks在DCT基或小波基下是稀疏的,bumps在过完备字典下也有稀疏表示,doppler则在时频域具有稀疏结构。用它们作为稀疏性先验的测试信号,可以验证不同测量矩阵、不同重构算法在有限采样率下的表现。我做过一组实验:在1024点信号中只取256个随机观测值,再用OMP和BP算法重构,对比重构误差和算法耗时,效果非常直观。
深度学习方向:现在很多论文用合成信号训练去噪网络。你可以写一个循环,用genSignal批量生成2000组不同参数、不同SNR的blocks/bumps/doppler信号,合成训练集和测试集,再丢给一个小型卷积网络训练。相比用真实采集的信号,合成数据的好处是标签完全已知,损失函数可以直接用输出与干净信号之间的MSE,训练曲线特别干净。
算法评估方向:除了信噪比,还可以计算结构相似性指标(SSIM)、峰值信噪比(PSNR),以及边缘保持指数(EPI)。不同指标反映的侧重点不同,比如SSIM更接近人眼感知,PSNR对噪声幅值敏感,EPI则直接衡量跳变位置的保持程度。用三类信号分别算这些指标,可以画出一张雷达图或者柱状图,直观比较不同算法的综合性能。
我自己实际体会最深的一点是:标准测试信号最大的价值不在于“标准”,而在于它制造困难的方式是已知的、可控的。你知道blocks有几个跳变点、bumps每个峰在什么位置、doppler的频率变化规律,所以你能够精确地定位算法在哪里失败、为什么会失败。这种“可解释的失败”比单纯追求指标数值更能推动算法改进。
最后分享一个我在项目里一直用的小技巧:把这几个函数和去噪脚本放在同一个文件夹,起名signal_benchmark/,然后在主脚本里用addpath('signal_benchmark')引入。以后无论是写新论文还是复现师兄的旧代码,都不需要到处找工具包,一套小工具就够用了。如果你在实际使用中遇到什么奇怪的报错或者效果问题,欢迎回来交流,我大概率遇到过类似的坑。