先说结论:Python完全能做到MATLAB那种多波形叠在一起对比的效果,而且还能做得更灵活。我当初从MATLAB切换到Python做信号处理时,也经历过一段别扭期——MATLAB的plot、hold on用惯了,总希望Python绘图也能一个函数就搞定。实际上Python生态里以matplotlib为主力的绘图工具,配合NumPy处理数据,叠图能力不输MATLAB,关键是得知道正确的“叠加姿势”。
这篇文章写给三类人:刚从MATLAB转过来的老工程师、正在学信号处理但不想守着一个封闭工具的研究生、需要在批量出图时提高效率的测试开发。全文不绕弯子,直接从plot最基础的叠加写法开始,一路讲到共享坐标轴、交互式叠图、动态波形叠加以及大数据量绘图的性能问题,中途会穿插我实际踩过的坑。
1. 为什么会产生“像MATLAB一样叠图”这种需求
1.1 MATLAB叠图习惯与Python的天然差异
在MATLAB里画多条曲线对比,核心就三句话:figure开画布、plot画曲线、hold on锁定坐标轴。代码长这样:
figure; plot(t, signal_a, 'r'); hold on; plot(t, signal_b, 'b'); plot(t, signal_c, 'g'); hold off; legend('A', 'B', 'C');这种操作太自然了,自然到很多从MATLAB转过来的人第一次用Python画多曲线时,写出的代码是这样的:
import matplotlib.pyplot as plt plt.plot(t, signal_a, 'r') plt.plot(t, signal_b, 'b') plt.plot(t, signal_c, 'g') plt.show()跑起来会发现三条线确实都在,但图例、坐标范围、线型这些细节全都得靠后续代码补。更让人不习惯的是Python里的状态机机制——在MATLAB中,图窗、坐标轴、曲线是显式的“对象”,而在matplotlib里,所有绘制操作都是作用在一个“当前坐标轴”上。这种设计本意是节省代码量,但初次接触时容易产生“不受控”的感觉。
1.2 真实场景:为什么多波形叠在一起比子图更好用
对比分析的场景很多。比如我反复处理的传感器标定数据,三轴加速度计同一时间段内的X轴、Y轴、Z轴波形,如果拆成三个子图,眼睛得来回扫描才能判断哪段时间哪个轴跳变异常;但如果叠在同一张图里,一两秒内就能看出三轴之间的时序关系、幅值差异、相位偏移。
叠加图的另一个优势是“数据密度高”。一张叠图可以容纳四到八条曲线,横向对比的直觉效率远高于子图。缺点也很明显,曲线多了会糊在一起,所以需要一套成熟的视觉区分方案:颜色、线型、透明度、线宽搭配着用。
2. 基础叠加的两种写法:从plt.plot到plt.subplots
2.1 最直观的叠加写法在与它的验证
回到刚才那段基础代码。它确实能叠图,但不够专业。专业写法是显式使用figure和axes对象:
import numpy as np import matplotlib.pyplot as plt # 构造三组模拟波形,模拟采样率1000Hz fs = 1000 t = np.arange(0, 1.0, 1/fs) signal_a = np.sin(2 * np.pi * 5 * t) signal_b = np.sin(2 * np.pi * 8 * t + 0.5) signal_c = np.sin(2 * np.pi * 12 * t + 1.0) fig, ax = plt.subplots(figsize=(10, 5)) ax.plot(t, signal_a, label='5Hz', color='#1f77b4', linewidth=1.5) ax.plot(t, signal_b, label='8Hz', color='#ff7f0e', linewidth=1.2) ax.plot(t, signal_c, label='12Hz', color='#2ca02c', linewidth=1.2) ax.set_xlabel('时间 (s)') ax.set_ylabel('幅值') ax.set_title('多波形叠加对比') ax.legend(loc='upper right') ax.grid(True, linestyle='--', alpha=0.6) plt.tight_layout() plt.show()这样写的好处是:所有配置都围绕ax对象展开,逻辑清晰,不会因为状态机残留导致画在错误的坐标轴上。我看过不少新人的代码,画着画着发现曲线出现在上一张图里,就是因为在切换画布时没有显式指定当前坐标轴。
2.2 用plt.subplots的扩展能力为后续铺路
plt.subplots的真正优势在于它支持“部分叠加、部分子图”的混合布局。比如我想把原始波形和它的滤波结果叠在一起,再把频谱图放在旁边,就需要GridSpec或add_subplot的方式来管理坐标轴:
import matplotlib.gridspec as gridspec fig = plt.figure(figsize=(12, 6)) gs = gridspec.GridSpec(2, 2, width_ratios=[3, 1], height_ratios=[1, 1]) ax1 = fig.add_subplot(gs[0, :]) # 第一行横跨两列,叠图区 ax2 = fig.add_subplot(gs[1, 0]) # 第二行左列,局部细节 ax3 = fig.add_subplot(gs[1, 1]) # 第二行右列,频谱或放大区域 ax1.plot(t, signal_a, label='原始') ax1.plot(t, signal_b, label='滤波后') ax1.legend() ax1.set_title('波形叠加') ax2.plot(t[:500], signal_a[:500], color='#d62728') ax2.plot(t[:500], signal_b[:500], color='#1f77b4') ax2.set_title('局部放大') ax3.specgram(signal_a, Fs=fs, cmap='viridis') ax3.set_title('频谱') plt.tight_layout() plt.show()我平时做对比分析时,很少只画一张叠图,通常会配一个局部放大窗口。这比MATLAB里用xlim手动改范围舒服得多,因为放大窗口固定显示前500毫秒,主图保留全局视角,两个视图互不干扰。
3. 多波形对比的视觉优化:颜色、线型和透明度
3.1 我的选色方案与线型搭配逻辑
多曲线叠图最怕“花”。我在实际使用中总结出一套比较稳的方案:曲线数量少于等于四条时,用颜色区分就够了;五条以上必须引入线型变化,因为色盲人群和灰度打印场景下纯靠颜色区分会出问题。
我的默认线型方案:
| 曲线序号 | 颜色 | 线型 | 线宽 |
|---|---|---|---|
| 1 | #1f77b4(蓝色) | 实线 | 1.5 |
| 2 | #ff7f0e(橙色) | 虚线 | 1.5 |
| 3 | #2ca02c(绿色) | 点划线 | 1.5 |
| 4 | #d62728(红色) | 点线 | 1.5 |
| 5 | #9467bd(紫色) | 实线+粗 | 2.0 |
如果需要超过六条曲线,我会先在业务层面考虑是否真的要叠那么多,因为超过六条之后哪怕颜色线型全用上,肉眼也很难追踪每条曲线。这时候更适合拆成两个子图,或者用后面要讲的交互式方案。
3.2 用透明度解决数据重叠时的“看不清”问题
当信号幅值接近、曲线几乎完全重合时,单纯换颜色作用有限。这时透明度(alpha)反而更有效。比如对比两次实验采集的同一通道波形,由于噪声和漂移,波形整体走向一致但细节有差异,用不透明画法几乎看不出区别,把两条曲线都调成半透明后,重叠区域颜色加深,差异区域则因为两条线不完全重叠而显现出浅色边界,差异一下子就看出来了。
fig, ax = plt.subplots(figsize=(10, 4)) ax.plot(t, exp_a, label='实验A', color='#1f77b4', alpha=0.6, linewidth=1.8) ax.plot(t, exp_b, label='实验B', color='#ff7f0e', alpha=0.6, linewidth=1.8) ax.fill_between(t, exp_a, exp_b, color='gray', alpha=0.2, label='差异区域') ax.legend() ax.grid(True, alpha=0.3) plt.show()这里的fill_between是另一个实用技巧:直接填充两条曲线之间的区域,差异一目了然。我经常把它用在“算法优化前后对比”的场景里,背景灰影就是优化的增益区域,比盯着两条线的缝隙猜半天强得多。
3.3 图例位置与坐标轴范围设置中的细节
图例默认位置是best,但波形图右上角经常是信息最密集的区域。我习惯看数据特征再定图例位置——如果主要波形集中在中上部,就把图例放lower right;如果波形在中间震荡,就把图例放在图外右侧,用bbox_to_anchor做坐标偏移。
坐标轴范围的坑也值得提一下。新手经常不设置xlim和ylim,导致matplotlib自动选择的Y轴范围把所有曲线都“压扁”,高频细节完全看不见。我处理带直流偏置的信号时深有体会:正弦波叠了一个2V直流分量后,自动限幅会让波动看起来只有一条直线。手动限定Y轴范围后,细节完全出来了。经验是做叠加对比前,先单独plot一遍看每段波形的幅值区间,再统一设定ylim,而不是让系统自动选。
4. 数据来源不同时的叠加技巧:时间轴对齐与单位转换
4.1 时间戳对齐的通用做法
做实测数据对比时,最麻烦的不是画图本身,而是数据时间轴对不齐。比如我用不同采样率采集的同一信号:数据A是1000Hz采的,数据B是256Hz采的,直接叠图,时间长度对不上,视觉上完全错位。
解决思路有两个:一是插值到统一采样率,二是用时间戳对齐到同一零点。插值我常用scipy.interpolate里的interp1d:
from scipy.interpolate import interp1d # 数据B从256Hz插值到1000Hz t_b_old = np.linspace(0, duration, len(data_b)) f_interp = interp1d(t_b_old, data_b, kind='linear', bounds_error=False, fill_value='extrapolate') data_b_new = f_interp(t)时间戳对齐更常见:每个数据文件里都带一个起始时间戳,统一把相对时间计算成“从各自零点开始的秒数”,然后对齐到触发沿。比如电机启动瞬间,我会以启动时刻为0秒,把启动前的数据划为负时间,这样就能把不同次实验的启动瞬间重叠在一起对比,直接观察启动冲击的差异。
4.2 不同单位、不同量级的物理量怎么叠
这是另一个高频场景:温度、振动、转速三个物理量,量级完全不在一个水平。温度可能是25到80度,振动加速度可能是0到10g,转速可能是0到3000rpm。硬叠在一起,振动曲线会被压缩成一条直线。
我的做法是双Y轴叠加,在matplotlib里用twinx():
fig, ax1 = plt.subplots(figsize=(10, 5)) ax1.plot(t, temp, label='温度', color='#d62728', linewidth=1.5) ax1.set_xlabel('时间 (s)') ax1.set_ylabel('温度 (°C)', color='#d62728') ax1.tick_params(axis='y', labelcolor='#d62728') ax2 = ax1.twinx() ax2.plot(t, vibration, label='振动', color='#1f77b4', linewidth=1.5) ax2.set_ylabel('振动 (g)', color='#1f77b4') ax2.tick_params(axis='y', labelcolor='#1f77b4') # 合并图例 lines1, labels1 = ax1.get_legend_handles_labels() lines2, labels2 = ax2.get_legend_handles_labels() ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left') plt.title('温度与振动联合对比') plt.show()双Y轴解决了量级问题,但也带来一个副作用:图例行会互相干扰。所以合并图例的写法一定要用上面get_legend_handles_labels的方式,手动拼接Handles,而不是简单legend()两遍。
4.3 分贝刻度与对数坐标在叠图中的处理
有些信号天然适合对数坐标展示,比如频响曲线。叠加对比频响曲线时,频率轴要log刻度,幅值轴用分贝。matplotlib里写法很简单:
ax.set_xscale('log') ax.set_yscale('log')但在对数坐标下叠加时要注意,fill_between区域的视觉效果和线性坐标完全不同,填充边界会显得很锐利。我在实际对比滤波器设计前后的频响曲线时,更倾向于用log频率轴、线性幅值轴,因为人耳对频率的感知是对数的,纯对数幅值轴会让低幅值区域的细节过度放大,反而不利于判断噪声底。
5. 交互式叠图:让多波形对比摆脱“一张静态图”的限制
5.1 用图例开关曲线
静态图只能展示固定信息,真正做数据探索时,交互式叠图是刚需。matplotlib的legend默认支持点击切换曲线显示状态,开启方式很简单:
fig, ax = plt.subplots(figsize=(12, 6)) for i, (label, data) in enumerate(signals_dict.items()): ax.plot(t, data, label=label, linewidth=1.2) ax.legend(loc='upper right', ncol=2) ax.set_title('点击图例可隐藏/显示曲线') plt.show()在默认交互状态下,点击图例里的条目,对应曲线就隐藏,再点击恢复。做多通道传感器数据的探索时,我通常是先全部显示,然后逐个隐藏排除干扰源,找出哪几个通道和数据异常有关。这个功能在静态图片上是完全体会不到的。
5.2 用mplcursors做数据标注交互
静态图上用annotate标注一个点需要手工指定坐标,等我实际观察数据时,往往不确定哪些点需要标注。mplcursors这个库可以直接悬停显示坐标信息:
pip install mplcursorsimport mplcursors fig, ax = plt.subplots(figsize=(10, 5)) line_1, = ax.plot(t, signal_a, label='原始', color='#1f77b4', linewidth=1.2) line_2, = ax.plot(t, signal_b, label='滤波后', color='#ff7f0e', linewidth=1.2) ax.legend() ax.grid(True, linestyle='--', alpha=0.5) cursor = mplcursors.cursor([line_1, line_2], hover=True) cursor.connect("add", lambda sel: sel.annotation.set_text( f"t={sel.target[0]:.3f}s\n原始={sel.target[1]:.3f}\n滤波后={sel.target[1]:.3f}" )) plt.show()悬停到曲线上,直接用鼠标找到具体时刻对应的数值,不用反复xlim放大、看坐标轴、再缩小,效率提升很明显。特别是对比很多条波形时,悬停取数比读轴快得多。
5.3 用滑块与按钮搭建一个简易对比面板
再进阶一点,我用matplotlib的Slider和Button控件搭过简易的信号对比面板。比如对比不同截止频率下的滤波效果,一个滑块控制截止频率,图形实时更新叠加曲线,相当于一个小型GUI工具,但完全基于matplotlib官方控件,不需要引入额外的GUI框架。
from matplotlib.widgets import Slider, Button fig, ax = plt.subplots(figsize=(10, 5)) plt.subplots_adjust(bottom=0.25) # 初始曲线 t = np.arange(0, 1, 0.001) raw = np.sin(2*np.pi*5*t) + 0.5*np.sin(2*np.pi*50*t) filtered_0 = lowpass(raw, cutoff=15) # 自定义滤波函数 line1, = ax.plot(t, raw, label='原始信号', color='#1f77b4', linewidth=1.2) line2, = ax.plot(t, filtered_0, label='滤波后', color='#ff7f0e', linewidth=1.5) ax.legend() ax.grid(True, alpha=0.3) # 滑块 ax_slider = plt.axes([0.15, 0.1, 0.7, 0.03]) slider = Slider(ax_slider, '截止频率', 1, 50, valinit=15, valstep=0.5) def update(val): cutoff = slider.val filtered = lowpass(raw, cutoff) line2.set_ydata(filtered) fig.canvas.draw_idle() slider.on_changed(update) plt.show()交互式叠图的本质是把“数据对比”从被动看图变成主动探索,滑块和悬停标注只是起步,用ipywidgets在Jupyter环境里还能做更复杂的联动交互。但需要注意,交互控件对性能有要求,如果数据量太大,拖动滑块会卡顿,这时候就需要用到性能优化方案了。
6. 大数据量波形叠加的性能优化与实测体验
6.1 数据量一大就卡?先分清瓶颈在哪
有一次我要在叠图里放四路采样率1MHz的振动信号,每路数据长度200万个点,四路总共800万点。这么画下去,matplotlib直接卡死几十秒,拖动缩放完全没法用。
性能瓶颈主要有三个:绘图命令数量、图形元素数量、渲染后端。我做了下面几件事,效果立竿见影。
第一步,缩减每路曲线的点数,用降采样的方式保留波形包络。对周期性信号,直接等间隔采样就行;对噪声和冲击信号,不能简单抽稀,要用scipy.signal.resample或者峰值保持法。我常用的是max-pooling思路,把200万个点按每50个点一组,每组取最大值和最小值,画出来就是一条“包络带”,视觉上保留了全部峰值信息:
def envelope_downsample(data, block=50): n = len(data) n_blocks = n // block trimmed = data[:n_blocks * block].reshape(n_blocks, block) return trimmed.max(axis=1), trimmed.min(axis=1) env_max, env_min = envelope_downsample(vibration) t_env = t[::block] ax.plot(t_env, env_max, color='#1f77b4', linewidth=0.8) ax.plot(t_env, env_min, color='#1f77b4', linewidth=0.8)第二步,设置ax.set_rasterized(True),把底层图形栅格化。矢量图在点少时很清晰,点多时渲染开销巨大;栅格化后以位图呈现,在缩放时稍微损失一点清晰度,但流畅度提升是数量级的。这个参数在图例、坐标轴文字之外的部分生效。
第三步,切换渲染后端。如果用的是QtAgg或TkAgg,卡顿感明显高于WebAgg。我实测过同一份数据,WebAgg的拖动缩放流畅度最好,但它在浏览器里打开,部分公司内网环境可能有限制。轻量数据场景,QtAgg其实也够用,关键在于前两步优化。
6.2 降低重绘开销:set_ydata与draw_idle
动态更新曲线时,不要用ax.plot重新画,应该用line.set_ydata替换数据,再用fig.canvas.draw_idle()触发重绘。这两者的性能差别相当于“整个页面刷新”和“局部刷新”的差别。滑块调参示例里我已经用了这个技巧,但在大循环里很容易忘。反复draw_idle虽然比draw好,但也不是越多越好,状态改变后只调用一次即可。
6.3 我的性能优化试验记录
| 场景 | 数据量 | 原始耗时 | 优化后耗时 | 优化手段 |
|---|---|---|---|---|
| 四路1MHz振动信号叠图 | 800万点 | 约42秒 | 约3.5秒 | 包络降采样+栅格化 |
| 拖动滑块实时调参 | 原始200万点/路 | 明显卡顿 | 基本流畅 | set_ydata+降采样 |
| 六路长时间趋势对比 | 1800万点 | 约2分钟 | 约6秒 | 栅格化+WebAgg后端 |
这个表格是我在实际调试中记录下来的参考数据,不同机器配置差异很大,但趋势是一致的:包络降采样和栅格化组合,处理几百万点级别的数据完全够用。超过千万点时,建议直接考虑用plotly或Bokeh这类专用可视化库,硬撑matplotlib不值得。
7. 批量出图时的自动化处理:从单张叠图到自动对比图集
7.1 用循环批量生成对比图
开发测试阶段,我经常要给几十组数据各生成一张对比图,每张图里叠加原始波形和滤波后波形,还要标注关键指标。手工一张张画不现实,写个循环能解决:
import os output_dir = 'compare_figs' os.makedirs(output_dir, exist_ok=True) figures_meta = [ ('measure_01.csv', 'kalman', '卡尔曼滤波'), ('measure_02.csv', 'butter', '巴特沃斯滤波'), ('measure_03.csv', 'moving_avg', '滑动平均'), ] for filename, method, method_name in figures_meta: data = load_csv(filename) t_axis = data['time'] raw = data['raw'] filtered = process_filter(raw, method) fig, ax = plt.subplots(figsize=(10, 4)) ax.plot(t_axis, raw, label='原始', alpha=0.6, linewidth=1) ax.plot(t_axis, filtered, label=method_name, linewidth=1.5) ax.legend(loc='upper right') ax.set_title(f'{filename} - {method_name}对比') ax.grid(True, alpha=0.3) fig.savefig(os.path.join(output_dir, f'{filename.replace(".csv", "")}_{method}.png'), dpi=150) plt.close(fig) # 关键:用完后关闭,否则内存会涨批量出图最容易被忽略的是内存管理。循环里每生成一张图,如果不close,图对象一直驻留在内存里,几十张图之后内存占用可能突破几百MB,最终程序变慢甚至崩溃。我习惯在savefig之后立即plt.close(fig),养成这个习惯能少踩很多坑。
7.2 自动对比报告:把叠图和统计数据拼成HTML
更进一步,我把叠图结果自动生成简单HTML报告,每张图下面附上关键统计指标表格,比如峰值、均方根、信噪比。用matplotlib直接写HTML并不复杂,fig.savefig出图后,用模板字符串拼接就行。这样持续集成的测试结果直接输出成一份可归档的对比报告,比手动整理截图省力得多。
7.3 命名规范与成果归档的教训
批量出图最容易乱的是文件命名。我早期吃过亏:文件名里只有序号没有参数信息,等到要追溯某次测试结果时,根本不知道哪张图对应什么条件。后来定了个简单规则:{数据源名}_{算法名}_{参数值}.png。比如motor_01_kalman_cutoff15.png,看到文件名就能还原当时的处理条件。配合dpi=150和bbox_inches='tight'裁剪空白,归档图集的观感也会好很多。
8. 我实际用下来的一套多波形叠图推荐配置
8.1 推荐使用的绘图参数
综合这几年使用经验,叠图场景下我固定的基础配置是:
- 画布尺寸:
figsize=(12, 6)或(14, 5),横长竖短,方便横向对比 - 中文字体:
plt.rcParams['font.sans-serif'] = ['SimHei'],但要注意Linux服务器上没有SimHei字体,这时要么安装字体,要么全用英文标签避免乱码 - 分辨率:交互探索用默认值;归档出图用
dpi=200 - 网格线:
ax.grid(True, linestyle='--', alpha=0.4),透明度别太高,否则会和曲线混在一起 - 坐标轴宽度:
ax.spines['top'].set_visible(False)、ax.spines['right'].set_visible(False),去掉两个边框,让视觉集中在数据上
8.2 将常用叠图逻辑封装为函数
反复写叠图代码太啰嗦,我把它封装成了一个自制函数。展示一下核心结构:
def multi_compare(curves, labels=None, colors=None, alphas=None, title='', xlabel='', ylabel='', ylim=None, fig_size=(12, 6), save_path=None): fig, ax = plt.subplots(figsize=fig_size) for idx, curve in enumerate(curves): ax.plot(curve, label=labels[idx] if labels else f'曲线{idx+1}', color=colors[idx] if colors else None, alpha=alphas[idx] if alphas else 0.8) ax.set_title(title) ax.set_xlabel(xlabel) ax.set_ylabel(ylabel) ax.grid(True, linestyle='--', alpha=0.4) if ylim: ax.set_ylim(ylim) ax.legend(loc='best') if save_path: fig.savefig(save_path, dpi=200, bbox_inches='tight') return fig, ax这个封装基本覆盖了日常80%的叠图需求,颜色图例线宽都设好默认值,特殊场景再手动调整。批量调参时直接改ylim和colors就行,不用每次从头写一套配置。
8.3 几个容易被忽视的细节提醒
最后说几个我自己踩过坑、不太会在文档里写到的提醒:
一是坐标轴单位。叠图时如果两条轴表示不同物理量(比如一条是角度,一条是角速度),图例里必须写明单位,否则读者会误以为是同一量纲的偏差。二是plt.legend()和ax.legend()混用的问题。在某些复杂布局下,plt.legend()认的是最近创建的坐标轴,而ax.legend()精确作用于指定坐标轴。为了保险,我在所有带figure/axes的代码里只用ax.legend()。三是保存图片时一定要加bbox_inches='tight',否则标题或者图例很容易被边缘裁掉。四是tight_layout()调用时机,我一般在savefig之前调用,顺序反过来时,保存出的图和预览的图可能不一样。
还有一点不得不提:matplotlib默认的中文显示在多数环境下是残缺的。要么用英文label,要么一次性配置好字体然后存放在启动配置脚本里。不要等到画到第20张图时才去补字体,那感觉可太狼狈了。
多波形叠图说到底不是单纯画个线的问题,而是数据对比方法论的一部分。把颜色、线型、透明度、坐标轴、交互方式、性能瓶颈都考虑进去之后,叠出来的图才真正能帮人快速发现数据里的规律。从MATLAB转过来的朋友,前面可能会有一段别扭期,但一旦适应了基于figure/axes的对象式绘图思维,就会发现Python这套机制的灵活度和扩展性确实可以做得比MATLAB更深。