diff options
Diffstat (limited to 'silx/gui/plot/backends')
-rw-r--r-- | silx/gui/plot/backends/BackendBase.py | 474 | ||||
-rw-r--r-- | silx/gui/plot/backends/BackendMatplotlib.py | 821 | ||||
-rw-r--r-- | silx/gui/plot/backends/BackendOpenGL.py | 1631 | ||||
-rw-r--r-- | silx/gui/plot/backends/ModestImage.py | 174 | ||||
-rw-r--r-- | silx/gui/plot/backends/__init__.py | 29 | ||||
-rw-r--r-- | silx/gui/plot/backends/_matplotlib.py | 64 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLPlotCurve.py | 1317 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLPlotFrame.py | 1039 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLPlotImage.py | 707 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLSupport.py | 192 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLText.py | 222 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLTexture.py | 239 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/PlotImageFile.py | 149 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/__init__.py | 44 |
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 |