资讯动态

PyQt5+pyqtgraph实时绘图:高刷新率数据可视化方案

发布时间:2026/10/15 13:06:47 来源:尧图企业网站定制
1. 为什么实时绘图这件事值得单独拿出来聊做过上位机或者数据采集项目的人都有一个共同体会数据采集本身不难难的是把采集到的数据实时、流畅、不卡顿地画出来。我最早接触这类需求是在一个传感器监测的小项目里当时用 Matplotlib 做动态刷新结果界面卡得像幻灯片CPU 占用直接飙到 80% 以上鼠标拖动窗口都要等半天。后来换成 PyQt5 加 pyqtgraph 的组合同样的数据量刷新率直接上了一个台阶界面响应也变得跟手了。这个组合之所以值得单独写一篇是因为它解决的是一个非常具体的问题在桌面端 Python 环境里如何用最低的成本实现高刷新率的实时数据可视化。它适合的人群很明确——做工业上位机、实验数据采集、嵌入式调试界面、金融行情监控这类需要数据来了就画场景的开发者。如果你只是偶尔画个静态图Matplotlib 完全够用但一旦涉及每秒几十帧甚至上百帧的刷新pyqtgraph 的优势就体现出来了。pyqtgraph 的底层是基于 Qt 的 GraphicsView 框架和 NumPy 数组运算的它不像 Matplotlib 那样每次重绘都走一遍完整的渲染管线而是直接操作图形项配合 NumPy 的向量化计算把单次绘制的开销压得很低。这也是为什么它在实时场景下能比 Matplotlib 快一到两个数量级。理解这一点后面的所有优化手段才有落脚点。2. 整体方案设计与技术选型思路2.1 为什么是 PyQt5 而不是别的 GUI 框架Python 桌面 GUI 的选择其实不少Tkinter、wxPython、PySide2/6、Kivy 都能做。选 PyQt5 主要看中三点一是它的信号槽机制天然适合处理数据线程产生数据、UI 线程刷新界面这种跨线程通信二是 Qt 的 GraphicsView 框架成熟稳定pyqtgraph 就是建立在它之上的三是生态资料多遇到问题好查。PySide2 和 PyQt5 在 API 上几乎一致如果你有商业授权方面的顾虑PySide2 是更稳妥的选择代码迁移成本极低基本改个 import 就行。我个人的习惯是先用 PyQt5 快速验证正式项目再根据授权情况决定是否切到 PySide2。2.2 pyqtgraph 相比 Matplotlib 的核心优势这里必须把原理讲清楚不然优化的时候容易抓瞎。Matplotlib 的绘图流程大致是构建 Figure 对象 → 渲染成图像 → 交给后端显示。每次更新数据哪怕只改一个点它也可能触发整个画布的重绘。而 pyqtgraph 的 PlotWidget 内部维护的是一个个独立的图形项PlotItem、PlotDataItem 等更新数据时只更新对应的曲线对象Qt 的 GraphicsView 会自动做局部重绘和脏区域管理。用一个生活化的类比Matplotlib 像是每次改一个字就把整页纸重新打印一遍pyqtgraph 像是用橡皮擦掉那一个字再写上去。数据量小的时候差别不明显数据量大、刷新频繁的时候差距就是天壤之别。2.3 线程模型的选择数据采集与 UI 刷新必须分离这是新手最容易踩的坑。很多人图省事直接在 Qt 主线程里用QTimer定时采集数据并刷新曲线数据量小的时候没问题一旦采集逻辑里有阻塞操作比如串口读取、网络请求、文件 IO整个界面就会假死。正确的做法是把数据采集放在独立的QThread里采集到的数据通过信号槽机制发给主线程的绘图对象。这里有个关键点pyqtgraph 的绘图操作必须在主线程执行因为 Qt 的 GUI 操作不是线程安全的。所以信号槽的作用不只是传数据更是把执行上下文从工作线程切换到主线程。2.4 数据缓冲策略环形缓冲区是标配实时数据是源源不断的但屏幕能显示的点数是有限的。如果每来一个点就往曲线里追加数组会无限增长内存迟早爆掉绘制速度也会越来越慢。所以必须用一个固定长度的缓冲区新数据进来就把最老的数据挤出去。我一般用collections.deque配合maxlen参数或者直接用 NumPy 的环形索引。deque 的好处是自动管理长度代码简洁NumPy 环形索引的好处是配合向量化操作更快。具体选哪个看数据量每秒几百个点用 deque 完全够每秒上万点就得上 NumPy 手动管理。3. 核心细节解析与实操要点3.1 环境搭建与依赖版本的那些坑先把环境说清楚因为 pyqtgraph 和 PyQt5 的版本兼容性有时候会坑人。我的建议是pip install PyQt55.15.9 pip install pyqtgraph0.13.3 pip install numpy1.24.3为什么锁版本因为 pyqtgraph 0.13 之前的版本对 PyQt5 5.15 的某些信号有兼容问题会出现曲线不刷新或者报TypeError的情况。NumPy 1.24 之后对某些旧 API 做了调整如果你用的 pyqtgraph 版本较老可能会遇到np.float被移除之类的报错。锁版本是最省心的做法等项目稳定了再考虑升级。注意如果你在虚拟环境里装记得先确认 PyQt5 装的是 wheel 包而不是源码编译源码编译在部分系统上会因为缺少 Qt 开发库而失败。3.2 PlotWidget 的初始化参数怎么调pyqtgraph 的PlotWidget初始化时有一堆参数调好了能省很多事。我常用的配置是这样的import pyqtgraph as pg from PyQt5.QtWidgets import QApplication, QMainWindow pg.setConfigOptions(antialiasTrue) # 抗锯齿曲线更平滑 pg.setConfigOption(background, w) # 白色背景 pg.setConfigOption(foreground, k) # 黑色前景 plot_widget pg.PlotWidget() plot_widget.setYRange(-10, 10) # 固定Y轴范围避免自动缩放导致的抖动 plot_widget.setXRange(0, 1000) plot_widget.showGrid(xTrue, yTrue, alpha0.3) # 网格线透明度0.3 plot_widget.setLabel(left, 数值) plot_widget.setLabel(bottom, 采样点)这里重点说两个参数。antialiasTrue会让曲线边缘更平滑但会略微增加渲染开销如果刷新率要求极高可以关掉。setYRange固定 Y 轴范围非常重要因为默认的自动缩放会在数据变化时不断调整坐标轴视觉上就是曲线在上下跳动非常影响观察。固定范围后超出范围的数据会被裁剪视觉上更稳定。3.3 曲线对象的创建与更新方式创建曲线有两种方式一种是plot()直接返回曲线对象另一种是PlotDataItem手动创建。我推荐后者因为可以更精细地控制curve plot_widget.plot(penpg.mkPen(colorb, width2))更新数据的时候千万不要用clear()再plot()那样每次都会重建图形项开销巨大。正确做法是调用setData()curve.setData(x_array, y_array)setData()内部会复用已有的图形项只更新数据点这是 pyqtgraph 高效的核心。还有一个技巧是开启setData的skipFiniteCheck参数如果数据里确定没有 NaN 和 Inf能再省一点时间curve.setData(x_array, y_array, skipFiniteCheckTrue)3.4 定时刷新的节奏控制刷新频率不是越高越好。屏幕刷新率一般是 60Hz你就算每秒画 1000 次人眼也分辨不出来反而白白消耗 CPU。我的经验是30 到 60 FPS 是比较舒服的区间对应QTimer的间隔就是 16ms 到 33ms。self.timer QTimer() self.timer.setInterval(33) # 约30FPS self.timer.timeout.connect(self.update_plot) self.timer.start()这里有个细节定时器刷新的是界面而数据采集是另一个节奏。两者解耦之后采集可以按自己的频率跑比如每 5ms 采一个点界面按 30FPS 刷新每次刷新把缓冲区里累积的新点一次性画出来。这样既保证了数据不丢又不会让界面过载。4. 完整实操过程与核心环节实现4.1 项目结构规划我习惯把代码拆成三个部分数据采集线程、主窗口、启动入口。这样职责清晰后面加功能也好扩展。realtime_plot/ ├── main.py # 启动入口 ├── worker.py # 数据采集线程 └── window.py # 主窗口与绘图逻辑4.2 数据采集线程的实现先写 worker模拟一个持续产生数据的采集过程。实际项目里这里换成串口读取或者传感器 API 调用即可。from PyQt5.QtCore import QThread, pyqtSignal import numpy as np import time class DataWorker(QThread): data_ready pyqtSignal(object) # 用 object 传 numpy 数组 def __init__(self, sample_rate200): super().__init__() self.sample_rate sample_rate self._running True def run(self): interval 1.0 / self.sample_rate t 0 while self._running: # 模拟采集正弦波加噪声 value np.sin(2 * np.pi * 0.5 * t) np.random.normal(0, 0.1) self.data_ready.emit(np.array([value])) t interval time.sleep(interval) def stop(self): self._running False self.wait()这里用pyqtSignal(object)而不是pyqtSignal(float)是因为后面要批量传数据用 object 更灵活。stop()里调用wait()是为了确保线程真正结束再退出避免程序关闭时线程还在跑导致崩溃。4.3 主窗口与绘图逻辑主窗口负责接收数据、维护缓冲区、定时刷新曲线。from PyQt5.QtWidgets import QMainWindow, QVBoxLayout, QWidget from PyQt5.QtCore import QTimer from collections import deque import pyqtgraph as pg import numpy as np class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(实时数据监测) self.resize(1000, 600) # 缓冲区保留最近2000个点 self.buffer_size 2000 self.y_buffer deque(maxlenself.buffer_size) self.x_buffer deque(maxlenself.buffer_size) self.counter 0 # 绘图控件 self.plot_widget pg.PlotWidget() self.plot_widget.setYRange(-2, 2) self.plot_widget.showGrid(xTrue, yTrue, alpha0.3) self.curve self.plot_widget.plot(penpg.mkPen(b, width2)) # 布局 central QWidget() layout QVBoxLayout(central) layout.addWidget(self.plot_widget) self.setCentralWidget(central) # 刷新定时器 self.timer QTimer() self.timer.setInterval(33) self.timer.timeout.connect(self.refresh_plot) self.timer.start() def on_data(self, values): for v in values: self.y_buffer.append(v) self.x_buffer.append(self.counter) self.counter 1 def refresh_plot(self): if len(self.y_buffer) 2: return x np.fromiter(self.x_buffer, dtypenp.float64) y np.fromiter(self.y_buffer, dtypenp.float64) self.curve.setData(x, y, skipFiniteCheckTrue)关键点在于on_data只负责往缓冲区塞数据不碰绘图refresh_plot才做实际的绘制。两者通过定时器解耦即使数据来得再快界面也只按固定节奏刷新。4.4 启动入口与线程绑定import sys from PyQt5.QtWidgets import QApplication from window import MainWindow from worker import DataWorker if __name__ __main__: app QApplication(sys.argv) window MainWindow() worker DataWorker(sample_rate200) worker.data_ready.connect(window.on_data) worker.start() window.show() exit_code app.exec_() worker.stop() sys.exit(exit_code)注意worker.stop()放在app.exec_()之后确保窗口关闭时线程能正常退出。如果放在前面窗口还没关线程就停了数据就断了。4.5 性能实测与参数对比我在一台普通笔记本上做了个简单测试采集 200Hz 的正弦波缓冲区 2000 点对比不同刷新间隔下的 CPU 占用刷新间隔帧率CPU占用界面响应16ms60FPS18%非常流畅33ms30FPS9%流畅50ms20FPS6%略有顿感100ms10FPS4%明显卡顿从数据看30FPS 是性价比最高的选择CPU 占用不到 10%视觉上也足够流畅。如果你的机器性能一般或者同时要画多条曲线可以适当放宽到 50ms。5. 常见问题与排查技巧实录5.1 曲线不刷新或者只画一次这是最高频的问题。原因通常有三个一是setData传的数组是空的或者长度不足 2二是定时器没启动或者被阻塞三是数据线程的信号没连上。排查顺序先在refresh_plot里打印一下数组长度确认数据进来了再确认timer.start()被调用了最后检查data_ready.connect是否在worker.start()之前。我遇到过最隐蔽的一次是信号连接写在了worker.start()之后导致前几个数据包丢了曲线看起来像没刷新。5.2 界面卡顿、鼠标拖动窗口延迟多半是绘图操作太频繁或者数据量太大。先降低刷新频率试试如果还卡检查是不是在refresh_plot里做了耗时操作比如np.fromiter转换大数组。可以把缓冲区调小或者改用 NumPy 预分配数组加环形索引的方式避免每次转换。还有一个容易被忽略的点antialiasTrue在数据点很多的时候开销不小可以关掉试试。5.3 程序关闭时报错或卡死典型原因是线程没正确退出。确保worker.stop()里调用了wait()并且stop()在app.exec_()返回之后执行。如果线程里有阻塞的 IO 操作需要在run循环里定期检查_running标志别让它一直卡在read上。5.4 内存持续增长如果没设缓冲区上限deque 或者 list 会无限增长。检查maxlen是否设置或者 NumPy 数组是否在每次追加时都重新分配。用deque(maxlenN)是最简单的解法它会自动丢弃老数据。5.5 多条曲线时的性能问题画多条曲线时每条曲线都是一个独立的PlotDataItem刷新时都要调setData。如果曲线数量超过 5 条建议合并数据更新或者用PlotCurveItem替代PlotDataItem前者更轻量但不支持某些高级特性。另外不同曲线的刷新频率可以不一样变化慢的曲线没必要跟着高频刷新。5.6 常见问题速查表现象可能原因解决方向曲线不显示数据为空/长度不足检查缓冲区填充逻辑界面卡顿刷新过频/数据量过大降帧率、缩缓冲区关闭崩溃线程未退出stop 中加 wait内存增长缓冲区无上限用 deque maxlen曲线抖动Y轴自动缩放固定 setYRange多曲线卡图形项过多合并更新或换轻量项6. 几个让我少走弯路的实操心得第一个心得是关于缓冲区的。我一开始用 list 加切片来维护固定长度每次data data[-2000:]看起来没问题但切片会创建新数组数据量大时开销很明显。换成 deque 之后追加是 O(1)内存也稳定代码还更短。第二个心得是关于坐标轴的。实时绘图时 X 轴如果用采样点序号会一直增长虽然固定了范围但视觉上曲线会往左跑。我后来改成用相对时间戳配合setXRange动态滚动看起来就像示波器一样体验好很多。第三个心得是关于数据类型的。setData传的数组最好是float64或者float32如果传 Python listpyqtgraph 内部会先转成 NumPy 数组多一次转换。养成直接传 NumPy 数组的习惯能省一点是一点。第四个心得是关于调试的。实时绘图出问题的时候别急着改代码先把刷新频率降到 1Hz让曲线慢慢画观察数据是不是对的。很多时候问题不在绘图而在数据本身。等数据确认没问题了再把频率提上去调性能。这套方案我在好几个项目里复用从传感器监测到音频波形显示都跑得挺稳。核心就一句话采集归采集刷新归刷新中间用缓冲区和信号槽隔开。把这个结构搭对了剩下的就是调参数的事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑