上位机开发日记 · 第 8 篇 · 界面与实时绘图:四条性能红线
阅读时长:约 5 分钟 ·难度:进阶 ·前置知识:第 6 篇(并发)、第 7 篇(数据)
数据已经是可信的,本篇让它好看又好用。
1. 一个反直觉的现象
你按第 6 篇的规矩把采集放进了子线程,界面不卡死了——但接上实时曲线之后,问题变成了另一种:
| 现象 | 表面原因 | 真实原因 |
|---|---|---|
| CPU 跑到 100%,曲线还是一顿一顿 | “Python 太慢” | 每来一帧重绘一次 + 自动缩放遍历全量数据 |
| 数据点一多就雪崩式变慢 | “电脑不行” | 逐点append触发 N 次重绘 |
| 曲线看起来"变平了" | “信号就是这样” | 降采样用错了方法,把尖峰抽掉了 |
第三条尤其危险:它不报错、不卡顿,只是安静地让你看不到真实信号。
2. 绘图库选型
| 库 | 适用 | 说明 |
|---|---|---|
| pyqtgraph | 实时刷新(默认选择) | 基于 Qt GraphicsView,专为实时设计 |
| matplotlib(Qt 后端) | 静态出图、报告 | 每次重绘开销大,不适合高频刷新 |
| QCustomPlot(C++) | 极限性能 | 需自己封装,成本高 |
【判断】实时波形用 pyqtgraph,导报告用 matplotlib,两者共存。这是两个不同的需求(看 vs 存档),没必要强行统一。
3. 四条性能红线
红线一:不用while True + sleep刷新
# 错误:自己造轮子,和 Qt 抢线程whileTrue:self.curve.setData(y)time.sleep(0.03)# 正确:交给 Qt 的定时器self.timer=QTimer(self)self.timer.setInterval(33)# 约 30 fpsself.timer.timeout.connect(self._refresh)self.timer.start()QTimer跑在主线程的事件循环里,与重绘、事件响应友好协作;while True会把这些全部堵死。
刷新频率设多少?【判断】25~30 fps 足够。人眼对波形刷新的感知上限就在这个量级,再高只是浪费 CPU——而且这些 CPU 本该用来采集和存储。
红线二:用setData全量替换,不要循环 append
# 错误:每次 append 都可能触发一次重绘与范围计算forvinnew_values:self.curve.append(v)# 正确:一次性替换整段数据self._curve.setData(x,y)红线三:固定坐标范围,关掉自动缩放
self._curve.setData(x,y)self._plot.setXRange(t_now-window,t_now,padding=0)# 固定时间窗self._plot.setYRange(y_min,y_max,padding=0)# 固定量程self._plot.setClipToView(True)# 只画视野内的点【判断】自动缩放是隐藏的性能杀手。它的实现往往是"遍历全部数据求最值",数据越积越多,每一帧都越来越慢。读数范围本来就是已知的——把它固定下来。
红线四:显示降采样,存储不降采样
这条是四条里最容易被忽视、后果最严重的一条。
4. 降采样的正确做法
错误示范:简单抽样
y_display=y[::factor]# 每 factor 个点取一个实测对比(10000 点、幅值 1 的正弦波,在索引 5001 处放 +50 尖峰、索引 7777 处放 -50 尖峰——两者都不在"每 5 取 1"的抽样网格上,可复现):
简单抽样之后,峰值从 50 掉到 1——尖峰被彻底抹平,画面上看起来就是这个信号从来没有过异常。
注意:如果尖峰恰好落在抽样网格上(比如索引 5000,能被 5 整除),简单抽样会"碰运气"保住它。但真实尖峰出现在哪个位置是随机的,工程上不能赌这个概率。
对一个测试系统来说,这意味着:正在发生的故障被你的绘图代码藏起来了。
正确做法:min/max 抽取(保留包络)
importnumpyasnpdefdecimate_minmax(y:np.ndarray,target:int=2000)->np.ndarray:"""按块取 min/max 交错输出,保留波形包络。输出点数约为 2*target。"""n=y.sizeifn<=target:returny factor=n//target usable=(n//factor)*factor blocks=y[:usable].reshape(-1,factor)out=np.empty(blocks.shape[0]*2,dtype=y.dtype)out[0::2]=blocks.min(axis=1)# 每块的谷值out[1::2]=blocks.max(axis=1)# 每块的峰值returnout这不是小技巧,而是示波器、频谱仪等仪器的通用做法:屏幕上 1 个像素对应原始数据的一段区间,就用这段区间的 min 和 max 来代表它。这样屏幕上看不到的细节不会丢,只是被"压缩"了。
【判断】需要 “显示点数” 时的选择依据:
| 目标 | 方法 |
|---|---|
| 看波形包络、不能漏尖峰 | min/max 抽取(推荐) |
| 看趋势、缓慢变化 | 均值抽取(blocks.mean(axis=1)) |
| 看统计特征 | RMS 抽取(np.sqrt((blocks**2).mean(axis=1))) |
**【事实】**pyqtgraph 内置降采样能力,可设为第一道防线:
self._curve.setDownsampling(auto=True,mode="peak")# peak 即保留极值self._curve.setClipToView(True)【判断】同时在自己的数据层也做一次降采样——前者防卡,后者保证你看到的东西没骗你。
5. 拉取模式:让刷新频率由你决定
结合第 6 篇的结论,完整的刷新链路是这样的:
classMonitor(QWidget):def__init__(self):self._buf=deque(maxlen=200_000)# 原始数据环形缓冲self._sampler.batch_ready.connect(self._on_frames)# 子线程 -> 主线程self._timer=QTimer(self)self._timer.setInterval(33)self._timer.timeout.connect(self._refresh)self._timer.start()def_on_frames(self,frames):"""主线程:只塞进缓冲,不做计算与绘图。"""forpayloadinframes:self._buf.extend(struct.unpack("<10f",payload))def_refresh(self):"""主线程:取快照 -> 降采样 -> 绘制。数据再快也只跑这么多次。"""y=decimate_minmax(np.fromiter(self._buf,np.float32),target=1000)self._curve.setData(np.linspace(-self._window,0,y.size),y)关键点:_refresh的执行次数与被采数据量无关——不管设备每秒发 10 帧还是 1000 帧,界面永远是每秒刷 30 次。这就是可控性。
6. 显示与存储必须解耦
最后一个常见错误,值得单独说:
为了让界面流畅,主动降低采集率。
这等于用永久的数据损失,换取临时的显示流畅。数据一旦没采到,事后任何算法都补不回来。
正确关系:
| 环节 | 精度 | 说明 |
|---|---|---|
| 采集 | 全速率、不降采样 | 保真 |
| 存储 | 全速率、不降采样 | 保真 |
| 显示 | 可以降采样 | 只影响观感,不影响数据 |
【判断】一句话原则:采全、存全、画少。任何试图"少采一点让界面流畅"的念头,都应该被立刻否定。
7. 本篇小结
| 记住这一条 | 说明 |
|---|---|
| 用 QTimer,不用 while+sleep | 与事件循环友好协作 |
| 刷新频率 25~30 fps | 再高只是浪费本该用于采集的 CPU |
| setData 全量替换 | 逐点 append 是性能陷阱 |
| 固定坐标范围 | 自动缩放会遍历全量数据 |
| 显示降采样必须保包络 | 简单抽样会把 ±50 的尖峰抹成 ±1 |
| 用 min/max 抽取 | 仪器行业的通用做法 |
| QTimer 拉取模式 | 刷新频率与数据速率解耦 |
| 采全、存全、画少 | 绝不为了流畅而少采 |
8. 动手练习
复现这个实验:拿你自己的数据(或用
np.sin造一段),故意在中间放一个尖峰,分别用简单抽样和 min/max 抽取画出来。亲眼看到尖峰消失一次,以后就再也不会写错了。检查自动缩放:搜索项目里的
autoRange、enableAutoRange,判断是否该关掉。如果坐标范围其实已知(比如电压量程固定 0~5 V),就把它固定。改造刷新链路:把现有的"每来一帧就
setData"改成"QTimer 定时拉取"。改完对比一下 CPU 占用——这通常是本项目里效果最明显的一次优化。
上一篇:[第 7 篇 · 数据与存储:格式、批量落盘与时间戳]
下一篇:[第 9 篇 · 健壮性:断线、假死与优雅关闭]
——功能齐了,接下来要让它在现场活下来。