pyqtgraph/pyqtgraph/graphicsItems/PlotCurveItem.py

711 lines
27 KiB
Python

# -*- coding: utf-8 -*-
from ..Qt import QtCore, QtGui, QtWidgets
HAVE_OPENGL = hasattr(QtWidgets, 'QOpenGLWidget')
import math
import warnings
import numpy as np
from .GraphicsObject import GraphicsObject
from .. import functions as fn
import sys
from .. import getConfigOption
from .. import debug
__all__ = ['PlotCurveItem']
class PlotCurveItem(GraphicsObject):
"""
Class representing a single plot curve. Instances of this class are created
automatically as part of PlotDataItem; these rarely need to be instantiated
directly.
Features:
- Fast data update
- Fill under curve
- Mouse interaction
===================== ===============================================
**Signals:**
sigPlotChanged(self) Emitted when the data being plotted has changed
sigClicked(self, ev) Emitted when the curve is clicked
===================== ===============================================
"""
sigPlotChanged = QtCore.Signal(object)
sigClicked = QtCore.Signal(object, object)
def __init__(self, *args, **kargs):
"""
Forwards all arguments to :func:`setData <pyqtgraph.PlotCurveItem.setData>`.
Some extra arguments are accepted as well:
============== =======================================================
**Arguments:**
parent The parent GraphicsObject (optional)
clickable If True, the item will emit sigClicked when it is
clicked on. Defaults to False.
============== =======================================================
"""
GraphicsObject.__init__(self, kargs.get('parent', None))
self.clear()
## this is disastrous for performance.
#self.setCacheMode(QtGui.QGraphicsItem.CacheMode.DeviceCoordinateCache)
self.metaData = {}
self.opts = {
'shadowPen': None,
'fillLevel': None,
'fillOutline': False,
'brush': None,
'stepMode': None,
'name': None,
'antialias': getConfigOption('antialias'),
'connect': 'all',
'mouseWidth': 8, # width of shape responding to mouse click
'compositionMode': None,
'skipFiniteCheck': True
}
if 'pen' not in kargs:
self.opts['pen'] = fn.mkPen('w')
self.setClickable(kargs.get('clickable', False))
self.setData(*args, **kargs)
def implements(self, interface=None):
ints = ['plotData']
if interface is None:
return ints
return interface in ints
def name(self):
return self.opts.get('name', None)
def setClickable(self, s, width=None):
"""Sets whether the item responds to mouse clicks.
The *width* argument specifies the width in pixels orthogonal to the
curve that will respond to a mouse click.
"""
self.clickable = s
if width is not None:
self.opts['mouseWidth'] = width
self._mouseShape = None
self._boundingRect = None
def setCompositionMode(self, mode):
"""
Change the composition mode of the item. This is useful when overlaying
multiple items.
Parameters
----------
mode : ``QtGui.QPainter.CompositionMode``
Composition of the item, often used when overlaying items. Common
options include:
``QPainter.CompositionMode.CompositionMode_SourceOver`` (Default)
Image replaces the background if it is opaque. Otherwise, it uses
the alpha channel to blend the image with the background.
``QPainter.CompositionMode.CompositionMode_Overlay`` Image color is
mixed with the background color to reflect the lightness or
darkness of the background
``QPainter.CompositionMode.CompositionMode_Plus`` Both the alpha
and color of the image and background pixels are added together.
``QPainter.CompositionMode.CompositionMode_Plus`` The output is the
image color multiplied by the background.
See ``QPainter::CompositionMode`` in the Qt Documentation for more
options and details
"""
self.opts['compositionMode'] = mode
self.update()
def getData(self):
return self.xData, self.yData
def dataBounds(self, ax, frac=1.0, orthoRange=None):
## Need this to run as fast as possible.
## check cache first:
cache = self._boundsCache[ax]
if cache is not None and cache[0] == (frac, orthoRange):
return cache[1]
(x, y) = self.getData()
if x is None or len(x) == 0:
return (None, None)
if ax == 0:
d = x
d2 = y
elif ax == 1:
d = y
d2 = x
else:
raise ValueError("Invalid axis value")
## If an orthogonal range is specified, mask the data now
if orthoRange is not None:
mask = (d2 >= orthoRange[0]) * (d2 <= orthoRange[1])
d = d[mask]
#d2 = d2[mask]
if len(d) == 0:
return (None, None)
## Get min/max (or percentiles) of the requested data range
if frac >= 1.0:
# include complete data range
# first try faster nanmin/max function, then cut out infs if needed.
with warnings.catch_warnings():
# All-NaN data is acceptable; Explicit numpy warning is not needed.
warnings.simplefilter("ignore")
b = (np.nanmin(d), np.nanmax(d))
if math.isinf(b[0]) or math.isinf(b[1]):
mask = np.isfinite(d)
d = d[mask]
if len(d) == 0:
return (None, None)
b = (d.min(), d.max())
elif frac <= 0.0:
raise Exception("Value for parameter 'frac' must be > 0. (got %s)" % str(frac))
else:
# include a percentile of data range
mask = np.isfinite(d)
d = d[mask]
if len(d) == 0:
return (None, None)
b = np.percentile(d, [50 * (1 - frac), 50 * (1 + frac)])
## adjust for fill level
if ax == 1 and self.opts['fillLevel'] not in [None, 'enclosed']:
b = (min(b[0], self.opts['fillLevel']), max(b[1], self.opts['fillLevel']))
## Add pen width only if it is non-cosmetic.
pen = self.opts['pen']
spen = self.opts['shadowPen']
if not pen.isCosmetic():
b = (b[0] - pen.widthF()*0.7072, b[1] + pen.widthF()*0.7072)
if spen is not None and not spen.isCosmetic() and spen.style() != QtCore.Qt.PenStyle.NoPen:
b = (b[0] - spen.widthF()*0.7072, b[1] + spen.widthF()*0.7072)
self._boundsCache[ax] = [(frac, orthoRange), b]
return b
def pixelPadding(self):
pen = self.opts['pen']
spen = self.opts['shadowPen']
w = 0
if pen.isCosmetic():
w += pen.widthF()*0.7072
if spen is not None and spen.isCosmetic() and spen.style() != QtCore.Qt.PenStyle.NoPen:
w = max(w, spen.widthF()*0.7072)
if self.clickable:
w = max(w, self.opts['mouseWidth']//2 + 1)
return w
def boundingRect(self):
if self._boundingRect is None:
(xmn, xmx) = self.dataBounds(ax=0)
if xmn is None or xmx is None:
return QtCore.QRectF()
(ymn, ymx) = self.dataBounds(ax=1)
if ymn is None or ymx is None:
return QtCore.QRectF()
px = py = 0.0
pxPad = self.pixelPadding()
if pxPad > 0:
# determine length of pixel in local x, y directions
px, py = self.pixelVectors()
try:
px = 0 if px is None else px.length()
except OverflowError:
px = 0
try:
py = 0 if py is None else py.length()
except OverflowError:
py = 0
# return bounds expanded by pixel size
px *= pxPad
py *= pxPad
#px += self._maxSpotWidth * 0.5
#py += self._maxSpotWidth * 0.5
self._boundingRect = QtCore.QRectF(xmn-px, ymn-py, (2*px)+xmx-xmn, (2*py)+ymx-ymn)
return self._boundingRect
def viewTransformChanged(self):
self.invalidateBounds()
self.prepareGeometryChange()
#def boundingRect(self):
#if self._boundingRect is None:
#(x, y) = self.getData()
#if x is None or y is None or len(x) == 0 or len(y) == 0:
#return QtCore.QRectF()
#if self.opts['shadowPen'] is not None:
#lineWidth = (max(self.opts['pen'].width(), self.opts['shadowPen'].width()) + 1)
#else:
#lineWidth = (self.opts['pen'].width()+1)
#pixels = self.pixelVectors()
#if pixels == (None, None):
#pixels = [Point(0,0), Point(0,0)]
#xmin = x.min()
#xmax = x.max()
#ymin = y.min()
#ymax = y.max()
#if self.opts['fillLevel'] is not None:
#ymin = min(ymin, self.opts['fillLevel'])
#ymax = max(ymax, self.opts['fillLevel'])
#xmin -= pixels[0].x() * lineWidth
#xmax += pixels[0].x() * lineWidth
#ymin -= abs(pixels[1].y()) * lineWidth
#ymax += abs(pixels[1].y()) * lineWidth
#self._boundingRect = QtCore.QRectF(xmin, ymin, xmax-xmin, ymax-ymin)
#return self._boundingRect
def invalidateBounds(self):
self._boundingRect = None
self._boundsCache = [None, None]
def setPen(self, *args, **kargs):
"""Set the pen used to draw the curve."""
self.opts['pen'] = fn.mkPen(*args, **kargs)
self.invalidateBounds()
self.update()
def setShadowPen(self, *args, **kargs):
"""Set the shadow pen used to draw behind the primary pen.
This pen must have a larger width than the primary
pen to be visible.
"""
self.opts['shadowPen'] = fn.mkPen(*args, **kargs)
self.invalidateBounds()
self.update()
def setBrush(self, *args, **kargs):
"""Set the brush used when filling the area under the curve"""
self.opts['brush'] = fn.mkBrush(*args, **kargs)
self.invalidateBounds()
self.update()
def setFillLevel(self, level):
"""Set the level filled to when filling under the curve"""
self.opts['fillLevel'] = level
self.fillPath = None
self.invalidateBounds()
self.update()
def setData(self, *args, **kargs):
"""
=============== ========================================================
**Arguments:**
x, y (numpy arrays) Data to show
pen Pen to use when drawing. Any single argument accepted by
:func:`mkPen <pyqtgraph.mkPen>` is allowed.
shadowPen Pen for drawing behind the primary pen. Usually this
is used to emphasize the curve by providing a
high-contrast border. Any single argument accepted by
:func:`mkPen <pyqtgraph.mkPen>` is allowed.
fillLevel (float or None) Fill the area 'under' the curve to
*fillLevel*
fillOutline (bool) If True, an outline surrounding the *fillLevel*
area is drawn.
brush QBrush to use when filling. Any single argument accepted
by :func:`mkBrush <pyqtgraph.mkBrush>` is allowed.
antialias (bool) Whether to use antialiasing when drawing. This
is disabled by default because it decreases performance.
stepMode (str or None) If "center", a step is drawn using the x
values as boundaries and the given y values are
associated to the mid-points between the boundaries of
each step. This is commonly used when drawing
histograms. Note that in this case, len(x) == len(y) + 1
If "left" or "right", the step is drawn assuming that
the y value is associated to the left or right boundary,
respectively. In this case len(x) == len(y)
If not passed or an empty string or None is passed, the
step mode is not enabled.
Passing True is a deprecated equivalent to "center".
connect Argument specifying how vertexes should be connected
by line segments. Default is "all", indicating full
connection. "pairs" causes only even-numbered segments
to be drawn. "finite" causes segments to be omitted if
they are attached to nan or inf values. For any other
connectivity, specify an array of boolean values.
compositionMode See :func:`setCompositionMode
<pyqtgraph.PlotCurveItem.setCompositionMode>`.
skipFiniteCheck Optimization parameter that can speed up plot time by
telling the painter to not check and compensate for NaN
values. If set to True, and NaN values exist, the data
may not be displayed or your plot will take a
significant performance hit. Defaults to False.
=============== ========================================================
If non-keyword arguments are used, they will be interpreted as
setData(y) for a single argument and setData(x, y) for two
arguments.
"""
self.updateData(*args, **kargs)
def updateData(self, *args, **kargs):
profiler = debug.Profiler()
if 'compositionMode' in kargs:
self.setCompositionMode(kargs['compositionMode'])
if len(args) == 1:
kargs['y'] = args[0]
elif len(args) == 2:
kargs['x'] = args[0]
kargs['y'] = args[1]
if 'y' not in kargs or kargs['y'] is None:
kargs['y'] = np.array([])
if 'x' not in kargs or kargs['x'] is None:
kargs['x'] = np.arange(len(kargs['y']))
for k in ['x', 'y']:
data = kargs[k]
if isinstance(data, list):
data = np.array(data)
kargs[k] = data
if not isinstance(data, np.ndarray) or data.ndim > 1:
raise Exception("Plot data must be 1D ndarray.")
if data.dtype.kind == 'c':
raise Exception("Can not plot complex data types.")
profiler("data checks")
#self.setCacheMode(QtGui.QGraphicsItem.CacheMode.NoCache) ## Disabling and re-enabling the cache works around a bug in Qt 4.6 causing the cached results to display incorrectly
## Test this bug with test_PlotWidget and zoom in on the animated plot
self.yData = kargs['y'].view(np.ndarray)
self.xData = kargs['x'].view(np.ndarray)
self.invalidateBounds()
self.prepareGeometryChange()
self.informViewBoundsChanged()
profiler('copy')
if 'stepMode' in kargs:
self.opts['stepMode'] = kargs['stepMode']
if self.opts['stepMode'] in ("center", True): ## check against True for backwards compatibility
if self.opts['stepMode'] is True:
import warnings
warnings.warn('stepMode=True is deprecated, use stepMode="center" instead', DeprecationWarning, stacklevel=3)
if len(self.xData) != len(self.yData)+1: ## allow difference of 1 for step mode plots
raise Exception("len(X) must be len(Y)+1 since stepMode=True (got %s and %s)" % (self.xData.shape, self.yData.shape))
else:
if self.xData.shape != self.yData.shape: ## allow difference of 1 for step mode plots
raise Exception("X and Y arrays must be the same shape--got %s and %s." % (self.xData.shape, self.yData.shape))
self.path = None
self.fillPath = None
self._mouseShape = None
#self.xDisp = self.yDisp = None
if 'name' in kargs:
self.opts['name'] = kargs['name']
if 'connect' in kargs:
self.opts['connect'] = kargs['connect']
if 'pen' in kargs:
self.setPen(kargs['pen'])
if 'shadowPen' in kargs and kargs['shadowPen'] is not None:
self.setShadowPen(kargs['shadowPen'])
if 'fillLevel' in kargs and kargs['fillLevel'] is not None:
self.setFillLevel(kargs['fillLevel'])
if 'fillOutline' in kargs:
self.opts['fillOutline'] = kargs['fillOutline']
if 'brush' in kargs and kargs['brush'] is not None:
self.setBrush(kargs['brush'])
if 'antialias' in kargs:
self.opts['antialias'] = kargs['antialias']
self.opts['skipFiniteCheck'] = kargs.get('skipFiniteCheck', False)
profiler('set')
self.update()
profiler('update')
self.sigPlotChanged.emit(self)
profiler('emit')
def generatePath(self, x, y):
stepMode = self.opts['stepMode']
if stepMode:
## each value in the x/y arrays generates 2 points.
if stepMode == "right":
x2 = np.empty((len(x) + 1, 2), dtype=x.dtype)
x2[:-1] = x[:, np.newaxis]
x2[-1] = x2[-2]
elif stepMode == "left":
x2 = np.empty((len(x) + 1, 2), dtype=x.dtype)
x2[1:] = x[:, np.newaxis]
x2[0] = x2[1]
elif stepMode in ("center", True): ## support True for back-compat
x2 = np.empty((len(x),2), dtype=x.dtype)
x2[:] = x[:, np.newaxis]
else:
raise ValueError("Unsupported stepMode %s" % stepMode)
if self.opts['fillLevel'] is None:
x = x2.reshape(x2.size)[1:-1]
y2 = np.empty((len(y),2), dtype=y.dtype)
y2[:] = y[:,np.newaxis]
y = y2.reshape(y2.size)
else:
## If we have a fill level, add two extra points at either end
x = x2.reshape(x2.size)
y2 = np.empty((len(y)+2,2), dtype=y.dtype)
y2[1:-1] = y[:,np.newaxis]
y = y2.reshape(y2.size)[1:-1]
y[0] = self.opts['fillLevel']
y[-1] = self.opts['fillLevel']
return fn.arrayToQPath(
x,
y,
connect=self.opts['connect'],
finiteCheck=not self.opts['skipFiniteCheck']
)
def getPath(self):
if self.path is None:
x,y = self.getData()
if x is None or len(x) == 0 or y is None or len(y) == 0:
self.path = QtGui.QPainterPath()
else:
self.path = self.generatePath(*self.getData())
self.fillPath = None
self._mouseShape = None
return self.path
@debug.warnOnException ## raising an exception here causes crash
def paint(self, p, opt, widget):
profiler = debug.Profiler()
if self.xData is None or len(self.xData) == 0:
return
if getConfigOption('enableExperimental'):
if HAVE_OPENGL and isinstance(widget, QtWidgets.QOpenGLWidget):
self.paintGL(p, opt, widget)
return
x = None
y = None
path = self.getPath()
profiler('generate path')
if self._exportOpts is not False:
aa = self._exportOpts.get('antialias', True)
else:
aa = self.opts['antialias']
p.setRenderHint(p.RenderHint.Antialiasing, aa)
cmode = self.opts['compositionMode']
if cmode is not None:
p.setCompositionMode(cmode)
if self.opts['brush'] is not None and self.opts['fillLevel'] is not None:
if self.fillPath is None:
if x is None:
x,y = self.getData()
p2 = QtGui.QPainterPath(self.path)
if self.opts['fillLevel'] != 'enclosed':
p2.lineTo(x[-1], self.opts['fillLevel'])
p2.lineTo(x[0], self.opts['fillLevel'])
p2.lineTo(x[0], y[0])
p2.closeSubpath()
self.fillPath = p2
profiler('generate fill path')
p.fillPath(self.fillPath, self.opts['brush'])
profiler('draw fill path')
# Avoid constructing a shadow pen if it's not used.
if self.opts.get('shadowPen') is not None:
if isinstance(self.opts.get('shadowPen'), QtGui.QPen):
sp = self.opts['shadowPen']
else:
sp = fn.mkPen(self.opts['shadowPen'])
if sp.style() != QtCore.Qt.PenStyle.NoPen:
p.setPen(sp)
p.drawPath(path)
if isinstance(self.opts.get('pen'), QtGui.QPen):
cp = self.opts['pen']
else:
cp = fn.mkPen(self.opts['pen'])
p.setPen(cp)
if self.opts['fillOutline'] and self.fillPath is not None:
p.drawPath(self.fillPath)
else:
p.drawPath(path)
profiler('drawPath')
def paintGL(self, p, opt, widget):
p.beginNativePainting()
import OpenGL.GL as gl
if sys.platform == 'win32':
# If Qt is built to dynamically load OpenGL, then the projection and
# modelview matrices are not setup.
# https://doc.qt.io/qt-6/windows-graphics.html
# https://code.woboq.org/qt6/qtbase/src/opengl/qopenglpaintengine.cpp.html
# Technically, we could enable it for all platforms, but for now, just
# enable it where it is required, i.e. Windows
gl.glMatrixMode(gl.GL_PROJECTION)
gl.glLoadIdentity()
gl.glOrtho(0, widget.width(), widget.height(), 0, -999999, 999999)
gl.glMatrixMode(gl.GL_MODELVIEW)
gl.glLoadMatrixf(QtGui.QMatrix4x4(self.sceneTransform()).data())
## set clipping viewport
view = self.getViewBox()
if view is not None:
rect = view.mapRectToItem(self, view.boundingRect())
#gl.glViewport(int(rect.x()), int(rect.y()), int(rect.width()), int(rect.height()))
#gl.glTranslate(-rect.x(), -rect.y(), 0)
gl.glEnable(gl.GL_STENCIL_TEST)
gl.glColorMask(gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE) # disable drawing to frame buffer
gl.glDepthMask(gl.GL_FALSE) # disable drawing to depth buffer
gl.glStencilFunc(gl.GL_NEVER, 1, 0xFF)
gl.glStencilOp(gl.GL_REPLACE, gl.GL_KEEP, gl.GL_KEEP)
## draw stencil pattern
gl.glStencilMask(0xFF)
gl.glClear(gl.GL_STENCIL_BUFFER_BIT)
gl.glBegin(gl.GL_TRIANGLES)
gl.glVertex2f(rect.x(), rect.y())
gl.glVertex2f(rect.x()+rect.width(), rect.y())
gl.glVertex2f(rect.x(), rect.y()+rect.height())
gl.glVertex2f(rect.x()+rect.width(), rect.y()+rect.height())
gl.glVertex2f(rect.x()+rect.width(), rect.y())
gl.glVertex2f(rect.x(), rect.y()+rect.height())
gl.glEnd()
gl.glColorMask(gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE)
gl.glDepthMask(gl.GL_TRUE)
gl.glStencilMask(0x00)
gl.glStencilFunc(gl.GL_EQUAL, 1, 0xFF)
try:
x, y = self.getData()
pos = np.empty((len(x), 2))
pos[:,0] = x
pos[:,1] = y
gl.glEnableClientState(gl.GL_VERTEX_ARRAY)
try:
gl.glVertexPointerf(pos)
pen = fn.mkPen(self.opts['pen'])
gl.glColor4f(*pen.color().getRgbF())
width = pen.width()
if pen.isCosmetic() and width < 1:
width = 1
gl.glPointSize(width)
gl.glLineWidth(width)
# enable antialiasing if requested
if self._exportOpts is not False:
aa = self._exportOpts.get('antialias', True)
else:
aa = self.opts['antialias']
if aa:
gl.glEnable(gl.GL_LINE_SMOOTH)
gl.glEnable(gl.GL_BLEND)
gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA)
gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST)
else:
gl.glDisable(gl.GL_LINE_SMOOTH)
gl.glDrawArrays(gl.GL_LINE_STRIP, 0, int(pos.size / pos.shape[-1]))
finally:
gl.glDisableClientState(gl.GL_VERTEX_ARRAY)
finally:
p.endNativePainting()
def clear(self):
self.xData = None ## raw values
self.yData = None
self.xDisp = None ## display values (after log / fft)
self.yDisp = None
self.path = None
self.fillPath = None
self._mouseShape = None
self._mouseBounds = None
self._boundsCache = [None, None]
#del self.xData, self.yData, self.xDisp, self.yDisp, self.path
def mouseShape(self):
"""
Return a QPainterPath representing the clickable shape of the curve
"""
if self._mouseShape is None:
view = self.getViewBox()
if view is None:
return QtGui.QPainterPath()
stroker = QtGui.QPainterPathStroker()
path = self.getPath()
path = self.mapToItem(view, path)
stroker.setWidth(self.opts['mouseWidth'])
mousePath = stroker.createStroke(path)
self._mouseShape = self.mapFromItem(view, mousePath)
return self._mouseShape
def mouseClickEvent(self, ev):
if not self.clickable or ev.button() != QtCore.Qt.MouseButton.LeftButton:
return
if self.mouseShape().contains(ev.pos()):
ev.accept()
self.sigClicked.emit(self, ev)
class ROIPlotItem(PlotCurveItem):
"""Plot curve that monitors an ROI and image for changes to automatically replot."""
def __init__(self, roi, data, img, axes=(0,1), xVals=None, color=None):
self.roi = roi
self.roiData = data
self.roiImg = img
self.axes = axes
self.xVals = xVals
PlotCurveItem.__init__(self, self.getRoiData(), x=self.xVals, color=color)
#roi.connect(roi, QtCore.SIGNAL('regionChanged'), self.roiChangedEvent)
roi.sigRegionChanged.connect(self.roiChangedEvent)
#self.roiChangedEvent()
def getRoiData(self):
d = self.roi.getArrayRegion(self.roiData, self.roiImg, axes=self.axes)
if d is None:
return
while d.ndim > 1:
d = d.mean(axis=1)
return d
def roiChangedEvent(self):
d = self.getRoiData()
self.updateData(d, self.xVals)