summaryrefslogtreecommitdiff
path: root/silx/gui/plot/backends
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/plot/backends')
-rw-r--r--silx/gui/plot/backends/BackendBase.py474
-rw-r--r--silx/gui/plot/backends/BackendMatplotlib.py821
-rw-r--r--silx/gui/plot/backends/BackendOpenGL.py1631
-rw-r--r--silx/gui/plot/backends/ModestImage.py174
-rw-r--r--silx/gui/plot/backends/__init__.py29
-rw-r--r--silx/gui/plot/backends/_matplotlib.py64
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotCurve.py1317
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotFrame.py1039
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotImage.py707
-rw-r--r--silx/gui/plot/backends/glutils/GLSupport.py192
-rw-r--r--silx/gui/plot/backends/glutils/GLText.py222
-rw-r--r--silx/gui/plot/backends/glutils/GLTexture.py239
-rw-r--r--silx/gui/plot/backends/glutils/PlotImageFile.py149
-rw-r--r--silx/gui/plot/backends/glutils/__init__.py44
14 files changed, 7102 insertions, 0 deletions
diff --git a/silx/gui/plot/backends/BackendBase.py b/silx/gui/plot/backends/BackendBase.py
new file mode 100644
index 0000000..74f96af
--- /dev/null
+++ b/silx/gui/plot/backends/BackendBase.py
@@ -0,0 +1,474 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-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.
+#
+# ############################################################################*/
+"""Base class for Plot backends.
+
+It documents the Plot backend API.
+
+This API is a simplified version of PyMca PlotBackend API.
+"""
+
+__authors__ = ["V.A. Sole", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "18/02/2016"
+
+
+import weakref
+
+
+# Names for setCursor
+CURSOR_DEFAULT = 'default'
+CURSOR_POINTING = 'pointing'
+CURSOR_SIZE_HOR = 'size horizontal'
+CURSOR_SIZE_VER = 'size vertical'
+CURSOR_SIZE_ALL = 'size all'
+
+
+class BackendBase(object):
+ """Class defining the API a backend of the Plot should provide."""
+
+ def __init__(self, plot, parent=None):
+ """Init.
+
+ :param Plot plot: The Plot this backend is attached to
+ :param parent: The parent widget of the plot widget.
+ """
+ self.__xLimits = 1., 100.
+ self.__yLimits = {'left': (1., 100.), 'right': (1., 100.)}
+ self.__yAxisInverted = False
+ self.__keepDataAspectRatio = False
+ # Store a weakref to get access to the plot state.
+ self._setPlot(plot)
+
+ @property
+ def _plot(self):
+ """The plot this backend is attached to."""
+ if self._plotRef is None:
+ raise RuntimeError('This backend is not attached to a Plot')
+
+ plot = self._plotRef()
+ if plot is None:
+ raise RuntimeError('This backend is no more attached to a Plot')
+ return plot
+
+ def _setPlot(self, plot):
+ """Allow to set plot after init.
+
+ Use with caution, basically **immediately** after init.
+ """
+ self._plotRef = weakref.ref(plot)
+
+ # Add methods
+
+ def addCurve(self, x, y, legend,
+ color, symbol, linewidth, linestyle,
+ yaxis,
+ xerror, yerror, z, selectable,
+ fill, alpha, symbolsize):
+ """Add a 1D curve given by x an y to the graph.
+
+ :param numpy.ndarray x: The data corresponding to the x axis
+ :param numpy.ndarray y: The data corresponding to the y axis
+ :param str legend: The legend to be associated to the curve
+ :param color: color(s) to be used
+ :type color: string ("#RRGGBB") or (npoints, 4) unsigned byte array or
+ one of the predefined color names defined in Colors.py
+ :param str symbol: Symbol to be drawn at each (x, y) position::
+
+ - ' ' or '' no symbol
+ - 'o' circle
+ - '.' point
+ - ',' pixel
+ - '+' cross
+ - 'x' x-cross
+ - 'd' diamond
+ - 's' square
+
+ :param float linewidth: The width of the curve in pixels
+ :param str linestyle: Type of line::
+
+ - ' ' or '' no line
+ - '-' solid line
+ - '--' dashed line
+ - '-.' dash-dot line
+ - ':' dotted line
+
+ :param str yaxis: The Y axis this curve belongs to in: 'left', 'right'
+ :param xerror: Values with the uncertainties on the x values
+ :type xerror: numpy.ndarray or None
+ :param yerror: Values with the uncertainties on the y values
+ :type yerror: numpy.ndarray or None
+ :param int z: Layer on which to draw the cuve
+ :param bool selectable: indicate if the curve can be selected
+ :param bool fill: True to fill the curve, False otherwise
+ :param float alpha: Curve opacity, as a float in [0., 1.]
+ :param float symbolsize: Size of the symbol (if any) drawn
+ at each (x, y) position.
+ :returns: The handle used by the backend to univocally access the curve
+ """
+ return legend
+
+ def addImage(self, data, legend,
+ origin, scale, z,
+ selectable, draggable,
+ colormap, alpha):
+ """Add an image to the plot.
+
+ :param numpy.ndarray data: (nrows, ncolumns) data or
+ (nrows, ncolumns, RGBA) ubyte array
+ :param str legend: The legend to be associated to the image
+ :param origin: (origin X, origin Y) of the data.
+ Default: (0., 0.)
+ :type origin: 2-tuple of float
+ :param scale: (scale X, scale Y) of the data.
+ Default: (1., 1.)
+ :type scale: 2-tuple of float
+ :param int z: Layer on which to draw the image
+ :param bool selectable: indicate if the image can be selected
+ :param bool draggable: indicate if the image can be moved
+ :param colormap: Dictionary describing the colormap to use.
+ Ignored if data is RGB(A).
+ :type colormap: dict or None
+ :param float alpha: Opacity of the image, as a float in range [0, 1].
+ :returns: The handle used by the backend to univocally access the image
+ """
+ return legend
+
+ def addItem(self, x, y, legend, shape, color, fill, overlay, z):
+ """Add an item (i.e. a shape) to the plot.
+
+ :param numpy.ndarray x: The X coords of the points of the shape
+ :param numpy.ndarray y: The Y coords of the points of the shape
+ :param str legend: The legend to be associated to the item
+ :param str shape: Type of item to be drawn in
+ hline, polygon, rectangle, vline, polylines
+ :param str color: Color of the item
+ :param bool fill: True to fill the shape
+ :param bool overlay: True if item is an overlay, False otherwise
+ :param int z: Layer on which to draw the item
+ :returns: The handle used by the backend to univocally access the item
+ """
+ return legend
+
+ def addMarker(self, x, y, legend, text, color,
+ selectable, draggable,
+ symbol, constraint, overlay):
+ """Add a point, vertical line or horizontal line marker to the plot.
+
+ :param float x: Horizontal position of the marker in graph coordinates.
+ If None, the marker is a horizontal line.
+ :param float y: Vertical position of the marker in graph coordinates.
+ If None, the marker is a vertical line.
+ :param str legend: Legend associated to the marker
+ :param str text: Text associated to the marker (or None for no text)
+ :param str color: Color to be used for instance 'blue', 'b', '#FF0000'
+ :param bool selectable: indicate if the marker can be selected
+ :param bool draggable: indicate if the marker can be moved
+ :param str symbol: Symbol representing the marker.
+ Only relevant for point markers where X and Y are not None.
+ Value in:
+
+ - 'o' circle
+ - '.' point
+ - ',' pixel
+ - '+' cross
+ - 'x' x-cross
+ - 'd' diamond
+ - 's' square
+
+ :param constraint: A function filtering marker displacement by
+ dragging operations or None for no filter.
+ This function is called each time a marker is
+ moved.
+ This parameter is only used if draggable is True.
+ :type constraint: None or a callable that takes the coordinates of
+ the current cursor position in the plot as input
+ and that returns the filtered coordinates.
+ :param bool overlay: True if marker is an overlay (Default: False).
+ This allows for rendering optimization if this
+ marker is changed often.
+ :return: Handle used by the backend to univocally access the marker
+ """
+ return legend
+
+ # Remove methods
+
+ def remove(self, item):
+ """Remove an existing item from the plot.
+
+ :param item: A backend specific item handle returned by a add* method
+ """
+ pass
+
+ # Interaction methods
+
+ def setGraphCursorShape(self, cursor):
+ """Set the cursor shape.
+
+ To override in interactive backends.
+
+ :param str cursor: Name of the cursor shape or None
+ """
+ pass
+
+ def setGraphCursor(self, flag, color, linewidth, linestyle):
+ """Toggle the display of a crosshair cursor and set its attributes.
+
+ To override in interactive backends.
+
+ :param bool flag: Toggle the display of a crosshair cursor.
+ :param color: The color to use for the crosshair.
+ :type color: A string (either a predefined color name in Colors.py
+ or "#RRGGBB")) or a 4 columns unsigned byte array.
+ :param int linewidth: The width of the lines of the crosshair.
+ :param linestyle: Type of line::
+
+ - ' ' no line
+ - '-' solid line
+ - '--' dashed line
+ - '-.' dash-dot line
+ - ':' dotted line
+
+ :type linestyle: None or one of the predefined styles.
+ """
+ pass
+
+ def pickItems(self, x, y):
+ """Get a list of items at a pixel position.
+
+ :param float x: The x pixel coord where to pick.
+ :param float y: The y pixel coord where to pick.
+ :return: All picked items from back to front.
+ One dict per item,
+ with 'kind' key in 'curve', 'marker', 'image';
+ 'legend' key, the item legend.
+ and for curves, 'xdata' and 'ydata' keys storing picked
+ position on the curve.
+ :rtype: list of dict
+ """
+ return []
+
+ # Update curve
+
+ def setCurveColor(self, curve, color):
+ """Set the color of a curve.
+
+ :param curve: The curve handle
+ :param str color: The color to use.
+ """
+ pass
+
+ # Misc.
+
+ def getWidgetHandle(self):
+ """Return the widget this backend is drawing to."""
+ return None
+
+ def postRedisplay(self):
+ """Trigger a :meth:`Plot.replot`.
+
+ Default implementation triggers a synchronous replot if plot is dirty.
+ This method should be overridden by the embedding widget in order to
+ provide an asynchronous call to replot in order to optimize the number
+ replot operations.
+ """
+ # This method can be deferred and it might happen that plot has been
+ # destroyed in between, especially with unittests
+
+ plot = self._plotRef()
+ if plot is not None and plot._getDirtyPlot():
+ plot.replot()
+
+ def replot(self):
+ """Redraw the plot."""
+ pass
+
+ def saveGraph(self, fileName, fileFormat, dpi):
+ """Save the graph to a file (or a StringIO)
+
+ :param fileName: Destination
+ :type fileName: String or StringIO or BytesIO
+ :param str fileFormat: String specifying the format
+ :param int dpi: The resolution to use or None.
+ """
+ pass
+
+ # Graph labels
+
+ def setGraphTitle(self, title):
+ """Set the main title of the plot.
+
+ :param str title: Title associated to the plot
+ """
+ pass
+
+ def setGraphXLabel(self, label):
+ """Set the X axis label.
+
+ :param str label: label associated to the plot bottom X axis
+ """
+ pass
+
+ def setGraphYLabel(self, label, axis):
+ """Set the left Y axis label.
+
+ :param str label: label associated to the plot left Y axis
+ :param str axis: The axis for which to get the limits: left or right
+ """
+ pass
+
+ # Graph limits
+
+ def setLimits(self, xmin, xmax, ymin, ymax, y2min=None, y2max=None):
+ """Set the limits of the X and Y axes at once.
+
+ :param float xmin: minimum bottom axis value
+ :param float xmax: maximum bottom axis value
+ :param float ymin: minimum left axis value
+ :param float ymax: maximum left axis value
+ :param float y2min: minimum right axis value
+ :param float y2max: maximum right axis value
+ """
+ self.__xLimits = xmin, xmax
+ self.__yLimits['left'] = ymin, ymax
+ if y2min is not None and y2max is not None:
+ self.__yLimits['right'] = y2min, y2max
+
+ def getGraphXLimits(self):
+ """Get the graph X (bottom) limits.
+
+ :return: Minimum and maximum values of the X axis
+ """
+ return self.__xLimits
+
+ def setGraphXLimits(self, xmin, xmax):
+ """Set the limits of X axis.
+
+ :param float xmin: minimum bottom axis value
+ :param float xmax: maximum bottom axis value
+ """
+ self.__xLimits = xmin, xmax
+
+ def getGraphYLimits(self, axis):
+ """Get the graph Y (left) limits.
+
+ :param str axis: The axis for which to get the limits: left or right
+ :return: Minimum and maximum values of the Y axis
+ """
+ return self.__yLimits[axis]
+
+ def setGraphYLimits(self, ymin, ymax, axis):
+ """Set the limits of the Y axis.
+
+ :param float ymin: minimum left axis value
+ :param float ymax: maximum left axis value
+ :param str axis: The axis for which to get the limits: left or right
+ """
+ self.__yLimits[axis] = ymin, ymax
+
+ # Graph axes
+
+ def setXAxisLogarithmic(self, flag):
+ """Set the X axis scale between linear and log.
+
+ :param bool flag: If True, the bottom axis will use a log scale
+ """
+ pass
+
+ def setYAxisLogarithmic(self, flag):
+ """Set the Y axis scale between linear and log.
+
+ :param bool flag: If True, the left axis will use a log scale
+ """
+ pass
+
+ def setYAxisInverted(self, flag):
+ """Invert the Y axis.
+
+ :param bool flag: If True, put the vertical axis origin on the top
+ """
+ self.__yAxisInverted = bool(flag)
+
+ def isYAxisInverted(self):
+ """Return True if left Y axis is inverted, False otherwise."""
+ return self.__yAxisInverted
+
+ def isKeepDataAspectRatio(self):
+ """Returns whether the plot is keeping data aspect ratio or not."""
+ return self.__keepDataAspectRatio
+
+ def setKeepDataAspectRatio(self, flag):
+ """Set whether to keep data aspect ratio or not.
+
+ :param flag: True to respect data aspect ratio
+ :type flag: Boolean, default True
+ """
+ self.__keepDataAspectRatio = bool(flag)
+
+ def setGraphGrid(self, which):
+ """Set grid.
+
+ :param which: None to disable grid, 'major' for major grid,
+ 'both' for major and minor grid
+ """
+ pass
+
+ # Data <-> Pixel coordinates conversion
+
+ def dataToPixel(self, x, y, axis):
+ """Convert a position in data space to a position in pixels
+ in the widget.
+
+ :param float x: The X coordinate in data space.
+ :param float y: The Y coordinate in data space.
+ :param str axis: The Y axis to use for the conversion
+ ('left' or 'right').
+ :returns: The corresponding position in pixels or
+ None if the data position is not in the displayed area.
+ :rtype: A tuple of 2 floats: (xPixel, yPixel) or None.
+ """
+ raise NotImplementedError()
+
+ def pixelToData(self, x, y, axis, check):
+ """Convert a position in pixels in the widget to a position in
+ the data space.
+
+ :param float x: The X coordinate in pixels.
+ :param float y: The Y coordinate in pixels.
+ :param str axis: The Y axis to use for the conversion
+ ('left' or 'right').
+ :param bool check: True to check if the coordinates are in the
+ plot area.
+ :returns: The corresponding position in data space or
+ None if the pixel position is not in the plot area.
+ :rtype: A tuple of 2 floats: (xData, yData) or None.
+ """
+ raise NotImplementedError()
+
+ def getPlotBoundsInPixels(self):
+ """Plot area bounds in widget coordinates in pixels.
+
+ :return: bounds as a 4-tuple of int: (left, top, width, height)
+ """
+ raise NotImplementedError()
diff --git a/silx/gui/plot/backends/BackendMatplotlib.py b/silx/gui/plot/backends/BackendMatplotlib.py
new file mode 100644
index 0000000..f9e60d5
--- /dev/null
+++ b/silx/gui/plot/backends/BackendMatplotlib.py
@@ -0,0 +1,821 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-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.
+#
+# ###########################################################################*/
+"""Matplotlib Plot backend."""
+
+from __future__ import division
+
+__authors__ = ["V.A. Sole", "T. Vincent, H. Payno"]
+__license__ = "MIT"
+__date__ = "18/01/2017"
+
+
+import logging
+
+import numpy
+
+
+_logger = logging.getLogger(__name__)
+
+
+from ... import qt
+
+from ._matplotlib import FigureCanvasQTAgg
+import matplotlib
+from matplotlib.container import Container
+from matplotlib.figure import Figure
+from matplotlib.patches import Rectangle, Polygon
+from matplotlib.image import AxesImage
+from matplotlib.backend_bases import MouseEvent
+from matplotlib.lines import Line2D
+from matplotlib.collections import PathCollection, LineCollection
+
+from .ModestImage import ModestImage
+from . import BackendBase
+from .. import Colors
+from .._utils import FLOAT32_MINPOS
+
+
+class BackendMatplotlib(BackendBase.BackendBase):
+ """Base class for Matplotlib backend without a FigureCanvas.
+
+ For interactive on screen plot, see :class:`BackendMatplotlibQt`.
+
+ See :class:`BackendBase.BackendBase` for public API documentation.
+ """
+
+ def __init__(self, plot, parent=None):
+ super(BackendMatplotlib, self).__init__(plot, parent)
+
+ # matplotlib is handling keep aspect ratio at draw time
+ # When keep aspect ratio is on, and one changes the limits and
+ # ask them *before* next draw has been performed he will get the
+ # limits without applying keep aspect ratio.
+ # This attribute is used to ensure consistent values returned
+ # when getting the limits at the expense of a replot
+ self._dirtyLimits = True
+
+ self.fig = Figure()
+ self.fig.set_facecolor("w")
+
+ self.ax = self.fig.add_axes([.15, .15, .75, .75], label="left")
+ self.ax2 = self.ax.twinx()
+ self.ax2.set_label("right")
+
+ # critical for picking!!!!
+ self.ax2.set_zorder(0)
+ self.ax2.set_autoscaley_on(True)
+ self.ax.set_zorder(1)
+ # this works but the figure color is left
+ if matplotlib.__version__[0] < '2':
+ self.ax.set_axis_bgcolor('none')
+ else:
+ self.ax.set_facecolor('none')
+ self.fig.sca(self.ax)
+
+ self._overlays = set()
+ self._background = None
+
+ self._colormaps = {}
+
+ self._graphCursor = tuple()
+ self.matplotlibVersion = matplotlib.__version__
+
+ self.setGraphXLimits(0., 100.)
+ self.setGraphYLimits(0., 100., axis='right')
+ self.setGraphYLimits(0., 100., axis='left')
+
+ self._enableAxis('right', False)
+
+ # 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, alpha, symbolsize):
+ assert parameter is not None
+ assert yaxis in ('left', 'right')
+
+ if (len(color) == 4 and
+ type(color[3]) in [type(1), numpy.uint8, numpy.int8]):
+ color = numpy.array(color, dtype=numpy.float) / 255.
+
+ if yaxis == "right":
+ axes = self.ax2
+ self._enableAxis("right", True)
+ else:
+ axes = self.ax
+
+ picker = 3 if selectable else None
+
+ artists = [] # All the artists composing the curve
+
+ # First add errorbars if any so they are behind the curve
+ if xerror is not None or yerror is not None:
+ if hasattr(color, 'dtype') and len(color) == len(x):
+ errorbarColor = 'k'
+ else:
+ errorbarColor = color
+
+ # On Debian 7 at least, Nx1 array yerr does not seems supported
+ if (yerror is not None and yerror.ndim == 2 and
+ yerror.shape[1] == 1 and len(x) != 1):
+ yerror = numpy.ravel(yerror)
+
+ errorbars = axes.errorbar(x, y, label=legend,
+ xerr=xerror, yerr=yerror,
+ linestyle=' ', color=errorbarColor)
+ artists += list(errorbars.get_children())
+
+ if hasattr(color, 'dtype') and len(color) == len(x):
+ # scatter plot
+ if color.dtype not in [numpy.float32, numpy.float]:
+ actualColor = color / 255.
+ else:
+ actualColor = color
+
+ if linestyle not in ["", " ", None]:
+ # scatter plot with an actual line ...
+ # we need to assign a color ...
+ curveList = axes.plot(x, y, label=legend,
+ linestyle=linestyle,
+ color=actualColor[0],
+ linewidth=linewidth,
+ picker=picker,
+ marker=None)
+ artists += list(curveList)
+
+ scatter = axes.scatter(x, y,
+ label=legend,
+ color=actualColor,
+ marker=symbol,
+ picker=picker,
+ s=symbolsize)
+ artists.append(scatter)
+
+ if fill:
+ artists.append(axes.fill_between(
+ x, FLOAT32_MINPOS, y, facecolor=actualColor[0], linestyle=''))
+
+ else: # Curve
+ curveList = axes.plot(x, y,
+ label=legend,
+ linestyle=linestyle,
+ color=color,
+ linewidth=linewidth,
+ marker=symbol,
+ picker=picker,
+ markersize=symbolsize)
+ artists += list(curveList)
+
+ if fill:
+ artists.append(
+ axes.fill_between(x, FLOAT32_MINPOS, y, facecolor=color))
+
+ for artist in artists:
+ artist.set_zorder(z)
+ if alpha < 1:
+ artist.set_alpha(alpha)
+
+ return Container(artists)
+
+ def addImage(self, data, legend,
+ origin, scale, z,
+ selectable, draggable,
+ colormap, alpha):
+ # Non-uniform image
+ # http://wiki.scipy.org/Cookbook/Histograms
+ # Non-linear axes
+ # http://stackoverflow.com/questions/11488800/non-linear-axes-for-imshow-in-matplotlib
+ for parameter in (data, legend, origin, scale, z,
+ selectable, draggable):
+ assert parameter is not None
+
+ origin = float(origin[0]), float(origin[1])
+ scale = float(scale[0]), float(scale[1])
+ height, width = data.shape[0:2]
+
+ picker = (selectable or draggable)
+
+ # Debian 7 specific support
+ # No transparent colormap with matplotlib < 1.2.0
+ # Add support for transparent colormap for uint8 data with
+ # colormap with 256 colors, linear norm, [0, 255] range
+ if matplotlib.__version__ < '1.2.0':
+ if (len(data.shape) == 2 and colormap['name'] is None and
+ 'colors' in colormap):
+ colors = numpy.array(colormap['colors'], copy=False)
+ if (colors.shape[-1] == 4 and
+ not numpy.all(numpy.equal(colors[3], 255))):
+ # This is a transparent colormap
+ if (colors.shape == (256, 4) and
+ colormap['normalization'] == 'linear' and
+ not colormap['autoscale'] and
+ colormap['vmin'] == 0 and
+ colormap['vmax'] == 255 and
+ data.dtype == numpy.uint8):
+ # Supported case, convert data to RGBA
+ data = colors[data.reshape(-1)].reshape(
+ data.shape + (4,))
+ else:
+ _logger.warning(
+ 'matplotlib %s does not support transparent '
+ 'colormap.', matplotlib.__version__)
+
+ if ((height * width) > 5.0e5 and
+ origin == (0., 0.) and scale == (1., 1.)):
+ imageClass = ModestImage
+ else:
+ imageClass = AxesImage
+
+ # the normalization can be a source of time waste
+ # Two possibilities, we receive data or a ready to show image
+ if len(data.shape) == 3: # RGBA image
+ image = imageClass(self.ax,
+ label="__IMAGE__" + legend,
+ interpolation='nearest',
+ picker=picker,
+ zorder=z,
+ origin='lower')
+
+ else:
+ # Convert colormap argument to matplotlib colormap
+ scalarMappable = Colors.getMPLScalarMappable(colormap, data)
+
+ # try as data
+ image = imageClass(self.ax,
+ label="__IMAGE__" + legend,
+ interpolation='nearest',
+ cmap=scalarMappable.cmap,
+ picker=picker,
+ zorder=z,
+ norm=scalarMappable.norm,
+ origin='lower')
+ if alpha < 1:
+ image.set_alpha(alpha)
+
+ # Set image extent
+ xmin = origin[0]
+ xmax = xmin + scale[0] * width
+ if scale[0] < 0.:
+ xmin, xmax = xmax, xmin
+
+ ymin = origin[1]
+ ymax = ymin + scale[1] * height
+ if scale[1] < 0.:
+ ymin, ymax = ymax, ymin
+
+ image.set_extent((xmin, xmax, ymin, ymax))
+
+ # Set image data
+ if scale[0] < 0. or scale[1] < 0.:
+ # For negative scale, step by -1
+ xstep = 1 if scale[0] >= 0. else -1
+ ystep = 1 if scale[1] >= 0. else -1
+ data = data[::ystep, ::xstep]
+
+ image.set_data(data)
+
+ self.ax.add_artist(image)
+
+ return image
+
+ def addItem(self, x, y, legend, shape, color, fill, overlay, z):
+ xView = numpy.array(x, copy=False)
+ yView = numpy.array(y, copy=False)
+
+ if shape == "line":
+ item = self.ax.plot(x, y, label=legend, color=color,
+ linestyle='-', marker=None)[0]
+
+ elif shape == "hline":
+ if hasattr(y, "__len__"):
+ y = y[-1]
+ item = self.ax.axhline(y, label=legend, color=color)
+
+ elif shape == "vline":
+ if hasattr(x, "__len__"):
+ x = x[-1]
+ item = self.ax.axvline(x, label=legend, color=color)
+
+ elif shape == 'rectangle':
+ xMin = numpy.nanmin(xView)
+ xMax = numpy.nanmax(xView)
+ yMin = numpy.nanmin(yView)
+ yMax = numpy.nanmax(yView)
+ w = xMax - xMin
+ h = yMax - yMin
+ item = Rectangle(xy=(xMin, yMin),
+ width=w,
+ height=h,
+ fill=False,
+ color=color)
+ if fill:
+ item.set_hatch('.')
+
+ self.ax.add_patch(item)
+
+ elif shape in ('polygon', 'polylines'):
+ xView = xView.reshape(1, -1)
+ yView = yView.reshape(1, -1)
+ item = Polygon(numpy.vstack((xView, yView)).T,
+ closed=(shape == 'polygon'),
+ fill=False,
+ label=legend,
+ color=color)
+ if fill and shape == 'polygon':
+ item.set_hatch('/')
+
+ self.ax.add_patch(item)
+
+ else:
+ raise NotImplementedError("Unsupported item shape %s" % shape)
+
+ item.set_zorder(z)
+
+ if overlay:
+ item.set_animated(True)
+ self._overlays.add(item)
+
+ return item
+
+ def addMarker(self, x, y, legend, text, color,
+ selectable, draggable,
+ symbol, constraint, overlay):
+ legend = "__MARKER__" + legend
+
+ if x is not None and y is not None:
+ line = self.ax.plot(x, y, label=legend,
+ linestyle=" ",
+ color=color,
+ marker=symbol,
+ markersize=10.)[-1]
+
+ if text is not None:
+ xtmp, ytmp = self.ax.transData.transform_point((x, y))
+ inv = self.ax.transData.inverted()
+ xtmp, ytmp = inv.transform_point((xtmp, ytmp))
+
+ if symbol is None:
+ valign = 'baseline'
+ else:
+ valign = 'top'
+ text = " " + text
+
+ line._infoText = self.ax.text(x, ytmp, text,
+ color=color,
+ horizontalalignment='left',
+ verticalalignment=valign)
+
+ elif x is not None:
+ line = self.ax.axvline(x, label=legend, color=color)
+ if text is not None:
+ text = " " + text
+ ymin, ymax = self.getGraphYLimits(axis='left')
+ delta = abs(ymax - ymin)
+ if ymin > ymax:
+ ymax = ymin
+ ymax -= 0.005 * delta
+ line._infoText = self.ax.text(x, ymax, text,
+ color=color,
+ horizontalalignment='left',
+ verticalalignment='top')
+
+ elif y is not None:
+ line = self.ax.axhline(y, label=legend, color=color)
+
+ if text is not None:
+ text = " " + text
+ xmin, xmax = self.getGraphXLimits()
+ delta = abs(xmax - xmin)
+ if xmin > xmax:
+ xmax = xmin
+ xmax -= 0.005 * delta
+ line._infoText = self.ax.text(xmax, y, text,
+ color=color,
+ horizontalalignment='right',
+ verticalalignment='top')
+
+ else:
+ raise RuntimeError('A marker must at least have one coordinate')
+
+ if selectable or draggable:
+ line.set_picker(5)
+
+ if overlay:
+ line.set_animated(True)
+ self._overlays.add(line)
+
+ return line
+
+ # Remove methods
+
+ def remove(self, item):
+ # Warning: It also needs to remove extra stuff if added as for markers
+ if hasattr(item, "_infoText"): # For markers text
+ item._infoText.remove()
+ item._infoText = None
+ self._overlays.discard(item)
+ item.remove()
+
+ # Interaction methods
+
+ def setGraphCursor(self, flag, color, linewidth, linestyle):
+ if flag:
+ lineh = self.ax.axhline(
+ self.ax.get_ybound()[0], visible=False, color=color,
+ linewidth=linewidth, linestyle=linestyle)
+ lineh.set_animated(True)
+
+ linev = self.ax.axvline(
+ self.ax.get_xbound()[0], visible=False, color=color,
+ linewidth=linewidth, linestyle=linestyle)
+ linev.set_animated(True)
+
+ self._graphCursor = lineh, linev
+ else:
+ if self._graphCursor is not None:
+ lineh, linev = self._graphCursor
+ lineh.remove()
+ linev.remove()
+ self._graphCursor = tuple()
+
+ # Active curve
+
+ def setCurveColor(self, curve, color):
+ # Store Line2D and PathCollection
+ for artist in curve.get_children():
+ if isinstance(artist, (Line2D, LineCollection)):
+ artist.set_color(color)
+ elif isinstance(artist, PathCollection):
+ artist.set_facecolors(color)
+ artist.set_edgecolors(color)
+ else:
+ _logger.warning(
+ 'setActiveCurve ignoring artist %s', str(artist))
+
+ # Misc.
+
+ def getWidgetHandle(self):
+ return self.fig.canvas
+
+ def _enableAxis(self, axis, flag=True):
+ """Show/hide Y axis
+
+ :param str axis: Axis name: 'left' or 'right'
+ :param bool flag: Default, True
+ """
+ assert axis in ('right', 'left')
+ axes = self.ax2 if axis == 'right' else self.ax
+ axes.get_yaxis().set_visible(flag)
+
+ def replot(self):
+ """Do not perform rendering.
+
+ Override in subclass to actually draw something.
+ """
+ # TODO images, markers? scatter plot? move in remove?
+ # Right Y axis only support curve for now
+ # Hide right Y axis if no line is present
+ self._dirtyLimits = False
+ if not self.ax2.lines:
+ self._enableAxis('right', False)
+
+ def saveGraph(self, fileName, fileFormat, dpi):
+ # fileName can be also a StringIO or file instance
+ if dpi is not None:
+ self.fig.savefig(fileName, format=fileFormat, dpi=dpi)
+ else:
+ self.fig.savefig(fileName, format=fileFormat)
+ self._plot._setDirtyPlot()
+
+ # Graph labels
+
+ def setGraphTitle(self, title):
+ self.ax.set_title(title)
+
+ def setGraphXLabel(self, label):
+ self.ax.set_xlabel(label)
+
+ def setGraphYLabel(self, label, axis):
+ axes = self.ax if axis == 'left' else self.ax2
+ axes.set_ylabel(label)
+
+ # Graph limits
+
+ def setLimits(self, xmin, xmax, ymin, ymax, y2min=None, y2max=None):
+ # Let matplotlib taking care of keep aspect ratio if any
+ self._dirtyLimits = True
+ self.ax.set_xlim(min(xmin, xmax), max(xmin, xmax))
+
+ if y2min is not None and y2max is not None:
+ if not self.isYAxisInverted():
+ self.ax2.set_ylim(min(y2min, y2max), max(y2min, y2max))
+ else:
+ self.ax2.set_ylim(max(y2min, y2max), min(y2min, y2max))
+
+ if not self.isYAxisInverted():
+ self.ax.set_ylim(min(ymin, ymax), max(ymin, ymax))
+ else:
+ self.ax.set_ylim(max(ymin, ymax), min(ymin, ymax))
+
+ def getGraphXLimits(self):
+ if self._dirtyLimits and self.isKeepDataAspectRatio():
+ self.replot() # makes sure we get the right limits
+ return self.ax.get_xbound()
+
+ def setGraphXLimits(self, xmin, xmax):
+ self._dirtyLimits = True
+ self.ax.set_xlim(min(xmin, xmax), max(xmin, xmax))
+
+ def getGraphYLimits(self, axis):
+ assert axis in ('left', 'right')
+ ax = self.ax2 if axis == 'right' else self.ax
+
+ if not ax.get_visible():
+ return None
+
+ if self._dirtyLimits and self.isKeepDataAspectRatio():
+ self.replot() # makes sure we get the right limits
+
+ return ax.get_ybound()
+
+ def setGraphYLimits(self, ymin, ymax, axis):
+ ax = self.ax2 if axis == 'right' else self.ax
+ if ymax < ymin:
+ ymin, ymax = ymax, ymin
+ self._dirtyLimits = True
+
+ if self.isKeepDataAspectRatio():
+ # matplotlib keeps limits of shared axis when keeping aspect ratio
+ # So x limits are kept when changing y limits....
+ # Change x limits first by taking into account aspect ratio
+ # and then change y limits.. so matplotlib does not need
+ # to make change (to y) to keep aspect ratio
+ xmin, xmax = ax.get_xbound()
+ curYMin, curYMax = ax.get_ybound()
+
+ newXRange = (xmax - xmin) * (ymax - ymin) / (curYMax - curYMin)
+ xcenter = 0.5 * (xmin + xmax)
+ ax.set_xlim(xcenter - 0.5 * newXRange, xcenter + 0.5 * newXRange)
+
+ if not self.isYAxisInverted():
+ ax.set_ylim(ymin, ymax)
+ else:
+ ax.set_ylim(ymax, ymin)
+
+ # Graph axes
+
+ def setXAxisLogarithmic(self, flag):
+ self.ax2.set_xscale('log' if flag else 'linear')
+ self.ax.set_xscale('log' if flag else 'linear')
+
+ def setYAxisLogarithmic(self, flag):
+ self.ax2.set_yscale('log' if flag else 'linear')
+ self.ax.set_yscale('log' if flag else 'linear')
+
+ def setYAxisInverted(self, flag):
+ if self.ax.yaxis_inverted() != bool(flag):
+ self.ax.invert_yaxis()
+
+ def isYAxisInverted(self):
+ return self.ax.yaxis_inverted()
+
+ def isKeepDataAspectRatio(self):
+ return self.ax.get_aspect() in (1.0, 'equal')
+
+ def setKeepDataAspectRatio(self, flag):
+ self.ax.set_aspect(1.0 if flag else 'auto')
+ self.ax2.set_aspect(1.0 if flag else 'auto')
+
+ def setGraphGrid(self, which):
+ self.ax.grid(False, which='both') # Disable all grid first
+ if which is not None:
+ self.ax.grid(True, which=which)
+
+ # Data <-> Pixel coordinates conversion
+
+ def dataToPixel(self, x, y, axis):
+ ax = self.ax2 if axis == "right" else self.ax
+
+ pixels = ax.transData.transform_point((x, y))
+ xPixel, yPixel = pixels.T
+ return xPixel, yPixel
+
+ def pixelToData(self, x, y, axis, check):
+ ax = self.ax2 if axis == "right" else self.ax
+
+ inv = ax.transData.inverted()
+ x, y = inv.transform_point((x, y))
+
+ if check:
+ xmin, xmax = self.getGraphXLimits()
+ ymin, ymax = self.getGraphYLimits(axis=axis)
+
+ if x > xmax or x < xmin or y > ymax or y < ymin:
+ return None # (x, y) is out of plot area
+
+ return x, y
+
+ def getPlotBoundsInPixels(self):
+ bbox = self.ax.get_window_extent().transformed(
+ self.fig.dpi_scale_trans.inverted())
+ dpi = self.fig.dpi
+ # Warning this is not returning int...
+ return (bbox.bounds[0] * dpi, bbox.bounds[1] * dpi,
+ bbox.bounds[2] * dpi, bbox.bounds[3] * dpi)
+
+
+class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
+ """QWidget matplotlib backend using a QtAgg canvas.
+
+ It adds fast overlay drawing and mouse event management.
+ """
+
+ _sigPostRedisplay = qt.Signal()
+ """Signal handling automatic asynchronous replot"""
+
+ def __init__(self, plot, parent=None):
+ self._insideResizeEventMethod = False
+
+ BackendMatplotlib.__init__(self, plot, parent)
+ FigureCanvasQTAgg.__init__(self, self.fig)
+ self.setParent(parent)
+
+ FigureCanvasQTAgg.setSizePolicy(
+ self, qt.QSizePolicy.Expanding, qt.QSizePolicy.Expanding)
+ FigureCanvasQTAgg.updateGeometry(self)
+
+ # Make postRedisplay asynchronous using Qt signal
+ self._sigPostRedisplay.connect(
+ super(BackendMatplotlibQt, self).postRedisplay,
+ qt.Qt.QueuedConnection)
+
+ self._picked = None
+
+ self.mpl_connect('button_press_event', self._onMousePress)
+ self.mpl_connect('button_release_event', self._onMouseRelease)
+ self.mpl_connect('motion_notify_event', self._onMouseMove)
+ self.mpl_connect('scroll_event', self._onMouseWheel)
+
+ def postRedisplay(self):
+ self._sigPostRedisplay.emit()
+
+ # Mouse event forwarding
+
+ _MPL_TO_PLOT_BUTTONS = {1: 'left', 2: 'middle', 3: 'right'}
+
+ def _onMousePress(self, event):
+ self._plot.onMousePress(
+ event.x, event.y, self._MPL_TO_PLOT_BUTTONS[event.button])
+
+ def _onMouseMove(self, event):
+ if self._graphCursor:
+ lineh, linev = self._graphCursor
+ if event.inaxes != self.ax and lineh.get_visible():
+ lineh.set_visible(False)
+ linev.set_visible(False)
+ self._plot._setDirtyPlot(overlayOnly=True)
+ else:
+ linev.set_visible(True)
+ linev.set_xdata((event.xdata, event.xdata))
+ lineh.set_visible(True)
+ lineh.set_ydata((event.ydata, event.ydata))
+ self._plot._setDirtyPlot(overlayOnly=True)
+ # onMouseMove must trigger replot if dirty flag is raised
+
+ self._plot.onMouseMove(event.x, event.y)
+
+ def _onMouseRelease(self, event):
+ self._plot.onMouseRelease(
+ event.x, event.y, self._MPL_TO_PLOT_BUTTONS[event.button])
+
+ def _onMouseWheel(self, event):
+ self._plot.onMouseWheel(event.x, event.y, event.step)
+
+ def leaveEvent(self, event):
+ """QWidget event handler"""
+ self._plot.onMouseLeaveWidget()
+
+ # picking
+
+ def _onPick(self, event):
+ # TODO not very nice and fragile, find a better way?
+ # Make a selection according to kind
+ if self._picked is None:
+ _logger.error('Internal picking error')
+ return
+
+ label = event.artist.get_label()
+ if label.startswith('__MARKER__'):
+ self._picked.append({'kind': 'marker', 'legend': label[10:]})
+
+ elif label.startswith('__IMAGE__'):
+ self._picked.append({'kind': 'image', 'legend': label[9:]})
+
+ else: # it's a curve, item have no picker for now
+ if isinstance(event.artist, PathCollection):
+ data = event.artist.get_offsets()[event.ind, :]
+ xdata, ydata = data[:, 0], data[:, 1]
+ elif isinstance(event.artist, Line2D):
+ xdata = event.artist.get_xdata()[event.ind]
+ ydata = event.artist.get_ydata()[event.ind]
+ else:
+ _logger.info('Unsupported artist, ignored')
+ return
+
+ self._picked.append({'kind': 'curve', 'legend': label,
+ 'xdata': xdata, 'ydata': ydata})
+
+ def pickItems(self, x, y):
+ self._picked = []
+
+ # Weird way to do an explicit picking: Simulate a button press event
+ mouseEvent = MouseEvent('button_press_event', self, x, y)
+ cid = self.mpl_connect('pick_event', self._onPick)
+ self.fig.pick(mouseEvent)
+ self.mpl_disconnect(cid)
+ picked = self._picked
+ self._picked = None
+
+ return picked
+
+ # replot control
+
+ def resizeEvent(self, event):
+ self._insideResizeEventMethod = True
+ # Need to dirty the whole plot on resize.
+ self._plot._setDirtyPlot()
+ FigureCanvasQTAgg.resizeEvent(self, event)
+ self._insideResizeEventMethod = False
+
+ def draw(self):
+ """Override canvas draw method to support faster draw of overlays."""
+ if self._plot._getDirtyPlot(): # Need a full redraw
+ FigureCanvasQTAgg.draw(self)
+ self._background = None # Any saved background is dirty
+
+ if (self._overlays or self._graphCursor or
+ self._plot._getDirtyPlot() == 'overlay'):
+ # There are overlays or crosshair, or they is just no more overlays
+
+ # Specific case: called from resizeEvent:
+ # avoid store/restore background, just draw the overlay
+ if not self._insideResizeEventMethod:
+ if self._background is None: # First store the background
+ self._background = self.copy_from_bbox(self.fig.bbox)
+
+ self.restore_region(self._background)
+
+ # This assume that items are only on left/bottom Axes
+ for item in self._overlays:
+ self.ax.draw_artist(item)
+
+ for item in self._graphCursor:
+ self.ax.draw_artist(item)
+
+ self.blit(self.fig.bbox)
+
+ def replot(self):
+ BackendMatplotlib.replot(self)
+ self.draw()
+
+ # cursor
+
+ _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]
+
+ FigureCanvasQTAgg.setCursor(self, qt.QCursor(cursor))
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
diff --git a/silx/gui/plot/backends/ModestImage.py b/silx/gui/plot/backends/ModestImage.py
new file mode 100644
index 0000000..93fba5a
--- /dev/null
+++ b/silx/gui/plot/backends/ModestImage.py
@@ -0,0 +1,174 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-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.
+#
+# ############################################################################*/
+"""Matplotlib computationally modest image class."""
+
+__authors__ = ["V.A. Sole", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "16/02/2016"
+
+
+import numpy
+
+from matplotlib import cbook
+from matplotlib.image import AxesImage
+
+
+class ModestImage(AxesImage):
+ """Computationally modest image class.
+
+Customization of https://github.com/ChrisBeaumont/ModestImage to allow
+extent support.
+
+ModestImage is an extension of the Matplotlib AxesImage class
+better suited for the interactive display of larger images. Before
+drawing, ModestImage resamples the data array based on the screen
+resolution and view window. This has very little affect on the
+appearance of the image, but can substantially cut down on
+computation since calculations of unresolved or clipped pixels
+are skipped.
+
+The interface of ModestImage is the same as AxesImage. However, it
+does not currently support setting the 'extent' property. There
+may also be weird coordinate warping operations for images that
+I'm not aware of. Don't expect those to work either.
+"""
+ def __init__(self, *args, **kwargs):
+ self._full_res = None
+ self._sx, self._sy = None, None
+ self._bounds = (None, None, None, None)
+ self._origExtent = None
+ super(ModestImage, self).__init__(*args, **kwargs)
+ if 'extent' in kwargs and kwargs['extent'] is not None:
+ self.set_extent(kwargs['extent'])
+
+ def set_extent(self, extent):
+ super(ModestImage, self).set_extent(extent)
+ if self._origExtent is None:
+ self._origExtent = self.get_extent()
+
+ def get_image_extent(self):
+ """Returns the extent of the whole image.
+
+ get_extent returns the extent of the drawn area and not of the full
+ image.
+
+ :return: Bounds of the image (x0, x1, y0, y1).
+ :rtype: Tuple of 4 floats.
+ """
+ if self._origExtent is not None:
+ return self._origExtent
+ else:
+ return self.get_extent()
+
+ def set_data(self, A):
+ """
+ Set the image array
+
+ ACCEPTS: numpy/PIL Image A
+ """
+
+ self._full_res = A
+ self._A = A
+
+ if (self._A.dtype != numpy.uint8 and
+ not numpy.can_cast(self._A.dtype, numpy.float)):
+ raise TypeError("Image data can not convert to float")
+
+ if (self._A.ndim not in (2, 3) or
+ (self._A.ndim == 3 and self._A.shape[-1] not in (3, 4))):
+ raise TypeError("Invalid dimensions for image data")
+
+ self._imcache = None
+ self._rgbacache = None
+ self._oldxslice = None
+ self._oldyslice = None
+ self._sx, self._sy = None, None
+
+ def get_array(self):
+ """Override to return the full-resolution array"""
+ return self._full_res
+
+ def _scale_to_res(self):
+ """ Change self._A and _extent to render an image whose
+resolution is matched to the eventual rendering."""
+ # extent has to be set BEFORE set_data
+ if self._origExtent is None:
+ if self.origin == "upper":
+ self._origExtent = (0, self._full_res.shape[1],
+ self._full_res.shape[0], 0)
+ else:
+ self._origExtent = (0, self._full_res.shape[1],
+ 0, self._full_res.shape[0])
+
+ if self.origin == "upper":
+ origXMin, origXMax, origYMax, origYMin = self._origExtent[0:4]
+ else:
+ origXMin, origXMax, origYMin, origYMax = self._origExtent[0:4]
+ ax = self.axes
+ ext = ax.transAxes.transform([1, 1]) - ax.transAxes.transform([0, 0])
+ xlim, ylim = ax.get_xlim(), ax.get_ylim()
+ xlim = max(xlim[0], origXMin), min(xlim[1], origXMax)
+ if ylim[0] > ylim[1]:
+ ylim = max(ylim[1], origYMin), min(ylim[0], origYMax)
+ else:
+ ylim = max(ylim[0], origYMin), min(ylim[1], origYMax)
+ # print("THOSE LIMITS ARE TO BE COMPARED WITH THE EXTENT")
+ # print("IN ORDER TO KNOW WHAT IT IS LIMITING THE DISPLAY")
+ # print("IF THE AXES OR THE EXTENT")
+ dx, dy = xlim[1] - xlim[0], ylim[1] - ylim[0]
+
+ y0 = max(0, ylim[0] - 5)
+ y1 = min(self._full_res.shape[0], ylim[1] + 5)
+ x0 = max(0, xlim[0] - 5)
+ x1 = min(self._full_res.shape[1], xlim[1] + 5)
+ y0, y1, x0, x1 = [int(a) for a in [y0, y1, x0, x1]]
+
+ sy = int(max(1, min((y1 - y0) / 5., numpy.ceil(dy / ext[1]))))
+ sx = int(max(1, min((x1 - x0) / 5., numpy.ceil(dx / ext[0]))))
+
+ # have we already calculated what we need?
+ if (self._sx is not None) and (self._sy is not None):
+ if (sx >= self._sx and sy >= self._sy and
+ x0 >= self._bounds[0] and x1 <= self._bounds[1] and
+ y0 >= self._bounds[2] and y1 <= self._bounds[3]):
+ return
+
+ self._A = self._full_res[y0:y1:sy, x0:x1:sx]
+ self._A = cbook.safe_masked_invalid(self._A)
+ x1 = x0 + self._A.shape[1] * sx
+ y1 = y0 + self._A.shape[0] * sy
+
+ if self.origin == "upper":
+ self.set_extent([x0, x1, y1, y0])
+ else:
+ self.set_extent([x0, x1, y0, y1])
+ self._sx = sx
+ self._sy = sy
+ self._bounds = (x0, x1, y0, y1)
+ self.changed()
+
+ def draw(self, renderer, *args, **kwargs):
+ self._scale_to_res()
+ super(ModestImage, self).draw(renderer, *args, **kwargs)
diff --git a/silx/gui/plot/backends/__init__.py b/silx/gui/plot/backends/__init__.py
new file mode 100644
index 0000000..966d9df
--- /dev/null
+++ b/silx/gui/plot/backends/__init__.py
@@ -0,0 +1,29 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 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.
+#
+# ###########################################################################*/
+"""This package implements the backend of the Plot."""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "21/03/2017"
diff --git a/silx/gui/plot/backends/_matplotlib.py b/silx/gui/plot/backends/_matplotlib.py
new file mode 100644
index 0000000..26732a0
--- /dev/null
+++ b/silx/gui/plot/backends/_matplotlib.py
@@ -0,0 +1,64 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-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.
+#
+# ###########################################################################*/
+"""This module inits matplotlib and setups the backend to use.
+
+It MUST be imported prior to any other import of matplotlib.
+
+It provides the matplotlib :class:`FigureCanvasQTAgg` class corresponding
+to the used backend.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "26/10/2016"
+
+
+import sys
+import logging
+
+
+_logger = logging.getLogger(__name__)
+
+if 'matplotlib' in sys.modules:
+ _logger.warning(
+ 'matplotlib already loaded, setting its backend may not work')
+
+
+from ... import qt
+
+import matplotlib
+
+if qt.BINDING == 'PySide':
+ matplotlib.rcParams['backend'] = 'Qt4Agg'
+ matplotlib.rcParams['backend.qt4'] = 'PySide'
+ from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg # noqa
+
+elif qt.BINDING == 'PyQt4':
+ matplotlib.rcParams['backend'] = 'Qt4Agg'
+ from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg # noqa
+
+elif qt.BINDING == 'PyQt5':
+ matplotlib.rcParams['backend'] = 'Qt5Agg'
+ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg # noqa
diff --git a/silx/gui/plot/backends/glutils/GLPlotCurve.py b/silx/gui/plot/backends/glutils/GLPlotCurve.py
new file mode 100644
index 0000000..4f08054
--- /dev/null
+++ b/silx/gui/plot/backends/glutils/GLPlotCurve.py
@@ -0,0 +1,1317 @@
+# 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.
+#
+# ############################################################################*/
+"""
+This module provides classes to render 2D lines and scatter plots
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/04/2017"
+
+
+import math
+import logging
+
+import numpy
+
+from silx.math.combo import min_max
+
+from ...._glutils import gl
+from ...._glutils import numpyToGLType, Program, vertexBuffer
+from ..._utils import FLOAT32_MINPOS
+from .GLSupport import buildFillMaskIndices
+
+
+_logger = logging.getLogger(__name__)
+
+
+_MPL_NONES = None, 'None', '', ' '
+
+
+# fill ########################################################################
+
+class _Fill2D(object):
+ _LINEAR, _LOG10_X, _LOG10_Y, _LOG10_X_Y = 0, 1, 2, 3
+
+ _SHADERS = {
+ 'vertexTransforms': {
+ _LINEAR: """
+ vec4 transformXY(float x, float y) {
+ return vec4(x, y, 0.0, 1.0);
+ }
+ """,
+ _LOG10_X: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(oneOverLog10 * log(x), y, 0.0, 1.0);
+ }
+ """,
+ _LOG10_Y: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(x, oneOverLog10 * log(y), 0.0, 1.0);
+ }
+ """,
+ _LOG10_X_Y: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(oneOverLog10 * log(x),
+ oneOverLog10 * log(y),
+ 0.0, 1.0);
+ }
+ """
+ },
+ 'vertex': """
+ #version 120
+
+ uniform mat4 matrix;
+ attribute float xPos;
+ attribute float yPos;
+
+ %s
+
+ void main(void) {
+ gl_Position = matrix * transformXY(xPos, yPos);
+ }
+ """,
+ 'fragment': """
+ #version 120
+
+ uniform vec4 color;
+
+ void main(void) {
+ gl_FragColor = color;
+ }
+ """
+ }
+
+ _programs = {
+ _LINEAR: Program(
+ _SHADERS['vertex'] % _SHADERS['vertexTransforms'][_LINEAR],
+ _SHADERS['fragment'], attrib0='xPos'),
+ _LOG10_X: Program(
+ _SHADERS['vertex'] % _SHADERS['vertexTransforms'][_LOG10_X],
+ _SHADERS['fragment'], attrib0='xPos'),
+ _LOG10_Y: Program(
+ _SHADERS['vertex'] % _SHADERS['vertexTransforms'][_LOG10_Y],
+ _SHADERS['fragment'], attrib0='xPos'),
+ _LOG10_X_Y: Program(
+ _SHADERS['vertex'] % _SHADERS['vertexTransforms'][_LOG10_X_Y],
+ _SHADERS['fragment'], attrib0='xPos'),
+ }
+
+ def __init__(self, xFillVboData=None, yFillVboData=None,
+ xMin=None, yMin=None, xMax=None, yMax=None,
+ color=(0., 0., 0., 1.)):
+ self.xFillVboData = xFillVboData
+ self.yFillVboData = yFillVboData
+ self.xMin, self.yMin = xMin, yMin
+ self.xMax, self.yMax = xMax, yMax
+ self.color = color
+
+ self._bboxVertices = None
+ self._indices = None
+ self._indicesType = None
+
+ def prepare(self):
+ if self._indices is None:
+ self._indices = buildFillMaskIndices(self.xFillVboData.size)
+ self._indicesType = numpyToGLType(self._indices.dtype)
+
+ if self._bboxVertices is None:
+ yMin, yMax = min(self.yMin, 1e-32), max(self.yMax, 1e-32)
+ self._bboxVertices = numpy.array(((self.xMin, self.xMin,
+ self.xMax, self.xMax),
+ (yMin, yMax, yMin, yMax)),
+ dtype=numpy.float32)
+
+ def render(self, matrix, isXLog, isYLog):
+ self.prepare()
+
+ if isXLog:
+ transform = self._LOG10_X_Y if isYLog else self._LOG10_X
+ else:
+ transform = self._LOG10_Y if isYLog else self._LINEAR
+
+ prog = self._programs[transform]
+ prog.use()
+
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix)
+
+ gl.glUniform4f(prog.uniforms['color'], *self.color)
+
+ xPosAttrib = prog.attributes['xPos']
+ yPosAttrib = prog.attributes['yPos']
+
+ gl.glEnableVertexAttribArray(xPosAttrib)
+ self.xFillVboData.setVertexAttrib(xPosAttrib)
+
+ gl.glEnableVertexAttribArray(yPosAttrib)
+ self.yFillVboData.setVertexAttrib(yPosAttrib)
+
+ # Prepare fill mask
+ gl.glEnable(gl.GL_STENCIL_TEST)
+ gl.glStencilMask(1)
+ gl.glStencilFunc(gl.GL_ALWAYS, 1, 1)
+ gl.glStencilOp(gl.GL_INVERT, gl.GL_INVERT, gl.GL_INVERT)
+ gl.glColorMask(gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE)
+ gl.glDepthMask(gl.GL_FALSE)
+
+ gl.glDrawElements(gl.GL_TRIANGLE_STRIP, self._indices.size,
+ self._indicesType, self._indices)
+
+ gl.glStencilFunc(gl.GL_EQUAL, 1, 1)
+ # Reset stencil while drawing
+ gl.glStencilOp(gl.GL_ZERO, gl.GL_ZERO, gl.GL_ZERO)
+ gl.glColorMask(gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE)
+ gl.glDepthMask(gl.GL_TRUE)
+
+ gl.glVertexAttribPointer(xPosAttrib, 1, gl.GL_FLOAT, gl.GL_FALSE, 0,
+ self._bboxVertices[0])
+ gl.glVertexAttribPointer(yPosAttrib, 1, gl.GL_FLOAT, gl.GL_FALSE, 0,
+ self._bboxVertices[1])
+ gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, self._bboxVertices[0].size)
+
+ gl.glDisable(gl.GL_STENCIL_TEST)
+
+
+# line ########################################################################
+
+SOLID, DASHED, DASHDOT, DOTTED = '-', '--', '-.', ':'
+
+
+class _Lines2D(object):
+ STYLES = SOLID, DASHED, DASHDOT, DOTTED
+ """Supported line styles"""
+
+ _LINEAR, _LOG10_X, _LOG10_Y, _LOG10_X_Y = 0, 1, 2, 3
+
+ _SHADERS = {
+ 'vertexTransforms': {
+ _LINEAR: """
+ vec4 transformXY(float x, float y) {
+ return vec4(x, y, 0.0, 1.0);
+ }
+ """,
+ _LOG10_X: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(oneOverLog10 * log(x), y, 0.0, 1.0);
+ }
+ """,
+ _LOG10_Y: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(x, oneOverLog10 * log(y), 0.0, 1.0);
+ }
+ """,
+ _LOG10_X_Y: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(oneOverLog10 * log(x),
+ oneOverLog10 * log(y),
+ 0.0, 1.0);
+ }
+ """
+ },
+ 'solid': {
+ 'vertex': """
+ #version 120
+
+ uniform mat4 matrix;
+ attribute float xPos;
+ attribute float yPos;
+ attribute vec4 color;
+
+ varying vec4 vColor;
+
+ %s
+
+ void main(void) {
+ gl_Position = matrix * transformXY(xPos, yPos);
+ vColor = color;
+ }
+ """,
+ 'fragment': """
+ #version 120
+
+ varying vec4 vColor;
+
+ void main(void) {
+ gl_FragColor = vColor;
+ }
+ """
+ },
+
+
+ # Limitation: Dash using an estimate of distance in screen coord
+ # to avoid computing distance when viewport is resized
+ # results in inequal dashes when viewport aspect ratio is far from 1
+ 'dashed': {
+ 'vertex': """
+ #version 120
+
+ uniform mat4 matrix;
+ uniform vec2 halfViewportSize;
+ attribute float xPos;
+ attribute float yPos;
+ attribute vec4 color;
+ attribute float distance;
+
+ varying float vDist;
+ varying vec4 vColor;
+
+ %s
+
+ void main(void) {
+ gl_Position = matrix * transformXY(xPos, yPos);
+ //Estimate distance in pixels
+ vec2 probe = vec2(matrix * vec4(1., 1., 0., 0.)) *
+ halfViewportSize;
+ float pixelPerDataEstimate = length(probe)/sqrt(2.);
+ vDist = distance * pixelPerDataEstimate;
+ vColor = color;
+ }
+ """,
+ 'fragment': """
+ #version 120
+
+ /* Dashes: [0, x], [y, z]
+ Dash period: w */
+ uniform vec4 dash;
+
+ varying float vDist;
+ varying vec4 vColor;
+
+ void main(void) {
+ float dist = mod(vDist, dash.w);
+ if ((dist > dash.x && dist < dash.y) || dist > dash.z) {
+ discard;
+ }
+ gl_FragColor = vColor;
+ }
+ """
+ }
+ }
+
+ _programs = {}
+
+ def __init__(self, xVboData=None, yVboData=None,
+ colorVboData=None, distVboData=None,
+ style=SOLID, color=(0., 0., 0., 1.),
+ width=1, dashPeriod=20, drawMode=None):
+ self.xVboData = xVboData
+ self.yVboData = yVboData
+ self.distVboData = distVboData
+ self.colorVboData = colorVboData
+ self.useColorVboData = colorVboData is not None
+
+ self.color = color
+ self._width = 1
+ self.width = width
+ self._style = None
+ self.style = style
+ self.dashPeriod = dashPeriod
+
+ self._drawMode = drawMode if drawMode is not None else gl.GL_LINE_STRIP
+
+ @property
+ def style(self):
+ return self._style
+
+ @style.setter
+ def style(self, style):
+ if style in _MPL_NONES:
+ self._style = None
+ self.render = self._renderNone
+ else:
+ assert style in self.STYLES
+ self._style = style
+ if style == SOLID:
+ self.render = self._renderSolid
+ else: # DASHED, DASHDOT, DOTTED
+ self.render = self._renderDash
+
+ @property
+ def width(self):
+ return self._width
+
+ @width.setter
+ def width(self, width):
+ # try:
+ # widthRange = self._widthRange
+ # except AttributeError:
+ # widthRange = gl.glGetFloatv(gl.GL_ALIASED_LINE_WIDTH_RANGE)
+ # # Shared among contexts, this should be enough..
+ # _Lines2D._widthRange = widthRange
+ # assert width >= widthRange[0] and width <= widthRange[1]
+ self._width = width
+
+ @classmethod
+ def _getProgram(cls, transform, style):
+ try:
+ prgm = cls._programs[(transform, style)]
+ except KeyError:
+ sources = cls._SHADERS[style]
+ vertexShdr = sources['vertex'] % \
+ cls._SHADERS['vertexTransforms'][transform]
+ prgm = Program(vertexShdr, sources['fragment'], attrib0='xPos')
+ cls._programs[(transform, style)] = prgm
+ return prgm
+
+ @classmethod
+ def init(cls):
+ gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST)
+
+ def _renderNone(self, matrix, isXLog, isYLog):
+ pass
+
+ render = _renderNone # Overridden in style setter
+
+ def _renderSolid(self, matrix, isXLog, isYLog):
+ if isXLog:
+ transform = self._LOG10_X_Y if isYLog else self._LOG10_X
+ else:
+ transform = self._LOG10_Y if isYLog else self._LINEAR
+
+ prog = self._getProgram(transform, 'solid')
+ prog.use()
+
+ gl.glEnable(gl.GL_LINE_SMOOTH)
+
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix)
+
+ colorAttrib = prog.attributes['color']
+ if self.useColorVboData and self.colorVboData is not None:
+ gl.glEnableVertexAttribArray(colorAttrib)
+ self.colorVboData.setVertexAttrib(colorAttrib)
+ else:
+ gl.glDisableVertexAttribArray(colorAttrib)
+ gl.glVertexAttrib4f(colorAttrib, *self.color)
+
+ xPosAttrib = prog.attributes['xPos']
+ gl.glEnableVertexAttribArray(xPosAttrib)
+ self.xVboData.setVertexAttrib(xPosAttrib)
+
+ yPosAttrib = prog.attributes['yPos']
+ gl.glEnableVertexAttribArray(yPosAttrib)
+ self.yVboData.setVertexAttrib(yPosAttrib)
+
+ gl.glLineWidth(self.width)
+ gl.glDrawArrays(self._drawMode, 0, self.xVboData.size)
+
+ gl.glDisable(gl.GL_LINE_SMOOTH)
+
+ def _renderDash(self, matrix, isXLog, isYLog):
+ if isXLog:
+ transform = self._LOG10_X_Y if isYLog else self._LOG10_X
+ else:
+ transform = self._LOG10_Y if isYLog else self._LINEAR
+
+ prog = self._getProgram(transform, 'dashed')
+ prog.use()
+
+ gl.glEnable(gl.GL_LINE_SMOOTH)
+
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix)
+ x, y, viewWidth, viewHeight = gl.glGetFloatv(gl.GL_VIEWPORT)
+ gl.glUniform2f(prog.uniforms['halfViewportSize'],
+ 0.5 * viewWidth, 0.5 * viewHeight)
+
+ if self.style == DOTTED:
+ dash = (0.1 * self.dashPeriod,
+ 0.6 * self.dashPeriod,
+ 0.7 * self.dashPeriod,
+ self.dashPeriod)
+ elif self.style == DASHDOT:
+ dash = (0.3 * self.dashPeriod,
+ 0.5 * self.dashPeriod,
+ 0.6 * self.dashPeriod,
+ self.dashPeriod)
+ else:
+ dash = (0.5 * self.dashPeriod,
+ self.dashPeriod,
+ self.dashPeriod,
+ self.dashPeriod)
+
+ gl.glUniform4f(prog.uniforms['dash'], *dash)
+
+ colorAttrib = prog.attributes['color']
+ if self.useColorVboData and self.colorVboData is not None:
+ gl.glEnableVertexAttribArray(colorAttrib)
+ self.colorVboData.setVertexAttrib(colorAttrib)
+ else:
+ gl.glDisableVertexAttribArray(colorAttrib)
+ gl.glVertexAttrib4f(colorAttrib, *self.color)
+
+ distAttrib = prog.attributes['distance']
+ gl.glEnableVertexAttribArray(distAttrib)
+ self.distVboData.setVertexAttrib(distAttrib)
+
+ xPosAttrib = prog.attributes['xPos']
+ gl.glEnableVertexAttribArray(xPosAttrib)
+ self.xVboData.setVertexAttrib(xPosAttrib)
+
+ yPosAttrib = prog.attributes['yPos']
+ gl.glEnableVertexAttribArray(yPosAttrib)
+ self.yVboData.setVertexAttrib(yPosAttrib)
+
+ gl.glLineWidth(self.width)
+ gl.glDrawArrays(self._drawMode, 0, self.xVboData.size)
+
+ gl.glDisable(gl.GL_LINE_SMOOTH)
+
+
+def _distancesFromArrays(xData, yData):
+ deltas = numpy.dstack((
+ numpy.ediff1d(xData, to_begin=numpy.float32(0.)),
+ numpy.ediff1d(yData, to_begin=numpy.float32(0.))))[0]
+ return numpy.cumsum(numpy.sqrt(numpy.sum(deltas ** 2, axis=1)))
+
+
+# points ######################################################################
+
+DIAMOND, CIRCLE, SQUARE, PLUS, X_MARKER, POINT, PIXEL, ASTERISK = \
+ 'd', 'o', 's', '+', 'x', '.', ',', '*'
+
+H_LINE, V_LINE = '_', '|'
+
+
+class _Points2D(object):
+ MARKERS = (DIAMOND, CIRCLE, SQUARE, PLUS, X_MARKER, POINT, PIXEL, ASTERISK,
+ H_LINE, V_LINE)
+
+ _LINEAR, _LOG10_X, _LOG10_Y, _LOG10_X_Y = 0, 1, 2, 3
+
+ _SHADERS = {
+ 'vertexTransforms': {
+ _LINEAR: """
+ vec4 transformXY(float x, float y) {
+ return vec4(x, y, 0.0, 1.0);
+ }
+ """,
+ _LOG10_X: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(oneOverLog10 * log(x), y, 0.0, 1.0);
+ }
+ """,
+ _LOG10_Y: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(x, oneOverLog10 * log(y), 0.0, 1.0);
+ }
+ """,
+ _LOG10_X_Y: """
+ const float oneOverLog10 = 0.43429448190325176;
+
+ vec4 transformXY(float x, float y) {
+ return vec4(oneOverLog10 * log(x),
+ oneOverLog10 * log(y),
+ 0.0, 1.0);
+ }
+ """
+ },
+ 'vertex': """
+ #version 120
+
+ uniform mat4 matrix;
+ uniform int transform;
+ uniform float size;
+ attribute float xPos;
+ attribute float yPos;
+ attribute vec4 color;
+
+ varying vec4 vColor;
+
+ %s
+
+ void main(void) {
+ gl_Position = matrix * transformXY(xPos, yPos);
+ vColor = color;
+ gl_PointSize = size;
+ }
+ """,
+
+ 'fragmentSymbols': {
+ DIAMOND: """
+ float alphaSymbol(vec2 coord, float size) {
+ vec2 centerCoord = abs(coord - vec2(0.5, 0.5));
+ float f = centerCoord.x + centerCoord.y;
+ return clamp(size * (0.5 - f), 0.0, 1.0);
+ }
+ """,
+ CIRCLE: """
+ float alphaSymbol(vec2 coord, float size) {
+ float radius = 0.5;
+ float r = distance(coord, vec2(0.5, 0.5));
+ return clamp(size * (radius - r), 0.0, 1.0);
+ }
+ """,
+ SQUARE: """
+ float alphaSymbol(vec2 coord, float size) {
+ return 1.0;
+ }
+ """,
+ PLUS: """
+ float alphaSymbol(vec2 coord, float size) {
+ vec2 d = abs(size * (coord - vec2(0.5, 0.5)));
+ if (min(d.x, d.y) < 0.5) {
+ return 1.0;
+ } else {
+ return 0.0;
+ }
+ }
+ """,
+ X_MARKER: """
+ float alphaSymbol(vec2 coord, float size) {
+ vec2 pos = floor(size * coord) + 0.5;
+ vec2 d_x = abs(pos.x + vec2(- pos.y, pos.y - size));
+ if (min(d_x.x, d_x.y) <= 0.5) {
+ return 1.0;
+ } else {
+ return 0.0;
+ }
+ }
+ """,
+ ASTERISK: """
+ float alphaSymbol(vec2 coord, float size) {
+ /* Combining +, x and cirle */
+ vec2 d_plus = abs(size * (coord - vec2(0.5, 0.5)));
+ vec2 pos = floor(size * coord) + 0.5;
+ vec2 d_x = abs(pos.x + vec2(- pos.y, pos.y - size));
+ if (min(d_plus.x, d_plus.y) < 0.5) {
+ return 1.0;
+ } else if (min(d_x.x, d_x.y) <= 0.5) {
+ float r = distance(coord, vec2(0.5, 0.5));
+ return clamp(size * (0.5 - r), 0.0, 1.0);
+ } else {
+ return 0.0;
+ }
+ }
+ """,
+ H_LINE: """
+ float alphaSymbol(vec2 coord, float size) {
+ float dy = abs(size * (coord.y - 0.5));
+ if (dy < 0.5) {
+ return 1.0;
+ } else {
+ return 0.0;
+ }
+ }
+ """,
+ V_LINE: """
+ float alphaSymbol(vec2 coord, float size) {
+ float dx = abs(size * (coord.x - 0.5));
+ if (dx < 0.5) {
+ return 1.0;
+ } else {
+ return 0.0;
+ }
+ }
+ """
+ },
+
+ 'fragment': """
+ #version 120
+
+ uniform float size;
+
+ varying vec4 vColor;
+
+ %s
+
+ void main(void) {
+ float alpha = alphaSymbol(gl_PointCoord, size);
+ if (alpha <= 0.0) {
+ discard;
+ } else {
+ gl_FragColor = vec4(vColor.rgb, alpha * clamp(vColor.a, 0.0, 1.0));
+ }
+ }
+ """
+ }
+
+ _programs = {}
+
+ def __init__(self, xVboData=None, yVboData=None, colorVboData=None,
+ marker=SQUARE, color=(0., 0., 0., 1.), size=7):
+ self.color = color
+ self._marker = None
+ self.marker = marker
+ self._size = 1
+ self.size = size
+
+ self.xVboData = xVboData
+ self.yVboData = yVboData
+ self.colorVboData = colorVboData
+ self.useColorVboData = colorVboData is not None
+
+ @property
+ def marker(self):
+ return self._marker
+
+ @marker.setter
+ def marker(self, marker):
+ if marker in _MPL_NONES:
+ self._marker = None
+ self.render = self._renderNone
+ else:
+ assert marker in self.MARKERS
+ self._marker = marker
+ self.render = self._renderMarkers
+
+ @property
+ def size(self):
+ return self._size
+
+ @size.setter
+ def size(self, size):
+ # try:
+ # sizeRange = self._sizeRange
+ # except AttributeError:
+ # sizeRange = gl.glGetFloatv(gl.GL_POINT_SIZE_RANGE)
+ # # Shared among contexts, this should be enough..
+ # _Points2D._sizeRange = sizeRange
+ # assert size >= sizeRange[0] and size <= sizeRange[1]
+ self._size = size
+
+ @classmethod
+ def _getProgram(cls, transform, marker):
+ """On-demand shader program creation."""
+ if marker == PIXEL:
+ marker = SQUARE
+ elif marker == POINT:
+ marker = CIRCLE
+ try:
+ prgm = cls._programs[(transform, marker)]
+ except KeyError:
+ vertShdr = cls._SHADERS['vertex'] % \
+ cls._SHADERS['vertexTransforms'][transform]
+ fragShdr = cls._SHADERS['fragment'] % \
+ cls._SHADERS['fragmentSymbols'][marker]
+ prgm = Program(vertShdr, fragShdr, attrib0='xPos')
+
+ cls._programs[(transform, marker)] = prgm
+ return prgm
+
+ @classmethod
+ def init(cls):
+ version = gl.glGetString(gl.GL_VERSION)
+ majorVersion = int(version[0])
+ assert majorVersion >= 2
+ gl.glEnable(gl.GL_VERTEX_PROGRAM_POINT_SIZE) # OpenGL 2
+ gl.glEnable(gl.GL_POINT_SPRITE) # OpenGL 2
+ if majorVersion >= 3: # OpenGL 3
+ gl.glEnable(gl.GL_PROGRAM_POINT_SIZE)
+
+ def _renderNone(self, matrix, isXLog, isYLog):
+ pass
+
+ render = _renderNone
+
+ def _renderMarkers(self, matrix, isXLog, isYLog):
+ if isXLog:
+ transform = self._LOG10_X_Y if isYLog else self._LOG10_X
+ else:
+ transform = self._LOG10_Y if isYLog else self._LINEAR
+
+ prog = self._getProgram(transform, self.marker)
+ prog.use()
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix)
+ if self.marker == PIXEL:
+ size = 1
+ elif self.marker == POINT:
+ size = math.ceil(0.5 * self.size) + 1 # Mimic Matplotlib point
+ else:
+ size = self.size
+ gl.glUniform1f(prog.uniforms['size'], size)
+ # gl.glPointSize(self.size)
+
+ cAttrib = prog.attributes['color']
+ if self.useColorVboData and self.colorVboData is not None:
+ gl.glEnableVertexAttribArray(cAttrib)
+ self.colorVboData.setVertexAttrib(cAttrib)
+ else:
+ gl.glDisableVertexAttribArray(cAttrib)
+ gl.glVertexAttrib4f(cAttrib, *self.color)
+
+ xAttrib = prog.attributes['xPos']
+ gl.glEnableVertexAttribArray(xAttrib)
+ self.xVboData.setVertexAttrib(xAttrib)
+
+ yAttrib = prog.attributes['yPos']
+ gl.glEnableVertexAttribArray(yAttrib)
+ self.yVboData.setVertexAttrib(yAttrib)
+
+ gl.glDrawArrays(gl.GL_POINTS, 0, self.xVboData.size)
+
+ gl.glUseProgram(0)
+
+
+# error bars ##################################################################
+
+class _ErrorBars(object):
+ """Display errors bars.
+
+ This is using its own VBO as opposed to fill/points/lines.
+ There is no picking on error bars.
+ As is, there is no way to update data and errors, but it handles
+ log scales by removing data <= 0 and clipping error bars to positive
+ range.
+
+ It uses 2 vertices per error bars and uses :class:`_Lines2D` to
+ render error bars and :class:`_Points2D` to render the ends.
+ """
+
+ def __init__(self, xData, yData, xError, yError,
+ xMin, yMin,
+ color=(0., 0., 0., 1.)):
+ """Initialization.
+
+ :param numpy.ndarray xData: X coordinates of the data.
+ :param numpy.ndarray yData: Y coordinates of the data.
+ :param xError: The absolute error on the X axis.
+ :type xError: A float, or a numpy.ndarray of float32.
+ If it is an array, it can either be a 1D array of
+ same length as the data or a 2D array with 2 rows
+ of same length as the data: row 0 for negative errors,
+ row 1 for positive errors.
+ :param yError: The absolute error on the Y axis.
+ :type yError: A float, or a numpy.ndarray of float32. See xError.
+ :param float xMin: The min X value already computed by GLPlotCurve2D.
+ :param float yMin: The min Y value already computed by GLPlotCurve2D.
+ :param color: The color to use for both lines and ending points.
+ :type color: tuple of 4 floats
+ """
+ self._attribs = None
+ self._isXLog, self._isYLog = False, False
+ self._xMin, self._yMin = xMin, yMin
+
+ if xError is not None or yError is not None:
+ assert len(xData) == len(yData)
+ self._xData = numpy.array(
+ xData, order='C', dtype=numpy.float32, copy=False)
+ self._yData = numpy.array(
+ yData, order='C', dtype=numpy.float32, copy=False)
+
+ # This also works if xError, yError is a float/int
+ self._xError = numpy.array(
+ xError, order='C', dtype=numpy.float32, copy=False)
+ self._yError = numpy.array(
+ yError, order='C', dtype=numpy.float32, copy=False)
+ else:
+ self._xData, self._yData = None, None
+ self._xError, self._yError = None, None
+
+ self._lines = _Lines2D(None, None, color=color, drawMode=gl.GL_LINES)
+ self._xErrPoints = _Points2D(None, None, color=color, marker=V_LINE)
+ self._yErrPoints = _Points2D(None, None, color=color, marker=H_LINE)
+
+ def _positiveValueFilter(self, onlyXPos, onlyYPos):
+ """Filter data (x, y) and errors (xError, yError) to remove
+ negative and null data values on required axis (onlyXPos, onlyYPos).
+
+ Returned arrays might be NOT contiguous.
+
+ :return: Filtered xData, yData, xError and yError arrays.
+ """
+ if ((not onlyXPos or self._xMin > 0.) and
+ (not onlyYPos or self._yMin > 0.)):
+ # No need to filter, all values are > 0 on log axes
+ return self._xData, self._yData, self._xError, self._yError
+
+ _logger.warning(
+ 'Removing values <= 0 of curve with error bars on a log axis.')
+
+ x, y = self._xData, self._yData
+ xError, yError = self._xError, self._yError
+
+ # First remove negative data
+ if onlyXPos and onlyYPos:
+ mask = (x > 0.) & (y > 0.)
+ elif onlyXPos:
+ mask = x > 0.
+ else: # onlyYPos
+ mask = y > 0.
+ x, y = x[mask], y[mask]
+
+ # Remove corresponding values from error arrays
+ if xError is not None and xError.size != 1:
+ if len(xError.shape) == 1:
+ xError = xError[mask]
+ else: # 2 rows
+ xError = xError[:, mask]
+ if yError is not None and yError.size != 1:
+ if len(yError.shape) == 1:
+ yError = yError[mask]
+ else: # 2 rows
+ yError = yError[:, mask]
+
+ return x, y, xError, yError
+
+ def _buildVertices(self, isXLog, isYLog):
+ """Generates error bars vertices according to log scales."""
+ xData, yData, xError, yError = self._positiveValueFilter(
+ isXLog, isYLog)
+
+ nbLinesPerDataPts = 1 if xError is not None else 0
+ nbLinesPerDataPts += 1 if yError is not None else 0
+
+ nbDataPts = len(xData)
+
+ # interleave coord+error, coord-error.
+ # xError vertices first if any, then yError vertices if any.
+ xCoords = numpy.empty(nbDataPts * nbLinesPerDataPts * 2,
+ dtype=numpy.float32)
+ yCoords = numpy.empty(nbDataPts * nbLinesPerDataPts * 2,
+ dtype=numpy.float32)
+
+ if xError is not None: # errors on the X axis
+ if len(xError.shape) == 2:
+ xErrorMinus, xErrorPlus = xError[0], xError[1]
+ else:
+ # numpy arrays of len 1 or len(xData)
+ xErrorMinus, xErrorPlus = xError, xError
+
+ # Interleave vertices for xError
+ endXError = 2 * nbDataPts
+ xCoords[0:endXError-1:2] = xData + xErrorPlus
+
+ minValues = xData - xErrorMinus
+ if isXLog:
+ # Clip min bounds to positive value
+ minValues[minValues <= 0] = FLOAT32_MINPOS
+ xCoords[1:endXError:2] = minValues
+
+ yCoords[0:endXError-1:2] = yData
+ yCoords[1:endXError:2] = yData
+ else:
+ endXError = 0
+
+ if yError is not None: # errors on the Y axis
+ if len(yError.shape) == 2:
+ yErrorMinus, yErrorPlus = yError[0], yError[1]
+ else:
+ # numpy arrays of len 1 or len(yData)
+ yErrorMinus, yErrorPlus = yError, yError
+
+ # Interleave vertices for yError
+ xCoords[endXError::2] = xData
+ xCoords[endXError+1::2] = xData
+ yCoords[endXError::2] = yData + yErrorPlus
+ minValues = yData - yErrorMinus
+ if isYLog:
+ # Clip min bounds to positive value
+ minValues[minValues <= 0] = FLOAT32_MINPOS
+ yCoords[endXError+1::2] = minValues
+
+ return xCoords, yCoords
+
+ def prepare(self, isXLog, isYLog):
+ if self._xData is None:
+ return
+
+ if self._isXLog != isXLog or self._isYLog != isYLog:
+ # Log state has changed
+ self._isXLog, self._isYLog = isXLog, isYLog
+
+ self.discard() # discard existing VBOs
+
+ if self._attribs is None:
+ xCoords, yCoords = self._buildVertices(isXLog, isYLog)
+
+ xAttrib, yAttrib = vertexBuffer((xCoords, yCoords))
+ self._attribs = xAttrib, yAttrib
+
+ self._lines.xVboData, self._lines.yVboData = xAttrib, yAttrib
+
+ # Set xError points using the same VBO as lines
+ self._xErrPoints.xVboData = xAttrib.copy()
+ self._xErrPoints.xVboData.size //= 2
+ self._xErrPoints.yVboData = yAttrib.copy()
+ self._xErrPoints.yVboData.size //= 2
+
+ # Set yError points using the same VBO as lines
+ self._yErrPoints.xVboData = xAttrib.copy()
+ self._yErrPoints.xVboData.size //= 2
+ self._yErrPoints.xVboData.offset += (xAttrib.itemsize *
+ xAttrib.size // 2)
+ self._yErrPoints.yVboData = yAttrib.copy()
+ self._yErrPoints.yVboData.size //= 2
+ self._yErrPoints.yVboData.offset += (yAttrib.itemsize *
+ yAttrib.size // 2)
+
+ def render(self, matrix, isXLog, isYLog):
+ if self._attribs is not None:
+ self._lines.render(matrix, isXLog, isYLog)
+ self._xErrPoints.render(matrix, isXLog, isYLog)
+ self._yErrPoints.render(matrix, isXLog, isYLog)
+
+ def discard(self):
+ if self._attribs is not None:
+ self._lines.xVboData, self._lines.yVboData = None, None
+ self._xErrPoints.xVboData, self._xErrPoints.yVboData = None, None
+ self._yErrPoints.xVboData, self._yErrPoints.yVboData = None, None
+ self._attribs[0].vbo.discard()
+ self._attribs = None
+
+
+# curves ######################################################################
+
+def _proxyProperty(*componentsAttributes):
+ """Create a property to access an attribute of attribute(s).
+ Useful for composition.
+ Supports multiple components this way:
+ getter returns the first found, setter sets all
+ """
+ def getter(self):
+ for compName, attrName in componentsAttributes:
+ try:
+ component = getattr(self, compName)
+ except AttributeError:
+ pass
+ else:
+ return getattr(component, attrName)
+
+ def setter(self, value):
+ for compName, attrName in componentsAttributes:
+ component = getattr(self, compName)
+ setattr(component, attrName, value)
+ return property(getter, setter)
+
+
+class GLPlotCurve2D(object):
+ def __init__(self, xData, yData, colorData=None,
+ xError=None, yError=None,
+ lineStyle=None, lineColor=None,
+ lineWidth=None, lineDashPeriod=None,
+ marker=None, markerColor=None, markerSize=None,
+ fillColor=None):
+ self._isXLog = False
+ self._isYLog = False
+ self.xData, self.yData, self.colorData = xData, yData, colorData
+
+ if fillColor is not None:
+ self.fill = _Fill2D(color=fillColor)
+ else:
+ self.fill = None
+
+ # Compute x bounds
+ if xError is None:
+ result = min_max(xData, min_positive=True)
+ self.xMin = result.minimum
+ self.xMinPos = result.min_positive
+ self.xMax = result.maximum
+ else:
+ # Takes the error into account
+ if hasattr(xError, 'shape') and len(xError.shape) == 2:
+ xErrorPlus, xErrorMinus = xError[0], xError[1]
+ else:
+ xErrorPlus, xErrorMinus = xError, xError
+ result = min_max(xData - xErrorMinus, min_positive=True)
+ self.xMin = result.minimum
+ self.xMinPos = result.min_positive
+ self.xMax = (xData + xErrorPlus).max()
+
+ # Compute y bounds
+ if yError is None:
+ result = min_max(yData, min_positive=True)
+ self.yMin = result.minimum
+ self.yMinPos = result.min_positive
+ self.yMax = result.maximum
+ else:
+ # Takes the error into account
+ if hasattr(yError, 'shape') and len(yError.shape) == 2:
+ yErrorPlus, yErrorMinus = yError[0], yError[1]
+ else:
+ yErrorPlus, yErrorMinus = yError, yError
+ result = min_max(yData - yErrorMinus, min_positive=True)
+ self.yMin = result.minimum
+ self.yMinPos = result.min_positive
+ self.yMax = (yData + yErrorPlus).max()
+
+ self._errorBars = _ErrorBars(xData, yData, xError, yError,
+ self.xMin, self.yMin)
+
+ kwargs = {'style': lineStyle}
+ if lineColor is not None:
+ kwargs['color'] = lineColor
+ if lineWidth is not None:
+ kwargs['width'] = lineWidth
+ if lineDashPeriod is not None:
+ kwargs['dashPeriod'] = lineDashPeriod
+ self.lines = _Lines2D(**kwargs)
+
+ kwargs = {'marker': marker}
+ if markerColor is not None:
+ kwargs['color'] = markerColor
+ if markerSize is not None:
+ kwargs['size'] = markerSize
+ self.points = _Points2D(**kwargs)
+
+ xVboData = _proxyProperty(('lines', 'xVboData'), ('points', 'xVboData'))
+
+ yVboData = _proxyProperty(('lines', 'yVboData'), ('points', 'yVboData'))
+
+ colorVboData = _proxyProperty(('lines', 'colorVboData'),
+ ('points', 'colorVboData'))
+
+ useColorVboData = _proxyProperty(('lines', 'useColorVboData'),
+ ('points', 'useColorVboData'))
+
+ distVboData = _proxyProperty(('lines', 'distVboData'))
+
+ lineStyle = _proxyProperty(('lines', 'style'))
+
+ lineColor = _proxyProperty(('lines', 'color'))
+
+ lineWidth = _proxyProperty(('lines', 'width'))
+
+ lineDashPeriod = _proxyProperty(('lines', 'dashPeriod'))
+
+ marker = _proxyProperty(('points', 'marker'))
+
+ markerColor = _proxyProperty(('points', 'color'))
+
+ markerSize = _proxyProperty(('points', 'size'))
+
+ @classmethod
+ def init(cls):
+ _Lines2D.init()
+ _Points2D.init()
+
+ @staticmethod
+ def _logFilterData(x, y, color=None, xLog=False, yLog=False):
+ # Copied from Plot.py
+ if xLog and yLog:
+ idx = numpy.nonzero((x > 0) & (y > 0))[0]
+ x = numpy.take(x, idx)
+ y = numpy.take(y, idx)
+ elif yLog:
+ idx = numpy.nonzero(y > 0)[0]
+ x = numpy.take(x, idx)
+ y = numpy.take(y, idx)
+ elif xLog:
+ idx = numpy.nonzero(x > 0)[0]
+ x = numpy.take(x, idx)
+ y = numpy.take(y, idx)
+ else:
+ idx = None
+
+ if idx is not None and isinstance(color, numpy.ndarray):
+ colors = numpy.zeros((x.size, 4), color.dtype)
+ colors[:, 0] = color[idx, 0]
+ colors[:, 1] = color[idx, 1]
+ colors[:, 2] = color[idx, 2]
+ colors[:, 3] = color[idx, 3]
+ else:
+ colors = color
+ return x, y, colors
+
+ def prepare(self, isXLog, isYLog):
+ # init only supports updating isXLog, isYLog
+ xData, yData, colorData = self.xData, self.yData, self.colorData
+
+ if self._isXLog != isXLog or self._isYLog != isYLog:
+ # Log state has changed
+ self._isXLog, self._isYLog = isXLog, isYLog
+
+ # Check if data <= 0. with log scale
+ if (isXLog and self.xMin <= 0.) or (isYLog and self.yMin <= 0.):
+ # Filtering data is needed
+ xData, yData, colorData = self._logFilterData(
+ self.xData, self.yData, self.colorData,
+ self._isXLog, self._isYLog)
+
+ self.discard() # discard existing VBOs
+
+ if self.xVboData is None:
+ xAttrib, yAttrib, cAttrib, dAttrib = None, None, None, None
+ if self.lineStyle in (DASHED, DASHDOT, DOTTED):
+ dists = _distancesFromArrays(xData, yData)
+ if self.colorData is None:
+ xAttrib, yAttrib, dAttrib = vertexBuffer(
+ (xData, yData, dists),
+ prefix=(1, 1, 0), suffix=(1, 1, 0))
+ else:
+ xAttrib, yAttrib, cAttrib, dAttrib = vertexBuffer(
+ (xData, yData, colorData, dists),
+ prefix=(1, 1, 0, 0), suffix=(1, 1, 0, 0))
+ elif self.colorData is None:
+ xAttrib, yAttrib = vertexBuffer(
+ (xData, yData), prefix=(1, 1), suffix=(1, 1))
+ else:
+ xAttrib, yAttrib, cAttrib = vertexBuffer(
+ (xData, yData, colorData), prefix=(1, 1, 0))
+
+ # Shrink VBO
+ self.xVboData = xAttrib.copy()
+ self.xVboData.size -= 2
+ self.xVboData.offset += xAttrib.itemsize
+
+ self.yVboData = yAttrib.copy()
+ self.yVboData.size -= 2
+ self.yVboData.offset += yAttrib.itemsize
+
+ if cAttrib is not None and colorData.dtype.kind == 'u':
+ cAttrib.normalisation = True # Normalise uint to [0, 1]
+ self.colorVboData = cAttrib
+ self.useColorVboData = cAttrib is not None
+ self.distVboData = dAttrib
+
+ if self.fill is not None:
+ xData = xData.reshape(xData.size, 1)
+ zero = numpy.array((1e-32,), dtype=self.yData.dtype)
+
+ # Add one point before data: (x0, 0.)
+ xAttrib.vbo.update(xData[0], xAttrib.offset,
+ xData[0].itemsize)
+ yAttrib.vbo.update(zero, yAttrib.offset, zero.itemsize)
+
+ # Add one point after data: (xN, 0.)
+ xAttrib.vbo.update(xData[-1],
+ xAttrib.offset +
+ (xAttrib.size - 1) * xAttrib.itemsize,
+ xData[-1].itemsize)
+ yAttrib.vbo.update(zero,
+ yAttrib.offset +
+ (yAttrib.size - 1) * yAttrib.itemsize,
+ zero.itemsize)
+
+ self.fill.xFillVboData = xAttrib
+ self.fill.yFillVboData = yAttrib
+ self.fill.xMin, self.fill.yMin = self.xMin, self.yMin
+ self.fill.xMax, self.fill.yMax = self.xMax, self.yMax
+
+ self._errorBars.prepare(isXLog, isYLog)
+
+ def render(self, matrix, isXLog, isYLog):
+ self.prepare(isXLog, isYLog)
+ if self.fill is not None:
+ self.fill.render(matrix, isXLog, isYLog)
+ self._errorBars.render(matrix, isXLog, isYLog)
+ self.lines.render(matrix, isXLog, isYLog)
+ self.points.render(matrix, isXLog, isYLog)
+
+ def discard(self):
+ if self.xVboData is not None:
+ self.xVboData.vbo.discard()
+
+ self.xVboData = None
+ self.yVboData = None
+ self.colorVboData = None
+ self.distVboData = None
+
+ self._errorBars.discard()
+
+ def pick(self, xPickMin, yPickMin, xPickMax, yPickMax):
+ """Perform picking on the curve according to its rendering.
+
+ The picking area is [xPickMin, xPickMax], [yPickMin, yPickMax].
+
+ In case a segment between 2 points with indices i, i+1 is picked,
+ only its lower index end point (i.e., i) is added to the result.
+ In case an end point with index i is picked it is added to the result,
+ and the segment [i-1, i] is not tested for picking.
+
+ :return: The indices of the picked data
+ :rtype: list of int
+ """
+ if (self.marker is None and self.lineStyle is None) or \
+ self.xMin > xPickMax or xPickMin > self.xMax or \
+ self.yMin > yPickMax or yPickMin > self.yMax:
+ # Note: With log scale the bounding box is too large if
+ # some data <= 0.
+ return None
+
+ elif self.lineStyle is not None:
+ # Using Cohen-Sutherland algorithm for line clipping
+ codes = ((self.yData > yPickMax) << 3) | \
+ ((self.yData < yPickMin) << 2) | \
+ ((self.xData > xPickMax) << 1) | \
+ (self.xData < xPickMin)
+
+ # Add all points that are inside the picking area
+ indices = numpy.nonzero(codes == 0)[0].tolist()
+
+ # Segment that might cross the area with no end point inside it
+ segToTestIdx = numpy.nonzero((codes[:-1] != 0) &
+ (codes[1:] != 0) &
+ ((codes[:-1] & codes[1:]) == 0))[0]
+
+ TOP, BOTTOM, RIGHT, LEFT = (1 << 3), (1 << 2), (1 << 1), (1 << 0)
+
+ for index in segToTestIdx:
+ if index not in indices:
+ x0, y0 = self.xData[index], self.yData[index]
+ x1, y1 = self.xData[index + 1], self.yData[index + 1]
+ code1 = codes[index + 1]
+
+ # check for crossing with horizontal bounds
+ # y0 == y1 is a never event:
+ # => pt0 and pt1 in same vertical area are not in segToTest
+ if code1 & TOP:
+ x = x0 + (x1 - x0) * (yPickMax - y0) / (y1 - y0)
+ elif code1 & BOTTOM:
+ x = x0 + (x1 - x0) * (yPickMin - y0) / (y1 - y0)
+ else:
+ x = None # No horizontal bounds intersection test
+
+ if x is not None and xPickMin <= x <= xPickMax:
+ # Intersection
+ indices.append(index)
+
+ else:
+ # check for crossing with vertical bounds
+ # x0 == x1 is a never event (see remark for y)
+ if code1 & RIGHT:
+ y = y0 + (y1 - y0) * (xPickMax - x0) / (x1 - x0)
+ elif code1 & LEFT:
+ y = y0 + (y1 - y0) * (xPickMin - x0) / (x1 - x0)
+ else:
+ y = None # No vertical bounds intersection test
+
+ if y is not None and yPickMin <= y <= yPickMax:
+ # Intersection
+ indices.append(index)
+
+ indices.sort()
+
+ else:
+ indices = numpy.nonzero((self.xData >= xPickMin) &
+ (self.xData <= xPickMax) &
+ (self.yData >= yPickMin) &
+ (self.yData <= yPickMax))[0].tolist()
+
+ return indices
diff --git a/silx/gui/plot/backends/glutils/GLPlotFrame.py b/silx/gui/plot/backends/glutils/GLPlotFrame.py
new file mode 100644
index 0000000..367419c
--- /dev/null
+++ b/silx/gui/plot/backends/glutils/GLPlotFrame.py
@@ -0,0 +1,1039 @@
+# 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.
+#
+# ############################################################################*/
+"""
+This modules provides the rendering of plot titles, axes and grid.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/04/2017"
+
+
+# TODO
+# keep aspect ratio managed here?
+# smarter dirty flag handling?
+
+import math
+import weakref
+import logging
+from collections import namedtuple
+
+import numpy
+
+from ...._glutils import gl, Program
+from ..._utils import FLOAT32_SAFE_MIN, FLOAT32_MINPOS, FLOAT32_SAFE_MAX
+from .GLSupport import mat4Ortho
+from .GLText import Text2D, CENTER, BOTTOM, TOP, LEFT, RIGHT, ROTATE_270
+from ..._utils.ticklayout import niceNumbersAdaptative, niceNumbersForLog10
+
+
+_logger = logging.getLogger(__name__)
+
+
+# PlotAxis ####################################################################
+
+class PlotAxis(object):
+ """Represents a 1D axis of the plot.
+ This class is intended to be used with :class:`GLPlotFrame`.
+ """
+
+ def __init__(self, plot,
+ tickLength=(0., 0.),
+ labelAlign=CENTER, labelVAlign=CENTER,
+ titleAlign=CENTER, titleVAlign=CENTER,
+ titleRotate=0, titleOffset=(0., 0.)):
+ self._ticks = None
+
+ self._plot = weakref.ref(plot)
+
+ self._isLog = False
+ self._dataRange = 1., 100.
+ self._displayCoords = (0., 0.), (1., 0.)
+ self._title = ''
+
+ self._tickLength = tickLength
+ self._labelAlign = labelAlign
+ self._labelVAlign = labelVAlign
+ self._titleAlign = titleAlign
+ self._titleVAlign = titleVAlign
+ self._titleRotate = titleRotate
+ self._titleOffset = titleOffset
+
+ @property
+ def dataRange(self):
+ """The range of the data represented on the axis as a tuple
+ of 2 floats: (min, max)."""
+ return self._dataRange
+
+ @dataRange.setter
+ def dataRange(self, dataRange):
+ assert len(dataRange) == 2
+ assert dataRange[0] <= dataRange[1]
+ dataRange = float(dataRange[0]), float(dataRange[1])
+
+ if dataRange != self._dataRange:
+ self._dataRange = dataRange
+ self._dirtyTicks()
+
+ @property
+ def isLog(self):
+ """Whether the axis is using a log10 scale or not as a bool."""
+ return self._isLog
+
+ @isLog.setter
+ def isLog(self, isLog):
+ isLog = bool(isLog)
+ if isLog != self._isLog:
+ self._isLog = isLog
+ self._dirtyTicks()
+
+ @property
+ def displayCoords(self):
+ """The coordinates of the start and end points of the axis
+ in display space (i.e., in pixels) as a tuple of 2 tuples of
+ 2 floats: ((x0, y0), (x1, y1)).
+ """
+ return self._displayCoords
+
+ @displayCoords.setter
+ def displayCoords(self, displayCoords):
+ assert len(displayCoords) == 2
+ assert len(displayCoords[0]) == 2
+ assert len(displayCoords[1]) == 2
+ displayCoords = tuple(displayCoords[0]), tuple(displayCoords[1])
+ if displayCoords != self._displayCoords:
+ self._displayCoords = displayCoords
+ self._dirtyTicks()
+
+ @property
+ def title(self):
+ """The text label associated with this axis as a str in latin-1."""
+ return self._title
+
+ @title.setter
+ def title(self, title):
+ if title != self._title:
+ self._title = title
+
+ plot = self._plot()
+ if plot is not None:
+ plot._dirty()
+
+ @property
+ def ticks(self):
+ """Ticks as tuples: ((x, y) in display, dataPos, textLabel)."""
+ if self._ticks is None:
+ self._ticks = tuple(self._ticksGenerator())
+ return self._ticks
+
+ def getVerticesAndLabels(self):
+ """Create the list of vertices for axis and associated text labels.
+
+ :returns: A tuple: List of 2D line vertices, List of Text2D labels.
+ """
+ vertices = list(self.displayCoords) # Add start and end points
+ labels = []
+ tickLabelsSize = [0., 0.]
+
+ xTickLength, yTickLength = self._tickLength
+ for (xPixel, yPixel), dataPos, text in self.ticks:
+ if text is None:
+ tickScale = 0.5
+ else:
+ tickScale = 1.
+
+ label = Text2D(text=text,
+ x=xPixel - xTickLength,
+ y=yPixel - yTickLength,
+ align=self._labelAlign,
+ valign=self._labelVAlign)
+
+ width, height = label.size
+ if width > tickLabelsSize[0]:
+ tickLabelsSize[0] = width
+ if height > tickLabelsSize[1]:
+ tickLabelsSize[1] = height
+
+ labels.append(label)
+
+ vertices.append((xPixel, yPixel))
+ vertices.append((xPixel + tickScale * xTickLength,
+ yPixel + tickScale * yTickLength))
+
+ (x0, y0), (x1, y1) = self.displayCoords
+ xAxisCenter = 0.5 * (x0 + x1)
+ yAxisCenter = 0.5 * (y0 + y1)
+
+ xOffset, yOffset = self._titleOffset
+
+ # Adaptative title positioning:
+ # tickNorm = math.sqrt(xTickLength ** 2 + yTickLength ** 2)
+ # xOffset = -tickLabelsSize[0] * xTickLength / tickNorm
+ # xOffset -= 3 * xTickLength
+ # yOffset = -tickLabelsSize[1] * yTickLength / tickNorm
+ # yOffset -= 3 * yTickLength
+
+ axisTitle = Text2D(text=self.title,
+ x=xAxisCenter + xOffset,
+ y=yAxisCenter + yOffset,
+ align=self._titleAlign,
+ valign=self._titleVAlign,
+ rotate=self._titleRotate)
+ labels.append(axisTitle)
+
+ return vertices, labels
+
+ def _dirtyTicks(self):
+ """Mark ticks as dirty and notify listener (i.e., background)."""
+ self._ticks = None
+ plot = self._plot()
+ if plot is not None:
+ plot._dirty()
+
+ @staticmethod
+ def _frange(start, stop, step):
+ """range for float (including stop)."""
+ while start <= stop:
+ yield start
+ start += step
+
+ def _ticksGenerator(self):
+ """Generator of ticks as tuples:
+ ((x, y) in display, dataPos, textLabel).
+ """
+ dataMin, dataMax = self.dataRange
+ if self.isLog and dataMin <= 0.:
+ _logger.warning(
+ 'Getting ticks while isLog=True and dataRange[0]<=0.')
+ dataMin = 1.
+ if dataMax < dataMin:
+ dataMax = 1.
+
+ if dataMin != dataMax: # data range is not null
+ (x0, y0), (x1, y1) = self.displayCoords
+
+ if self.isLog:
+ logMin, logMax = math.log10(dataMin), math.log10(dataMax)
+ tickMin, tickMax, step, _ = niceNumbersForLog10(logMin, logMax)
+
+ xScale = (x1 - x0) / (logMax - logMin)
+ yScale = (y1 - y0) / (logMax - logMin)
+
+ for logPos in self._frange(tickMin, tickMax, step):
+ if logMin <= logPos <= logMax:
+ dataPos = 10 ** logPos
+ xPixel = x0 + (logPos - logMin) * xScale
+ yPixel = y0 + (logPos - logMin) * yScale
+ text = '1e%+03d' % logPos
+ yield ((xPixel, yPixel), dataPos, text)
+
+ if step == 1:
+ ticks = list(self._frange(tickMin, tickMax, step))[:-1]
+ for logPos in ticks:
+ dataOrigPos = 10 ** logPos
+ for index in range(2, 10):
+ dataPos = dataOrigPos * index
+ if dataMin <= dataPos <= dataMax:
+ logSubPos = math.log10(dataPos)
+ xPixel = x0 + (logSubPos - logMin) * xScale
+ yPixel = y0 + (logSubPos - logMin) * yScale
+ yield ((xPixel, yPixel), dataPos, None)
+
+ else:
+ xScale = (x1 - x0) / (dataMax - dataMin)
+ yScale = (y1 - y0) / (dataMax - dataMin)
+
+ nbPixels = math.sqrt(pow(x1 - x0, 2) + pow(y1 - y0, 2))
+
+ # Density of 1.3 label per 92 pixels
+ # i.e., 1.3 label per inch on a 92 dpi screen
+ tickMin, tickMax, step, nbFrac = niceNumbersAdaptative(
+ dataMin, dataMax, nbPixels, 1.3 / 92)
+
+ for dataPos in self._frange(tickMin, tickMax, step):
+ if dataMin <= dataPos <= dataMax:
+ xPixel = x0 + (dataPos - dataMin) * xScale
+ yPixel = y0 + (dataPos - dataMin) * yScale
+
+ if nbFrac == 0:
+ text = '%g' % dataPos
+ else:
+ text = ('%.' + str(nbFrac) + 'f') % dataPos
+ yield ((xPixel, yPixel), dataPos, text)
+
+
+# GLPlotFrame #################################################################
+
+class GLPlotFrame(object):
+ """Base class for rendering a 2D frame surrounded by axes."""
+
+ _TICK_LENGTH_IN_PIXELS = 5
+ _LINE_WIDTH = 1
+
+ _SHADERS = {
+ 'vertex': """
+ attribute vec2 position;
+ uniform mat4 matrix;
+
+ void main(void) {
+ gl_Position = matrix * vec4(position, 0.0, 1.0);
+ }
+ """,
+ 'fragment': """
+ uniform vec4 color;
+ uniform float tickFactor; /* = 1./tickLength or 0. for solid line */
+
+ void main(void) {
+ if (mod(tickFactor * (gl_FragCoord.x + gl_FragCoord.y), 2.) < 1.) {
+ gl_FragColor = color;
+ } else {
+ discard;
+ }
+ }
+ """
+ }
+
+ _Margins = namedtuple('Margins', ('left', 'right', 'top', 'bottom'))
+
+ def __init__(self, margins):
+ """
+ :param margins: The margins around plot area for axis and labels.
+ :type margins: dict with 'left', 'right', 'top', 'bottom' keys and
+ values as ints.
+ """
+ self._renderResources = None
+
+ self._margins = self._Margins(**margins)
+
+ self.axes = [] # List of PlotAxis to be updated by subclasses
+
+ self._grid = False
+ self._size = 0., 0.
+ self._title = ''
+
+ @property
+ def isDirty(self):
+ """True if it need to refresh graphic rendering, False otherwise."""
+ return self._renderResources is None
+
+ GRID_NONE = 0
+ GRID_MAIN_TICKS = 1
+ GRID_SUB_TICKS = 2
+ GRID_ALL_TICKS = (GRID_MAIN_TICKS + GRID_SUB_TICKS)
+
+ @property
+ def margins(self):
+ """Margins in pixels around the plot."""
+ return self._margins
+
+ @property
+ def grid(self):
+ """Grid display mode:
+ - 0: No grid.
+ - 1: Grid on main ticks.
+ - 2: Grid on sub-ticks for log scale axes.
+ - 3: Grid on main and sub ticks."""
+ return self._grid
+
+ @grid.setter
+ def grid(self, grid):
+ assert grid in (self.GRID_NONE, self.GRID_MAIN_TICKS,
+ self.GRID_SUB_TICKS, self.GRID_ALL_TICKS)
+ if grid != self._grid:
+ self._grid = grid
+ self._dirty()
+
+ @property
+ def size(self):
+ """Size in pixels of the plot area including margins."""
+ return self._size
+
+ @size.setter
+ def size(self, size):
+ assert len(size) == 2
+ size = tuple(size)
+ if size != self._size:
+ self._size = size
+ self._dirty()
+
+ @property
+ def plotOrigin(self):
+ """Plot area origin (left, top) in widget coordinates in pixels."""
+ return self.margins.left, self.margins.top
+
+ @property
+ def plotSize(self):
+ """Plot area size (width, height) in pixels."""
+ w, h = self.size
+ w -= self.margins.left + self.margins.right
+ h -= self.margins.top + self.margins.bottom
+ return w, h
+
+ @property
+ def title(self):
+ """Main title as a str in latin-1."""
+ return self._title
+
+ @title.setter
+ def title(self, title):
+ if title != self._title:
+ self._title = title
+ self._dirty()
+
+ # In-place update
+ # if self._renderResources is not None:
+ # self._renderResources[-1][-1].text = title
+
+ def _dirty(self):
+ # When Text2D require discard we need to handle it
+ self._renderResources = None
+
+ def _buildGridVertices(self):
+ if self._grid == self.GRID_NONE:
+ return []
+
+ elif self._grid == self.GRID_MAIN_TICKS:
+ def test(text):
+ return text is not None
+ elif self._grid == self.GRID_SUB_TICKS:
+ def test(text):
+ return text is None
+ elif self._grid == self.GRID_ALL_TICKS:
+ def test(_):
+ return True
+ else:
+ logging.warning('Wrong grid mode: %d' % self._grid)
+ return []
+
+ return self._buildGridVerticesWithTest(test)
+
+ def _buildGridVerticesWithTest(self, test):
+ """Override in subclass to generate grid vertices"""
+ return []
+
+ def _buildVerticesAndLabels(self):
+ # To fill with copy of axes lists
+ vertices = []
+ labels = []
+
+ for axis in self.axes:
+ axisVertices, axisLabels = axis.getVerticesAndLabels()
+ vertices += axisVertices
+ labels += axisLabels
+
+ vertices = numpy.array(vertices, dtype=numpy.float32)
+
+ # Add main title
+ xTitle = (self.size[0] + self.margins.left -
+ self.margins.right) // 2
+ yTitle = self.margins.top - self._TICK_LENGTH_IN_PIXELS
+ labels.append(Text2D(text=self.title,
+ x=xTitle,
+ y=yTitle,
+ align=CENTER,
+ valign=BOTTOM))
+
+ # grid
+ gridVertices = numpy.array(self._buildGridVertices(),
+ dtype=numpy.float32)
+
+ self._renderResources = (vertices, gridVertices, labels)
+
+ _program = Program(
+ _SHADERS['vertex'], _SHADERS['fragment'], attrib0='position')
+
+ def render(self):
+ if self._renderResources is None:
+ self._buildVerticesAndLabels()
+ vertices, gridVertices, labels = self._renderResources
+
+ width, height = self.size
+ matProj = mat4Ortho(0, width, height, 0, 1, -1)
+
+ gl.glViewport(0, 0, width, height)
+
+ prog = self._program
+ prog.use()
+
+ gl.glLineWidth(self._LINE_WIDTH)
+
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matProj)
+ gl.glUniform4f(prog.uniforms['color'], 0., 0., 0., 1.)
+ gl.glUniform1f(prog.uniforms['tickFactor'], 0.)
+
+ gl.glEnableVertexAttribArray(prog.attributes['position'])
+ gl.glVertexAttribPointer(prog.attributes['position'],
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0, vertices)
+
+ gl.glDrawArrays(gl.GL_LINES, 0, len(vertices))
+
+ for label in labels:
+ label.render(matProj)
+
+ def renderGrid(self):
+ if self._grid == self.GRID_NONE:
+ return
+
+ if self._renderResources is None:
+ self._buildVerticesAndLabels()
+ vertices, gridVertices, labels = self._renderResources
+
+ width, height = self.size
+ matProj = mat4Ortho(0, width, height, 0, 1, -1)
+
+ gl.glViewport(0, 0, width, height)
+
+ prog = self._program
+ prog.use()
+
+ gl.glLineWidth(self._LINE_WIDTH)
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matProj)
+ gl.glUniform4f(prog.uniforms['color'], 0.7, 0.7, 0.7, 1.)
+ gl.glUniform1f(prog.uniforms['tickFactor'], 0.) # 1/2.) # 1/tickLen
+
+ gl.glEnableVertexAttribArray(prog.attributes['position'])
+ gl.glVertexAttribPointer(prog.attributes['position'],
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0, gridVertices)
+
+ gl.glDrawArrays(gl.GL_LINES, 0, len(gridVertices))
+
+
+# GLPlotFrame2D ###############################################################
+
+class GLPlotFrame2D(GLPlotFrame):
+ def __init__(self, margins):
+ """
+ :param margins: The margins around plot area for axis and labels.
+ :type margins: dict with 'left', 'right', 'top', 'bottom' keys and
+ values as ints.
+ """
+ super(GLPlotFrame2D, self).__init__(margins)
+ self.axes.append(PlotAxis(self,
+ tickLength=(0., -5.),
+ labelAlign=CENTER, labelVAlign=TOP,
+ titleAlign=CENTER, titleVAlign=TOP,
+ titleRotate=0,
+ titleOffset=(0, self.margins.bottom // 2)))
+
+ self._x2AxisCoords = ()
+
+ self.axes.append(PlotAxis(self,
+ tickLength=(5., 0.),
+ labelAlign=RIGHT, labelVAlign=CENTER,
+ titleAlign=CENTER, titleVAlign=BOTTOM,
+ titleRotate=ROTATE_270,
+ titleOffset=(-3 * self.margins.left // 4,
+ 0)))
+
+ self._y2Axis = PlotAxis(self,
+ tickLength=(-5., 0.),
+ labelAlign=LEFT, labelVAlign=CENTER,
+ titleAlign=CENTER, titleVAlign=TOP,
+ titleRotate=ROTATE_270,
+ titleOffset=(3 * self.margins.right // 4,
+ 0))
+
+ self._isYAxisInverted = False
+
+ self._dataRanges = {
+ 'x': (1., 100.), 'y': (1., 100.), 'y2': (1., 100.)}
+
+ self._baseVectors = (1., 0.), (0., 1.)
+
+ self._transformedDataRanges = None
+ self._transformedDataProjMat = None
+ self._transformedDataY2ProjMat = None
+
+ def _dirty(self):
+ super(GLPlotFrame2D, self)._dirty()
+ self._transformedDataRanges = None
+ self._transformedDataProjMat = None
+ self._transformedDataY2ProjMat = None
+
+ @property
+ def isDirty(self):
+ """True if it need to refresh graphic rendering, False otherwise."""
+ return (super(GLPlotFrame2D, self).isDirty or
+ self._transformedDataRanges is None or
+ self._transformedDataProjMat is None or
+ self._transformedDataY2ProjMat is None)
+
+ @property
+ def xAxis(self):
+ return self.axes[0]
+
+ @property
+ def yAxis(self):
+ return self.axes[1]
+
+ @property
+ def y2Axis(self):
+ return self._y2Axis
+
+ @property
+ def isY2Axis(self):
+ """Whether to display the left Y axis or not."""
+ return len(self.axes) == 3
+
+ @isY2Axis.setter
+ def isY2Axis(self, isY2Axis):
+ if isY2Axis != self.isY2Axis:
+ if isY2Axis:
+ self.axes.append(self._y2Axis)
+ else:
+ self.axes = self.axes[:2]
+
+ self._dirty()
+
+ @property
+ def isYAxisInverted(self):
+ """Whether Y axes are inverted or not as a bool."""
+ return self._isYAxisInverted
+
+ @isYAxisInverted.setter
+ def isYAxisInverted(self, value):
+ value = bool(value)
+ if value != self._isYAxisInverted:
+ self._isYAxisInverted = value
+ self._dirty()
+
+ DEFAULT_BASE_VECTORS = (1., 0.), (0., 1.)
+ """Values of baseVectors for orthogonal axes."""
+
+ @property
+ def baseVectors(self):
+ """Coordinates of the X and Y axes in the orthogonal plot coords.
+
+ Raises ValueError if corresponding matrix is singular.
+
+ 2 tuples of 2 floats: (xx, xy), (yx, yy)
+ """
+ return self._baseVectors
+
+ @baseVectors.setter
+ def baseVectors(self, baseVectors):
+ self._dirty()
+
+ (xx, xy), (yx, yy) = baseVectors
+ vectors = (float(xx), float(xy)), (float(yx), float(yy))
+
+ det = (vectors[0][0] * vectors[1][1] - vectors[1][0] * vectors[0][1])
+ if det == 0.:
+ raise ValueError("Singular matrix for base vectors: " +
+ str(vectors))
+
+ if vectors != self._baseVectors:
+ self._baseVectors = vectors
+ self._dirty()
+
+ @property
+ def dataRanges(self):
+ """Ranges of data visible in the plot on x, y and y2 axes.
+
+ This is different to the axes range when axes are not orthogonal.
+
+ Type: ((xMin, xMax), (yMin, yMax), (y2Min, y2Max))
+ """
+ return self._DataRanges(self._dataRanges['x'],
+ self._dataRanges['y'],
+ self._dataRanges['y2'])
+
+ @staticmethod
+ def _clipToSafeRange(min_, max_, isLog):
+ # Clip range if needed
+ minLimit = FLOAT32_MINPOS if isLog else FLOAT32_SAFE_MIN
+ min_ = numpy.clip(min_, minLimit, FLOAT32_SAFE_MAX)
+ max_ = numpy.clip(max_, minLimit, FLOAT32_SAFE_MAX)
+ assert min_ < max_
+ return min_, max_
+
+ def setDataRanges(self, x=None, y=None, y2=None):
+ """Set data range over each axes.
+
+ The provided ranges are clipped to possible values
+ (i.e., 32 float range + positive range for log scale).
+
+ :param x: (min, max) data range over X axis
+ :param y: (min, max) data range over Y axis
+ :param y2: (min, max) data range over Y2 axis
+ """
+ if x is not None:
+ self._dataRanges['x'] = \
+ self._clipToSafeRange(x[0], x[1], self.xAxis.isLog)
+
+ if y is not None:
+ self._dataRanges['y'] = \
+ self._clipToSafeRange(y[0], y[1], self.yAxis.isLog)
+
+ if y2 is not None:
+ self._dataRanges['y2'] = \
+ self._clipToSafeRange(y2[0], y2[1], self.y2Axis.isLog)
+
+ self.xAxis.dataRange = self._dataRanges['x']
+ self.yAxis.dataRange = self._dataRanges['y']
+ self.y2Axis.dataRange = self._dataRanges['y2']
+
+ _DataRanges = namedtuple('dataRanges', ('x', 'y', 'y2'))
+
+ @property
+ def transformedDataRanges(self):
+ """Bounds of the displayed area in transformed data coordinates
+ (i.e., log scale applied if any as well as skew)
+
+ 3-tuple of 2-tuple (min, max) for each axis: x, y, y2.
+ """
+ if self._transformedDataRanges is None:
+ (xMin, xMax), (yMin, yMax), (y2Min, y2Max) = self.dataRanges
+
+ if self.xAxis.isLog:
+ try:
+ xMin = math.log10(xMin)
+ except ValueError:
+ _logger.info('xMin: warning log10(%f)', xMin)
+ xMin = 0.
+ try:
+ xMax = math.log10(xMax)
+ except ValueError:
+ _logger.info('xMax: warning log10(%f)', xMax)
+ xMax = 0.
+
+ if self.yAxis.isLog:
+ try:
+ yMin = math.log10(yMin)
+ except ValueError:
+ _logger.info('yMin: warning log10(%f)', yMin)
+ yMin = 0.
+ try:
+ yMax = math.log10(yMax)
+ except ValueError:
+ _logger.info('yMax: warning log10(%f)', yMax)
+ yMax = 0.
+
+ try:
+ y2Min = math.log10(y2Min)
+ except ValueError:
+ _logger.info('yMin: warning log10(%f)', y2Min)
+ y2Min = 0.
+ try:
+ y2Max = math.log10(y2Max)
+ except ValueError:
+ _logger.info('yMax: warning log10(%f)', y2Max)
+ y2Max = 0.
+
+ # Non-orthogonal axes
+ if self.baseVectors != self.DEFAULT_BASE_VECTORS:
+ (xx, xy), (yx, yy) = self.baseVectors
+ skew_mat = numpy.array(((xx, yx), (xy, yy)))
+
+ corners = [(xMin, yMin), (xMin, yMax),
+ (xMax, yMin), (xMax, yMax),
+ (xMin, y2Min), (xMin, y2Max),
+ (xMax, y2Min), (xMax, y2Max)]
+
+ corners = numpy.array(
+ [numpy.dot(skew_mat, corner) for corner in corners],
+ dtype=numpy.float32)
+ xMin, xMax = corners[:, 0].min(), corners[:, 0].max()
+ yMin, yMax = corners[0:4, 1].min(), corners[0:4, 1].max()
+ y2Min, y2Max = corners[4:, 1].min(), corners[4:, 1].max()
+
+ self._transformedDataRanges = self._DataRanges(
+ (xMin, xMax), (yMin, yMax), (y2Min, y2Max))
+
+ return self._transformedDataRanges
+
+ @property
+ def transformedDataProjMat(self):
+ """Orthographic projection matrix for rendering transformed data
+
+ :type: numpy.matrix
+ """
+ if self._transformedDataProjMat is None:
+ xMin, xMax = self.transformedDataRanges.x
+ yMin, yMax = self.transformedDataRanges.y
+
+ if self.isYAxisInverted:
+ mat = mat4Ortho(xMin, xMax, yMax, yMin, 1, -1)
+ else:
+ mat = mat4Ortho(xMin, xMax, yMin, yMax, 1, -1)
+
+ # Non-orthogonal axes
+ if self.baseVectors != self.DEFAULT_BASE_VECTORS:
+ (xx, xy), (yx, yy) = self.baseVectors
+ mat = mat * numpy.matrix((
+ (xx, yx, 0., 0.),
+ (xy, yy, 0., 0.),
+ (0., 0., 1., 0.),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
+
+ self._transformedDataProjMat = mat
+
+ return self._transformedDataProjMat
+
+ @property
+ def transformedDataY2ProjMat(self):
+ """Orthographic projection matrix for rendering transformed data
+ for the 2nd Y axis
+
+ :type: numpy.matrix
+ """
+ if self._transformedDataY2ProjMat is None:
+ xMin, xMax = self.transformedDataRanges.x
+ y2Min, y2Max = self.transformedDataRanges.y2
+
+ if self.isYAxisInverted:
+ mat = mat4Ortho(xMin, xMax, y2Max, y2Min, 1, -1)
+ else:
+ mat = mat4Ortho(xMin, xMax, y2Min, y2Max, 1, -1)
+
+ # Non-orthogonal axes
+ if self.baseVectors != self.DEFAULT_BASE_VECTORS:
+ (xx, xy), (yx, yy) = self.baseVectors
+ mat = mat * numpy.matrix((
+ (xx, yx, 0., 0.),
+ (xy, yy, 0., 0.),
+ (0., 0., 1., 0.),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
+
+ self._transformedDataY2ProjMat = mat
+
+ return self._transformedDataY2ProjMat
+
+ def dataToPixel(self, x, y, axis='left'):
+ """Convert data coordinate to widget pixel coordinate.
+ """
+ assert axis in ('left', 'right')
+
+ trBounds = self.transformedDataRanges
+
+ if self.xAxis.isLog:
+ if x < FLOAT32_MINPOS:
+ return None
+ xDataTr = math.log10(x)
+ else:
+ xDataTr = x
+
+ if self.yAxis.isLog:
+ if y < FLOAT32_MINPOS:
+ return None
+ yDataTr = math.log10(y)
+ else:
+ yDataTr = y
+
+ # Non-orthogonal axes
+ if self.baseVectors != self.DEFAULT_BASE_VECTORS:
+ (xx, xy), (yx, yy) = self.baseVectors
+ skew_mat = numpy.array(((xx, yx), (xy, yy)))
+
+ coords = numpy.dot(skew_mat, numpy.array((xDataTr, yDataTr)))
+ xDataTr, yDataTr = coords
+
+ plotWidth, plotHeight = self.plotSize
+
+ xPixel = int(self.margins.left +
+ plotWidth * (xDataTr - trBounds.x[0]) /
+ (trBounds.x[1] - trBounds.x[0]))
+
+ usedAxis = trBounds.y if axis == "left" else trBounds.y2
+ yOffset = (plotHeight * (yDataTr - usedAxis[0]) /
+ (usedAxis[1] - usedAxis[0]))
+
+ if self.isYAxisInverted:
+ yPixel = int(self.margins.top + yOffset)
+ else:
+ yPixel = int(self.size[1] - self.margins.bottom - yOffset)
+
+ return xPixel, yPixel
+
+ def pixelToData(self, x, y, axis="left"):
+ """Convert pixel position to data coordinates.
+
+ :param float x: X coord
+ :param float y: Y coord
+ :param str axis: Y axis to use in ('left', 'right')
+ :return: (x, y) position in data coords
+ """
+ assert axis in ("left", "right")
+
+ plotWidth, plotHeight = self.plotSize
+
+ trBounds = self.transformedDataRanges
+
+ xData = (x - self.margins.left + 0.5) / float(plotWidth)
+ xData = trBounds.x[0] + xData * (trBounds.x[1] - trBounds.x[0])
+
+ usedAxis = trBounds.y if axis == "left" else trBounds.y2
+ if self.isYAxisInverted:
+ yData = (y - self.margins.top + 0.5) / float(plotHeight)
+ yData = usedAxis[0] + yData * (usedAxis[1] - usedAxis[0])
+ else:
+ yData = self.size[1] - self.margins.bottom - y - 0.5
+ yData /= float(plotHeight)
+ yData = usedAxis[0] + yData * (usedAxis[1] - usedAxis[0])
+
+ # non-orthogonal axis
+ if self.baseVectors != self.DEFAULT_BASE_VECTORS:
+ (xx, xy), (yx, yy) = self.baseVectors
+ skew_mat = numpy.array(((xx, yx), (xy, yy)))
+ skew_mat = numpy.linalg.inv(skew_mat)
+
+ coords = numpy.dot(skew_mat, numpy.array((xData, yData)))
+ xData, yData = coords
+
+ if self.xAxis.isLog:
+ xData = pow(10, xData)
+ if self.yAxis.isLog:
+ yData = pow(10, yData)
+
+ return xData, yData
+
+ def _buildGridVerticesWithTest(self, test):
+ vertices = []
+
+ if self.baseVectors == self.DEFAULT_BASE_VECTORS:
+ for axis in self.axes:
+ for (xPixel, yPixel), data, text in axis.ticks:
+ if test(text):
+ vertices.append((xPixel, yPixel))
+ if axis == self.xAxis:
+ vertices.append((xPixel, self.margins.top))
+ elif axis == self.yAxis:
+ vertices.append((self.size[0] - self.margins.right,
+ yPixel))
+ else: # axis == self.y2Axis
+ vertices.append((self.margins.left, yPixel))
+
+ else:
+ # Get plot corners in data coords
+ plotLeft, plotTop = self.plotOrigin
+ plotWidth, plotHeight = self.plotSize
+
+ corners = [(plotLeft, plotTop),
+ (plotLeft, plotTop + plotHeight),
+ (plotLeft + plotWidth, plotTop + plotHeight),
+ (plotLeft + plotWidth, plotTop)]
+
+ for axis in self.axes:
+ if axis == self.xAxis:
+ cornersInData = numpy.array([
+ self.pixelToData(x, y) for (x, y) in corners])
+ borders = ((cornersInData[0], cornersInData[3]), # top
+ (cornersInData[1], cornersInData[0]), # left
+ (cornersInData[3], cornersInData[2])) # right
+
+ for (xPixel, yPixel), data, text in axis.ticks:
+ if test(text):
+ for (x0, y0), (x1, y1) in borders:
+ if min(x0, x1) <= data < max(x0, x1):
+ yIntersect = (data - x0) * \
+ (y1 - y0) / (x1 - x0) + y0
+
+ pixelPos = self.dataToPixel(
+ data, yIntersect)
+ if pixelPos is not None:
+ vertices.append((xPixel, yPixel))
+ vertices.append(pixelPos)
+ break # Stop at first intersection
+
+ else: # y or y2 axes
+ if axis == self.yAxis:
+ axis_name = 'left'
+ cornersInData = numpy.array([
+ self.pixelToData(x, y) for (x, y) in corners])
+ borders = (
+ (cornersInData[3], cornersInData[2]), # right
+ (cornersInData[0], cornersInData[3]), # top
+ (cornersInData[2], cornersInData[1])) # bottom
+
+ else: # axis == self.y2Axis
+ axis_name = 'right'
+ corners = numpy.array([self.pixelToData(
+ x, y, axis='right') for (x, y) in corners])
+ borders = (
+ (cornersInData[1], cornersInData[0]), # left
+ (cornersInData[0], cornersInData[3]), # top
+ (cornersInData[2], cornersInData[1])) # bottom
+
+ for (xPixel, yPixel), data, text in axis.ticks:
+ if test(text):
+ for (x0, y0), (x1, y1) in borders:
+ if min(y0, y1) <= data < max(y0, y1):
+ xIntersect = (data - y0) * \
+ (x1 - x0) / (y1 - y0) + x0
+
+ pixelPos = self.dataToPixel(
+ xIntersect, data, axis=axis_name)
+ if pixelPos is not None:
+ vertices.append((xPixel, yPixel))
+ vertices.append(pixelPos)
+ break # Stop at first intersection
+
+ return vertices
+
+ def _buildVerticesAndLabels(self):
+ width, height = self.size
+
+ xCoords = (self.margins.left - 0.5,
+ width - self.margins.right + 0.5)
+ yCoords = (height - self.margins.bottom + 0.5,
+ self.margins.top - 0.5)
+
+ self.axes[0].displayCoords = ((xCoords[0], yCoords[0]),
+ (xCoords[1], yCoords[0]))
+
+ self._x2AxisCoords = ((xCoords[0], yCoords[1]),
+ (xCoords[1], yCoords[1]))
+
+ if self.isYAxisInverted:
+ # Y axes are inverted, axes coordinates are inverted
+ yCoords = yCoords[1], yCoords[0]
+
+ self.axes[1].displayCoords = ((xCoords[0], yCoords[0]),
+ (xCoords[0], yCoords[1]))
+
+ self._y2Axis.displayCoords = ((xCoords[1], yCoords[0]),
+ (xCoords[1], yCoords[1]))
+
+ super(GLPlotFrame2D, self)._buildVerticesAndLabels()
+
+ vertices, gridVertices, labels = self._renderResources
+
+ # Adds vertices for borders without axis
+ extraVertices = []
+ extraVertices += self._x2AxisCoords
+ if not self.isY2Axis:
+ extraVertices += self._y2Axis.displayCoords
+
+ extraVertices = numpy.array(
+ extraVertices, copy=False, dtype=numpy.float32)
+ vertices = numpy.append(vertices, extraVertices, axis=0)
+
+ self._renderResources = (vertices, gridVertices, labels)
diff --git a/silx/gui/plot/backends/glutils/GLPlotImage.py b/silx/gui/plot/backends/glutils/GLPlotImage.py
new file mode 100644
index 0000000..8fff82b
--- /dev/null
+++ b/silx/gui/plot/backends/glutils/GLPlotImage.py
@@ -0,0 +1,707 @@
+# 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.
+#
+# ############################################################################*/
+"""
+This module provides a class to render 2D array as a colormap or RGB(A) image
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/04/2017"
+
+
+import math
+import numpy
+
+from silx.math.combo import min_max
+
+from ...._glutils import gl, Program, Texture
+from ..._utils import FLOAT32_MINPOS
+from .GLSupport import mat4Translate, mat4Scale
+from .GLTexture import Image
+
+
+class _GLPlotData2D(object):
+ def __init__(self, data, origin, scale):
+ self.data = data
+ assert len(origin) == 2
+ self.origin = tuple(origin)
+ assert len(scale) == 2
+ self.scale = tuple(scale)
+
+ def pick(self, x, y):
+ if self.xMin <= x <= self.xMax and self.yMin <= y <= self.yMax:
+ ox, oy = self.origin
+ sx, sy = self.scale
+ col = int((x - ox) / sx)
+ row = int((y - oy) / sy)
+ return col, row
+ else:
+ return None
+
+ @property
+ def xMin(self):
+ ox, sx = self.origin[0], self.scale[0]
+ return ox if sx >= 0. else ox + sx * self.data.shape[1]
+
+ @property
+ def yMin(self):
+ oy, sy = self.origin[1], self.scale[1]
+ return oy if sy >= 0. else oy + sy * self.data.shape[0]
+
+ @property
+ def xMax(self):
+ ox, sx = self.origin[0], self.scale[0]
+ return ox + sx * self.data.shape[1] if sx >= 0. else ox
+
+ @property
+ def yMax(self):
+ oy, sy = self.origin[1], self.scale[1]
+ return oy + sy * self.data.shape[0] if sy >= 0. else oy
+
+ def discard(self):
+ pass
+
+ def prepare(self):
+ pass
+
+ def render(self, matrix, isXLog, isYLog):
+ pass
+
+
+class GLPlotColormap(_GLPlotData2D):
+
+ _SHADERS = {
+ 'linear': {
+ 'vertex': """
+ #version 120
+
+ uniform mat4 matrix;
+ attribute vec2 texCoords;
+ attribute vec2 position;
+
+ varying vec2 coords;
+
+ void main(void) {
+ coords = texCoords;
+ gl_Position = matrix * vec4(position, 0.0, 1.0);
+ }
+ """,
+ 'fragTransform': """
+ vec2 textureCoords(void) {
+ return coords;
+ }
+ """},
+
+ 'log': {
+ 'vertex': """
+ #version 120
+
+ attribute vec2 position;
+ uniform mat4 matrix;
+ uniform mat4 matOffset;
+ uniform bvec2 isLog;
+
+ varying vec2 coords;
+
+ const float oneOverLog10 = 0.43429448190325176;
+
+ void main(void) {
+ vec4 dataPos = matOffset * vec4(position, 0.0, 1.0);
+ if (isLog.x) {
+ dataPos.x = oneOverLog10 * log(dataPos.x);
+ }
+ if (isLog.y) {
+ dataPos.y = oneOverLog10 * log(dataPos.y);
+ }
+ coords = dataPos.xy;
+ gl_Position = matrix * dataPos;
+ }
+ """,
+ 'fragTransform': """
+ uniform bvec2 isLog;
+ uniform struct {
+ vec2 oneOverRange;
+ vec2 originOverRange;
+ } bounds;
+
+ vec2 textureCoords(void) {
+ vec2 pos = coords;
+ if (isLog.x) {
+ pos.x = pow(10., coords.x);
+ }
+ if (isLog.y) {
+ pos.y = pow(10., coords.y);
+ }
+ return pos * bounds.oneOverRange - bounds.originOverRange;
+ // TODO texture coords in range different from [0, 1]
+ }
+ """},
+
+ 'fragment': """
+ #version 120
+
+ uniform sampler2D data;
+ uniform struct {
+ sampler2D texture;
+ bool isLog;
+ float min;
+ float oneOverRange;
+ } cmap;
+ uniform float alpha;
+
+ varying vec2 coords;
+
+ %s
+
+ const float oneOverLog10 = 0.43429448190325176;
+
+ void main(void) {
+ float value = texture2D(data, textureCoords()).r;
+ if (cmap.isLog) {
+ if (value > 0.) {
+ value = clamp(cmap.oneOverRange *
+ (oneOverLog10 * log(value) - cmap.min),
+ 0., 1.);
+ } else {
+ value = 0.;
+ }
+ } else { /*Linear mapping*/
+ value = clamp(cmap.oneOverRange * (value - cmap.min), 0., 1.);
+ }
+
+ gl_FragColor = texture2D(cmap.texture, vec2(value, 0.5));
+ gl_FragColor.a *= alpha;
+ }
+ """
+ }
+
+ _DATA_TEX_UNIT = 0
+ _CMAP_TEX_UNIT = 1
+
+ _INTERNAL_FORMATS = {
+ numpy.dtype(numpy.float32): gl.GL_R32F,
+ # Use normalized integer for unsigned int formats
+ numpy.dtype(numpy.uint16): gl.GL_R16,
+ numpy.dtype(numpy.uint8): gl.GL_R8,
+ }
+
+ _linearProgram = Program(_SHADERS['linear']['vertex'],
+ _SHADERS['fragment'] %
+ _SHADERS['linear']['fragTransform'],
+ attrib0='position')
+
+ _logProgram = Program(_SHADERS['log']['vertex'],
+ _SHADERS['fragment'] %
+ _SHADERS['log']['fragTransform'],
+ attrib0='position')
+
+ def __init__(self, data, origin, scale,
+ colormap, cmapIsLog=False, cmapRange=None,
+ alpha=1.0):
+ """Create a 2D colormap
+
+ :param data: The 2D scalar data array to display
+ :type data: numpy.ndarray with 2 dimensions (dtype=numpy.float32)
+ :param origin: (x, y) coordinates of the origin of the data array
+ :type origin: 2-tuple of floats.
+ :param scale: (sx, sy) scale factors of the data array.
+ This is the size of a data pixel in plot data space.
+ :type scale: 2-tuple of floats.
+ :param str colormap: Name of the colormap to use
+ TODO: Accept a 1D scalar array as the colormap
+ :param bool cmapIsLog: If True, uses log10 of the data value
+ :param cmapRange: The range of colormap or None for autoscale colormap
+ For logarithmic colormap, the range is in the untransformed data
+ TODO: check consistency with matplotlib
+ :type cmapRange: (float, float) or None
+ :param float alpha: Opacity from 0 (transparent) to 1 (opaque)
+ """
+ assert data.dtype in self._INTERNAL_FORMATS
+
+ super(GLPlotColormap, self).__init__(data, origin, scale)
+ self.colormap = numpy.array(colormap, copy=False)
+ self.cmapIsLog = cmapIsLog
+ self._cmapRange = None # User-provided range info
+ self._cmapRangeCache = None # Store extra data for range
+ self.cmapRange = cmapRange # Update _cmapRange
+ self._alpha = numpy.clip(alpha, 0., 1.)
+
+ self._cmap_texture = None
+ self._texture = None
+ self._textureIsDirty = False
+
+ def discard(self):
+ if self._cmap_texture is not None:
+ self._cmap_texture.discard()
+ self._cmap_texture = None
+
+ if self._texture is not None:
+ self._texture.discard()
+ self._texture = None
+ self._textureIsDirty = False
+
+ @property
+ def cmapRange(self):
+ if self._cmapRange is None: # Auto-scale mode
+ if self._cmapRangeCache is None:
+ # Build data , positive ranges
+ result = min_max(self.data, min_positive=True)
+ min_ = result.minimum
+ minPos = result.min_positive
+ max_ = result.maximum
+ maxPos = max_ if max_ > 0. else 1.
+ if minPos is None:
+ minPos = maxPos
+ self._cmapRangeCache = {'range': (min_, max_),
+ 'pos': (minPos, maxPos)}
+
+ return self._cmapRangeCache['pos' if self.cmapIsLog else 'range']
+
+ else:
+ if not self.cmapIsLog:
+ return self._cmapRange # Return range as is
+ else:
+ if self._cmapRangeCache is None:
+ # Build a strictly positive range from cmapRange
+ min_, max_ = self._cmapRange
+ if min_ > 0. and max_ > 0.:
+ minPos, maxPos = min_, max_
+ else:
+ result = min_max(self.data, min_positive=True)
+ minPos = result.min_positive
+ dataMax = result.maximum
+ if max_ > 0.:
+ maxPos = max_
+ elif dataMax > 0.:
+ maxPos = dataMax
+ else:
+ maxPos = 1. # Arbitrary fallback
+ if minPos is None:
+ minPos = maxPos
+ self._cmapRangeCache = minPos, maxPos
+ return self._cmapRangeCache # Strictly positive range
+
+ @cmapRange.setter
+ def cmapRange(self, cmapRange):
+ self._cmapRangeCache = None
+ if cmapRange is None:
+ self._cmapRange = None
+ else:
+ assert len(cmapRange) == 2
+ assert cmapRange[0] <= cmapRange[1]
+ self._cmapRange = tuple(cmapRange)
+
+ @property
+ def alpha(self):
+ return self._alpha
+
+ def updateData(self, data):
+ assert data.dtype in self._INTERNAL_FORMATS
+ oldData = self.data
+ self.data = data
+
+ self._cmapRangeCache = None
+
+ if self._texture is not None:
+ if (self.data.shape != oldData.shape or
+ self.data.dtype != oldData.dtype):
+ self.discard()
+ else:
+ self._textureIsDirty = True
+
+ def prepare(self):
+ if self._cmap_texture is None:
+ # TODO share cmap texture accross Images
+ # put all cmaps in one texture
+ colormap = numpy.empty((16, 256, self.colormap.shape[1]),
+ dtype=self.colormap.dtype)
+ colormap[:] = self.colormap
+ format_ = gl.GL_RGBA if colormap.shape[-1] == 4 else gl.GL_RGB
+ self._cmap_texture = Texture(internalFormat=format_,
+ data=colormap,
+ format_=format_,
+ texUnit=self._CMAP_TEX_UNIT,
+ minFilter=gl.GL_NEAREST,
+ magFilter=gl.GL_NEAREST,
+ wrap=(gl.GL_CLAMP_TO_EDGE,
+ gl.GL_CLAMP_TO_EDGE))
+
+ if self._texture is None:
+ internalFormat = self._INTERNAL_FORMATS[self.data.dtype]
+
+ self._texture = Image(internalFormat,
+ self.data,
+ format_=gl.GL_RED,
+ texUnit=self._DATA_TEX_UNIT)
+ elif self._textureIsDirty:
+ self._textureIsDirty = True
+ self._texture.updateAll(format_=gl.GL_RED, data=self.data)
+
+ def _setCMap(self, prog):
+ dataMin, dataMax = self.cmapRange # If log, it is stricly positive
+
+ if self.data.dtype in (numpy.uint16, numpy.uint8):
+ # Using unsigned int as normalized integer in OpenGL
+ # So normalize range
+ maxInt = float(numpy.iinfo(self.data.dtype).max)
+ dataMin, dataMax = dataMin / maxInt, dataMax / maxInt
+
+ if self.cmapIsLog:
+ dataMin = math.log10(dataMin)
+ dataMax = math.log10(dataMax)
+
+ gl.glUniform1i(prog.uniforms['cmap.texture'],
+ self._cmap_texture.texUnit)
+ gl.glUniform1i(prog.uniforms['cmap.isLog'], self.cmapIsLog)
+ gl.glUniform1f(prog.uniforms['cmap.min'], dataMin)
+ if dataMax > dataMin:
+ oneOverRange = 1. / (dataMax - dataMin)
+ else:
+ oneOverRange = 0. # Fall-back
+ gl.glUniform1f(prog.uniforms['cmap.oneOverRange'], oneOverRange)
+
+ self._cmap_texture.bind()
+
+ def _renderLinear(self, matrix):
+ self.prepare()
+
+ prog = self._linearProgram
+ prog.use()
+
+ gl.glUniform1i(prog.uniforms['data'], self._DATA_TEX_UNIT)
+
+ mat = matrix * mat4Translate(*self.origin) * mat4Scale(*self.scale)
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, mat)
+
+ gl.glUniform1f(prog.uniforms['alpha'], self.alpha)
+
+ self._setCMap(prog)
+
+ self._texture.render(prog.attributes['position'],
+ prog.attributes['texCoords'],
+ self._DATA_TEX_UNIT)
+
+ def _renderLog10(self, matrix, isXLog, isYLog):
+ xMin, yMin = self.xMin, self.yMin
+ if ((isXLog and xMin < FLOAT32_MINPOS) or
+ (isYLog and yMin < FLOAT32_MINPOS)):
+ # Do not render images that are partly or totally <= 0
+ return
+
+ self.prepare()
+
+ prog = self._logProgram
+ prog.use()
+
+ ox, oy = self.origin
+
+ gl.glUniform1i(prog.uniforms['data'], self._DATA_TEX_UNIT)
+
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix)
+ mat = mat4Translate(ox, oy) * mat4Scale(*self.scale)
+ gl.glUniformMatrix4fv(prog.uniforms['matOffset'], 1, gl.GL_TRUE, mat)
+
+ gl.glUniform2i(prog.uniforms['isLog'], isXLog, isYLog)
+
+ ex = ox + self.scale[0] * self.data.shape[1]
+ ey = oy + self.scale[1] * self.data.shape[0]
+
+ xOneOverRange = 1. / (ex - ox)
+ yOneOverRange = 1. / (ey - oy)
+ gl.glUniform2f(prog.uniforms['bounds.originOverRange'],
+ ox * xOneOverRange, oy * yOneOverRange)
+ gl.glUniform2f(prog.uniforms['bounds.oneOverRange'],
+ xOneOverRange, yOneOverRange)
+
+ gl.glUniform1f(prog.uniforms['alpha'], self.alpha)
+
+ self._setCMap(prog)
+
+ try:
+ tiles = self._texture.tiles
+ except AttributeError:
+ raise RuntimeError("No texture, discard has already been called")
+ if len(tiles) > 1:
+ raise NotImplementedError(
+ "Image over multiple textures not supported with log scale")
+
+ texture, vertices, info = tiles[0]
+
+ texture.bind(self._DATA_TEX_UNIT)
+
+ posAttrib = prog.attributes['position']
+ stride = vertices.shape[-1] * vertices.itemsize
+ gl.glEnableVertexAttribArray(posAttrib)
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ stride, vertices)
+
+ gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(vertices))
+
+ def render(self, matrix, isXLog, isYLog):
+ if any((isXLog, isYLog)):
+ self._renderLog10(matrix, isXLog, isYLog)
+ else:
+ self._renderLinear(matrix)
+
+ # Unbind colormap texture
+ gl.glActiveTexture(gl.GL_TEXTURE0 + self._cmap_texture.texUnit)
+ gl.glBindTexture(self._cmap_texture.target, 0)
+
+
+# image #######################################################################
+
+class GLPlotRGBAImage(_GLPlotData2D):
+
+ _SHADERS = {
+ 'linear': {
+ 'vertex': """
+ #version 120
+
+ 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;
+ }
+ """,
+ 'fragment': """
+ #version 120
+
+ uniform sampler2D tex;
+ uniform float alpha;
+
+ varying vec2 coords;
+
+ void main(void) {
+ gl_FragColor = texture2D(tex, coords);
+ gl_FragColor.a *= alpha;
+ }
+ """},
+
+ 'log': {
+ 'vertex': """
+ #version 120
+
+ attribute vec2 position;
+ uniform mat4 matrix;
+ uniform mat4 matOffset;
+ uniform bvec2 isLog;
+
+ varying vec2 coords;
+
+ const float oneOverLog10 = 0.43429448190325176;
+
+ void main(void) {
+ vec4 dataPos = matOffset * vec4(position, 0.0, 1.0);
+ if (isLog.x) {
+ dataPos.x = oneOverLog10 * log(dataPos.x);
+ }
+ if (isLog.y) {
+ dataPos.y = oneOverLog10 * log(dataPos.y);
+ }
+ coords = dataPos.xy;
+ gl_Position = matrix * dataPos;
+ }
+ """,
+ 'fragment': """
+ #version 120
+
+ uniform sampler2D tex;
+ uniform bvec2 isLog;
+ uniform struct {
+ vec2 oneOverRange;
+ vec2 originOverRange;
+ } bounds;
+ uniform float alpha;
+
+ varying vec2 coords;
+
+ vec2 textureCoords(void) {
+ vec2 pos = coords;
+ if (isLog.x) {
+ pos.x = pow(10., coords.x);
+ }
+ if (isLog.y) {
+ pos.y = pow(10., coords.y);
+ }
+ return pos * bounds.oneOverRange - bounds.originOverRange;
+ // TODO texture coords in range different from [0, 1]
+ }
+
+ void main(void) {
+ gl_FragColor = texture2D(tex, textureCoords());
+ gl_FragColor.a *= alpha;
+ }
+ """}
+ }
+
+ _DATA_TEX_UNIT = 0
+
+ _SUPPORTED_DTYPES = (numpy.dtype(numpy.float32),
+ numpy.dtype(numpy.uint8))
+
+ _linearProgram = Program(_SHADERS['linear']['vertex'],
+ _SHADERS['linear']['fragment'],
+ attrib0='position')
+
+ _logProgram = Program(_SHADERS['log']['vertex'],
+ _SHADERS['log']['fragment'],
+ attrib0='position')
+
+ def __init__(self, data, origin, scale, alpha):
+ """Create a 2D RGB(A) image from data
+
+ :param data: The 2D image data array to display
+ :type data: numpy.ndarray with 3 dimensions
+ (dtype=numpy.uint8 or numpy.float32)
+ :param origin: (x, y) coordinates of the origin of the data array
+ :type origin: 2-tuple of floats.
+ :param scale: (sx, sy) scale factors of the data array.
+ This is the size of a data pixel in plot data space.
+ :type scale: 2-tuple of floats.
+ :param float alpha: Opacity from 0 (transparent) to 1 (opaque)
+ """
+ assert data.dtype in self._SUPPORTED_DTYPES
+ super(GLPlotRGBAImage, self).__init__(data, origin, scale)
+ self._texture = None
+ self._textureIsDirty = False
+ self._alpha = numpy.clip(alpha, 0., 1.)
+
+ @property
+ def alpha(self):
+ return self._alpha
+
+ def discard(self):
+ if self._texture is not None:
+ self._texture.discard()
+ self._texture = None
+ self._textureIsDirty = False
+
+ def updateData(self, data):
+ assert data.dtype in self._SUPPORTED_DTYPES
+ oldData = self.data
+ self.data = data
+
+ if self._texture is not None:
+ if self.data.shape != oldData.shape:
+ self.discard()
+ else:
+ self._textureIsDirty = True
+
+ def prepare(self):
+ if self._texture is None:
+ format_ = gl.GL_RGBA if self.data.shape[2] == 4 else gl.GL_RGB
+
+ self._texture = Image(format_,
+ self.data,
+ format_=format_,
+ texUnit=self._DATA_TEX_UNIT)
+ elif self._textureIsDirty:
+ self._textureIsDirty = False
+
+ # We should check that internal format is the same
+ format_ = gl.GL_RGBA if self.data.shape[2] == 4 else gl.GL_RGB
+ self._texture.updateAll(format_=format_, data=self.data)
+
+ def _renderLinear(self, matrix):
+ self.prepare()
+
+ prog = self._linearProgram
+ prog.use()
+
+ gl.glUniform1i(prog.uniforms['tex'], self._DATA_TEX_UNIT)
+
+ mat = matrix * mat4Translate(*self.origin) * mat4Scale(*self.scale)
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, mat)
+
+ gl.glUniform1f(prog.uniforms['alpha'], self.alpha)
+
+ self._texture.render(prog.attributes['position'],
+ prog.attributes['texCoords'],
+ self._DATA_TEX_UNIT)
+
+ def _renderLog(self, matrix, isXLog, isYLog):
+ self.prepare()
+
+ prog = self._logProgram
+ prog.use()
+
+ ox, oy = self.origin
+
+ gl.glUniform1i(prog.uniforms['tex'], self._DATA_TEX_UNIT)
+
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix)
+ mat = mat4Translate(ox, oy) * mat4Scale(*self.scale)
+ gl.glUniformMatrix4fv(prog.uniforms['matOffset'], 1, gl.GL_TRUE, mat)
+
+ gl.glUniform2i(prog.uniforms['isLog'], isXLog, isYLog)
+
+ gl.glUniform1f(prog.uniforms['alpha'], self.alpha)
+
+ ex = ox + self.scale[0] * self.data.shape[1]
+ ey = oy + self.scale[1] * self.data.shape[0]
+
+ xOneOverRange = 1. / (ex - ox)
+ yOneOverRange = 1. / (ey - oy)
+ gl.glUniform2f(prog.uniforms['bounds.originOverRange'],
+ ox * xOneOverRange, oy * yOneOverRange)
+ gl.glUniform2f(prog.uniforms['bounds.oneOverRange'],
+ xOneOverRange, yOneOverRange)
+
+ try:
+ tiles = self._texture.tiles
+ except AttributeError:
+ raise RuntimeError("No texture, discard has already been called")
+ if len(tiles) > 1:
+ raise NotImplementedError(
+ "Image over multiple textures not supported with log scale")
+
+ texture, vertices, info = tiles[0]
+
+ texture.bind(self._DATA_TEX_UNIT)
+
+ posAttrib = prog.attributes['position']
+ stride = vertices.shape[-1] * vertices.itemsize
+ gl.glEnableVertexAttribArray(posAttrib)
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ stride, vertices)
+
+ gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(vertices))
+
+ def render(self, matrix, isXLog, isYLog):
+ if any((isXLog, isYLog)):
+ self._renderLog(matrix, isXLog, isYLog)
+ else:
+ self._renderLinear(matrix)
diff --git a/silx/gui/plot/backends/glutils/GLSupport.py b/silx/gui/plot/backends/glutils/GLSupport.py
new file mode 100644
index 0000000..3f473be
--- /dev/null
+++ b/silx/gui/plot/backends/glutils/GLSupport.py
@@ -0,0 +1,192 @@
+# 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.
+#
+# ############################################################################*/
+"""
+This module provides convenient classes and functions for OpenGL rendering.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/04/2017"
+
+
+import numpy
+
+from ...._glutils import gl
+
+
+def buildFillMaskIndices(nIndices):
+ if nIndices <= numpy.iinfo(numpy.uint16).max + 1:
+ dtype = numpy.uint16
+ else:
+ dtype = numpy.uint32
+
+ lastIndex = nIndices - 1
+ splitIndex = lastIndex // 2 + 1
+ indices = numpy.empty(nIndices, dtype=dtype)
+ indices[::2] = numpy.arange(0, splitIndex, step=1, dtype=dtype)
+ indices[1::2] = numpy.arange(lastIndex, splitIndex - 1, step=-1,
+ dtype=dtype)
+ return indices
+
+
+class Shape2D(object):
+ _NO_HATCH = 0
+ _HATCH_STEP = 20
+
+ def __init__(self, points, fill='solid', stroke=True,
+ fillColor=(0., 0., 0., 1.), strokeColor=(0., 0., 0., 1.),
+ strokeClosed=True):
+ self.vertices = numpy.array(points, dtype=numpy.float32, copy=False)
+ self.strokeClosed = strokeClosed
+
+ self._indices = buildFillMaskIndices(len(self.vertices))
+
+ tVertex = numpy.transpose(self.vertices)
+ xMin, xMax = min(tVertex[0]), max(tVertex[0])
+ yMin, yMax = min(tVertex[1]), max(tVertex[1])
+ self.bboxVertices = numpy.array(((xMin, yMin), (xMin, yMax),
+ (xMax, yMin), (xMax, yMax)),
+ dtype=numpy.float32)
+ self._xMin, self._xMax = xMin, xMax
+ self._yMin, self._yMax = yMin, yMax
+
+ self.fill = fill
+ self.fillColor = fillColor
+ self.stroke = stroke
+ self.strokeColor = strokeColor
+
+ @property
+ def xMin(self):
+ return self._xMin
+
+ @property
+ def xMax(self):
+ return self._xMax
+
+ @property
+ def yMin(self):
+ return self._yMin
+
+ @property
+ def yMax(self):
+ return self._yMax
+
+ def prepareFillMask(self, posAttrib):
+ gl.glEnableVertexAttribArray(posAttrib)
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0, self.vertices)
+
+ gl.glEnable(gl.GL_STENCIL_TEST)
+ gl.glStencilMask(1)
+ gl.glStencilFunc(gl.GL_ALWAYS, 1, 1)
+ gl.glStencilOp(gl.GL_INVERT, gl.GL_INVERT, gl.GL_INVERT)
+ gl.glColorMask(gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE)
+ gl.glDepthMask(gl.GL_FALSE)
+
+ gl.glDrawElements(gl.GL_TRIANGLE_STRIP, len(self._indices),
+ gl.GL_UNSIGNED_SHORT, self._indices)
+
+ gl.glStencilFunc(gl.GL_EQUAL, 1, 1)
+ # Reset stencil while drawing
+ gl.glStencilOp(gl.GL_ZERO, gl.GL_ZERO, gl.GL_ZERO)
+ gl.glColorMask(gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE)
+ gl.glDepthMask(gl.GL_TRUE)
+
+ def renderFill(self, posAttrib):
+ self.prepareFillMask(posAttrib)
+
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0, self.bboxVertices)
+ gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(self.bboxVertices))
+
+ gl.glDisable(gl.GL_STENCIL_TEST)
+
+ def renderStroke(self, posAttrib):
+ gl.glEnableVertexAttribArray(posAttrib)
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0, self.vertices)
+ gl.glLineWidth(1)
+ drawMode = gl.GL_LINE_LOOP if self.strokeClosed else gl.GL_LINE_STRIP
+ gl.glDrawArrays(drawMode, 0, len(self.vertices))
+
+ def render(self, posAttrib, colorUnif, hatchStepUnif):
+ assert self.fill in ['hatch', 'solid', None]
+ if self.fill is not None:
+ gl.glUniform4f(colorUnif, *self.fillColor)
+ step = self._HATCH_STEP if self.fill == 'hatch' else self._NO_HATCH
+ gl.glUniform1i(hatchStepUnif, step)
+ self.renderFill(posAttrib)
+
+ if self.stroke:
+ gl.glUniform4f(colorUnif, *self.strokeColor)
+ gl.glUniform1i(hatchStepUnif, self._NO_HATCH)
+ self.renderStroke(posAttrib)
+
+
+# matrix ######################################################################
+
+def mat4Ortho(left, right, bottom, top, near, far):
+ """Orthographic projection matrix (row-major)"""
+ return numpy.matrix((
+ (2./(right - left), 0., 0., -(right+left)/float(right-left)),
+ (0., 2./(top - bottom), 0., -(top+bottom)/float(top-bottom)),
+ (0., 0., -2./(far-near), -(far+near)/float(far-near)),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
+
+
+def mat4Translate(x=0., y=0., z=0.):
+ """Translation matrix (row-major)"""
+ return numpy.matrix((
+ (1., 0., 0., x),
+ (0., 1., 0., y),
+ (0., 0., 1., z),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
+
+
+def mat4Scale(sx=1., sy=1., sz=1.):
+ """Scale matrix (row-major)"""
+ return numpy.matrix((
+ (sx, 0., 0., 0.),
+ (0., sy, 0., 0.),
+ (0., 0., sz, 0.),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
+
+
+def mat4Identity():
+ """Identity matrix"""
+ return numpy.matrix((
+ (1., 0., 0., 0.),
+ (0., 1., 0., 0.),
+ (0., 0., 1., 0.),
+ (0., 0., 0., 1.)), dtype=numpy.float32)
diff --git a/silx/gui/plot/backends/glutils/GLText.py b/silx/gui/plot/backends/glutils/GLText.py
new file mode 100644
index 0000000..495882c
--- /dev/null
+++ b/silx/gui/plot/backends/glutils/GLText.py
@@ -0,0 +1,222 @@
+# 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.
+#
+# ############################################################################*/
+"""
+This module provides minimalistic text support for OpenGL.
+It provides Latin-1 (ISO8859-1) characters for one monospace font at one size.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/04/2017"
+
+
+import numpy
+
+from ...._glutils import font, gl, getGLContext, Program, Texture
+from .GLSupport import mat4Translate
+
+
+# TODO: Font should be configurable by the main program: using mpl.rcParams?
+
+
+# Text2D ######################################################################
+
+LEFT, CENTER, RIGHT = 'left', 'center', 'right'
+TOP, BASELINE, BOTTOM = 'top', 'baseline', 'bottom'
+ROTATE_90, ROTATE_180, ROTATE_270 = 90, 180, 270
+
+
+class Text2D(object):
+
+ _SHADERS = {
+ 'vertex': """
+ #version 120
+
+ attribute vec2 position;
+ attribute vec2 texCoords;
+ uniform mat4 matrix;
+
+ varying vec2 vCoords;
+
+ void main(void) {
+ gl_Position = matrix * vec4(position, 0.0, 1.0);
+ vCoords = texCoords;
+ }
+ """,
+ 'fragment': """
+ #version 120
+
+ uniform sampler2D texText;
+ uniform vec4 color;
+ uniform vec4 bgColor;
+
+ varying vec2 vCoords;
+
+ void main(void) {
+ gl_FragColor = mix(bgColor, color, texture2D(texText, vCoords).r);
+ }
+ """
+ }
+
+ _TEX_COORDS = numpy.array(((0., 0.), (1., 0.), (0., 1.), (1., 1.)),
+ dtype=numpy.float32).ravel()
+
+ _program = Program(_SHADERS['vertex'],
+ _SHADERS['fragment'],
+ attrib0='position')
+
+ _textures = {}
+
+ _rasterTextCache = {}
+ """Internal cache storing already rasterized text"""
+ # TODO limit cache size and discard least recent used
+
+ def __init__(self, text, x=0, y=0,
+ color=(0., 0., 0., 1.),
+ bgColor=None,
+ align=LEFT, valign=BASELINE,
+ rotate=0):
+ self._vertices = None
+ self._text = text
+ self.x = x
+ self.y = y
+ self.color = color
+ self.bgColor = bgColor
+
+ if align not in (LEFT, CENTER, RIGHT):
+ raise ValueError(
+ "Horizontal alignment not supported: {0}".format(align))
+ self._align = align
+
+ if valign not in (TOP, CENTER, BASELINE, BOTTOM):
+ raise ValueError(
+ "Vertical alignment not supported: {0}".format(valign))
+ self._valign = valign
+
+ self._rotate = numpy.radians(rotate)
+
+ @classmethod
+ def _getTexture(cls, text):
+ key = getGLContext(), text
+ if key not in cls._textures:
+ image, offset = font.rasterText(text,
+ font.getDefaultFontFamily())
+ cls._textures[key] = (Texture(gl.GL_RED,
+ data=image,
+ minFilter=gl.GL_NEAREST,
+ magFilter=gl.GL_NEAREST,
+ wrap=(gl.GL_CLAMP_TO_EDGE,
+ gl.GL_CLAMP_TO_EDGE)),
+ offset)
+
+ return cls._textures[key]
+
+ @property
+ def text(self):
+ return self._text
+
+ @property
+ def size(self): # TODO very poor implementation
+ image, offset = font.rasterText(self.text,
+ font.getDefaultFontFamily())
+ return image.shape[1], image.shape[0]
+
+ def getVertices(self, offset, shape):
+ height, width = shape
+
+ if self._align == LEFT:
+ xOrig = 0
+ elif self._align == RIGHT:
+ xOrig = - width
+ else: # CENTER
+ xOrig = - width // 2
+
+ if self._valign == BASELINE:
+ yOrig = - offset
+ elif self._valign == TOP:
+ yOrig = 0
+ elif self._valign == BOTTOM:
+ yOrig = - height
+ else: # CENTER
+ yOrig = - height // 2
+
+ vertices = numpy.array((
+ (xOrig, yOrig),
+ (xOrig + width, yOrig),
+ (xOrig, yOrig + height),
+ (xOrig + width, yOrig + height)), dtype=numpy.float32)
+
+ cos, sin = numpy.cos(self._rotate), numpy.sin(self._rotate)
+ vertices = numpy.ascontiguousarray(numpy.transpose(numpy.array((
+ cos * vertices[:, 0] - sin * vertices[:, 1],
+ sin * vertices[:, 0] + cos * vertices[:, 1]),
+ dtype=numpy.float32)))
+
+ return vertices
+
+ def render(self, matrix):
+ if not self.text:
+ return
+
+ prog = self._program
+ prog.use()
+
+ texUnit = 0
+ texture, offset = self._getTexture(self.text)
+
+ gl.glUniform1i(prog.uniforms['texText'], texUnit)
+
+ gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE,
+ matrix * mat4Translate(int(self.x), int(self.y)))
+
+ gl.glUniform4f(prog.uniforms['color'], *self.color)
+ if self.bgColor is not None:
+ bgColor = self.bgColor
+ else:
+ bgColor = self.color[0], self.color[1], self.color[2], 0.
+ gl.glUniform4f(prog.uniforms['bgColor'], *bgColor)
+
+ vertices = self.getVertices(offset, texture.shape)
+
+ posAttrib = prog.attributes['position']
+ gl.glEnableVertexAttribArray(posAttrib)
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0,
+ vertices)
+
+ texAttrib = prog.attributes['texCoords']
+ gl.glEnableVertexAttribArray(texAttrib)
+ gl.glVertexAttribPointer(texAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ 0,
+ self._TEX_COORDS)
+
+ with texture:
+ gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, 4)
diff --git a/silx/gui/plot/backends/glutils/GLTexture.py b/silx/gui/plot/backends/glutils/GLTexture.py
new file mode 100644
index 0000000..25dd9f1
--- /dev/null
+++ b/silx/gui/plot/backends/glutils/GLTexture.py
@@ -0,0 +1,239 @@
+# 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.
+#
+# ############################################################################*/
+"""This module provides classes wrapping OpenGL texture."""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/04/2017"
+
+
+from ctypes import c_void_p
+import logging
+
+import numpy
+
+from ...._glutils import gl, Texture, numpyToGLType
+
+
+_logger = logging.getLogger(__name__)
+
+
+def _checkTexture2D(internalFormat, shape,
+ format_=None, type_=gl.GL_FLOAT, border=0):
+ """Check if texture size with provided parameters is supported
+
+ :rtype: bool
+ """
+ height, width = shape
+ gl.glTexImage2D(gl.GL_PROXY_TEXTURE_2D, 0, internalFormat,
+ width, height, border,
+ format_ or internalFormat,
+ type_, c_void_p(0))
+ width = gl.glGetTexLevelParameteriv(
+ gl.GL_PROXY_TEXTURE_2D, 0, gl.GL_TEXTURE_WIDTH)
+ return bool(width)
+
+
+MIN_TEXTURE_SIZE = 64
+
+
+def _getMaxSquareTexture2DSize(internalFormat=gl.GL_RGBA,
+ format_=None,
+ type_=gl.GL_FLOAT,
+ border=0):
+ """Returns a supported size for a corresponding square texture
+
+ :returns: GL_MAX_TEXTURE_SIZE or a smaller supported size (not optimal)
+ :rtype: int
+ """
+ # Is this useful?
+ maxTexSize = gl.glGetIntegerv(gl.GL_MAX_TEXTURE_SIZE)
+ while maxTexSize > MIN_TEXTURE_SIZE and \
+ not _checkTexture2D(internalFormat, (maxTexSize, maxTexSize),
+ format_, type_, border):
+ maxTexSize //= 2
+ return max(MIN_TEXTURE_SIZE, maxTexSize)
+
+
+class Image(object):
+ """Image of any size eventually using multiple textures or larger texture
+ """
+
+ _WRAP = (gl.GL_CLAMP_TO_EDGE, gl.GL_CLAMP_TO_EDGE)
+ _MIN_FILTER = gl.GL_NEAREST
+ _MAG_FILTER = gl.GL_NEAREST
+
+ def __init__(self, internalFormat, data, format_=None, texUnit=0):
+ self.internalFormat = internalFormat
+ self.height, self.width = data.shape[0:2]
+ type_ = numpyToGLType(data.dtype)
+
+ if _checkTexture2D(internalFormat, data.shape[0:2], format_, type_):
+ texture = Texture(internalFormat,
+ data,
+ format_,
+ texUnit=texUnit,
+ minFilter=self._MIN_FILTER,
+ magFilter=self._MAG_FILTER,
+ wrap=self._WRAP)
+ vertices = numpy.array((
+ (0., 0., 0., 0.),
+ (self.width, 0., 1., 0.),
+ (0., self.height, 0., 1.),
+ (self.width, self.height, 1., 1.)), dtype=numpy.float32)
+ self.tiles = ((texture, vertices,
+ {'xOrigData': 0, 'yOrigData': 0,
+ 'wData': self.width, 'hData': self.height}),)
+
+ else:
+ # Handle dimension too large: make tiles
+ maxTexSize = _getMaxSquareTexture2DSize(internalFormat,
+ format_, type_)
+
+ nCols = (self.width+maxTexSize-1) // maxTexSize
+ colWidths = [self.width // nCols] * nCols
+ colWidths[-1] += self.width % nCols
+
+ nRows = (self.height+maxTexSize-1) // maxTexSize
+ rowHeights = [self.height//nRows] * nRows
+ rowHeights[-1] += self.height % nRows
+
+ tiles = []
+ yOrig = 0
+ for hData in rowHeights:
+ xOrig = 0
+ for wData in colWidths:
+ if (hData < MIN_TEXTURE_SIZE or wData < MIN_TEXTURE_SIZE) \
+ and not _checkTexture2D(internalFormat,
+ (hData, wData),
+ format_,
+ type_):
+ # Ensure texture size is at least MIN_TEXTURE_SIZE
+ tH = max(hData, MIN_TEXTURE_SIZE)
+ tW = max(wData, MIN_TEXTURE_SIZE)
+
+ uMax, vMax = float(wData)/tW, float(hData)/tH
+
+ # TODO issue with type_ and alignment
+ texture = Texture(internalFormat,
+ data=None,
+ format_=format_,
+ shape=(tH, tW),
+ texUnit=texUnit,
+ minFilter=self._MIN_FILTER,
+ magFilter=self._MAG_FILTER,
+ wrap=self._WRAP)
+ # TODO handle unpack
+ texture.update(format_,
+ data[yOrig:yOrig+hData,
+ xOrig:xOrig+wData])
+ # texture.update(format_, type_, data,
+ # width=wData, height=hData,
+ # unpackRowLength=width,
+ # unpackSkipPixels=xOrig,
+ # unpackSkipRows=yOrig)
+ else:
+ uMax, vMax = 1, 1
+ # TODO issue with type_ and unpacking tiles
+ # TODO idea to handle unpack: use array strides
+ # As it is now, it will make a copy
+ texture = Texture(internalFormat,
+ data[yOrig:yOrig+hData,
+ xOrig:xOrig+wData],
+ format_,
+ shape=(hData, wData),
+ texUnit=texUnit,
+ minFilter=self._MIN_FILTER,
+ magFilter=self._MAG_FILTER,
+ wrap=self._WRAP)
+ # TODO
+ # unpackRowLength=width,
+ # unpackSkipPixels=xOrig,
+ # unpackSkipRows=yOrig)
+ vertices = numpy.array((
+ (xOrig, yOrig, 0., 0.),
+ (xOrig + wData, yOrig, uMax, 0.),
+ (xOrig, yOrig + hData, 0., vMax),
+ (xOrig + wData, yOrig + hData, uMax, vMax)),
+ dtype=numpy.float32)
+ tiles.append((texture, vertices,
+ {'xOrigData': xOrig, 'yOrigData': yOrig,
+ 'wData': wData, 'hData': hData}))
+ xOrig += wData
+ yOrig += hData
+ self.tiles = tuple(tiles)
+
+ def discard(self):
+ for texture, vertices, _ in self.tiles:
+ texture.discard()
+ del self.tiles
+
+ def updateAll(self, format_, data, texUnit=0):
+ if not hasattr(self, 'tiles'):
+ raise RuntimeError("No texture, discard has already been called")
+
+ assert data.shape[:2] == (self.height, self.width)
+ if len(self.tiles) == 1:
+ self.tiles[0][0].update(format_, data, texUnit=texUnit)
+ else:
+ for texture, _, info in self.tiles:
+ yOrig, xOrig = info['yOrigData'], info['xOrigData']
+ height, width = info['hData'], info['wData']
+ texture.update(format_,
+ data[yOrig:yOrig+height, xOrig:xOrig+width],
+ texUnit=texUnit)
+ # TODO check
+ # width=info['wData'], height=info['hData'],
+ # texUnit=texUnit, unpackAlign=unpackAlign,
+ # unpackRowLength=self.width,
+ # unpackSkipPixels=info['xOrigData'],
+ # unpackSkipRows=info['yOrigData'])
+
+ def render(self, posAttrib, texAttrib, texUnit=0):
+ try:
+ tiles = self.tiles
+ except AttributeError:
+ raise RuntimeError("No texture, discard has already been called")
+
+ for texture, vertices, _ in tiles:
+ texture.bind(texUnit)
+
+ stride = vertices.shape[-1] * vertices.itemsize
+ gl.glEnableVertexAttribArray(posAttrib)
+ gl.glVertexAttribPointer(posAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ stride, vertices)
+
+ texCoordsPtr = c_void_p(vertices.ctypes.data +
+ 2 * vertices.itemsize)
+ gl.glEnableVertexAttribArray(texAttrib)
+ gl.glVertexAttribPointer(texAttrib,
+ 2,
+ gl.GL_FLOAT,
+ gl.GL_FALSE,
+ stride, texCoordsPtr)
+ gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(vertices))
diff --git a/silx/gui/plot/backends/glutils/PlotImageFile.py b/silx/gui/plot/backends/glutils/PlotImageFile.py
new file mode 100644
index 0000000..e4ebe24
--- /dev/null
+++ b/silx/gui/plot/backends/glutils/PlotImageFile.py
@@ -0,0 +1,149 @@
+# 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.
+#
+# ############################################################################*/
+"""Function to save an image to a file."""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/04/2017"
+
+
+import base64
+import struct
+import sys
+import zlib
+
+
+# Image writer ################################################################
+
+def convertRGBDataToPNG(data):
+ """Convert a RGB bitmap to PNG.
+
+ It only supports RGB bitmap with one byte per channel stored as a 3D array.
+ See `Definitive Guide <http://www.libpng.org/pub/png/book/>`_ and
+ `Specification <http://www.libpng.org/pub/png/spec/1.2/>`_ for details.
+
+ :param data: A 3D array (h, w, rgb) storing an RGB image
+ :type data: numpy.ndarray of unsigned bytes
+ :returns: The PNG encoded data
+ :rtype: bytes
+ """
+ height, width = data.shape[0], data.shape[1]
+ depth = 8 # 8 bit per channel
+ colorType = 2 # 'truecolor' = RGB
+ interlace = 0 # No
+
+ IHDRdata = struct.pack(">ccccIIBBBBB", b'I', b'H', b'D', b'R',
+ width, height, depth, colorType,
+ 0, 0, interlace)
+
+ # Add filter 'None' before each scanline
+ preparedData = b'\x00' + b'\x00'.join(line.tostring() for line in data)
+ compressedData = zlib.compress(preparedData, 8)
+
+ IDATdata = struct.pack("cccc", b'I', b'D', b'A', b'T')
+ IDATdata += compressedData
+
+ return b''.join([
+ b'\x89PNG\r\n\x1a\n', # PNG signature
+ # IHDR chunk: Image Header
+ struct.pack(">I", 13), # length
+ IHDRdata,
+ struct.pack(">I", zlib.crc32(IHDRdata) & 0xffffffff), # CRC
+ # IDAT chunk: Payload
+ struct.pack(">I", len(compressedData)),
+ IDATdata,
+ struct.pack(">I", zlib.crc32(IDATdata) & 0xffffffff), # CRC
+ b'\x00\x00\x00\x00IEND\xaeB`\x82' # IEND chunk: footer
+ ])
+
+
+def saveImageToFile(data, fileNameOrObj, fileFormat):
+ """Save a RGB image to a file.
+
+ :param data: A 3D array (h, w, 3) storing an RGB image.
+ :type data: numpy.ndarray with of unsigned bytes.
+ :param fileNameOrObj: Filename or object to use to write the image.
+ :type fileNameOrObj: A str or a 'file-like' object with a 'write' method.
+ :param str fileFormat: The type of the file in: 'png', 'ppm', 'svg', 'tiff'.
+ """
+ assert len(data.shape) == 3
+ assert data.shape[2] == 3
+ assert fileFormat in ('png', 'ppm', 'svg', 'tiff')
+
+ if not hasattr(fileNameOrObj, 'write'):
+ if sys.version < "3.0":
+ fileObj = open(fileNameOrObj, "wb")
+ else:
+ fileObj = open(fileNameOrObj, "w", newline='')
+ else: # Use as a file-like object
+ fileObj = fileNameOrObj
+
+ if fileFormat == 'svg':
+ height, width = data.shape[:2]
+ base64Data = base64.b64encode(convertRGBDataToPNG(data))
+
+ fileObj.write(
+ '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n')
+ fileObj.write('<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"\n')
+ fileObj.write(
+ ' "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n')
+ fileObj.write('<svg xmlns:xlink="http://www.w3.org/1999/xlink"\n')
+ fileObj.write(' xmlns="http://www.w3.org/2000/svg"\n')
+ fileObj.write(' version="1.1"\n')
+ fileObj.write(' width="%d"\n' % width)
+ fileObj.write(' height="%d">\n' % height)
+ fileObj.write(' <image xlink:href="data:image/png;base64,')
+ fileObj.write(base64Data.decode('ascii'))
+ fileObj.write('"\n')
+ fileObj.write(' x="0"\n')
+ fileObj.write(' y="0"\n')
+ fileObj.write(' width="%d"\n' % width)
+ fileObj.write(' height="%d"\n' % height)
+ fileObj.write(' id="image" />\n')
+ fileObj.write('</svg>')
+
+ elif fileFormat == 'ppm':
+ height, width = data.shape[:2]
+
+ fileObj.write('P6\n')
+ fileObj.write('%d %d\n' % (width, height))
+ fileObj.write('255\n')
+ fileObj.write(data.tostring())
+
+ elif fileFormat == 'png':
+ fileObj.write(convertRGBDataToPNG(data))
+
+ elif fileFormat == 'tiff':
+ if fileObj == fileNameOrObj:
+ raise NotImplementedError(
+ 'Save TIFF to a file-like object not implemented')
+
+ from silx.third_party.TiffIO import TiffIO
+
+ tif = TiffIO(fileNameOrObj, mode='wb+')
+ tif.writeImage(data, info={'Title': 'PyMCA GL Snapshot'})
+
+ if fileObj != fileNameOrObj:
+ fileObj.close()
diff --git a/silx/gui/plot/backends/glutils/__init__.py b/silx/gui/plot/backends/glutils/__init__.py
new file mode 100644
index 0000000..771de39
--- /dev/null
+++ b/silx/gui/plot/backends/glutils/__init__.py
@@ -0,0 +1,44 @@
+# 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.
+#
+# ############################################################################*/
+"""This module provides convenient classes for the OpenGL rendering backend.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/04/2017"
+
+
+import logging
+
+
+_logger = logging.getLogger(__name__)
+
+
+from .GLPlotCurve import * # noqa
+from .GLPlotFrame import * # noqa
+from .GLPlotImage import * # noqa
+from .GLSupport import * # noqa
+from .GLText import * # noqa
+from .GLTexture import * # noqa