pyqtgraph/graphicsItems/PlotCurveItem.py

591 lines
22 KiB
Python
Raw Normal View History

from ..Qt import QtGui, QtCore
try:
from ..Qt import QtOpenGL
HAVE_OPENGL = True
except:
HAVE_OPENGL = False
import numpy as np
2012-05-11 22:05:41 +00:00
from .GraphicsObject import GraphicsObject
from .. import functions as fn
from ..Point import Point
2012-05-11 22:05:41 +00:00
import struct, 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) Emitted when the curve is clicked
==================== ===============================================
"""
sigPlotChanged = QtCore.Signal(object)
sigClicked = QtCore.Signal(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.DeviceCoordinateCache)
self.metaData = {}
self.opts = {
'pen': fn.mkPen('w'),
'shadowPen': None,
'fillLevel': None,
'brush': None,
'stepMode': False,
'name': None,
'antialias': getConfigOption('antialias'),
'connect': 'all',
'mouseWidth': 8, # width of shape responding to mouse click
}
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 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
## 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:
b = (np.nanmin(d), np.nanmax(d))
elif frac <= 0.0:
raise Exception("Value for parameter 'frac' must be > 0. (got %s)" % str(frac))
else:
mask = np.isfinite(d)
d = d[mask]
b = np.percentile(d, [50 * (1 - frac), 50 * (1 + frac)])
## adjust for fill level
if ax == 1 and self.opts['fillLevel'] is not None:
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.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.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)
(ymn, ymx) = self.dataBounds(ax=1)
if xmn 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()
px = 0 if px is None else px.length()
py = 0 if py is None else py.length()
# 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 tyhe 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*
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.
2013-08-04 18:36:14 +00:00
stepMode If True, two orthogonal lines are drawn for each sample
as steps. This is commonly used when drawing histograms.
Note that in this case, len(x) == len(y) + 1
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.
============== ========================================================
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 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 'complex' in str(data.dtype):
raise Exception("Can not plot complex data types.")
profiler("data checks")
#self.setCacheMode(QtGui.QGraphicsItem.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.invalidateBounds()
self.prepareGeometryChange()
self.informViewBoundsChanged()
self.yData = kargs['y'].view(np.ndarray)
self.xData = kargs['x'].view(np.ndarray)
profiler('copy')
if 'stepMode' in kargs:
self.opts['stepMode'] = kargs['stepMode']
if self.opts['stepMode'] is True:
if len(self.xData) != len(self.yData)+1: ## allow difference of 1 for step mode plots
2013-06-20 12:44:46 +00:00
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
2013-06-20 12:44:46 +00:00
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:
self.setShadowPen(kargs['shadowPen'])
if 'fillLevel' in kargs:
self.setFillLevel(kargs['fillLevel'])
if 'brush' in kargs:
self.setBrush(kargs['brush'])
if 'antialias' in kargs:
self.opts['antialias'] = kargs['antialias']
profiler('set')
self.update()
profiler('update')
self.sigPlotChanged.emit(self)
profiler('emit')
def generatePath(self, x, y):
if self.opts['stepMode']:
## each value in the x/y arrays generates 2 points.
x2 = np.empty((len(x),2), dtype=x.dtype)
x2[:] = x[:,np.newaxis]
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']
path = fn.arrayToQPath(x, y, connect=self.opts['connect'])
return path
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:
return QtGui.QPainterPath()
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:
return
if HAVE_OPENGL and getConfigOption('enableExperimental') and isinstance(widget, QtOpenGL.QGLWidget):
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.Antialiasing, aa)
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)
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')
sp = fn.mkPen(self.opts['shadowPen'])
cp = fn.mkPen(self.opts['pen'])
## Copy pens and apply alpha adjustment
#sp = QtGui.QPen(self.opts['shadowPen'])
#cp = QtGui.QPen(self.opts['pen'])
#for pen in [sp, cp]:
#if pen is None:
#continue
#c = pen.color()
#c.setAlpha(c.alpha() * self.opts['alphaHint'])
#pen.setColor(c)
##pen.setCosmetic(True)
if sp is not None and sp.style() != QtCore.Qt.NoPen:
p.setPen(sp)
p.drawPath(path)
p.setPen(cp)
p.drawPath(path)
profiler('drawPath')
#print "Render hints:", int(p.renderHints())
#p.setPen(QtGui.QPen(QtGui.QColor(255,0,0)))
#p.drawRect(self.boundingRect())
def paintGL(self, p, opt, widget):
p.beginNativePainting()
import OpenGL.GL as gl
## 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'])
color = pen.color()
gl.glColor4f(color.red()/255., color.green()/255., color.blue()/255., color.alpha()/255.)
width = pen.width()
if pen.isCosmetic() and width < 1:
width = 1
gl.glPointSize(width)
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);
gl.glDrawArrays(gl.GL_LINE_STRIP, 0, 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.LeftButton:
return
if self.mouseShape().contains(ev.pos()):
ev.accept()
self.sigClicked.emit(self)
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)