summaryrefslogtreecommitdiff
path: root/silx/gui/plot/backends/BackendOpenGL.py
diff options
context:
space:
mode:
authorPicca Frédéric-Emmanuel <picca@synchrotron-soleil.fr>2017-08-18 14:48:52 +0200
committerPicca Frédéric-Emmanuel <picca@synchrotron-soleil.fr>2017-08-18 14:48:52 +0200
commitf7bdc2acff3c13a6d632c28c4569690ab106eed7 (patch)
tree9d67cdb7152ee4e711379e03fe0546c7c3b97303 /silx/gui/plot/backends/BackendOpenGL.py
Import Upstream version 0.5.0+dfsg
Diffstat (limited to 'silx/gui/plot/backends/BackendOpenGL.py')
-rw-r--r--silx/gui/plot/backends/BackendOpenGL.py1631
1 files changed, 1631 insertions, 0 deletions
diff --git a/silx/gui/plot/backends/BackendOpenGL.py b/silx/gui/plot/backends/BackendOpenGL.py
new file mode 100644
index 0000000..bc10eca
--- /dev/null
+++ b/silx/gui/plot/backends/BackendOpenGL.py
@@ -0,0 +1,1631 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2014-2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+"""OpenGL Plot backend."""
+
+from __future__ import division
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "21/03/2017"
+
+from collections import OrderedDict, namedtuple
+from ctypes import c_void_p
+import logging
+
+import numpy
+
+from .._utils import FLOAT32_MINPOS
+from . import BackendBase
+from .. import Colors
+from ... import qt
+
+from ..._glutils import gl
+from ... import _glutils as glu
+from .glutils import (
+ GLPlotCurve2D, GLPlotColormap, GLPlotRGBAImage, GLPlotFrame2D,
+ mat4Ortho, mat4Identity,
+ LEFT, RIGHT, BOTTOM, TOP,
+ Text2D, Shape2D)
+from .glutils.PlotImageFile import saveImageToFile
+
+_logger = logging.getLogger(__name__)
+
+
+# TODO idea: BackendQtMixIn class to share code between mpl and gl
+# TODO check if OpenGL is available
+# TODO make an off-screen mesa backend
+
+# Bounds ######################################################################
+
+class Range(namedtuple('Range', ('min_', 'max_'))):
+ """Describes a 1D range"""
+
+ @property
+ def range_(self):
+ return self.max_ - self.min_
+
+ @property
+ def center(self):
+ return 0.5 * (self.min_ + self.max_)
+
+
+class Bounds(object):
+ """Describes plot bounds with 2 y axis"""
+
+ def __init__(self, xMin, xMax, yMin, yMax, y2Min, y2Max):
+ self._xAxis = Range(xMin, xMax)
+ self._yAxis = Range(yMin, yMax)
+ self._y2Axis = Range(y2Min, y2Max)
+
+ def __repr__(self):
+ return "x: %s, y: %s, y2: %s" % (repr(self._xAxis),
+ repr(self._yAxis),
+ repr(self._y2Axis))
+
+ @property
+ def xAxis(self):
+ return self._xAxis
+
+ @property
+ def yAxis(self):
+ return self._yAxis
+
+ @property
+ def y2Axis(self):
+ return self._y2Axis
+
+
+# Content #####################################################################
+
+class PlotDataContent(object):
+ """Manage plot data content: images and curves.
+
+ This class is only meant to work with _OpenGLPlotCanvas.
+ """
+
+ _PRIMITIVE_TYPES = 'curve', 'image'
+
+ def __init__(self):
+ self._primitives = OrderedDict() # For images and curves
+
+ def add(self, primitive):
+ """Add a curve or image to the content dictionary.
+
+ This function generates the key in the dict from the primitive.
+
+ :param primitive: The primitive to add.
+ :type primitive: Instance of GLPlotCurve2D, GLPlotColormap,
+ GLPlotRGBAImage.
+ """
+ if isinstance(primitive, GLPlotCurve2D):
+ primitiveType = 'curve'
+ elif isinstance(primitive, (GLPlotColormap, GLPlotRGBAImage)):
+ primitiveType = 'image'
+ else:
+ raise RuntimeError('Unsupported object type: %s', primitive)
+
+ key = primitiveType, primitive.info['legend']
+ self._primitives[key] = primitive
+
+ def get(self, primitiveType, legend):
+ """Get the corresponding primitive of given type with given legend.
+
+ :param str primitiveType: Type of primitive ('curve' or 'image').
+ :param str legend: The legend of the primitive to retrieve.
+ :return: The corresponding curve or None if no such curve.
+ """
+ assert primitiveType in self._PRIMITIVE_TYPES
+ return self._primitives.get((primitiveType, legend))
+
+ def pop(self, primitiveType, key):
+ """Pop the corresponding curve or return None if no such curve.
+
+ :param str primitiveType:
+ :param str key:
+ :return:
+ """
+ assert primitiveType in self._PRIMITIVE_TYPES
+ return self._primitives.pop((primitiveType, key), None)
+
+ def zOrderedPrimitives(self, reverse=False):
+ """List of primitives sorted according to their z order.
+
+ It is a stable sort (as sorted):
+ Original order is preserved when key is the same.
+
+ :param bool reverse: Ascending (True, default) or descending (False).
+ """
+ return sorted(self._primitives.values(),
+ key=lambda primitive: primitive.info['zOrder'],
+ reverse=reverse)
+
+ def primitives(self):
+ """Iterator over all primitives."""
+ return self._primitives.values()
+
+ def primitiveKeys(self, primitiveType):
+ """Iterator over primitives of a specific type."""
+ assert primitiveType in self._PRIMITIVE_TYPES
+ for type_, key in self._primitives.keys():
+ if type_ == primitiveType:
+ yield key
+
+ def getBounds(self, xPositive=False, yPositive=False):
+ """Bounds of the data.
+
+ Can return strictly positive bounds (for log scale).
+ In this case, curves are clipped to their smaller positive value
+ and images with negative min are ignored.
+
+ :param bool xPositive: True to get strictly positive range.
+ :param bool yPositive: True to get strictly positive range.
+ :return: The range of data for x, y and y2, or default (1., 100.)
+ if no range found for one dimension.
+ :rtype: Bounds
+ """
+ xMin, yMin, y2Min = float('inf'), float('inf'), float('inf')
+ xMax = 0. if xPositive else -float('inf')
+ if yPositive:
+ yMax, y2Max = 0., 0.
+ else:
+ yMax, y2Max = -float('inf'), -float('inf')
+
+ for item in self._primitives.values():
+ # To support curve <= 0. and log and bypass images:
+ # If positive only, uses x|yMinPos if available
+ # and bypass other data with negative min bounds
+ if xPositive:
+ itemXMin = getattr(item, 'xMinPos', item.xMin)
+ if itemXMin is None or itemXMin < FLOAT32_MINPOS:
+ continue
+ else:
+ itemXMin = item.xMin
+
+ if yPositive:
+ itemYMin = getattr(item, 'yMinPos', item.yMin)
+ if itemYMin is None or itemYMin < FLOAT32_MINPOS:
+ continue
+ else:
+ itemYMin = item.yMin
+
+ if itemXMin < xMin:
+ xMin = itemXMin
+ if item.xMax > xMax:
+ xMax = item.xMax
+
+ if item.info.get('yAxis') == 'right':
+ if itemYMin < y2Min:
+ y2Min = itemYMin
+ if item.yMax > y2Max:
+ y2Max = item.yMax
+ else:
+ if itemYMin < yMin:
+ yMin = itemYMin
+ if item.yMax > yMax:
+ yMax = item.yMax
+
+ # One of the limit has not been updated, return default range
+ if xMin >= xMax:
+ xMin, xMax = 1., 100.
+ if yMin >= yMax:
+ yMin, yMax = 1., 100.
+ if y2Min >= y2Max:
+ y2Min, y2Max = 1., 100.
+
+ return Bounds(xMin, xMax, yMin, yMax, y2Min, y2Max)
+
+
+# shaders #####################################################################
+
+_baseVertShd = """
+ attribute vec2 position;
+ uniform mat4 matrix;
+ uniform bvec2 isLog;
+
+ const float oneOverLog10 = 0.43429448190325176;
+
+ void main(void) {
+ vec2 posTransformed = position;
+ if (isLog.x) {
+ posTransformed.x = oneOverLog10 * log(position.x);
+ }
+ if (isLog.y) {
+ posTransformed.y = oneOverLog10 * log(position.y);
+ }
+ gl_Position = matrix * vec4(posTransformed, 0.0, 1.0);
+ }
+ """
+
+_baseFragShd = """
+ uniform vec4 color;
+ uniform int hatchStep;
+ uniform float tickLen;
+
+ void main(void) {
+ if (tickLen != 0.) {
+ if (mod((gl_FragCoord.x + gl_FragCoord.y) / tickLen, 2.) < 1.) {
+ gl_FragColor = color;
+ } else {
+ discard;
+ }
+ } else if (hatchStep == 0 ||
+ mod(gl_FragCoord.x - gl_FragCoord.y, float(hatchStep)) == 0.) {
+ gl_FragColor = color;
+ } else {
+ discard;
+ }
+ }
+ """
+
+_texVertShd = """
+ attribute vec2 position;
+ attribute vec2 texCoords;
+ uniform mat4 matrix;
+
+ varying vec2 coords;
+
+ void main(void) {
+ gl_Position = matrix * vec4(position, 0.0, 1.0);
+ coords = texCoords;
+ }
+ """
+
+_texFragShd = """
+ uniform sampler2D tex;
+
+ varying vec2 coords;
+
+ void main(void) {
+ gl_FragColor = texture2D(tex, coords);
+ }
+ """
+
+
+# BackendOpenGL ###############################################################
+
+_current_context = None
+
+
+def _getContext():
+ assert _current_context is not None
+ return _current_context
+
+
+class BackendOpenGL(BackendBase.BackendBase, qt.QGLWidget):
+ """OpenGL-based Plot backend.
+
+ WARNINGS:
+ Unless stated otherwise, this API is NOT thread-safe and MUST be
+ called from the main thread.
+ When numpy arrays are passed as arguments to the API (through
+ :func:`addCurve` and :func:`addImage`), they are copied only if
+ required.
+ So, the caller should not modify these arrays afterwards.
+ """
+
+ _sigPostRedisplay = qt.Signal()
+ """Signal handling automatic asynchronous replot"""
+
+ def __init__(self, plot, parent=None):
+ qt.QGLWidget.__init__(self, parent)
+ BackendBase.BackendBase.__init__(self, plot, parent)
+
+ self.matScreenProj = mat4Identity()
+
+ self._progBase = glu.Program(
+ _baseVertShd, _baseFragShd, attrib0='position')
+ self._progTex = glu.Program(
+ _texVertShd, _texFragShd, attrib0='position')
+ self._plotFBOs = {}
+
+ self._keepDataAspectRatio = False
+
+ self._devicePixelRatio = 1.0
+
+ self._crosshairCursor = None
+ self._mousePosInPixels = None
+
+ self._markers = OrderedDict()
+ self._items = OrderedDict()
+ self._plotContent = PlotDataContent() # For images and curves
+ self._selectionAreas = OrderedDict()
+ self._glGarbageCollector = []
+
+ self._plotFrame = GLPlotFrame2D(
+ margins={'left': 100, 'right': 50, 'top': 50, 'bottom': 50})
+
+ # Make postRedisplay asynchronous using Qt signal
+ self._sigPostRedisplay.connect(
+ super(BackendOpenGL, self).postRedisplay,
+ qt.Qt.QueuedConnection)
+
+ # TODO is this needed? move it Plot?
+ self.setGraphXLimits(0., 100.)
+ self.setGraphYLimits(0., 100., axis='right')
+ self.setGraphYLimits(0., 100., axis='left')
+
+ self.setAutoFillBackground(False)
+ self.setMouseTracking(True)
+
+ # QWidget
+
+ _MOUSE_BTNS = {1: 'left', 2: 'right', 4: 'middle'}
+
+ def sizeHint(self):
+ return qt.QSize(8 * 80, 6 * 80) # Mimic MatplotlibBackend
+
+ def mousePressEvent(self, event):
+ xPixel = event.x() * self._devicePixelRatio
+ yPixel = event.y() * self._devicePixelRatio
+ btn = self._MOUSE_BTNS[event.button()]
+ self._plot.onMousePress(xPixel, yPixel, btn)
+ event.accept()
+
+ def mouseMoveEvent(self, event):
+ xPixel = event.x() * self._devicePixelRatio
+ yPixel = event.y() * self._devicePixelRatio
+
+ # Handle crosshair
+ inXPixel, inYPixel = self._mouseInPlotArea(xPixel, yPixel)
+ isCursorInPlot = inXPixel == xPixel and inYPixel == yPixel
+
+ previousMousePosInPixels = self._mousePosInPixels
+ self._mousePosInPixels = (xPixel, yPixel) if isCursorInPlot else None
+ if (self._crosshairCursor is not None and
+ previousMousePosInPixels != self._crosshairCursor):
+ # Avoid replot when cursor remains outside plot area
+ self._plot._setDirtyPlot(overlayOnly=True)
+
+ self._plot.onMouseMove(xPixel, yPixel)
+ event.accept()
+
+ def mouseReleaseEvent(self, event):
+ xPixel = event.x() * self._devicePixelRatio
+ yPixel = event.y() * self._devicePixelRatio
+
+ btn = self._MOUSE_BTNS[event.button()]
+ self._plot.onMouseRelease(xPixel, yPixel, btn)
+ event.accept()
+
+ def wheelEvent(self, event):
+ xPixel = event.x() * self._devicePixelRatio
+ yPixel = event.y() * self._devicePixelRatio
+
+ if hasattr(event, 'angleDelta'): # Qt 5
+ delta = event.angleDelta().y()
+ else: # Qt 4 support
+ delta = event.delta()
+ angleInDegrees = delta / 8.
+ self._plot.onMouseWheel(xPixel, yPixel, angleInDegrees)
+ event.accept()
+
+ def leaveEvent(self, _):
+ self._plot.onMouseLeaveWidget()
+
+ # QGLWidget API
+
+ @staticmethod
+ def _setBlendFuncGL():
+ # glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
+ gl.glBlendFuncSeparate(gl.GL_SRC_ALPHA,
+ gl.GL_ONE_MINUS_SRC_ALPHA,
+ gl.GL_ONE,
+ gl.GL_ONE)
+
+ def initializeGL(self):
+ gl.testGL()
+
+ gl.glClearColor(1., 1., 1., 1.)
+ gl.glClearStencil(0)
+
+ gl.glEnable(gl.GL_BLEND)
+ self._setBlendFuncGL()
+
+ # For lines
+ gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST)
+
+ # For points
+ gl.glEnable(gl.GL_VERTEX_PROGRAM_POINT_SIZE) # OpenGL 2
+ gl.glEnable(gl.GL_POINT_SPRITE) # OpenGL 2
+ # gl.glEnable(gl.GL_PROGRAM_POINT_SIZE)
+
+ def _paintDirectGL(self):
+ self._renderPlotAreaGL()
+ self._plotFrame.render()
+ self._renderMarkersGL()
+ self._renderOverlayGL()
+
+ def _paintFBOGL(self):
+ context = glu.getGLContext()
+ plotFBOTex = self._plotFBOs.get(context)
+ if (self._plot._getDirtyPlot() or self._plotFrame.isDirty or
+ plotFBOTex is None):
+ self._plotVertices = numpy.array(((-1., -1., 0., 0.),
+ (1., -1., 1., 0.),
+ (-1., 1., 0., 1.),
+ (1., 1., 1., 1.)),
+ dtype=numpy.float32)
+ if plotFBOTex is None or \
+ plotFBOTex.shape[1] != self._plotFrame.size[0] or \
+ plotFBOTex.shape[0] != self._plotFrame.size[1]:
+ if plotFBOTex is not None:
+ plotFBOTex.discard()
+ plotFBOTex = glu.FramebufferTexture(
+ gl.GL_RGBA,
+ shape=(self._plotFrame.size[1],
+ self._plotFrame.size[0]),
+ minFilter=gl.GL_NEAREST,
+ magFilter=gl.GL_NEAREST,
+ wrap=(gl.GL_CLAMP_TO_EDGE,
+ gl.GL_CLAMP_TO_EDGE))
+ self._plotFBOs[context] = plotFBOTex
+
+ with plotFBOTex:
+ gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_STENCIL_BUFFER_BIT)
+ self._renderPlotAreaGL()
+ self._plotFrame.render()
+
+ # Render plot in screen coords
+ gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1])
+
+ self._progTex.use()
+ texUnit = 0
+
+ gl.glUniform1i(self._progTex.uniforms['tex'], texUnit)
+ gl.glUniformMatrix4fv(self._progTex.uniforms['matrix'], 1, gl.GL_TRUE,
+ mat4Identity())
+
+ stride = self._plotVertices.shape[-1] * self._plotVertices.itemsize
+ gl.glEnableVertexAttribArray(self._progTex.attributes['position'])
+ gl.glVertexAttribPointer(self._progTex.attributes['position'],
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ stride, self._plotVertices)
+
+ texCoordsPtr = c_void_p(self._plotVertices.ctypes.data +
+ 2 * self._plotVertices.itemsize) # Better way?
+ gl.glEnableVertexAttribArray(self._progTex.attributes['texCoords'])
+ gl.glVertexAttribPointer(self._progTex.attributes['texCoords'],
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ stride, texCoordsPtr)
+
+ with plotFBOTex.texture:
+ gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(self._plotVertices))
+
+ self._renderMarkersGL()
+ self._renderOverlayGL()
+
+ def paintGL(self):
+ global _current_context
+ _current_context = self.context()
+
+ glu.setGLContextGetter(_getContext)
+
+ if hasattr(self, 'windowHandle'): # Qt 5
+ devicePixelRatio = self.windowHandle().devicePixelRatio()
+ if devicePixelRatio != self._devicePixelRatio:
+ self._devicePixelRatio = devicePixelRatio
+ self.resizeGL(int(self.width() * devicePixelRatio),
+ int(self.height() * devicePixelRatio))
+
+ # Release OpenGL resources
+ for item in self._glGarbageCollector:
+ item.discard()
+ self._glGarbageCollector = []
+
+ gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_STENCIL_BUFFER_BIT)
+
+ # Check if window is large enough
+ plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:]
+ if plotWidth <= 2 or plotHeight <= 2:
+ return
+
+ # self._paintDirectGL()
+ self._paintFBOGL()
+
+ glu.setGLContextGetter()
+ _current_context = None
+
+ def _nonOrthoAxesLineMarkerPrimitives(self, marker, pixelOffset):
+ """Generates the vertices and label for a line marker.
+
+ :param dict marker: Description of a line marker
+ :param int pixelOffset: Offset of text from borders in pixels
+ :return: Line vertices and Text label or None
+ :rtype: 2-tuple (2x2 numpy.array of float, Text2D)
+ """
+ label, vertices = None, None
+
+ xCoord, yCoord = marker['x'], marker['y']
+ assert xCoord is None or yCoord is None # Specific to line markers
+
+ # Get plot corners in data coords
+ plotLeft, plotTop, plotWidth, plotHeight = self.getPlotBoundsInPixels()
+
+ corners = [(plotLeft, plotTop),
+ (plotLeft, plotTop + plotHeight),
+ (plotLeft + plotWidth, plotTop + plotHeight),
+ (plotLeft + plotWidth, plotTop)]
+ corners = numpy.array([self.pixelToData(x, y, axis='left', check=False)
+ for (x, y) in corners])
+
+ borders = {
+ 'right': (corners[3], corners[2]),
+ 'top': (corners[0], corners[3]),
+ 'bottom': (corners[2], corners[1]),
+ 'left': (corners[1], corners[0])
+ }
+
+ textLayouts = { # align, valign, offsets
+ 'right': (RIGHT, BOTTOM, (-1., -1.)),
+ 'top': (LEFT, TOP, (1., 1.)),
+ 'bottom': (LEFT, BOTTOM, (1., -1.)),
+ 'left': (LEFT, BOTTOM, (1., -1.))
+ }
+
+ if xCoord is None: # Horizontal line in data space
+ if marker['text'] is not None:
+ # Find intersection of hline with borders in data
+ # Order is important as it stops at first intersection
+ for border_name in ('right', 'top', 'bottom', 'left'):
+ (x0, y0), (x1, y1) = borders[border_name]
+
+ if min(y0, y1) <= yCoord < max(y0, y1):
+ xIntersect = (yCoord - y0) * (x1 - x0) / (y1 - y0) + x0
+
+ # Add text label
+ pixelPos = self.dataToPixel(
+ xIntersect, yCoord, axis='left', check=False)
+
+ align, valign, offsets = textLayouts[border_name]
+
+ x = pixelPos[0] + offsets[0] * pixelOffset
+ y = pixelPos[1] + offsets[1] * pixelOffset
+ label = Text2D(marker['text'], x, y,
+ color=marker['color'],
+ bgColor=(1., 1., 1., 0.5),
+ align=align, valign=valign)
+ break # Stop at first intersection
+
+ xMin, xMax = corners[:, 0].min(), corners[:, 0].max()
+ vertices = numpy.array(
+ ((xMin, yCoord), (xMax, yCoord)), dtype=numpy.float32)
+
+ else: # yCoord is None: vertical line in data space
+ if marker['text'] is not None:
+ # Find intersection of hline with borders in data
+ # Order is important as it stops at first intersection
+ for border_name in ('top', 'bottom', 'right', 'left'):
+ (x0, y0), (x1, y1) = borders[border_name]
+ if min(x0, x1) <= xCoord < max(x0, x1):
+ yIntersect = (xCoord - x0) * (y1 - y0) / (x1 - x0) + y0
+
+ # Add text label
+ pixelPos = self.dataToPixel(
+ xCoord, yIntersect, axis='left', check=False)
+
+ align, valign, offsets = textLayouts[border_name]
+
+ x = pixelPos[0] + offsets[0] * pixelOffset
+ y = pixelPos[1] + offsets[1] * pixelOffset
+ label = Text2D(marker['text'], x, y,
+ color=marker['color'],
+ bgColor=(1., 1., 1., 0.5),
+ align=align, valign=valign)
+ break # Stop at first intersection
+
+ yMin, yMax = corners[:, 1].min(), corners[:, 1].max()
+ vertices = numpy.array(
+ ((xCoord, yMin), (xCoord, yMax)), dtype=numpy.float32)
+
+ return vertices, label
+
+ def _renderMarkersGL(self):
+ if len(self._markers) == 0:
+ return
+
+ plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:]
+
+ isXLog = self._plotFrame.xAxis.isLog
+ isYLog = self._plotFrame.yAxis.isLog
+
+ # Render in plot area
+ gl.glScissor(self._plotFrame.margins.left,
+ self._plotFrame.margins.bottom,
+ plotWidth, plotHeight)
+ gl.glEnable(gl.GL_SCISSOR_TEST)
+
+ gl.glViewport(self._plotFrame.margins.left,
+ self._plotFrame.margins.bottom,
+ plotWidth, plotHeight)
+
+ # Prepare vertical and horizontal markers rendering
+ self._progBase.use()
+ gl.glUniformMatrix4fv(self._progBase.uniforms['matrix'], 1, gl.GL_TRUE,
+ self._plotFrame.transformedDataProjMat)
+ gl.glUniform2i(self._progBase.uniforms['isLog'], isXLog, isYLog)
+ gl.glUniform1i(self._progBase.uniforms['hatchStep'], 0)
+ gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.)
+ posAttrib = self._progBase.attributes['position']
+
+ labels = []
+ pixelOffset = 3
+
+ for marker in self._markers.values():
+ xCoord, yCoord = marker['x'], marker['y']
+
+ if ((isXLog and xCoord is not None and
+ xCoord < FLOAT32_MINPOS) or
+ (isYLog and yCoord is not None and
+ yCoord < FLOAT32_MINPOS)):
+ # Do not render markers with negative coords on log axis
+ continue
+
+ if xCoord is None or yCoord is None:
+ if not self.isDefaultBaseVectors(): # Non-orthogonal axes
+ vertices, label = self._nonOrthoAxesLineMarkerPrimitives(
+ marker, pixelOffset)
+ if label is not None:
+ labels.append(label)
+
+ else: # Orthogonal axes
+ pixelPos = self.dataToPixel(
+ xCoord, yCoord, axis='left', check=False)
+
+ if xCoord is None: # Horizontal line in data space
+ if marker['text'] is not None:
+ x = self._plotFrame.size[0] - \
+ self._plotFrame.margins.right - pixelOffset
+ y = pixelPos[1] - pixelOffset
+ label = Text2D(marker['text'], x, y,
+ color=marker['color'],
+ bgColor=(1., 1., 1., 0.5),
+ align=RIGHT, valign=BOTTOM)
+ labels.append(label)
+
+ xMin, xMax = self._plotFrame.dataRanges.x
+ vertices = numpy.array(((xMin, yCoord),
+ (xMax, yCoord)),
+ dtype=numpy.float32)
+
+ else: # yCoord is None: vertical line in data space
+ if marker['text'] is not None:
+ x = pixelPos[0] + pixelOffset
+ y = self._plotFrame.margins.top + pixelOffset
+ label = Text2D(marker['text'], x, y,
+ color=marker['color'],
+ bgColor=(1., 1., 1., 0.5),
+ align=LEFT, valign=TOP)
+ labels.append(label)
+
+ yMin, yMax = self._plotFrame.dataRanges.y
+ vertices = numpy.array(((xCoord, yMin),
+ (xCoord, yMax)),
+ dtype=numpy.float32)
+
+ self._progBase.use()
+
+ gl.glUniform4f(self._progBase.uniforms['color'],
+ *marker['color'])
+
+ gl.glEnableVertexAttribArray(posAttrib)
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0, vertices)
+ gl.glLineWidth(1)
+ gl.glDrawArrays(gl.GL_LINES, 0, len(vertices))
+
+ else:
+ pixelPos = self.dataToPixel(
+ xCoord, yCoord, axis='left', check=True)
+ if pixelPos is None:
+ # Do not render markers outside visible plot area
+ continue
+
+ if marker['text'] is not None:
+ x = pixelPos[0] + pixelOffset
+ y = pixelPos[1] + pixelOffset
+ label = Text2D(marker['text'], x, y,
+ color=marker['color'],
+ bgColor=(1., 1., 1., 0.5),
+ align=LEFT, valign=TOP)
+ labels.append(label)
+
+ # For now simple implementation: using a curve for each marker
+ # Should pack all markers to a single set of points
+ markerCurve = GLPlotCurve2D(
+ numpy.array((xCoord,), dtype=numpy.float32),
+ numpy.array((yCoord,), dtype=numpy.float32),
+ marker=marker['symbol'],
+ markerColor=marker['color'],
+ markerSize=11)
+ markerCurve.render(self._plotFrame.transformedDataProjMat,
+ isXLog, isYLog)
+ markerCurve.discard()
+
+ gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1])
+
+ # Render marker labels
+ for label in labels:
+ label.render(self.matScreenProj)
+
+ gl.glDisable(gl.GL_SCISSOR_TEST)
+
+ def _renderOverlayGL(self):
+ # Render selection area and crosshair cursor
+ if self._selectionAreas or self._crosshairCursor is not None:
+ plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:]
+
+ # Scissor to plot area
+ gl.glScissor(self._plotFrame.margins.left,
+ self._plotFrame.margins.bottom,
+ plotWidth, plotHeight)
+ gl.glEnable(gl.GL_SCISSOR_TEST)
+
+ self._progBase.use()
+ gl.glUniform2i(self._progBase.uniforms['isLog'],
+ self._plotFrame.xAxis.isLog,
+ self._plotFrame.yAxis.isLog)
+ gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.)
+ posAttrib = self._progBase.attributes['position']
+ matrixUnif = self._progBase.uniforms['matrix']
+ colorUnif = self._progBase.uniforms['color']
+ hatchStepUnif = self._progBase.uniforms['hatchStep']
+
+ # Render selection area in plot area
+ if self._selectionAreas:
+ gl.glViewport(self._plotFrame.margins.left,
+ self._plotFrame.margins.bottom,
+ plotWidth, plotHeight)
+
+ gl.glUniformMatrix4fv(matrixUnif, 1, gl.GL_TRUE,
+ self._plotFrame.transformedDataProjMat)
+
+ for shape in self._selectionAreas.values():
+ if shape.isVideoInverted:
+ gl.glBlendFunc(gl.GL_ONE_MINUS_DST_COLOR, gl.GL_ZERO)
+
+ shape.render(posAttrib, colorUnif, hatchStepUnif)
+
+ if shape.isVideoInverted:
+ self._setBlendFuncGL()
+
+ # Render crosshair cursor is screen frame but with scissor
+ if (self._crosshairCursor is not None and
+ self._mousePosInPixels is not None):
+ gl.glViewport(
+ 0, 0, self._plotFrame.size[0], self._plotFrame.size[1])
+
+ gl.glUniformMatrix4fv(matrixUnif, 1, gl.GL_TRUE,
+ self.matScreenProj)
+
+ color, lineWidth = self._crosshairCursor
+ gl.glUniform4f(colorUnif, *color)
+ gl.glUniform1i(hatchStepUnif, 0)
+
+ xPixel, yPixel = self._mousePosInPixels
+ xPixel, yPixel = xPixel + 0.5, yPixel + 0.5
+ vertices = numpy.array(((0., yPixel),
+ (self._plotFrame.size[0], yPixel),
+ (xPixel, 0.),
+ (xPixel, self._plotFrame.size[1])),
+ dtype=numpy.float32)
+
+ gl.glEnableVertexAttribArray(posAttrib)
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0, vertices)
+ gl.glLineWidth(lineWidth)
+ gl.glDrawArrays(gl.GL_LINES, 0, len(vertices))
+
+ gl.glDisable(gl.GL_SCISSOR_TEST)
+
+ def _renderPlotAreaGL(self):
+ plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:]
+
+ self._plotFrame.renderGrid()
+
+ gl.glScissor(self._plotFrame.margins.left,
+ self._plotFrame.margins.bottom,
+ plotWidth, plotHeight)
+ gl.glEnable(gl.GL_SCISSOR_TEST)
+
+ # Matrix
+ trBounds = self._plotFrame.transformedDataRanges
+ if trBounds.x[0] == trBounds.x[1] or \
+ trBounds.y[0] == trBounds.y[1]:
+ return
+
+ isXLog = self._plotFrame.xAxis.isLog
+ isYLog = self._plotFrame.yAxis.isLog
+
+ gl.glViewport(self._plotFrame.margins.left,
+ self._plotFrame.margins.bottom,
+ plotWidth, plotHeight)
+
+ # Render images and curves
+ # sorted is stable: original order is preserved when key is the same
+ for item in self._plotContent.zOrderedPrimitives():
+ if item.info.get('yAxis') == 'right':
+ item.render(self._plotFrame.transformedDataY2ProjMat,
+ isXLog, isYLog)
+ else:
+ item.render(self._plotFrame.transformedDataProjMat,
+ isXLog, isYLog)
+
+ # Render Items
+ self._progBase.use()
+ gl.glUniformMatrix4fv(self._progBase.uniforms['matrix'], 1, gl.GL_TRUE,
+ self._plotFrame.transformedDataProjMat)
+ gl.glUniform2i(self._progBase.uniforms['isLog'],
+ self._plotFrame.xAxis.isLog,
+ self._plotFrame.yAxis.isLog)
+ gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.)
+
+ for item in self._items.values():
+ shape2D = item.get('_shape2D')
+ if shape2D is None:
+ shape2D = Shape2D(tuple(zip(item['x'], item['y'])),
+ fill=item['fill'],
+ fillColor=item['color'],
+ stroke=True,
+ strokeColor=item['color'])
+ item['_shape2D'] = shape2D
+
+ if ((isXLog and shape2D.xMin < FLOAT32_MINPOS) or
+ (isYLog and shape2D.yMin < FLOAT32_MINPOS)):
+ # Ignore items <= 0. on log axes
+ continue
+
+ posAttrib = self._progBase.attributes['position']
+ colorUnif = self._progBase.uniforms['color']
+ hatchStepUnif = self._progBase.uniforms['hatchStep']
+ shape2D.render(posAttrib, colorUnif, hatchStepUnif)
+
+ gl.glDisable(gl.GL_SCISSOR_TEST)
+
+ def resizeGL(self, width, height):
+ if width == 0 or height == 0: # Do not resize
+ return
+ self._plotFrame.size = width, height
+
+ self.matScreenProj = mat4Ortho(0, self._plotFrame.size[0],
+ self._plotFrame.size[1], 0,
+ 1, -1)
+
+ (xMin, xMax), (yMin, yMax), (y2Min, y2Max) = \
+ self._plotFrame.dataRanges
+ self.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max)
+
+ # Add methods
+
+ def addCurve(self, x, y, legend,
+ color, symbol, linewidth, linestyle,
+ yaxis,
+ xerror, yerror, z, selectable,
+ fill, alpha, symbolsize):
+ for parameter in (x, y, legend, color, symbol, linewidth, linestyle,
+ yaxis, z, selectable, fill, symbolsize):
+ assert parameter is not None
+ assert yaxis in ('left', 'right')
+
+ x = numpy.array(x, dtype=numpy.float32, copy=False, order='C')
+ y = numpy.array(y, dtype=numpy.float32, copy=False, order='C')
+ if xerror is not None:
+ xerror = numpy.array(
+ xerror, dtype=numpy.float32, copy=False, order='C')
+ if yerror is not None:
+ yerror = numpy.array(
+ yerror, dtype=numpy.float32, copy=False, order='C')
+
+ # TODO check and improve this
+ if (len(color) == 4 and
+ type(color[3]) in [type(1), numpy.uint8, numpy.int8]):
+ color = numpy.array(color, dtype=numpy.float32) / 255.
+
+ if isinstance(color, numpy.ndarray) and color.ndim == 2:
+ colorArray = color
+ color = None
+ else:
+ colorArray = None
+ color = Colors.rgba(color)
+
+ if alpha < 1.: # Apply image transparency
+ if colorArray is not None and colorArray.shape[1] == 4:
+ # multiply alpha channel
+ colorArray[:, 3] = colorArray[:, 3] * alpha
+ if color is not None:
+ color = color[0], color[1], color[2], color[3] * alpha
+
+ behaviors = set()
+ if selectable:
+ behaviors.add('selectable')
+
+ curve = GLPlotCurve2D(x, y, colorArray,
+ xError=xerror,
+ yError=yerror,
+ lineStyle=linestyle,
+ lineColor=color,
+ lineWidth=linewidth,
+ marker=symbol,
+ markerColor=color,
+ markerSize=symbolsize,
+ fillColor=color if fill else None)
+ curve.info = {
+ 'legend': legend,
+ 'zOrder': z,
+ 'behaviors': behaviors,
+ 'yAxis': 'left' if yaxis is None else yaxis,
+ }
+
+ if yaxis == "right":
+ self._plotFrame.isY2Axis = True
+
+ self._plotContent.add(curve)
+
+ return legend, 'curve'
+
+ def addImage(self, data, legend,
+ origin, scale, z,
+ selectable, draggable,
+ colormap, alpha):
+ for parameter in (data, legend, origin, scale, z,
+ selectable, draggable):
+ assert parameter is not None
+
+ behaviors = set()
+ if selectable:
+ behaviors.add('selectable')
+ if draggable:
+ behaviors.add('draggable')
+
+ if data.ndim == 2:
+ # Ensure array is contiguous and eventually convert its type
+ if data.dtype in (numpy.float32, numpy.uint8, numpy.uint16):
+ data = numpy.array(data, copy=False, order='C')
+ else:
+ _logger.info(
+ 'addImage: Convert %s data to float32', str(data.dtype))
+ data = numpy.array(data, dtype=numpy.float32, order='C')
+
+ colormapIsLog = colormap['normalization'].startswith('log')
+
+ if colormap['autoscale']:
+ cmapRange = None
+ else:
+ cmapRange = colormap['vmin'], colormap['vmax']
+ assert cmapRange[0] <= cmapRange[1]
+
+ # Retrieve colormap LUT from name and color array
+ colormapLut = Colors.applyColormapToData(
+ numpy.arange(256, dtype=numpy.uint8),
+ name=colormap['name'],
+ normalization='linear',
+ autoscale=False,
+ vmin=0,
+ vmax=255,
+ colors=colormap.get('colors'))
+
+ image = GLPlotColormap(data,
+ origin,
+ scale,
+ colormapLut,
+ colormapIsLog,
+ cmapRange,
+ alpha)
+ image.info = {
+ 'legend': legend,
+ 'zOrder': z,
+ 'behaviors': behaviors
+ }
+ self._plotContent.add(image)
+
+ elif len(data.shape) == 3:
+ # For RGB, RGBA data
+ assert data.shape[2] in (3, 4)
+ assert data.dtype in (numpy.float32, numpy.uint8)
+
+ image = GLPlotRGBAImage(data, origin, scale, alpha)
+
+ image.info = {
+ 'legend': legend,
+ 'zOrder': z,
+ 'behaviors': behaviors
+ }
+
+ if self._plotFrame.xAxis.isLog and image.xMin <= 0.:
+ raise RuntimeError(
+ 'Cannot add image with X <= 0 with X axis log scale')
+ if self._plotFrame.yAxis.isLog and image.yMin <= 0.:
+ raise RuntimeError(
+ 'Cannot add image with Y <= 0 with Y axis log scale')
+
+ self._plotContent.add(image)
+
+ else:
+ raise RuntimeError("Unsupported data shape {0}".format(data.shape))
+
+ return legend, 'image'
+
+ def addItem(self, x, y, legend, shape, color, fill, overlay, z):
+ # TODO handle overlay
+ if shape not in ('polygon', 'rectangle', 'line', 'vline', 'hline'):
+ raise NotImplementedError("Unsupported shape {0}".format(shape))
+
+ x = numpy.array(x, copy=False)
+ y = numpy.array(y, copy=False)
+
+ if shape == 'rectangle':
+ xMin, xMax = x
+ x = numpy.array((xMin, xMin, xMax, xMax))
+ yMin, yMax = y
+ y = numpy.array((yMin, yMax, yMax, yMin))
+
+ # TODO is this needed?
+ if self._plotFrame.xAxis.isLog and x.min() <= 0.:
+ raise RuntimeError(
+ 'Cannot add item with X <= 0 with X axis log scale')
+ if self._plotFrame.yAxis.isLog and y.min() <= 0.:
+ raise RuntimeError(
+ 'Cannot add item with Y <= 0 with Y axis log scale')
+
+ self._items[legend] = {
+ 'shape': shape,
+ 'color': Colors.rgba(color),
+ 'fill': 'hatch' if fill else None,
+ 'x': x,
+ 'y': y
+ }
+
+ return legend, 'item'
+
+ def addMarker(self, x, y, legend, text, color,
+ selectable, draggable,
+ symbol, constraint, overlay):
+ # TODO handle overlay
+
+ if symbol is None:
+ symbol = '+'
+
+ behaviors = set()
+ if selectable:
+ behaviors.add('selectable')
+ if draggable:
+ behaviors.add('draggable')
+
+ # Apply constraint to provided position
+ isConstraint = (draggable and constraint is not None and
+ x is not None and y is not None)
+ if isConstraint:
+ x, y = constraint(x, y)
+
+ if x is not None and self._plotFrame.xAxis.isLog and x <= 0.:
+ raise RuntimeError(
+ 'Cannot add marker with X <= 0 with X axis log scale')
+ if y is not None and self._plotFrame.yAxis.isLog and y <= 0.:
+ raise RuntimeError(
+ 'Cannot add marker with Y <= 0 with Y axis log scale')
+
+ self._markers[legend] = {
+ 'x': x,
+ 'y': y,
+ 'legend': legend,
+ 'text': text,
+ 'color': Colors.rgba(color),
+ 'behaviors': behaviors,
+ 'constraint': constraint if isConstraint else None,
+ 'symbol': symbol,
+ }
+
+ return legend, 'marker'
+
+ # Remove methods
+
+ def remove(self, item):
+ legend, kind = item
+
+ if kind == 'curve':
+ curve = self._plotContent.pop('curve', legend)
+ if curve is not None:
+ # Check if some curves remains on the right Y axis
+ y2AxisItems = (item for item in self._plotContent.primitives()
+ if item.info.get('yAxis', 'left') == 'right')
+ self._plotFrame.isY2Axis = next(y2AxisItems, None) is not None
+
+ self._glGarbageCollector.append(curve)
+
+ elif kind == 'image':
+ image = self._plotContent.pop('image', legend)
+ if image is not None:
+ self._glGarbageCollector.append(image)
+
+ elif kind == 'marker':
+ self._markers.pop(legend, False)
+
+ elif kind == 'item':
+ self._items.pop(legend, False)
+
+ else:
+ _logger.error('Unsupported kind: %s', str(kind))
+
+ # Interaction methods
+
+ _QT_CURSORS = {
+ None: qt.Qt.ArrowCursor,
+ BackendBase.CURSOR_DEFAULT: qt.Qt.ArrowCursor,
+ BackendBase.CURSOR_POINTING: qt.Qt.PointingHandCursor,
+ BackendBase.CURSOR_SIZE_HOR: qt.Qt.SizeHorCursor,
+ BackendBase.CURSOR_SIZE_VER: qt.Qt.SizeVerCursor,
+ BackendBase.CURSOR_SIZE_ALL: qt.Qt.SizeAllCursor,
+ }
+
+ def setGraphCursorShape(self, cursor):
+ cursor = self._QT_CURSORS[cursor]
+
+ super(BackendOpenGL, self).setCursor(qt.QCursor(cursor))
+
+ def setGraphCursor(self, flag, color, linewidth, linestyle):
+ if linestyle is not '-':
+ _logger.warning(
+ "BackendOpenGL.setGraphCursor linestyle parameter ignored")
+
+ if flag:
+ color = Colors.rgba(color)
+ crosshairCursor = color, linewidth
+ else:
+ crosshairCursor = None
+
+ if crosshairCursor != self._crosshairCursor:
+ self._crosshairCursor = crosshairCursor
+
+ _PICK_OFFSET = 3 # Offset in pixel used for picking
+
+ def _mouseInPlotArea(self, x, y):
+ xPlot = numpy.clip(
+ x, self._plotFrame.margins.left,
+ self._plotFrame.size[0] - self._plotFrame.margins.right - 1)
+ yPlot = numpy.clip(
+ y, self._plotFrame.margins.top,
+ self._plotFrame.size[1] - self._plotFrame.margins.bottom - 1)
+ return xPlot, yPlot
+
+ def pickItems(self, x, y):
+ picked = []
+
+ dataPos = self.pixelToData(x, y, axis='left', check=True)
+ if dataPos is not None:
+ # Pick markers
+ for marker in reversed(list(self._markers.values())):
+ pixelPos = self.dataToPixel(
+ marker['x'], marker['y'], axis='left', check=False)
+ if pixelPos is None: # negative coord on a log axis
+ continue
+
+ if marker['x'] is None: # Horizontal line
+ pt1 = self.pixelToData(
+ x, y - self._PICK_OFFSET, axis='left', check=False)
+ pt2 = self.pixelToData(
+ x, y + self._PICK_OFFSET, axis='left', check=False)
+ isPicked = (min(pt1[1], pt2[1]) <= marker['y'] <=
+ max(pt1[1], pt2[1]))
+
+ elif marker['y'] is None: # Vertical line
+ pt1 = self.pixelToData(
+ x - self._PICK_OFFSET, y, axis='left', check=False)
+ pt2 = self.pixelToData(
+ x + self._PICK_OFFSET, y, axis='left', check=False)
+ isPicked = (min(pt1[0], pt2[0]) <= marker['x'] <=
+ max(pt1[0], pt2[0]))
+
+ else:
+ isPicked = (
+ numpy.fabs(x - pixelPos[0]) <= self._PICK_OFFSET and
+ numpy.fabs(y - pixelPos[1]) <= self._PICK_OFFSET)
+
+ if isPicked:
+ picked.append(dict(kind='marker',
+ legend=marker['legend']))
+
+ # Pick image and curves
+ for item in self._plotContent.zOrderedPrimitives(reverse=True):
+ if isinstance(item, (GLPlotColormap, GLPlotRGBAImage)):
+ pickedPos = item.pick(*dataPos)
+ if pickedPos is not None:
+ picked.append(dict(kind='image',
+ legend=item.info['legend']))
+
+ elif isinstance(item, GLPlotCurve2D):
+ offset = self._PICK_OFFSET
+ if item.marker is not None:
+ offset = max(item.markerSize / 2., offset)
+ if item.lineStyle is not None:
+ offset = max(item.lineWidth / 2., offset)
+
+ yAxis = item.info['yAxis']
+
+ inAreaPos = self._mouseInPlotArea(x - offset, y - offset)
+ dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1],
+ axis=yAxis, check=True)
+ if dataPos is None:
+ continue
+ xPick0, yPick0 = dataPos
+
+ inAreaPos = self._mouseInPlotArea(x + offset, y + offset)
+ dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1],
+ axis=yAxis, check=True)
+ if dataPos is None:
+ continue
+ xPick1, yPick1 = dataPos
+
+ if xPick0 < xPick1:
+ xPickMin, xPickMax = xPick0, xPick1
+ else:
+ xPickMin, xPickMax = xPick1, xPick0
+
+ if yPick0 < yPick1:
+ yPickMin, yPickMax = yPick0, yPick1
+ else:
+ yPickMin, yPickMax = yPick1, yPick0
+
+ pickedIndices = item.pick(xPickMin, yPickMin,
+ xPickMax, yPickMax)
+ if pickedIndices:
+ picked.append(dict(kind='curve',
+ legend=item.info['legend'],
+ xdata=item.xData[pickedIndices],
+ ydata=item.yData[pickedIndices]))
+
+ return picked
+
+ # Update curve
+
+ def setCurveColor(self, curve, color):
+ pass # TODO
+
+ # Misc.
+
+ def getWidgetHandle(self):
+ return self
+
+ def postRedisplay(self):
+ self._sigPostRedisplay.emit()
+
+ def replot(self):
+ self.update() # async redraw
+ # self.repaint() # immediate redraw
+
+ def saveGraph(self, fileName, fileFormat, dpi):
+ if dpi is not None:
+ _logger.warning("saveGraph ignores dpi parameter")
+
+ if fileFormat not in ['png', 'ppm', 'svg', 'tiff']:
+ raise NotImplementedError('Unsupported format: %s' % fileFormat)
+
+ self.makeCurrent()
+
+ data = numpy.empty(
+ (self._plotFrame.size[1], self._plotFrame.size[0], 3),
+ dtype=numpy.uint8, order='C')
+
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0)
+ gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1)
+ gl.glReadPixels(0, 0, self._plotFrame.size[0], self._plotFrame.size[1],
+ gl.GL_RGB, gl.GL_UNSIGNED_BYTE, data)
+
+ # glReadPixels gives bottom to top,
+ # while images are stored as top to bottom
+ data = numpy.flipud(data)
+
+ # fileName is either a file-like object or a str
+ saveImageToFile(data, fileName, fileFormat)
+
+ # Graph labels
+
+ def setGraphTitle(self, title):
+ self._plotFrame.title = title
+
+ def setGraphXLabel(self, label):
+ self._plotFrame.xAxis.title = label
+
+ def setGraphYLabel(self, label, axis):
+ if axis == 'left':
+ self._plotFrame.yAxis.title = label
+ else: # right axis
+ if label:
+ _logger.warning('Right axis label not implemented')
+
+ # Non orthogonal axes
+
+ def setBaseVectors(self, x=(1., 0.), y=(0., 1.)):
+ """Set base vectors.
+
+ Useful for non-orthogonal axes.
+ If an axis is in log scale, skew is applied to log transformed values.
+
+ Base vector does not work well with log axes, to investi
+ """
+ if x != (1., 0.) and y != (0., 1.):
+ if self._plotFrame.xAxis.isLog:
+ _logger.warning("setBaseVectors disables X axis logarithmic.")
+ self.setXAxisLogarithmic(False)
+ if self._plotFrame.yAxis.isLog:
+ _logger.warning("setBaseVectors disables Y axis logarithmic.")
+ self.setYAxisLogarithmic(False)
+
+ if self.isKeepDataAspectRatio():
+ _logger.warning("setBaseVectors disables keepDataAspectRatio.")
+ self.keepDataAspectRatio(False)
+
+ self._plotFrame.baseVectors = x, y
+
+ def getBaseVectors(self):
+ return self._plotFrame.baseVectors
+
+ def isDefaultBaseVectors(self):
+ return self._plotFrame.baseVectors == \
+ self._plotFrame.DEFAULT_BASE_VECTORS
+
+ # Graph limits
+
+ def _setDataRanges(self, xlim=None, ylim=None, y2lim=None):
+ """Set the visible range of data in the plot frame.
+
+ This clips the ranges to possible values (takes care of float32
+ range + positive range for log).
+ This also takes care of non-orthogonal axes.
+
+ This should be moved to PlotFrame.
+ """
+ # Update axes range with a clipped range if too wide
+ self._plotFrame.setDataRanges(xlim, ylim, y2lim)
+
+ if not self.isDefaultBaseVectors():
+ # Update axes range with axes bounds in data coords
+ plotLeft, plotTop, plotWidth, plotHeight = \
+ self.getPlotBoundsInPixels()
+
+ self._plotFrame.xAxis.dataRange = sorted([
+ self.pixelToData(x, y, axis='left', check=False)[0]
+ for (x, y) in ((plotLeft, plotTop + plotHeight),
+ (plotLeft + plotWidth, plotTop + plotHeight))])
+
+ self._plotFrame.yAxis.dataRange = sorted([
+ self.pixelToData(x, y, axis='left', check=False)[1]
+ for (x, y) in ((plotLeft, plotTop + plotHeight),
+ (plotLeft, plotTop))])
+
+ self._plotFrame.y2Axis.dataRange = sorted([
+ self.pixelToData(x, y, axis='right', check=False)[1]
+ for (x, y) in ((plotLeft + plotWidth, plotTop + plotHeight),
+ (plotLeft + plotWidth, plotTop))])
+
+ def _ensureAspectRatio(self, keepDim=None):
+ """Update plot bounds in order to keep aspect ratio.
+
+ Warning: keepDim on right Y axis is not implemented !
+
+ :param str keepDim: The dimension to maintain: 'x', 'y' or None.
+ If None (the default), the dimension with the largest range.
+ """
+ plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:]
+ if plotWidth <= 2 or plotHeight <= 2:
+ return
+
+ if keepDim is None:
+ dataBounds = self._plotContent.getBounds(
+ self._plotFrame.xAxis.isLog, self._plotFrame.yAxis.isLog)
+ if dataBounds.yAxis.range_ != 0.:
+ dataRatio = dataBounds.xAxis.range_
+ dataRatio /= float(dataBounds.yAxis.range_)
+
+ plotRatio = plotWidth / float(plotHeight) # Test != 0 before
+
+ keepDim = 'x' if dataRatio > plotRatio else 'y'
+ else: # Limit case
+ keepDim = 'x'
+
+ (xMin, xMax), (yMin, yMax), (y2Min, y2Max) = \
+ self._plotFrame.dataRanges
+ if keepDim == 'y':
+ dataW = (yMax - yMin) * plotWidth / float(plotHeight)
+ xCenter = 0.5 * (xMin + xMax)
+ xMin = xCenter - 0.5 * dataW
+ xMax = xCenter + 0.5 * dataW
+ elif keepDim == 'x':
+ dataH = (xMax - xMin) * plotHeight / float(plotWidth)
+ yCenter = 0.5 * (yMin + yMax)
+ yMin = yCenter - 0.5 * dataH
+ yMax = yCenter + 0.5 * dataH
+ y2Center = 0.5 * (y2Min + y2Max)
+ y2Min = y2Center - 0.5 * dataH
+ y2Max = y2Center + 0.5 * dataH
+ else:
+ raise RuntimeError('Unsupported dimension to keep: %s' % keepDim)
+
+ # Update plot frame bounds
+ self._setDataRanges(xlim=(xMin, xMax),
+ ylim=(yMin, yMax),
+ y2lim=(y2Min, y2Max))
+
+ def _setPlotBounds(self, xRange=None, yRange=None, y2Range=None,
+ keepDim=None):
+ # Update axes range with a clipped range if too wide
+ self._setDataRanges(xlim=xRange,
+ ylim=yRange,
+ y2lim=y2Range)
+
+ # Keep data aspect ratio
+ if self.isKeepDataAspectRatio():
+ self._ensureAspectRatio(keepDim)
+
+ def setLimits(self, xmin, xmax, ymin, ymax, y2min=None, y2max=None):
+ assert xmin < xmax
+ assert ymin < ymax
+
+ if y2min is None or y2max is None:
+ y2Range = None
+ else:
+ assert y2min < y2max
+ y2Range = y2min, y2max
+ self._setPlotBounds((xmin, xmax), (ymin, ymax), y2Range)
+
+ def getGraphXLimits(self):
+ return self._plotFrame.dataRanges.x
+
+ def setGraphXLimits(self, xmin, xmax):
+ assert xmin < xmax
+ self._setPlotBounds(xRange=(xmin, xmax), keepDim='x')
+
+ def getGraphYLimits(self, axis):
+ assert axis in ("left", "right")
+ if axis == "left":
+ return self._plotFrame.dataRanges.y
+ else:
+ return self._plotFrame.dataRanges.y2
+
+ def setGraphYLimits(self, ymin, ymax, axis):
+ assert ymin < ymax
+ assert axis in ("left", "right")
+
+ if axis == "left":
+ self._setPlotBounds(yRange=(ymin, ymax), keepDim='y')
+ else:
+ self._setPlotBounds(y2Range=(ymin, ymax), keepDim='y')
+
+ # Graph axes
+
+ def setXAxisLogarithmic(self, flag):
+ if flag != self._plotFrame.xAxis.isLog:
+ if flag and self._keepDataAspectRatio:
+ _logger.warning(
+ "KeepDataAspectRatio is ignored with log axes")
+
+ if flag and not self.isDefaultBaseVectors():
+ _logger.warning(
+ "setXAxisLogarithmic ignored because baseVectors are set")
+ return
+
+ self._plotFrame.xAxis.isLog = flag
+
+ def setYAxisLogarithmic(self, flag):
+ if (flag != self._plotFrame.yAxis.isLog or
+ flag != self._plotFrame.y2Axis.isLog):
+ if flag and self._keepDataAspectRatio:
+ _logger.warning(
+ "KeepDataAspectRatio is ignored with log axes")
+
+ if flag and not self.isDefaultBaseVectors():
+ _logger.warning(
+ "setYAxisLogarithmic ignored because baseVectors are set")
+ return
+
+ self._plotFrame.yAxis.isLog = flag
+ self._plotFrame.y2Axis.isLog = flag
+
+ def setYAxisInverted(self, flag):
+ if flag != self._plotFrame.isYAxisInverted:
+ self._plotFrame.isYAxisInverted = flag
+
+ def isYAxisInverted(self):
+ return self._plotFrame.isYAxisInverted
+
+ def isKeepDataAspectRatio(self):
+ if self._plotFrame.xAxis.isLog or self._plotFrame.yAxis.isLog:
+ return False
+ else:
+ return self._keepDataAspectRatio
+
+ def setKeepDataAspectRatio(self, flag):
+ if flag and (self._plotFrame.xAxis.isLog or
+ self._plotFrame.yAxis.isLog):
+ _logger.warning("KeepDataAspectRatio is ignored with log axes")
+ if flag and not self.isDefaultBaseVectors():
+ _logger.warning(
+ "keepDataAspectRatio ignored because baseVectors are set")
+
+ self._keepDataAspectRatio = flag
+
+ def setGraphGrid(self, which):
+ assert which in (None, 'major', 'both')
+ self._plotFrame.grid = which is not None # TODO True grid support
+
+ # Data <-> Pixel coordinates conversion
+
+ def dataToPixel(self, x, y, axis, check=False):
+ assert axis in ('left', 'right')
+
+ if x is None or y is None:
+ dataBounds = self._plotContent.getBounds(
+ self._plotFrame.xAxis.isLog, self._plotFrame.yAxis.isLog)
+
+ if x is None:
+ x = dataBounds.xAxis.center
+
+ if y is None:
+ if axis == 'left':
+ y = dataBounds.yAxis.center
+ else:
+ y = dataBounds.y2Axis.center
+
+ result = self._plotFrame.dataToPixel(x, y, axis)
+
+ if check and result is not None:
+ xPixel, yPixel = result
+ width, height = self._plotFrame.size
+ if (xPixel < self._plotFrame.margins.left or
+ xPixel > (width - self._plotFrame.margins.right) or
+ yPixel < self._plotFrame.margins.top or
+ yPixel > height - self._plotFrame.margins.bottom):
+ return None # (x, y) is out of plot area
+
+ return result
+
+ def pixelToData(self, x, y, axis, check):
+ assert axis in ("left", "right")
+
+ if x is None:
+ x = self._plotFrame.size[0] / 2.
+ if y is None:
+ y = self._plotFrame.size[1] / 2.
+
+ if check and (x < self._plotFrame.margins.left or
+ x > (self._plotFrame.size[0] -
+ self._plotFrame.margins.right) or
+ y < self._plotFrame.margins.top or
+ y > (self._plotFrame.size[1] -
+ self._plotFrame.margins.bottom)):
+ return None # (x, y) is out of plot area
+
+ return self._plotFrame.pixelToData(x, y, axis)
+
+ def getPlotBoundsInPixels(self):
+ return self._plotFrame.plotOrigin + self._plotFrame.plotSize