diff options
author | Picca Frédéric-Emmanuel <picca@debian.org> | 2021-01-06 14:10:12 +0100 |
---|---|---|
committer | Picca Frédéric-Emmanuel <picca@debian.org> | 2021-01-06 14:10:12 +0100 |
commit | b3bea947efa55d2c0f198b6c6795b3177be27f45 (patch) | |
tree | 4116758aafe4483bf472c1d54b519e685737fd77 /silx/gui/plot/backends | |
parent | 5ad425ff4e62f5e003178813ebd073577679a00e (diff) |
New upstream version 0.14.0+dfsg
Diffstat (limited to 'silx/gui/plot/backends')
-rwxr-xr-x | silx/gui/plot/backends/BackendBase.py | 25 | ||||
-rwxr-xr-x | silx/gui/plot/backends/BackendMatplotlib.py | 149 | ||||
-rwxr-xr-x | silx/gui/plot/backends/BackendOpenGL.py | 426 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLPlotCurve.py | 86 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLPlotFrame.py | 159 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLPlotImage.py | 103 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLPlotItem.py | 94 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLPlotTriangles.py | 14 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLText.py | 60 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLTexture.py | 5 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/__init__.py | 3 |
11 files changed, 701 insertions, 423 deletions
diff --git a/silx/gui/plot/backends/BackendBase.py b/silx/gui/plot/backends/BackendBase.py index bcc93a5..6fc1aa7 100755 --- a/silx/gui/plot/backends/BackendBase.py +++ b/silx/gui/plot/backends/BackendBase.py @@ -58,8 +58,8 @@ class BackendBase(object): self.__yLimits = {'left': (1., 100.), 'right': (1., 100.)} self.__yAxisInverted = False self.__keepDataAspectRatio = False + self.__xAxisTimeSeries = False self._xAxisTimeZone = None - self._axesDisplayed = True # Store a weakref to get access to the plot state. self._setPlot(plot) @@ -457,14 +457,14 @@ class BackendBase(object): :rtype: bool """ - raise NotImplementedError() + 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. """ - raise NotImplementedError() + self.__xAxisTimeSeries = bool(isTimeSeries) def setXAxisLogarithmic(self, flag): """Set the X axis scale between linear and log. @@ -548,20 +548,17 @@ class BackendBase(object): """ raise NotImplementedError() - def setAxesDisplayed(self, displayed): - """Display or not the axes. + def setAxesMargins(self, left: float, top: float, right: float, bottom: float): + """Set the size of plot margins as ratios. - :param bool displayed: If `True` axes are displayed. If `False` axes - are not anymore visible and the margin used for them is removed. - """ - self._axesDisplayed = displayed + Values are expected in [0., 1.] - def isAxesDisplayed(self): - """private because in some case it is possible that one of the two axes - are displayed and not the other. - This only check status set to axes from the public API + :param float left: + :param float top: + :param float right: + :param float bottom: """ - return self._axesDisplayed + pass def setForegroundColors(self, foregroundColor, gridColor): """Set foreground and grid colors used to display this widget. diff --git a/silx/gui/plot/backends/BackendMatplotlib.py b/silx/gui/plot/backends/BackendMatplotlib.py index 036e630..140672f 100755 --- a/silx/gui/plot/backends/BackendMatplotlib.py +++ b/silx/gui/plot/backends/BackendMatplotlib.py @@ -33,6 +33,7 @@ __date__ = "21/12/2018" import logging import datetime as dt +from typing import Tuple import numpy from pkg_resources import parse_version as _parse_version @@ -44,7 +45,7 @@ _logger = logging.getLogger(__name__) from ... import qt # First of all init matplotlib and set its backend -from ..matplotlib import FigureCanvasQTAgg +from ...utils.matplotlib import FigureCanvasQTAgg import matplotlib from matplotlib.container import Container from matplotlib.figure import Figure @@ -593,7 +594,7 @@ class BackendMatplotlib(BackendBase.BackendBase): if (len(color) == 4 and type(color[3]) in [type(1), numpy.uint8, numpy.int8]): - color = numpy.array(color, dtype=numpy.float) / 255. + color = numpy.array(color, dtype=numpy.float64) / 255. if yaxis == "right": axes = self.ax2 @@ -601,7 +602,7 @@ class BackendMatplotlib(BackendBase.BackendBase): else: axes = self.ax - picker = 3 + pickradius = 3 artists = [] # All the artists composing the curve @@ -627,7 +628,7 @@ class BackendMatplotlib(BackendBase.BackendBase): if hasattr(color, 'dtype') and len(color) == len(x): # scatter plot - if color.dtype not in [numpy.float32, numpy.float]: + if color.dtype not in [numpy.float32, numpy.float64]: actualColor = color / 255. else: actualColor = color @@ -639,7 +640,8 @@ class BackendMatplotlib(BackendBase.BackendBase): linestyle=linestyle, color=actualColor[0], linewidth=linewidth, - picker=picker, + picker=True, + pickradius=pickradius, marker=None) artists += list(curveList) @@ -647,7 +649,8 @@ class BackendMatplotlib(BackendBase.BackendBase): scatter = axes.scatter(x, y, color=actualColor, marker=marker, - picker=picker, + picker=True, + pickradius=pickradius, s=symbolsize**2) artists.append(scatter) @@ -665,7 +668,8 @@ class BackendMatplotlib(BackendBase.BackendBase): color=color, linewidth=linewidth, marker=symbol, - picker=picker, + picker=True, + pickradius=pickradius, markersize=symbolsize) artists += list(curveList) @@ -744,13 +748,13 @@ class BackendMatplotlib(BackendBase.BackendBase): color = numpy.array(color, copy=False) assert color.ndim == 2 and len(color) == len(x) - if color.dtype not in [numpy.float32, numpy.float]: + if color.dtype not in [numpy.float32, numpy.float64]: color = color.astype(numpy.float32) / 255. collection = TriMesh( Triangulation(x, y, triangles), alpha=alpha, - picker=0) # 0 enables picking on filled triangle + pickradius=0) # 0 enables picking on filled triangle collection.set_color(color) self.ax.add_collection(collection) @@ -893,7 +897,8 @@ class BackendMatplotlib(BackendBase.BackendBase): else: raise RuntimeError('A marker must at least have one coordinate') - line.set_picker(5) + line.set_picker(True) + line.set_pickradius(5) # All markers are overlays line.set_animated(True) @@ -1014,7 +1019,11 @@ class BackendMatplotlib(BackendBase.BackendBase): lambda item: item.isVisible() and item._backendRenderer is not None) count = len(items) for index, item in enumerate(items): - zorder = 1. + index / count + if item.getZValue() < 0.5: + # Make sure matplotlib z order is below the grid (with z=0.5) + zorder = 0.5 * index / count + else: # Make sure matplotlib z order is above the grid (> 0.5) + zorder = 1. + index / count if zorder != item._backendRenderer.get_zorder(): item._backendRenderer.set_zorder(zorder) @@ -1196,67 +1205,58 @@ class BackendMatplotlib(BackendBase.BackendBase): # Data <-> Pixel coordinates conversion - def _mplQtYAxisCoordConversion(self, y, asint=True): - """Qt origin (top) to/from matplotlib origin (bottom) conversion. + def _getDevicePixelRatio(self) -> float: + """Compatibility wrapper for devicePixelRatioF""" + return 1. - :param y: - :param bool asint: True to cast to int, False to keep as float + def _mplToQtPosition(self, x: float, y: float) -> Tuple[float, float]: + """Convert matplotlib "display" space coord to Qt widget logical pixel + """ + ratio = self._getDevicePixelRatio() + # Convert from matplotlib origin (bottom) to Qt origin (top) + # and apply device pixel ratio + return x / ratio, (self.fig.get_window_extent().height - y) / ratio - :rtype: float + def _qtToMplPosition(self, x: float, y: float) -> Tuple[float, float]: + """Convert Qt widget logical pixel to matplotlib "display" space coord """ - value = self.fig.get_window_extent().height - y - return int(value) if asint else value + 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 - - pixels = ax.transData.transform_point((x, y)) - xPixel, yPixel = pixels.T - - # Convert from matplotlib origin (bottom) to Qt origin (top) - yPixel = self._mplQtYAxisCoordConversion(yPixel, asint=False) - - return xPixel, yPixel + displayPos = ax.transData.transform_point((x, y)).transpose() + return self._mplToQtPosition(*displayPos) def pixelToData(self, x, y, axis): ax = self.ax2 if axis == "right" else self.ax - - # Convert from Qt origin (top) to matplotlib origin (bottom) - y = self._mplQtYAxisCoordConversion(y, asint=False) - - inv = ax.transData.inverted() - x, y = inv.transform_point((x, y)) - return x, y + 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... - return (int(bbox.xmin), - self._mplQtYAxisCoordConversion(bbox.ymax, asint=True), - int(bbox.width), - int(bbox.height)) + 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 setAxesDisplayed(self, displayed): - """Display or not the axes. + def setAxesMargins(self, left: float, top: float, right: float, bottom: float): + width, height = 1. - left - right, 1. - top - bottom + position = left, bottom, width, height + + # Toggle display of axes and viewbox rect + isFrameOn = position != (0., 0., 1., 1.) + self.ax.set_frame_on(isFrameOn) + self.ax2.set_frame_on(isFrameOn) + + self.ax.set_position(position) + self.ax2.set_position(position) - :param bool displayed: If `True` axes are displayed. If `False` axes - are not anymore visible and the margin used for them is removed. - """ - BackendBase.BackendBase.setAxesDisplayed(self, displayed) - if displayed: - # show axes and viewbox rect - self.ax.set_frame_on(True) - self.ax2.set_frame_on(True) - # set the default margins - self.ax.set_position([.15, .15, .75, .75]) - self.ax2.set_position([.15, .15, .75, .75]) - else: - # hide axes and viewbox rect - self.ax.set_frame_on(False) - self.ax2.set_frame_on(False) - # remove external margins - self.ax.set_position([0, 0, 1, 1]) - self.ax2.set_position([0, 0, 1, 1]) self._synchronizeBackgroundColors() self._synchronizeForegroundColors() self._plot._setDirtyPlot() @@ -1349,6 +1349,15 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): def postRedisplay(self): self._sigPostRedisplay.emit() + def _getDevicePixelRatio(self) -> float: + """Compatibility wrapper for devicePixelRatioF""" + if hasattr(self, 'devicePixelRatioF'): + ratio = self.devicePixelRatioF() + else: # Qt < 5.6 compatibility + ratio = float(self.devicePixelRatio()) + # Safety net: avoid returning 0 + return ratio if ratio != 0. else 1. + # Mouse event forwarding _MPL_TO_PLOT_BUTTONS = {1: 'left', 2: 'middle', 3: 'right'} @@ -1356,17 +1365,14 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): def _onMousePress(self, event): button = self._MPL_TO_PLOT_BUTTONS.get(event.button, None) if button is not None: - self._plot.onMousePress( - event.x, self._mplQtYAxisCoordConversion(event.y), - button) + 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( - event.x, - self._mplQtYAxisCoordConversion(event.y), - axis='left', - check=True) + x, y, axis='left', check=True) lineh, linev = self._graphCursor if position is not None: linev.set_visible(True) @@ -1380,19 +1386,17 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): self._plot._setDirtyPlot(overlayOnly=True) # onMouseMove must trigger replot if dirty flag is raised - self._plot.onMouseMove( - event.x, self._mplQtYAxisCoordConversion(event.y)) + 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: - self._plot.onMouseRelease( - event.x, self._mplQtYAxisCoordConversion(event.y), - button) + x, y = self._mplToQtPosition(event.x, event.y) + self._plot.onMouseRelease(int(x), int(y), button) def _onMouseWheel(self, event): - self._plot.onMouseWheel( - event.x, self._mplQtYAxisCoordConversion(event.y), event.step) + x, y = self._mplToQtPosition(event.x, event.y) + self._plot.onMouseWheel(int(x), int(y), event.step) def leaveEvent(self, event): """QWidget event handler""" @@ -1406,8 +1410,9 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): # picking def pickItem(self, x, y, item): + xDisplay, yDisplay = self._qtToMplPosition(x, y) mouseEvent = MouseEvent( - 'button_press_event', self, x, self._mplQtYAxisCoordConversion(y)) + '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( diff --git a/silx/gui/plot/backends/BackendOpenGL.py b/silx/gui/plot/backends/BackendOpenGL.py index cf1da31..909d18a 100755 --- a/silx/gui/plot/backends/BackendOpenGL.py +++ b/silx/gui/plot/backends/BackendOpenGL.py @@ -43,12 +43,7 @@ from ... import qt from ..._glutils import gl from ... import _glutils as glu -from .glutils import ( - GLLines2D, GLPlotTriangles, - GLPlotCurve2D, GLPlotColormap, GLPlotRGBAImage, GLPlotFrame2D, - mat4Ortho, mat4Identity, - LEFT, RIGHT, BOTTOM, TOP, - Text2D, FilledShape2D) +from . import glutils from .glutils.PlotImageFile import saveImageToFile _logger = logging.getLogger(__name__) @@ -216,7 +211,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): self._backgroundColor = 1., 1., 1., 1. self._dataBackgroundColor = 1., 1., 1., 1. - self.matScreenProj = mat4Identity() + self.matScreenProj = glutils.mat4Identity() self._progBase = glu.Program( _baseVertShd, _baseFragShd, attrib0='position') @@ -231,10 +226,13 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): self._glGarbageCollector = [] - self._plotFrame = GLPlotFrame2D( + self._plotFrame = glutils.GLPlotFrame2D( foregroundColor=(0., 0., 0., 1.), gridColor=(.7, .7, .7, 1.), - margins={'left': 100, 'right': 50, 'top': 50, 'bottom': 50}) + marginRatios=(.15, .1, .1, .15)) + self._plotFrame.size = ( # Init size with size int + int(self.getDevicePixelRatio() * 640), + int(self.getDevicePixelRatio() * 480)) # Make postRedisplay asynchronous using Qt signal self._sigPostRedisplay.connect( @@ -254,50 +252,43 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): def mousePressEvent(self, event): if event.button() not in self._MOUSE_BTNS: return super(BackendOpenGL, self).mousePressEvent(event) - xPixel = event.x() * self.getDevicePixelRatio() - yPixel = event.y() * self.getDevicePixelRatio() - btn = self._MOUSE_BTNS[event.button()] - self._plot.onMousePress(xPixel, yPixel, btn) + self._plot.onMousePress( + event.x(), event.y(), self._MOUSE_BTNS[event.button()]) event.accept() def mouseMoveEvent(self, event): - xPixel = event.x() * self.getDevicePixelRatio() - yPixel = event.y() * self.getDevicePixelRatio() - - # Handle crosshair - inXPixel, inYPixel = self._mouseInPlotArea(xPixel, yPixel) - isCursorInPlot = inXPixel == xPixel and inYPixel == yPixel + qtPos = event.x(), event.y() previousMousePosInPixels = self._mousePosInPixels - self._mousePosInPixels = (xPixel, yPixel) if isCursorInPlot else None + 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(xPixel, yPixel) + self._plot.onMouseMove(*qtPos) event.accept() def mouseReleaseEvent(self, event): if event.button() not in self._MOUSE_BTNS: return super(BackendOpenGL, self).mouseReleaseEvent(event) - xPixel = event.x() * self.getDevicePixelRatio() - yPixel = event.y() * self.getDevicePixelRatio() - - btn = self._MOUSE_BTNS[event.button()] - self._plot.onMouseRelease(xPixel, yPixel, btn) + self._plot.onMouseRelease( + event.x(), event.y(), self._MOUSE_BTNS[event.button()]) event.accept() def wheelEvent(self, event): - xPixel = event.x() * self.getDevicePixelRatio() - yPixel = event.y() * self.getDevicePixelRatio() - if hasattr(event, 'angleDelta'): # Qt 5 delta = event.angleDelta().y() else: # Qt 4 support delta = event.delta() angleInDegrees = delta / 8. - self._plot.onMouseWheel(xPixel, yPixel, angleInDegrees) + self._plot.onMouseWheel(event.x(), event.y(), angleInDegrees) event.accept() def leaveEvent(self, _): @@ -371,7 +362,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): gl.glUniform1i(self._progTex.uniforms['tex'], texUnit) gl.glUniformMatrix4fv(self._progTex.uniforms['matrix'], 1, gl.GL_TRUE, - mat4Identity().astype(numpy.float32)) + glutils.mat4Identity().astype(numpy.float32)) gl.glEnableVertexAttribArray(self._progTex.attributes['position']) gl.glVertexAttribPointer(self._progTex.attributes['position'], @@ -405,10 +396,11 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_STENCIL_BUFFER_BIT) # Check if window is large enough - plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] - if plotWidth <= 2 or plotHeight <= 2: + if self._plotFrame.plotSize <= (2, 2): return + # Sync plot frame with window + self._plotFrame.devicePixelRatio = self.getDevicePixelRatio() # self._paintDirectGL() self._paintFBOGL() @@ -422,7 +414,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): True to render items that are overlays. """ # Values that are often used - plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] + plotWidth, plotHeight = self._plotFrame.plotSize isXLog = self._plotFrame.xAxis.isLog isYLog = self._plotFrame.yAxis.isLog isYInverted = self._plotFrame.isYAxisInverted @@ -431,6 +423,9 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): labels = [] pixelOffset = 3 + context = glutils.RenderContext( + isXLog=isXLog, isYLog=isYLog, dpi=self.getDotsPerInch()) + for plotItem in self.getItemsFromBackToFront( condition=lambda i: i.isVisible() and i.isOverlay() == overlay): if plotItem._backendRenderer is None: @@ -438,20 +433,16 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): item = plotItem._backendRenderer - if isinstance(item, (GLPlotCurve2D, - GLPlotColormap, - GLPlotRGBAImage, - GLPlotTriangles)): # Render data items + if isinstance(item, glutils.GLPlotItem): # Render data items gl.glViewport(self._plotFrame.margins.left, self._plotFrame.margins.bottom, plotWidth, plotHeight) - - if isinstance(item, GLPlotCurve2D) and item.info.get('yAxis') == 'right': - item.render(self._plotFrame.transformedDataY2ProjMat, - isXLog, isYLog) + # Set matrix + if item.yaxis == 'right': + context.matrix = self._plotFrame.transformedDataY2ProjMat else: - item.render(self._plotFrame.transformedDataProjMat, - isXLog, isYLog) + 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]) @@ -463,53 +454,67 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): if item['shape'] == 'hline': width = self._plotFrame.size[0] - _, yPixel = self._plot.dataToPixel( - None, item['y'], axis='left', check=False) - points = numpy.array(((0., yPixel), (width, yPixel)), - dtype=numpy.float32) + _, yPixel = self._plotFrame.dataToPixel( + 0.5 * sum(self._plotFrame.dataRanges[0]), + item['y'], + axis='left') + subShapes = [numpy.array(((0., yPixel), (width, yPixel)), + dtype=numpy.float32)] elif item['shape'] == 'vline': - xPixel, _ = self._plot.dataToPixel( - item['x'], None, axis='left', check=False) + xPixel, _ = self._plotFrame.dataToPixel( + item['x'], + 0.5 * sum(self._plotFrame.dataRanges[1]), + axis='left') height = self._plotFrame.size[1] - points = numpy.array(((xPixel, 0), (xPixel, height)), - dtype=numpy.float32) + subShapes = [numpy.array(((xPixel, 0), (xPixel, height)), + dtype=numpy.float32)] else: - points = numpy.array([ - self._plot.dataToPixel(x, y, axis='left', check=False) - for (x, y) in zip(item['x'], item['y'])]) - - # Draw the fill - if (item['fill'] is not None and - item['shape'] not in ('hline', 'vline')): - self._progBase.use() - gl.glUniformMatrix4fv( - self._progBase.uniforms['matrix'], 1, gl.GL_TRUE, - self.matScreenProj.astype(numpy.float32)) - gl.glUniform2i(self._progBase.uniforms['isLog'], False, False) - gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.) - - shape2D = FilledShape2D( - points, style=item['fill'], color=item['color']) - shape2D.render( - posAttrib=self._progBase.attributes['position'], - colorUnif=self._progBase.uniforms['color'], - hatchStepUnif=self._progBase.uniforms['hatchStep']) - - # Draw the stroke - if item['linestyle'] not in ('', ' ', None): - if item['shape'] != 'polylines': - # close the polyline - points = numpy.append(points, - numpy.atleast_2d(points[0]), axis=0) - - lines = GLLines2D(points[:, 0], points[:, 1], - style=item['linestyle'], - color=item['color'], - dash2ndColor=item['linebgcolor'], - width=item['linewidth']) - lines.render(self.matScreenProj) + # Split sub-shapes at not finite values + splits = numpy.nonzero(numpy.logical_not(numpy.logical_and( + numpy.isfinite(item['x']), numpy.isfinite(item['y']))))[0] + splits = numpy.concatenate(([-1], splits, [len(item['x'])])) + subShapes = [] + for begin, end in zip(splits[:-1] + 1, splits[1:]): + if end > begin: + subShapes.append(numpy.array([ + self._plotFrame.dataToPixel(x, y, axis='left') + for (x, y) in zip(item['x'][begin:end], item['y'][begin:end])])) + + for points in subShapes: # Draw each sub-shape + # Draw the fill + if (item['fill'] is not None and + item['shape'] not in ('hline', 'vline')): + self._progBase.use() + gl.glUniformMatrix4fv( + self._progBase.uniforms['matrix'], 1, gl.GL_TRUE, + self.matScreenProj.astype(numpy.float32)) + gl.glUniform2i(self._progBase.uniforms['isLog'], False, False) + gl.glUniform1f(self._progBase.uniforms['tickLen'], 0.) + + shape2D = glutils.FilledShape2D( + points, style=item['fill'], color=item['color']) + shape2D.render( + posAttrib=self._progBase.attributes['position'], + colorUnif=self._progBase.uniforms['color'], + hatchStepUnif=self._progBase.uniforms['hatchStep']) + + # Draw the stroke + if item['linestyle'] not in ('', ' ', None): + if item['shape'] != 'polylines': + # close the polyline + points = numpy.append(points, + numpy.atleast_2d(points[0]), axis=0) + + lines = glutils.GLLines2D( + points[:, 0], points[:, 1], + style=item['linestyle'], + color=item['color'], + dash2ndColor=item['linebgcolor'], + width=item['linewidth']) + context.matrix = self.matScreenProj + lines.render(context) elif isinstance(item, _MarkerItem): gl.glViewport(0, 0, self._plotFrame.size[0], self._plotFrame.size[1]) @@ -522,76 +527,103 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): continue if xCoord is None or yCoord is None: - pixelPos = self._plot.dataToPixel( - xCoord, yCoord, axis=yAxis, check=False) - 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 = Text2D(item['text'], x, y, - color=item['color'], - bgColor=(1., 1., 1., 0.5), - align=RIGHT, valign=BOTTOM) + label = glutils.Text2D( + item['text'], x, y, + color=item['color'], + bgColor=(1., 1., 1., 0.5), + align=glutils.RIGHT, + valign=glutils.BOTTOM, + devicePixelRatio=self.getDevicePixelRatio()) labels.append(label) width = self._plotFrame.size[0] - lines = GLLines2D((0, width), (pixelPos[1], pixelPos[1]), - style=item['linestyle'], - color=item['color'], - width=item['linewidth']) - lines.render(self.matScreenProj) + lines = glutils.GLLines2D( + (0, width), (pixelPos[1], pixelPos[1]), + style=item['linestyle'], + color=item['color'], + width=item['linewidth']) + context.matrix = self.matScreenProj + lines.render(context) else: # yCoord is None: vertical line in data space + yRange = self._plotFrame.dataRanges[1 if yAxis == 'left' else 2] + pixelPos = self._plotFrame.dataToPixel( + xCoord, 0.5 * sum(yRange), axis=yAxis) + if item['text'] is not None: x = pixelPos[0] + pixelOffset y = self._plotFrame.margins.top + pixelOffset - label = Text2D(item['text'], x, y, - color=item['color'], - bgColor=(1., 1., 1., 0.5), - align=LEFT, valign=TOP) + label = glutils.Text2D( + item['text'], x, y, + color=item['color'], + bgColor=(1., 1., 1., 0.5), + align=glutils.LEFT, + valign=glutils.TOP, + devicePixelRatio=self.getDevicePixelRatio()) labels.append(label) height = self._plotFrame.size[1] - lines = GLLines2D((pixelPos[0], pixelPos[0]), (0, height), - style=item['linestyle'], - color=item['color'], - width=item['linewidth']) - lines.render(self.matScreenProj) + lines = glutils.GLLines2D( + (pixelPos[0], pixelPos[0]), (0, height), + style=item['linestyle'], + color=item['color'], + width=item['linewidth']) + context.matrix = self.matScreenProj + lines.render(context) else: - pixelPos = self._plot.dataToPixel( - xCoord, yCoord, axis=yAxis, check=True) - if pixelPos is None: + 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 = BOTTOM + valign = glutils.BOTTOM vPixelOffset = -pixelOffset else: - valign = TOP + valign = glutils.TOP vPixelOffset = pixelOffset if item['text'] is not None: x = pixelPos[0] + pixelOffset y = pixelPos[1] + vPixelOffset - label = Text2D(item['text'], x, y, - color=item['color'], - bgColor=(1., 1., 1., 0.5), - align=LEFT, valign=valign) + label = glutils.Text2D( + item['text'], x, y, + color=item['color'], + bgColor=(1., 1., 1., 0.5), + align=glutils.LEFT, + valign=valign, + devicePixelRatio=self.getDevicePixelRatio()) labels.append(label) # For now simple implementation: using a curve for each marker # Should pack all markers to a single set of points - markerCurve = GLPlotCurve2D( + markerCurve = glutils.GLPlotCurve2D( numpy.array((pixelPos[0],), dtype=numpy.float64), numpy.array((pixelPos[1],), dtype=numpy.float64), marker=item['symbol'], markerColor=item['color'], markerSize=11) - markerCurve.render(self.matScreenProj, False, False) + + context = glutils.RenderContext( + matrix=self.matScreenProj, + isXLog=False, + isYLog=False, + dpi=self.getDotsPerInch()) + markerCurve.render(context) markerCurve.discard() else: @@ -605,7 +637,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): def _renderOverlayGL(self): """Render overlay layer: overlay items and crosshair.""" - plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] + plotWidth, plotHeight = self._plotFrame.plotSize # Scissor to plot area gl.glScissor(self._plotFrame.margins.left, @@ -658,7 +690,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): It renders the background, grid and items except overlays """ - plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] + plotWidth, plotHeight = self._plotFrame.plotSize gl.glScissor(self._plotFrame.margins.left, self._plotFrame.margins.bottom, @@ -687,9 +719,10 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): int(self.getDevicePixelRatio() * width), int(self.getDevicePixelRatio() * height)) - self.matScreenProj = mat4Ortho(0, self._plotFrame.size[0], - self._plotFrame.size[1], 0, - 1, -1) + self.matScreenProj = glutils.mat4Ortho( + 0, self._plotFrame.size[0], + self._plotFrame.size[1], 0, + 1, -1) # Store current ranges previousXRange = self.getGraphXLimits() @@ -824,21 +857,20 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): fillColor = None if fill is True: fillColor = color - curve = GLPlotCurve2D(x, y, colorArray, - xError=xerror, - yError=yerror, - lineStyle=linestyle, - lineColor=color, - lineWidth=linewidth, - marker=symbol, - markerColor=color, - markerSize=symbolsize, - fillColor=fillColor, - baseline=baseline, - isYLog=isYLog) - curve.info = { - 'yAxis': 'left' if yaxis is None else yaxis, - } + curve = glutils.GLPlotCurve2D( + x, y, colorArray, + xError=xerror, + yError=yerror, + lineStyle=linestyle, + lineColor=color, + lineWidth=linewidth, + marker=symbol, + markerColor=color, + markerSize=symbolsize, + fillColor=fillColor, + baseline=baseline, + isYLog=isYLog) + curve.yaxis = 'left' if yaxis is None else yaxis if yaxis == "right": self._plotFrame.isY2Axis = True @@ -853,7 +885,10 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): if data.ndim == 2: # Ensure array is contiguous and eventually convert its type - if data.dtype in (numpy.float32, numpy.uint8, numpy.uint16): + 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( @@ -861,24 +896,27 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): data = numpy.array(data, dtype=numpy.float32, order='C') normalization = colormap.getNormalization() - if normalization in GLPlotColormap.SUPPORTED_NORMALIZATIONS: + 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() - - image = GLPlotColormap(data, - origin, - scale, - colormapLut, - normalization, - gamma, - cmapRange, - alpha) + 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 = GLPlotRGBAImage(rgba, origin, scale, alpha) + image = glutils.GLPlotRGBAImage(rgba, origin, scale, alpha) elif len(data.shape) == 3: # For RGB, RGBA data @@ -893,7 +931,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): else: raise ValueError('Unsupported data type') - image = GLPlotRGBAImage(data, origin, scale, alpha) + image = glutils.GLPlotRGBAImage(data, origin, scale, alpha) else: raise RuntimeError("Unsupported data shape {0}".format(data.shape)) @@ -916,7 +954,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): if self._plotFrame.yAxis.isLog: y = numpy.log10(y) - triangles = GLPlotTriangles(x, y, color, triangles, alpha) + triangles = glutils.GLPlotTriangles(x, y, color, triangles, alpha) return triangles @@ -944,11 +982,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): # Remove methods def remove(self, item): - if isinstance(item, (GLPlotCurve2D, - GLPlotColormap, - GLPlotRGBAImage, - GLPlotTriangles)): - if isinstance(item, GLPlotCurve2D): + 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 @@ -997,13 +1032,18 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): _PICK_OFFSET = 3 # Offset in pixel used for picking def _mouseInPlotArea(self, x, y): - xPlot = numpy.clip( - x, self._plotFrame.margins.left, - self._plotFrame.size[0] - self._plotFrame.margins.right - 1) - yPlot = numpy.clip( - y, self._plotFrame.margins.top, - self._plotFrame.size[1] - self._plotFrame.margins.bottom - 1) - return xPlot, yPlot + """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. @@ -1016,22 +1056,26 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): """ offset = self._PICK_OFFSET if item.marker is not None: - offset = max(item.markerSize / 2., offset) + # Convert markerSize from points to qt pixels + qtDpi = self.getDotsPerInch() / self.getDevicePixelRatio() + size = item.markerSize / 72. * qtDpi + offset = max(size / 2., offset) if item.lineStyle is not None: - offset = max(item.lineWidth / 2., offset) - - yAxis = item.info['yAxis'] + # Convert line width from points to qt pixels + qtDpi = self.getDotsPerInch() / self.getDevicePixelRatio() + lineWidth = item.lineWidth / 72. * qtDpi + offset = max(lineWidth / 2., offset) inAreaPos = self._mouseInPlotArea(x - offset, y - offset) dataPos = self._plot.pixelToData(inAreaPos[0], inAreaPos[1], - axis=yAxis, check=True) + 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=yAxis, check=True) + axis=item.yaxis, check=True) if dataPos is None: return None xPick1, yPick1 = dataPos @@ -1051,8 +1095,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): xPickMin = numpy.log10(xPickMin) xPickMax = numpy.log10(xPickMax) - if (yAxis == 'left' and self._plotFrame.yAxis.isLog) or ( - yAxis == 'right' and self._plotFrame.y2Axis.isLog): + 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) @@ -1060,6 +1104,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): 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 @@ -1100,17 +1145,11 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): return (0,) if isPicked else None # Pick image, curve, triangles - elif isinstance(item, (GLPlotCurve2D, - GLPlotColormap, - GLPlotRGBAImage, - GLPlotTriangles)): - if isinstance(item, (GLPlotColormap, GLPlotRGBAImage, GLPlotTriangles)): - return item.pick(*dataPos) # Might be None - - elif isinstance(item, GLPlotCurve2D): + elif isinstance(item, glutils.GLPlotItem): + if isinstance(item, glutils.GLPlotCurve2D): return self.__pickCurves(item, x, y) else: - return None + return item.pick(*dataPos) # Might be None # Update curve @@ -1184,8 +1223,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): if axis == 'left': self._plotFrame.yAxis.title = label else: # right axis - if label: - _logger.warning('Right axis label not implemented') + self._plotFrame.y2Axis.title = label # Graph limits @@ -1209,7 +1247,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): :param str keepDim: The dimension to maintain: 'x', 'y' or None. If None (the default), the dimension with the largest range. """ - plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] + plotWidth, plotHeight = self._plotFrame.plotSize if plotWidth <= 2 or plotHeight <= 2: return @@ -1352,17 +1390,25 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): # Data <-> Pixel coordinates conversion def dataToPixel(self, x, y, axis): - return self._plotFrame.dataToPixel(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): - return self._plotFrame.pixelToData(x, y, axis) + devicePixelRatio = self.getDevicePixelRatio() + return self._plotFrame.pixelToData( + x * devicePixelRatio, y * devicePixelRatio, axis) def getPlotBoundsInPixels(self): - return self._plotFrame.plotOrigin + self._plotFrame.plotSize + devicePixelRatio = self.getDevicePixelRatio() + return tuple(int(value / devicePixelRatio) + for value in self._plotFrame.plotOrigin + self._plotFrame.plotSize) - def setAxesDisplayed(self, displayed): - BackendBase.BackendBase.setAxesDisplayed(self, displayed) - self._plotFrame.displayed = displayed + 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 diff --git a/silx/gui/plot/backends/glutils/GLPlotCurve.py b/silx/gui/plot/backends/glutils/GLPlotCurve.py index 9ab85fd..c4e2c1e 100644 --- a/silx/gui/plot/backends/glutils/GLPlotCurve.py +++ b/silx/gui/plot/backends/glutils/GLPlotCurve.py @@ -43,6 +43,7 @@ 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__) @@ -172,10 +173,10 @@ class _Fill2D(object): self._xFillVboData, self._yFillVboData = vertexBuffer(points.T) - def render(self, matrix): + def render(self, context): """Perform rendering - :param numpy.ndarray matrix: 4x4 transform matrix to use + :param RenderContext context: """ self.prepare() @@ -186,7 +187,7 @@ class _Fill2D(object): gl.glUniformMatrix4fv( self._PROGRAM.uniforms['matrix'], 1, gl.GL_TRUE, - numpy.dot(matrix, + numpy.dot(context.matrix, mat4Translate(*self.offset)).astype(numpy.float32)) gl.glUniform4f(self._PROGRAM.uniforms['color'], *self.color) @@ -404,11 +405,13 @@ class GLLines2D(object): """OpenGL context initialization""" gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST) - def render(self, matrix): + def render(self, context): """Perform rendering - :param numpy.ndarray matrix: 4x4 transform matrix to use + :param RenderContext context: """ + width = self.width / 72. * context.dpi + style = self.style if style is None: return @@ -425,7 +428,7 @@ class GLLines2D(object): gl.glUniform2f(program.uniforms['halfViewportSize'], 0.5 * viewWidth, 0.5 * viewHeight) - dashPeriod = self.dashPeriod * self.width + dashPeriod = self.dashPeriod * width if self.style == DOTTED: dash = (0.2 * dashPeriod, 0.5 * dashPeriod, @@ -463,10 +466,10 @@ class GLLines2D(object): 0, self.distVboData) - if self.width != 1: + if width != 1: gl.glEnable(gl.GL_LINE_SMOOTH) - matrix = numpy.dot(matrix, + matrix = numpy.dot(context.matrix, mat4Translate(*self.offset)).astype(numpy.float32) gl.glUniformMatrix4fv(program.uniforms['matrix'], 1, gl.GL_TRUE, matrix) @@ -503,7 +506,7 @@ class GLLines2D(object): 0, self.yVboData) - gl.glLineWidth(self.width) + gl.glLineWidth(width) gl.glDrawArrays(self._drawMode, 0, self.xVboData.size) gl.glDisable(gl.GL_LINE_SMOOTH) @@ -516,10 +519,26 @@ def distancesFromArrays(xData, yData): :param numpy.ndarray yData: Y coordinate of points :rtype: numpy.ndarray """ - deltas = numpy.dstack(( - numpy.ediff1d(xData, to_begin=numpy.float32(0.)), - numpy.ediff1d(yData, to_begin=numpy.float32(0.))))[0] - return numpy.cumsum(numpy.sqrt(numpy.sum(deltas ** 2, axis=1))) + # Split array into sub-shapes at not finite points + splits = numpy.nonzero(numpy.logical_not(numpy.logical_and( + numpy.isfinite(xData), numpy.isfinite(yData))))[0] + splits = numpy.concatenate(([-1], splits, [len(xData) - 1])) + + # Compute distance independently for each sub-shapes, + # putting not finite points as last points of sub-shapes + distances = [] + for begin, end in zip(splits[:-1] + 1, splits[1:] + 1): + if begin == end: # Empty shape + continue + elif end - begin == 1: # Single element + distances.append([0]) + else: + deltas = numpy.dstack(( + numpy.ediff1d(xData[begin:end], to_begin=numpy.float32(0.)), + numpy.ediff1d(yData[begin:end], to_begin=numpy.float32(0.))))[0] + distances.append( + numpy.cumsum(numpy.sqrt(numpy.sum(deltas ** 2, axis=1)))) + return numpy.concatenate(distances) # points ###################################################################### @@ -833,10 +852,10 @@ class _Points2D(object): if majorVersion >= 3: # OpenGL 3 gl.glEnable(gl.GL_PROGRAM_POINT_SIZE) - def render(self, matrix): + def render(self, context): """Perform rendering - :param numpy.ndarray matrix: 4x4 transform matrix to use + :param RenderContext context: """ if self.marker is None: return @@ -844,7 +863,7 @@ class _Points2D(object): program = self._getProgram(self.marker) program.use() - matrix = numpy.dot(matrix, + matrix = numpy.dot(context.matrix, mat4Translate(*self.offset)).astype(numpy.float32) gl.glUniformMatrix4fv(program.uniforms['matrix'], 1, gl.GL_TRUE, matrix) @@ -854,6 +873,13 @@ class _Points2D(object): size = math.ceil(0.5 * self.size) + 1 # Mimic Matplotlib point else: size = self.size + size = size / 72. * context.dpi + + if self.marker in (PLUS, H_LINE, V_LINE, + TICK_LEFT, TICK_RIGHT, TICK_UP, TICK_DOWN): + # Convert to nearest odd number + size = size // 2 * 2 + 1. + gl.glUniform1f(program.uniforms['size'], size) # gl.glPointSize(self.size) @@ -1021,17 +1047,17 @@ class _ErrorBars(object): self._yErrPoints.yVboData.offset += (yAttrib.itemsize * yAttrib.size // 2) - def render(self, matrix): + def render(self, context): """Perform rendering - :param numpy.ndarray matrix: 4x4 transform matrix to use + :param RenderContext context: """ self.prepare() if self._attribs is not None: - self._lines.render(matrix) - self._xErrPoints.render(matrix) - self._yErrPoints.render(matrix) + self._lines.render(context) + self._xErrPoints.render(context) + self._yErrPoints.render(context) def discard(self): """Release VBOs""" @@ -1067,7 +1093,7 @@ def _proxyProperty(*componentsAttributes): return property(getter, setter) -class GLPlotCurve2D(object): +class GLPlotCurve2D(GLPlotItem): def __init__(self, xData, yData, colorData=None, xError=None, yError=None, lineStyle=SOLID, @@ -1080,7 +1106,7 @@ class GLPlotCurve2D(object): fillColor=None, baseline=None, isYLog=False): - + super().__init__() self.colorData = colorData # Compute x bounds @@ -1220,19 +1246,17 @@ class GLPlotCurve2D(object): self.colorVboData = cAttrib self.useColorVboData = cAttrib is not None - def render(self, matrix, isXLog, isYLog): + def render(self, context): """Perform rendering - :param numpy.ndarray matrix: 4x4 transform matrix to use - :param bool isXLog: - :param bool isYLog: + :param RenderContext context: Rendering information """ self.prepare() if self.fill is not None: - self.fill.render(matrix) - self._errorBars.render(matrix) - self.lines.render(matrix) - self.points.render(matrix) + self.fill.render(context) + self._errorBars.render(context) + self.lines.render(context) + self.points.render(context) def discard(self): """Release VBOs""" diff --git a/silx/gui/plot/backends/glutils/GLPlotFrame.py b/silx/gui/plot/backends/glutils/GLPlotFrame.py index 43f6e10..c5ee75b 100644 --- a/silx/gui/plot/backends/glutils/GLPlotFrame.py +++ b/silx/gui/plot/backends/glutils/GLPlotFrame.py @@ -61,7 +61,7 @@ class PlotAxis(object): This class is intended to be used with :class:`GLPlotFrame`. """ - def __init__(self, plot, + def __init__(self, plotFrame, tickLength=(0., 0.), foregroundColor=(0., 0., 0., 1.0), labelAlign=CENTER, labelVAlign=CENTER, @@ -69,7 +69,7 @@ class PlotAxis(object): titleRotate=0, titleOffset=(0., 0.)): self._ticks = None - self._plot = weakref.ref(plot) + self._plotFrameRef = weakref.ref(plotFrame) self._isDateTime = False self._timeZone = None @@ -157,6 +157,12 @@ class PlotAxis(object): self._dirtyTicks() @property + def devicePixelRatio(self): + """Returns the ratio between qt pixels and device pixels.""" + plotFrame = self._plotFrameRef() + return plotFrame.devicePixelRatio if plotFrame is not None else 1. + + @property def title(self): """The text label associated with this axis as a str in latin-1.""" return self._title @@ -165,10 +171,18 @@ class PlotAxis(object): def title(self, title): if title != self._title: self._title = title + self._dirtyPlotFrame() - plot = self._plot() - if plot is not None: - plot._dirty() + @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): @@ -201,6 +215,8 @@ class PlotAxis(object): tickLabelsSize = [0., 0.] xTickLength, yTickLength = self._tickLength + xTickLength *= self.devicePixelRatio + yTickLength *= self.devicePixelRatio for (xPixel, yPixel), dataPos, text in self.ticks: if text is None: tickScale = 0.5 @@ -212,7 +228,8 @@ class PlotAxis(object): x=xPixel - xTickLength, y=yPixel - yTickLength, align=self._labelAlign, - valign=self._labelVAlign) + valign=self._labelVAlign, + devicePixelRatio=self.devicePixelRatio) width, height = label.size if width > tickLabelsSize[0]: @@ -230,7 +247,7 @@ class PlotAxis(object): xAxisCenter = 0.5 * (x0 + x1) yAxisCenter = 0.5 * (y0 + y1) - xOffset, yOffset = self._titleOffset + xOffset, yOffset = self.titleOffset # Adaptative title positioning: # tickNorm = math.sqrt(xTickLength ** 2 + yTickLength ** 2) @@ -245,17 +262,22 @@ class PlotAxis(object): y=yAxisCenter + yOffset, align=self._titleAlign, valign=self._titleVAlign, - rotate=self._titleRotate) + rotate=self._titleRotate, + devicePixelRatio=self.devicePixelRatio) labels.append(axisTitle) return vertices, labels + def _dirtyPlotFrame(self): + """Dirty parent GLPlotFrame""" + plotFrame = self._plotFrameRef() + if plotFrame is not None: + plotFrame._dirty() + def _dirtyTicks(self): """Mark ticks as dirty and notify listener (i.e., background).""" self._ticks = None - plot = self._plot() - if plot is not None: - plot._dirty() + self._dirtyPlotFrame() @staticmethod def _frange(start, stop, step): @@ -314,7 +336,7 @@ class PlotAxis(object): xScale = (x1 - x0) / (dataMax - dataMin) yScale = (y1 - y0) / (dataMax - dataMin) - nbPixels = math.sqrt(pow(x1 - x0, 2) + pow(y1 - y0, 2)) + 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 @@ -391,11 +413,11 @@ class GLPlotFrame(object): # Margins used when plot frame is not displayed _NoDisplayMargins = _Margins(0, 0, 0, 0) - def __init__(self, margins, foregroundColor, gridColor): + def __init__(self, marginRatios, foregroundColor, gridColor): """ - :param margins: The margins around plot area for axis and labels. - :type margins: dict with 'left', 'right', 'top', 'bottom' keys and - values as ints. + :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. @@ -403,7 +425,9 @@ class GLPlotFrame(object): """ self._renderResources = None - self._margins = self._Margins(**margins) + self.__marginRatios = marginRatios + self.__marginsCache = None + self._foregroundColor = foregroundColor self._gridColor = gridColor @@ -412,7 +436,8 @@ class GLPlotFrame(object): self._grid = False self._size = 0., 0. self._title = '' - self._displayed = True + + self._devicePixelRatio = 1. @property def isDirty(self): @@ -453,26 +478,49 @@ class GLPlotFrame(object): if self._gridColor != color: self._gridColor = color self._dirty() - + @property - def displayed(self): - """Whether axes and their labels are displayed or not (bool)""" - return self._displayed - - @displayed.setter - def displayed(self, displayed): - displayed = bool(displayed) - if displayed != self._displayed: - self._displayed = displayed + def marginRatios(self): + """Plot margin ratios: (left, top, right, bottom) as 4 float in [0, 1]. + """ + return self.__marginRatios + + @marginRatios.setter + def marginRatios(self, ratios): + ratios = tuple(float(v) for v in ratios) + assert len(ratios) == 4 + for value in ratios: + assert 0. <= value <= 1. + assert ratios[0] + ratios[2] < 1. + assert ratios[1] + ratios[3] < 1. + + if self.__marginRatios != ratios: + self.__marginRatios = ratios + self.__marginsCache = None # Clear cached margins self._dirty() @property def margins(self): """Margins in pixels around the plot.""" - if not self.displayed: - return self._NoDisplayMargins - else: - return self._margins + if self.__marginsCache is None: + width, height = self.size + left, top, right, bottom = self.marginRatios + self.__marginsCache = self._Margins( + left=int(left*width), + right=int(right*width), + top=int(top*height), + bottom=int(bottom*height)) + return self.__marginsCache + + @property + def devicePixelRatio(self): + return self._devicePixelRatio + + @devicePixelRatio.setter + def devicePixelRatio(self, ratio): + if ratio != self._devicePixelRatio: + self._devicePixelRatio = ratio + self._dirty() @property def grid(self): @@ -493,7 +541,7 @@ class GLPlotFrame(object): @property def size(self): - """Size in pixels of the plot area including margins.""" + """Size in device pixels of the plot area including margins.""" return self._size @size.setter @@ -502,6 +550,7 @@ class GLPlotFrame(object): size = tuple(size) if size != self._size: self._size = size + self.__marginsCache = None # Clear cached margins self._dirty() @property @@ -580,7 +629,8 @@ class GLPlotFrame(object): x=xTitle, y=yTitle, align=CENTER, - valign=BOTTOM)) + valign=BOTTOM, + devicePixelRatio=self.devicePixelRatio)) # grid gridVertices = numpy.array(self._buildGridVertices(), @@ -592,7 +642,7 @@ class GLPlotFrame(object): _SHADERS['vertex'], _SHADERS['fragment'], attrib0='position') def render(self): - if not self.displayed: + if self.margins == self._NoDisplayMargins: return if self._renderResources is None: @@ -661,25 +711,24 @@ class GLPlotFrame(object): # GLPlotFrame2D ############################################################### class GLPlotFrame2D(GLPlotFrame): - def __init__(self, margins, foregroundColor, gridColor): + def __init__(self, marginRatios, foregroundColor, gridColor): """ - :param margins: The margins around plot area for axis and labels. - :type margins: dict with 'left', 'right', 'top', 'bottom' keys and - values as ints. + :param List[float] marginRatios: + The ratios of margins around plot area for axis and labels. + (left, top, right, bottom) as float in [0., 1.] :param foregroundColor: color used for the frame and labels. :type foregroundColor: tuple with RGBA values ranging from 0.0 to 1.0 :param gridColor: color used for grid lines. :type gridColor: tuple RGBA with RGBA values ranging from 0.0 to 1.0 """ - super(GLPlotFrame2D, self).__init__(margins, foregroundColor, gridColor) + super(GLPlotFrame2D, self).__init__(marginRatios, foregroundColor, gridColor) self.axes.append(PlotAxis(self, tickLength=(0., -5.), foregroundColor=self._foregroundColor, labelAlign=CENTER, labelVAlign=TOP, titleAlign=CENTER, titleVAlign=TOP, - titleRotate=0, - titleOffset=(0, self.margins.bottom // 2))) + titleRotate=0)) self._x2AxisCoords = () @@ -688,18 +737,14 @@ class GLPlotFrame2D(GLPlotFrame): foregroundColor=self._foregroundColor, labelAlign=RIGHT, labelVAlign=CENTER, titleAlign=CENTER, titleVAlign=BOTTOM, - titleRotate=ROTATE_270, - titleOffset=(-3 * self.margins.left // 4, - 0))) + titleRotate=ROTATE_270)) self._y2Axis = PlotAxis(self, tickLength=(-5., 0.), foregroundColor=self._foregroundColor, labelAlign=LEFT, labelVAlign=CENTER, titleAlign=CENTER, titleVAlign=TOP, - titleRotate=ROTATE_270, - titleOffset=(3 * self.margins.right // 4, - 0)) + titleRotate=ROTATE_270) self._isYAxisInverted = False @@ -794,6 +839,24 @@ class GLPlotFrame2D(GLPlotFrame): 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. diff --git a/silx/gui/plot/backends/glutils/GLPlotImage.py b/silx/gui/plot/backends/glutils/GLPlotImage.py index e985a3d..f60a159 100644 --- a/silx/gui/plot/backends/glutils/GLPlotImage.py +++ b/silx/gui/plot/backends/glutils/GLPlotImage.py @@ -40,10 +40,12 @@ 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(object): +class _GLPlotData2D(GLPlotItem): def __init__(self, data, origin, scale): + super().__init__() self.data = data assert len(origin) == 2 self.origin = tuple(origin) @@ -80,15 +82,6 @@ class _GLPlotData2D(object): oy, sy = self.origin[1], self.scale[1] return oy + sy * self.data.shape[0] if sy >= 0. else oy - def discard(self): - pass - - def prepare(self): - pass - - def render(self, matrix, isXLog, isYLog): - pass - class GLPlotColormap(_GLPlotData2D): @@ -160,6 +153,11 @@ class GLPlotColormap(_GLPlotData2D): 'fragment': """ #version 120 + /* isnan declaration for compatibility with GLSL 1.20 */ + bool isnan(float value) { + return (value != value); + } + uniform sampler2D data; uniform sampler2D cmap_texture; uniform int cmap_normalization; @@ -167,6 +165,7 @@ class GLPlotColormap(_GLPlotData2D): uniform float cmap_min; uniform float cmap_oneOverRange; uniform float alpha; + uniform vec4 nancolor; varying vec2 coords; @@ -175,7 +174,8 @@ class GLPlotColormap(_GLPlotData2D): const float oneOverLog10 = 0.43429448190325176; void main(void) { - float value = texture2D(data, textureCoords()).r; + float data = texture2D(data, textureCoords()).r; + float value = data; if (cmap_normalization == 1) { /*Logarithm mapping*/ if (value > 0.) { value = clamp(cmap_oneOverRange * @@ -202,7 +202,11 @@ class GLPlotColormap(_GLPlotData2D): value = clamp(cmap_oneOverRange * (value - cmap_min), 0., 1.); } - gl_FragColor = texture2D(cmap_texture, vec2(value, 0.5)); + if (isnan(data)) { + gl_FragColor = nancolor; + } else { + gl_FragColor = texture2D(cmap_texture, vec2(value, 0.5)); + } gl_FragColor.a *= alpha; } """ @@ -213,6 +217,7 @@ class GLPlotColormap(_GLPlotData2D): _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, @@ -232,7 +237,7 @@ class GLPlotColormap(_GLPlotData2D): def __init__(self, data, origin, scale, colormap, normalization='linear', gamma=0., cmapRange=None, - alpha=1.0): + alpha=1.0, nancolor=(1., 1., 1., 0.)): """Create a 2D colormap :param data: The 2D scalar data array to display @@ -252,6 +257,8 @@ class GLPlotColormap(_GLPlotData2D): 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 @@ -263,6 +270,7 @@ class GLPlotColormap(_GLPlotData2D): self._cmapRange = (1., 10.) # Colormap range self.cmapRange = cmapRange # Update _cmapRange self._alpha = numpy.clip(alpha, 0., 1.) + self._nancolor = numpy.clip(nancolor, 0., 1.) self._cmap_texture = None self._texture = None @@ -283,7 +291,7 @@ class GLPlotColormap(_GLPlotData2D): if self.normalization == 'log': assert self._cmapRange[0] > 0. and self._cmapRange[1] > 0. elif self.normalization == 'sqrt': - assert self._cmapRange[0] >= 0. and self._cmapRange[1] > 0. + assert self._cmapRange[0] >= 0. and self._cmapRange[1] >= 0. return self._cmapRange @cmapRange.setter @@ -324,6 +332,7 @@ class GLPlotColormap(_GLPlotData2D): 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] @@ -376,9 +385,15 @@ class GLPlotColormap(_GLPlotData2D): oneOverRange = 0. # Fall-back gl.glUniform1f(prog.uniforms['cmap_oneOverRange'], oneOverRange) + gl.glUniform4f(prog.uniforms['nancolor'], *self._nancolor) + self._cmap_texture.bind() - def _renderLinear(self, matrix): + def _renderLinear(self, context): + """Perform rendering when both axes have linear scales + + :param RenderContext context: Rendering information + """ self.prepare() prog = self._linearProgram @@ -386,7 +401,7 @@ class GLPlotColormap(_GLPlotData2D): gl.glUniform1i(prog.uniforms['data'], self._DATA_TEX_UNIT) - mat = numpy.dot(numpy.dot(matrix, + mat = numpy.dot(numpy.dot(context.matrix, mat4Translate(*self.origin)), mat4Scale(*self.scale)) gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, @@ -400,10 +415,14 @@ class GLPlotColormap(_GLPlotData2D): prog.attributes['texCoords'], self._DATA_TEX_UNIT) - def _renderLog10(self, matrix, isXLog, isYLog): + def _renderLog10(self, context): + """Perform rendering when one axis has log scale + + :param RenderContext context: Rendering information + """ xMin, yMin = self.xMin, self.yMin - if ((isXLog and xMin < FLOAT32_MINPOS) or - (isYLog and yMin < FLOAT32_MINPOS)): + 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 @@ -417,12 +436,12 @@ class GLPlotColormap(_GLPlotData2D): gl.glUniform1i(prog.uniforms['data'], self._DATA_TEX_UNIT) gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, - matrix.astype(numpy.float32)) + 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'], isXLog, isYLog) + 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] @@ -461,11 +480,15 @@ class GLPlotColormap(_GLPlotData2D): gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(vertices)) - def render(self, matrix, isXLog, isYLog): - if any((isXLog, isYLog)): - self._renderLog10(matrix, isXLog, isYLog) + def render(self, context): + """Perform rendering + + :param RenderContext context: Rendering information + """ + if any((context.isXLog, context.isYLog)): + self._renderLog10(context) else: - self._renderLinear(matrix) + self._renderLinear(context) # Unbind colormap texture gl.glActiveTexture(gl.GL_TEXTURE0 + self._cmap_texture.texUnit) @@ -635,7 +658,11 @@ class GLPlotRGBAImage(_GLPlotData2D): format_ = gl.GL_RGBA if self.data.shape[2] == 4 else gl.GL_RGB self._texture.updateAll(format_=format_, data=self.data) - def _renderLinear(self, matrix): + def _renderLinear(self, context): + """Perform rendering with both axes having linear scales + + :param RenderContext context: Rendering information + """ self.prepare() prog = self._linearProgram @@ -643,7 +670,7 @@ class GLPlotRGBAImage(_GLPlotData2D): gl.glUniform1i(prog.uniforms['tex'], self._DATA_TEX_UNIT) - mat = numpy.dot(numpy.dot(matrix, mat4Translate(*self.origin)), + 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)) @@ -654,7 +681,11 @@ class GLPlotRGBAImage(_GLPlotData2D): prog.attributes['texCoords'], self._DATA_TEX_UNIT) - def _renderLog(self, matrix, isXLog, isYLog): + def _renderLog(self, context): + """Perform rendering with axes having log scale + + :param RenderContext context: Rendering information + """ self.prepare() prog = self._logProgram @@ -665,12 +696,12 @@ class GLPlotRGBAImage(_GLPlotData2D): gl.glUniform1i(prog.uniforms['tex'], self._DATA_TEX_UNIT) gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, - matrix.astype(numpy.float32)) + 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'], isXLog, isYLog) + gl.glUniform2i(prog.uniforms['isLog'], context.isXLog, context.isYLog) gl.glUniform1f(prog.uniforms['alpha'], self.alpha) @@ -707,8 +738,12 @@ class GLPlotRGBAImage(_GLPlotData2D): gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(vertices)) - def render(self, matrix, isXLog, isYLog): - if any((isXLog, isYLog)): - self._renderLog(matrix, isXLog, isYLog) + def render(self, context): + """Perform rendering + + :param RenderContext context: Rendering information + """ + if any((context.isXLog, context.isYLog)): + self._renderLog(context) else: - self._renderLinear(matrix) + self._renderLinear(context) diff --git a/silx/gui/plot/backends/glutils/GLPlotItem.py b/silx/gui/plot/backends/glutils/GLPlotItem.py new file mode 100644 index 0000000..899f38e --- /dev/null +++ b/silx/gui/plot/backends/glutils/GLPlotItem.py @@ -0,0 +1,94 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 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 a base class for PlotWidget OpenGL backend primitives +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "02/07/2020" + + +class RenderContext: + """Context with which to perform OpenGL rendering. + + :param numpy.ndarray matrix: 4x4 transform matrix to use for rendering + :param bool isXLog: Whether X axis is log scale or not + :param bool isYLog: Whether Y axis is log scale or not + :param float dpi: Number of device pixels per inch + """ + + def __init__(self, matrix=None, isXLog=False, isYLog=False, dpi=96.): + self.matrix = matrix + """Current transformation matrix""" + + self.__isXLog = isXLog + self.__isYLog = isYLog + self.__dpi = dpi + + @property + def isXLog(self): + """True if X axis is using log scale""" + return self.__isXLog + + @property + def isYLog(self): + """True if Y axis is using log scale""" + return self.__isYLog + + @property + def dpi(self): + """Number of device pixels per inch""" + return self.__dpi + + +class GLPlotItem: + """Base class for primitives used in the PlotWidget OpenGL backend""" + + def __init__(self): + self.yaxis = 'left' + "YAxis this item is attached to (either 'left' or 'right')" + + def pick(self, x, y): + """Perform picking at given position. + + :param float x: X coordinate in plot data frame of reference + :param float y: Y coordinate in plot data frame of reference + :returns: + Result of picking as a list of indices or None if nothing picked + :rtype: Union[List[int],None] + """ + return None + + def render(self, context): + """Performs OpenGL rendering of the item. + + :param RenderContext context: Rendering context information + """ + pass + + def discard(self): + """Discards OpenGL resources this item has created.""" + pass diff --git a/silx/gui/plot/backends/glutils/GLPlotTriangles.py b/silx/gui/plot/backends/glutils/GLPlotTriangles.py index 7aeb5ab..d5ba1a6 100644 --- a/silx/gui/plot/backends/glutils/GLPlotTriangles.py +++ b/silx/gui/plot/backends/glutils/GLPlotTriangles.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2019 European Synchrotron Radiation Facility +# Copyright (c) 2019-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 @@ -38,9 +38,10 @@ import numpy from .....math.combo import min_max from .... import _glutils as glutils from ...._glutils import gl +from .GLPlotItem import GLPlotItem -class GLPlotTriangles(object): +class GLPlotTriangles(GLPlotItem): """Handle rendering of a set of colored triangles""" _PROGRAM = glutils.Program( @@ -81,6 +82,7 @@ class GLPlotTriangles(object): :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)) @@ -161,12 +163,10 @@ class GLPlotTriangles(object): usage=gl.GL_STATIC_DRAW, target=gl.GL_ELEMENT_ARRAY_BUFFER) - def render(self, matrix, isXLog, isYLog): + def render(self, context): """Perform rendering - :param numpy.ndarray matrix: 4x4 transform matrix to use - :param bool isXLog: - :param bool isYLog: + :param RenderContext context: Rendering information """ self.prepare() @@ -178,7 +178,7 @@ class GLPlotTriangles(object): gl.glUniformMatrix4fv(self._PROGRAM.uniforms['matrix'], 1, gl.GL_TRUE, - matrix.astype(numpy.float32)) + context.matrix.astype(numpy.float32)) gl.glUniform1f(self._PROGRAM.uniforms['alpha'], self.__alpha) diff --git a/silx/gui/plot/backends/glutils/GLText.py b/silx/gui/plot/backends/glutils/GLText.py index 725c12c..d6ae6fa 100644 --- a/silx/gui/plot/backends/glutils/GLText.py +++ b/silx/gui/plot/backends/glutils/GLText.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2019 European Synchrotron Radiation Facility +# 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 @@ -140,7 +140,9 @@ class Text2D(object): color=(0., 0., 0., 1.), bgColor=None, align=LEFT, valign=BASELINE, - rotate=0): + rotate=0, + devicePixelRatio= 1.): + self.devicePixelRatio = devicePixelRatio self._vertices = None self._text = text self.x = x @@ -160,30 +162,35 @@ class Text2D(object): self._rotate = numpy.radians(rotate) - def _getTexture(self, text): + def _getTexture(self, text, devicePixelRatio): # Retrieve/initialize texture cache for current context + textureKey = text, devicePixelRatio + context = Context.getCurrent() if context not in self._textures: self._textures[context] = _Cache( callback=lambda key, value: value[0].discard()) textures = self._textures[context] - if text not in textures: - image, offset = font.rasterText(text, - font.getDefaultFontFamily()) - if text not in self._sizes: - self._sizes[text] = image.shape[1], image.shape[0] - - textures[text] = ( - Texture(gl.GL_RED, - data=image, - minFilter=gl.GL_NEAREST, - magFilter=gl.GL_NEAREST, - wrap=(gl.GL_CLAMP_TO_EDGE, - gl.GL_CLAMP_TO_EDGE)), - offset) - - return textures[text] + if textureKey not in textures: + image, offset = font.rasterText( + text, + font.getDefaultFontFamily(), + devicePixelRatio=self.devicePixelRatio) + if textureKey not in self._sizes: + self._sizes[textureKey] = image.shape[1], image.shape[0] + + texture = Texture( + gl.GL_RED, + data=image, + minFilter=gl.GL_NEAREST, + magFilter=gl.GL_NEAREST, + wrap=(gl.GL_CLAMP_TO_EDGE, + gl.GL_CLAMP_TO_EDGE)) + texture.prepare() + textures[textureKey] = texture, offset + + return textures[textureKey] @property def text(self): @@ -191,11 +198,14 @@ class Text2D(object): @property def size(self): - if self.text not in self._sizes: - image, offset = font.rasterText(self.text, - font.getDefaultFontFamily()) - self._sizes[self.text] = image.shape[1], image.shape[0] - return self._sizes[self.text] + textureKey = self.text, self.devicePixelRatio + if textureKey not in self._sizes: + image, offset = font.rasterText( + self.text, + font.getDefaultFontFamily(), + devicePixelRatio=self.devicePixelRatio) + self._sizes[textureKey] = image.shape[1], image.shape[0] + return self._sizes[textureKey] def getVertices(self, offset, shape): height, width = shape @@ -238,7 +248,7 @@ class Text2D(object): prog.use() texUnit = 0 - texture, offset = self._getTexture(self.text) + texture, offset = self._getTexture(self.text, self.devicePixelRatio) gl.glUniform1i(prog.uniforms['texText'], texUnit) diff --git a/silx/gui/plot/backends/glutils/GLTexture.py b/silx/gui/plot/backends/glutils/GLTexture.py index 118a36f..37fbdd0 100644 --- a/silx/gui/plot/backends/glutils/GLTexture.py +++ b/silx/gui/plot/backends/glutils/GLTexture.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2019 European Synchrotron Radiation Facility +# 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 @@ -98,6 +98,7 @@ class Image(object): minFilter=self._MIN_FILTER, magFilter=self._MAG_FILTER, wrap=self._WRAP) + texture.prepare() vertices = numpy.array(( (0., 0., 0., 0.), (self.width, 0., 1., 0.), @@ -177,6 +178,7 @@ class Image(object): (xOrig, yOrig + hData, 0., vMax), (xOrig + wData, yOrig + hData, uMax, vMax)), dtype=numpy.float32) + texture.prepare() tiles.append((texture, vertices, {'xOrigData': xOrig, 'yOrigData': yOrig, 'wData': wData, 'hData': hData})) @@ -203,6 +205,7 @@ class Image(object): 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, diff --git a/silx/gui/plot/backends/glutils/__init__.py b/silx/gui/plot/backends/glutils/__init__.py index d58c084..f87d7c1 100644 --- a/silx/gui/plot/backends/glutils/__init__.py +++ b/silx/gui/plot/backends/glutils/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2019 European Synchrotron Radiation Facility +# 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 @@ -39,6 +39,7 @@ _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 |