ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PyQtGraph自定义绘图实战:实现十字游标、区域高亮与数据标签

PyQtGraph自定义绘图实战:实现十字游标、区域高亮与数据标签 上一次我们把一个最基本的PyQtGraph绘图窗口跑通之后我心里其实一直惦记着一件事光能画折线、散点还不够项目里真正麻烦的是那些“非标准”的图形——跟随鼠标的十字参考线、用来圈选数据区间的半透明区域、峰值点边上的自定义标注。Matplotlib做这类静态叠加需求并不难但一旦要求图形随鼠标实时刷新它每次全量重绘的代价就很尴尬直接拿QPainter去画又等于把坐标变换、事件路由、缩放联动全部重写一遍。PyQtGraph给出的方案是以QGraphicsView为底座的绘图框架让你把绘制、坐标、事件都统一塞进一个ViewBox里。这篇系列第二篇我们就往深水区走专门聊自定义图表图形绘制。这里没有绕圈子的余地自定义绘制就是PyQtGraph区别于普通图表库的核心能力也是从“会用”走向“能用它做产品”的必经一步。看完这篇文章你能自己写出一套带交互、能正确响应缩放、不会越画越卡的自定义图形部件。1. 为什么说自定义绘制是PyQtGraph的深水区也是它的灵魂1.1 我在项目里遇到的具体问题先说个真实场景。当时我做一个设备状态监控面板曲线图只是背景真正难的是一堆交互标记操作员把鼠标移到曲线上某个点需要出现十字参考线并显示坐标在时间轴上拖出一个范围这块区域要用半透明高亮标出来若干个历史峰值点上面还要钉上小标签说明当时的异常类型。这套需求放在Matplotlib里静态效果其实能做就是每次鼠标移动都要对整张图调用draw一秒能刷十几帧就算不错眼睛看着明显有“甩尾感”。换成传统QPainter再配QGraphicsView灵活性倒是够了但自己维护坐标映射、事件传递、缩放重算工作量基本等于重新发明半个绘图框架。试过才知道PyQtGraph刚好卡在中间底层已经是QGraphicsView这套成熟的场景管理机制上层又替你做好了ViewBox的缩放平移和坐标换算你要写的只有“这个图形长什么样、怎么响应事件”。1.2 PyQtGraph绘图体系的四层结构刚开始接触PyQtGraph的人很容易被海量Item搞晕其实它的绘图体系可以分成四层第一层是容器层PlotWidget、GraphicsLayoutWidget负责把PlotItem装进Qt窗口。第二层是坐标层PlotItem和它内部的ViewBox负责数据坐标与屏幕坐标的换算、缩放平移、自动范围。第三层是预置图形层PlotCurveItem、ScatterPlotItem、TextItem、BarGraphItem这些官方已经做好的图形开箱即用。第四层是自定义图形层你继承GraphicsObject或GraphicsWidget自己写boundingRect()和paint()画什么完全由你决定。大多数教程停在第三层画个折线、换把颜色就算完。可一旦业务复杂起来需求总会往第四层挤。这也正是自定义图表图形绘制的核心战场你不再是选一个工具而是给这个框架补上专属零件。官方预置Item解决90%的常规问题剩下10%才是项目体验的差异化所在。所以这篇文章的三套代码远近高低各不相同十字游标演示“数据坐标绘制”区域高亮演示“动态边界加交互”数据标签演示“屏幕坐标绘制”。三种画法覆盖了自定义图形最核心的分支。2. 坐标系是自定义绘图的唯一地基2.1 三个映射方法先把坐标捋清楚自定义图形写不好八成是坐标没搞明白。PyQtGraph里最常用的三个映射方法是viewRect()获取当前视口对应的数据坐标范围返回一个QRectF。在paint里画线要画满整个视图靠的就是它。mapSceneToView(p)把场景坐标通常是鼠标事件里的scenePos()转换成数据坐标。做命中检测、显示鼠标坐标的时候几乎天天用。mapViewToScene(p)和mapViewToDevice(p)前者把数据坐标转回场景坐标后者直接转成设备像素坐标保存截图或做屏幕空间绘制时有用。这三个方法并不难但有个前提必须先理解当自定义Item被加入ViewBox时它的本地坐标系就是数据坐标系。你在paint()里写的坐标直接就是数据坐标不用再做任何手动换算。ViewBox内部有一个childGroup所有Item都被挂在这个组下面ViewBox每次刷新视口时都会对这个组做缩放平移所以QPainter在进入paint()时已经背着数据坐标到设备坐标的worldTransform了。这个设计带来的好处是巨大的数据坐标为1000还是0.001在paint里都是普通数字你不需要关心屏幕上有多少像素。2.2 缩放之后图形错位的根因我在这个环节踩过最深的坑就是“视图缩放之后自定义图形位置全乱了”。第一次写十字游标时我把游标的位置存成了一个固定坐标再拿一个固定的矩形边界去画线。初始状态看着没问题但一旦用户缩放曲线线就不跟着数据点了或者线只画到旧视口的一半就没了。根因有两个。第一个是绘制范围没随viewRect实时更新。画十字线、高亮区域、网格这类需要覆盖整个视口的图形paint()里必须每次实时获取self.getViewBox().viewRect()不要在初始化或者鼠标移动的时候缓存一个矩形到成员变量里。缩放后ViewBox的viewRect已经变了你还拿旧范围画出来的东西自然对不上。第二个是boundingRect返回范围与实际绘制范围不匹配。场景裁剪和命中检测都依赖boundingRect如果你的Item要画的区域跨越整个视口但boundingRect只返回了一小块那么视图一缩放超出boundingRect的部分就会被裁剪掉。这在自定义绘图里几乎是必踩的坑我的原则是宁可让boundingRect比实际绘制范围大一点也不要让它小。等图形数量多了再考虑收紧boundingRect来优化场景索引性能。后面两章的三段代码会反复用到这几个概念先把坐标系吃透后面才不会看到一半卡住。3. 从零构造十字游标先解决绘制再解决尺寸失真3.1 GraphicsObject的骨架代码自定义图形最简单的入口是继承pyqtgraph.GraphicsObject。这个类要求你实现两个方法boundingRect()和paint()。前者告诉场景“这个图形占多大地方”后者负责实际把图形画出来。下面是一个完整的十字游标实现它会在鼠标所在位置画一条水平线和一条垂直线中心画一个小圆点import pyqtgraph as pg from pyqtgraph.Qt import QtCore, QtGui class CrossHairCursor(pg.GraphicsObject): def __init__(self, color#ff5555): super().__init__() self._x 0.0 self._y 0.0 # 画笔和画刷都缓存起来不要在paint里反复创建 self._pen pg.mkPen(color, width1) self._pen.setCosmetic(True) self._brush pg.mkBrush(color) self.setZValue(100) def boundingRect(self): # 返回一个足够大的范围保证任意缩放都不会裁剪掉线条 return QtCore.QRectF(-1e9, -1e9, 2e9, 2e9) def paint(self, p, *args): vb self.getViewBox() if vb is None: return vr vb.viewRect() p.setRenderHint(QtGui.QPainter.Antialiasing, True) p.setPen(self._pen) # 水平线从视口左边到右边y固定为当前游标的y p.drawLine(QtCore.QPointF(vr.left(), self._y), QtCore.QPointF(vr.right(), self._y)) # 垂直线从视口上边到下边x固定为当前游标的x p.drawLine(QtCore.QPointF(self._x, vr.top()), QtCore.QPointF(self._x, vr.bottom())) # 中心小圆点 dx, dy self.pixelVectors() if dx is not None and dy is not None: rx dx.length() * 5 # 半径约5像素 ry dy.length() * 5 p.setBrush(self._brush) p.drawEllipse(QtCore.QPointF(self._x, self._y), rx, ry) def update_position(self, x, y): self._x x self._y y self.update()这段代码有几个细节要重点说。paint方法的签名写成def paint(self, p, *args)不要写死成三个参数。PyQtGraph在不同版本的Qt绑定下传给paint的额外参数可能有差异用*args兜住最稳这也是很多自定义Item的标准写法。boundingRect()返回(-1e9, -1e9, 2e9, 2e9)看似粗暴但它是故意的因为十字线的绘制范围横跨整个视口视口又随时会缩放你无法预判它的边界干脆给一个大范围来避免裁剪。代价是场景索引在管理这个Item时会多一点开销在图形数量少的场景下可以忽略不计。update_position方法只更新内部坐标并调用update()而不是setPos()。这是我个人更推荐的写法paint里直接使用数据坐标不依赖Item自身的position逻辑更直白也省去了坐标系混乱的麻烦。3.2 线宽和圆点尺寸cosmetic与pixelVectors第一次跑通十字游标你可能发现一个问题视图缩放得越小线条变得越粗中心圆点变得越大。原因是painter身上的worldTransform把数据坐标映射到了屏幕坐标而你的画笔宽度和圆点半径也是以数据坐标为单位算的缩放自然全被放大。两个解决方案。第一个是给画笔设置cosmetic属性pen.setCosmetic(True)。cosmetic pen在Qt里的意思是“不随变换缩放永远保持像素宽度”。十字线的水平线和垂直线就应该用这种方式无论你怎么缩放线宽始终保持1像素观感一致。注意如果画笔宽度设为0Qt默认也把它当cosmetic处理但显式调用setCosmetic(True)更清晰。第二个是中心圆点的大小。我想让圆点在屏幕上保持固定半径大约5像素但数据坐标下没有一个固定数字能满足这个需求必须实时换算当前一个像素等于多少数据单位。PyQtGraph在这个场景下提供了pixelVectors()它返回两个向量dx代表当前x方向上一个像素对应的数据向量dy代表y方向上一个像素对应的数据向量。取这两个向量的长度再乘以想要的像素值就得到数据坐标下对应的半径。这段逻辑很关键它几乎是所有“屏幕尺寸固定但数据坐标定位”图形的通用解法。后面区域高亮的边缘命中阈值也会用同样的思路。4. 区域高亮完整交互命中判定、拖拽与脏矩形刷新4.1 高亮区域的基础绘制十字游标只是“画出来”还不够过瘾。项目里我的第二个需求是用户能在图上框选一个时间区间这个区间用半透明颜色高亮出来还能拖拽调整范围。这就是区域高亮图形。先看基础绘制版本class RangeHighlighter(pg.GraphicsObject): def __init__(self, x010.0, x140.0, color(255, 180, 50)): super().__init__() self._x0 x0 self._x1 x1 self._brush pg.mkBrush(color[0], color[1], color[2], 60) self._pen pg.mkPen(color, width1) self._pen.setCosmetic(True) # 拖拽状态 self._mode None # None, left, right, move self._drag_offset 0.0 self.setAcceptHoverEvents(True) self.setZValue(50) def boundingRect(self): return QtCore.QRectF(self._x0, -1e9, self._x1 - self._x0, 2e9) def paint(self, p, *args): vb self.getViewBox() if vb is None: return vr vb.viewRect() rect QtCore.QRectF(self._x0, vr.top(), self._x1 - self._x0, vr.height()) p.setRenderHint(QtGui.QPainter.Antialiasing, False) p.setPen(self._pen) p.setBrush(self._brush) p.drawRect(rect)基础绘制只有两个要点矩形的高度用当前viewRect()的高度保证高亮区域铺满整个可视区宽度由_x0到_x1决定这两个值就是数据坐标下的时间区间边界。半透明填充用的是pg.mkBrush(r, g, b, alpha)最后一位alpha是透明度60通常是“既能看清底色、又能明显区分选中区”的档位。如果你做的是多块高亮重叠alpha再低一点会更好看否则叠太多会糊成一片。4.2 边缘命中判定与鼠标拖拽高亮的灵魂在于交互。我给它设计了三种操作拖左边缘改起点拖右边缘改终点拖中间整体平移。事件处理的核心矛盾是ViewBox本身会拦截鼠标事件来做缩放平移自定义Item必须抢先accept事件才能把控制权抢过来。def _data_x_from_event(self, ev): vb self.getViewBox() if vb is None: return 0.0 # 场景坐标 - 数据坐标 pt vb.mapSceneToView(ev.scenePos()) return pt.x() def _edge_threshold(self): # 屏幕上8像素对应的数据宽度作为边缘命中判定阈值 dx, dy self.pixelVectors() if dx is None: return 8.0 return 8.0 * dx.length() def hoverMoveEvent(self, ev): x self._data_x_from_event(ev) edge self._edge_threshold() if abs(x - self._x0) edge: self.setCursor(QtCore.Qt.SizeHorCursor) elif abs(x - self._x1) edge: self.setCursor(QtCore.Qt.SizeHorCursor) else: self.setCursor(QtCore.Qt.SizeAllCursor) ev.accept() super().hoverMoveEvent(ev) def mousePressEvent(self, ev): if ev.button() ! QtCore.Qt.LeftButton: super().mousePressEvent(ev) return x self._data_x_from_event(ev) edge self._edge_threshold() if abs(x - self._x0) edge: self._mode left elif abs(x - self._x1) edge: self._mode right else: self._mode move self._drag_offset x - self._x0 ev.accept() def mouseMoveEvent(self, ev): if not self._mode: super().mouseMoveEvent(ev) return x self._data_x_from_event(ev) edge self._edge_threshold() if self._mode left: self._x0 min(x, self._x1 - edge) elif self._mode right: self._x1 max(x, self._x0 edge) elif self._mode move: delta x - self._x0 - self._drag_offset self._x0 delta self._x1 delta self.prepareGeometryChange() self.update() ev.accept() def mouseReleaseEvent(self, ev): self._mode None ev.accept() super().mouseReleaseEvent(ev)几个关键点解释一下。命中阈值为什么不用固定数值因为如果视图缩放得很大一个像素对应的数据宽度可能只有0.001固定阈值比如1.0会让你认为鼠标离边缘十万八千里反过来视图缩得很小时阈值1.0又可能连半个区域都算“边缘”。用_edge_threshold()把8像素实时换算成数据宽度才能保证用户在屏幕上看到的命中范围始终稳定在8像素左右。拖拽左边缘时用了min(x, self._x1 - edge)右边缘用max(x, self._x0 edge)。这是为了防止拖拽过程中把左右边界拖反高亮区域变成负宽度甚至颠倒。很多人写拖拽不考虑这个拖得快一点区域就翻转消失了体验特别糟。还有一个对Qt图形底层极敏感的点mouseMoveEvent里修改_x0、_x1之后必须先调用self.prepareGeometryChange()再self.update()。prepareGeometryChange()通知场景“这个Item的几何边界要变了”场景会更新它内部的索引。如果你忘了调只调update()那么场景仍然按照旧boundingRect判断哪些区域需要重绘拖拽时就会出现残影、拖出来一半画面不刷新、甚至点不中新边缘的情况。我在第一次实现区域高亮时就是忘了prepareGeometryChange()拖到第二次就发现新拖出来的区域显示不出来旧区域的地方还有一些色块残影。加上那一行之后问题立刻消失从此我对“改boundingRect必须配套prepareGeometryChange”这条规则有了肌肉记忆。4.3 把三个自定义图形串进主窗口三个图形部件都齐了需要一个主窗口把它们串起来。下面这段代码生成一条随机曲线挂上游标、高亮和峰值标签import sys import numpy as np import pyqtgraph as pg from pyqtgraph.Qt import QtCore, QtGui, QtWidgets class MainWindow(QtWidgets.QWidget): def __init__(self): super().__init__() self.resize(1000, 600) layout QtWidgets.QVBoxLayout(self) self.info_label QtWidgets.QLabel(十字游标: (0, 0) 高亮区域: ()) layout.addWidget(self.info_label) self.plot pg.PlotWidget() layout.addWidget(self.plot) # 模拟数据 x np.linspace(0, 100, 1000) y np.random.RandomState(42).randn(1000).cumsum() self.curve self.plot.plot(x, y, penpg.mkPen(#33ccff, width2)) # 三个自定义图形 self.cursor CrossHairCursor() self.plot.addItem(self.cursor) self.highlight RangeHighlighter(x025.0, x145.0) self.plot.addItem(self.highlight) # 峰值点标注 peaks np.where((y[1:-1] y[:-2]) (y[1:-1] y[1:]))[0] 1 top_peaks peaks[np.argsort(y[peaks])[-5:]] for idx in top_peaks: label DataPointLabel(x[idx], y[idx], f峰值 {idx:.0f}) self.plot.addItem(label) self.plot.enableAutoRange() # 鼠标移动信号用SignalProxy做限流 self.proxy pg.SignalProxy( self.plot.scene().sigMouseMoved, rateLimit30, slotself._on_mouse_moved ) def _on_mouse_moved(self, ev): pos ev[0] vb self.plot.getPlotItem().vb if not vb.sceneBoundingRect().contains(pos): return pt vb.mapSceneToView(pos) self.cursor.update_position(pt.x(), pt.y()) self.info_label.setText( f十字游标: ({pt.x():.2f}, {pt.y():.2f}) 高亮区域: f({self.highlight._x0:.2f}, {self.highlight._x1:.2f}) ) if __name__ __main__: app QtWidgets.QApplication(sys.argv) w MainWindow() w.show() sys.exit(app.exec_())这里再提一个实用细节plot.scene().sigMouseMoved在鼠标移动时会高频触发每次回调如果都做字符串格式化、更新LabelUI会被拖慢。用pg.SignalProxy包一层并设置rateLimit30意思是每秒最多处理30次回调足够人眼实时感知又不会把所有性能砸在信号洪流上。这个技巧在带实时采集曲线的项目里尤其重要。5. 数据标签的屏幕空间方案两种坐标系的选择5.1 用ItemIgnoresTransformations锁定屏幕尺寸十字游标和区域高亮都采用数据坐标绘制但第三种需求——峰值点文字标签——就不太适合沿用同一种方式。因为文字如果跟着视图缩放你会得到一堆被拉大或压缩的变形字体这在任何工具里都算“失控”。PyQtGraph处理这类问题最直接的方式是给Item设置ItemIgnoresTransformations标志。设置之后Item内部的绘制不再受父级缩放变换影响你在paint里画的东西始终保持屏幕像素尺寸而setPos仍然把Item钉在数据坐标点上。一图两用位置跟随数据外观固定大小。class DataPointLabel(pg.GraphicsObject): def __init__(self, x, y, text, color#ffcc00): super().__init__() self._text text self._color color self._w max(len(text) * 12 12, 30) self.setPos(x, y) self.setFlag(QtCore.Qt.ItemIgnoresTransformations, True) self.setZValue(200) def boundingRect(self): # 这里已经是屏幕像素坐标系 return QtCore.QRectF(0, -30, self._w, 34) def paint(self, p, *args): p.setRenderHint(QtGui.QPainter.Antialiasing, False) pen pg.mkPen(self._color, width1) pen.setCosmetic(True) p.setPen(pen) # 连接线从数据点向上引出 p.drawLine(QtCore.QPointF(4, 0), QtCore.QPointF(4, -10)) # 背景框 p.setBrush(pg.mkBrush(30, 30, 30, 220)) p.drawRect(QtCore.QRectF(0, -24, self._w - 8, 20)) # 文字 p.setPen(pg.mkPen(self._color)) p.drawText(QtCore.QRectF(2, -21, self._w - 12, 16), QtCore.Qt.AlignCenter, self._text)需要留意的是设置ItemIgnoresTransformations之后paint里的坐标系就从一个“可缩放的无限世界”变成了“固定的像素平面”boundingRect里写的0、-30都是像素值。此时如果你还想着用数据坐标去画什么会发现自己画的东西飘在屏幕上一动不动。这个标志适合画面里的“小配件”比如标签、图例、角标不适合需要跟数据联动的图形本体。顺带说一句如果你需要的不是整块标签而只是“某个点旁边写一两个字”也可以直接用官方的pg.TextItem它内部已经处理好了尺寸锁定。但自己写一次的好处是能完全掌控样式——我这边的背景框、连接线、透明底都是自定制的官方TextItem想做到这个程度得绕不少弯。5.2 什么时候用数据坐标什么时候用屏幕坐标经过三种图形我相信你已经摸到规律了。判断标准很简单这个图形在缩放时是应该跟着数据一起变还是应该保持屏幕上的固定尺寸。曲线、散点、十字线的线体、区域高亮的矩形范围全部属于“跟随数据”的图形必须用数据坐标绘制坐标直接写在paint里。线宽、圆点半径、边缘命中阈值、文字字号、标签背景框属于“固定屏幕体验”的部分必须用cosmetic pen、pixelVectors或ItemIgnoresTransformations来控制。把这两类混在一起是新手最常见的错误要么文字跟着缩放变形要么应该跟随数据的虚线边界在缩放时脱离数据点。我的经验是每个自定义图形开工前先画一条分界线左边是“数据坐标的绘制内容”右边是“屏幕像素的绘制内容”再把右边的每一项分别落实为对应技术手段。这样写着写着思路会特别清晰后期调样式也不会东一锤西一棒。6. 性能实测paint为什么慢以及调优顺序6.1 两个最常见的卡顿源自定义图形画出来容易画多了之后性能问题就浮出水面。从我实测的几个项目看卡顿几乎都来自两个地方。第一个是paint里的对象创建。QPen、QBrush、QFont这类对象在Qt里是隐式共享的重量级句柄如果在paint里每一次绘制都重新构建图形Item一多CPU时间全耗在构造函数上了。这也是我上面三份代码都把画笔画刷缓存到__init__的原因。别看这个优化不起眼当图上有几百个自定义Item时它带来的差距就是肉眼可见的卡与不卡。第二个是Item数量失控。绘图需求往往是迭代出来的今天加一个标签点明天加一个标记后天把所有数据点都加上标签。如果每个标签都单独创建一个Item场景里就会有成百上千个QGraphicsItem。QGraphicsScene的事件分发、索引更新都跟Item数量强相关Item一多鼠标每挪动一毫米场景都要在大量Item之间做求交和命中判断性能雪崩。正确的做法是需要绘制大量同类图形时用一个Item在paint里用QPainterPath把所有图形一次画完。比如要给一万个点画特殊标记不要建一万个ScatterPlotItem而是把一万个符号塞进一个QPainterPath然后一条paint走完。批量绘制对QPainter来说只是多几个path命令对场景来说却只多一个Item。6.2 按这个顺序调优别跳过前面的部分如果把性能问题比作漏水我的排查顺序是固定的也建议你照着来先确认是不是只更新了应该更新的Item。最常见的反面例子是鼠标移动只影响十字游标代码却把整个PlotWidget的update()都调了一遍。改成游标自己update()其他部分完全不用重绘这往往是最直接的一击。再查paint里有没有反复创建画笔、画刷、字体、路径。有就果断搬到__init__或做成懒加载缓存。然后检查有没有大量图形点各自为政。有就考虑合并成单个Item用QPainterPath批量绘制。最后才是boundingRect的收窄。单独一个boundingRect返回天文数字问题不大但几百个Item叠加时大boundingRect会让场景索引非常拥挤。等图形数量多了再把十字游标这类Item的boundingRect收紧到当前viewRect附近并监听view range变化去更新它。注意这个顺序有一个反直觉的地方很多人一上来就优化boundingRect觉得这是“底层性能提升”结果收缩边界引发的绘制错误、漏刷新问题一大堆实际收益却远不如“少造几个对象”“少重绘几个区域”来得直接。性能优化要按成本收益排序而不是按“听起来高级”排序。另外如果自定义图形里大量使用了抗锯齿而你的图形只是横平竖直的矩形和线条关掉Antialiasing反而更清晰更快。抗锯齿适合曲线、圆点这类斜向元素纯直角矩形开抗锯齿不仅费性能边缘还会显得“毛”得不偿失。最后再分享一个实践细节。做完这三套自定义图形我最大的体会是所有“屏幕固定尺寸”的需求都应该收拢成一个工具函数比如统一写成px_span lambda px: px * self.pixelVectors()[0].length()让每个Item都从同一个入口去换算。这样当屏幕DPI变了、或者viewbox换了坐标系时你只需要改一处而不用去每个类里翻找。项目后期我加了高DPI适配就是靠这个偷了个懒。希望这几个自定义图形的拆解能帮你少走几段弯路早点把PyQtGraph这块深水区趟明白。
返回列表