diff options
Diffstat (limited to 'src/silx/gui/plot/backends')
-rwxr-xr-x | src/silx/gui/plot/backends/BackendBase.py | 606 | ||||
-rwxr-xr-x | src/silx/gui/plot/backends/BackendMatplotlib.py | 1726 | ||||
-rwxr-xr-x | src/silx/gui/plot/backends/BackendOpenGL.py | 1660 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/__init__.py | 28 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLPlotCurve.py | 1494 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLPlotFrame.py | 1399 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLPlotImage.py | 789 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLPlotItem.py | 105 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLPlotTriangles.py | 203 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLSupport.py | 174 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLText.py | 297 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLTexture.py | 269 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/PlotImageFile.py | 159 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/__init__.py | 45 |
14 files changed, 8954 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..8d70286 --- /dev/null +++ b/src/silx/gui/plot/backends/BackendBase.py @@ -0,0 +1,606 @@ +# /*########################################################################## +# +# Copyright (c) 2004-2023 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. +""" + +from __future__ import annotations + + +__authors__ = ["V.A. Sole", "T. Vincent"] +__license__ = "MIT" +__date__ = "21/12/2018" + +from collections.abc import Callable +import weakref +from silx.gui.colors import RGBAColorType + +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.0, 100.0 + self.__yLimits = {"left": (1.0, 100.0), "right": (1.0, 100.0)} + 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, + gapcolor, + 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 Union[str, None] gapcolor: + color used to fill dashed line gaps. + :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 linestyle: Type of line:: + + - ' ' or '' no line + - '-' solid line + - '--' dashed line + - '-.' dash-dot line + - ':' dotted line + - (offset, (dash pattern)) + + :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, gapcolor + ): + """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 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 + - (offset, (dash pattern)) + :param float linewidth: Width of the line. + Only relevant for line markers where X or Y is None. + :param str gapcolor: 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: float | None, + y: float | None, + text: str | None, + color: str, + symbol: str | None, + linestyle: str | tuple[float, tuple[float, ...] | None], + linewidth: float, + constraint: Callable[[float, float], tuple[float, float]] | None, + yaxis: str, + font: qt.QFont, + bgcolor: RGBAColorType | None, + ) -> object: + """Add a point, vertical line or horizontal line marker to the plot. + + :param x: Horizontal position of the marker in graph coordinates. + If None, the marker is a horizontal line. + :param y: Vertical position of the marker in graph coordinates. + If None, the marker is a vertical line. + :param text: Text associated to the marker (or None for no text) + :param color: Color to be used for instance 'blue', 'b', '#FF0000' + :param bgcolor: Text background color to be used for instance 'blue', 'b', '#FF0000' + :param 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 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 + - (offset, (dash pattern)) + :param 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. + It takes the coordinates of the current cursor position in the plot + as input and that returns the filtered coordinates. + :param yaxis: The Y axis this marker belongs to in: 'left', 'right' + :param font: QFont to use to render text + :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 + - (offset, (dash pattern)) + + :type linestyle: None, one of the predefined styles or (offset, (dash pattern)). + """ + 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 isYRightAxisVisible(self) -> bool: + """Return True if the Y axis on the right side of the plot is visible""" + return False + + 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 x: The X coordinate in data space. + :type x: float or sequence of float + :param y: The Y coordinate in data space. + :type y: float or sequence of float + :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..facb63c --- /dev/null +++ b/src/silx/gui/plot/backends/BackendMatplotlib.py @@ -0,0 +1,1726 @@ +# /*########################################################################## +# +# Copyright (c) 2004-2023 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 annotations + +__authors__ = ["V.A. Sole", "T. Vincent, H. Payno"] +__license__ = "MIT" +__date__ = "21/12/2018" + + +import logging +import datetime as dt +from typing import Tuple, Union +import numpy + +from packaging.version import Version + + +_logger = logging.getLogger(__name__) + + +from ... import qt + +# First of all init matplotlib and set its backend +from ...utils.matplotlib import ( + DefaultTickFormatter, + FigureCanvasQTAgg, + qFontToFontProperties, +) +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, 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, + formatDatetimes, + timestamp, +) +from ...qt import inspect as qt_inspect +from .... import config +from silx.gui.colors import RGBAColorType + +_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 == "\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) + try: + dtMin = dt.datetime.fromtimestamp(vmin, tz=self.tz) + dtMax = dt.datetime.fromtimestamp(vmax, tz=self.tz) + except ValueError: + _logger.warning("Data range cannot be displayed with time axis") + return [] + + 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 + + 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) + return formatDatetimes( + [datetime], + self.locator.spacing, + self.locator.unit, + )[datetime] + + def format_ticks(self, values): + return tuple( + formatDatetimes( + [dt.datetime.fromtimestamp(value, tz=self.tz) for value in values], + self.locator.spacing, + self.locator.unit, + ).values() + ) + + +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 SecondEdgeColorPatchMixIn: + """Mix-in class to add a second color for patches with dashed lines""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._second_edgecolor = None + + def set_second_edgecolor(self, color): + """Set the second color used to fill dashed edges""" + self._second_edgecolor = color + + def get_second_edgecolor(self): + """Returns the second color used to fill dashed edges""" + return self._second_edgecolor + + def draw(self, renderer): + linestyle = self.get_linestyle() + if linestyle == "solid" or self.get_second_edgecolor() is None: + super().draw(renderer) + return + + edgecolor = self.get_edgecolor() + hatch = self.get_hatch() + + self.set_linestyle("solid") + self.set_edgecolor(self.get_second_edgecolor()) + self.set_hatch(None) + super().draw(renderer) + + self.set_linestyle(linestyle) + self.set_edgecolor(edgecolor) + self.set_hatch(hatch) + super().draw(renderer) + + +class Rectangle2EdgeColor(SecondEdgeColorPatchMixIn, Rectangle): + """Rectangle patch with a second edge color for dashed line""" + + +class Polygon2EdgeColor(SecondEdgeColorPatchMixIn, Polygon): + """Polygon patch with a second edge color for dashed line""" + + +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, 0.0), silx_scale=(1.0, 1.0), **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 = Version(matplotlib.__version__) + + self.fig = Figure( + tight_layout=config._MPL_TIGHT_LAYOUT, + ) + self.fig.set_facecolor("w") + + if config._MPL_TIGHT_LAYOUT: + self.ax = self.fig.add_subplot(label="left") + else: + self.ax = self.fig.add_axes([0.15, 0.15, 0.75, 0.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) + + # Configure axes tick label formatter + for axis in (self.ax.yaxis, self.ax.xaxis, self.ax2.yaxis, self.ax2.xaxis): + axis.set_major_formatter(DefaultTickFormatter()) + + self.ax2.set_autoscaley_on(True) + + # this works but the figure color is left + if self._matplotlibVersion < 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, + gapcolor, + 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.0 + + 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.0 + 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, + ) + + if gapcolor is not None and self._matplotlibVersion >= Version("3.6.0"): + for line2d in curveList: + line2d.set_gapcolor(gapcolor) + 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.0: + xmin, xmax = xmax, xmin + + ymin = origin[1] + ymax = ymin + scale[1] * height + if scale[1] < 0.0: + ymin, ymax = ymax, ymin + + image.set_extent((xmin, xmax, ymin, ymax)) + + # Set image data + if scale[0] < 0.0 or scale[1] < 0.0: + # For negative scale, step by -1 + xstep = 1 if scale[0] >= 0.0 else -1 + ystep = 1 if scale[1] >= 0.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.0 + + 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, gapcolor + ): + if gapcolor is not None and shape not in ( + "rectangle", + "polygon", + "polylines", + ): + _logger.warning( + "gapcolor 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 = Rectangle2EdgeColor( + xy=(xMin, yMin), + width=w, + height=h, + fill=False, + color=color, + linestyle=linestyle, + linewidth=linewidth, + ) + item.set_second_edgecolor(gapcolor) + + if fill: + item.set_hatch(".") + + 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 = Polygon2EdgeColor( + points, + closed=closed, + fill=False, + color=color, + linestyle=linestyle, + linewidth=linewidth, + ) + item.set_second_edgecolor(gapcolor) + + if fill and shape == "polygon": + item.set_hatch("/") + + 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, + font, + bgcolor: RGBAColorType | None, + ): + textArtist = None + fontProperties = None if font is None else qFontToFontProperties(font) + + 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 + + if bgcolor is None: + bgcolor = "none" + + 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.0 + )[-1] + + if text is not None: + textArtist = _TextWithOffset( + x, + y, + text, + color=color, + backgroundcolor=bgcolor, + horizontalalignment="left", + fontproperties=fontProperties, + ) + 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.0, + text, + color=color, + backgroundcolor=bgcolor, + horizontalalignment="left", + verticalalignment="top", + fontproperties=fontProperties, + ) + 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.0, + y, + text, + color=color, + backgroundcolor=bgcolor, + horizontalalignment="right", + verticalalignment="top", + fontproperties=fontProperties, + ) + 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.0 + 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 __initXAxisFormatterAndLocator(self): + if self.ax.xaxis.get_scale() != "linear": + return # Do not override formatter and locator + + if not self.isXAxisTimeSeries(): + self.ax.xaxis.set_major_formatter(DefaultTickFormatter()) + return + + # 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()) + ) + + 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 + self.__initXAxisFormatterAndLocator() + + 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 >= 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() + + xscale = "log" if flag else "linear" + self.ax2.set_xscale(xscale) + self.ax.set_xscale(xscale) + self.__initXAxisFormatterAndLocator() + + def setYAxisLogarithmic(self, flag): + # Workaround for matplotlib 2.0 issue with negative bounds + # before switching to log scale + if flag and self._matplotlibVersion >= 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() + + if flag: + self.ax2.set_yscale("log") + self.ax.set_yscale("log") + return + + self.ax2.set_yscale("linear") + self.ax2.yaxis.set_major_formatter(DefaultTickFormatter()) + self.ax.set_yscale("linear") + self.ax.yaxis.set_major_formatter(DefaultTickFormatter()) + + 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 isYRightAxisVisible(self): + return self.ax2.yaxis.get_visible() + + 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.0 + + def _mplToQtPosition( + self, x: Union[float, numpy.ndarray], y: Union[float, numpy.ndarray] + ) -> Tuple[Union[float, numpy.ndarray], Union[float, numpy.ndarray]]: + """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 + points = numpy.transpose((x, y)) + displayPos = ax.transData.transform(points).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.0 - left - right, 1.0 - top - bottom + position = left, bottom, width, height + + istight = config._MPL_TIGHT_LAYOUT and (left, top, right, bottom) != ( + 0, + 0, + 0, + 0, + ) + if self._matplotlibVersion >= Version("3.6"): + self.fig.set_layout_engine("tight" if istight else None) + else: + self.fig.set_tight_layout(True if istight else None) + + # Toggle display of axes and viewbox rect + isFrameOn = position != (0.0, 0.0, 1.0, 1.0) + 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 < 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(BackendMatplotlib, FigureCanvasQTAgg): + """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.0 else 1.0 + + # 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. + """ + if self.size().isEmpty(): + return # Skip rendering of 0-sized canvas + + self.updateZOrder() + + if not qt_inspect.isValid(self): + _logger.info("draw requested but widget no longer exists") + return + + # Starting with mpl 2.1.0, toggling autoscale raises a ValueError + # in some situations. See #1081, #1136, #1163, + if self._matplotlibVersion >= 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): + if not qt_inspect.isValid(self): + _logger.info("replot requested but widget no longer exists") + return + + 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 Version("1.5") <= self._matplotlibVersion < 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..370f14b --- /dev/null +++ b/src/silx/gui/plot/backends/BackendOpenGL.py @@ -0,0 +1,1660 @@ +# /*########################################################################## +# +# Copyright (c) 2014-2023 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 annotations + +__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 +from silx.gui.colors import RGBAColorType + +_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, + linewidth, + dashoffset, + dashpattern, + gapcolor, + ): + 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, + "linewidth": linewidth, + "dashoffset": dashoffset, + "dashpattern": dashpattern, + "gapcolor": gapcolor, + } + ) + + +class _MarkerItem(dict): + def __init__( + self, + x, + y, + text, + color, + symbol, + linewidth, + dashoffset, + dashpattern, + constraint, + yaxis, + font, + bgcolor, + ): + 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, + "linewidth": linewidth, + "dashoffset": dashoffset, + "dashpattern": dashpattern, + "yaxis": yaxis, + "font": font, + "bgcolor": bgcolor, + } + ) + + +# 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. + """ + + _TEXT_MARKER_PADDING = 4 + + def __init__(self, plot, parent=None, f=qt.Qt.Widget): + glu.OpenGLWidget.__init__( + self, + parent, + alphaBufferSize=8, + depthBufferSize=0, + stencilBufferSize=0, + version=(2, 1), + f=f, + ) + BackendBase.BackendBase.__init__(self, plot, parent) + + self._defaultFont: qt.QFont = None + self.__isOpenGLValid = False + + self._backgroundColor = 1.0, 1.0, 1.0, 1.0 + self._dataBackgroundColor = 1.0, 1.0, 1.0, 1.0 + + 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.0, 0.0, 1.0), + gridColor=(0.7, 0.7, 0.7, 1.0), + marginRatios=(0.15, 0.1, 0.1, 0.15), + font=self.getDefaultFont(), + ) + 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 = { + qt.Qt.LeftButton: "left", + qt.Qt.RightButton: "right", + qt.Qt.MiddleButton: "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) + x, y = qt.getMouseEventPosition(event) + self._plot.onMousePress(x, y, self._MOUSE_BTNS[event.button()]) + event.accept() + + def mouseMoveEvent(self, event): + qtPos = qt.getMouseEventPosition(event) + + 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) + x, y = qt.getMouseEventPosition(event) + self._plot.onMouseRelease(x, y, self._MOUSE_BTNS[event.button()]) + event.accept() + + def wheelEvent(self, event): + delta = event.angleDelta().y() + angleInDegrees = delta / 8.0 + x, y = qt.getMouseEventPosition(event) + self._plot.onMouseWheel(x, y, angleInDegrees) + event.accept() + + def leaveEvent(self, _): + self._plot.onMouseLeaveWidget() + + # OpenGLWidget API + + def initializeGL(self): + self.__isOpenGLValid = gl.testGL() + if not self.__isOpenGLValid: + return + + 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.0, -1.0), (1.0, -1.0), (-1.0, 1.0), (1.0, 1.0)), + dtype=numpy.float32, + ), + # Texture coordinates + numpy.array( + ((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)), + 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): + if not self.__isOpenGLValid: + return + + 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._plotFrame.dotsPerInch = self.getDotsPerInch() + # 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(), + plotFrame=self._plotFrame, + ) + + 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.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.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["dashpattern"] is not 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], + color=item["color"], + gapColor=item["gapcolor"], + width=item["linewidth"], + dashOffset=item["dashoffset"], + dashPattern=item["dashpattern"], + ) + 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"] + bgColor = item["bgcolor"] + 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"], + item["font"], + x, + y, + color=color, + bgColor=bgColor, + align=glutils.RIGHT, + valign=glutils.BOTTOM, + devicePixelRatio=self.getDevicePixelRatio(), + padding=self._TEXT_MARKER_PADDING, + ) + labels.append(label) + + width = self._plotFrame.size[0] + lines = glutils.GLLines2D( + (0, width), + (pixelPos[1], pixelPos[1]), + color=color, + width=item["linewidth"], + dashOffset=item["dashoffset"], + dashPattern=item["dashpattern"], + ) + 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"], + item["font"], + x, + y, + color=color, + bgColor=bgColor, + align=glutils.LEFT, + valign=glutils.TOP, + devicePixelRatio=self.getDevicePixelRatio(), + padding=self._TEXT_MARKER_PADDING, + ) + labels.append(label) + + height = self._plotFrame.size[1] + lines = glutils.GLLines2D( + (pixelPos[0], pixelPos[0]), + (0, height), + color=color, + width=item["linewidth"], + dashOffset=item["dashoffset"], + dashPattern=item["dashpattern"], + ) + 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"], + item["font"], + x, + y, + color=color, + bgColor=bgColor, + align=glutils.LEFT, + valign=valign, + devicePixelRatio=self.getDevicePixelRatio(), + padding=self._TEXT_MARKER_PADDING, + ) + labels.append(label) + + # For now simple implementation: using a curve for each marker + # Should pack all markers to a single set of points + marker = glutils.Points2D( + (pixelPos[0],), + (pixelPos[1],), + marker=item["symbol"], + color=color, + size=11, + ) + context.matrix = self.matScreenProj + marker.render(context) + + 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, self._plotFrame.dotsPerInch) + + 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.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.0, yPixel), + (self._plotFrame.size[0], yPixel), + (xPixel, 0.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") + + _DASH_PATTERNS = { + "": (0.0, None), + " ": (0.0, None), + "-": (0.0, ()), + "--": (0.0, (3.7, 1.6, 3.7, 1.6)), + "-.": (0.0, (6.4, 1.6, 1, 1.6)), + ":": (0.0, (1, 1.65, 1, 1.65)), + None: (0.0, None), + } + """Convert from linestyle to (offset, (dash pattern)) + + Note: dash pattern internal convention differs from matplotlib: + - None: no line at all + - (): "solid" line + """ + + def _lineStyleToDashOffsetPattern( + self, style + ) -> tuple[float, tuple[float, float, float, float] | tuple[()] | None]: + """Convert a linestyle to its corresponding offset and dash pattern""" + if style is None or isinstance(style, str): + return self._DASH_PATTERNS[style] + + # (offset, (dash pattern)) case + offset, pattern = style + if pattern is None: + # Convert from matplotlib to internal representation of solid + pattern = () + if len(pattern) == 2: + pattern = pattern * 2 + return float(offset), tuple(float(v) for v in pattern) + + def addCurve( + self, + x, + y, + color, + gapcolor, + 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.0 + + if isinstance(color, numpy.ndarray) and color.ndim == 2: + colorArray = color + color = None + else: + colorArray = None + color = colors.rgba(color) + + if alpha < 1.0: # 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 + + dashoffset, dashpattern = self._lineStyleToDashOffsetPattern(linestyle) + curve = glutils.GLPlotCurve2D( + x, + y, + colorArray, + xError=xerror, + yError=yerror, + lineColor=color, + lineGapColor=gapcolor, + lineWidth=linewidth, + lineDashOffset=dashoffset, + lineDashPattern=dashpattern, + 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.0: + raise RuntimeError("Cannot add image with X <= 0 with X axis log scale") + if self._plotFrame.yAxis.isLog and image.yMin <= 0.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, gapcolor + ): + 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.0: + raise RuntimeError("Cannot add item with X <= 0 with X axis log scale") + if self._plotFrame.yAxis.isLog and y.min() <= 0.0: + raise RuntimeError("Cannot add item with Y <= 0 with Y axis log scale") + + dashoffset, dashpattern = self._lineStyleToDashOffsetPattern(linestyle) + return _ShapeItem( + x, + y, + shape, + color, + fill, + overlay, + linewidth, + dashoffset, + dashpattern, + gapcolor, + ) + + def getDefaultFont(self): + """Returns the default font, used by raw markers and axes labels""" + if self._defaultFont is None: + from matplotlib.font_manager import findfont, FontProperties + + font_filename = findfont(FontProperties(family=["sans-serif"])) + _logger.debug("Load font from mpl: %s", font_filename) + id = qt.QFontDatabase.addApplicationFont(font_filename) + family = qt.QFontDatabase.applicationFontFamilies(id)[0] + font = qt.QFont(family, 10, qt.QFont.Normal, False) + font.setStyleStrategy(qt.QFont.PreferAntialias) + self._defaultFont = font + return self._defaultFont + + def addMarker( + self, + x, + y, + text, + color, + symbol, + linestyle, + linewidth, + constraint, + yaxis, + font, + bgcolor: RGBAColorType | None, + ): + if font is None: + font = self.getDefaultFont() + + dashoffset, dashpattern = self._lineStyleToDashOffsetPattern(linestyle) + return _MarkerItem( + x, + y, + text, + color, + symbol, + linewidth, + dashoffset, + dashpattern, + constraint, + yaxis, + font, + bgcolor, + ) + + # 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.0 * qtDpi + offset = max(size / 2.0, offset) + if item.lineDashPattern is not None: + # Convert line width from points to qt pixels + qtDpi = self.getDotsPerInch() / self.getDevicePixelRatio() + lineWidth = item.lineWidth / 72.0 * qtDpi + offset = max(lineWidth / 2.0, 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", "tif", "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.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 isYRightAxisVisible(self): + return self._plotFrame.isY2Axis + + 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..d75a943 --- /dev/null +++ b/src/silx/gui/plot/backends/__init__.py @@ -0,0 +1,28 @@ +# /*########################################################################## +# +# 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..26442d7 --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/GLPlotCurve.py @@ -0,0 +1,1494 @@ +# /*########################################################################## +# +# Copyright (c) 2014-2023 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +This module provides classes to render 2D lines and scatter plots +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "03/04/2017" + + +import math +import logging + +import numpy + +from silx.math.combo import min_max + +from ...._glutils import gl +from ...._glutils import 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.0, 0.0, 1.0), + offset=(0.0, 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.0, -1.0, 1.0, 1.0), dtype=numpy.float32), + ) + gl.glVertexAttribPointer( + yPosAttrib, + 1, + gl.GL_FLOAT, + gl.GL_FALSE, + 0, + numpy.array((-1.0, 1.0, -1.0, 1.0), 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 ######################################################################## + + +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 List[float] color: RGBA color as 4 float in [0, 1] + :param float width: Line width + :param List[float] dashPattern: + "unscaled" dash pattern as 4 lengths in points (dash1, gap1, dash2, gap2). + This pattern is scaled with the line width. + Set to () to draw solid lines (default), and to None to disable rendering. + :param float dashOffset: The offset in points the patterns starts at. + The offset is scaled with the line width. + :param drawMode: OpenGL drawing mode + :param List[float] offset: Translation of coordinates (ox, oy) + """ + + _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 float distanceScale; + 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.); + vDist = distance * distanceScale; + vColor = color; + } + """, + fragmentShader=""" + #version 120 + + /* Dashes: [0, x], [y, z] + Dash period: w */ + uniform vec4 dash; + uniform float dashOffset; + uniform vec4 gapColor; + + varying float vDist; + varying vec4 vColor; + + void main(void) { + float dist = mod(vDist + dashOffset, dash.w); + if ((dist > dash.x && dist < dash.y) || dist > dash.z) { + if (gapColor.a == 0.) { + discard; // Discard full transparent bg color + } else { + gl_FragColor = gapColor; + } + } else { + gl_FragColor = vColor; + } + } + """, + attrib0="xPos", + ) + + def __init__( + self, + xVboData=None, + yVboData=None, + colorVboData=None, + distVboData=None, + color=(0.0, 0.0, 0.0, 1.0), + gapColor=None, + width=1, + dashOffset=0.0, + dashPattern=(), + drawMode=None, + offset=(0.0, 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.gapColor = gapColor + self.width = width + self.dashPattern = dashPattern + self.dashOffset = dashOffset + self.offset = offset + + self._drawMode = drawMode if drawMode is not None else gl.GL_LINE_STRIP + + @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: + """ + if self.dashPattern is None: # Nothing to display + return + + if self.dashPattern == (): # No dash: solid line + program = self._SOLID_PROGRAM + program.use() + + else: # Dashed line defined by 4 control points + program = self._DASH_PROGRAM + program.use() + + # Scale pattern by width, convert from lengths in points to offsets in pixels + scale = self.width / 72.0 * context.dpi + dashOffsets = tuple( + offset * scale for offset in numpy.cumsum(self.dashPattern) + ) + gl.glUniform4f(program.uniforms["dash"], *dashOffsets) + gl.glUniform1f(program.uniforms["dashOffset"], self.dashOffset * scale) + + if self.gapColor is None: + # Use fully transparent color which gets discarded in shader + gapColor = (0.0, 0.0, 0.0, 0.0) + else: + gapColor = self.gapColor + gl.glUniform4f(program.uniforms["gapColor"], *gapColor) + + viewWidth = gl.glGetFloatv(gl.GL_VIEWPORT)[2] + xNDCPerData = ( + numpy.dot(context.matrix, [1.0, 0.0, 0.0, 1.0])[0] + - numpy.dot(context.matrix, [0.0, 0.0, 0.0, 1.0])[0] + ) + xPixelPerData = 0.5 * viewWidth * xNDCPerData + gl.glUniform1f(program.uniforms["distanceScale"], xPixelPerData) + + 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 + ) + + 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(self.width / 72.0 * context.dpi) + gl.glDrawArrays(self._drawMode, 0, self.xVboData.size) + + gl.glDisable(gl.GL_LINE_SMOOTH) + + +def distancesFromArrays(xData, yData, ratio: float = 1.0): + """Returns distances between each points + + :param numpy.ndarray xData: X coordinate of points + :param numpy.ndarray yData: Y coordinate of points + :param ratio: Y/X pixel per data resolution ratio + :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(numpy.array([0], dtype=numpy.float32)) + else: + deltas = numpy.dstack( + ( + numpy.ediff1d(xData[begin:end], to_begin=numpy.float32(0.0)), + numpy.ediff1d( + yData[begin:end] * ratio, to_begin=numpy.float32(0.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 = "_", "|", "\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))); + return local_smoothstep(1.5, 0.5, min(d.x, d.y)); + } + """, + 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)); + return local_smoothstep(1.5, 0.5, min(d_x.x, d_x.y)); + } + """, + 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 d = abs(size * (coord.y - 0.5)); + return local_smoothstep(1.5, 0.5, d); + } + """, + V_LINE: """ + float alphaSymbol(vec2 coord, float size) { + float d = abs(size * (coord.x - 0.5)); + return local_smoothstep(1.5, 0.5, d); + } + """, + 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 = local_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 (coord.x > 0.5) { + return 0.0; + } + return local_smoothstep(1.5, 0.5, dy); + } + """, + TICK_RIGHT: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float dy = abs(coord.y); + if (coord.x < -0.5) { + return 0.0; + } + return local_smoothstep(1.5, 0.5, dy); + } + """, + TICK_UP: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float dx = abs(coord.x); + if (coord.y > 0.5) { + return 0.0; + } + return local_smoothstep(1.5, 0.5, dx); + } + """, + TICK_DOWN: """ + float alphaSymbol(vec2 coord, float size) { + coord = size * (coord - 0.5); + float dx = abs(coord.x); + if (coord.y < -0.5) { + return 0.0; + } + return local_smoothstep(1.5, 0.5, dx); + } + """, + 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 local_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 local_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 local_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 local_smoothstep(-0.1, 0.1, d); + } else { + return 0.0; + } + } + """, + } + + _FRAGMENT_SHADER_TEMPLATE = """ + #version 120 + + uniform float size; + + varying vec4 vColor; + + /* smoothstep function implementation to support GLSL 1.20 */ + float local_smoothstep(float edge0, float edge1, float x) { + float t; + t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); + } + + %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.0, 0.0, 1.0), + size=7, + offset=(0.0, 0.0), + ): + self.color = color + self._marker = None + self.marker = marker + self.size = size + self.offset = offset + + 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 + + if colorVboData is not None: + assert isinstance(colorVboData, VertexBufferAttrib) + 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.getVersion() + majorVersion = 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.0 * 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.0 + + 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) + + 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.glDrawArrays(gl.GL_POINTS, 0, self.xVboData.size) + + +# 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.0, 0.0, 1.0), + offset=(0.0, 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, + lineColor=(0.0, 0.0, 0.0, 1.0), + lineGapColor=None, + lineWidth=1, + lineDashOffset=0.0, + lineDashPattern=(), + marker=SQUARE, + markerColor=(0.0, 0.0, 0.0, 1.0), + markerSize=7, + fillColor=None, + baseline=None, + isYLog=False, + ): + super().__init__() + self._ratio = None + 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, 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.color = lineColor + self.lines.gapColor = lineGapColor + self.lines.width = lineWidth + self.lines.dashOffset = lineDashOffset + self.lines.dashPattern = lineDashPattern + 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")) + + lineColor = _proxyProperty(("lines", "color")) + + lineGapColor = _proxyProperty(("lines", "gapColor")) + + lineWidth = _proxyProperty(("lines", "width")) + + lineDashOffset = _proxyProperty(("lines", "dashOffset")) + + lineDashPattern = _proxyProperty(("lines", "dashPattern")) + + 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.lineDashPattern: + dists = distancesFromArrays(self.xData, self.yData, self._ratio) + 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 + """ + if self.lineDashPattern: + visibleRanges = context.plotFrame.transformedDataRanges + xLimits = visibleRanges.x + yLimits = visibleRanges.y if self.yaxis == "left" else visibleRanges.y2 + width, height = context.plotFrame.plotSize + ratio = (height * (xLimits[1] - xLimits[0])) / ( + width * (yLimits[1] - yLimits[0]) + ) + if ( + self._ratio is None or abs(1.0 - ratio / self._ratio) > 0.05 + ): # Tolerate 5% difference + # Rebuild curve buffers to update distances + self._ratio = ratio + self.discard() + + 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.lineDashPattern 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.lineDashPattern 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..42cfa50 --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/GLPlotFrame.py @@ -0,0 +1,1399 @@ +# /*########################################################################## +# +# Copyright (c) 2014-2023 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. +""" + +from __future__ import annotations + +__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 +import numbers +from typing import Optional, Union +from collections import namedtuple + +import numpy + +from .... import qt +from ...._glutils import gl, Program +from ....utils.matplotlib import DefaultTickFormatter +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 ( + DtUnit, + bestUnit, + calcTicksAdaptive, + formatDatetimes, +) +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, 0.0), + foregroundColor=(0.0, 0.0, 0.0, 1.0), + labelAlign=CENTER, + labelVAlign=CENTER, + titleAlign=CENTER, + titleVAlign=CENTER, + orderOffsetAlign=CENTER, + orderOffsetVAlign=CENTER, + titleRotate=0, + titleOffset=(0.0, 0.0), + font: qt.QFont | None = None, + ): + self._tickFormatter = DefaultTickFormatter() + self._ticks = None + self._orderAndOffsetText = "" + + self._plotFrameRef = weakref.ref(plotFrame) + + self._isDateTime = False + self._timeZone = None + self._isLog = False + self._dataRange = 1.0, 100.0 + self._displayCoords = (0.0, 0.0), (1.0, 0.0) + self._title = "" + + self._tickLength = tickLength + self._foregroundColor = foregroundColor + self._labelAlign = labelAlign + self._labelVAlign = labelVAlign + self._orderOffetAnchor = (1.0, 0.0) + self._orderOffsetAlign = orderOffsetAlign + self._orderOffsetVAlign = orderOffsetVAlign + self._titleAlign = titleAlign + self._titleVAlign = titleVAlign + self._titleRotate = titleRotate + self._titleOffset = titleOffset + self._font = font + + @property + def dataRange(self): + """The range of the data represented on the axis as a tuple + of 2 floats: (min, max).""" + return self._dataRange + + @property + def font(self) -> qt.QFont: + if self._font is None: + return qt.QApplication.instance().font() + return self._font + + @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.0 + + @property + def dotsPerInch(self): + """Returns the screen DPI""" + plotFrame = self._plotFrameRef() + return plotFrame.dotsPerInch if plotFrame is not None else 92 + + @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 orderOffetAnchor(self) -> tuple[float, float]: + """Anchor position for the tick order&offset text""" + return self._orderOffetAnchor + + @orderOffetAnchor.setter + def orderOffetAnchor(self, position: tuple[float, float]): + if position != self._orderOffetAnchor: + self._orderOffetAnchor = position + self._dirtyTicks() + + @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 = [] + + 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.0 + + label = Text2D( + text=text, + font=self.font, + color=self._foregroundColor, + x=xPixel - xTickLength, + y=yPixel - yTickLength, + align=self._labelAlign, + valign=self._labelVAlign, + devicePixelRatio=self.devicePixelRatio, + ) + 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, + font=self.font, + color=self._foregroundColor, + x=xAxisCenter + xOffset, + y=yAxisCenter + yOffset, + align=self._titleAlign, + valign=self._titleVAlign, + rotate=self._titleRotate, + devicePixelRatio=self.devicePixelRatio, + ) + labels.append(axisTitle) + + if self._orderAndOffsetText: + xOrderOffset, yOrderOffet = self.orderOffetAnchor + labels.append( + Text2D( + text=self._orderAndOffsetText, + font=self.font, + color=self._foregroundColor, + x=xOrderOffset, + y=yOrderOffet, + align=self._orderOffsetAlign, + valign=self._orderOffsetVAlign, + devicePixelRatio=self.devicePixelRatio, + ) + ) + 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). + """ + self._orderAndOffsetText = "" + + dataMin, dataMax = self.dataRange + if self.isLog and dataMin <= 0.0: + _logger.warning("Getting ticks while isLog=True and dataRange[0]<=0.") + dataMin = 1.0 + if dataMax < dataMin: + dataMax = 1.0 + + 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 * self.devicePixelRatio / self.dotsPerInch + + if not self.isTimeSeries: + tickMin, tickMax, step, _ = niceNumbersAdaptative( + dataMin, dataMax, nbPixels, tickDensity + ) + + visibleTickPositions = [ + pos + for pos in self._frange(tickMin, tickMax, step) + if dataMin <= pos <= dataMax + ] + self._tickFormatter.axis.set_view_interval(dataMin, dataMax) + self._tickFormatter.axis.set_data_interval(dataMin, dataMax) + texts = self._tickFormatter.format_ticks(visibleTickPositions) + self._orderAndOffsetText = self._tickFormatter.get_offset() + + for dataPos, text in zip(visibleTickPositions, texts): + xPixel = x0 + (dataPos - dataMin) * xScale + yPixel = y0 + (dataPos - dataMin) * yScale + yield ((xPixel, yPixel), dataPos, text) + + else: + # Time series + try: + dtMin = dt.datetime.fromtimestamp(dataMin, tz=self.timeZone) + dtMax = dt.datetime.fromtimestamp(dataMax, tz=self.timeZone) + except ValueError: + _logger.warning("Data range cannot be displayed with time axis") + return # Range is out of bound of the datetime + + if bestUnit( + (dtMax - dtMin).total_seconds() == DtUnit.MICRO_SECONDS + ): + # Special case for micro seconds: Reduce tick density + tickDensity = 1.0 * self.devicePixelRatio / self.dotsPerInch + + tickDateTimes, spacing, unit = calcTicksAdaptive( + dtMin, dtMax, nbPixels, tickDensity + ) + visibleDatetimes = tuple( + dt for dt in tickDateTimes if dtMin <= dt <= dtMax + ) + ticks = formatDatetimes(visibleDatetimes, spacing, unit) + + for tickDateTime, text in ticks.items(): + dataPos = timestamp(tickDateTime) + xPixel = x0 + (dataPos - dataMin) * xScale + yPixel = y0 + (dataPos - dataMin) * yScale + 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, font: qt.QFont): + """ + :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 + :param font: Font used by the axes label + """ + 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, 0.0 + self._title = "" + self._font: qt.QFont = font + + self._devicePixelRatio = 1.0 + self._dpi = 92 + + @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.0 <= value <= 1.0 + assert ratios[0] + ratios[2] < 1.0 + assert ratios[1] + ratios[3] < 1.0 + + 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 dotsPerInch(self): + return self._dpi + + @dotsPerInch.setter + def dotsPerInch(self, dpi): + if dpi != self._dpi: + self._dpi = dpi + 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, + font=self._font, + 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.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, self.dotsPerInch) + + 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.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, font: qt.QFont): + """ + :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 + :param font: Font used by the axes label + """ + super(GLPlotFrame2D, self).__init__( + marginRatios, foregroundColor, gridColor, font + ) + self._font = font + + self.axes.append( + PlotAxis( + self, + tickLength=(0.0, -5.0), + foregroundColor=self._foregroundColor, + labelAlign=CENTER, + labelVAlign=TOP, + orderOffsetAlign=RIGHT, + orderOffsetVAlign=TOP, + titleAlign=CENTER, + titleVAlign=TOP, + titleRotate=0, + font=self._font, + ) + ) + + self._x2AxisCoords = () + + self.axes.append( + PlotAxis( + self, + tickLength=(5.0, 0.0), + foregroundColor=self._foregroundColor, + labelAlign=RIGHT, + labelVAlign=CENTER, + orderOffsetAlign=LEFT, + orderOffsetVAlign=BOTTOM, + titleAlign=CENTER, + titleVAlign=BOTTOM, + titleRotate=ROTATE_270, + font=self._font, + ) + ) + + self._y2Axis = PlotAxis( + self, + tickLength=(-5.0, 0.0), + foregroundColor=self._foregroundColor, + labelAlign=LEFT, + labelVAlign=CENTER, + orderOffsetAlign=RIGHT, + orderOffsetVAlign=BOTTOM, + titleAlign=CENTER, + titleVAlign=TOP, + titleRotate=ROTATE_270, + font=self._font, + ) + + self._isYAxisInverted = False + + self._dataRanges = {"x": (1.0, 100.0), "y": (1.0, 100.0), "y2": (1.0, 100.0)} + + self._baseVectors = (1.0, 0.0), (0.0, 1.0) + + 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.0), (0.0, 1.0) + """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.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.0 + try: + xMax = math.log10(xMax) + except ValueError: + _logger.info("xMax: warning log10(%f)", xMax) + xMax = 0.0 + + if self.yAxis.isLog: + try: + yMin = math.log10(yMin) + except ValueError: + _logger.info("yMin: warning log10(%f)", yMin) + yMin = 0.0 + try: + yMax = math.log10(yMax) + except ValueError: + _logger.info("yMax: warning log10(%f)", yMax) + yMax = 0.0 + + try: + y2Min = math.log10(y2Min) + except ValueError: + _logger.info("yMin: warning log10(%f)", y2Min) + y2Min = 0.0 + try: + y2Max = math.log10(y2Max) + except ValueError: + _logger.info("yMax: warning log10(%f)", y2Max) + y2Max = 0.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 + + @staticmethod + def __applyLog( + data: Union[float, numpy.ndarray], isLog: bool + ) -> Optional[Union[float, numpy.ndarray]]: + """Apply log to data filtering out""" + if not isLog: + return data + + if isinstance(data, numbers.Real): + return None if data < FLOAT32_MINPOS else math.log10(data) + + isBelowMin = data < FLOAT32_MINPOS + if numpy.any(isBelowMin): + data = numpy.array(data, copy=True, dtype=numpy.float64) + data[isBelowMin] = numpy.nan + + with numpy.errstate(divide="ignore"): + return numpy.log10(data) + + def dataToPixel(self, x, y, axis="left"): + """Convert data coordinate to widget pixel coordinate.""" + assert axis in ("left", "right") + + trBounds = self.transformedDataRanges + + xDataTr = self.__applyLog(x, self.xAxis.isLog) + if xDataTr is None: + return None + + yDataTr = self.__applyLog(y, self.yAxis.isLog) + if yDataTr is None: + return None + + # 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 = 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 = self.margins.top + yOffset + else: + yPixel = self.size[1] - self.margins.bottom - yOffset + + return ( + int(xPixel) + if isinstance(xPixel, numbers.Real) + else xPixel.astype(numpy.int64), + int(yPixel) + if isinstance(yPixel, numbers.Real) + else yPixel.astype(numpy.int64), + ) + + 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])) + + # Set order&offset anchor **before** handling Y axis inversion + fontPixelSize = self._font.pixelSize() + if fontPixelSize == -1: + fontPixelSize = self._font.pointSizeF() / 72.0 * self.dotsPerInch + + self.axes[0].orderOffetAnchor = ( + xCoords[1], + yCoords[0] + fontPixelSize * 1.2, + ) + self.axes[1].orderOffetAnchor = ( + xCoords[0], + yCoords[1] - 4 * self.devicePixelRatio, + ) + self._y2Axis.orderOffetAnchor = ( + xCoords[1], + yCoords[1] - 4 * self.devicePixelRatio, + ) + + 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..0973c47 --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/GLPlotImage.py @@ -0,0 +1,789 @@ +# /*########################################################################## +# +# Copyright (c) 2014-2023 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 ...._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.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.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.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.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 float data_scale; + 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 raw_data = texture2D(data, textureCoords()).r * data_scale; + float value = 0.; + if (cmap_normalization == 1) { /*Logarithm mapping*/ + if (raw_data > 0.) { + value = clamp(cmap_oneOverRange * + (oneOverLog10 * log(raw_data) - cmap_min), + 0., 1.); + } else { + value = 0.; + } + } else if (cmap_normalization == 2) { /*Square root mapping*/ + if (raw_data >= 0.) { + value = clamp(cmap_oneOverRange * (sqrt(raw_data) - cmap_min), + 0., 1.); + } else { + value = 0.; + } + } else if (cmap_normalization == 3) { /*Gamma correction mapping*/ + value = pow( + clamp(cmap_oneOverRange * (raw_data - 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(raw_data + sqrt(raw_data*raw_data + 1.0)) - cmap_min), 0., 1.); + } else { /*Linear mapping and fallback*/ + value = clamp(cmap_oneOverRange * (raw_data - cmap_min), 0., 1.); + } + + if (isnan(raw_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.0, + cmapRange=None, + alpha=1.0, + nancolor=(1.0, 1.0, 1.0, 0.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.0, 10.0) # Colormap range + self.cmapRange = cmapRange # Update _cmapRange + self._alpha = numpy.clip(alpha, 0.0, 1.0) + self._nancolor = numpy.clip(nancolor, 0.0, 1.0) + + 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.0 and self._cmapRange[1] > 0.0 + elif self.normalization == "sqrt": + assert self._cmapRange[0] >= 0.0 and self._cmapRange[1] >= 0.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.0 + + if self.data.dtype in (numpy.uint16, numpy.uint8): + # Using unsigned int as normalized integer in OpenGL + # So revert normalization in the shader + dataScale = float(numpy.iinfo(self.data.dtype).max) + else: + dataScale = 1.0 + + 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.glUniform1f(prog.uniforms["data_scale"], dataScale) + 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.0 / (dataMax - dataMin) + else: + oneOverRange = 0.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.0 / (ex - ox) + yOneOverRange = 1.0 / (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.0, 1.0) + + @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.0 / (ex - ox) + yOneOverRange = 1.0 / (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..0287ad5 --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/GLPlotItem.py @@ -0,0 +1,105 @@ +# /*########################################################################## +# +# Copyright (c) 2020-2022 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.0, plotFrame=None + ): + self.matrix = matrix + """Current transformation matrix""" + + self.__isXLog = isXLog + self.__isYLog = isYLog + self.__dpi = dpi + self.__plotFrame = plotFrame + + @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 + + @property + def plotFrame(self): + """Current PlotFrame""" + return self.__plotFrame + + +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..e8a8e4a --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/GLPlotTriangles.py @@ -0,0 +1,203 @@ +# /*########################################################################## +# +# 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.0): + """ + + :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.0, 1.0) + 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..c9afda0 --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/GLSupport.py @@ -0,0 +1,174 @@ +# /*########################################################################## +# +# 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.0, 0.0, 1.0)): + 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.0 / (right - left), 0.0, 0.0, -(right + left) / float(right - left)), + (0.0, 2.0 / (top - bottom), 0.0, -(top + bottom) / float(top - bottom)), + (0.0, 0.0, -2.0 / (far - near), -(far + near) / float(far - near)), + (0.0, 0.0, 0.0, 1.0), + ), + dtype=numpy.float64, + ) + + +def mat4Translate(x=0.0, y=0.0, z=0.0): + """Translation matrix (row-major)""" + return numpy.array( + ( + (1.0, 0.0, 0.0, x), + (0.0, 1.0, 0.0, y), + (0.0, 0.0, 1.0, z), + (0.0, 0.0, 0.0, 1.0), + ), + dtype=numpy.float64, + ) + + +def mat4Scale(sx=1.0, sy=1.0, sz=1.0): + """Scale matrix (row-major)""" + return numpy.array( + ( + (sx, 0.0, 0.0, 0.0), + (0.0, sy, 0.0, 0.0), + (0.0, 0.0, sz, 0.0), + (0.0, 0.0, 0.0, 1.0), + ), + dtype=numpy.float64, + ) + + +def mat4Identity(): + """Identity matrix""" + return numpy.array( + ( + (1.0, 0.0, 0.0, 0.0), + (0.0, 1.0, 0.0, 0.0), + (0.0, 0.0, 1.0, 0.0), + (0.0, 0.0, 0.0, 1.0), + ), + 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..15d7a70 --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/GLText.py @@ -0,0 +1,297 @@ +# /*########################################################################## +# +# Copyright (c) 2014-2023 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. +""" + +from __future__ import annotations + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "03/04/2017" + + +from collections import OrderedDict +import weakref + +import numpy + +from .... import qt +from ...._glutils import font, gl, Context, Program, Texture +from .GLSupport import mat4Translate +from silx.gui.colors import RGBAColorType + + +class _Cache: + """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() # Needed for popitem(last=False) + + 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: + _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) { + if (vCoords.x < 0.0 || vCoords.x > 1.0 || vCoords.y < 0.0 || vCoords.y > 1.0) { + gl_FragColor = bgColor; + } else { + gl_FragColor = mix(bgColor, color, texture2D(texText, vCoords).r); + } + } + """, + } + + _program = Program(_SHADERS["vertex"], _SHADERS["fragment"], attrib0="position") + + # Discard texture objects when removed from the cache + _textures = weakref.WeakKeyDictionary() + """Cache already created textures""" + + def __init__( + self, + text: str, + font: qt.QFont, + x: float = 0.0, + y: float = 0.0, + color: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0), + bgColor: RGBAColorType | None = None, + align: str = LEFT, + valign: str = BASELINE, + rotate: float = 0.0, + devicePixelRatio: float = 1.0, + padding: int = 0, + ): + self.devicePixelRatio = devicePixelRatio + self.font = font + self._vertices = None + self._text = text + self._padding = padding + 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, dotsPerInch: float) -> tuple[Texture, int]: + # Retrieve/initialize texture cache for current context + key = self.text, self.font.key(), dotsPerInch + + 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 key not in textures: + image, offset = font.rasterText(self.text, self.font, dotsPerInch) + + 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[key] = texture, offset + + return textures[key] + + @property + def text(self) -> str: + return self._text + + @property + def padding(self) -> int: + return self._padding + + def getVertices(self, offset: int, shape: tuple[int, int]) -> numpy.ndarray: + 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: numpy.ndarray, dotsPerInch: float): + if not self.text.strip(): + return + + prog = self._program + prog.use() + + texUnit = 0 + texture, offset = self._getTexture(dotsPerInch) + + 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.0 + gl.glUniform4f(prog.uniforms["bgColor"], *bgColor) + + paddingOffset = max(0, int(self.padding * self.devicePixelRatio)) + height, width = texture.shape + vertices = self.getVertices( + offset, (height + 2 * paddingOffset, width + 2 * paddingOffset) + ) + + posAttrib = prog.attributes["position"] + gl.glEnableVertexAttribArray(posAttrib) + gl.glVertexAttribPointer(posAttrib, 2, gl.GL_FLOAT, gl.GL_FALSE, 0, vertices) + + xoffset = paddingOffset / width + yoffset = paddingOffset / height + texCoords = numpy.array( + ( + (-xoffset, -yoffset), + (1.0 + xoffset, -yoffset), + (-xoffset, 1.0 + yoffset), + (1.0 + xoffset, 1.0 + yoffset), + ), + dtype=numpy.float32, + ).ravel() + + texAttrib = prog.attributes["texCoords"] + gl.glEnableVertexAttribArray(texAttrib) + gl.glVertexAttribPointer(texAttrib, 2, gl.GL_FLOAT, gl.GL_FALSE, 0, texCoords) + + 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..cbbe7ac --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/GLTexture.py @@ -0,0 +1,269 @@ +# /*########################################################################## +# +# 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, 0.0, 0.0), + (self.width, 0.0, 1.0, 0.0), + (0.0, self.height, 0.0, 1.0), + (self.width, self.height, 1.0, 1.0), + ), + 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, 0.0), + (xOrig + wData, yOrig, uMax, 0.0), + (xOrig, yOrig + hData, 0.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..1622122 --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/PlotImageFile.py @@ -0,0 +1,159 @@ +# /*########################################################################## +# +# Copyright (c) 2014-2023 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 zlib + +from fabio.TiffIO import TiffIO + + +# 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", "tif", "tiff") + + if not hasattr(fileNameOrObj, "write"): + 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 in ("tif", "tiff"): + if fileObj == fileNameOrObj: + raise NotImplementedError("Save TIFF to a file-like object not implemented") + + 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..bc15b78 --- /dev/null +++ b/src/silx/gui/plot/backends/glutils/__init__.py @@ -0,0 +1,45 @@ +# /*########################################################################## +# +# 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 |