diff options
Diffstat (limited to 'src/silx/gui/plot/backends/glutils')
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLPlotCurve.py | 1494 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLPlotFrame.py | 1399 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLPlotImage.py | 789 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLPlotItem.py | 105 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLPlotTriangles.py | 203 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLSupport.py | 174 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLText.py | 297 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/GLTexture.py | 269 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/PlotImageFile.py | 159 | ||||
-rw-r--r-- | src/silx/gui/plot/backends/glutils/__init__.py | 45 |
10 files changed, 4934 insertions, 0 deletions
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 |