diff options
Diffstat (limited to 'src/silx/gui/plot/backends')
-rwxr-xr-x | src/silx/gui/plot/backends/BackendBase.py | 568 | ||||
-rwxr-xr-x | src/silx/gui/plot/backends/BackendMatplotlib.py | 1557 | ||||
-rwxr-xr-x | src/silx/gui/plot/backends/BackendOpenGL.py | 1420 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/__init__.py | 29 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLPlotCurve.py | 1380 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLPlotFrame.py | 1210 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLPlotImage.py | 756 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLPlotItem.py | 99 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLPlotTriangles.py | 197 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLSupport.py | 158 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLText.py | 287 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLTexture.py | 241 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/PlotImageFile.py | 153 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/__init__.py | 46 |
14 files changed, 8101 insertions, 0 deletions
diff --git a/src/silx/gui/plot/backends/BackendBase.py b/src/silx/gui/plot/backends/BackendBase.py new file mode 100755 index 0000000..1e86807 --- /dev/null +++ b/src/silx/gui/plot/backends/BackendBase.py @@ -0,0 +1,568 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2021 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__ = "21/12/2018" + +import weakref +from ... import qt + + +# 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 + self.__xAxisTimeSeries = False + self._xAxisTimeZone = None + # 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, + color, symbol, linewidth, linestyle, + yaxis, + xerror, yerror, + fill, alpha, symbolsize, baseline): + """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 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 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 object() + + def addImage(self, data, + origin, scale, + colormap, alpha): + """Add an image to the plot. + + :param numpy.ndarray data: (nrows, ncolumns) data or + (nrows, ncolumns, RGBA) ubyte array + :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 ~silx.gui.colors.Colormap colormap: Colormap object to use. + Ignored if data is RGB(A). + :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 object() + + def addTriangles(self, x, y, triangles, + color, alpha): + """Add a set of triangles. + + :param numpy.ndarray x: The data corresponding to the x axis + :param numpy.ndarray y: The data corresponding to the y axis + :param numpy.ndarray triangles: The indices to make triangles + as a (Ntriangle, 3) array + :param numpy.ndarray color: color(s) as (npoints, 4) array + :param float alpha: Opacity as a float in [0., 1.] + :returns: The triangles' unique identifier used by the backend + """ + return object() + + def addShape(self, x, y, shape, color, fill, overlay, + linestyle, linewidth, linebgcolor): + """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 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 str linestyle: Style of the line. + Only relevant for line markers where X or Y is None. + Value in: + + - ' ' no line + - '-' solid line + - '--' dashed line + - '-.' dash-dot line + - ':' dotted line + :param float linewidth: Width of the line. + Only relevant for line markers where X or Y is None. + :param str linebgcolor: Background color of the line, e.g., 'blue', 'b', + '#FF0000'. It is used to draw dotted line using a second color. + :returns: The handle used by the backend to univocally access the item + """ + return object() + + def addMarker(self, x, y, text, color, + symbol, linestyle, linewidth, constraint, yaxis): + """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 text: Text associated to the marker (or None for no text) + :param str color: Color to be used for instance 'blue', 'b', '#FF0000' + :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 str linestyle: Style of the line. + Only relevant for line markers where X or Y is None. + Value in: + + - ' ' no line + - '-' solid line + - '--' dashed line + - '-.' dash-dot line + - ':' dotted line + :param float linewidth: Width of the line. + Only relevant for line markers where X or Y is None. + :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. + :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 str yaxis: The Y axis this marker belongs to in: 'left', 'right' + :return: Handle used by the backend to univocally access the marker + """ + return object() + + # 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 getItemsFromBackToFront(self, condition=None): + """Returns the list of plot items order as rendered by the backend. + + This is the order used for rendering. + By default, it takes into account overlays, z value and order of addition of items, + but backends can override it. + + :param callable condition: + Callable taking an item as input and returning False for items to skip. + If None (default), no item is skipped. + :rtype: List[~silx.gui.plot.items.Item] + """ + # Sort items: Overlays first, then others + # and in each category ordered by z and then by order of addition + # as content keeps this order. + content = self._plot.getItems() + if condition is not None: + content = [item for item in content if condition(item)] + + return sorted( + content, + key=lambda i: ((1 if i.isOverlay() else 0), i.getZValue())) + + def pickItem(self, x, y, item): + """Return picked indices if any, or None. + + :param float x: The x pixel coord where to pick. + :param float y: The y pixel coord where to pick. + :param item: A backend item created with add* methods. + :return: None if item was not picked, else returns + picked indices information. + :rtype: Union[None,List] + """ + return None + + # 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 backend update and repaint.""" + self.replot() + + def replot(self): + """Redraw the plot.""" + with self._plot._paintContext(): + pass + + def saveGraph(self, fileName, fileFormat, dpi): + """Save the graph to a file (or a StringIO) + + At least "png", "svg" are supported. + + :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 getXAxisTimeZone(self): + """Returns tzinfo that is used if the X-Axis plots date-times. + + None means the datetimes are interpreted as local time. + + :rtype: datetime.tzinfo of None. + """ + return self._xAxisTimeZone + + def setXAxisTimeZone(self, tz): + """Sets tzinfo that is used if the X-Axis plots date-times. + + Use None to let the datetimes be interpreted as local time. + + :rtype: datetime.tzinfo of None. + """ + self._xAxisTimeZone = tz + + def isXAxisTimeSeries(self): + """Return True if the X-axis scale shows datetime objects. + + :rtype: bool + """ + return self.__xAxisTimeSeries + + def setXAxisTimeSeries(self, isTimeSeries): + """Set whether the X-axis is a time series + + :param bool flag: True to switch to time series, False for regular axis. + """ + self.__xAxisTimeSeries = bool(isTimeSeries) + + 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): + """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'). + :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() + + def setAxesMargins(self, left: float, top: float, right: float, bottom: float): + """Set the size of plot margins as ratios. + + Values are expected in [0., 1.] + + :param float left: + :param float top: + :param float right: + :param float bottom: + """ + pass + + def setForegroundColors(self, foregroundColor, gridColor): + """Set foreground and grid colors used to display this widget. + + :param List[float] foregroundColor: RGBA foreground color of the widget + :param List[float] gridColor: RGBA grid color of the data view + """ + pass + + def setBackgroundColors(self, backgroundColor, dataBackgroundColor): + """Set background colors used to display this widget. + + :param List[float] backgroundColor: RGBA background color of the widget + :param Union[Tuple[float],None] dataBackgroundColor: + RGBA background color of the data view + """ + pass diff --git a/src/silx/gui/plot/backends/BackendMatplotlib.py b/src/silx/gui/plot/backends/BackendMatplotlib.py new file mode 100755 index 0000000..7fe4ec0 --- /dev/null +++ b/src/silx/gui/plot/backends/BackendMatplotlib.py @@ -0,0 +1,1557 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2021 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__ = "21/12/2018" + + +import logging +import datetime as dt +from typing import Tuple +import numpy + +from pkg_resources import parse_version as _parse_version + + +_logger = logging.getLogger(__name__) + + +from ... import qt + +# First of all init matplotlib and set its backend +from ...utils.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.text import Text +from matplotlib.collections import PathCollection, LineCollection +from matplotlib.ticker import Formatter, ScalarFormatter, Locator +from matplotlib.tri import Triangulation +from matplotlib.collections import TriMesh +from matplotlib import path as mpath + +from . import BackendBase +from .. import items +from .._utils import FLOAT32_MINPOS +from .._utils.dtime_ticklayout import calcTicks, bestFormatString, timestamp + +_PATCH_LINESTYLE = { + "-": 'solid', + "--": 'dashed', + '-.': 'dashdot', + ':': 'dotted', + '': "solid", + None: "solid", +} +"""Patches do not uses the same matplotlib syntax""" + +_MARKER_PATHS = {} +"""Store cached extra marker paths""" + +_SPECIAL_MARKERS = { + 'tickleft': 0, + 'tickright': 1, + 'tickup': 2, + 'tickdown': 3, + 'caretleft': 4, + 'caretright': 5, + 'caretup': 6, + 'caretdown': 7, +} + + +def normalize_linestyle(linestyle): + """Normalize known old-style linestyle, else return the provided value.""" + return _PATCH_LINESTYLE.get(linestyle, linestyle) + +def get_path_from_symbol(symbol): + """Get the path representation of a symbol, else None if + it is not provided. + + :param str symbol: Symbol description used by silx + :rtype: Union[None,matplotlib.path.Path] + """ + if symbol == u'\u2665': + path = _MARKER_PATHS.get(symbol, None) + if path is not None: + return path + vertices = numpy.array([ + [0,-99], + [31,-73], [47,-55], [55,-46], + [63,-37], [94,-2], [94,33], + [94,69], [71,89], [47,89], + [24,89], [8,74], [0,58], + [-8,74], [-24,89], [-47,89], + [-71,89], [-94,69], [-94,33], + [-94,-2], [-63,-37], [-55,-46], + [-47,-55], [-31,-73], [0,-99], + [0,-99]]) + codes = [mpath.Path.CURVE4] * len(vertices) + codes[0] = mpath.Path.MOVETO + codes[-1] = mpath.Path.CLOSEPOLY + path = mpath.Path(vertices, codes) + _MARKER_PATHS[symbol] = path + return path + return None + +class NiceDateLocator(Locator): + """ + Matplotlib Locator that uses Nice Numbers algorithm (adapted to dates) + to find the tick locations. This results in the same number behaviour + as when using the silx Open GL backend. + + Expects the data to be posix timestampes (i.e. seconds since 1970) + """ + def __init__(self, numTicks=5, tz=None): + """ + :param numTicks: target number of ticks + :param datetime.tzinfo tz: optional time zone. None is local time. + """ + super(NiceDateLocator, self).__init__() + self.numTicks = numTicks + + self._spacing = None + self._unit = None + self.tz = tz + + @property + def spacing(self): + """ The current spacing. Will be updated when new tick value are made""" + return self._spacing + + @property + def unit(self): + """ The current DtUnit. Will be updated when new tick value are made""" + return self._unit + + def __call__(self): + """Return the locations of the ticks""" + vmin, vmax = self.axis.get_view_interval() + return self.tick_values(vmin, vmax) + + def tick_values(self, vmin, vmax): + """ Calculates tick values + """ + if vmax < vmin: + vmin, vmax = vmax, vmin + + # vmin and vmax should be timestamps (i.e. seconds since 1 Jan 1970) + dtMin = dt.datetime.fromtimestamp(vmin, tz=self.tz) + dtMax = dt.datetime.fromtimestamp(vmax, tz=self.tz) + dtTicks, self._spacing, self._unit = \ + calcTicks(dtMin, dtMax, self.numTicks) + + # Convert datetime back to time stamps. + ticks = [timestamp(dtTick) for dtTick in dtTicks] + return ticks + + +class NiceAutoDateFormatter(Formatter): + """ + Matplotlib FuncFormatter that is linked to a NiceDateLocator and gives the + best possible formats given the locators current spacing an date unit. + """ + + def __init__(self, locator, tz=None): + """ + :param niceDateLocator: a NiceDateLocator object + :param datetime.tzinfo tz: optional time zone. None is local time. + """ + super(NiceAutoDateFormatter, self).__init__() + self.locator = locator + self.tz = tz + + @property + def formatString(self): + if self.locator.spacing is None or self.locator.unit is None: + # Locator has no spacing or units yet. Return elaborate fmtString + return "Y-%m-%d %H:%M:%S" + else: + return bestFormatString(self.locator.spacing, self.locator.unit) + + def __call__(self, x, pos=None): + """Return the format for tick val *x* at position *pos* + Expects x to be a POSIX timestamp (seconds since 1 Jan 1970) + """ + dateTime = dt.datetime.fromtimestamp(x, tz=self.tz) + tickStr = dateTime.strftime(self.formatString) + return tickStr + + +class _PickableContainer(Container): + """Artists container with a :meth:`contains` method""" + + def __init__(self, *args, **kwargs): + Container.__init__(self, *args, **kwargs) + self.__zorder = None + + @property + def axes(self): + """Mimin Artist.axes""" + for child in self.get_children(): + if hasattr(child, 'axes'): + return child.axes + return None + + def draw(self, *args, **kwargs): + """artist-like draw to broadcast draw to children""" + for child in self.get_children(): + child.draw(*args, **kwargs) + + def get_zorder(self): + """Mimic Artist.get_zorder""" + return self.__zorder + + def set_zorder(self, z): + """Mimic Artist.set_zorder to broadcast to children""" + if z != self.__zorder: + self.__zorder = z + for child in self.get_children(): + child.set_zorder(z) + + def contains(self, mouseevent): + """Mimic Artist.contains, and call it on all children. + + :param mouseevent: + :return: Picking status and associated information as a dict + :rtype: (bool,dict) + """ + # Goes through children from front to back and return first picked one. + for child in reversed(self.get_children()): + picked, info = child.contains(mouseevent) + if picked: + return picked, info + return False, {} + + +class _TextWithOffset(Text): + """Text object which can be displayed at a specific position + of the plot, but with a pixel offset""" + + def __init__(self, *args, **kwargs): + Text.__init__(self, *args, **kwargs) + self.pixel_offset = (0, 0) + self.__cache = None + + def draw(self, renderer): + self.__cache = None + return Text.draw(self, renderer) + + def __get_xy(self): + if self.__cache is not None: + return self.__cache + + align = self.get_horizontalalignment() + if align == "left": + xoffset = self.pixel_offset[0] + elif align == "right": + xoffset = -self.pixel_offset[0] + else: + xoffset = 0 + + align = self.get_verticalalignment() + if align == "top": + yoffset = -self.pixel_offset[1] + elif align == "bottom": + yoffset = self.pixel_offset[1] + else: + yoffset = 0 + + trans = self.get_transform() + x = super(_TextWithOffset, self).convert_xunits(self._x) + y = super(_TextWithOffset, self).convert_xunits(self._y) + pos = x, y + + try: + invtrans = trans.inverted() + except numpy.linalg.LinAlgError: + # Cannot inverse transform, fallback: pos without offset + self.__cache = None + return pos + + proj = trans.transform_point(pos) + proj = proj + numpy.array((xoffset, yoffset)) + pos = invtrans.transform_point(proj) + self.__cache = pos + return pos + + def convert_xunits(self, x): + """Return the pixel position of the annotated point.""" + return self.__get_xy()[0] + + def convert_yunits(self, y): + """Return the pixel position of the annotated point.""" + return self.__get_xy()[1] + + +class _MarkerContainer(_PickableContainer): + """Marker artists container supporting draw/remove and text position update + + :param artists: + Iterable with either one Line2D or a Line2D and a Text. + The use of an iterable if enforced by Container being + a subclass of tuple that defines a specific __new__. + :param x: X coordinate of the marker (None for horizontal lines) + :param y: Y coordinate of the marker (None for vertical lines) + """ + + def __init__(self, artists, symbol, x, y, yAxis): + self.line = artists[0] + self.text = artists[1] if len(artists) > 1 else None + self.symbol = symbol + self.x = x + self.y = y + self.yAxis = yAxis + + _PickableContainer.__init__(self, artists) + + def draw(self, *args, **kwargs): + """artist-like draw to broadcast draw to line and text""" + self.line.draw(*args, **kwargs) + if self.text is not None: + self.text.draw(*args, **kwargs) + + def updateMarkerText(self, xmin, xmax, ymin, ymax, yinverted): + """Update marker text position and visibility according to plot limits + + :param xmin: X axis lower limit + :param xmax: X axis upper limit + :param ymin: Y axis lower limit + :param ymax: Y axis upper limit + :param yinverted: True if the y axis is inverted + """ + if self.text is not None: + visible = ((self.x is None or xmin <= self.x <= xmax) and + (self.y is None or ymin <= self.y <= ymax)) + self.text.set_visible(visible) + + if self.x is not None and self.y is not None: + if self.symbol is None: + valign = 'baseline' + else: + if yinverted: + valign = 'bottom' + else: + valign = 'top' + self.text.set_verticalalignment(valign) + + elif self.y is None: # vertical line + # Always display it on top + center = (ymax + ymin) * 0.5 + pos = (ymax - ymin) * 0.5 * 0.99 + if yinverted: + pos = -pos + self.text.set_y(center + pos) + + elif self.x is None: # Horizontal line + delta = abs(xmax - xmin) + if xmin > xmax: + xmax = xmin + xmax -= 0.005 * delta + self.text.set_x(xmax) + + def contains(self, mouseevent): + """Mimic Artist.contains, and call it on the line Artist. + + :param mouseevent: + :return: Picking status and associated information as a dict + :rtype: (bool,dict) + """ + return self.line.contains(mouseevent) + + +class _DoubleColoredLinePatch(matplotlib.patches.Patch): + """Matplotlib patch to display any patch using double color.""" + + def __init__(self, patch): + super(_DoubleColoredLinePatch, self).__init__() + self.__patch = patch + self.linebgcolor = None + + def __getattr__(self, name): + return getattr(self.__patch, name) + + def draw(self, renderer): + oldLineStype = self.__patch.get_linestyle() + if self.linebgcolor is not None and oldLineStype != "solid": + oldLineColor = self.__patch.get_edgecolor() + oldHatch = self.__patch.get_hatch() + self.__patch.set_linestyle("solid") + self.__patch.set_edgecolor(self.linebgcolor) + self.__patch.set_hatch(None) + self.__patch.draw(renderer) + self.__patch.set_linestyle(oldLineStype) + self.__patch.set_edgecolor(oldLineColor) + self.__patch.set_hatch(oldHatch) + self.__patch.draw(renderer) + + def set_transform(self, transform): + self.__patch.set_transform(transform) + + def get_path(self): + return self.__patch.get_path() + + def contains(self, mouseevent, radius=None): + return self.__patch.contains(mouseevent, radius) + + def contains_point(self, point, radius=None): + return self.__patch.contains_point(point, radius) + + +class Image(AxesImage): + """An AxesImage with a fast path for uint8 RGBA images. + + :param List[float] silx_origin: (ox, oy) Offset of the image. + :param List[float] silx_scale: (sx, sy) Scale of the image. + """ + + def __init__(self, *args, + silx_origin=(0., 0.), + silx_scale=(1., 1.), + **kwargs): + super().__init__(*args, **kwargs) + self.__silx_origin = silx_origin + self.__silx_scale = silx_scale + + def contains(self, mouseevent): + """Overridden to fill 'ind' with row and column""" + inside, info = super().contains(mouseevent) + if inside: + x, y = mouseevent.xdata, mouseevent.ydata + ox, oy = self.__silx_origin + sx, sy = self.__silx_scale + height, width = self.get_size() + column = numpy.clip(int((x - ox) / sx), 0, width - 1) + row = numpy.clip(int((y - oy) / sy), 0, height - 1) + info['ind'] = (row,), (column,) + return inside, info + + def set_data(self, A): + """Overridden to add a fast path for RGBA unit8 images""" + A = numpy.array(A, copy=False) + if A.ndim != 3 or A.shape[2] != 4 or A.dtype != numpy.uint8: + super(Image, self).set_data(A) + else: + # Call AxesImage.set_data with small data to set attributes + super(Image, self).set_data(numpy.zeros((2, 2, 4), dtype=A.dtype)) + self._A = A # Override stored data + + +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._axesDisplayed = True + self._matplotlibVersion = _parse_version(matplotlib.__version__) + + 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") + # Make sure background of Axes is displayed + self.ax2.patch.set_visible(False) + self.ax.patch.set_visible(True) + + # Set axis zorder=0.5 so grid is displayed at 0.5 + self.ax.set_axisbelow(True) + + # disable the use of offsets + try: + axes = [ + self.ax.get_yaxis().get_major_formatter(), + self.ax.get_xaxis().get_major_formatter(), + self.ax2.get_yaxis().get_major_formatter(), + self.ax2.get_xaxis().get_major_formatter(), + ] + for axis in axes: + axis.set_useOffset(False) + axis.set_scientific(False) + except: + _logger.warning('Cannot disabled axes offsets in %s ' + % matplotlib.__version__) + + self.ax2.set_autoscaley_on(True) + + # this works but the figure color is left + if self._matplotlibVersion < _parse_version('2'): + self.ax.set_axis_bgcolor('none') + else: + self.ax.set_facecolor('none') + self.fig.sca(self.ax) + + self._background = None + + self._colormaps = {} + + self._graphCursor = tuple() + + self._enableAxis('right', False) + self._isXAxisTimeSeries = False + + def getItemsFromBackToFront(self, condition=None): + """Order as BackendBase + take into account matplotlib Axes structure""" + def axesOrder(item): + if item.isOverlay(): + return 2 + elif isinstance(item, items.YAxisMixIn) and item.getYAxis() == 'right': + return 1 + else: + return 0 + + return sorted( + BackendBase.BackendBase.getItemsFromBackToFront( + self, condition=condition), + key=axesOrder) + + def _overlayItems(self): + """Generator of backend renderer for overlay items""" + for item in self._plot.getItems(): + if (item.isOverlay() and + item.isVisible() and + item._backendRenderer is not None): + yield item._backendRenderer + + def _hasOverlays(self): + """Returns whether there is an overlay layer or not. + + The overlay layers contains overlay items and the crosshair. + + :rtype: bool + """ + if self._graphCursor: + return True # There is the crosshair + + for item in self._overlayItems(): + return True # There is at least one overlay item + return False + + # Add methods + + def _getMarkerFromSymbol(self, symbol): + """Returns a marker that can be displayed by matplotlib. + + :param str symbol: A symbol description used by silx + :rtype: Union[str,int,matplotlib.path.Path] + """ + path = get_path_from_symbol(symbol) + if path is not None: + return path + num = _SPECIAL_MARKERS.get(symbol, None) + if num is not None: + return num + # This symbol must be supported by matplotlib + return symbol + + def addCurve(self, x, y, + color, symbol, linewidth, linestyle, + yaxis, + xerror, yerror, + fill, alpha, symbolsize, baseline): + for parameter in (x, y, color, symbol, linewidth, linestyle, + yaxis, 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.float64) / 255. + + if yaxis == "right": + axes = self.ax2 + self._enableAxis("right", True) + else: + axes = self.ax + + pickradius = 3 + + 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 + + # Nx1 error array deprecated in matplotlib >=3.1 (removed in 3.3) + if (isinstance(xerror, numpy.ndarray) and xerror.ndim == 2 and + xerror.shape[1] == 1): + xerror = numpy.ravel(xerror) + if (isinstance(yerror, numpy.ndarray) and yerror.ndim == 2 and + yerror.shape[1] == 1): + yerror = numpy.ravel(yerror) + + errorbars = axes.errorbar(x, y, + 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.float64]: + 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, + linestyle=linestyle, + color=actualColor[0], + linewidth=linewidth, + picker=True, + pickradius=pickradius, + marker=None) + artists += list(curveList) + + marker = self._getMarkerFromSymbol(symbol) + scatter = axes.scatter(x, y, + color=actualColor, + marker=marker, + picker=True, + pickradius=pickradius, + s=symbolsize**2) + artists.append(scatter) + + if fill: + if baseline is None: + _baseline = FLOAT32_MINPOS + else: + _baseline = baseline + artists.append(axes.fill_between( + x, _baseline, y, facecolor=actualColor[0], linestyle='')) + + else: # Curve + curveList = axes.plot(x, y, + linestyle=linestyle, + color=color, + linewidth=linewidth, + marker=symbol, + picker=True, + pickradius=pickradius, + markersize=symbolsize) + artists += list(curveList) + + if fill: + if baseline is None: + _baseline = FLOAT32_MINPOS + else: + _baseline = baseline + artists.append( + axes.fill_between(x, _baseline, y, facecolor=color)) + + for artist in artists: + if alpha < 1: + artist.set_alpha(alpha) + + return _PickableContainer(artists) + + def addImage(self, data, origin, scale, 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, origin, scale): + 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] + + # All image are shown as RGBA image + image = Image(self.ax, + interpolation='nearest', + picker=True, + origin='lower', + silx_origin=origin, + silx_scale=scale) + + 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] + + if data.ndim == 2: # Data image, convert to RGBA image + data = colormap.applyToData(data) + elif data.dtype == numpy.uint16: + # Normalize uint16 data to have a similar behavior as opengl backend + data = data.astype(numpy.float32) + data /= 65535 + + image.set_data(data) + self.ax.add_artist(image) + return image + + def addTriangles(self, x, y, triangles, color, alpha): + for parameter in (x, y, triangles, color, alpha): + assert parameter is not None + + color = numpy.array(color, copy=False) + assert color.ndim == 2 and len(color) == len(x) + + if color.dtype not in [numpy.float32, numpy.float64]: + color = color.astype(numpy.float32) / 255. + + collection = TriMesh( + Triangulation(x, y, triangles), + alpha=alpha, + pickradius=0) # 0 enables picking on filled triangle + collection.set_color(color) + self.ax.add_collection(collection) + + return collection + + def addShape(self, x, y, shape, color, fill, overlay, + linestyle, linewidth, linebgcolor): + if (linebgcolor is not None and + shape not in ('rectangle', 'polygon', 'polylines')): + _logger.warning( + 'linebgcolor not implemented for %s with matplotlib backend', + shape) + xView = numpy.array(x, copy=False) + yView = numpy.array(y, copy=False) + + linestyle = normalize_linestyle(linestyle) + + if shape == "line": + item = self.ax.plot(x, y, color=color, + linestyle=linestyle, linewidth=linewidth, + marker=None)[0] + + elif shape == "hline": + if hasattr(y, "__len__"): + y = y[-1] + item = self.ax.axhline(y, color=color, + linestyle=linestyle, linewidth=linewidth) + + elif shape == "vline": + if hasattr(x, "__len__"): + x = x[-1] + item = self.ax.axvline(x, color=color, + linestyle=linestyle, linewidth=linewidth) + + 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, + linestyle=linestyle, + linewidth=linewidth) + if fill: + item.set_hatch('.') + + if linestyle != "solid" and linebgcolor is not None: + item = _DoubleColoredLinePatch(item) + item.linebgcolor = linebgcolor + + self.ax.add_patch(item) + + elif shape in ('polygon', 'polylines'): + points = numpy.array((xView, yView)).T + if shape == 'polygon': + closed = True + else: # shape == 'polylines' + closed = numpy.all(numpy.equal(points[0], points[-1])) + item = Polygon(points, + closed=closed, + fill=False, + color=color, + linestyle=linestyle, + linewidth=linewidth) + if fill and shape == 'polygon': + item.set_hatch('/') + + if linestyle != "solid" and linebgcolor is not None: + item = _DoubleColoredLinePatch(item) + item.linebgcolor = linebgcolor + + self.ax.add_patch(item) + + else: + raise NotImplementedError("Unsupported item shape %s" % shape) + + if overlay: + item.set_animated(True) + + return item + + def addMarker(self, x, y, text, color, + symbol, linestyle, linewidth, constraint, yaxis): + textArtist = None + + xmin, xmax = self.getGraphXLimits() + ymin, ymax = self.getGraphYLimits(axis=yaxis) + + if yaxis == 'left': + ax = self.ax + elif yaxis == 'right': + ax = self.ax2 + else: + assert(False) + + marker = self._getMarkerFromSymbol(symbol) + if x is not None and y is not None: + line = ax.plot(x, y, + linestyle=" ", + color=color, + marker=marker, + markersize=10.)[-1] + + if text is not None: + textArtist = _TextWithOffset(x, y, text, + color=color, + horizontalalignment='left') + if symbol is not None: + textArtist.pixel_offset = 10, 3 + elif x is not None: + line = ax.axvline(x, + color=color, + linewidth=linewidth, + linestyle=linestyle) + if text is not None: + # Y position will be updated in updateMarkerText call + textArtist = _TextWithOffset(x, 1., text, + color=color, + horizontalalignment='left', + verticalalignment='top') + textArtist.pixel_offset = 5, 3 + elif y is not None: + line = ax.axhline(y, + color=color, + linewidth=linewidth, + linestyle=linestyle) + + if text is not None: + # X position will be updated in updateMarkerText call + textArtist = _TextWithOffset(1., y, text, + color=color, + horizontalalignment='right', + verticalalignment='top') + textArtist.pixel_offset = 5, 3 + else: + raise RuntimeError('A marker must at least have one coordinate') + + line.set_picker(True) + line.set_pickradius(5) + + # All markers are overlays + line.set_animated(True) + if textArtist is not None: + ax.add_artist(textArtist) + textArtist.set_animated(True) + + artists = [line] if textArtist is None else [line, textArtist] + container = _MarkerContainer(artists, symbol, x, y, yaxis) + container.updateMarkerText(xmin, xmax, ymin, ymax, self.isYAxisInverted()) + + return container + + def _updateMarkers(self): + xmin, xmax = self.ax.get_xbound() + ymin1, ymax1 = self.ax.get_ybound() + ymin2, ymax2 = self.ax2.get_ybound() + yinverted = self.isYAxisInverted() + for item in self._overlayItems(): + if isinstance(item, _MarkerContainer): + if item.yAxis == 'left': + item.updateMarkerText(xmin, xmax, ymin1, ymax1, yinverted) + else: + item.updateMarkerText(xmin, xmax, ymin2, ymax2, yinverted) + + # Remove methods + + def remove(self, item): + try: + item.remove() + except ValueError: + pass # Already removed e.g., in set[X|Y]AxisLogarithmic + + # 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: + 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. + """ + with self._plot._paintContext(): + self._replot() + + def _replot(self): + """Call from subclass :meth:`replot` to handle updates""" + # 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 _drawOverlays(self): + """Draw overlays if any.""" + def condition(item): + return (item.isVisible() and + item._backendRenderer is not None and + item.isOverlay()) + + for item in self.getItemsFromBackToFront(condition=condition): + if (isinstance(item, items.YAxisMixIn) and + item.getYAxis() == 'right'): + axes = self.ax2 + else: + axes = self.ax + axes.draw_artist(item._backendRenderer) + + for item in self._graphCursor: + self.ax.draw_artist(item) + + def updateZOrder(self): + """Reorder all items with z order from 0 to 1""" + items = self.getItemsFromBackToFront( + lambda item: item.isVisible() and item._backendRenderer is not None) + count = len(items) + for index, item in enumerate(items): + if item.getZValue() < 0.5: + # Make sure matplotlib z order is below the grid (with z=0.5) + zorder = 0.5 * index / count + else: # Make sure matplotlib z order is above the grid (> 0.5) + zorder = 1. + index / count + if zorder != item._backendRenderer.get_zorder(): + item._backendRenderer.set_zorder(zorder) + + def saveGraph(self, fileName, fileFormat, dpi): + self.updateZOrder() + + # 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)) + + self._updateMarkers() + + def getGraphXLimits(self): + if self._dirtyLimits and self.isKeepDataAspectRatio(): + self.ax.apply_aspect() + self.ax2.apply_aspect() + self._dirtyLimits = False + return self.ax.get_xbound() + + def setGraphXLimits(self, xmin, xmax): + self._dirtyLimits = True + self.ax.set_xlim(min(xmin, xmax), max(xmin, xmax)) + self._updateMarkers() + + 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.ax.apply_aspect() + self.ax2.apply_aspect() + self._dirtyLimits = False + + 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) + + self._updateMarkers() + + # Graph axes + + def setXAxisTimeZone(self, tz): + super(BackendMatplotlib, self).setXAxisTimeZone(tz) + + # Make new formatter and locator with the time zone. + self.setXAxisTimeSeries(self.isXAxisTimeSeries()) + + def isXAxisTimeSeries(self): + return self._isXAxisTimeSeries + + def setXAxisTimeSeries(self, isTimeSeries): + self._isXAxisTimeSeries = isTimeSeries + if self._isXAxisTimeSeries: + # We can't use a matplotlib.dates.DateFormatter because it expects + # the data to be in datetimes. Silx works internally with + # timestamps (floats). + locator = NiceDateLocator(tz=self.getXAxisTimeZone()) + self.ax.xaxis.set_major_locator(locator) + self.ax.xaxis.set_major_formatter( + NiceAutoDateFormatter(locator, tz=self.getXAxisTimeZone())) + else: + try: + scalarFormatter = ScalarFormatter(useOffset=False) + except: + _logger.warning('Cannot disabled axes offsets in %s ' % + matplotlib.__version__) + scalarFormatter = ScalarFormatter() + self.ax.xaxis.set_major_formatter(scalarFormatter) + + def setXAxisLogarithmic(self, flag): + # Workaround for matplotlib 2.1.0 when one tries to set an axis + # to log scale with both limits <= 0 + # In this case a draw with positive limits is needed first + if flag and self._matplotlibVersion >= _parse_version('2.1.0'): + xlim = self.ax.get_xlim() + if xlim[0] <= 0 and xlim[1] <= 0: + self.ax.set_xlim(1, 10) + self.draw() + + self.ax2.set_xscale('log' if flag else 'linear') + self.ax.set_xscale('log' if flag else 'linear') + + def setYAxisLogarithmic(self, flag): + # Workaround for matplotlib 2.0 issue with negative bounds + # before switching to log scale + if flag and self._matplotlibVersion >= _parse_version('2.0.0'): + redraw = False + for axis, dataRangeIndex in ((self.ax, 1), (self.ax2, 2)): + ylim = axis.get_ylim() + if ylim[0] <= 0 or ylim[1] <= 0: + dataRange = self._plot.getDataRange()[dataRangeIndex] + if dataRange is None: + dataRange = 1, 100 # Fallback + axis.set_ylim(*dataRange) + redraw = True + if redraw: + self.draw() + + 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() + self._updateMarkers() + + 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 _getDevicePixelRatio(self) -> float: + """Compatibility wrapper for devicePixelRatioF""" + return 1. + + def _mplToQtPosition(self, x: float, y: float) -> Tuple[float, float]: + """Convert matplotlib "display" space coord to Qt widget logical pixel + """ + ratio = self._getDevicePixelRatio() + # Convert from matplotlib origin (bottom) to Qt origin (top) + # and apply device pixel ratio + return x / ratio, (self.fig.get_window_extent().height - y) / ratio + + def _qtToMplPosition(self, x: float, y: float) -> Tuple[float, float]: + """Convert Qt widget logical pixel to matplotlib "display" space coord + """ + ratio = self._getDevicePixelRatio() + # Apply device pixel ration and + # convert from Qt origin (top) to matplotlib origin (bottom) + return x * ratio, self.fig.get_window_extent().height - (y * ratio) + + def dataToPixel(self, x, y, axis): + ax = self.ax2 if axis == "right" else self.ax + displayPos = ax.transData.transform_point((x, y)).transpose() + return self._mplToQtPosition(*displayPos) + + def pixelToData(self, x, y, axis): + ax = self.ax2 if axis == "right" else self.ax + displayPos = self._qtToMplPosition(x, y) + return tuple(ax.transData.inverted().transform_point(displayPos)) + + def getPlotBoundsInPixels(self): + bbox = self.ax.get_window_extent() + # Warning this is not returning int... + ratio = self._getDevicePixelRatio() + return tuple(int(value / ratio) for value in ( + bbox.xmin, + self.fig.get_window_extent().height - bbox.ymax, + bbox.width, + bbox.height)) + + def setAxesMargins(self, left: float, top: float, right: float, bottom: float): + width, height = 1. - left - right, 1. - top - bottom + position = left, bottom, width, height + + # Toggle display of axes and viewbox rect + isFrameOn = position != (0., 0., 1., 1.) + self.ax.set_frame_on(isFrameOn) + self.ax2.set_frame_on(isFrameOn) + + self.ax.set_position(position) + self.ax2.set_position(position) + + self._synchronizeBackgroundColors() + self._synchronizeForegroundColors() + self._plot._setDirtyPlot() + + def _synchronizeBackgroundColors(self): + backgroundColor = self._plot.getBackgroundColor().getRgbF() + + dataBackgroundColor = self._plot.getDataBackgroundColor() + if dataBackgroundColor.isValid(): + dataBackgroundColor = dataBackgroundColor.getRgbF() + else: + dataBackgroundColor = backgroundColor + + if self.ax.get_frame_on(): + self.fig.patch.set_facecolor(backgroundColor) + if self._matplotlibVersion < _parse_version('2'): + self.ax.set_axis_bgcolor(dataBackgroundColor) + else: + self.ax.set_facecolor(dataBackgroundColor) + else: + self.fig.patch.set_facecolor(dataBackgroundColor) + + def _synchronizeForegroundColors(self): + foregroundColor = self._plot.getForegroundColor().getRgbF() + + gridColor = self._plot.getGridColor() + if gridColor.isValid(): + gridColor = gridColor.getRgbF() + else: + gridColor = foregroundColor + + for axes in (self.ax, self.ax2): + if axes.get_frame_on(): + axes.spines['bottom'].set_color(foregroundColor) + axes.spines['top'].set_color(foregroundColor) + axes.spines['right'].set_color(foregroundColor) + axes.spines['left'].set_color(foregroundColor) + axes.tick_params(axis='x', colors=foregroundColor) + axes.tick_params(axis='y', colors=foregroundColor) + axes.yaxis.label.set_color(foregroundColor) + axes.xaxis.label.set_color(foregroundColor) + axes.title.set_color(foregroundColor) + + for line in axes.get_xgridlines(): + line.set_color(gridColor) + + for line in axes.get_ygridlines(): + line.set_color(gridColor) + # axes.grid().set_markeredgecolor(gridColor) + + def setBackgroundColors(self, backgroundColor, dataBackgroundColor): + self._synchronizeBackgroundColors() + + def setForegroundColors(self, foregroundColor, gridColor): + self._synchronizeForegroundColors() + + +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): + BackendMatplotlib.__init__(self, plot, parent) + FigureCanvasQTAgg.__init__(self, self.fig) + self.setParent(parent) + + self._limitsBeforeResize = None + + FigureCanvasQTAgg.setSizePolicy( + self, qt.QSizePolicy.Expanding, qt.QSizePolicy.Expanding) + FigureCanvasQTAgg.updateGeometry(self) + + # Make postRedisplay asynchronous using Qt signal + self._sigPostRedisplay.connect( + self.__deferredReplot, 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() + + def __deferredReplot(self): + # Since this is deferred, makes sure it is still needed + plot = self._plotRef() + if (plot is not None and + plot._getDirtyPlot() and + plot.getBackend() is self): + self.replot() + + def _getDevicePixelRatio(self) -> float: + """Compatibility wrapper for devicePixelRatioF""" + if hasattr(self, 'devicePixelRatioF'): + ratio = self.devicePixelRatioF() + else: # Qt < 5.6 compatibility + ratio = float(self.devicePixelRatio()) + # Safety net: avoid returning 0 + return ratio if ratio != 0. else 1. + + # Mouse event forwarding + + _MPL_TO_PLOT_BUTTONS = {1: 'left', 2: 'middle', 3: 'right'} + + def _onMousePress(self, event): + button = self._MPL_TO_PLOT_BUTTONS.get(event.button, None) + if button is not None: + x, y = self._mplToQtPosition(event.x, event.y) + self._plot.onMousePress(int(x), int(y), button) + + def _onMouseMove(self, event): + x, y = self._mplToQtPosition(event.x, event.y) + if self._graphCursor: + position = self._plot.pixelToData( + x, y, axis='left', check=True) + lineh, linev = self._graphCursor + if position is not None: + linev.set_visible(True) + linev.set_xdata((position[0], position[0])) + lineh.set_visible(True) + lineh.set_ydata((position[1], position[1])) + self._plot._setDirtyPlot(overlayOnly=True) + elif lineh.get_visible(): + lineh.set_visible(False) + linev.set_visible(False) + self._plot._setDirtyPlot(overlayOnly=True) + # onMouseMove must trigger replot if dirty flag is raised + + self._plot.onMouseMove(int(x), int(y)) + + def _onMouseRelease(self, event): + button = self._MPL_TO_PLOT_BUTTONS.get(event.button, None) + if button is not None: + x, y = self._mplToQtPosition(event.x, event.y) + self._plot.onMouseRelease(int(x), int(y), button) + + def _onMouseWheel(self, event): + x, y = self._mplToQtPosition(event.x, event.y) + self._plot.onMouseWheel(int(x), int(y), event.step) + + def leaveEvent(self, event): + """QWidget event handler""" + try: + plot = self._plot + except RuntimeError: + pass + else: + plot.onMouseLeaveWidget() + + # picking + + def pickItem(self, x, y, item): + xDisplay, yDisplay = self._qtToMplPosition(x, y) + mouseEvent = MouseEvent( + 'button_press_event', self, int(xDisplay), int(yDisplay)) + # Override axes and data position with the axes + mouseEvent.inaxes = item.axes + mouseEvent.xdata, mouseEvent.ydata = self.pixelToData( + x, y, axis='left' if item.axes is self.ax else 'right') + picked, info = item.contains(mouseEvent) + + if not picked: + return None + + elif isinstance(item, TriMesh): + # Convert selected triangle to data point indices + triangulation = item._triangulation + indices = triangulation.get_masked_triangles()[info['ind'][0]] + + # Sort picked triangle points by distance to mouse + # from furthest to closest to put closest point last + # This is to be somewhat consistent with last scatter point + # being the top one. + xdata, ydata = self.pixelToData(x, y, axis='left') + dists = ((triangulation.x[indices] - xdata) ** 2 + + (triangulation.y[indices] - ydata) ** 2) + return indices[numpy.flip(numpy.argsort(dists), axis=0)] + + else: # Returns indices if any + return info.get('ind', ()) + + # replot control + + def resizeEvent(self, event): + # Store current limits + self._limitsBeforeResize = ( + self.ax.get_xbound(), self.ax.get_ybound(), self.ax2.get_ybound()) + + FigureCanvasQTAgg.resizeEvent(self, event) + if self.isKeepDataAspectRatio() or self._hasOverlays(): + # This is needed with matplotlib 1.5.x and 2.0.x + self._plot._setDirtyPlot() + + def draw(self): + """Overload draw + + It performs a full redraw (including overlays) of the plot. + It also resets background and emit limits changed signal. + + This is directly called by matplotlib for widget resize. + """ + self.updateZOrder() + + # Starting with mpl 2.1.0, toggling autoscale raises a ValueError + # in some situations. See #1081, #1136, #1163, + if self._matplotlibVersion >= _parse_version("2.0.0"): + try: + FigureCanvasQTAgg.draw(self) + except ValueError as err: + _logger.debug( + "ValueError caught while calling FigureCanvasQTAgg.draw: " + "'%s'", err) + else: + FigureCanvasQTAgg.draw(self) + + if self._hasOverlays(): + # Save background + self._background = self.copy_from_bbox(self.fig.bbox) + else: + self._background = None # Reset background + + # Check if limits changed due to a resize of the widget + if self._limitsBeforeResize is not None: + xLimits, yLimits, yRightLimits = self._limitsBeforeResize + self._limitsBeforeResize = None + + if (xLimits != self.ax.get_xbound() or + yLimits != self.ax.get_ybound()): + self._updateMarkers() + + if xLimits != self.ax.get_xbound(): + self._plot.getXAxis()._emitLimitsChanged() + if yLimits != self.ax.get_ybound(): + self._plot.getYAxis(axis='left')._emitLimitsChanged() + if yRightLimits != self.ax2.get_ybound(): + self._plot.getYAxis(axis='right')._emitLimitsChanged() + + self._drawOverlays() + + def replot(self): + with self._plot._paintContext(): + BackendMatplotlib._replot(self) + + dirtyFlag = self._plot._getDirtyPlot() + + if dirtyFlag == 'overlay': + # Only redraw overlays using fast rendering path + if self._background is None: + self._background = self.copy_from_bbox(self.fig.bbox) + self.restore_region(self._background) + self._drawOverlays() + self.blit(self.fig.bbox) + + elif dirtyFlag: # Need full redraw + self.draw() + + # Workaround issue of rendering overlays with some matplotlib versions + if (_parse_version('1.5') <= self._matplotlibVersion < _parse_version('2.1') and + not hasattr(self, '_firstReplot')): + self._firstReplot = False + if self._hasOverlays(): + qt.QTimer.singleShot(0, self.draw) # Request async draw + + # cursor + + _QT_CURSORS = { + 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): + if cursor is None: + FigureCanvasQTAgg.unsetCursor(self) + else: + cursor = self._QT_CURSORS[cursor] + FigureCanvasQTAgg.setCursor(self, qt.QCursor(cursor)) diff --git a/src/silx/gui/plot/backends/BackendOpenGL.py b/src/silx/gui/plot/backends/BackendOpenGL.py new file mode 100755 index 0000000..f1a12af --- /dev/null +++ b/src/silx/gui/plot/backends/BackendOpenGL.py @@ -0,0 +1,1420 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2021 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/12/2018" + +import logging +import weakref + +import numpy + +from .. import items +from .._utils import FLOAT32_MINPOS +from . import BackendBase +from ... import colors +from ... import qt + +from ..._glutils import gl +from ... import _glutils as glu +from . import glutils +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 + +# Content ##################################################################### + +class _ShapeItem(dict): + def __init__(self, x, y, shape, color, fill, overlay, + linestyle, linewidth, linebgcolor): + super(_ShapeItem, self).__init__() + + if shape not in ('polygon', 'rectangle', 'line', + 'vline', 'hline', 'polylines'): + 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)) + + # Ignore fill for polylines to mimic matplotlib + fill = fill if shape != 'polylines' else False + + self.update({ + 'shape': shape, + 'color': colors.rgba(color), + 'fill': 'hatch' if fill else None, + 'x': x, + 'y': y, + 'linestyle': linestyle, + 'linewidth': linewidth, + 'linebgcolor': linebgcolor, + }) + + +class _MarkerItem(dict): + def __init__(self, x, y, text, color, + symbol, linestyle, linewidth, constraint, yaxis): + super(_MarkerItem, self).__init__() + + if symbol is None: + symbol = '+' + + # Apply constraint to provided position + isConstraint = (constraint is not None and + x is not None and y is not None) + if isConstraint: + x, y = constraint(x, y) + + self.update({ + 'x': x, + 'y': y, + 'text': text, + 'color': colors.rgba(color), + 'constraint': constraint if isConstraint else None, + 'symbol': symbol, + 'linestyle': linestyle, + 'linewidth': linewidth, + 'yaxis': yaxis, + }) + + +# 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); + gl_FragColor.a = 1.0; + } + """ + +# BackendOpenGL ############################################################### + + +class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): + """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. + """ + + def __init__(self, plot, parent=None, f=qt.Qt.WindowFlags()): + glu.OpenGLWidget.__init__(self, parent, + alphaBufferSize=8, + depthBufferSize=0, + stencilBufferSize=0, + version=(2, 1), + f=f) + BackendBase.BackendBase.__init__(self, plot, parent) + + self._backgroundColor = 1., 1., 1., 1. + self._dataBackgroundColor = 1., 1., 1., 1. + + self.matScreenProj = glutils.mat4Identity() + + self._progBase = glu.Program( + _baseVertShd, _baseFragShd, attrib0='position') + self._progTex = glu.Program( + _texVertShd, _texFragShd, attrib0='position') + self._plotFBOs = weakref.WeakKeyDictionary() + + self._keepDataAspectRatio = False + + self._crosshairCursor = None + self._mousePosInPixels = None + + self._glGarbageCollector = [] + + self._plotFrame = glutils.GLPlotFrame2D( + foregroundColor=(0., 0., 0., 1.), + gridColor=(.7, .7, .7, 1.), + marginRatios=(.15, .1, .1, .15)) + self._plotFrame.size = ( # Init size with size int + int(self.getDevicePixelRatio() * 640), + int(self.getDevicePixelRatio() * 480)) + + 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): + if event.button() not in self._MOUSE_BTNS: + return super(BackendOpenGL, self).mousePressEvent(event) + self._plot.onMousePress( + event.x(), event.y(), self._MOUSE_BTNS[event.button()]) + event.accept() + + def mouseMoveEvent(self, event): + qtPos = event.x(), event.y() + + previousMousePosInPixels = self._mousePosInPixels + if qtPos == self._mouseInPlotArea(*qtPos): + devicePixelRatio = self.getDevicePixelRatio() + devicePos = qtPos[0] * devicePixelRatio, qtPos[1] * devicePixelRatio + self._mousePosInPixels = devicePos # Mouse in plot area + else: + self._mousePosInPixels = None # Mouse outside plot area + + if (self._crosshairCursor is not None and + previousMousePosInPixels != self._mousePosInPixels): + # Avoid replot when cursor remains outside plot area + self._plot._setDirtyPlot(overlayOnly=True) + + self._plot.onMouseMove(*qtPos) + event.accept() + + def mouseReleaseEvent(self, event): + if event.button() not in self._MOUSE_BTNS: + return super(BackendOpenGL, self).mouseReleaseEvent(event) + self._plot.onMouseRelease( + event.x(), event.y(), self._MOUSE_BTNS[event.button()]) + event.accept() + + def wheelEvent(self, event): + delta = event.angleDelta().y() + angleInDegrees = delta / 8. + if qt.BINDING == "PySide6": + x, y = event.position().x(), event.position().y() + else: + x, y = event.x(), event.y() + self._plot.onMouseWheel(x, y, angleInDegrees) + event.accept() + + def leaveEvent(self, _): + self._plot.onMouseLeaveWidget() + + # OpenGLWidget API + + def initializeGL(self): + gl.testGL() + + gl.glClearStencil(0) + + gl.glEnable(gl.GL_BLEND) + # gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) + gl.glBlendFuncSeparate(gl.GL_SRC_ALPHA, + gl.GL_ONE_MINUS_SRC_ALPHA, + gl.GL_ONE, + gl.GL_ONE) + + # 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._renderOverlayGL() + + def _paintFBOGL(self): + context = glu.Context.getCurrent() + plotFBOTex = self._plotFBOs.get(context) + if (self._plot._getDirtyPlot() or self._plotFrame.isDirty or + plotFBOTex is None): + self._plotVertices = ( + # Vertex coordinates + numpy.array(((-1., -1.), (1., -1.), (-1., 1.), (1., 1.)), + dtype=numpy.float32), + # Texture coordinates + numpy.array(((0., 0.), (1., 0.), (0., 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.glClearColor(*self._backgroundColor) + 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, + glutils.mat4Identity().astype(numpy.float32)) + + gl.glEnableVertexAttribArray(self._progTex.attributes['position']) + gl.glVertexAttribPointer(self._progTex.attributes['position'], + 2, + gl.GL_FLOAT, + gl.GL_FALSE, + 0, + self._plotVertices[0]) + + gl.glEnableVertexAttribArray(self._progTex.attributes['texCoords']) + gl.glVertexAttribPointer(self._progTex.attributes['texCoords'], + 2, + gl.GL_FLOAT, + gl.GL_FALSE, + 0, + self._plotVertices[1]) + + with plotFBOTex.texture: + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(self._plotVertices[0])) + + self._renderOverlayGL() + + def paintGL(self): + plot = self._plotRef() + if plot is None: + return + + with plot._paintContext(): + with glu.Context.current(self.context()): + # Release OpenGL resources + for item in self._glGarbageCollector: + item.discard() + self._glGarbageCollector = [] + + gl.glClearColor(*self._backgroundColor) + gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_STENCIL_BUFFER_BIT) + + # Check if window is large enough + if self._plotFrame.plotSize <= (2, 2): + return + + # Sync plot frame with window + self._plotFrame.devicePixelRatio = self.getDevicePixelRatio() + # self._paintDirectGL() + self._paintFBOGL() + + def _renderItems(self, overlay=False): + """Render items according to :class:`PlotWidget` order + + Note: Scissor test should already be set. + + :param bool overlay: + False (the default) to render item that are not overlays. + True to render items that are overlays. + """ + # Values that are often used + plotWidth, plotHeight = self._plotFrame.plotSize + isXLog = self._plotFrame.xAxis.isLog + isYLog = self._plotFrame.yAxis.isLog + isYInverted = self._plotFrame.isYAxisInverted + + # Used by marker rendering + labels = [] + pixelOffset = 3 + + context = glutils.RenderContext( + isXLog=isXLog, isYLog=isYLog, dpi=self.getDotsPerInch()) + + for plotItem in self.getItemsFromBackToFront( + condition=lambda i: i.isVisible() and i.isOverlay() == overlay): + if plotItem._backendRenderer is None: + continue + + item = plotItem._backendRenderer + + if isinstance(item, glutils.GLPlotItem): # Render data items + gl.glViewport(self._plotFrame.margins.left, + self._plotFrame.margins.bottom, + plotWidth, plotHeight) + # Set matrix + if item.yaxis == 'right': + context.matrix = self._plotFrame.transformedDataY2ProjMat + else: + context.matrix = self._plotFrame.transformedDataProjMat + item.render(context) + + elif isinstance(item, _ShapeItem): # Render shape items + gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) + + if ((isXLog and numpy.min(item['x']) < FLOAT32_MINPOS) or + (isYLog and numpy.min(item['y']) < FLOAT32_MINPOS)): + # Ignore items <= 0. on log axes + continue + + if item['shape'] == 'hline': + width = self._plotFrame.size[0] + _, yPixel = self._plotFrame.dataToPixel( + 0.5 * sum(self._plotFrame.dataRanges[0]), + item['y'], + axis='left') + subShapes = [numpy.array(((0., yPixel), (width, yPixel)), + dtype=numpy.float32)] + + elif item['shape'] == 'vline': + xPixel, _ = self._plotFrame.dataToPixel( + item['x'], + 0.5 * sum(self._plotFrame.dataRanges[1]), + axis='left') + height = self._plotFrame.size[1] + subShapes = [numpy.array(((xPixel, 0), (xPixel, height)), + dtype=numpy.float32)] + + else: + # Split sub-shapes at not finite values + splits = numpy.nonzero(numpy.logical_not(numpy.logical_and( + numpy.isfinite(item['x']), numpy.isfinite(item['y']))))[0] + splits = numpy.concatenate(([-1], splits, [len(item['x'])])) + subShapes = [] + for begin, end in zip(splits[:-1] + 1, splits[1:]): + if end > begin: + subShapes.append(numpy.array([ + self._plotFrame.dataToPixel(x, y, axis='left') + for (x, y) in zip(item['x'][begin:end], item['y'][begin:end])])) + + for points in subShapes: # Draw each sub-shape + # Draw the fill + if (item['fill'] is not None and + item['shape'] not in ('hline', 'vline')): + self._progBase.use() + gl.glUniformMatrix4fv( + self._progBase.uniforms['matrix'], 1, gl.GL_TRUE, + self.matScreenProj.astype(numpy.float32)) + gl.glUniform2i(self._progBase.uniforms['isLog'], False, False) + gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.) + + shape2D = glutils.FilledShape2D( + points, style=item['fill'], color=item['color']) + shape2D.render( + posAttrib=self._progBase.attributes['position'], + colorUnif=self._progBase.uniforms['color'], + hatchStepUnif=self._progBase.uniforms['hatchStep']) + + # Draw the stroke + if item['linestyle'] not in ('', ' ', None): + if item['shape'] != 'polylines': + # close the polyline + points = numpy.append(points, + numpy.atleast_2d(points[0]), axis=0) + + lines = glutils.GLLines2D( + points[:, 0], points[:, 1], + style=item['linestyle'], + color=item['color'], + dash2ndColor=item['linebgcolor'], + width=item['linewidth']) + context.matrix = self.matScreenProj + lines.render(context) + + elif isinstance(item, _MarkerItem): + gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) + + xCoord, yCoord, yAxis = item['x'], item['y'], item['yaxis'] + + if ((isXLog and xCoord is not None and xCoord <= 0) or + (isYLog and yCoord is not None and yCoord <= 0)): + # Do not render markers with negative coords on log axis + continue + + color = item['color'] + intensity = color[0] * 0.299 + color[1] * 0.587 + color[2] * 0.114 + bgColor = (1., 1., 1., 0.5) if intensity <= 0.5 else (0., 0., 0., 0.5) + if xCoord is None or yCoord is None: + if xCoord is None: # Horizontal line in data space + pixelPos = self._plotFrame.dataToPixel( + 0.5 * sum(self._plotFrame.dataRanges[0]), + yCoord, + axis=yAxis) + + if item['text'] is not None: + x = self._plotFrame.size[0] - \ + self._plotFrame.margins.right - pixelOffset + y = pixelPos[1] - pixelOffset + label = glutils.Text2D( + item['text'], x, y, + color=item['color'], + bgColor=bgColor, + align=glutils.RIGHT, + valign=glutils.BOTTOM, + devicePixelRatio=self.getDevicePixelRatio()) + labels.append(label) + + width = self._plotFrame.size[0] + lines = glutils.GLLines2D( + (0, width), (pixelPos[1], pixelPos[1]), + style=item['linestyle'], + color=item['color'], + width=item['linewidth']) + context.matrix = self.matScreenProj + lines.render(context) + + else: # yCoord is None: vertical line in data space + yRange = self._plotFrame.dataRanges[1 if yAxis == 'left' else 2] + pixelPos = self._plotFrame.dataToPixel( + xCoord, 0.5 * sum(yRange), axis=yAxis) + + if item['text'] is not None: + x = pixelPos[0] + pixelOffset + y = self._plotFrame.margins.top + pixelOffset + label = glutils.Text2D( + item['text'], x, y, + color=item['color'], + bgColor=bgColor, + align=glutils.LEFT, + valign=glutils.TOP, + devicePixelRatio=self.getDevicePixelRatio()) + labels.append(label) + + height = self._plotFrame.size[1] + lines = glutils.GLLines2D( + (pixelPos[0], pixelPos[0]), (0, height), + style=item['linestyle'], + color=item['color'], + width=item['linewidth']) + context.matrix = self.matScreenProj + lines.render(context) + + else: + xmin, xmax = self._plot.getXAxis().getLimits() + ymin, ymax = self._plot.getYAxis(axis=yAxis).getLimits() + if not xmin < xCoord < xmax or not ymin < yCoord < ymax: + # Do not render markers outside visible plot area + continue + pixelPos = self._plotFrame.dataToPixel( + xCoord, yCoord, axis=yAxis) + + if isYInverted: + valign = glutils.BOTTOM + vPixelOffset = -pixelOffset + else: + valign = glutils.TOP + vPixelOffset = pixelOffset + + if item['text'] is not None: + x = pixelPos[0] + pixelOffset + y = pixelPos[1] + vPixelOffset + label = glutils.Text2D( + item['text'], x, y, + color=item['color'], + bgColor=bgColor, + align=glutils.LEFT, + valign=valign, + devicePixelRatio=self.getDevicePixelRatio()) + labels.append(label) + + # For now simple implementation: using a curve for each marker + # Should pack all markers to a single set of points + markerCurve = glutils.GLPlotCurve2D( + numpy.array((pixelPos[0],), dtype=numpy.float64), + numpy.array((pixelPos[1],), dtype=numpy.float64), + marker=item['symbol'], + markerColor=item['color'], + markerSize=11) + + context = glutils.RenderContext( + matrix=self.matScreenProj, + isXLog=False, + isYLog=False, + dpi=self.getDotsPerInch()) + markerCurve.render(context) + markerCurve.discard() + + else: + _logger.error('Unsupported item: %s', str(item)) + continue + + # Render marker labels + gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) + for label in labels: + label.render(self.matScreenProj) + + def _renderOverlayGL(self): + """Render overlay layer: overlay items and crosshair.""" + plotWidth, plotHeight = self._plotFrame.plotSize + + # Scissor to plot area + gl.glScissor(self._plotFrame.margins.left, + self._plotFrame.margins.bottom, + plotWidth, plotHeight) + gl.glEnable(gl.GL_SCISSOR_TEST) + + self._renderItems(overlay=True) + + # Render crosshair cursor + if self._crosshairCursor is not None and self._mousePosInPixels is not None: + self._progBase.use() + gl.glUniform2i(self._progBase.uniforms['isLog'], False, False) + 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'] + + gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) + + gl.glUniformMatrix4fv(matrixUnif, 1, gl.GL_TRUE, + self.matScreenProj.astype(numpy.float32)) + + 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): + """Render base layer of plot area. + + It renders the background, grid and items except overlays + """ + plotWidth, plotHeight = self._plotFrame.plotSize + + gl.glScissor(self._plotFrame.margins.left, + self._plotFrame.margins.bottom, + plotWidth, plotHeight) + gl.glEnable(gl.GL_SCISSOR_TEST) + + if self._dataBackgroundColor != self._backgroundColor: + gl.glClearColor(*self._dataBackgroundColor) + gl.glClear(gl.GL_COLOR_BUFFER_BIT) + + self._plotFrame.renderGrid() + + # Matrix + trBounds = self._plotFrame.transformedDataRanges + if trBounds.x[0] != trBounds.x[1] and trBounds.y[0] != trBounds.y[1]: + # Do rendering of items + self._renderItems(overlay=False) + + gl.glDisable(gl.GL_SCISSOR_TEST) + + def resizeGL(self, width, height): + if width == 0 or height == 0: # Do not resize + return + + self._plotFrame.size = ( + int(self.getDevicePixelRatio() * width), + int(self.getDevicePixelRatio() * height)) + + self.matScreenProj = glutils.mat4Ortho( + 0, self._plotFrame.size[0], + self._plotFrame.size[1], 0, + 1, -1) + + # Store current ranges + previousXRange = self.getGraphXLimits() + previousYRange = self.getGraphYLimits(axis='left') + previousYRightRange = self.getGraphYLimits(axis='right') + + (xMin, xMax), (yMin, yMax), (y2Min, y2Max) = \ + self._plotFrame.dataRanges + self.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max) + + # If plot range has changed, then emit signal + if previousXRange != self.getGraphXLimits(): + self._plot.getXAxis()._emitLimitsChanged() + if previousYRange != self.getGraphYLimits(axis='left'): + self._plot.getYAxis(axis='left')._emitLimitsChanged() + if previousYRightRange != self.getGraphYLimits(axis='right'): + self._plot.getYAxis(axis='right')._emitLimitsChanged() + + # Add methods + + @staticmethod + def _castArrayTo(v): + """Returns best floating type to cast the array to. + + :param numpy.ndarray v: Array to cast + :rtype: numpy.dtype + :raise ValueError: If dtype is not supported + """ + if numpy.issubdtype(v.dtype, numpy.floating): + return numpy.float32 if v.itemsize <= 4 else numpy.float64 + elif numpy.issubdtype(v.dtype, numpy.integer): + return numpy.float32 if v.itemsize <= 2 else numpy.float64 + else: + raise ValueError('Unsupported data type') + + def addCurve(self, x, y, + color, symbol, linewidth, linestyle, + yaxis, + xerror, yerror, + fill, alpha, symbolsize, baseline): + for parameter in (x, y, color, symbol, linewidth, linestyle, + yaxis, fill, symbolsize): + assert parameter is not None + assert yaxis in ('left', 'right') + + # Convert input data + x = numpy.array(x, copy=False) + y = numpy.array(y, copy=False) + + # Check if float32 is enough + if (self._castArrayTo(x) is numpy.float32 and + self._castArrayTo(y) is numpy.float32): + dtype = numpy.float32 + else: + dtype = numpy.float64 + + x = numpy.array(x, dtype=dtype, copy=False, order='C') + y = numpy.array(y, dtype=dtype, copy=False, order='C') + + # Convert errors to float32 + 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') + + # Handle axes log scale: convert data + + if self._plotFrame.xAxis.isLog: + logX = numpy.log10(x) + + if xerror is not None: + # Transform xerror so that + # log10(x) +/- xerror' = log10(x +/- xerror) + if hasattr(xerror, 'shape') and len(xerror.shape) == 2: + xErrorMinus, xErrorPlus = xerror[0], xerror[1] + else: + xErrorMinus, xErrorPlus = xerror, xerror + with numpy.errstate(divide='ignore', invalid='ignore'): + # Ignore divide by zero, invalid value encountered in log10 + xErrorMinus = logX - numpy.log10(x - xErrorMinus) + xErrorPlus = numpy.log10(x + xErrorPlus) - logX + xerror = numpy.array((xErrorMinus, xErrorPlus), + dtype=numpy.float32) + + x = logX + + isYLog = (yaxis == 'left' and self._plotFrame.yAxis.isLog) or ( + yaxis == 'right' and self._plotFrame.y2Axis.isLog) + + if isYLog: + logY = numpy.log10(y) + + if yerror is not None: + # Transform yerror so that + # log10(y) +/- yerror' = log10(y +/- yerror) + if hasattr(yerror, 'shape') and len(yerror.shape) == 2: + yErrorMinus, yErrorPlus = yerror[0], yerror[1] + else: + yErrorMinus, yErrorPlus = yerror, yerror + with numpy.errstate(divide='ignore', invalid='ignore'): + # Ignore divide by zero, invalid value encountered in log10 + yErrorMinus = logY - numpy.log10(y - yErrorMinus) + yErrorPlus = numpy.log10(y + yErrorPlus) - logY + yerror = numpy.array((yErrorMinus, yErrorPlus), + dtype=numpy.float32) + + y = logY + + # TODO check if need more filtering of error (e.g., clip to positive) + + # 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 + + fillColor = None + if fill is True: + fillColor = color + curve = glutils.GLPlotCurve2D( + x, y, colorArray, + xError=xerror, + yError=yerror, + lineStyle=linestyle, + lineColor=color, + lineWidth=linewidth, + marker=symbol, + markerColor=color, + markerSize=symbolsize, + fillColor=fillColor, + baseline=baseline, + isYLog=isYLog) + curve.yaxis = 'left' if yaxis is None else yaxis + + if yaxis == "right": + self._plotFrame.isY2Axis = True + + return curve + + def addImage(self, data, + origin, scale, + colormap, alpha): + for parameter in (data, origin, scale): + assert parameter is not None + + if data.ndim == 2: + # Ensure array is contiguous and eventually convert its type + dtypes = [dtype for dtype in ( + numpy.float32, numpy.float16, numpy.uint8, numpy.uint16) + if glu.isSupportedGLType(dtype)] + if data.dtype in dtypes: + 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') + + normalization = colormap.getNormalization() + if normalization in glutils.GLPlotColormap.SUPPORTED_NORMALIZATIONS: + # Fast path applying colormap on the GPU + cmapRange = colormap.getColormapRange(data=data) + colormapLut = colormap.getNColors(nbColors=256) + gamma = colormap.getGammaNormalizationParameter() + nanColor = colors.rgba(colormap.getNaNColor()) + + image = glutils.GLPlotColormap( + data, + origin, + scale, + colormapLut, + normalization, + gamma, + cmapRange, + alpha, + nanColor) + + else: # Fallback applying colormap on CPU + rgba = colormap.applyToData(data) + image = glutils.GLPlotRGBAImage(rgba, origin, scale, alpha) + + elif len(data.shape) == 3: + # For RGB, RGBA data + assert data.shape[2] in (3, 4) + + if numpy.issubdtype(data.dtype, numpy.floating): + data = numpy.array(data, dtype=numpy.float32, copy=False) + elif data.dtype in [numpy.uint8, numpy.uint16]: + pass + elif numpy.issubdtype(data.dtype, numpy.integer): + data = numpy.array(data, dtype=numpy.uint8, copy=False) + else: + raise ValueError('Unsupported data type') + + image = glutils.GLPlotRGBAImage(data, origin, scale, alpha) + + else: + raise RuntimeError("Unsupported data shape {0}".format(data.shape)) + + # TODO is this needed? + 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') + + return image + + def addTriangles(self, x, y, triangles, + color, alpha): + # Handle axes log scale: convert data + if self._plotFrame.xAxis.isLog: + x = numpy.log10(x) + if self._plotFrame.yAxis.isLog: + y = numpy.log10(y) + + triangles = glutils.GLPlotTriangles(x, y, color, triangles, alpha) + + return triangles + + def addShape(self, x, y, shape, color, fill, overlay, + linestyle, linewidth, linebgcolor): + x = numpy.array(x, copy=False) + y = numpy.array(y, copy=False) + + # 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') + + return _ShapeItem(x, y, shape, color, fill, overlay, + linestyle, linewidth, linebgcolor) + + def addMarker(self, x, y, text, color, + symbol, linestyle, linewidth, constraint, yaxis): + return _MarkerItem(x, y, text, color, + symbol, linestyle, linewidth, constraint, yaxis) + + # Remove methods + + def remove(self, item): + if isinstance(item, glutils.GLPlotItem): + if item.yaxis == 'right': + # Check if some curves remains on the right Y axis + y2AxisItems = (item for item in self._plot.getItems() + if isinstance(item, items.YAxisMixIn) and + item.getYAxis() == 'right') + self._plotFrame.isY2Axis = next(y2AxisItems, None) is not None + + if item.isInitialized(): + self._glGarbageCollector.append(item) + + elif isinstance(item, (_MarkerItem, _ShapeItem)): + pass # No-op + + else: + _logger.error('Unsupported item: %s', str(item)) + + # Interaction methods + + _QT_CURSORS = { + 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): + if cursor is None: + super(BackendOpenGL, self).unsetCursor() + else: + cursor = self._QT_CURSORS[cursor] + super(BackendOpenGL, self).setCursor(qt.QCursor(cursor)) + + def setGraphCursor(self, flag, color, linewidth, linestyle): + if linestyle != '-': + _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): + """Returns closest visible position in the plot. + + This is performed in Qt widget pixel, not device pixel. + + :param float x: X coordinate in Qt widget pixel + :param float y: Y coordinate in Qt widget pixel + :return: (x, y) closest point in the plot. + :rtype: List[float] + """ + left, top, width, height = self.getPlotBoundsInPixels() + return (numpy.clip(x, left, left + width - 1), # TODO -1? + numpy.clip(y, top, top + height - 1)) + + def __pickCurves(self, item, x, y): + """Perform picking on a curve item. + + :param GLPlotCurve2D item: + :param float x: X position of the mouse in widget coordinates + :param float y: Y position of the mouse in widget coordinates + :return: List of indices of picked points or None if not picked + :rtype: Union[List[int],None] + """ + offset = self._PICK_OFFSET + if item.marker is not None: + # Convert markerSize from points to qt pixels + qtDpi = self.getDotsPerInch() / self.getDevicePixelRatio() + size = item.markerSize / 72. * qtDpi + offset = max(size / 2., offset) + if item.lineStyle is not None: + # Convert line width from points to qt pixels + qtDpi = self.getDotsPerInch() / self.getDevicePixelRatio() + lineWidth = item.lineWidth / 72. * qtDpi + offset = max(lineWidth / 2., offset) + + inAreaPos = self._mouseInPlotArea(x - offset, y - offset) + dataPos = self._plot.pixelToData(inAreaPos[0], inAreaPos[1], + axis=item.yaxis, check=True) + if dataPos is None: + return None + xPick0, yPick0 = dataPos + + inAreaPos = self._mouseInPlotArea(x + offset, y + offset) + dataPos = self._plot.pixelToData(inAreaPos[0], inAreaPos[1], + axis=item.yaxis, check=True) + if dataPos is None: + return None + 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 + + # Apply log scale if axis is log + if self._plotFrame.xAxis.isLog: + xPickMin = numpy.log10(xPickMin) + xPickMax = numpy.log10(xPickMax) + + if (item.yaxis == 'left' and self._plotFrame.yAxis.isLog) or ( + item.yaxis == 'right' and self._plotFrame.y2Axis.isLog): + yPickMin = numpy.log10(yPickMin) + yPickMax = numpy.log10(yPickMax) + + return item.pick(xPickMin, yPickMin, + xPickMax, yPickMax) + + def pickItem(self, x, y, item): + # Picking is performed in Qt widget pixels not device pixels + dataPos = self._plot.pixelToData(x, y, axis='left', check=True) + if dataPos is None: + return None # Outside plot area + + if item is None: + _logger.error("No item provided for picking") + return None + + # Pick markers + if isinstance(item, _MarkerItem): + yaxis = item['yaxis'] + pixelPos = self._plot.dataToPixel( + item['x'], item['y'], axis=yaxis, check=False) + if pixelPos is None: + return None # negative coord on a log axis + + if item['x'] is None: # Horizontal line + pt1 = self._plot.pixelToData( + x, y - self._PICK_OFFSET, axis=yaxis, check=False) + pt2 = self._plot.pixelToData( + x, y + self._PICK_OFFSET, axis=yaxis, check=False) + isPicked = (min(pt1[1], pt2[1]) <= item['y'] <= + max(pt1[1], pt2[1])) + + elif item['y'] is None: # Vertical line + pt1 = self._plot.pixelToData( + x - self._PICK_OFFSET, y, axis=yaxis, check=False) + pt2 = self._plot.pixelToData( + x + self._PICK_OFFSET, y, axis=yaxis, check=False) + isPicked = (min(pt1[0], pt2[0]) <= item['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) + + return (0,) if isPicked else None + + # Pick image, curve, triangles + elif isinstance(item, glutils.GLPlotItem): + if isinstance(item, glutils.GLPlotCurve2D): + return self.__pickCurves(item, x, y) + else: + return item.pick(*dataPos) # Might be None + + # Update curve + + def setCurveColor(self, curve, color): + pass # TODO + + # Misc. + + def getWidgetHandle(self): + return self + + def postRedisplay(self): + self.update() + + def replot(self): + self.update() # async 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) + + if not self.isValid(): + _logger.error('OpenGL 2.1 not available, cannot save OpenGL image') + width, height = self._plotFrame.size + data = numpy.zeros((height, width, 3), dtype=numpy.uint8) + else: + self.makeCurrent() + + data = numpy.empty( + (self._plotFrame.size[1], self._plotFrame.size[0], 3), + dtype=numpy.uint8, order='C') + + context = self.context() + framebufferTexture = self._plotFBOs.get(context) + if framebufferTexture is None: + # Fallback, supports direct rendering mode: _paintDirectGL + # might have issues as it can read on-screen framebuffer + fboName = self.defaultFramebufferObject() + width, height = self._plotFrame.size + else: + fboName = framebufferTexture.name + height, width = framebufferTexture.shape + + previousFramebuffer = gl.glGetInteger(gl.GL_FRAMEBUFFER_BINDING) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, fboName) + gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) + gl.glReadPixels(0, 0, width, height, + gl.GL_RGB, gl.GL_UNSIGNED_BYTE, data) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, previousFramebuffer) + + # 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 + self._plotFrame.y2Axis.title = label + + # 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) + + 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._plotFrame.plotSize + if plotWidth <= 2 or plotHeight <= 2: + return + + if keepDim is None: + ranges = self._plot.getDataRange() + if (ranges.y is not None and + ranges.x is not None and + (ranges.y[1] - ranges.y[0]) != 0.): + dataRatio = (ranges.x[1] - ranges.x[0]) / float(ranges.y[1] - ranges.y[0]) + 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 getXAxisTimeZone(self): + return self._plotFrame.xAxis.timeZone + + def setXAxisTimeZone(self, tz): + self._plotFrame.xAxis.timeZone = tz + + def isXAxisTimeSeries(self): + return self._plotFrame.xAxis.isTimeSeries + + def setXAxisTimeSeries(self, isTimeSeries): + self._plotFrame.xAxis.isTimeSeries = isTimeSeries + + def setXAxisLogarithmic(self, flag): + if flag != self._plotFrame.xAxis.isLog: + if flag and self._keepDataAspectRatio: + _logger.warning( + "KeepDataAspectRatio is ignored with log axes") + + 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") + + 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") + + 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): + result = self._plotFrame.dataToPixel(x, y, axis) + if result is None: + return None + else: + devicePixelRatio = self.getDevicePixelRatio() + return tuple(value/devicePixelRatio for value in result) + + def pixelToData(self, x, y, axis): + devicePixelRatio = self.getDevicePixelRatio() + return self._plotFrame.pixelToData( + x * devicePixelRatio, y * devicePixelRatio, axis) + + def getPlotBoundsInPixels(self): + devicePixelRatio = self.getDevicePixelRatio() + return tuple(int(value / devicePixelRatio) + for value in self._plotFrame.plotOrigin + self._plotFrame.plotSize) + + def setAxesMargins(self, left: float, top: float, right: float, bottom: float): + self._plotFrame.marginRatios = left, top, right, bottom + + def setForegroundColors(self, foregroundColor, gridColor): + self._plotFrame.foregroundColor = foregroundColor + self._plotFrame.gridColor = gridColor + + def setBackgroundColors(self, backgroundColor, dataBackgroundColor): + self._backgroundColor = backgroundColor + self._dataBackgroundColor = dataBackgroundColor diff --git a/src/silx/gui/plot/backends/__init__.py b/src/silx/gui/plot/backends/__init__.py new file mode 100644 index 0000000..966d9df --- /dev/null +++ b/src/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/src/silx/gui/plot/backends/glutils/GLPlotCurve.py b/src/silx/gui/plot/backends/glutils/GLPlotCurve.py new file mode 100644 index 0000000..e4667b4 --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/GLPlotCurve.py @@ -0,0 +1,1380 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2021 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 +""" + +from __future__ import division + +__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 Program, vertexBuffer, VertexBufferAttrib +from .GLSupport import buildFillMaskIndices, mat4Identity, mat4Translate +from .GLPlotImage import GLPlotItem + + +_logger = logging.getLogger(__name__) + + +_MPL_NONES = None, 'None', '', ' ' +"""Possible values for None""" + + +def _notNaNSlices(array, length=1): + """Returns slices of none NaN values in the array. + + :param numpy.ndarray array: 1D array from which to get slices + :param int length: Slices shorter than length gets discarded + :return: Array of (start, end) slice indices + :rtype: numpy.ndarray + """ + isnan = numpy.isnan(numpy.array(array, copy=False).reshape(-1)) + notnan = numpy.logical_not(isnan) + start = numpy.where(numpy.logical_and(isnan[:-1], notnan[1:]))[0] + 1 + if notnan[0]: + start = numpy.append(0, start) + end = numpy.where(numpy.logical_and(notnan[:-1], isnan[1:]))[0] + 1 + if notnan[-1]: + end = numpy.append(end, len(array)) + slices = numpy.transpose((start, end)) + if length > 1: + # discard slices with less than length values + slices = slices[numpy.diff(slices, axis=1).ravel() >= length] + return slices + + +# fill ######################################################################## + +class _Fill2D(object): + """Object rendering curve filling as polygons + + :param numpy.ndarray xData: X coordinates of points + :param numpy.ndarray yData: Y coordinates of points + :param float baseline: Y value of the 'bottom' of the fill. + 0 for linear Y scale, -38 for log Y scale + :param List[float] color: RGBA color as 4 float in [0, 1] + :param List[float] offset: Translation of coordinates (ox, oy) + """ + + _PROGRAM = Program( + vertexShader=""" + #version 120 + + uniform mat4 matrix; + attribute float xPos; + attribute float yPos; + + void main(void) { + gl_Position = matrix * vec4(xPos, yPos, 0.0, 1.0); + } + """, + fragmentShader=""" + #version 120 + + uniform vec4 color; + + void main(void) { + gl_FragColor = color; + } + """, + attrib0='xPos') + + def __init__(self, xData=None, yData=None, + baseline=0, + color=(0., 0., 0., 1.), + offset=(0., 0.)): + self.xData = xData + self.yData = yData + self._xFillVboData = None + self._yFillVboData = None + self.color = color + self.offset = offset + + # Offset baseline + self.baseline = baseline - self.offset[1] + + def prepare(self): + """Rendering preparation: build indices and bounding box vertices""" + if (self._xFillVboData is None and + self.xData is not None and self.yData is not None): + + # Get slices of not NaN values longer than 1 element + isnan = numpy.logical_or(numpy.isnan(self.xData), numpy.isnan(self.yData)) + notnan = numpy.logical_not(isnan) + start = numpy.where(numpy.logical_and(isnan[:-1], notnan[1:]))[0] + 1 + if notnan[0]: + start = numpy.append(0, start) + end = numpy.where(numpy.logical_and(notnan[:-1], isnan[1:]))[0] + 1 + if notnan[-1]: + end = numpy.append(end, len(isnan)) + slices = numpy.transpose((start, end)) + # discard slices with less than length values + slices = slices[numpy.diff(slices, axis=1).reshape(-1) >= 2] + + # Number of points: slice + 2 * leading and trailing points + # Twice leading and trailing points to produce degenerated triangles + nbPoints = numpy.sum(numpy.diff(slices, axis=1)) * 2 + 4 * len(slices) + points = numpy.empty((nbPoints, 2), dtype=numpy.float32) + + offset = 0 + # invert baseline for filling + new_y_data = numpy.append(self.yData, self.baseline) + for start, end in slices: + # Duplicate first point for connecting degenerated triangle + points[offset:offset+2] = self.xData[start], new_y_data[start] + + # 2nd point of the polygon is last point + points[offset+2] = self.xData[start], self.baseline[start] + + indices = numpy.append(numpy.arange(start, end), + numpy.arange(len(self.xData) + end-1, len(self.xData) + start-1, -1)) + indices = indices[buildFillMaskIndices(len(indices))] + + points[offset+3:offset+3+len(indices), 0] = self.xData[indices % len(self.xData)] + points[offset+3:offset+3+len(indices), 1] = new_y_data[indices] + + # Duplicate last point for connecting degenerated triangle + points[offset+3+len(indices)] = points[offset+3+len(indices)-1] + + offset += len(indices) + 4 + + self._xFillVboData, self._yFillVboData = vertexBuffer(points.T) + + def render(self, context): + """Perform rendering + + :param RenderContext context: + """ + self.prepare() + + if self._xFillVboData is None: + return # Nothing to display + + self._PROGRAM.use() + + gl.glUniformMatrix4fv( + self._PROGRAM.uniforms['matrix'], 1, gl.GL_TRUE, + numpy.dot(context.matrix, + mat4Translate(*self.offset)).astype(numpy.float32)) + + gl.glUniform4f(self._PROGRAM.uniforms['color'], *self.color) + + xPosAttrib = self._PROGRAM.attributes['xPos'] + yPosAttrib = self._PROGRAM.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.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, self._xFillVboData.size) + + 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) + + # Draw directly in NDC + gl.glUniformMatrix4fv(self._PROGRAM.uniforms['matrix'], 1, gl.GL_TRUE, + mat4Identity().astype(numpy.float32)) + + # NDC vertices + gl.glVertexAttribPointer( + xPosAttrib, 1, gl.GL_FLOAT, gl.GL_FALSE, 0, + numpy.array((-1., -1., 1., 1.), dtype=numpy.float32)) + gl.glVertexAttribPointer( + yPosAttrib, 1, gl.GL_FLOAT, gl.GL_FALSE, 0, + numpy.array((-1., 1., -1., 1.), dtype=numpy.float32)) + + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, 4) + + gl.glDisable(gl.GL_STENCIL_TEST) + + def discard(self): + """Release VBOs""" + if self.isInitialized(): + self._xFillVboData.vbo.discard() + + self._xFillVboData = None + self._yFillVboData = None + + def isInitialized(self): + return self._xFillVboData is not None + + +# line ######################################################################## + +SOLID, DASHED, DASHDOT, DOTTED = '-', '--', '-.', ':' + + +class GLLines2D(object): + """Object rendering curve as a polyline + + :param xVboData: X coordinates VBO + :param yVboData: Y coordinates VBO + :param colorVboData: VBO of colors + :param distVboData: VBO of distance along the polyline + :param str style: Line style in: '-', '--', '-.', ':' + :param List[float] color: RGBA color as 4 float in [0, 1] + :param float width: Line width + :param float dashPeriod: Period of dashes + :param drawMode: OpenGL drawing mode + :param List[float] offset: Translation of coordinates (ox, oy) + """ + + STYLES = SOLID, DASHED, DASHDOT, DOTTED + """Supported line styles""" + + _SOLID_PROGRAM = Program( + vertexShader=""" + #version 120 + + uniform mat4 matrix; + attribute float xPos; + attribute float yPos; + attribute vec4 color; + + varying vec4 vColor; + + void main(void) { + gl_Position = matrix * vec4(xPos, yPos, 0., 1.) ; + vColor = color; + } + """, + fragmentShader=""" + #version 120 + + varying vec4 vColor; + + void main(void) { + gl_FragColor = vColor; + } + """, + attrib0='xPos') + + # 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 + _DASH_PROGRAM = Program( + vertexShader=""" + #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; + + void main(void) { + gl_Position = matrix * vec4(xPos, yPos, 0., 1.); + //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; + } + """, + fragmentShader=""" + #version 120 + + /* Dashes: [0, x], [y, z] + Dash period: w */ + uniform vec4 dash; + uniform vec4 dash2ndColor; + + 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) { + if (dash2ndColor.a == 0.) { + discard; // Discard full transparent bg color + } else { + gl_FragColor = dash2ndColor; + } + } else { + gl_FragColor = vColor; + } + } + """, + attrib0='xPos') + + def __init__(self, xVboData=None, yVboData=None, + colorVboData=None, distVboData=None, + style=SOLID, color=(0., 0., 0., 1.), dash2ndColor=None, + width=1, dashPeriod=10., drawMode=None, + offset=(0., 0.)): + if (xVboData is not None and + not isinstance(xVboData, VertexBufferAttrib)): + xVboData = numpy.array(xVboData, copy=False, dtype=numpy.float32) + self.xVboData = xVboData + + if (yVboData is not None and + not isinstance(yVboData, VertexBufferAttrib)): + yVboData = numpy.array(yVboData, copy=False, dtype=numpy.float32) + self.yVboData = yVboData + + # Compute distances if not given while providing numpy array coordinates + if (isinstance(self.xVboData, numpy.ndarray) and + isinstance(self.yVboData, numpy.ndarray) and + distVboData is None): + distVboData = distancesFromArrays(self.xVboData, self.yVboData) + + if (distVboData is not None and + not isinstance(distVboData, VertexBufferAttrib)): + distVboData = numpy.array( + distVboData, copy=False, dtype=numpy.float32) + self.distVboData = distVboData + + if colorVboData is not None: + assert isinstance(colorVboData, VertexBufferAttrib) + self.colorVboData = colorVboData + self.useColorVboData = colorVboData is not None + + self.color = color + self.dash2ndColor = dash2ndColor + self.width = width + self._style = None + self.style = style + self.dashPeriod = dashPeriod + self.offset = offset + + self._drawMode = drawMode if drawMode is not None else gl.GL_LINE_STRIP + + @property + def style(self): + """Line style (Union[str,None])""" + return self._style + + @style.setter + def style(self, style): + if style in _MPL_NONES: + self._style = None + else: + assert style in self.STYLES + self._style = style + + @classmethod + def init(cls): + """OpenGL context initialization""" + gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST) + + def render(self, context): + """Perform rendering + + :param RenderContext context: + """ + width = self.width / 72. * context.dpi + + style = self.style + if style is None: + return + + elif style == SOLID: + program = self._SOLID_PROGRAM + program.use() + + else: # DASHED, DASHDOT, DOTTED + program = self._DASH_PROGRAM + program.use() + + x, y, viewWidth, viewHeight = gl.glGetFloatv(gl.GL_VIEWPORT) + gl.glUniform2f(program.uniforms['halfViewportSize'], + 0.5 * viewWidth, 0.5 * viewHeight) + + dashPeriod = self.dashPeriod * width + if self.style == DOTTED: + dash = (0.2 * dashPeriod, + 0.5 * dashPeriod, + 0.7 * dashPeriod, + dashPeriod) + elif self.style == DASHDOT: + dash = (0.3 * dashPeriod, + 0.5 * dashPeriod, + 0.6 * dashPeriod, + dashPeriod) + else: + dash = (0.5 * dashPeriod, + dashPeriod, + dashPeriod, + dashPeriod) + + gl.glUniform4f(program.uniforms['dash'], *dash) + + if self.dash2ndColor is None: + # Use fully transparent color which gets discarded in shader + dash2ndColor = (0., 0., 0., 0.) + else: + dash2ndColor = self.dash2ndColor + gl.glUniform4f(program.uniforms['dash2ndColor'], *dash2ndColor) + + distAttrib = program.attributes['distance'] + gl.glEnableVertexAttribArray(distAttrib) + if isinstance(self.distVboData, VertexBufferAttrib): + self.distVboData.setVertexAttrib(distAttrib) + else: + gl.glVertexAttribPointer(distAttrib, + 1, + gl.GL_FLOAT, + False, + 0, + self.distVboData) + + if width != 1: + gl.glEnable(gl.GL_LINE_SMOOTH) + + matrix = numpy.dot(context.matrix, + mat4Translate(*self.offset)).astype(numpy.float32) + gl.glUniformMatrix4fv(program.uniforms['matrix'], + 1, gl.GL_TRUE, matrix) + + colorAttrib = program.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 = program.attributes['xPos'] + gl.glEnableVertexAttribArray(xPosAttrib) + if isinstance(self.xVboData, VertexBufferAttrib): + self.xVboData.setVertexAttrib(xPosAttrib) + else: + gl.glVertexAttribPointer(xPosAttrib, + 1, + gl.GL_FLOAT, + False, + 0, + self.xVboData) + + yPosAttrib = program.attributes['yPos'] + gl.glEnableVertexAttribArray(yPosAttrib) + if isinstance(self.yVboData, VertexBufferAttrib): + self.yVboData.setVertexAttrib(yPosAttrib) + else: + gl.glVertexAttribPointer(yPosAttrib, + 1, + gl.GL_FLOAT, + False, + 0, + self.yVboData) + + gl.glLineWidth(width) + gl.glDrawArrays(self._drawMode, 0, self.xVboData.size) + + gl.glDisable(gl.GL_LINE_SMOOTH) + + +def distancesFromArrays(xData, yData): + """Returns distances between each points + + :param numpy.ndarray xData: X coordinate of points + :param numpy.ndarray yData: Y coordinate of points + :rtype: numpy.ndarray + """ + # Split array into sub-shapes at not finite points + splits = numpy.nonzero(numpy.logical_not(numpy.logical_and( + numpy.isfinite(xData), numpy.isfinite(yData))))[0] + splits = numpy.concatenate(([-1], splits, [len(xData) - 1])) + + # Compute distance independently for each sub-shapes, + # putting not finite points as last points of sub-shapes + distances = [] + for begin, end in zip(splits[:-1] + 1, splits[1:] + 1): + if begin == end: # Empty shape + continue + elif end - begin == 1: # Single element + distances.append([0]) + else: + deltas = numpy.dstack(( + numpy.ediff1d(xData[begin:end], to_begin=numpy.float32(0.)), + numpy.ediff1d(yData[begin:end], to_begin=numpy.float32(0.))))[0] + distances.append( + numpy.cumsum(numpy.sqrt(numpy.sum(deltas ** 2, axis=1)))) + return numpy.concatenate(distances) + + +# points ###################################################################### + +DIAMOND, CIRCLE, SQUARE, PLUS, X_MARKER, POINT, PIXEL, ASTERISK = \ + 'd', 'o', 's', '+', 'x', '.', ',', '*' + +H_LINE, V_LINE, HEART = '_', '|', u'\u2665' + +TICK_LEFT = "tickleft" +TICK_RIGHT = "tickright" +TICK_UP = "tickup" +TICK_DOWN = "tickdown" +CARET_LEFT = "caretleft" +CARET_RIGHT = "caretright" +CARET_UP = "caretup" +CARET_DOWN = "caretdown" + + +class _Points2D(object): + """Object rendering curve markers + + :param xVboData: X coordinates VBO + :param yVboData: Y coordinates VBO + :param colorVboData: VBO of colors + :param str marker: Kind of symbol to use, see :attr:`MARKERS`. + :param List[float] color: RGBA color as 4 float in [0, 1] + :param float size: Marker size + :param List[float] offset: Translation of coordinates (ox, oy) + """ + + MARKERS = (DIAMOND, CIRCLE, SQUARE, PLUS, X_MARKER, POINT, PIXEL, ASTERISK, + H_LINE, V_LINE, HEART, TICK_LEFT, TICK_RIGHT, TICK_UP, TICK_DOWN, + CARET_LEFT, CARET_RIGHT, CARET_UP, CARET_DOWN) + """List of supported markers""" + + _VERTEX_SHADER = """ + #version 120 + + uniform mat4 matrix; + uniform int transform; + uniform float size; + attribute float xPos; + attribute float yPos; + attribute vec4 color; + + varying vec4 vColor; + + void main(void) { + gl_Position = matrix * vec4(xPos, yPos, 0., 1.); + vColor = color; + gl_PointSize = size; + } + """ + + _FRAGMENT_SHADER_SYMBOLS = { + 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 circle */ + 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; + } + } + """, + HEART: """ + float alphaSymbol(vec2 coord, float size) { + coord = (coord - 0.5) * 2.; + coord *= 0.75; + coord.y += 0.25; + float a = atan(coord.x,-coord.y)/3.141593; + float r = length(coord); + float h = abs(a); + float d = (13.0*h - 22.0*h*h + 10.0*h*h*h)/(6.0-5.0*h); + float res = clamp(r-d, 0., 1.); + // antialiasing + res = smoothstep(0.1, 0.001, res); + return res; + } + """, + TICK_LEFT: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float dy = abs(coord.y); + if (dy < 0.5 && coord.x < 0.5) { + return 1.0; + } else { + return 0.0; + } + } + """, + TICK_RIGHT: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float dy = abs(coord.y); + if (dy < 0.5 && coord.x > -0.5) { + return 1.0; + } else { + return 0.0; + } + } + """, + TICK_UP: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float dx = abs(coord.x); + if (dx < 0.5 && coord.y < 0.5) { + return 1.0; + } else { + return 0.0; + } + } + """, + TICK_DOWN: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float dx = abs(coord.x); + if (dx < 0.5 && coord.y > -0.5) { + return 1.0; + } else { + return 0.0; + } + } + """, + CARET_LEFT: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float d = abs(coord.x) - abs(coord.y); + if (d >= -0.1 && coord.x > 0.5) { + return smoothstep(-0.1, 0.1, d); + } else { + return 0.0; + } + } + """, + CARET_RIGHT: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float d = abs(coord.x) - abs(coord.y); + if (d >= -0.1 && coord.x < 0.5) { + return smoothstep(-0.1, 0.1, d); + } else { + return 0.0; + } + } + """, + CARET_UP: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float d = abs(coord.y) - abs(coord.x); + if (d >= -0.1 && coord.y > 0.5) { + return smoothstep(-0.1, 0.1, d); + } else { + return 0.0; + } + } + """, + CARET_DOWN: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float d = abs(coord.y) - abs(coord.x); + if (d >= -0.1 && coord.y < 0.5) { + return smoothstep(-0.1, 0.1, d); + } else { + return 0.0; + } + } + """, + } + + _FRAGMENT_SHADER_TEMPLATE = """ + #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, + offset=(0., 0.)): + self.color = color + self._marker = None + self.marker = marker + self.size = size + self.offset = offset + + self.xVboData = xVboData + self.yVboData = yVboData + self.colorVboData = colorVboData + self.useColorVboData = colorVboData is not None + + @property + def marker(self): + """Symbol used to display markers (str)""" + return self._marker + + @marker.setter + def marker(self, marker): + if marker in _MPL_NONES: + self._marker = None + else: + assert marker in self.MARKERS + self._marker = marker + + @classmethod + def _getProgram(cls, marker): + """On-demand shader program creation.""" + if marker == PIXEL: + marker = SQUARE + elif marker == POINT: + marker = CIRCLE + + if marker not in cls._PROGRAMS: + cls._PROGRAMS[marker] = Program( + vertexShader=cls._VERTEX_SHADER, + fragmentShader=(cls._FRAGMENT_SHADER_TEMPLATE % + cls._FRAGMENT_SHADER_SYMBOLS[marker]), + attrib0='xPos') + + return cls._PROGRAMS[marker] + + @classmethod + def init(cls): + """OpenGL context initialization""" + 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 render(self, context): + """Perform rendering + + :param RenderContext context: + """ + if self.marker is None: + return + + program = self._getProgram(self.marker) + program.use() + + matrix = numpy.dot(context.matrix, + mat4Translate(*self.offset)).astype(numpy.float32) + gl.glUniformMatrix4fv(program.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 + size = size / 72. * context.dpi + + if self.marker in (PLUS, H_LINE, V_LINE, + TICK_LEFT, TICK_RIGHT, TICK_UP, TICK_DOWN): + # Convert to nearest odd number + size = size // 2 * 2 + 1. + + gl.glUniform1f(program.uniforms['size'], size) + # gl.glPointSize(self.size) + + cAttrib = program.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 = program.attributes['xPos'] + gl.glEnableVertexAttribArray(xAttrib) + self.xVboData.setVertexAttrib(xAttrib) + + yAttrib = program.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. + + It uses 2 vertices per error bars and uses :class:`GLLines2D` to + render error bars and :class:`_Points2D` to render the ends. + + :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 List[float] color: RGBA color as 4 float in [0, 1] + :param List[float] offset: Translation of coordinates (ox, oy) + """ + + def __init__(self, xData, yData, xError, yError, + xMin, yMin, + color=(0., 0., 0., 1.), + offset=(0., 0.)): + self._attribs = None + self._xMin, self._yMin = xMin, yMin + self.offset = offset + + if xError is not None or yError is not None: + 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 = GLLines2D( + None, None, color=color, drawMode=gl.GL_LINES, offset=offset) + self._xErrPoints = _Points2D( + None, None, color=color, marker=V_LINE, offset=offset) + self._yErrPoints = _Points2D( + None, None, color=color, marker=H_LINE, offset=offset) + + def _buildVertices(self): + """Generates error bars vertices""" + nbLinesPerDataPts = (0 if self._xError is None else 2) + \ + (0 if self._yError is None else 2) + + nbDataPts = len(self._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 self._xError is not None: # errors on the X axis + if len(self._xError.shape) == 2: + xErrorMinus, xErrorPlus = self._xError[0], self._xError[1] + else: + # numpy arrays of len 1 or len(xData) + xErrorMinus, xErrorPlus = self._xError, self._xError + + # Interleave vertices for xError + endXError = 4 * nbDataPts + with numpy.errstate(invalid="ignore"): + xCoords[0:endXError-3:4] = self._xData + xErrorPlus + xCoords[1:endXError-2:4] = self._xData + xCoords[2:endXError-1:4] = self._xData + with numpy.errstate(invalid="ignore"): + xCoords[3:endXError:4] = self._xData - xErrorMinus + + yCoords[0:endXError-3:4] = self._yData + yCoords[1:endXError-2:4] = self._yData + yCoords[2:endXError-1:4] = self._yData + yCoords[3:endXError:4] = self._yData + + else: + endXError = 0 + + if self._yError is not None: # errors on the Y axis + if len(self._yError.shape) == 2: + yErrorMinus, yErrorPlus = self._yError[0], self._yError[1] + else: + # numpy arrays of len 1 or len(yData) + yErrorMinus, yErrorPlus = self._yError, self._yError + + # Interleave vertices for yError + xCoords[endXError::4] = self._xData + xCoords[endXError+1::4] = self._xData + xCoords[endXError+2::4] = self._xData + xCoords[endXError+3::4] = self._xData + + with numpy.errstate(invalid="ignore"): + yCoords[endXError::4] = self._yData + yErrorPlus + yCoords[endXError+1::4] = self._yData + yCoords[endXError+2::4] = self._yData + with numpy.errstate(invalid="ignore"): + yCoords[endXError+3::4] = self._yData - yErrorMinus + + return xCoords, yCoords + + def prepare(self): + """Rendering preparation: build indices and bounding box vertices""" + if self._xData is None: + return + + if self._attribs is None: + xCoords, yCoords = self._buildVertices() + + xAttrib, yAttrib = vertexBuffer((xCoords, yCoords)) + self._attribs = xAttrib, yAttrib + + self._lines.xVboData = xAttrib + self._lines.yVboData = 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, context): + """Perform rendering + + :param RenderContext context: + """ + self.prepare() + + if self._attribs is not None: + self._lines.render(context) + self._xErrPoints.render(context) + self._yErrPoints.render(context) + + def discard(self): + """Release VBOs""" + if self.isInitialized(): + 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 + + def isInitialized(self): + return self._attribs is not 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(GLPlotItem): + def __init__(self, xData, yData, colorData=None, + xError=None, yError=None, + lineStyle=SOLID, + lineColor=(0., 0., 0., 1.), + lineWidth=1, + lineDashPeriod=20, + marker=SQUARE, + markerColor=(0., 0., 0., 1.), + markerSize=7, + fillColor=None, + baseline=None, + isYLog=False): + super().__init__() + self.colorData = colorData + + # Compute x bounds + if xError is None: + self.xMin, self.xMax = min_max(xData, min_positive=False) + else: + # Takes the error into account + if hasattr(xError, 'shape') and len(xError.shape) == 2: + xErrorMinus, xErrorPlus = xError[0], xError[1] + else: + xErrorMinus, xErrorPlus = xError, xError + self.xMin = numpy.nanmin(xData - xErrorMinus) + self.xMax = numpy.nanmax(xData + xErrorPlus) + + # Compute y bounds + if yError is None: + self.yMin, self.yMax = min_max(yData, min_positive=False) + else: + # Takes the error into account + if hasattr(yError, 'shape') and len(yError.shape) == 2: + yErrorMinus, yErrorPlus = yError[0], yError[1] + else: + yErrorMinus, yErrorPlus = yError, yError + self.yMin = numpy.nanmin(yData - yErrorMinus) + self.yMax = numpy.nanmax(yData + yErrorPlus) + + # Handle data offset + if xData.itemsize > 4 or yData.itemsize > 4: # Use normalization + # offset data, do not offset error as it is relative + self.offset = self.xMin, self.yMin + with numpy.errstate(invalid="ignore"): + self.xData = (xData - self.offset[0]).astype(numpy.float32) + self.yData = (yData - self.offset[1]).astype(numpy.float32) + + else: # float32 + self.offset = 0., 0. + self.xData = xData + self.yData = yData + if fillColor is not None: + def deduce_baseline(baseline): + if baseline is None: + _baseline = 0 + else: + _baseline = baseline + if not isinstance(_baseline, numpy.ndarray): + _baseline = numpy.repeat(_baseline, + len(self.xData)) + if isYLog is True: + with numpy.errstate(divide='ignore', invalid='ignore'): + log_val = numpy.log10(_baseline) + _baseline = numpy.where(_baseline>0.0, log_val, -38) + return _baseline + + _baseline = deduce_baseline(baseline) + + # Use different baseline depending of Y log scale + self.fill = _Fill2D(self.xData, self.yData, + baseline=_baseline, + color=fillColor, + offset=self.offset) + else: + self.fill = None + + self._errorBars = _ErrorBars(self.xData, self.yData, + xError, yError, + self.xMin, self.yMin, + offset=self.offset) + + self.lines = GLLines2D() + self.lines.style = lineStyle + self.lines.color = lineColor + self.lines.width = lineWidth + self.lines.dashPeriod = lineDashPeriod + self.lines.offset = self.offset + + self.points = _Points2D() + self.points.marker = marker + self.points.color = markerColor + self.points.size = markerSize + self.points.offset = self.offset + + 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): + """OpenGL context initialization""" + GLLines2D.init() + _Points2D.init() + + def prepare(self): + """Rendering preparation: build indices and bounding box vertices""" + if self.xVboData is None: + xAttrib, yAttrib, cAttrib, dAttrib = None, None, None, None + if self.lineStyle in (DASHED, DASHDOT, DOTTED): + dists = distancesFromArrays(self.xData, self.yData) + if self.colorData is None: + xAttrib, yAttrib, dAttrib = vertexBuffer( + (self.xData, self.yData, dists)) + else: + xAttrib, yAttrib, cAttrib, dAttrib = vertexBuffer( + (self.xData, self.yData, self.colorData, dists)) + elif self.colorData is None: + xAttrib, yAttrib = vertexBuffer((self.xData, self.yData)) + else: + xAttrib, yAttrib, cAttrib = vertexBuffer( + (self.xData, self.yData, self.colorData)) + + self.xVboData = xAttrib + self.yVboData = yAttrib + self.distVboData = dAttrib + + if cAttrib is not None and self.colorData.dtype.kind == 'u': + cAttrib.normalization = True # Normalize uint to [0, 1] + self.colorVboData = cAttrib + self.useColorVboData = cAttrib is not None + + def render(self, context): + """Perform rendering + + :param RenderContext context: Rendering information + """ + self.prepare() + if self.fill is not None: + self.fill.render(context) + self._errorBars.render(context) + self.lines.render(context) + self.points.render(context) + + def discard(self): + """Release VBOs""" + 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() + if self.fill is not None: + self.fill.discard() + + def isInitialized(self): + return (self.xVboData is not None or + self._errorBars.isInitialized() or + (self.fill is not None and self.fill.isInitialized())) + + 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: Union[List[int],None] + """ + 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: + return None + + # offset picking bounds + xPickMin = xPickMin - self.offset[0] + xPickMax = xPickMax - self.offset[0] + yPickMin = yPickMin - self.offset[1] + yPickMax = yPickMax - self.offset[1] + + if self.lineStyle is not None: + # Using Cohen-Sutherland algorithm for line clipping + with numpy.errstate(invalid='ignore'): # Ignore NaN comparison warnings + codes = ((self.yData > yPickMax) << 3) | \ + ((self.yData < yPickMin) << 2) | \ + ((self.xData > xPickMax) << 1) | \ + (self.xData < xPickMin) + + notNaN = numpy.logical_not(numpy.logical_or( + numpy.isnan(self.xData), numpy.isnan(self.yData))) + + # Add all points that are inside the picking area + indices = numpy.nonzero( + numpy.logical_and(codes == 0, notNaN))[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: + with numpy.errstate(invalid='ignore'): # Ignore NaN comparison warnings + indices = numpy.nonzero((self.xData >= xPickMin) & + (self.xData <= xPickMax) & + (self.yData >= yPickMin) & + (self.yData <= yPickMax))[0].tolist() + + return tuple(indices) if len(indices) > 0 else None diff --git a/src/silx/gui/plot/backends/glutils/GLPlotFrame.py b/src/silx/gui/plot/backends/glutils/GLPlotFrame.py new file mode 100644 index 0000000..1fccb02 --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/GLPlotFrame.py @@ -0,0 +1,1210 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2021 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 datetime as dt +import math +import weakref +import logging +from collections import namedtuple + +import numpy + +from ...._glutils import gl, Program +from ..._utils import checkAxisLimits, FLOAT32_MINPOS +from .GLSupport import mat4Ortho +from .GLText import Text2D, CENTER, BOTTOM, TOP, LEFT, RIGHT, ROTATE_270 +from ..._utils.ticklayout import niceNumbersAdaptative, niceNumbersForLog10 +from ..._utils.dtime_ticklayout import calcTicksAdaptive, bestFormatString +from ..._utils.dtime_ticklayout import timestamp + +_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, plotFrame, + tickLength=(0., 0.), + foregroundColor=(0., 0., 0., 1.0), + labelAlign=CENTER, labelVAlign=CENTER, + titleAlign=CENTER, titleVAlign=CENTER, + titleRotate=0, titleOffset=(0., 0.)): + self._ticks = None + + self._plotFrameRef = weakref.ref(plotFrame) + + self._isDateTime = False + self._timeZone = None + self._isLog = False + self._dataRange = 1., 100. + self._displayCoords = (0., 0.), (1., 0.) + self._title = '' + + self._tickLength = tickLength + self._foregroundColor = foregroundColor + 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 timeZone(self): + """Returnss datetime.tzinfo that is used if this axis plots date times.""" + return self._timeZone + + @timeZone.setter + def timeZone(self, tz): + """Sets dateetime.tzinfo that is used if this axis plots date times.""" + self._timeZone = tz + self._dirtyTicks() + + @property + def isTimeSeries(self): + """Whether the axis is showing floats as datetime objects""" + return self._isDateTime + + @isTimeSeries.setter + def isTimeSeries(self, isTimeSeries): + isTimeSeries = bool(isTimeSeries) + if isTimeSeries != self._isDateTime: + self._isDateTime = isTimeSeries + 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 devicePixelRatio(self): + """Returns the ratio between qt pixels and device pixels.""" + plotFrame = self._plotFrameRef() + return plotFrame.devicePixelRatio if plotFrame is not None else 1. + + @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 + self._dirtyPlotFrame() + + @property + def titleOffset(self): + """Title offset in pixels (x: int, y: int)""" + return self._titleOffset + + @titleOffset.setter + def titleOffset(self, offset): + if offset != self._titleOffset: + self._titleOffset = offset + self._dirtyTicks() + + @property + def foregroundColor(self): + """Color used for frame and labels""" + return self._foregroundColor + + @foregroundColor.setter + def foregroundColor(self, color): + """Color used for frame and labels""" + assert len(color) == 4, \ + "foregroundColor must have length 4, got {}".format(len(self._foregroundColor)) + if self._foregroundColor != color: + self._foregroundColor = color + self._dirtyTicks() + + @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 + xTickLength *= self.devicePixelRatio + yTickLength *= self.devicePixelRatio + for (xPixel, yPixel), dataPos, text in self.ticks: + if text is None: + tickScale = 0.5 + else: + tickScale = 1. + + label = Text2D(text=text, + color=self._foregroundColor, + x=xPixel - xTickLength, + y=yPixel - yTickLength, + align=self._labelAlign, + valign=self._labelVAlign, + devicePixelRatio=self.devicePixelRatio) + + 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, + color=self._foregroundColor, + x=xAxisCenter + xOffset, + y=yAxisCenter + yOffset, + align=self._titleAlign, + valign=self._titleVAlign, + rotate=self._titleRotate, + devicePixelRatio=self.devicePixelRatio) + labels.append(axisTitle) + + return vertices, labels + + def _dirtyPlotFrame(self): + """Dirty parent GLPlotFrame""" + plotFrame = self._plotFrameRef() + if plotFrame is not None: + plotFrame._dirty() + + def _dirtyTicks(self): + """Mark ticks as dirty and notify listener (i.e., background).""" + self._ticks = None + self._dirtyPlotFrame() + + @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: + + if self.isTimeSeries: + _logger.warning("Time series not implemented for log-scale") + + 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)) / self.devicePixelRatio + + # Density of 1.3 label per 92 pixels + # i.e., 1.3 label per inch on a 92 dpi screen + tickDensity = 1.3 / 92 + + if not self.isTimeSeries: + tickMin, tickMax, step, nbFrac = niceNumbersAdaptative( + dataMin, dataMax, nbPixels, tickDensity) + + 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) + else: + # Time series + dtMin = dt.datetime.fromtimestamp(dataMin, tz=self.timeZone) + dtMax = dt.datetime.fromtimestamp(dataMax, tz=self.timeZone) + + tickDateTimes, spacing, unit = calcTicksAdaptive( + dtMin, dtMax, nbPixels, tickDensity) + + for tickDateTime in tickDateTimes: + if dtMin <= tickDateTime <= dtMax: + + dataPos = timestamp(tickDateTime) + xPixel = x0 + (dataPos - dataMin) * xScale + yPixel = y0 + (dataPos - dataMin) * yScale + + fmtStr = bestFormatString(spacing, unit) + text = tickDateTime.strftime(fmtStr) + + 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')) + + # Margins used when plot frame is not displayed + _NoDisplayMargins = _Margins(0, 0, 0, 0) + + def __init__(self, marginRatios, foregroundColor, gridColor): + """ + :param List[float] marginRatios: + The ratios of margins around plot area for axis and labels. + (left, top, right, bottom) as float in [0., 1.] + :param foregroundColor: color used for the frame and labels. + :type foregroundColor: tuple with RGBA values ranging from 0.0 to 1.0 + :param gridColor: color used for grid lines. + :type gridColor: tuple RGBA with RGBA values ranging from 0.0 to 1.0 + """ + self._renderResources = None + + self.__marginRatios = marginRatios + self.__marginsCache = None + + self._foregroundColor = foregroundColor + self._gridColor = gridColor + + self.axes = [] # List of PlotAxis to be updated by subclasses + + self._grid = False + self._size = 0., 0. + self._title = '' + + self._devicePixelRatio = 1. + + @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 foregroundColor(self): + """Color used for frame and labels""" + return self._foregroundColor + + @foregroundColor.setter + def foregroundColor(self, color): + """Color used for frame and labels""" + assert len(color) == 4, \ + "foregroundColor must have length 4, got {}".format(len(self._foregroundColor)) + if self._foregroundColor != color: + self._foregroundColor = color + for axis in self.axes: + axis.foregroundColor = color + self._dirty() + + @property + def gridColor(self): + """Color used for frame and labels""" + return self._gridColor + + @gridColor.setter + def gridColor(self, color): + """Color used for frame and labels""" + assert len(color) == 4, \ + "gridColor must have length 4, got {}".format(len(self._gridColor)) + if self._gridColor != color: + self._gridColor = color + self._dirty() + + @property + def marginRatios(self): + """Plot margin ratios: (left, top, right, bottom) as 4 float in [0, 1]. + """ + return self.__marginRatios + + @marginRatios.setter + def marginRatios(self, ratios): + ratios = tuple(float(v) for v in ratios) + assert len(ratios) == 4 + for value in ratios: + assert 0. <= value <= 1. + assert ratios[0] + ratios[2] < 1. + assert ratios[1] + ratios[3] < 1. + + if self.__marginRatios != ratios: + self.__marginRatios = ratios + self.__marginsCache = None # Clear cached margins + self._dirty() + + @property + def margins(self): + """Margins in pixels around the plot.""" + if self.__marginsCache is None: + width, height = self.size + left, top, right, bottom = self.marginRatios + self.__marginsCache = self._Margins( + left=int(left*width), + right=int(right*width), + top=int(top*height), + bottom=int(bottom*height)) + return self.__marginsCache + + @property + def devicePixelRatio(self): + return self._devicePixelRatio + + @devicePixelRatio.setter + def devicePixelRatio(self, ratio): + if ratio != self._devicePixelRatio: + self._devicePixelRatio = ratio + self._dirty() + + @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 device 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.__marginsCache = None # Clear cached margins + 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, + color=self._foregroundColor, + x=xTitle, + y=yTitle, + align=CENTER, + valign=BOTTOM, + devicePixelRatio=self.devicePixelRatio)) + + # 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.margins == self._NoDisplayMargins: + 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.astype(numpy.float32)) + gl.glUniform4f(prog.uniforms['color'], *self._foregroundColor) + 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.astype(numpy.float32)) + gl.glUniform4f(prog.uniforms['color'], *self._gridColor) + 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, marginRatios, foregroundColor, gridColor): + """ + :param List[float] marginRatios: + The ratios of margins around plot area for axis and labels. + (left, top, right, bottom) as float in [0., 1.] + :param foregroundColor: color used for the frame and labels. + :type foregroundColor: tuple with RGBA values ranging from 0.0 to 1.0 + :param gridColor: color used for grid lines. + :type gridColor: tuple RGBA with RGBA values ranging from 0.0 to 1.0 + + """ + super(GLPlotFrame2D, self).__init__(marginRatios, foregroundColor, gridColor) + self.axes.append(PlotAxis(self, + tickLength=(0., -5.), + foregroundColor=self._foregroundColor, + labelAlign=CENTER, labelVAlign=TOP, + titleAlign=CENTER, titleVAlign=TOP, + titleRotate=0)) + + self._x2AxisCoords = () + + self.axes.append(PlotAxis(self, + tickLength=(5., 0.), + foregroundColor=self._foregroundColor, + labelAlign=RIGHT, labelVAlign=CENTER, + titleAlign=CENTER, titleVAlign=BOTTOM, + titleRotate=ROTATE_270)) + + self._y2Axis = PlotAxis(self, + tickLength=(-5., 0.), + foregroundColor=self._foregroundColor, + labelAlign=LEFT, labelVAlign=CENTER, + titleAlign=CENTER, titleVAlign=TOP, + titleRotate=ROTATE_270) + + 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() + + def _updateTitleOffset(self): + """Update axes title offset according to margins""" + margins = self.margins + self.xAxis.titleOffset = 0, margins.bottom // 2 + self.yAxis.titleOffset = -3 * margins.left // 4, 0 + self.y2Axis.titleOffset = 3 * margins.right // 4, 0 + + # Override size and marginRatios setters to update titleOffsets + @GLPlotFrame.size.setter + def size(self, size): + GLPlotFrame.size.fset(self, size) + self._updateTitleOffset() + + @GLPlotFrame.marginRatios.setter + def marginRatios(self, ratios): + GLPlotFrame.marginRatios.fset(self, ratios) + self._updateTitleOffset() + + @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']) + + 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'] = checkAxisLimits( + x[0], x[1], self.xAxis.isLog, name='x') + + if y is not None: + self._dataRanges['y'] = checkAxisLimits( + y[0], y[1], self.yAxis.isLog, name='y') + + if y2 is not None: + self._dataRanges['y2'] = checkAxisLimits( + y2[0], y2[1], self.y2Axis.isLog, name='y2') + + 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. + + 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) + 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) + 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) + + @property + def foregroundColor(self): + """Color used for frame and labels""" + return self._foregroundColor + + @foregroundColor.setter + def foregroundColor(self, color): + """Color used for frame and labels""" + assert len(color) == 4, \ + "foregroundColor must have length 4, got {}".format(len(self._foregroundColor)) + if self._foregroundColor != color: + self._y2Axis.foregroundColor = color + GLPlotFrame.foregroundColor.fset(self, color) # call parent property diff --git a/src/silx/gui/plot/backends/glutils/GLPlotImage.py b/src/silx/gui/plot/backends/glutils/GLPlotImage.py new file mode 100644 index 0000000..3ad94b9 --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/GLPlotImage.py @@ -0,0 +1,756 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2021 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 +from .GLPlotItem import GLPlotItem + + +class _GLPlotData2D(GLPlotItem): + def __init__(self, data, origin, scale): + super().__init__() + 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 (row,), (col,) + 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 + + +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 vec2 bounds_oneOverRange; + uniform vec2 bounds_originOverRange; + + 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 + + /* isnan declaration for compatibility with GLSL 1.20 */ + bool isnan(float value) { + return (value != value); + } + + uniform sampler2D data; + uniform sampler2D cmap_texture; + uniform int cmap_normalization; + uniform float cmap_parameter; + uniform float cmap_min; + uniform float cmap_oneOverRange; + uniform float alpha; + uniform vec4 nancolor; + + varying vec2 coords; + + %s + + const float oneOverLog10 = 0.43429448190325176; + + void main(void) { + float data = texture2D(data, textureCoords()).r; + float value = data; + if (cmap_normalization == 1) { /*Logarithm mapping*/ + if (value > 0.) { + value = clamp(cmap_oneOverRange * + (oneOverLog10 * log(value) - cmap_min), + 0., 1.); + } else { + value = 0.; + } + } else if (cmap_normalization == 2) { /*Square root mapping*/ + if (value >= 0.) { + value = clamp(cmap_oneOverRange * (sqrt(value) - cmap_min), + 0., 1.); + } else { + value = 0.; + } + } else if (cmap_normalization == 3) { /*Gamma correction mapping*/ + value = pow( + clamp(cmap_oneOverRange * (value - cmap_min), 0., 1.), + cmap_parameter); + } else if (cmap_normalization == 4) { /* arcsinh mapping */ + /* asinh = log(x + sqrt(x*x + 1) for compatibility with GLSL 1.20 */ + value = clamp(cmap_oneOverRange * (log(value + sqrt(value*value + 1.0)) - cmap_min), 0., 1.); + } else { /*Linear mapping and fallback*/ + value = clamp(cmap_oneOverRange * (value - cmap_min), 0., 1.); + } + + if (isnan(data)) { + gl_FragColor = nancolor; + } else { + 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, + numpy.dtype(numpy.float16): gl.GL_R16F, + # 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') + + SUPPORTED_NORMALIZATIONS = 'linear', 'log', 'sqrt', 'gamma', 'arcsinh' + + def __init__(self, data, origin, scale, + colormap, normalization='linear', gamma=0., cmapRange=None, + alpha=1.0, nancolor=(1., 1., 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 str normalization: The colormap normalization. + One of: 'linear', 'log', 'sqrt', 'gamma' + ;param float gamma: The gamma parameter (for 'gamma' normalization) + :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) + :param nancolor: RGBA color for Not-A-Number values + :type nancolor: 4-tuple of float in [0., 1.] + """ + assert data.dtype in self._INTERNAL_FORMATS + assert normalization in self.SUPPORTED_NORMALIZATIONS + + super(GLPlotColormap, self).__init__(data, origin, scale) + self.colormap = numpy.array(colormap, copy=False) + self.normalization = normalization + self.gamma = gamma + self._cmapRange = (1., 10.) # Colormap range + self.cmapRange = cmapRange # Update _cmapRange + self._alpha = numpy.clip(alpha, 0., 1.) + self._nancolor = numpy.clip(nancolor, 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 + + def isInitialized(self): + return (self._cmap_texture is not None or + self._texture is not None) + + @property + def cmapRange(self): + if self.normalization == 'log': + assert self._cmapRange[0] > 0. and self._cmapRange[1] > 0. + elif self.normalization == 'sqrt': + assert self._cmapRange[0] >= 0. and self._cmapRange[1] >= 0. + return self._cmapRange + + @cmapRange.setter + def cmapRange(self, cmapRange): + assert len(cmapRange) == 2 + assert cmapRange[0] <= cmapRange[1] + self._cmapRange = float(cmapRange[0]), float(cmapRange[1]) + + @property + def alpha(self): + return self._alpha + + def updateData(self, data): + assert data.dtype in self._INTERNAL_FORMATS + oldData = self.data + self.data = data + + 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)) + self._cmap_texture.prepare() + + 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 + param = 0. + + 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.normalization == 'log': + dataMin = math.log10(dataMin) + dataMax = math.log10(dataMax) + normID = 1 + elif self.normalization == 'sqrt': + dataMin = math.sqrt(dataMin) + dataMax = math.sqrt(dataMax) + normID = 2 + elif self.normalization == 'gamma': + # Keep dataMin, dataMax as is + param = self.gamma + normID = 3 + elif self.normalization == 'arcsinh': + dataMin = numpy.arcsinh(dataMin) + dataMax = numpy.arcsinh(dataMax) + normID = 4 + else: # Linear and fallback + normID = 0 + + gl.glUniform1i(prog.uniforms['cmap_texture'], + self._cmap_texture.texUnit) + gl.glUniform1i(prog.uniforms['cmap_normalization'], normID) + gl.glUniform1f(prog.uniforms['cmap_parameter'], param) + 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) + + gl.glUniform4f(prog.uniforms['nancolor'], *self._nancolor) + + self._cmap_texture.bind() + + def _renderLinear(self, context): + """Perform rendering when both axes have linear scales + + :param RenderContext context: Rendering information + """ + self.prepare() + + prog = self._linearProgram + prog.use() + + gl.glUniform1i(prog.uniforms['data'], self._DATA_TEX_UNIT) + + mat = numpy.dot(numpy.dot(context.matrix, + mat4Translate(*self.origin)), + mat4Scale(*self.scale)) + gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, + mat.astype(numpy.float32)) + + 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, context): + """Perform rendering when one axis has log scale + + :param RenderContext context: Rendering information + """ + xMin, yMin = self.xMin, self.yMin + if ((context.isXLog and xMin < FLOAT32_MINPOS) or + (context.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, + context.matrix.astype(numpy.float32)) + mat = numpy.dot(mat4Translate(ox, oy), mat4Scale(*self.scale)) + gl.glUniformMatrix4fv(prog.uniforms['matOffset'], 1, gl.GL_TRUE, + mat.astype(numpy.float32)) + + gl.glUniform2i(prog.uniforms['isLog'], context.isXLog, context.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, context): + """Perform rendering + + :param RenderContext context: Rendering information + """ + if any((context.isXLog, context.isYLog)): + self._renderLog10(context) + else: + self._renderLinear(context) + + # 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 vec2 bounds_oneOverRange; + uniform vec2 bounds_originOverRange; + 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), + numpy.dtype(numpy.uint16)) + + _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.isInitialized(): + self._texture.discard() + self._texture = None + self._textureIsDirty = False + + def isInitialized(self): + return self._texture is not None + + 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: + formatName = 'GL_RGBA' if self.data.shape[2] == 4 else 'GL_RGB' + format_ = getattr(gl, formatName) + + if self.data.dtype == numpy.uint16: + formatName += '16' # Use sized internal format for uint16 + internalFormat = getattr(gl, formatName) + + self._texture = Image(internalFormat, + 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, context): + """Perform rendering with both axes having linear scales + + :param RenderContext context: Rendering information + """ + self.prepare() + + prog = self._linearProgram + prog.use() + + gl.glUniform1i(prog.uniforms['tex'], self._DATA_TEX_UNIT) + + mat = numpy.dot(numpy.dot(context.matrix, mat4Translate(*self.origin)), + mat4Scale(*self.scale)) + gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, + mat.astype(numpy.float32)) + + gl.glUniform1f(prog.uniforms['alpha'], self.alpha) + + self._texture.render(prog.attributes['position'], + prog.attributes['texCoords'], + self._DATA_TEX_UNIT) + + def _renderLog(self, context): + """Perform rendering with axes having log scale + + :param RenderContext context: Rendering information + """ + 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, + context.matrix.astype(numpy.float32)) + mat = numpy.dot(mat4Translate(ox, oy), mat4Scale(*self.scale)) + gl.glUniformMatrix4fv(prog.uniforms['matOffset'], 1, gl.GL_TRUE, + mat.astype(numpy.float32)) + + gl.glUniform2i(prog.uniforms['isLog'], context.isXLog, context.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, context): + """Perform rendering + + :param RenderContext context: Rendering information + """ + if any((context.isXLog, context.isYLog)): + self._renderLog(context) + else: + self._renderLinear(context) diff --git a/src/silx/gui/plot/backends/glutils/GLPlotItem.py b/src/silx/gui/plot/backends/glutils/GLPlotItem.py new file mode 100644 index 0000000..ae13091 --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/GLPlotItem.py @@ -0,0 +1,99 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2020-2021 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 base class for PlotWidget OpenGL backend primitives +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "02/07/2020" + + +class RenderContext: + """Context with which to perform OpenGL rendering. + + :param numpy.ndarray matrix: 4x4 transform matrix to use for rendering + :param bool isXLog: Whether X axis is log scale or not + :param bool isYLog: Whether Y axis is log scale or not + :param float dpi: Number of device pixels per inch + """ + + def __init__(self, matrix=None, isXLog=False, isYLog=False, dpi=96.): + self.matrix = matrix + """Current transformation matrix""" + + self.__isXLog = isXLog + self.__isYLog = isYLog + self.__dpi = dpi + + @property + def isXLog(self): + """True if X axis is using log scale""" + return self.__isXLog + + @property + def isYLog(self): + """True if Y axis is using log scale""" + return self.__isYLog + + @property + def dpi(self): + """Number of device pixels per inch""" + return self.__dpi + + +class GLPlotItem: + """Base class for primitives used in the PlotWidget OpenGL backend""" + + def __init__(self): + self.yaxis = 'left' + "YAxis this item is attached to (either 'left' or 'right')" + + def pick(self, x, y): + """Perform picking at given position. + + :param float x: X coordinate in plot data frame of reference + :param float y: Y coordinate in plot data frame of reference + :returns: + Result of picking as a list of indices or None if nothing picked + :rtype: Union[List[int],None] + """ + return None + + def render(self, context): + """Performs OpenGL rendering of the item. + + :param RenderContext context: Rendering context information + """ + pass + + def discard(self): + """Discards OpenGL resources this item has created.""" + pass + + def isInitialized(self) -> bool: + """Returns True if resources where initialized and requires `discard`. + """ + return True diff --git a/src/silx/gui/plot/backends/glutils/GLPlotTriangles.py b/src/silx/gui/plot/backends/glutils/GLPlotTriangles.py new file mode 100644 index 0000000..fbe9e02 --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/GLPlotTriangles.py @@ -0,0 +1,197 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2019-2021 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 a set of 2D triangles +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "03/04/2017" + + +import ctypes + +import numpy + +from .....math.combo import min_max +from .... import _glutils as glutils +from ...._glutils import gl +from .GLPlotItem import GLPlotItem + + +class GLPlotTriangles(GLPlotItem): + """Handle rendering of a set of colored triangles""" + + _PROGRAM = glutils.Program( + vertexShader=""" + #version 120 + + uniform mat4 matrix; + attribute float xPos; + attribute float yPos; + attribute vec4 color; + + varying vec4 vColor; + + void main(void) { + gl_Position = matrix * vec4(xPos, yPos, 0.0, 1.0); + vColor = color; + } + """, + fragmentShader=""" + #version 120 + + uniform float alpha; + varying vec4 vColor; + + void main(void) { + gl_FragColor = vColor; + gl_FragColor.a *= alpha; + } + """, + attrib0='xPos') + + def __init__(self, x, y, color, triangles, alpha=1.): + """ + + :param numpy.ndarray x: X coordinates of triangle corners + :param numpy.ndarray y: Y coordinates of triangle corners + :param numpy.ndarray color: color for each point + :param numpy.ndarray triangles: (N, 3) array of indices of triangles + :param float alpha: Opacity in [0, 1] + """ + super().__init__() + # Check and convert input data + x = numpy.ravel(numpy.array(x, dtype=numpy.float32)) + y = numpy.ravel(numpy.array(y, dtype=numpy.float32)) + color = numpy.array(color, copy=False) + # Cast to uint32 + triangles = numpy.array(triangles, copy=False, dtype=numpy.uint32) + + assert x.size == y.size + assert x.size == len(color) + assert color.ndim == 2 and color.shape[1] in (3, 4) + if numpy.issubdtype(color.dtype, numpy.floating): + color = numpy.array(color, dtype=numpy.float32, copy=False) + elif numpy.issubdtype(color.dtype, numpy.integer): + color = numpy.array(color, dtype=numpy.uint8, copy=False) + else: + raise ValueError('Unsupported color type') + assert triangles.ndim == 2 and triangles.shape[1] == 3 + + self.__x_y_color = x, y, color + self.xMin, self.xMax = min_max(x, finite=True) + self.yMin, self.yMax = min_max(y, finite=True) + self.__triangles = triangles + self.__alpha = numpy.clip(float(alpha), 0., 1.) + self.__vbos = None + self.__indicesVbo = None + self.__picking_triangles = None + + def pick(self, x, y): + """Perform picking + + :param float x: X coordinates in plot data frame + :param float y: Y coordinates in plot data frame + :return: List of picked data point indices + :rtype: Union[List[int],None] + """ + if (x < self.xMin or x > self.xMax or + y < self.yMin or y > self.yMax): + return None + + xPts, yPts = self.__x_y_color[:2] + if self.__picking_triangles is None: + self.__picking_triangles = numpy.zeros( + self.__triangles.shape + (3,), dtype=numpy.float32) + self.__picking_triangles[:, :, 0] = xPts[self.__triangles] + self.__picking_triangles[:, :, 1] = yPts[self.__triangles] + + segment = numpy.array(((x, y, -1), (x, y, 1)), dtype=numpy.float32) + # Picked triangle indices + indices = glutils.segmentTrianglesIntersection( + segment, self.__picking_triangles)[0] + # Point indices + indices = numpy.unique(numpy.ravel(self.__triangles[indices])) + + # Sorted from furthest to closest point + dists = (xPts[indices] - x) ** 2 + (yPts[indices] - y) ** 2 + indices = indices[numpy.flip(numpy.argsort(dists), axis=0)] + + return tuple(indices) if len(indices) > 0 else None + + def discard(self): + """Release resources on the GPU""" + if self.isInitialized(): + self.__vbos[0].vbo.discard() + self.__vbos = None + self.__indicesVbo.discard() + self.__indicesVbo = None + + def isInitialized(self): + return self.__vbos is not None + + def prepare(self): + """Allocate resources on the GPU""" + if self.__vbos is None: + self.__vbos = glutils.vertexBuffer(self.__x_y_color) + # Normalization is need for color + self.__vbos[-1].normalization = True + + if self.__indicesVbo is None: + self.__indicesVbo = glutils.VertexBuffer( + numpy.ravel(self.__triangles), + usage=gl.GL_STATIC_DRAW, + target=gl.GL_ELEMENT_ARRAY_BUFFER) + + def render(self, context): + """Perform rendering + + :param RenderContext context: Rendering information + """ + self.prepare() + + if self.__vbos is None or self.__indicesVbo is None: + return # Nothing to display + + self._PROGRAM.use() + + gl.glUniformMatrix4fv(self._PROGRAM.uniforms['matrix'], + 1, + gl.GL_TRUE, + context.matrix.astype(numpy.float32)) + + gl.glUniform1f(self._PROGRAM.uniforms['alpha'], self.__alpha) + + for index, name in enumerate(('xPos', 'yPos', 'color')): + attr = self._PROGRAM.attributes[name] + gl.glEnableVertexAttribArray(attr) + self.__vbos[index].setVertexAttrib(attr) + + with self.__indicesVbo: + gl.glDrawElements(gl.GL_TRIANGLES, + self.__triangles.size, + glutils.numpyToGLType(self.__triangles.dtype), + ctypes.c_void_p(0)) diff --git a/src/silx/gui/plot/backends/glutils/GLSupport.py b/src/silx/gui/plot/backends/glutils/GLSupport.py new file mode 100644 index 0000000..da6dffa --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/GLSupport.py @@ -0,0 +1,158 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2018 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, dtype=None): + """Returns triangle strip indices for rendering a filled polygon mask + + :param int nIndices: Number of points + :param Union[numpy.dtype,None] dtype: + If specified the dtype of the returned indices array + :return: 1D array of indices constructing a triangle strip + :rtype: numpy.ndarray + """ + if dtype is None: + 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 FilledShape2D(object): + _NO_HATCH = 0 + _HATCH_STEP = 20 + + def __init__(self, points, style='solid', color=(0., 0., 0., 1.)): + self.vertices = numpy.array(points, dtype=numpy.float32, copy=False) + 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.style = style + self.color = color + + def render(self, posAttrib, colorUnif, hatchStepUnif): + assert self.style in ('hatch', 'solid') + gl.glUniform4f(colorUnif, *self.color) + step = self._HATCH_STEP if self.style == 'hatch' else self._NO_HATCH + gl.glUniform1i(hatchStepUnif, step) + + # Prepare fill mask + 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) + + 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) + + +# matrix ###################################################################### + +def mat4Ortho(left, right, bottom, top, near, far): + """Orthographic projection matrix (row-major)""" + return numpy.array(( + (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.float64) + + +def mat4Translate(x=0., y=0., z=0.): + """Translation matrix (row-major)""" + return numpy.array(( + (1., 0., 0., x), + (0., 1., 0., y), + (0., 0., 1., z), + (0., 0., 0., 1.)), dtype=numpy.float64) + + +def mat4Scale(sx=1., sy=1., sz=1.): + """Scale matrix (row-major)""" + return numpy.array(( + (sx, 0., 0., 0.), + (0., sy, 0., 0.), + (0., 0., sz, 0.), + (0., 0., 0., 1.)), dtype=numpy.float64) + + +def mat4Identity(): + """Identity matrix""" + return numpy.array(( + (1., 0., 0., 0.), + (0., 1., 0., 0.), + (0., 0., 1., 0.), + (0., 0., 0., 1.)), dtype=numpy.float64) diff --git a/src/silx/gui/plot/backends/glutils/GLText.py b/src/silx/gui/plot/backends/glutils/GLText.py new file mode 100644 index 0000000..d6ae6fa --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/GLText.py @@ -0,0 +1,287 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2020 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" + + +from collections import OrderedDict +import weakref + +import numpy + +from ...._glutils import font, gl, Context, Program, Texture +from .GLSupport import mat4Translate + + +# TODO: Font should be configurable by the main program: using mpl.rcParams? + + +class _Cache(object): + """LRU (Least Recent Used) cache. + + :param int maxsize: Maximum number of (key, value) pairs in the cache + :param callable callback: + Called when a (key, value) pair is removed from the cache. + It must take 2 arguments: key and value. + """ + + def __init__(self, maxsize=128, callback=None): + self._maxsize = int(maxsize) + self._callback = callback + self._cache = OrderedDict() + + def __contains__(self, item): + return item in self._cache + + def __getitem__(self, key): + if key in self._cache: + # Remove/add key from ordered dict to store last access info + value = self._cache.pop(key) + self._cache[key] = value + return value + else: + raise KeyError + + def __setitem__(self, key, value): + """Add a key, value pair to the cache. + + :param key: The key to set + :param value: The corresponding value + """ + if key not in self._cache and len(self._cache) >= self._maxsize: + removedKey, removedValue = self._cache.popitem(last=False) + if self._callback is not None: + self._callback(removedKey, removedValue) + self._cache[key] = value + + +# 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') + + # Discard texture objects when removed from the cache + _textures = weakref.WeakKeyDictionary() + """Cache already created textures""" + + _sizes = _Cache() + """Cache already computed sizes""" + + def __init__(self, text, x=0, y=0, + color=(0., 0., 0., 1.), + bgColor=None, + align=LEFT, valign=BASELINE, + rotate=0, + devicePixelRatio= 1.): + self.devicePixelRatio = devicePixelRatio + 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) + + def _getTexture(self, text, devicePixelRatio): + # Retrieve/initialize texture cache for current context + textureKey = text, devicePixelRatio + + context = Context.getCurrent() + if context not in self._textures: + self._textures[context] = _Cache( + callback=lambda key, value: value[0].discard()) + textures = self._textures[context] + + if textureKey not in textures: + image, offset = font.rasterText( + text, + font.getDefaultFontFamily(), + devicePixelRatio=self.devicePixelRatio) + if textureKey not in self._sizes: + self._sizes[textureKey] = image.shape[1], image.shape[0] + + texture = 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)) + texture.prepare() + textures[textureKey] = texture, offset + + return textures[textureKey] + + @property + def text(self): + return self._text + + @property + def size(self): + textureKey = self.text, self.devicePixelRatio + if textureKey not in self._sizes: + image, offset = font.rasterText( + self.text, + font.getDefaultFontFamily(), + devicePixelRatio=self.devicePixelRatio) + self._sizes[textureKey] = image.shape[1], image.shape[0] + return self._sizes[textureKey] + + 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, self.devicePixelRatio) + + gl.glUniform1i(prog.uniforms['texText'], texUnit) + + mat = numpy.dot(matrix, mat4Translate(int(self.x), int(self.y))) + gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, + mat.astype(numpy.float32)) + + 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/src/silx/gui/plot/backends/glutils/GLTexture.py b/src/silx/gui/plot/backends/glutils/GLTexture.py new file mode 100644 index 0000000..37fbdd0 --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/GLTexture.py @@ -0,0 +1,241 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2020 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) + texture.prepare() + 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_, + 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) + texture.prepare() + 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) + texture.prepare() + # 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/src/silx/gui/plot/backends/glutils/PlotImageFile.py b/src/silx/gui/plot/backends/glutils/PlotImageFile.py new file mode 100644 index 0000000..5fb6853 --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/PlotImageFile.py @@ -0,0 +1,153 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2020 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.tobytes() 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_info < (3, ): + fileObj = open(fileNameOrObj, "wb") + else: + if fileFormat in ('png', 'ppm', 'tiff'): + # Open in binary mode + 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(b'P6\n') + fileObj.write(b'%d %d\n' % (width, height)) + fileObj.write(b'255\n') + fileObj.write(data.tobytes()) + + 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': 'OpenGL Plot Snapshot'}) + + if fileObj != fileNameOrObj: + fileObj.close() diff --git a/src/silx/gui/plot/backends/glutils/__init__.py b/src/silx/gui/plot/backends/glutils/__init__.py new file mode 100644 index 0000000..f87d7c1 --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/__init__.py @@ -0,0 +1,46 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2020 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 .GLPlotItem import GLPlotItem, RenderContext # noqa +from .GLPlotTriangles import GLPlotTriangles # noqa +from .GLSupport import * # noqa +from .GLText import * # noqa +from .GLTexture import * # noqa |