☰
PyQt5+pyqtgraph实时绘图:高性能数据可视化实战
2026/10/15 13:07:00 网站建设 项目流程

1. 为什么实时绘图这件事值得单独拿出来说

做上位机、测试工具或者数据采集软件的朋友,大概率都绕不开一个需求:把传感器、串口、网口或者仿真出来的数据,实时画成曲线显示出来。这个需求听起来简单,但真动手做的时候,坑一个接一个。用 Matplotlib 吧,刷新一快就卡成幻灯片;用 Qt 自带的 QPainter 自己画吧,坐标轴、缩放、图例全得手搓,工作量爆炸。折腾一圈下来,PyQt5 + pyqtgraph这套组合基本是绕不过去的最优解。

我这些年做过不少数据监控类的桌面工具,从几十赫兹的温湿度采集,到上千赫兹的振动信号显示,pyqtgraph 几乎是我默认的绘图库。它的底层基于 Qt 的 Graphics View 框架,加上大量针对实时场景的优化,画动态曲线的时候帧率能轻松跑到 60fps 以上,而 CPU 占用还压得很低。这篇文章我就把 PyQt5 配合 pyqtgraph 做实时数据绘制的完整思路、关键参数、踩过的坑,一次性讲透。不管你是刚接触 PyQt 的新手,还是已经写过几版监控界面但总觉得卡顿的老手,应该都能从里面捞到点能直接抄的东西。

核心要解决的问题其实就三个:数据怎么高效地喂给绘图控件、界面怎么在刷新时不卡死、曲线怎么画才能既流畅又不吃内存。这三个问题解决了,一个能打的实时绘图工具就成型了。下面我按设计思路、核心细节、实操实现、问题排查四个大块来展开,中间会穿插大量我实际调过的参数和代码片段。

2. 整体设计思路与方案选型拆解

2.1 为什么是 pyqtgraph 而不是 Matplotlib

先说选型。很多人第一反应是用 Matplotlib,毕竟熟。但 Matplotlib 的设计初衷是出版级静态图,它的渲染管线每次重绘都要走一遍完整的 Agg 后端流程,哪怕你只改了一个点的数据,它也可能把整张图重新算一遍。实测下来,Matplotlib 在嵌入式 Qt 里做实时刷新,超过 20fps 就开始明显掉帧,数据量一大直接卡死主线程。

pyqtgraph 完全是另一个路子。它把绘图元素直接映射成 Qt 的图形项(QGraphicsItem),利用 Qt 的场景图机制做局部重绘。你更新一条曲线的数据,它只重画那条曲线所在的区域,坐标轴、网格这些静态元素基本不动。这就好比 Matplotlib 是每次把整面墙重新刷一遍,而 pyqtgraph 只补刷脏了的那一块,效率差着数量级。

还有一个关键点是 pyqtgraph 对 NumPy 数组是原生支持的。你直接把一个numpy.ndarray塞给setData(),它内部用向量化操作处理,比 Python 列表循环快得多。做实时绘图,数据本身就是连续产生的,用 NumPy 的环形缓冲区来管理再合适不过。

提示:如果你的需求是出论文插图、静态报表,Matplotlib 依然是更好的选择。但凡是涉及"动起来"的曲线,优先考虑 pyqtgraph。

2.2 数据流架构:生产者-消费者模型

实时绘图最容易犯的错误,就是把数据采集和界面刷新塞在同一个线程里。串口读数据是阻塞的,你一边读一边画,读的时候界面就僵住了,用户点个按钮都没反应。正确的做法是采集和显示分离。

我通常的架构是这样:一个独立的工作线程负责从数据源(串口、网卡、仿真器)读数据,读到的数据先塞进一个线程安全的队列(queue.Queue或者collections.deque)。主线程里用 Qt 的定时器(QTimer)周期性去队列里取数据,批量更新到绘图控件上。这样采集线程只管读,主线程只管画,两边通过队列解耦,谁也不会阻塞谁。

这里有个细节:不要每来一个数据点就调一次setData()。假设你的采样率是 1000Hz,那就是每秒 1000 次重绘请求,Qt 的事件循环根本处理不过来,界面会卡到怀疑人生。正确的做法是攒一批再画,比如定时器设成 30ms 触发一次,每次把这段时间攒下的几十个点一次性更新上去。人眼对 30fps 以上的刷新已经感知不出差别了,30ms 对应约 33fps,完全够用。

2.3 缓冲区管理:环形缓冲 vs 滚动窗口

实时曲线有两种常见的显示模式。一种是滚动模式,曲线像心电图一样从右往左滚,窗口里始终显示最近 N 个点;另一种是示波器模式,X 轴固定范围,新数据不断填充,填满了就整体左移。

不管哪种模式,底层都需要一个固定长度的缓冲区。我强烈建议用 NumPy 预分配一个定长数组,配合一个写指针做环形缓冲。为什么不用list.append()然后切片?因为列表的 append 和切片在数据量大时会产生大量临时对象,GC 压力大,而且切片操作是 O(n) 的拷贝。环形缓冲用预分配数组,写入是 O(1),读取视图也是 O(1),内存占用恒定,不会随着运行时间增长而膨胀。

具体做法是开一个长度为 N 的np.zeros(N),维护一个write_index,每写一个点就buffer[write_index] = value,然后write_index = (write_index + 1) % N。要显示的时候,用np.roll或者切片拼接把逻辑顺序理顺。这里np.roll会拷贝,数据量大时也有开销,更省的做法是维护两个切片视图,或者干脆用双倍长度的缓冲区做镜像,避免 roll。

3. 核心细节解析与实操要点

3.1 pyqtgraph 的 PlotWidget 与 PlotItem 怎么选

pyqtgraph 提供了两个层级的绘图入口。最上层是PlotWidget,它本身是个 QWidget,可以直接塞进 Qt 的布局里,用起来最省事。下层是PlotItem,它是个图形项,需要你自己放进GraphicsLayoutWidget或者GraphicsView里。

新手直接用PlotWidget就行,它内部已经帮你封装好了PlotItem、坐标轴、场景。但如果你要做多图联动、自定义布局,比如一个窗口里上下叠三个曲线图,那就得用GraphicsLayoutWidget配合addPlot(),这样能精确控制每个子图的位置和大小。

import pyqtgraph as pg from PyQt5 import QtWidgets # 简单场景:单个曲线图 app = QtWidgets.QApplication([]) plot_widget = pg.PlotWidget() plot_widget.show() # 复杂场景:多子图 win = pg.GraphicsLayoutWidget() plot1 = win.addPlot(row=0, col=0, title="通道1") plot2 = win.addPlot(row=1, col=0, title="通道2") win.show()

我个人的习惯是,只要超过一个图,就用GraphicsLayoutWidget,因为后期加图例、加游标、做联动的时候,PlotItem的接口更灵活。

3.2 曲线对象的创建与 setData 的正确姿势

画一条曲线,核心就两步:创建PlotDataItem,然后不断调setData()。

curve = plot_widget.plot(pen=pg.mkPen(color='y', width=2)) # 后续更新 curve.setData(x_array, y_array)

这里有几个关键点。第一,pen参数决定线条样式,pg.mkPen可以指定颜色、线宽、线型。实时曲线我一般用 1.5 到 2 的线宽,太细了看不清,太粗了刷新时像素填充开销大。

第二,setData()每次都会触发重绘。如果你同时更新多条曲线,比如 8 通道的数据,不要一条一条调setData(),那样会触发 8 次重绘。更好的做法是用setData()的批量更新,或者临时关闭重绘,全部更新完再开。pyqtgraph 提供了setUpdatesEnabled(False)和setUpdatesEnabled(True)来包住批量操作。

第三,X 轴数据能省则省。如果你的 X 轴就是等间隔的采样点序号,完全不需要传 x 数组。pyqtgraph 在只传 y 数组时,会自动用 0,1,2,3... 作为 X 坐标,省去一次数组构造和传输。这个优化在高速刷新时能省下可观的 CPU。

3.3 坐标轴与网格的性能陷阱

坐标轴看着不起眼,但它是实时绘图里隐藏的性能杀手。默认情况下,pyqtgraph 的坐标轴会自动根据数据范围调整刻度,每次数据更新它都要重新计算刻度位置、重绘刻度文字。数据范围频繁变化时,这个开销不小。

我的做法是固定坐标轴范围。既然是实时监控,Y 轴范围通常是你关心的量程,比如温度 0 到 100 度,那就直接setYRange(0, 100, padding=0)锁死。X 轴如果是滚动模式,范围也是固定的窗口宽度。范围固定了,刻度就不用反复算,重绘量大幅下降。

网格线也是同理。showGrid(x=True, y=True)打开网格会好看,但网格线多了也增加绘制负担。如果追求极致性能,可以关掉网格,或者只保留一个方向的网格。

plot_widget.setYRange(0, 100, padding=0) plot_widget.setXRange(0, 500, padding=0) plot_widget.showGrid(x=True, y=True, alpha=0.3)

注意:padding=0很重要。pyqtgraph 默认会在数据范围外留一点边距,导致你设的范围和实际显示范围不一致,滚动的时候会有轻微跳动。

3.4 抗锯齿与降采样的取舍

pg.setConfigOptions(antialias=True)能让曲线看起来更平滑,但抗锯齿是拿性能换的。在数据点密集、刷新率高的场景下,抗锯齿会让帧率明显下降。我的建议是:数据点少(几百个以内)时开抗锯齿,数据点多(几千个以上)时关掉。因为点多了以后,相邻点挤在一起,抗不抗锯齿肉眼根本分不出来,纯属浪费算力。

另一个相关设置是useOpenGL。pyqtgraph 支持用 OpenGL 加速渲染,pg.setConfigOptions(useOpenGL=True)。在数据量特别大(几万个点)的时候,OpenGL 能显著提升帧率。但 OpenGL 也有代价:它依赖显卡驱动,在某些老机器或者远程桌面环境下可能出问题,而且开启后某些绘图特性会受限。我的经验是,数据点超过 5000 再考虑开 OpenGL,否则没必要引入这个复杂度。

4. 完整实操过程与核心环节实现

4.1 环境准备与依赖安装

先把环境搭起来。核心依赖就三个:PyQt5、pyqtgraph、numpy。

pip install PyQt5 pyqtgraph numpy

版本上,pyqtgraph 建议用 0.13 以上的版本,对高 DPI 屏幕的支持更好,API 也更稳定。numpy 用 1.20 以上即可。如果你用的是 Anaconda,这些包基本都自带,注意别和系统里的 pip 版本冲突就行。

装完之后跑一个最小示例验证环境:

import sys import numpy as np import pyqtgraph as pg from PyQt5.QtWidgets import QApplication app = QApplication(sys.argv) win = pg.PlotWidget() win.plot(np.random.normal(size=100)) win.show() sys.exit(app.exec_())

能弹出一个带随机曲线的窗口,环境就没问题了。

4.2 搭建主界面骨架

我习惯把界面拆成三块:顶部工具栏(开始/暂停/清空按钮)、中间绘图区、底部状态栏(显示帧率、采样率)。用 QVBoxLayout 竖着排。

from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel) from PyQt5.QtCore import QTimer import pyqtgraph as pg import numpy as np class RealtimePlotter(QWidget): def __init__(self, buffer_size=1000): super().__init__() self.buffer_size = buffer_size self.setWindowTitle("实时数据监控") self.resize(1000, 600) # 数据缓冲区 self.data_buffer = np.zeros(buffer_size) self.write_index = 0 self.total_written = 0 # 绘图控件 self.plot_widget = pg.PlotWidget() self.plot_widget.setYRange(-10, 10, padding=0) self.plot_widget.setXRange(0, buffer_size, padding=0) self.plot_widget.showGrid(x=True, y=True, alpha=0.3) self.curve = self.plot_widget.plot( pen=pg.mkPen(color='y', width=2)) # 按钮 self.btn_start = QPushButton("开始") self.btn_pause = QPushButton("暂停") self.btn_clear = QPushButton("清空") self.status_label = QLabel("就绪") btn_layout = QHBoxLayout() btn_layout.addWidget(self.btn_start) btn_layout.addWidget(self.btn_pause) btn_layout.addWidget(self.btn_clear) btn_layout.addStretch() btn_layout.addWidget(self.status_label) layout = QVBoxLayout() layout.addLayout(btn_layout) layout.addWidget(self.plot_widget) self.setLayout(layout) # 刷新定时器 self.refresh_timer = QTimer() self.refresh_timer.setInterval(30) self.refresh_timer.timeout.connect(self.refresh_plot) self.btn_start.clicked.connect(self.start) self.btn_pause.clicked.connect(self.pause) self.btn_clear.clicked.connect(self.clear)

这个骨架搭好,后面就是往里填数据逻辑。

4.3 数据采集线程的实现

采集线程用QThread或者 Python 原生threading.Thread都行。我倾向用原生线程,因为采集逻辑和 Qt 的信号槽关系不大,用原生线程更轻量。数据通过queue.Queue传给主线程。

import threading import queue import time class DataProducer(threading.Thread): def __init__(self, data_queue, sample_rate=100): super().__init__(daemon=True) self.data_queue = data_queue self.sample_rate = sample_rate self.running = False self.interval = 1.0 / sample_rate def run(self): t = 0 while self.running: # 模拟数据源,实际替换成串口/网口读取 value = np.sin(t * 0.1) * 5 + np.random.normal(0, 0.2) self.data_queue.put(value) t += 1 time.sleep(self.interval) def start_collect(self): self.running = True if not self.is_alive(): self.start() def stop_collect(self): self.running = False

这里daemon=True保证主程序退出时采集线程自动结束,不会挂住进程。sample_rate控制采样频率,实际项目里这个值由硬件决定,仿真的时候自己设。

4.4 主线程批量刷新逻辑

主线程的定时器每 30ms 触发一次,把队列里攒下的数据全部取出来,写进环形缓冲,然后一次性更新曲线。

def refresh_plot(self): # 批量取出队列中的数据 new_data = [] while not self.data_queue.empty(): try: new_data.append(self.data_queue.get_nowait()) except queue.Empty: break if not new_data: return # 写入环形缓冲 n = len(new_data) for v in new_data: self.data_buffer[self.write_index] = v self.write_index = (self.write_index + 1) % self.buffer_size self.total_written += n # 构造显示用的有序数组 if self.total_written < self.buffer_size: # 还没填满,只显示已写入部分 y = self.data_buffer[:self.write_index] else: # 已填满,把环形缓冲理顺成时间顺序 y = np.concatenate(( self.data_buffer[self.write_index:], self.data_buffer[:self.write_index] )) self.curve.setData(y) self.status_label.setText(f"点数: {self.total_written}")

这段代码是核心。注意np.concatenate每次刷新都会产生一个新数组,数据量大时(比如缓冲区 10000 点)会有一定开销。如果追求极致,可以用双倍缓冲区做镜像写入,避免拼接。但 10000 点以内的拼接,实测开销在几十微秒级别,对 30ms 的刷新周期来说完全可以忽略。

4.5 参数计算:缓冲区大小和刷新周期怎么定

这两个参数不是拍脑袋定的,得根据你的实际需求算。

缓冲区大小决定了你能看到多长时间的历史数据。公式是:缓冲区大小 = 采样率 × 想显示的时间窗口。比如采样率 100Hz,想显示最近 10 秒的数据,那缓冲区就是 1000 点。如果采样率 1000Hz,想显示 5 秒,那就是 5000 点。

刷新周期决定了界面的流畅度。理论上刷新越快越流畅,但太快了 CPU 扛不住。经验值是 20ms 到 50ms 之间。20ms 对应 50fps,已经很丝滑;50ms 对应 20fps,勉强够用。我一般用 30ms,兼顾流畅和开销。

还有一个隐藏参数是每次刷新的数据量。刷新周期乘以采样率,就是每次要处理的数据点数。比如 30ms 周期、1000Hz 采样,每次要处理 30 个点。这个量级对 NumPy 来说毫无压力。但如果采样率上万,每次几百个点,就得考虑降采样了——显示的时候不需要把每个点都画出来,可以每隔几个点取一个,视觉上几乎没差别,性能却能提升好几倍。

采样率显示窗口缓冲区大小刷新周期每次处理点数
100Hz10s100030ms3
500Hz10s500030ms15
1000Hz5s500030ms30
5000Hz2s1000030ms150
10000Hz1s1000030ms300

这张表可以直接拿去对照自己的场景。超过 5000Hz 的采样率,建议在显示前做降采样,否则即使 pyqtgraph 扛得住,人眼也分辨不出那么多点的细节。

4.6 多通道曲线的实现

实际项目里经常要同时显示好几路信号。多通道的实现有两种思路。一种是每条曲线一个独立的PlotDataItem,共享同一个PlotItem,这样它们画在同一个坐标系里。另一种是每个通道一个子图,用GraphicsLayoutWidget上下排列。

# 方案一:同图多曲线 colors = ['y', 'c', 'm', 'g'] self.curves = [] for i in range(4): c = self.plot_widget.plot(pen=pg.mkPen(color=colors[i], width=2)) self.curves.append(c) # 更新时 for i, curve in enumerate(self.curves): curve.setData(channel_data[i])

同图多曲线适合对比不同信号的幅度和相位关系。如果各路信号量纲差异大,比如一路是电压一路是温度,那就得用子图方案,各自有独立的 Y 轴。

多通道更新时,记得用setUpdatesEnabled(False)包住整个更新循环,全部设完再开,避免每条曲线各触发一次重绘。

5. 常见问题与排查技巧实录

5.1 界面卡顿、无响应的排查路径

卡顿是实时绘图最常见的问题,原因可能有好几层,得按顺序排查。

第一层:是不是在主线程里做了耗时操作。检查你的数据读取是不是直接在定时器回调里做的。如果是串口read()这种阻塞调用,界面必卡。解决办法就是把读取挪到独立线程。

第二层:是不是刷新太频繁。打印一下定时器的实际触发频率,如果远高于设定值,说明有地方在重复触发。另外检查是不是每来一个数据点就调了一次setData()。

第三层:数据量是不是太大。如果缓冲区有几万个点,每次setData()都要处理这么多数据,再快的库也扛不住。这时候要么缩小缓冲区,要么做降采样。

第四层:是不是开了抗锯齿又开了 OpenGL。这两个一起开在某些驱动上会互相拖累,试试关掉其中一个。

我整理了一个排查速查表:

现象可能原因排查方法解决
界面完全冻结主线程阻塞看是否有 sleep/read 在主线程数据读取移到子线程
曲线刷新一顿一顿刷新周期太长打印定时器间隔缩短到 20-30ms
运行越久越卡内存泄漏/缓冲区膨胀监控内存占用用定长环形缓冲
帧率上不去数据点太多统计每次 setData 点数降采样或缩小缓冲
曲线闪烁重绘冲突观察是否多次重绘批量更新 + 关闭重绘

5.2 曲线显示异常的那些坑

曲线只显示一半或者错位。这通常是环形缓冲理顺逻辑写错了。检查write_index和total_written的关系,特别是缓冲区还没填满和已经填满这两种状态的切换点。我踩过这个坑,缓冲区刚填满的那一瞬间,如果判断条件写错,曲线会突然跳一下。

曲线抖动厉害。如果数据本身有噪声,显示出来就是毛刺。可以在显示前做一次滑动平均,或者用 pyqtgraph 的setDownsampling和setClipToView来优化显示。setClipToView(True)只绘制视野内的点,视野外的直接跳过,对大数据量很有用。

Y 轴范围不对。如果没手动设范围,pyqtgraph 会自动缩放,数据一波动范围就跟着变,看着眼花。实时监控一定要手动锁死 Y 轴范围。

5.3 内存与性能的长期稳定性

实时监控工具经常要连续跑几天甚至几周,内存稳定性至关重要。几个必须注意的点:

第一,绝对不要在循环里创建新的数组或对象。每次刷新都np.array(new_data)会产生大量临时对象,GC 频繁触发,时间长了内存碎片化严重。用预分配的缓冲区,写入用索引赋值。

第二,定时器的回调里不要做字符串格式化。status_label.setText(f"...")这种操作看着小,但每秒几十次也会累积开销。状态栏更新频率可以降到每秒一两次,没必要跟曲线同步刷新。

第三,曲线对象复用。不要每次刷新都plot()新建曲线,那样会不断往场景里加图形项,内存只增不减。创建一次,之后只调setData()。

提示:可以用tracemalloc或者psutil定期打印进程内存占用,跑个通宵看看曲线是否平稳。如果内存持续上涨,基本就是有对象没释放。

5.4 高 DPI 屏幕下的显示问题

现在 4K 屏很普及,pyqtgraph 在高 DPI 下默认可能显示模糊或者尺寸不对。解决办法是在 QApplication 创建之前设置高 DPI 属性:

from PyQt5.QtCore import Qt QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)

另外 pyqtgraph 的setConfigOptions里可以设background和foreground,深色主题在高 DPI 下看着更舒服,也更省电(对 OLED 屏而言)。

6. 几个能直接提升体验的进阶技巧

6.1 用 ViewBox 实现鼠标缩放与拖拽

pyqtgraph 默认就支持鼠标滚轮缩放和右键拖拽平移,但实时刷新的时候,用户一缩放,坐标轴范围就变了,曲线可能跑出视野。我的做法是加一个"自动跟随"的开关,默认开启时锁死范围,用户手动缩放后自动关闭跟随,给用户自由探索的空间。

self.plot_widget.setMouseEnabled(x=True, y=True) # 监听范围变化 self.plot_widget.getViewBox().sigRangeChanged.connect(self.on_range_changed)

6.2 加一条游标线显示当前值

实时监控里,用户经常想知道当前值是多少。加一条水平参考线,跟着最新数据点移动,直观又实用。

self.cursor_line = pg.InfiniteLine( pos=0, angle=0, movable=False, pen=pg.mkPen('r', width=1, style=Qt.DashLine)) self.plot_widget.addItem(self.cursor_line) # 刷新时更新 self.cursor_line.setPos(y[-1])

6.3 数据导出与回放

监控工具跑完,用户往往想把数据存下来。最简单的做法是在采集线程里同时写一份到 CSV 文件,用csv模块或者np.savetxt。注意写文件也要在独立线程里做,别拖累主线程。回放功能则是把存下来的数据按时间戳重新喂给绘图逻辑,相当于把实时源换成文件源,代码结构基本不用改。

6.4 帧率统计与自适应降采样

想知道你的界面到底跑多快,可以加一个帧率计数器。记录每次刷新的时间戳,算最近 N 次的平均间隔,倒数就是帧率。如果发现帧率低于阈值,自动增大降采样倍数,保证界面始终流畅。这个自适应逻辑在低配机器上特别有用。

import time self.frame_times = [] def refresh_plot(self): now = time.perf_counter() self.frame_times.append(now) if len(self.frame_times) > 30: self.frame_times.pop(0) if len(self.frame_times) > 1: fps = (len(self.frame_times) - 1) / (self.frame_times[-1] - self.frame_times[0]) self.status_label.setText(f"FPS: {fps:.1f}") # ... 其余刷新逻辑

这套组合拳打下来,一个既流畅又稳定的实时绘图工具就基本成型了。我在实际项目里用这套架构跑过连续 72 小时的压力测试,内存曲线平稳,帧率始终维持在 30fps 以上,中间没有出现过卡死或者崩溃。真正决定成败的往往不是 pyqtgraph 本身,而是数据流的设计和缓冲区的管理——把这两块理顺了,剩下的都是水到渠成的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询