如果有人问我,在PyQt5界面里画动态连续波形图,最稳的组合是什么,我的答案一定是PyQt5配合pyqtgraph,而不是大家下意识会选的matplotlib。这个结论不是我一开始就信的,完全是被一次实测逼出来的——用matplotlib做实时刷新,波形一多直接卡成PPT,换成pyqtgraph之后,同样量级的数据居然能跑到几十帧且CPU占用很低。
这篇内容就是把我那次"实测成功"的完整过程整理出来:怎么把pyqtgraph嵌进PyQt5窗口,怎么用定时器和数据采集线程驱动动态更新,为什么setData比重新plot高效那么多,以及我踩过的四个坑。无论你是想做个串口示波器、传感器波形监控、心电信号显示,还是做AI推理过程的可视化,这套思路都能直接复用。
1. 为什么我放弃matplotlib,选择pyqtgraph画实时波形
1.1 让我下决心换库的那次卡顿现场
最早我做实时波形,用的也是matplotlib。当时项目要显示一路心电模拟信号,大概每秒200个采样点,我用matplotlib的plt.ion()加上set_ydata()做刷新,数据量不算大,但每次刷新都有明显的迟滞感,窗口拖拽和缩放更是卡得离谱。
测试场景是这样的:模拟信号源每10毫秒产生一个点,绘图区要显示最近10秒的波形,也就是1000个点,每50毫秒刷新一次。matplotlib在低刷新率下勉强能跑,但CPU占用一直居高不下,而且只要把窗口拉伸一下,或者同时显示两路信号,掉帧就非常明显,延迟从几百毫秒到下一秒都出现过。对于实时监控类工具来说,这种卡顿是致命的。
后来我换了个pyqtgraph的demo跑同样的数据,效果差异大到让我怀疑是不是数据变少了。同样的1000个点,同一个Windows平台,pyqtgraph刷新流畅到几乎感觉不到,CPU占用掉了一大截,拖拽、缩放、坐标轴跟随全都顺滑。从那一刻起,我就明白了实时波形这个场景,选型比努力更重要。
1.2 pyqtgraph的性能底气到底从哪来
pyqtgraph性能好的原因,拆开看其实有三个方面。
第一,它底层大量使用numpy数组直接操作数据,数据管线里最耗时的波形更新操作全部是向量化计算。比如setData(x, y)传入的是一整个numpy数组,绘图库内部直接交给OpenGL渲染管线或者Qt的GraphicsView体系处理,而不是像matplotlib那样对每个点都维护一个Python对象。
第二,pyqtgraph舍弃了matplotlib那套复杂的"艺术家(Artist)"对象模型,改成了一套精简的GraphicsObject体系。每条曲线本质上是一个PlotDataItem,数据更新时只需要把这个item的缓存数据替换掉,再通知视图重绘这个区域,省掉了大量对象创建和属性同步的开销。
第三,OpenGL加速在硬件支持时能开启,把绘图工作交给显卡,进一步解放CPU。不过OpenGL模式在有些老旧机器上会遇到兼容性问题,一般场景下不开也足够快。
我这样说可能有点抽象,打个比方:matplotlib像是每次重画一页PPT,得把整个页面上的元素都过一遍;pyqtgraph像是只擦掉白板上一块区域,重新写那几行字。数据量小的时候差别不明显,一旦实时刷新,这个差距就是几十倍的体验之差。
1.3 什么时候其实不需要pyqtgraph
pyqtgraph不是万能的,也不是所有画图需求都该用它。我个人的判断标准是这样的:
- 只是生成一张静态图表,数据量不大,效果要求偏"出版级"的,或者需要复杂的中文排版、统计图样式,matplotlib更合适。
- 需要交互式探索数据,比如缩放、平移、选区分析,pyqtgraph支持这些操作,但灵活度和插件生态不如matplotlib。
- 要在GUI里嵌入实时刷新的波形、频谱、散点流、多通道信号,pyqtgraph是明显更优的选择。
这一篇涉及的场景很明确:PyQt5窗口内嵌入动态波形。所以下面所有代码都围绕pyqtgraph展开。
2. 环境准备与PyQt5+pyqtgraph集成的最小骨架
2.1 安装依赖时最容易翻车的版本坑
在动手写代码之前,先把依赖装好。我用的是Python 3.10,安装命令如下:
pip install PyQt5 pyqtgraph numpy这里提醒一句,pyqtgraph的版本迭代比较快,不同版本API有细微差别。如果项目里还有其他依赖需要特定版本,建议在虚拟环境里装,不要图省事直接装到全局。另外一个常见翻车点:如果操作系统里同时存在多个Python版本,或者用了Anaconda,安装时容易装到别的解释器里去。用一个虚拟环境隔离是最省心的做法。
验证是否安装成功,可以直接在Python里跑一句:
python -c "import pyqtgraph; print(pyqtgraph.__version__)"如果输出版本号,基本就可以正常使用了。pyqtgraph在使用时会自动寻找已安装的Qt绑定库,如果同时装了PyQt5和PySide2,pyqtgraph默认优先使用PyQt5,也可以通过环境变量指定。
2.2 把波形控件嵌入GUI的最简代码
先写一个最精简的界面骨架,把pyqtgraph的波形控件放进PyQt5的窗口里。这个骨架是所有后续功能的起点,建议直接理解然后手敲一遍。
import pyqtgraph as pg from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("PyQt5 + pyqtgraph 动态波形") self.resize(800, 500) central_widget = QWidget() self.setCentralWidget(central_widget) layout = QVBoxLayout(central_widget) # 创建pyqtgraph绘图控件,并配置基础样式 self.plot_widget = pg.PlotWidget() layout.addWidget(self.plot_widget) # 配置画笔颜色和样式 self.curve = self.plot_widget.plot(pen=pg.mkPen(color='c', width=2)) # 显示网格,便于读数 self.plot_widget.showGrid(x=True, y=True, alpha=0.3) app = QApplication([]) window = MainWindow() window.show() app.exec_()代码很简单,但有三个细节值得说。
第一个细节,self.curve = self.plot_widget.plot(pen=...)返回的是一个PlotDataItem对象。很多人第一次接触pyqtgraph时,会习惯性地在每个刷新周期里调用plot()方法来画新数据。我之前也这么干过,后来发现每次plot()都会创建一个新的曲线对象,旧的曲线如果没被清除,内存和绘制压力会累积。正确做法是创建一条曲线之后复用,后面靠setData()来更新数据。
第二个细节,pg.PlotWidget()是带坐标轴和交互能力的绘图控件。如果完全不需要交互,比如只想纯展示,可以换成pg.PlotWidget()并调用setMouseEnabled(False, False)关闭鼠标交互,避免误操作干扰显示。后面实战部分我会针对波形监控场景做限制。
第三个细节,配色。波形显示背景默认是黑色,画笔颜色用亮色(青色、绿色)对比度更好,长时间盯着看不累,还能按信号通道区分曲线颜色。
2.3 先别急着动态:先把静态波形跑通
所谓"动态连续波形图",本质就是不断用新数据替换旧数据。所以在写动态逻辑之前,建议先确认静态数据显示功能是通的。这是排查问题的基本功:分层验证,别把问题混在一起。
先做个静态验证代码:
import numpy as np import pyqtgraph as pg from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("静态波形测试") self.resize(800, 500) central_widget = QWidget() self.setCentralWidget(central_widget) layout = QVBoxLayout(central_widget) self.plot_widget = pg.PlotWidget() layout.addWidget(self.plot_widget) self.curve = self.plot_widget.plot(pen=pg.mkPen(color='c', width=2)) x = np.linspace(0, 10, 500) y = np.sin(x) self.curve.setData(x, y) app = QApplication([]) window = MainWindow() window.show() app.exec_()如果你能看到一条标准的正弦曲线,说明PyQt5和pyqtgraph之间的集成已经通了。这一步是后面所有工作的地基,地基稳了,再谈动态刷新才有意义。
3. 动态连续波形的核心更新机制:定时器与setData
3.1 为什么刷新波形不建议调用plot()重新画
很多第一次用pyqtgraph的人,包括我曾经也是,看到"动态"两个字,第一反应是用一个循环不断调用plot()画新曲线。这样做的问题,我已经在上面提过:每次plot()都会新建一个完整的图形对象,旧曲线还在画布里,内存和绘图负载持续累积。最终表现就是运行时间越长越卡,直到界面彻底无响应。
动态波形的正确更新模式是复用曲线对象,替换数据。对应的方法就是setData(x, y)或者更简化的setData(y)(此时x由pyqtgraph根据y数组的下标自动生成)。
setData的性能优势在于它只更新底层数据缓存,然后触发一次区域重绘。相比plot()完整走一遍"创建图形元素、添加到场景、重新布局",开销小了一个数量级。
3.2 用定时器驱动,实现第一版动态波形
PyQt5的图形界面跑在事件循环里,所有界面更新都必须在主线程的事件循环中执行。最简单的动态刷新方式就是用QTimer定时触发一个更新函数。
下面是我实测跑通的第一个动态波形代码,逻辑是:每次定时器触发,生成一个新的正弦波数组,整体覆盖到曲线上。这其实不算严格意义的"连续波形",更像"刷新整段波形",但它最能展示setData的高效。
import sys import numpy as np import pyqtgraph as pg from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget from PyQt5.QtCore import QTimer class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("动态波形 - setData刷新") self.resize(800, 500) central_widget = QWidget() self.setCentralWidget(central_widget) layout = QVBoxLayout(central_widget) self.plot_widget = pg.PlotWidget() layout.addWidget(self.plot_widget) self.curve = self.plot_widget.plot(pen=pg.mkPen(color='c', width=2)) self.phase = 0.0 self.timer = QTimer() self.timer.timeout.connect(self.update_curve) self.timer.start(50) def update_curve(self): # 生成模拟信号:相位随时间变化的正弦波 self.phase += 0.05 x = np.linspace(0, 2 * np.pi, 200) y = np.sin(x - self.phase) self.curve.setData(x, y) app = QApplication([]) window = MainWindow() window.show() app.exec_()跑起来之后,你会看到一条正弦曲线在画布上左右滑动,流畅度明显比matplotlib版本好。定时器间隔设置成50毫秒,对应刷新率约20Hz,这是一个视觉上比较顺滑、同时CPU压力不大的折中值。
3.3 缓冲区与数据积压:动态波形的心跳设计
上面代码是"整段刷新",但很多真实场景其实是"新数据不断追加进来,旧数据滚出去"。比如采集设备每秒产生200个点,我们要在界面上显示最近1000个点。
此时需要一个缓冲区的概念。一个很朴素的做法是每来一个新点,就把数组整体左移一位,丢弃最旧的点,把新点放到末尾。这相当于一个环形缓冲区的朴素实现。
class WaveformBuffer: def __init__(self, size=1000): self.size = size self.data = np.zeros(size) def push(self, value): # 左移丢弃旧数据,末尾追加新数据 self.data[:-1] = self.data[1:] self.data[-1] = value return self.data左移操作看起来简单,但这里有个隐藏的性能问题:每次push都做一次数组拷贝,如果数据量是1000点,操作成本不大;但如果缓冲区扩大到几万点,且刷新频率很高,这个拷贝操作会让CPU压力上升。
性能更好的做法是维护一个环形缓冲区,用head索引标记当前写入位置,推入新点时覆盖旧位置。等到刷新到界面上时,从head位置开始把整个数组排好序再传给setData。
class RingBuffer: def __init__(self, size=1000): self.size = size self.data = np.zeros(size) self.head = 0 self.count = 0 def push(self, value): self.data[self.head] = value self.head = (self.head + 1) % self.size self.count = min(self.count + 1, self.size) def get_ordered(self): if self.count < self.size: return self.data[:self.count] # 从head位置切片拼接,形成按时间排序的数据 return np.concatenate([ self.data[self.head:], self.data[:self.head] ])这段代码实际运行中表现非常稳,几万点的缓冲区在20Hz刷新下CPU占用依然很低。建议做实时波形时直接采用环形缓冲区设计,给后续数据量扩展留足余地。
4. 数据采集线程与GUI刷新:把卡顿问题根治
4.1 串口/采集卡数据不能直接塞进主线程
定时器驱动能解决"界面自己造数刷新"的场景,但真实项目里数据往往是外部设备持续送过来的,比如串口、USB采集卡、网络Socket。如果直接在定时器的回调函数里去读串口,会遇到两个问题:串口读数据可能阻塞(等待数据超时),阻塞期间界面直接卡死;频繁在主线程里做设备IO,事件循环响应变慢,窗口拖拽、按钮点击都会迟滞。
正确思路是把"数据采集"和"界面刷新"拆成两层:采集线程负责从设备拿数据,放进一个共享缓冲区;界面定时器每隔几十毫秒从缓冲区取最新一批数据去刷新波形。这样即使设备IO偶尔卡一下,界面依然保持流畅。
4.2 用QThread加信号槽传递数据的标准写法
PyQt5里最规范的跨线程数据传递方式是QThread配合信号(signal)。工作线程发信号,主线程通过槽函数接收,Qt的信号槽机制保证了跨线程传递是线程安全的。
下面是一个标准demo:模拟设备每10毫秒产生一个数据点,发送到主线程,主线程收到后把点推进缓冲区并刷新界面。
import sys import time import numpy as np import pyqtgraph as pg from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget from PyQt5.QtCore import QThread, pyqtSignal, QTimer class DataWorker(QThread): data_ready = pyqtSignal(float) def __init__(self): super().__init__() self.running = True def run(self): t = 0.0 while self.running: # 模拟传感器读数,真实项目这里替换为设备读取代码 value = np.sin(t) + 0.3 * np.random.randn() t += 0.01 self.data_ready.emit(value) time.sleep(0.01) def stop(self): self.running = False class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("采集线程 + 动态波形") self.resize(800, 500) central_widget = QWidget() self.setCentralWidget(central_widget) layout = QVBoxLayout(central_widget) self.plot_widget = pg.PlotWidget() layout.addWidget(self.plot_widget) self.curve = self.plot_widget.plot(pen=pg.mkPen(color='g', width=2)) # 1000点环形缓冲区 self.buffer = RingBuffer(1000) self.refresh_x = np.arange(1000) # 启动采集线程 self.worker = DataWorker() self.worker.data_ready.connect(self.on_data_received) self.worker.start() # 界面定时刷新 self.timer = QTimer() self.timer.timeout.connect(self.refresh_plot) self.timer.start(30) def on_data_received(self, value): # 收到线程发来的数据点,推入缓冲区 self.buffer.push(value) def refresh_plot(self): # 定时器从缓冲区取有序数组刷新界面 y = self.buffer.get_ordered() n = len(y) if n < 2: return x = np.arange(n) self.curve.setData(x, y) self.plot_widget.setXRange(0, n - 1) def closeEvent(self, event): self.worker.stop() self.worker.wait() super().closeEvent(event) app = QApplication([]) window = MainWindow() window.show() app.exec_()这段代码是我实际项目的骨架,有两点特别值得注意。
第一点,关闭窗口时一定要停掉线程。上面代码里重写了closeEvent,调用worker.stop()把运行标志置False,再wait()等待线程退出。如果不这样做,主窗口关闭了,后台线程可能还在跑,导致程序进程无法退出,变成"关不掉"的僵尸进程。这是新手最容易忽略的一个坑。
第二点,信号带参数传递时,类型要匹配。用pyqtSignal(float)时,emit的参数必须能转换为float。如果你的数据是带时间戳的结构体,可以定义成pyqtSignal(object),把整个数据对象传过去。
4.3 pyqtgraph的线程边界与安全更新规则
关于pyqtgraph的线程安全,网上的说法比较模糊,我也实测过几种用法。结论是:pyqtgraph的setData建议在界面线程(主线程)里调用,不要在worker线程里直接操作曲线对象。虽然某些简单场景下这样用可能不报错,但涉及到窗口重绘、坐标轴更新时,跨线程更新UI组件会引发不可预知的崩溃或显示异常。
稳妥的做法就是上面代码演示的:worker线程只管发数据,所有界面更新集中在主线程定时器里做。这也符合Qt的UI线程模型,崩溃几率最小。
如果需要更高刷新率(比如100Hz以上),可以适当降低定时器间隔,但要注意定时器的实际触发精度。Qt的QTimer在Windows系统上的最低稳定精度大概是10毫秒左右,再小就会变得不准确。如果想要更精确的定时,或者需要严格控制采样节奏,建议在worker线程里做硬实时采样,主线程只负责以固定的界面刷新率取数据。
5. 实测路径复盘:我踩过的四个坑
5.1 坑一:坐标轴疯狂闪烁,波形像坏掉的霓虹灯
这是我最开始遇到的观感问题。动态刷新跑起来后,x轴和y轴的刻度值在快速跳动,整个画图区的坐标轴像坏掉了一样闪来闪去,盯着看一会儿眼睛就花了。
原因很简单:数据每次刷新后,pyqtgraph默认会根据新数据重新计算可视范围。如果数据边界在不停变化,坐标轴的刻度范围也跟着不停重建,视觉上就是闪烁。
解决方案是固定坐标轴范围。对于"滚动波形"场景,通常只显示最近N个点,x轴范围基本固定;y轴范围可以是输入信号的合理上下限,也可以根据历史数据动态调整但限制在某一区间。
我实测有效的做法是在刷新函数里显式设置范围,并且关闭鼠标缩放、拖动:
self.plot_widget.setMouseEnabled(x=False, y=False) self.plot_widget.setXRange(0, n - 1, padding=0) self.plot_widget.setYRange(-2, 2, padding=0)设置padding=0能避免坐标轴自动留白带来的抖动。固定范围后,波形即使在上下跳动,坐标轴刻度也不会闪来闪去,观感立刻就专业了。
5.2 坑二:内存暴涨,跑了十分钟就吃掉1GB内存
动态波形跑起来很流畅,但内存像漏水一样不停涨,这是我踩过的第二个坑。一开始我怀疑是pyqtgraph的bug,后来定位到原因完全是我自己写出来的:我在刷新函数里每次都用切片生成新的numpy数组,而且没有释放旧的引用。
比如我曾写这样的代码:
y = np.random.randn(1000) x = np.arange(1000) self.plot_widget.plot(x, y) # 错误示范:每次新建曲线对象定时器每触发一次,就新建一个PlotDataItem,旧的没有清除,场景里的图形对象越来越多。内存和绘图开销同步上涨,运行越久越卡。
修复方法是前面反复强调的:使用单一的曲线对象,配合setData覆盖数据。另外一个容易忽视的点是:setData传入的数组会被pyqtgraph缓存引用,意味着你修改这个数组,曲线上的数据也会变。所以如果不想让外部数据被内部引用拖住无法释放,最好传入显式构造的新数组或切片副本。
5.3 坑三:setData报了TypeError,原来形状维度有讲究
还有一次,在把数据接入真实采集卡时,setData直接抛了TypeError,一度让我以为是pyqtgraph的bug。排查之后发现原因很基础:setData(x, y)要求x和y是一维数组,且长度相等。
我的采集程序从设备缓冲区里读出来的数据是二维的,形状是(n, 1),就是每个采样点包了一层额外的维度。pyqtgraph不理解这种"列向量"形状,两个维度不匹配就报错了。把数据flatten()成(n,)之后一切正常。
y = raw_data.flatten()如果x也是自己构造的,也要确保是一维的。建议在使用setData前打一个断点,或者用np.ndim检查一下数组维度,很多意料之外的报错根源都在这里。
5.4 坑四:界面卡死后才发现定时器重叠
另一个让我头疼的问题是:运行一段时间后界面突然卡死,过几秒又恢复,然后再次卡死,像打嗝一样。后来我在update_curve函数入口加了一行日志,才发现同一个定时器的回调函数在上一次还没执行完时,下一次又触发了。当数据量变大、波形刷新耗时超过定时器间隔时,多个回调叠在一起执行,互相抢占资源,界面就表现出间歇性卡死。
解决办法有两种。第一种,在回调函数开头加个"正在刷新"的标记,如果上一次还没完成就跳过这次刷新:
def refresh_plot(self): if self._refreshing: return self._refreshing = True try: # 刷新逻辑 pass finally: self._refreshing = False第二种,改用单次定时器:每次回调结束后重新启动定时器,而不是循环定时器。这样天然不会重叠。
self.timer = QTimer() self.timer.timeout.connect(self.refresh_plot) def refresh_plot(self): # 刷新逻辑 self.timer.start(30) # 处理完再开始计时实测下来,对于耗时不确定的场景,加执行标志最简单可靠;对于耗时基本固定的场景,单次定时器更优雅。
6. 从能用变好用:滚动波形、采样节奏与波形模拟
6.1 示波器式滚动窗口的正确实现姿势
真实示波器和监控界面通常用的是"滚动"效果:波形从左向右持续推移,新数据出现在右侧,旧数据从左侧滑出。这种效果的数据结构就是我们前面写的环形缓冲区,但显示方式有两种常见选择。
第一种是"整体平移法",把缓冲区里的所有数据按顺序排好,一次性setData显示,同时x轴范围跟随最新点前进。我上面那段采集线程demo就是这种方式。
第二种是"增量添加法",pyqtgraph也支持每次往曲线后面添加新点:
self.curve.setData(y) # 覆盖式 # 或者使用 append 方法(视版本支持情况)不过我个人更推荐第一种,因为整体setData的逻辑简单直接,内存占用也可控,滚动效果完全由坐标轴范围的移动来实现,调试起来更直观。
滚动窗口的坐标轴更新有一点要注意:如果x轴范围始终显示最近N个点,波形滚动比较平稳;如果x轴范围跟着数据无脑跳变,视觉效果就会一卡一卡的。建议在刷新函数里固定x轴显示窗口宽度,只更新右边界,这样滚动最平滑。
6.2 更新频率与采样频率如何搭配
动态波形的"动态"背后涉及两个不同的频率:数据生成频率和界面刷新频率,不能混淆。
数据生成(采集)频率由设备或模拟信号源决定。比如每10毫秒产生一个点,即100Hz的采样率。界面刷新频率由QTimer间隔决定,比如每50毫秒刷新一次,即20Hz的界面刷新率。
这两个频率各自独立。界面刷新时,一次就把缓冲区里积攒的几个新点全部画上去,而不是等每个点来了都刷新一次。这样既保证了波形连续,又不浪费CPU在过高的界面刷新率上。
我的经验是:界面刷新率设在15Hz到30Hz之间比较合适。低于15Hz,肉眼能感觉到明显跳跃;高于30Hz,体验提升不明显,但CPU占用和发热显著增加。如果是同时显示多条波形,建议从20Hz左右起步,再根据实际运行情况调整。
6.3 用模拟数据源进行开发和测试
真实设备不一定随时在线,而且开发阶段反复连接设备很不方便。我强烈建议在项目里做一个模拟数据源,用来单独验证绘图和刷新逻辑。等绘图层稳了,再替换成真实设备的数据,排查问题的范围会小很多。
我常用的是这样的模拟代码,可以生成"带趋势、带噪声、带异常尖峰"的信号,比纯正弦波更接近真实传感器数据:
class MockSignalSource: def __init__(self): self.t = 0.0 def next_value(self): # 模拟一条带直流偏置、低频波动和随机噪声的信号 base = 0.5 + 0.3 * np.sin(2 * np.pi * 0.5 * self.t) low_freq = 0.2 * np.sin(2 * np.pi * 0.05 * self.t) noise = 0.1 * np.random.randn() spike = 5.0 if np.random.rand() < 0.002 else 0 self.t += 0.01 return base + low_freq + noise + spike尖峰数据可以用来测试绘图代码是不是会被异常值带跑坐标轴,这是真实监控里很重要的一点。如果你后续想扩展功能,还可以在模拟数据里加入信号丢失、数据毛刺等状况,验证界面的健壮性。
实测后期,我把绘图层和模拟数据源分开封装之后,接入真实串口数据几乎没改绘图代码,只替换了数据来源。这个架构上的小设计,让我少踩了很多次调试的苦。
如果你也准备做实时波形相关的工具,可以从最小骨架开始,先把静态波形跑通,再加定时器,再加线程和缓冲区,最后再考虑滚动效果和模拟数据。每一步踩的坑都有清晰的边界,遇到问题也容易定位。这套流程我反复验证过,确实稳。