summaryrefslogtreecommitdiff
path: root/src/silx/gui/plot/backends
diff options
context:
space:
mode:
Diffstat (limited to 'src/silx/gui/plot/backends')
-rwxr-xr-xsrc/silx/gui/plot/backends/BackendBase.py606
-rwxr-xr-xsrc/silx/gui/plot/backends/BackendMatplotlib.py1726
-rwxr-xr-xsrc/silx/gui/plot/backends/BackendOpenGL.py1660
-rw-r--r--src/silx/gui/plot/backends/__init__.py28
-rw-r--r--src/silx/gui/plot/backends/glutils/GLPlotCurve.py1494
-rw-r--r--src/silx/gui/plot/backends/glutils/GLPlotFrame.py1399
-rw-r--r--src/silx/gui/plot/backends/glutils/GLPlotImage.py789
-rw-r--r--src/silx/gui/plot/backends/glutils/GLPlotItem.py105
-rw-r--r--src/silx/gui/plot/backends/glutils/GLPlotTriangles.py203
-rw-r--r--src/silx/gui/plot/backends/glutils/GLSupport.py174
-rw-r--r--src/silx/gui/plot/backends/glutils/GLText.py297
-rw-r--r--src/silx/gui/plot/backends/glutils/GLTexture.py269
-rw-r--r--src/silx/gui/plot/backends/glutils/PlotImageFile.py159
-rw-r--r--src/silx/gui/plot/backends/glutils/__init__.py45
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