diff options
Diffstat (limited to 'silx/gui/plot/backends/glutils')
-rw-r--r-- | silx/gui/plot/backends/glutils/GLPlotCurve.py | 1317 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLPlotFrame.py | 1039 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLPlotImage.py | 707 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLSupport.py | 192 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLText.py | 222 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/GLTexture.py | 239 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/PlotImageFile.py | 149 | ||||
-rw-r--r-- | silx/gui/plot/backends/glutils/__init__.py | 44 |
8 files changed, 3909 insertions, 0 deletions
diff --git a/silx/gui/plot/backends/glutils/GLPlotCurve.py b/silx/gui/plot/backends/glutils/GLPlotCurve.py new file mode 100644 index 0000000..4f08054 --- /dev/null +++ b/silx/gui/plot/backends/glutils/GLPlotCurve.py @@ -0,0 +1,1317 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +This 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 numpyToGLType, Program, vertexBuffer +from ..._utils import FLOAT32_MINPOS +from .GLSupport import buildFillMaskIndices + + +_logger = logging.getLogger(__name__) + + +_MPL_NONES = None, 'None', '', ' ' + + +# fill ######################################################################## + +class _Fill2D(object): + _LINEAR, _LOG10_X, _LOG10_Y, _LOG10_X_Y = 0, 1, 2, 3 + + _SHADERS = { + 'vertexTransforms': { + _LINEAR: """ + vec4 transformXY(float x, float y) { + return vec4(x, y, 0.0, 1.0); + } + """, + _LOG10_X: """ + const float oneOverLog10 = 0.43429448190325176; + + vec4 transformXY(float x, float y) { + return vec4(oneOverLog10 * log(x), y, 0.0, 1.0); + } + """, + _LOG10_Y: """ + const float oneOverLog10 = 0.43429448190325176; + + vec4 transformXY(float x, float y) { + return vec4(x, oneOverLog10 * log(y), 0.0, 1.0); + } + """, + _LOG10_X_Y: """ + const float oneOverLog10 = 0.43429448190325176; + + vec4 transformXY(float x, float y) { + return vec4(oneOverLog10 * log(x), + oneOverLog10 * log(y), + 0.0, 1.0); + } + """ + }, + 'vertex': """ + #version 120 + + uniform mat4 matrix; + attribute float xPos; + attribute float yPos; + + %s + + void main(void) { + gl_Position = matrix * transformXY(xPos, yPos); + } + """, + 'fragment': """ + #version 120 + + uniform vec4 color; + + void main(void) { + gl_FragColor = color; + } + """ + } + + _programs = { + _LINEAR: Program( + _SHADERS['vertex'] % _SHADERS['vertexTransforms'][_LINEAR], + _SHADERS['fragment'], attrib0='xPos'), + _LOG10_X: Program( + _SHADERS['vertex'] % _SHADERS['vertexTransforms'][_LOG10_X], + _SHADERS['fragment'], attrib0='xPos'), + _LOG10_Y: Program( + _SHADERS['vertex'] % _SHADERS['vertexTransforms'][_LOG10_Y], + _SHADERS['fragment'], attrib0='xPos'), + _LOG10_X_Y: Program( + _SHADERS['vertex'] % _SHADERS['vertexTransforms'][_LOG10_X_Y], + _SHADERS['fragment'], attrib0='xPos'), + } + + def __init__(self, xFillVboData=None, yFillVboData=None, + xMin=None, yMin=None, xMax=None, yMax=None, + color=(0., 0., 0., 1.)): + self.xFillVboData = xFillVboData + self.yFillVboData = yFillVboData + self.xMin, self.yMin = xMin, yMin + self.xMax, self.yMax = xMax, yMax + self.color = color + + self._bboxVertices = None + self._indices = None + self._indicesType = None + + def prepare(self): + if self._indices is None: + self._indices = buildFillMaskIndices(self.xFillVboData.size) + self._indicesType = numpyToGLType(self._indices.dtype) + + if self._bboxVertices is None: + yMin, yMax = min(self.yMin, 1e-32), max(self.yMax, 1e-32) + self._bboxVertices = numpy.array(((self.xMin, self.xMin, + self.xMax, self.xMax), + (yMin, yMax, yMin, yMax)), + dtype=numpy.float32) + + def render(self, matrix, isXLog, isYLog): + self.prepare() + + if isXLog: + transform = self._LOG10_X_Y if isYLog else self._LOG10_X + else: + transform = self._LOG10_Y if isYLog else self._LINEAR + + prog = self._programs[transform] + prog.use() + + gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix) + + gl.glUniform4f(prog.uniforms['color'], *self.color) + + xPosAttrib = prog.attributes['xPos'] + yPosAttrib = prog.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.glDrawElements(gl.GL_TRIANGLE_STRIP, self._indices.size, + self._indicesType, 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(xPosAttrib, 1, gl.GL_FLOAT, gl.GL_FALSE, 0, + self._bboxVertices[0]) + gl.glVertexAttribPointer(yPosAttrib, 1, gl.GL_FLOAT, gl.GL_FALSE, 0, + self._bboxVertices[1]) + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, self._bboxVertices[0].size) + + gl.glDisable(gl.GL_STENCIL_TEST) + + +# line ######################################################################## + +SOLID, DASHED, DASHDOT, DOTTED = '-', '--', '-.', ':' + + +class _Lines2D(object): + STYLES = SOLID, DASHED, DASHDOT, DOTTED + """Supported line styles""" + + _LINEAR, _LOG10_X, _LOG10_Y, _LOG10_X_Y = 0, 1, 2, 3 + + _SHADERS = { + 'vertexTransforms': { + _LINEAR: """ + vec4 transformXY(float x, float y) { + return vec4(x, y, 0.0, 1.0); + } + """, + _LOG10_X: """ + const float oneOverLog10 = 0.43429448190325176; + + vec4 transformXY(float x, float y) { + return vec4(oneOverLog10 * log(x), y, 0.0, 1.0); + } + """, + _LOG10_Y: """ + const float oneOverLog10 = 0.43429448190325176; + + vec4 transformXY(float x, float y) { + return vec4(x, oneOverLog10 * log(y), 0.0, 1.0); + } + """, + _LOG10_X_Y: """ + const float oneOverLog10 = 0.43429448190325176; + + vec4 transformXY(float x, float y) { + return vec4(oneOverLog10 * log(x), + oneOverLog10 * log(y), + 0.0, 1.0); + } + """ + }, + 'solid': { + 'vertex': """ + #version 120 + + uniform mat4 matrix; + attribute float xPos; + attribute float yPos; + attribute vec4 color; + + varying vec4 vColor; + + %s + + void main(void) { + gl_Position = matrix * transformXY(xPos, yPos); + vColor = color; + } + """, + 'fragment': """ + #version 120 + + varying vec4 vColor; + + void main(void) { + gl_FragColor = vColor; + } + """ + }, + + + # 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 + 'dashed': { + 'vertex': """ + #version 120 + + uniform mat4 matrix; + uniform vec2 halfViewportSize; + attribute float xPos; + attribute float yPos; + attribute vec4 color; + attribute float distance; + + varying float vDist; + varying vec4 vColor; + + %s + + void main(void) { + gl_Position = matrix * transformXY(xPos, yPos); + //Estimate distance in pixels + vec2 probe = vec2(matrix * vec4(1., 1., 0., 0.)) * + halfViewportSize; + float pixelPerDataEstimate = length(probe)/sqrt(2.); + vDist = distance * pixelPerDataEstimate; + vColor = color; + } + """, + 'fragment': """ + #version 120 + + /* Dashes: [0, x], [y, z] + Dash period: w */ + uniform vec4 dash; + + varying float vDist; + varying vec4 vColor; + + void main(void) { + float dist = mod(vDist, dash.w); + if ((dist > dash.x && dist < dash.y) || dist > dash.z) { + discard; + } + gl_FragColor = vColor; + } + """ + } + } + + _programs = {} + + def __init__(self, xVboData=None, yVboData=None, + colorVboData=None, distVboData=None, + style=SOLID, color=(0., 0., 0., 1.), + width=1, dashPeriod=20, drawMode=None): + self.xVboData = xVboData + self.yVboData = yVboData + self.distVboData = distVboData + self.colorVboData = colorVboData + self.useColorVboData = colorVboData is not None + + self.color = color + self._width = 1 + self.width = width + self._style = None + self.style = style + self.dashPeriod = dashPeriod + + self._drawMode = drawMode if drawMode is not None else gl.GL_LINE_STRIP + + @property + def style(self): + return self._style + + @style.setter + def style(self, style): + if style in _MPL_NONES: + self._style = None + self.render = self._renderNone + else: + assert style in self.STYLES + self._style = style + if style == SOLID: + self.render = self._renderSolid + else: # DASHED, DASHDOT, DOTTED + self.render = self._renderDash + + @property + def width(self): + return self._width + + @width.setter + def width(self, width): + # try: + # widthRange = self._widthRange + # except AttributeError: + # widthRange = gl.glGetFloatv(gl.GL_ALIASED_LINE_WIDTH_RANGE) + # # Shared among contexts, this should be enough.. + # _Lines2D._widthRange = widthRange + # assert width >= widthRange[0] and width <= widthRange[1] + self._width = width + + @classmethod + def _getProgram(cls, transform, style): + try: + prgm = cls._programs[(transform, style)] + except KeyError: + sources = cls._SHADERS[style] + vertexShdr = sources['vertex'] % \ + cls._SHADERS['vertexTransforms'][transform] + prgm = Program(vertexShdr, sources['fragment'], attrib0='xPos') + cls._programs[(transform, style)] = prgm + return prgm + + @classmethod + def init(cls): + gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST) + + def _renderNone(self, matrix, isXLog, isYLog): + pass + + render = _renderNone # Overridden in style setter + + def _renderSolid(self, matrix, isXLog, isYLog): + if isXLog: + transform = self._LOG10_X_Y if isYLog else self._LOG10_X + else: + transform = self._LOG10_Y if isYLog else self._LINEAR + + prog = self._getProgram(transform, 'solid') + prog.use() + + gl.glEnable(gl.GL_LINE_SMOOTH) + + gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix) + + colorAttrib = prog.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 = prog.attributes['xPos'] + gl.glEnableVertexAttribArray(xPosAttrib) + self.xVboData.setVertexAttrib(xPosAttrib) + + yPosAttrib = prog.attributes['yPos'] + gl.glEnableVertexAttribArray(yPosAttrib) + self.yVboData.setVertexAttrib(yPosAttrib) + + gl.glLineWidth(self.width) + gl.glDrawArrays(self._drawMode, 0, self.xVboData.size) + + gl.glDisable(gl.GL_LINE_SMOOTH) + + def _renderDash(self, matrix, isXLog, isYLog): + if isXLog: + transform = self._LOG10_X_Y if isYLog else self._LOG10_X + else: + transform = self._LOG10_Y if isYLog else self._LINEAR + + prog = self._getProgram(transform, 'dashed') + prog.use() + + gl.glEnable(gl.GL_LINE_SMOOTH) + + gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matrix) + x, y, viewWidth, viewHeight = gl.glGetFloatv(gl.GL_VIEWPORT) + gl.glUniform2f(prog.uniforms['halfViewportSize'], + 0.5 * viewWidth, 0.5 * viewHeight) + + if self.style == DOTTED: + dash = (0.1 * self.dashPeriod, + 0.6 * self.dashPeriod, + 0.7 * self.dashPeriod, + self.dashPeriod) + elif self.style == DASHDOT: + dash = (0.3 * self.dashPeriod, + 0.5 * self.dashPeriod, + 0.6 * self.dashPeriod, + self.dashPeriod) + else: + dash = (0.5 * self.dashPeriod, + self.dashPeriod, + self.dashPeriod, + self.dashPeriod) + + gl.glUniform4f(prog.uniforms['dash'], *dash) + + colorAttrib = prog.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) + + distAttrib = prog.attributes['distance'] + gl.glEnableVertexAttribArray(distAttrib) + self.distVboData.setVertexAttrib(distAttrib) + + xPosAttrib = prog.attributes['xPos'] + gl.glEnableVertexAttribArray(xPosAttrib) + self.xVboData.setVertexAttrib(xPosAttrib) + + yPosAttrib = prog.attributes['yPos'] + gl.glEnableVertexAttribArray(yPosAttrib) + self.yVboData.setVertexAttrib(yPosAttrib) + + gl.glLineWidth(self.width) + gl.glDrawArrays(self._drawMode, 0, self.xVboData.size) + + gl.glDisable(gl.GL_LINE_SMOOTH) + + +def _distancesFromArrays(xData, yData): + deltas = numpy.dstack(( + numpy.ediff1d(xData, to_begin=numpy.float32(0.)), + numpy.ediff1d(yData, to_begin=numpy.float32(0.))))[0] + return numpy.cumsum(numpy.sqrt(numpy.sum(deltas ** 2, axis=1))) + + +# points ###################################################################### + +DIAMOND, CIRCLE, SQUARE, PLUS, X_MARKER, POINT, PIXEL, ASTERISK = \ + 'd', 'o', 's', '+', 'x', '.', ',', '*' + +H_LINE, V_LINE = '_', '|' + + +class _Points2D(object): + MARKERS = (DIAMOND, CIRCLE, SQUARE, PLUS, X_MARKER, POINT, PIXEL, ASTERISK, + H_LINE, V_LINE) + + _LINEAR, _LOG10_X, _LOG10_Y, _LOG10_X_Y = 0, 1, 2, 3 + + _SHADERS = { + 'vertexTransforms': { + _LINEAR: """ + vec4 transformXY(float x, float y) { + return vec4(x, y, 0.0, 1.0); + } + """, + _LOG10_X: """ + const float oneOverLog10 = 0.43429448190325176; + + vec4 transformXY(float x, float y) { + return vec4(oneOverLog10 * log(x), y, 0.0, 1.0); + } + """, + _LOG10_Y: """ + const float oneOverLog10 = 0.43429448190325176; + + vec4 transformXY(float x, float y) { + return vec4(x, oneOverLog10 * log(y), 0.0, 1.0); + } + """, + _LOG10_X_Y: """ + const float oneOverLog10 = 0.43429448190325176; + + vec4 transformXY(float x, float y) { + return vec4(oneOverLog10 * log(x), + oneOverLog10 * log(y), + 0.0, 1.0); + } + """ + }, + 'vertex': """ + #version 120 + + uniform mat4 matrix; + uniform int transform; + uniform float size; + attribute float xPos; + attribute float yPos; + attribute vec4 color; + + varying vec4 vColor; + + %s + + void main(void) { + gl_Position = matrix * transformXY(xPos, yPos); + vColor = color; + gl_PointSize = size; + } + """, + + 'fragmentSymbols': { + DIAMOND: """ + float alphaSymbol(vec2 coord, float size) { + vec2 centerCoord = abs(coord - vec2(0.5, 0.5)); + float f = centerCoord.x + centerCoord.y; + return clamp(size * (0.5 - f), 0.0, 1.0); + } + """, + CIRCLE: """ + float alphaSymbol(vec2 coord, float size) { + float radius = 0.5; + float r = distance(coord, vec2(0.5, 0.5)); + return clamp(size * (radius - r), 0.0, 1.0); + } + """, + SQUARE: """ + float alphaSymbol(vec2 coord, float size) { + return 1.0; + } + """, + PLUS: """ + float alphaSymbol(vec2 coord, float size) { + vec2 d = abs(size * (coord - vec2(0.5, 0.5))); + if (min(d.x, d.y) < 0.5) { + return 1.0; + } else { + return 0.0; + } + } + """, + X_MARKER: """ + float alphaSymbol(vec2 coord, float size) { + vec2 pos = floor(size * coord) + 0.5; + vec2 d_x = abs(pos.x + vec2(- pos.y, pos.y - size)); + if (min(d_x.x, d_x.y) <= 0.5) { + return 1.0; + } else { + return 0.0; + } + } + """, + ASTERISK: """ + float alphaSymbol(vec2 coord, float size) { + /* Combining +, x and cirle */ + vec2 d_plus = abs(size * (coord - vec2(0.5, 0.5))); + vec2 pos = floor(size * coord) + 0.5; + vec2 d_x = abs(pos.x + vec2(- pos.y, pos.y - size)); + if (min(d_plus.x, d_plus.y) < 0.5) { + return 1.0; + } else if (min(d_x.x, d_x.y) <= 0.5) { + float r = distance(coord, vec2(0.5, 0.5)); + return clamp(size * (0.5 - r), 0.0, 1.0); + } else { + return 0.0; + } + } + """, + H_LINE: """ + float alphaSymbol(vec2 coord, float size) { + float dy = abs(size * (coord.y - 0.5)); + if (dy < 0.5) { + return 1.0; + } else { + return 0.0; + } + } + """, + V_LINE: """ + float alphaSymbol(vec2 coord, float size) { + float dx = abs(size * (coord.x - 0.5)); + if (dx < 0.5) { + return 1.0; + } else { + return 0.0; + } + } + """ + }, + + 'fragment': """ + #version 120 + + uniform float size; + + varying vec4 vColor; + + %s + + void main(void) { + float alpha = alphaSymbol(gl_PointCoord, size); + if (alpha <= 0.0) { + discard; + } else { + gl_FragColor = vec4(vColor.rgb, alpha * clamp(vColor.a, 0.0, 1.0)); + } + } + """ + } + + _programs = {} + + def __init__(self, xVboData=None, yVboData=None, colorVboData=None, + marker=SQUARE, color=(0., 0., 0., 1.), size=7): + self.color = color + self._marker = None + self.marker = marker + self._size = 1 + self.size = size + + self.xVboData = xVboData + self.yVboData = yVboData + self.colorVboData = colorVboData + self.useColorVboData = colorVboData is not None + + @property + def marker(self): + return self._marker + + @marker.setter + def marker(self, marker): + if marker in _MPL_NONES: + self._marker = None + self.render = self._renderNone + else: + assert marker in self.MARKERS + self._marker = marker + self.render = self._renderMarkers + + @property + def size(self): + return self._size + + @size.setter + def size(self, size): + # try: + # sizeRange = self._sizeRange + # except AttributeError: + # sizeRange = gl.glGetFloatv(gl.GL_POINT_SIZE_RANGE) + # # Shared among contexts, this should be enough.. + # _Points2D._sizeRange = sizeRange + # assert size >= sizeRange[0] and size <= sizeRange[1] + self._size = size + + @classmethod + def _getProgram(cls, transform, marker): + """On-demand shader program creation.""" + if marker == PIXEL: + marker = SQUARE + elif marker == POINT: + marker = CIRCLE + try: + prgm = cls._programs[(transform, marker)] + except KeyError: + vertShdr = cls._SHADERS['vertex'] % \ + cls._SHADERS['vertexTransforms'][transform] + fragShdr = cls._SHADERS['fragment'] % \ + cls._SHADERS['fragmentSymbols'][marker] + prgm = Program(vertShdr, fragShdr, attrib0='xPos') + + cls._programs[(transform, marker)] = prgm + return prgm + + @classmethod + def init(cls): + version = gl.glGetString(gl.GL_VERSION) + majorVersion = int(version[0]) + assert majorVersion >= 2 + gl.glEnable(gl.GL_VERTEX_PROGRAM_POINT_SIZE) # OpenGL 2 + gl.glEnable(gl.GL_POINT_SPRITE) # OpenGL 2 + if majorVersion >= 3: # OpenGL 3 + gl.glEnable(gl.GL_PROGRAM_POINT_SIZE) + + def _renderNone(self, matrix, isXLog, isYLog): + pass + + render = _renderNone + + def _renderMarkers(self, matrix, isXLog, isYLog): + if isXLog: + transform = self._LOG10_X_Y if isYLog else self._LOG10_X + else: + transform = self._LOG10_Y if isYLog else self._LINEAR + + prog = self._getProgram(transform, self.marker) + prog.use() + gl.glUniformMatrix4fv(prog.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 + gl.glUniform1f(prog.uniforms['size'], size) + # gl.glPointSize(self.size) + + cAttrib = prog.attributes['color'] + if self.useColorVboData and self.colorVboData is not None: + gl.glEnableVertexAttribArray(cAttrib) + self.colorVboData.setVertexAttrib(cAttrib) + else: + gl.glDisableVertexAttribArray(cAttrib) + gl.glVertexAttrib4f(cAttrib, *self.color) + + xAttrib = prog.attributes['xPos'] + gl.glEnableVertexAttribArray(xAttrib) + self.xVboData.setVertexAttrib(xAttrib) + + yAttrib = prog.attributes['yPos'] + gl.glEnableVertexAttribArray(yAttrib) + self.yVboData.setVertexAttrib(yAttrib) + + gl.glDrawArrays(gl.GL_POINTS, 0, self.xVboData.size) + + gl.glUseProgram(0) + + +# error bars ################################################################## + +class _ErrorBars(object): + """Display errors bars. + + This is using its own VBO as opposed to fill/points/lines. + There is no picking on error bars. + As is, there is no way to update data and errors, but it handles + log scales by removing data <= 0 and clipping error bars to positive + range. + + It uses 2 vertices per error bars and uses :class:`_Lines2D` to + render error bars and :class:`_Points2D` to render the ends. + """ + + def __init__(self, xData, yData, xError, yError, + xMin, yMin, + color=(0., 0., 0., 1.)): + """Initialization. + + :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 color: The color to use for both lines and ending points. + :type color: tuple of 4 floats + """ + self._attribs = None + self._isXLog, self._isYLog = False, False + self._xMin, self._yMin = xMin, yMin + + if xError is not None or yError is not None: + assert len(xData) == len(yData) + 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 = _Lines2D(None, None, color=color, drawMode=gl.GL_LINES) + self._xErrPoints = _Points2D(None, None, color=color, marker=V_LINE) + self._yErrPoints = _Points2D(None, None, color=color, marker=H_LINE) + + def _positiveValueFilter(self, onlyXPos, onlyYPos): + """Filter data (x, y) and errors (xError, yError) to remove + negative and null data values on required axis (onlyXPos, onlyYPos). + + Returned arrays might be NOT contiguous. + + :return: Filtered xData, yData, xError and yError arrays. + """ + if ((not onlyXPos or self._xMin > 0.) and + (not onlyYPos or self._yMin > 0.)): + # No need to filter, all values are > 0 on log axes + return self._xData, self._yData, self._xError, self._yError + + _logger.warning( + 'Removing values <= 0 of curve with error bars on a log axis.') + + x, y = self._xData, self._yData + xError, yError = self._xError, self._yError + + # First remove negative data + if onlyXPos and onlyYPos: + mask = (x > 0.) & (y > 0.) + elif onlyXPos: + mask = x > 0. + else: # onlyYPos + mask = y > 0. + x, y = x[mask], y[mask] + + # Remove corresponding values from error arrays + if xError is not None and xError.size != 1: + if len(xError.shape) == 1: + xError = xError[mask] + else: # 2 rows + xError = xError[:, mask] + if yError is not None and yError.size != 1: + if len(yError.shape) == 1: + yError = yError[mask] + else: # 2 rows + yError = yError[:, mask] + + return x, y, xError, yError + + def _buildVertices(self, isXLog, isYLog): + """Generates error bars vertices according to log scales.""" + xData, yData, xError, yError = self._positiveValueFilter( + isXLog, isYLog) + + nbLinesPerDataPts = 1 if xError is not None else 0 + nbLinesPerDataPts += 1 if yError is not None else 0 + + nbDataPts = len(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 xError is not None: # errors on the X axis + if len(xError.shape) == 2: + xErrorMinus, xErrorPlus = xError[0], xError[1] + else: + # numpy arrays of len 1 or len(xData) + xErrorMinus, xErrorPlus = xError, xError + + # Interleave vertices for xError + endXError = 2 * nbDataPts + xCoords[0:endXError-1:2] = xData + xErrorPlus + + minValues = xData - xErrorMinus + if isXLog: + # Clip min bounds to positive value + minValues[minValues <= 0] = FLOAT32_MINPOS + xCoords[1:endXError:2] = minValues + + yCoords[0:endXError-1:2] = yData + yCoords[1:endXError:2] = yData + else: + endXError = 0 + + if yError is not None: # errors on the Y axis + if len(yError.shape) == 2: + yErrorMinus, yErrorPlus = yError[0], yError[1] + else: + # numpy arrays of len 1 or len(yData) + yErrorMinus, yErrorPlus = yError, yError + + # Interleave vertices for yError + xCoords[endXError::2] = xData + xCoords[endXError+1::2] = xData + yCoords[endXError::2] = yData + yErrorPlus + minValues = yData - yErrorMinus + if isYLog: + # Clip min bounds to positive value + minValues[minValues <= 0] = FLOAT32_MINPOS + yCoords[endXError+1::2] = minValues + + return xCoords, yCoords + + def prepare(self, isXLog, isYLog): + if self._xData is None: + return + + if self._isXLog != isXLog or self._isYLog != isYLog: + # Log state has changed + self._isXLog, self._isYLog = isXLog, isYLog + + self.discard() # discard existing VBOs + + if self._attribs is None: + xCoords, yCoords = self._buildVertices(isXLog, isYLog) + + xAttrib, yAttrib = vertexBuffer((xCoords, yCoords)) + self._attribs = xAttrib, yAttrib + + self._lines.xVboData, self._lines.yVboData = xAttrib, 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, matrix, isXLog, isYLog): + if self._attribs is not None: + self._lines.render(matrix, isXLog, isYLog) + self._xErrPoints.render(matrix, isXLog, isYLog) + self._yErrPoints.render(matrix, isXLog, isYLog) + + def discard(self): + if self._attribs is not None: + 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 + + +# 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(object): + def __init__(self, xData, yData, colorData=None, + xError=None, yError=None, + lineStyle=None, lineColor=None, + lineWidth=None, lineDashPeriod=None, + marker=None, markerColor=None, markerSize=None, + fillColor=None): + self._isXLog = False + self._isYLog = False + self.xData, self.yData, self.colorData = xData, yData, colorData + + if fillColor is not None: + self.fill = _Fill2D(color=fillColor) + else: + self.fill = None + + # Compute x bounds + if xError is None: + result = min_max(xData, min_positive=True) + self.xMin = result.minimum + self.xMinPos = result.min_positive + self.xMax = result.maximum + else: + # Takes the error into account + if hasattr(xError, 'shape') and len(xError.shape) == 2: + xErrorPlus, xErrorMinus = xError[0], xError[1] + else: + xErrorPlus, xErrorMinus = xError, xError + result = min_max(xData - xErrorMinus, min_positive=True) + self.xMin = result.minimum + self.xMinPos = result.min_positive + self.xMax = (xData + xErrorPlus).max() + + # Compute y bounds + if yError is None: + result = min_max(yData, min_positive=True) + self.yMin = result.minimum + self.yMinPos = result.min_positive + self.yMax = result.maximum + else: + # Takes the error into account + if hasattr(yError, 'shape') and len(yError.shape) == 2: + yErrorPlus, yErrorMinus = yError[0], yError[1] + else: + yErrorPlus, yErrorMinus = yError, yError + result = min_max(yData - yErrorMinus, min_positive=True) + self.yMin = result.minimum + self.yMinPos = result.min_positive + self.yMax = (yData + yErrorPlus).max() + + self._errorBars = _ErrorBars(xData, yData, xError, yError, + self.xMin, self.yMin) + + kwargs = {'style': lineStyle} + if lineColor is not None: + kwargs['color'] = lineColor + if lineWidth is not None: + kwargs['width'] = lineWidth + if lineDashPeriod is not None: + kwargs['dashPeriod'] = lineDashPeriod + self.lines = _Lines2D(**kwargs) + + kwargs = {'marker': marker} + if markerColor is not None: + kwargs['color'] = markerColor + if markerSize is not None: + kwargs['size'] = markerSize + self.points = _Points2D(**kwargs) + + xVboData = _proxyProperty(('lines', 'xVboData'), ('points', 'xVboData')) + + yVboData = _proxyProperty(('lines', 'yVboData'), ('points', 'yVboData')) + + colorVboData = _proxyProperty(('lines', 'colorVboData'), + ('points', 'colorVboData')) + + useColorVboData = _proxyProperty(('lines', 'useColorVboData'), + ('points', 'useColorVboData')) + + distVboData = _proxyProperty(('lines', 'distVboData')) + + lineStyle = _proxyProperty(('lines', 'style')) + + lineColor = _proxyProperty(('lines', 'color')) + + lineWidth = _proxyProperty(('lines', 'width')) + + lineDashPeriod = _proxyProperty(('lines', 'dashPeriod')) + + marker = _proxyProperty(('points', 'marker')) + + markerColor = _proxyProperty(('points', 'color')) + + markerSize = _proxyProperty(('points', 'size')) + + @classmethod + def init(cls): + _Lines2D.init() + _Points2D.init() + + @staticmethod + def _logFilterData(x, y, color=None, xLog=False, yLog=False): + # Copied from Plot.py + if xLog and yLog: + idx = numpy.nonzero((x > 0) & (y > 0))[0] + x = numpy.take(x, idx) + y = numpy.take(y, idx) + elif yLog: + idx = numpy.nonzero(y > 0)[0] + x = numpy.take(x, idx) + y = numpy.take(y, idx) + elif xLog: + idx = numpy.nonzero(x > 0)[0] + x = numpy.take(x, idx) + y = numpy.take(y, idx) + else: + idx = None + + if idx is not None and isinstance(color, numpy.ndarray): + colors = numpy.zeros((x.size, 4), color.dtype) + colors[:, 0] = color[idx, 0] + colors[:, 1] = color[idx, 1] + colors[:, 2] = color[idx, 2] + colors[:, 3] = color[idx, 3] + else: + colors = color + return x, y, colors + + def prepare(self, isXLog, isYLog): + # init only supports updating isXLog, isYLog + xData, yData, colorData = self.xData, self.yData, self.colorData + + if self._isXLog != isXLog or self._isYLog != isYLog: + # Log state has changed + self._isXLog, self._isYLog = isXLog, isYLog + + # Check if data <= 0. with log scale + if (isXLog and self.xMin <= 0.) or (isYLog and self.yMin <= 0.): + # Filtering data is needed + xData, yData, colorData = self._logFilterData( + self.xData, self.yData, self.colorData, + self._isXLog, self._isYLog) + + self.discard() # discard existing VBOs + + if self.xVboData is None: + xAttrib, yAttrib, cAttrib, dAttrib = None, None, None, None + if self.lineStyle in (DASHED, DASHDOT, DOTTED): + dists = _distancesFromArrays(xData, yData) + if self.colorData is None: + xAttrib, yAttrib, dAttrib = vertexBuffer( + (xData, yData, dists), + prefix=(1, 1, 0), suffix=(1, 1, 0)) + else: + xAttrib, yAttrib, cAttrib, dAttrib = vertexBuffer( + (xData, yData, colorData, dists), + prefix=(1, 1, 0, 0), suffix=(1, 1, 0, 0)) + elif self.colorData is None: + xAttrib, yAttrib = vertexBuffer( + (xData, yData), prefix=(1, 1), suffix=(1, 1)) + else: + xAttrib, yAttrib, cAttrib = vertexBuffer( + (xData, yData, colorData), prefix=(1, 1, 0)) + + # Shrink VBO + self.xVboData = xAttrib.copy() + self.xVboData.size -= 2 + self.xVboData.offset += xAttrib.itemsize + + self.yVboData = yAttrib.copy() + self.yVboData.size -= 2 + self.yVboData.offset += yAttrib.itemsize + + if cAttrib is not None and colorData.dtype.kind == 'u': + cAttrib.normalisation = True # Normalise uint to [0, 1] + self.colorVboData = cAttrib + self.useColorVboData = cAttrib is not None + self.distVboData = dAttrib + + if self.fill is not None: + xData = xData.reshape(xData.size, 1) + zero = numpy.array((1e-32,), dtype=self.yData.dtype) + + # Add one point before data: (x0, 0.) + xAttrib.vbo.update(xData[0], xAttrib.offset, + xData[0].itemsize) + yAttrib.vbo.update(zero, yAttrib.offset, zero.itemsize) + + # Add one point after data: (xN, 0.) + xAttrib.vbo.update(xData[-1], + xAttrib.offset + + (xAttrib.size - 1) * xAttrib.itemsize, + xData[-1].itemsize) + yAttrib.vbo.update(zero, + yAttrib.offset + + (yAttrib.size - 1) * yAttrib.itemsize, + zero.itemsize) + + self.fill.xFillVboData = xAttrib + self.fill.yFillVboData = yAttrib + self.fill.xMin, self.fill.yMin = self.xMin, self.yMin + self.fill.xMax, self.fill.yMax = self.xMax, self.yMax + + self._errorBars.prepare(isXLog, isYLog) + + def render(self, matrix, isXLog, isYLog): + self.prepare(isXLog, isYLog) + if self.fill is not None: + self.fill.render(matrix, isXLog, isYLog) + self._errorBars.render(matrix, isXLog, isYLog) + self.lines.render(matrix, isXLog, isYLog) + self.points.render(matrix, isXLog, isYLog) + + def discard(self): + 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() + + 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: list of int + """ + if (self.marker is None and self.lineStyle is None) or \ + self.xMin > xPickMax or xPickMin > self.xMax or \ + self.yMin > yPickMax or yPickMin > self.yMax: + # Note: With log scale the bounding box is too large if + # some data <= 0. + return None + + elif self.lineStyle is not None: + # Using Cohen-Sutherland algorithm for line clipping + codes = ((self.yData > yPickMax) << 3) | \ + ((self.yData < yPickMin) << 2) | \ + ((self.xData > xPickMax) << 1) | \ + (self.xData < xPickMin) + + # Add all points that are inside the picking area + indices = numpy.nonzero(codes == 0)[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: + indices = numpy.nonzero((self.xData >= xPickMin) & + (self.xData <= xPickMax) & + (self.yData >= yPickMin) & + (self.yData <= yPickMax))[0].tolist() + + return indices diff --git a/silx/gui/plot/backends/glutils/GLPlotFrame.py b/silx/gui/plot/backends/glutils/GLPlotFrame.py new file mode 100644 index 0000000..367419c --- /dev/null +++ b/silx/gui/plot/backends/glutils/GLPlotFrame.py @@ -0,0 +1,1039 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +This modules provides the rendering of plot titles, axes and grid. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "03/04/2017" + + +# TODO +# keep aspect ratio managed here? +# smarter dirty flag handling? + +import math +import weakref +import logging +from collections import namedtuple + +import numpy + +from ...._glutils import gl, Program +from ..._utils import FLOAT32_SAFE_MIN, FLOAT32_MINPOS, FLOAT32_SAFE_MAX +from .GLSupport import mat4Ortho +from .GLText import Text2D, CENTER, BOTTOM, TOP, LEFT, RIGHT, ROTATE_270 +from ..._utils.ticklayout import niceNumbersAdaptative, niceNumbersForLog10 + + +_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, plot, + tickLength=(0., 0.), + labelAlign=CENTER, labelVAlign=CENTER, + titleAlign=CENTER, titleVAlign=CENTER, + titleRotate=0, titleOffset=(0., 0.)): + self._ticks = None + + self._plot = weakref.ref(plot) + + self._isLog = False + self._dataRange = 1., 100. + self._displayCoords = (0., 0.), (1., 0.) + self._title = '' + + self._tickLength = tickLength + self._labelAlign = labelAlign + self._labelVAlign = labelVAlign + self._titleAlign = titleAlign + self._titleVAlign = titleVAlign + self._titleRotate = titleRotate + self._titleOffset = titleOffset + + @property + def dataRange(self): + """The range of the data represented on the axis as a tuple + of 2 floats: (min, max).""" + return self._dataRange + + @dataRange.setter + def dataRange(self, dataRange): + assert len(dataRange) == 2 + assert dataRange[0] <= dataRange[1] + dataRange = float(dataRange[0]), float(dataRange[1]) + + if dataRange != self._dataRange: + self._dataRange = dataRange + self._dirtyTicks() + + @property + def isLog(self): + """Whether the axis is using a log10 scale or not as a bool.""" + return self._isLog + + @isLog.setter + def isLog(self, isLog): + isLog = bool(isLog) + if isLog != self._isLog: + self._isLog = isLog + self._dirtyTicks() + + @property + def 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 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 + + plot = self._plot() + if plot is not None: + plot._dirty() + + @property + def ticks(self): + """Ticks as tuples: ((x, y) in display, dataPos, textLabel).""" + if self._ticks is None: + self._ticks = tuple(self._ticksGenerator()) + return self._ticks + + def getVerticesAndLabels(self): + """Create the list of vertices for axis and associated text labels. + + :returns: A tuple: List of 2D line vertices, List of Text2D labels. + """ + vertices = list(self.displayCoords) # Add start and end points + labels = [] + tickLabelsSize = [0., 0.] + + xTickLength, yTickLength = self._tickLength + for (xPixel, yPixel), dataPos, text in self.ticks: + if text is None: + tickScale = 0.5 + else: + tickScale = 1. + + label = Text2D(text=text, + x=xPixel - xTickLength, + y=yPixel - yTickLength, + align=self._labelAlign, + valign=self._labelVAlign) + + width, height = label.size + if width > tickLabelsSize[0]: + tickLabelsSize[0] = width + if height > tickLabelsSize[1]: + tickLabelsSize[1] = height + + labels.append(label) + + vertices.append((xPixel, yPixel)) + vertices.append((xPixel + tickScale * xTickLength, + yPixel + tickScale * yTickLength)) + + (x0, y0), (x1, y1) = self.displayCoords + xAxisCenter = 0.5 * (x0 + x1) + yAxisCenter = 0.5 * (y0 + y1) + + xOffset, yOffset = self._titleOffset + + # Adaptative title positioning: + # tickNorm = math.sqrt(xTickLength ** 2 + yTickLength ** 2) + # xOffset = -tickLabelsSize[0] * xTickLength / tickNorm + # xOffset -= 3 * xTickLength + # yOffset = -tickLabelsSize[1] * yTickLength / tickNorm + # yOffset -= 3 * yTickLength + + axisTitle = Text2D(text=self.title, + x=xAxisCenter + xOffset, + y=yAxisCenter + yOffset, + align=self._titleAlign, + valign=self._titleVAlign, + rotate=self._titleRotate) + labels.append(axisTitle) + + return vertices, labels + + def _dirtyTicks(self): + """Mark ticks as dirty and notify listener (i.e., background).""" + self._ticks = None + plot = self._plot() + if plot is not None: + plot._dirty() + + @staticmethod + def _frange(start, stop, step): + """range for float (including stop).""" + while start <= stop: + yield start + start += step + + def _ticksGenerator(self): + """Generator of ticks as tuples: + ((x, y) in display, dataPos, textLabel). + """ + dataMin, dataMax = self.dataRange + if self.isLog and dataMin <= 0.: + _logger.warning( + 'Getting ticks while isLog=True and dataRange[0]<=0.') + dataMin = 1. + if dataMax < dataMin: + dataMax = 1. + + if dataMin != dataMax: # data range is not null + (x0, y0), (x1, y1) = self.displayCoords + + if self.isLog: + 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)) + + # Density of 1.3 label per 92 pixels + # i.e., 1.3 label per inch on a 92 dpi screen + tickMin, tickMax, step, nbFrac = niceNumbersAdaptative( + dataMin, dataMax, nbPixels, 1.3 / 92) + + for dataPos in self._frange(tickMin, tickMax, step): + if dataMin <= dataPos <= dataMax: + xPixel = x0 + (dataPos - dataMin) * xScale + yPixel = y0 + (dataPos - dataMin) * yScale + + if nbFrac == 0: + text = '%g' % dataPos + else: + text = ('%.' + str(nbFrac) + 'f') % dataPos + yield ((xPixel, yPixel), dataPos, text) + + +# 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')) + + def __init__(self, margins): + """ + :param margins: The margins around plot area for axis and labels. + :type margins: dict with 'left', 'right', 'top', 'bottom' keys and + values as ints. + """ + self._renderResources = None + + self._margins = self._Margins(**margins) + + self.axes = [] # List of PlotAxis to be updated by subclasses + + self._grid = False + self._size = 0., 0. + self._title = '' + + @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 margins(self): + """Margins in pixels around the plot.""" + return self._margins + + @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 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._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, + x=xTitle, + y=yTitle, + align=CENTER, + valign=BOTTOM)) + + # 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._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) + gl.glUniform4f(prog.uniforms['color'], 0., 0., 0., 1.) + gl.glUniform1f(prog.uniforms['tickFactor'], 0.) + + gl.glEnableVertexAttribArray(prog.attributes['position']) + gl.glVertexAttribPointer(prog.attributes['position'], + 2, + gl.GL_FLOAT, + gl.GL_FALSE, + 0, vertices) + + gl.glDrawArrays(gl.GL_LINES, 0, len(vertices)) + + for label in labels: + label.render(matProj) + + def renderGrid(self): + if self._grid == self.GRID_NONE: + return + + if self._renderResources is None: + self._buildVerticesAndLabels() + vertices, gridVertices, labels = self._renderResources + + width, height = self.size + matProj = mat4Ortho(0, width, height, 0, 1, -1) + + gl.glViewport(0, 0, width, height) + + prog = self._program + prog.use() + + gl.glLineWidth(self._LINE_WIDTH) + gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, matProj) + gl.glUniform4f(prog.uniforms['color'], 0.7, 0.7, 0.7, 1.) + gl.glUniform1f(prog.uniforms['tickFactor'], 0.) # 1/2.) # 1/tickLen + + gl.glEnableVertexAttribArray(prog.attributes['position']) + gl.glVertexAttribPointer(prog.attributes['position'], + 2, + gl.GL_FLOAT, + gl.GL_FALSE, + 0, gridVertices) + + gl.glDrawArrays(gl.GL_LINES, 0, len(gridVertices)) + + +# GLPlotFrame2D ############################################################### + +class GLPlotFrame2D(GLPlotFrame): + def __init__(self, margins): + """ + :param margins: The margins around plot area for axis and labels. + :type margins: dict with 'left', 'right', 'top', 'bottom' keys and + values as ints. + """ + super(GLPlotFrame2D, self).__init__(margins) + self.axes.append(PlotAxis(self, + tickLength=(0., -5.), + labelAlign=CENTER, labelVAlign=TOP, + titleAlign=CENTER, titleVAlign=TOP, + titleRotate=0, + titleOffset=(0, self.margins.bottom // 2))) + + self._x2AxisCoords = () + + self.axes.append(PlotAxis(self, + tickLength=(5., 0.), + labelAlign=RIGHT, labelVAlign=CENTER, + titleAlign=CENTER, titleVAlign=BOTTOM, + titleRotate=ROTATE_270, + titleOffset=(-3 * self.margins.left // 4, + 0))) + + self._y2Axis = PlotAxis(self, + tickLength=(-5., 0.), + labelAlign=LEFT, labelVAlign=CENTER, + titleAlign=CENTER, titleVAlign=TOP, + titleRotate=ROTATE_270, + titleOffset=(3 * self.margins.right // 4, + 0)) + + self._isYAxisInverted = False + + self._dataRanges = { + 'x': (1., 100.), 'y': (1., 100.), 'y2': (1., 100.)} + + self._baseVectors = (1., 0.), (0., 1.) + + self._transformedDataRanges = None + self._transformedDataProjMat = None + self._transformedDataY2ProjMat = None + + def _dirty(self): + super(GLPlotFrame2D, self)._dirty() + self._transformedDataRanges = None + self._transformedDataProjMat = None + self._transformedDataY2ProjMat = None + + @property + def isDirty(self): + """True if it need to refresh graphic rendering, False otherwise.""" + return (super(GLPlotFrame2D, self).isDirty or + self._transformedDataRanges is None or + self._transformedDataProjMat is None or + self._transformedDataY2ProjMat is None) + + @property + def xAxis(self): + return self.axes[0] + + @property + def yAxis(self): + return self.axes[1] + + @property + def y2Axis(self): + return self._y2Axis + + @property + def isY2Axis(self): + """Whether to display the left Y axis or not.""" + return len(self.axes) == 3 + + @isY2Axis.setter + def isY2Axis(self, isY2Axis): + if isY2Axis != self.isY2Axis: + if isY2Axis: + self.axes.append(self._y2Axis) + else: + self.axes = self.axes[:2] + + self._dirty() + + @property + def isYAxisInverted(self): + """Whether Y axes are inverted or not as a bool.""" + return self._isYAxisInverted + + @isYAxisInverted.setter + def isYAxisInverted(self, value): + value = bool(value) + if value != self._isYAxisInverted: + self._isYAxisInverted = value + self._dirty() + + DEFAULT_BASE_VECTORS = (1., 0.), (0., 1.) + """Values of baseVectors for orthogonal axes.""" + + @property + def baseVectors(self): + """Coordinates of the X and Y axes in the orthogonal plot coords. + + Raises ValueError if corresponding matrix is singular. + + 2 tuples of 2 floats: (xx, xy), (yx, yy) + """ + return self._baseVectors + + @baseVectors.setter + def baseVectors(self, baseVectors): + self._dirty() + + (xx, xy), (yx, yy) = baseVectors + vectors = (float(xx), float(xy)), (float(yx), float(yy)) + + det = (vectors[0][0] * vectors[1][1] - vectors[1][0] * vectors[0][1]) + if det == 0.: + raise ValueError("Singular matrix for base vectors: " + + str(vectors)) + + if vectors != self._baseVectors: + self._baseVectors = vectors + self._dirty() + + @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']) + + @staticmethod + def _clipToSafeRange(min_, max_, isLog): + # Clip range if needed + minLimit = FLOAT32_MINPOS if isLog else FLOAT32_SAFE_MIN + min_ = numpy.clip(min_, minLimit, FLOAT32_SAFE_MAX) + max_ = numpy.clip(max_, minLimit, FLOAT32_SAFE_MAX) + assert min_ < max_ + return min_, max_ + + 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'] = \ + self._clipToSafeRange(x[0], x[1], self.xAxis.isLog) + + if y is not None: + self._dataRanges['y'] = \ + self._clipToSafeRange(y[0], y[1], self.yAxis.isLog) + + if y2 is not None: + self._dataRanges['y2'] = \ + self._clipToSafeRange(y2[0], y2[1], self.y2Axis.isLog) + + self.xAxis.dataRange = self._dataRanges['x'] + self.yAxis.dataRange = self._dataRanges['y'] + self.y2Axis.dataRange = self._dataRanges['y2'] + + _DataRanges = namedtuple('dataRanges', ('x', 'y', 'y2')) + + @property + def transformedDataRanges(self): + """Bounds of the displayed area in transformed data coordinates + (i.e., log scale applied if any as well as skew) + + 3-tuple of 2-tuple (min, max) for each axis: x, y, y2. + """ + if self._transformedDataRanges is None: + (xMin, xMax), (yMin, yMax), (y2Min, y2Max) = self.dataRanges + + if self.xAxis.isLog: + try: + xMin = math.log10(xMin) + except ValueError: + _logger.info('xMin: warning log10(%f)', xMin) + xMin = 0. + try: + xMax = math.log10(xMax) + except ValueError: + _logger.info('xMax: warning log10(%f)', xMax) + xMax = 0. + + if self.yAxis.isLog: + try: + yMin = math.log10(yMin) + except ValueError: + _logger.info('yMin: warning log10(%f)', yMin) + yMin = 0. + try: + yMax = math.log10(yMax) + except ValueError: + _logger.info('yMax: warning log10(%f)', yMax) + yMax = 0. + + try: + y2Min = math.log10(y2Min) + except ValueError: + _logger.info('yMin: warning log10(%f)', y2Min) + y2Min = 0. + try: + y2Max = math.log10(y2Max) + except ValueError: + _logger.info('yMax: warning log10(%f)', y2Max) + y2Max = 0. + + # Non-orthogonal axes + if self.baseVectors != self.DEFAULT_BASE_VECTORS: + (xx, xy), (yx, yy) = self.baseVectors + skew_mat = numpy.array(((xx, yx), (xy, yy))) + + corners = [(xMin, yMin), (xMin, yMax), + (xMax, yMin), (xMax, yMax), + (xMin, y2Min), (xMin, y2Max), + (xMax, y2Min), (xMax, y2Max)] + + corners = numpy.array( + [numpy.dot(skew_mat, corner) for corner in corners], + dtype=numpy.float32) + xMin, xMax = corners[:, 0].min(), corners[:, 0].max() + yMin, yMax = corners[0:4, 1].min(), corners[0:4, 1].max() + y2Min, y2Max = corners[4:, 1].min(), corners[4:, 1].max() + + 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) + + # Non-orthogonal axes + if self.baseVectors != self.DEFAULT_BASE_VECTORS: + (xx, xy), (yx, yy) = self.baseVectors + mat = mat * numpy.matrix(( + (xx, yx, 0., 0.), + (xy, yy, 0., 0.), + (0., 0., 1., 0.), + (0., 0., 0., 1.)), dtype=numpy.float32) + + 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) + + # Non-orthogonal axes + if self.baseVectors != self.DEFAULT_BASE_VECTORS: + (xx, xy), (yx, yy) = self.baseVectors + mat = mat * numpy.matrix(( + (xx, yx, 0., 0.), + (xy, yy, 0., 0.), + (0., 0., 1., 0.), + (0., 0., 0., 1.)), dtype=numpy.float32) + + self._transformedDataY2ProjMat = mat + + return self._transformedDataY2ProjMat + + def dataToPixel(self, x, y, axis='left'): + """Convert data coordinate to widget pixel coordinate. + """ + assert axis in ('left', 'right') + + trBounds = self.transformedDataRanges + + if self.xAxis.isLog: + if x < FLOAT32_MINPOS: + return None + xDataTr = math.log10(x) + else: + xDataTr = x + + if self.yAxis.isLog: + if y < FLOAT32_MINPOS: + return None + yDataTr = math.log10(y) + else: + yDataTr = y + + # Non-orthogonal axes + if self.baseVectors != self.DEFAULT_BASE_VECTORS: + (xx, xy), (yx, yy) = self.baseVectors + skew_mat = numpy.array(((xx, yx), (xy, yy))) + + coords = numpy.dot(skew_mat, numpy.array((xDataTr, yDataTr))) + xDataTr, yDataTr = coords + + plotWidth, plotHeight = self.plotSize + + xPixel = int(self.margins.left + + plotWidth * (xDataTr - trBounds.x[0]) / + (trBounds.x[1] - trBounds.x[0])) + + usedAxis = trBounds.y if axis == "left" else trBounds.y2 + yOffset = (plotHeight * (yDataTr - usedAxis[0]) / + (usedAxis[1] - usedAxis[0])) + + if self.isYAxisInverted: + yPixel = int(self.margins.top + yOffset) + else: + yPixel = int(self.size[1] - self.margins.bottom - yOffset) + + return xPixel, yPixel + + def pixelToData(self, x, y, axis="left"): + """Convert pixel position to data coordinates. + + :param float x: X coord + :param float y: Y coord + :param str axis: Y axis to use in ('left', 'right') + :return: (x, y) position in data coords + """ + assert axis in ("left", "right") + + plotWidth, plotHeight = self.plotSize + + trBounds = self.transformedDataRanges + + xData = (x - self.margins.left + 0.5) / float(plotWidth) + xData = trBounds.x[0] + xData * (trBounds.x[1] - trBounds.x[0]) + + usedAxis = trBounds.y if axis == "left" else trBounds.y2 + if self.isYAxisInverted: + yData = (y - self.margins.top + 0.5) / float(plotHeight) + yData = usedAxis[0] + yData * (usedAxis[1] - usedAxis[0]) + else: + yData = self.size[1] - self.margins.bottom - y - 0.5 + yData /= float(plotHeight) + yData = usedAxis[0] + yData * (usedAxis[1] - usedAxis[0]) + + # non-orthogonal axis + if self.baseVectors != self.DEFAULT_BASE_VECTORS: + (xx, xy), (yx, yy) = self.baseVectors + skew_mat = numpy.array(((xx, yx), (xy, yy))) + skew_mat = numpy.linalg.inv(skew_mat) + + coords = numpy.dot(skew_mat, numpy.array((xData, yData))) + xData, yData = coords + + if self.xAxis.isLog: + xData = pow(10, xData) + if self.yAxis.isLog: + yData = pow(10, yData) + + return xData, yData + + def _buildGridVerticesWithTest(self, test): + vertices = [] + + if self.baseVectors == self.DEFAULT_BASE_VECTORS: + for axis in self.axes: + for (xPixel, yPixel), data, text in axis.ticks: + if test(text): + vertices.append((xPixel, yPixel)) + if axis == self.xAxis: + vertices.append((xPixel, self.margins.top)) + elif axis == self.yAxis: + vertices.append((self.size[0] - self.margins.right, + yPixel)) + else: # axis == self.y2Axis + vertices.append((self.margins.left, yPixel)) + + else: + # Get plot corners in data coords + plotLeft, plotTop = self.plotOrigin + plotWidth, plotHeight = self.plotSize + + corners = [(plotLeft, plotTop), + (plotLeft, plotTop + plotHeight), + (plotLeft + plotWidth, plotTop + plotHeight), + (plotLeft + plotWidth, plotTop)] + + for axis in self.axes: + if axis == self.xAxis: + cornersInData = numpy.array([ + self.pixelToData(x, y) for (x, y) in corners]) + borders = ((cornersInData[0], cornersInData[3]), # top + (cornersInData[1], cornersInData[0]), # left + (cornersInData[3], cornersInData[2])) # right + + for (xPixel, yPixel), data, text in axis.ticks: + if test(text): + for (x0, y0), (x1, y1) in borders: + if min(x0, x1) <= data < max(x0, x1): + yIntersect = (data - x0) * \ + (y1 - y0) / (x1 - x0) + y0 + + pixelPos = self.dataToPixel( + data, yIntersect) + if pixelPos is not None: + vertices.append((xPixel, yPixel)) + vertices.append(pixelPos) + break # Stop at first intersection + + else: # y or y2 axes + if axis == self.yAxis: + axis_name = 'left' + cornersInData = numpy.array([ + self.pixelToData(x, y) for (x, y) in corners]) + borders = ( + (cornersInData[3], cornersInData[2]), # right + (cornersInData[0], cornersInData[3]), # top + (cornersInData[2], cornersInData[1])) # bottom + + else: # axis == self.y2Axis + axis_name = 'right' + corners = numpy.array([self.pixelToData( + x, y, axis='right') for (x, y) in corners]) + borders = ( + (cornersInData[1], cornersInData[0]), # left + (cornersInData[0], cornersInData[3]), # top + (cornersInData[2], cornersInData[1])) # bottom + + for (xPixel, yPixel), data, text in axis.ticks: + if test(text): + for (x0, y0), (x1, y1) in borders: + if min(y0, y1) <= data < max(y0, y1): + xIntersect = (data - y0) * \ + (x1 - x0) / (y1 - y0) + x0 + + pixelPos = self.dataToPixel( + xIntersect, data, axis=axis_name) + if pixelPos is not None: + vertices.append((xPixel, yPixel)) + vertices.append(pixelPos) + break # Stop at first intersection + + return vertices + + def _buildVerticesAndLabels(self): + width, height = self.size + + xCoords = (self.margins.left - 0.5, + width - self.margins.right + 0.5) + yCoords = (height - self.margins.bottom + 0.5, + self.margins.top - 0.5) + + self.axes[0].displayCoords = ((xCoords[0], yCoords[0]), + (xCoords[1], yCoords[0])) + + self._x2AxisCoords = ((xCoords[0], yCoords[1]), + (xCoords[1], yCoords[1])) + + if self.isYAxisInverted: + # Y axes are inverted, axes coordinates are inverted + yCoords = yCoords[1], yCoords[0] + + self.axes[1].displayCoords = ((xCoords[0], yCoords[0]), + (xCoords[0], yCoords[1])) + + self._y2Axis.displayCoords = ((xCoords[1], yCoords[0]), + (xCoords[1], yCoords[1])) + + super(GLPlotFrame2D, self)._buildVerticesAndLabels() + + vertices, gridVertices, labels = self._renderResources + + # Adds vertices for borders without axis + extraVertices = [] + extraVertices += self._x2AxisCoords + if not self.isY2Axis: + extraVertices += self._y2Axis.displayCoords + + extraVertices = numpy.array( + extraVertices, copy=False, dtype=numpy.float32) + vertices = numpy.append(vertices, extraVertices, axis=0) + + self._renderResources = (vertices, gridVertices, labels) diff --git a/silx/gui/plot/backends/glutils/GLPlotImage.py b/silx/gui/plot/backends/glutils/GLPlotImage.py new file mode 100644 index 0000000..8fff82b --- /dev/null +++ b/silx/gui/plot/backends/glutils/GLPlotImage.py @@ -0,0 +1,707 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +This module provides a class to render 2D array as a colormap or RGB(A) image +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "03/04/2017" + + +import math +import numpy + +from silx.math.combo import min_max + +from ...._glutils import gl, Program, Texture +from ..._utils import FLOAT32_MINPOS +from .GLSupport import mat4Translate, mat4Scale +from .GLTexture import Image + + +class _GLPlotData2D(object): + def __init__(self, data, origin, scale): + 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 col, row + else: + return None + + @property + def xMin(self): + ox, sx = self.origin[0], self.scale[0] + return ox if sx >= 0. else ox + sx * self.data.shape[1] + + @property + def yMin(self): + oy, sy = self.origin[1], self.scale[1] + return oy if sy >= 0. else oy + sy * self.data.shape[0] + + @property + def xMax(self): + ox, sx = self.origin[0], self.scale[0] + return ox + sx * self.data.shape[1] if sx >= 0. else ox + + @property + def yMax(self): + oy, sy = self.origin[1], self.scale[1] + return oy + sy * self.data.shape[0] if sy >= 0. else oy + + def discard(self): + pass + + def prepare(self): + pass + + def render(self, matrix, isXLog, isYLog): + pass + + +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 struct { + vec2 oneOverRange; + vec2 originOverRange; + } bounds; + + 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 + + uniform sampler2D data; + uniform struct { + sampler2D texture; + bool isLog; + float min; + float oneOverRange; + } cmap; + uniform float alpha; + + varying vec2 coords; + + %s + + const float oneOverLog10 = 0.43429448190325176; + + void main(void) { + float value = texture2D(data, textureCoords()).r; + if (cmap.isLog) { + if (value > 0.) { + value = clamp(cmap.oneOverRange * + (oneOverLog10 * log(value) - cmap.min), + 0., 1.); + } else { + value = 0.; + } + } else { /*Linear mapping*/ + value = clamp(cmap.oneOverRange * (value - cmap.min), 0., 1.); + } + + 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, + # 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') + + def __init__(self, data, origin, scale, + colormap, cmapIsLog=False, cmapRange=None, + alpha=1.0): + """Create a 2D colormap + + :param data: The 2D scalar data array to display + :type data: numpy.ndarray with 2 dimensions (dtype=numpy.float32) + :param origin: (x, y) coordinates of the origin of the data array + :type origin: 2-tuple of floats. + :param scale: (sx, sy) scale factors of the data array. + This is the size of a data pixel in plot data space. + :type scale: 2-tuple of floats. + :param str colormap: Name of the colormap to use + TODO: Accept a 1D scalar array as the colormap + :param bool cmapIsLog: If True, uses log10 of the data value + :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) + """ + assert data.dtype in self._INTERNAL_FORMATS + + super(GLPlotColormap, self).__init__(data, origin, scale) + self.colormap = numpy.array(colormap, copy=False) + self.cmapIsLog = cmapIsLog + self._cmapRange = None # User-provided range info + self._cmapRangeCache = None # Store extra data for range + self.cmapRange = cmapRange # Update _cmapRange + self._alpha = numpy.clip(alpha, 0., 1.) + + self._cmap_texture = None + self._texture = None + self._textureIsDirty = False + + def discard(self): + if self._cmap_texture is not None: + self._cmap_texture.discard() + self._cmap_texture = None + + if self._texture is not None: + self._texture.discard() + self._texture = None + self._textureIsDirty = False + + @property + def cmapRange(self): + if self._cmapRange is None: # Auto-scale mode + if self._cmapRangeCache is None: + # Build data , positive ranges + result = min_max(self.data, min_positive=True) + min_ = result.minimum + minPos = result.min_positive + max_ = result.maximum + maxPos = max_ if max_ > 0. else 1. + if minPos is None: + minPos = maxPos + self._cmapRangeCache = {'range': (min_, max_), + 'pos': (minPos, maxPos)} + + return self._cmapRangeCache['pos' if self.cmapIsLog else 'range'] + + else: + if not self.cmapIsLog: + return self._cmapRange # Return range as is + else: + if self._cmapRangeCache is None: + # Build a strictly positive range from cmapRange + min_, max_ = self._cmapRange + if min_ > 0. and max_ > 0.: + minPos, maxPos = min_, max_ + else: + result = min_max(self.data, min_positive=True) + minPos = result.min_positive + dataMax = result.maximum + if max_ > 0.: + maxPos = max_ + elif dataMax > 0.: + maxPos = dataMax + else: + maxPos = 1. # Arbitrary fallback + if minPos is None: + minPos = maxPos + self._cmapRangeCache = minPos, maxPos + return self._cmapRangeCache # Strictly positive range + + @cmapRange.setter + def cmapRange(self, cmapRange): + self._cmapRangeCache = None + if cmapRange is None: + self._cmapRange = None + else: + assert len(cmapRange) == 2 + assert cmapRange[0] <= cmapRange[1] + self._cmapRange = tuple(cmapRange) + + @property + def alpha(self): + return self._alpha + + def updateData(self, data): + assert data.dtype in self._INTERNAL_FORMATS + oldData = self.data + self.data = data + + self._cmapRangeCache = None + + 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)) + + 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 + + if self.data.dtype in (numpy.uint16, numpy.uint8): + # Using unsigned int as normalized integer in OpenGL + # So normalize range + maxInt = float(numpy.iinfo(self.data.dtype).max) + dataMin, dataMax = dataMin / maxInt, dataMax / maxInt + + if self.cmapIsLog: + dataMin = math.log10(dataMin) + dataMax = math.log10(dataMax) + + gl.glUniform1i(prog.uniforms['cmap.texture'], + self._cmap_texture.texUnit) + gl.glUniform1i(prog.uniforms['cmap.isLog'], self.cmapIsLog) + gl.glUniform1f(prog.uniforms['cmap.min'], dataMin) + if dataMax > dataMin: + oneOverRange = 1. / (dataMax - dataMin) + else: + oneOverRange = 0. # Fall-back + gl.glUniform1f(prog.uniforms['cmap.oneOverRange'], oneOverRange) + + self._cmap_texture.bind() + + def _renderLinear(self, matrix): + self.prepare() + + prog = self._linearProgram + prog.use() + + gl.glUniform1i(prog.uniforms['data'], self._DATA_TEX_UNIT) + + mat = matrix * mat4Translate(*self.origin) * mat4Scale(*self.scale) + gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, mat) + + 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, matrix, isXLog, isYLog): + xMin, yMin = self.xMin, self.yMin + if ((isXLog and xMin < FLOAT32_MINPOS) or + (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, matrix) + mat = mat4Translate(ox, oy) * mat4Scale(*self.scale) + gl.glUniformMatrix4fv(prog.uniforms['matOffset'], 1, gl.GL_TRUE, mat) + + gl.glUniform2i(prog.uniforms['isLog'], isXLog, isYLog) + + ex = ox + self.scale[0] * self.data.shape[1] + ey = oy + self.scale[1] * self.data.shape[0] + + xOneOverRange = 1. / (ex - ox) + yOneOverRange = 1. / (ey - oy) + gl.glUniform2f(prog.uniforms['bounds.originOverRange'], + ox * xOneOverRange, oy * yOneOverRange) + gl.glUniform2f(prog.uniforms['bounds.oneOverRange'], + xOneOverRange, yOneOverRange) + + gl.glUniform1f(prog.uniforms['alpha'], self.alpha) + + self._setCMap(prog) + + try: + tiles = self._texture.tiles + except AttributeError: + raise RuntimeError("No texture, discard has already been called") + if len(tiles) > 1: + raise NotImplementedError( + "Image over multiple textures not supported with log scale") + + texture, vertices, info = tiles[0] + + texture.bind(self._DATA_TEX_UNIT) + + posAttrib = prog.attributes['position'] + stride = vertices.shape[-1] * vertices.itemsize + gl.glEnableVertexAttribArray(posAttrib) + gl.glVertexAttribPointer(posAttrib, + 2, + gl.GL_FLOAT, + gl.GL_FALSE, + stride, vertices) + + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(vertices)) + + def render(self, matrix, isXLog, isYLog): + if any((isXLog, isYLog)): + self._renderLog10(matrix, isXLog, isYLog) + else: + self._renderLinear(matrix) + + # 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 struct { + vec2 oneOverRange; + vec2 originOverRange; + } bounds; + 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)) + + _linearProgram = Program(_SHADERS['linear']['vertex'], + _SHADERS['linear']['fragment'], + attrib0='position') + + _logProgram = Program(_SHADERS['log']['vertex'], + _SHADERS['log']['fragment'], + attrib0='position') + + def __init__(self, data, origin, scale, alpha): + """Create a 2D RGB(A) image from data + + :param data: The 2D image data array to display + :type data: numpy.ndarray with 3 dimensions + (dtype=numpy.uint8 or numpy.float32) + :param origin: (x, y) coordinates of the origin of the data array + :type origin: 2-tuple of floats. + :param scale: (sx, sy) scale factors of the data array. + This is the size of a data pixel in plot data space. + :type scale: 2-tuple of floats. + :param float alpha: Opacity from 0 (transparent) to 1 (opaque) + """ + assert data.dtype in self._SUPPORTED_DTYPES + super(GLPlotRGBAImage, self).__init__(data, origin, scale) + self._texture = None + self._textureIsDirty = False + self._alpha = numpy.clip(alpha, 0., 1.) + + @property + def alpha(self): + return self._alpha + + def discard(self): + if self._texture is not None: + self._texture.discard() + self._texture = None + self._textureIsDirty = False + + 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: + format_ = gl.GL_RGBA if self.data.shape[2] == 4 else gl.GL_RGB + + self._texture = Image(format_, + 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, matrix): + self.prepare() + + prog = self._linearProgram + prog.use() + + gl.glUniform1i(prog.uniforms['tex'], self._DATA_TEX_UNIT) + + mat = matrix * mat4Translate(*self.origin) * mat4Scale(*self.scale) + gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, mat) + + gl.glUniform1f(prog.uniforms['alpha'], self.alpha) + + self._texture.render(prog.attributes['position'], + prog.attributes['texCoords'], + self._DATA_TEX_UNIT) + + def _renderLog(self, matrix, isXLog, isYLog): + 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, matrix) + mat = mat4Translate(ox, oy) * mat4Scale(*self.scale) + gl.glUniformMatrix4fv(prog.uniforms['matOffset'], 1, gl.GL_TRUE, mat) + + gl.glUniform2i(prog.uniforms['isLog'], isXLog, isYLog) + + gl.glUniform1f(prog.uniforms['alpha'], self.alpha) + + ex = ox + self.scale[0] * self.data.shape[1] + ey = oy + self.scale[1] * self.data.shape[0] + + xOneOverRange = 1. / (ex - ox) + yOneOverRange = 1. / (ey - oy) + gl.glUniform2f(prog.uniforms['bounds.originOverRange'], + ox * xOneOverRange, oy * yOneOverRange) + gl.glUniform2f(prog.uniforms['bounds.oneOverRange'], + xOneOverRange, yOneOverRange) + + try: + tiles = self._texture.tiles + except AttributeError: + raise RuntimeError("No texture, discard has already been called") + if len(tiles) > 1: + raise NotImplementedError( + "Image over multiple textures not supported with log scale") + + texture, vertices, info = tiles[0] + + texture.bind(self._DATA_TEX_UNIT) + + posAttrib = prog.attributes['position'] + stride = vertices.shape[-1] * vertices.itemsize + gl.glEnableVertexAttribArray(posAttrib) + gl.glVertexAttribPointer(posAttrib, + 2, + gl.GL_FLOAT, + gl.GL_FALSE, + stride, vertices) + + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, len(vertices)) + + def render(self, matrix, isXLog, isYLog): + if any((isXLog, isYLog)): + self._renderLog(matrix, isXLog, isYLog) + else: + self._renderLinear(matrix) diff --git a/silx/gui/plot/backends/glutils/GLSupport.py b/silx/gui/plot/backends/glutils/GLSupport.py new file mode 100644 index 0000000..3f473be --- /dev/null +++ b/silx/gui/plot/backends/glutils/GLSupport.py @@ -0,0 +1,192 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +This 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): + 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 Shape2D(object): + _NO_HATCH = 0 + _HATCH_STEP = 20 + + def __init__(self, points, fill='solid', stroke=True, + fillColor=(0., 0., 0., 1.), strokeColor=(0., 0., 0., 1.), + strokeClosed=True): + self.vertices = numpy.array(points, dtype=numpy.float32, copy=False) + self.strokeClosed = strokeClosed + + 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.fill = fill + self.fillColor = fillColor + self.stroke = stroke + self.strokeColor = strokeColor + + @property + def xMin(self): + return self._xMin + + @property + def xMax(self): + return self._xMax + + @property + def yMin(self): + return self._yMin + + @property + def yMax(self): + return self._yMax + + def prepareFillMask(self, posAttrib): + 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) + + def renderFill(self, posAttrib): + self.prepareFillMask(posAttrib) + + 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) + + def renderStroke(self, posAttrib): + gl.glEnableVertexAttribArray(posAttrib) + gl.glVertexAttribPointer(posAttrib, + 2, + gl.GL_FLOAT, + gl.GL_FALSE, + 0, self.vertices) + gl.glLineWidth(1) + drawMode = gl.GL_LINE_LOOP if self.strokeClosed else gl.GL_LINE_STRIP + gl.glDrawArrays(drawMode, 0, len(self.vertices)) + + def render(self, posAttrib, colorUnif, hatchStepUnif): + assert self.fill in ['hatch', 'solid', None] + if self.fill is not None: + gl.glUniform4f(colorUnif, *self.fillColor) + step = self._HATCH_STEP if self.fill == 'hatch' else self._NO_HATCH + gl.glUniform1i(hatchStepUnif, step) + self.renderFill(posAttrib) + + if self.stroke: + gl.glUniform4f(colorUnif, *self.strokeColor) + gl.glUniform1i(hatchStepUnif, self._NO_HATCH) + self.renderStroke(posAttrib) + + +# matrix ###################################################################### + +def mat4Ortho(left, right, bottom, top, near, far): + """Orthographic projection matrix (row-major)""" + return numpy.matrix(( + (2./(right - left), 0., 0., -(right+left)/float(right-left)), + (0., 2./(top - bottom), 0., -(top+bottom)/float(top-bottom)), + (0., 0., -2./(far-near), -(far+near)/float(far-near)), + (0., 0., 0., 1.)), dtype=numpy.float32) + + +def mat4Translate(x=0., y=0., z=0.): + """Translation matrix (row-major)""" + return numpy.matrix(( + (1., 0., 0., x), + (0., 1., 0., y), + (0., 0., 1., z), + (0., 0., 0., 1.)), dtype=numpy.float32) + + +def mat4Scale(sx=1., sy=1., sz=1.): + """Scale matrix (row-major)""" + return numpy.matrix(( + (sx, 0., 0., 0.), + (0., sy, 0., 0.), + (0., 0., sz, 0.), + (0., 0., 0., 1.)), dtype=numpy.float32) + + +def mat4Identity(): + """Identity matrix""" + return numpy.matrix(( + (1., 0., 0., 0.), + (0., 1., 0., 0.), + (0., 0., 1., 0.), + (0., 0., 0., 1.)), dtype=numpy.float32) diff --git a/silx/gui/plot/backends/glutils/GLText.py b/silx/gui/plot/backends/glutils/GLText.py new file mode 100644 index 0000000..495882c --- /dev/null +++ b/silx/gui/plot/backends/glutils/GLText.py @@ -0,0 +1,222 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +""" +This module provides minimalistic text support for OpenGL. +It provides Latin-1 (ISO8859-1) characters for one monospace font at one size. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "03/04/2017" + + +import numpy + +from ...._glutils import font, gl, getGLContext, Program, Texture +from .GLSupport import mat4Translate + + +# TODO: Font should be configurable by the main program: using mpl.rcParams? + + +# Text2D ###################################################################### + +LEFT, CENTER, RIGHT = 'left', 'center', 'right' +TOP, BASELINE, BOTTOM = 'top', 'baseline', 'bottom' +ROTATE_90, ROTATE_180, ROTATE_270 = 90, 180, 270 + + +class Text2D(object): + + _SHADERS = { + 'vertex': """ + #version 120 + + attribute vec2 position; + attribute vec2 texCoords; + uniform mat4 matrix; + + varying vec2 vCoords; + + void main(void) { + gl_Position = matrix * vec4(position, 0.0, 1.0); + vCoords = texCoords; + } + """, + 'fragment': """ + #version 120 + + uniform sampler2D texText; + uniform vec4 color; + uniform vec4 bgColor; + + varying vec2 vCoords; + + void main(void) { + gl_FragColor = mix(bgColor, color, texture2D(texText, vCoords).r); + } + """ + } + + _TEX_COORDS = numpy.array(((0., 0.), (1., 0.), (0., 1.), (1., 1.)), + dtype=numpy.float32).ravel() + + _program = Program(_SHADERS['vertex'], + _SHADERS['fragment'], + attrib0='position') + + _textures = {} + + _rasterTextCache = {} + """Internal cache storing already rasterized text""" + # TODO limit cache size and discard least recent used + + def __init__(self, text, x=0, y=0, + color=(0., 0., 0., 1.), + bgColor=None, + align=LEFT, valign=BASELINE, + rotate=0): + self._vertices = None + self._text = text + self.x = x + self.y = y + self.color = color + self.bgColor = bgColor + + if align not in (LEFT, CENTER, RIGHT): + raise ValueError( + "Horizontal alignment not supported: {0}".format(align)) + self._align = align + + if valign not in (TOP, CENTER, BASELINE, BOTTOM): + raise ValueError( + "Vertical alignment not supported: {0}".format(valign)) + self._valign = valign + + self._rotate = numpy.radians(rotate) + + @classmethod + def _getTexture(cls, text): + key = getGLContext(), text + if key not in cls._textures: + image, offset = font.rasterText(text, + font.getDefaultFontFamily()) + cls._textures[key] = (Texture(gl.GL_RED, + data=image, + minFilter=gl.GL_NEAREST, + magFilter=gl.GL_NEAREST, + wrap=(gl.GL_CLAMP_TO_EDGE, + gl.GL_CLAMP_TO_EDGE)), + offset) + + return cls._textures[key] + + @property + def text(self): + return self._text + + @property + def size(self): # TODO very poor implementation + image, offset = font.rasterText(self.text, + font.getDefaultFontFamily()) + return image.shape[1], image.shape[0] + + def getVertices(self, offset, shape): + height, width = shape + + if self._align == LEFT: + xOrig = 0 + elif self._align == RIGHT: + xOrig = - width + else: # CENTER + xOrig = - width // 2 + + if self._valign == BASELINE: + yOrig = - offset + elif self._valign == TOP: + yOrig = 0 + elif self._valign == BOTTOM: + yOrig = - height + else: # CENTER + yOrig = - height // 2 + + vertices = numpy.array(( + (xOrig, yOrig), + (xOrig + width, yOrig), + (xOrig, yOrig + height), + (xOrig + width, yOrig + height)), dtype=numpy.float32) + + cos, sin = numpy.cos(self._rotate), numpy.sin(self._rotate) + vertices = numpy.ascontiguousarray(numpy.transpose(numpy.array(( + cos * vertices[:, 0] - sin * vertices[:, 1], + sin * vertices[:, 0] + cos * vertices[:, 1]), + dtype=numpy.float32))) + + return vertices + + def render(self, matrix): + if not self.text: + return + + prog = self._program + prog.use() + + texUnit = 0 + texture, offset = self._getTexture(self.text) + + gl.glUniform1i(prog.uniforms['texText'], texUnit) + + gl.glUniformMatrix4fv(prog.uniforms['matrix'], 1, gl.GL_TRUE, + matrix * mat4Translate(int(self.x), int(self.y))) + + gl.glUniform4f(prog.uniforms['color'], *self.color) + if self.bgColor is not None: + bgColor = self.bgColor + else: + bgColor = self.color[0], self.color[1], self.color[2], 0. + gl.glUniform4f(prog.uniforms['bgColor'], *bgColor) + + vertices = self.getVertices(offset, texture.shape) + + posAttrib = prog.attributes['position'] + gl.glEnableVertexAttribArray(posAttrib) + gl.glVertexAttribPointer(posAttrib, + 2, + gl.GL_FLOAT, + gl.GL_FALSE, + 0, + vertices) + + texAttrib = prog.attributes['texCoords'] + gl.glEnableVertexAttribArray(texAttrib) + gl.glVertexAttribPointer(texAttrib, + 2, + gl.GL_FLOAT, + gl.GL_FALSE, + 0, + self._TEX_COORDS) + + with texture: + gl.glDrawArrays(gl.GL_TRIANGLE_STRIP, 0, 4) diff --git a/silx/gui/plot/backends/glutils/GLTexture.py b/silx/gui/plot/backends/glutils/GLTexture.py new file mode 100644 index 0000000..25dd9f1 --- /dev/null +++ b/silx/gui/plot/backends/glutils/GLTexture.py @@ -0,0 +1,239 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""This 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) + vertices = numpy.array(( + (0., 0., 0., 0.), + (self.width, 0., 1., 0.), + (0., self.height, 0., 1.), + (self.width, self.height, 1., 1.)), dtype=numpy.float32) + self.tiles = ((texture, vertices, + {'xOrigData': 0, 'yOrigData': 0, + 'wData': self.width, 'hData': self.height}),) + + else: + # Handle dimension too large: make tiles + maxTexSize = _getMaxSquareTexture2DSize(internalFormat, + format_, type_) + + nCols = (self.width+maxTexSize-1) // maxTexSize + colWidths = [self.width // nCols] * nCols + colWidths[-1] += self.width % nCols + + nRows = (self.height+maxTexSize-1) // maxTexSize + rowHeights = [self.height//nRows] * nRows + rowHeights[-1] += self.height % nRows + + tiles = [] + yOrig = 0 + for hData in rowHeights: + xOrig = 0 + for wData in colWidths: + if (hData < MIN_TEXTURE_SIZE or wData < MIN_TEXTURE_SIZE) \ + and not _checkTexture2D(internalFormat, + (hData, wData), + format_, + type_): + # Ensure texture size is at least MIN_TEXTURE_SIZE + tH = max(hData, MIN_TEXTURE_SIZE) + tW = max(wData, MIN_TEXTURE_SIZE) + + uMax, vMax = float(wData)/tW, float(hData)/tH + + # TODO issue with type_ and alignment + texture = Texture(internalFormat, + data=None, + format_=format_, + shape=(tH, tW), + texUnit=texUnit, + minFilter=self._MIN_FILTER, + magFilter=self._MAG_FILTER, + wrap=self._WRAP) + # TODO handle unpack + texture.update(format_, + data[yOrig:yOrig+hData, + xOrig:xOrig+wData]) + # texture.update(format_, type_, data, + # width=wData, height=hData, + # unpackRowLength=width, + # unpackSkipPixels=xOrig, + # unpackSkipRows=yOrig) + else: + uMax, vMax = 1, 1 + # TODO issue with type_ and unpacking tiles + # TODO idea to handle unpack: use array strides + # As it is now, it will make a copy + texture = Texture(internalFormat, + data[yOrig:yOrig+hData, + xOrig:xOrig+wData], + format_, + shape=(hData, wData), + texUnit=texUnit, + minFilter=self._MIN_FILTER, + magFilter=self._MAG_FILTER, + wrap=self._WRAP) + # TODO + # unpackRowLength=width, + # unpackSkipPixels=xOrig, + # unpackSkipRows=yOrig) + vertices = numpy.array(( + (xOrig, yOrig, 0., 0.), + (xOrig + wData, yOrig, uMax, 0.), + (xOrig, yOrig + hData, 0., vMax), + (xOrig + wData, yOrig + hData, uMax, vMax)), + dtype=numpy.float32) + 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) + # 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/silx/gui/plot/backends/glutils/PlotImageFile.py b/silx/gui/plot/backends/glutils/PlotImageFile.py new file mode 100644 index 0000000..e4ebe24 --- /dev/null +++ b/silx/gui/plot/backends/glutils/PlotImageFile.py @@ -0,0 +1,149 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""Function to save an image to a file.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "03/04/2017" + + +import base64 +import struct +import sys +import zlib + + +# Image writer ################################################################ + +def convertRGBDataToPNG(data): + """Convert a RGB bitmap to PNG. + + It only supports RGB bitmap with one byte per channel stored as a 3D array. + See `Definitive Guide <http://www.libpng.org/pub/png/book/>`_ and + `Specification <http://www.libpng.org/pub/png/spec/1.2/>`_ for details. + + :param data: A 3D array (h, w, rgb) storing an RGB image + :type data: numpy.ndarray of unsigned bytes + :returns: The PNG encoded data + :rtype: bytes + """ + height, width = data.shape[0], data.shape[1] + depth = 8 # 8 bit per channel + colorType = 2 # 'truecolor' = RGB + interlace = 0 # No + + IHDRdata = struct.pack(">ccccIIBBBBB", b'I', b'H', b'D', b'R', + width, height, depth, colorType, + 0, 0, interlace) + + # Add filter 'None' before each scanline + preparedData = b'\x00' + b'\x00'.join(line.tostring() for line in data) + compressedData = zlib.compress(preparedData, 8) + + IDATdata = struct.pack("cccc", b'I', b'D', b'A', b'T') + IDATdata += compressedData + + return b''.join([ + b'\x89PNG\r\n\x1a\n', # PNG signature + # IHDR chunk: Image Header + struct.pack(">I", 13), # length + IHDRdata, + struct.pack(">I", zlib.crc32(IHDRdata) & 0xffffffff), # CRC + # IDAT chunk: Payload + struct.pack(">I", len(compressedData)), + IDATdata, + struct.pack(">I", zlib.crc32(IDATdata) & 0xffffffff), # CRC + b'\x00\x00\x00\x00IEND\xaeB`\x82' # IEND chunk: footer + ]) + + +def saveImageToFile(data, fileNameOrObj, fileFormat): + """Save a RGB image to a file. + + :param data: A 3D array (h, w, 3) storing an RGB image. + :type data: numpy.ndarray with of unsigned bytes. + :param fileNameOrObj: Filename or object to use to write the image. + :type fileNameOrObj: A str or a 'file-like' object with a 'write' method. + :param str fileFormat: The type of the file in: 'png', 'ppm', 'svg', 'tiff'. + """ + assert len(data.shape) == 3 + assert data.shape[2] == 3 + assert fileFormat in ('png', 'ppm', 'svg', 'tiff') + + if not hasattr(fileNameOrObj, 'write'): + if sys.version < "3.0": + 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('P6\n') + fileObj.write('%d %d\n' % (width, height)) + fileObj.write('255\n') + fileObj.write(data.tostring()) + + elif fileFormat == 'png': + fileObj.write(convertRGBDataToPNG(data)) + + elif fileFormat == 'tiff': + if fileObj == fileNameOrObj: + raise NotImplementedError( + 'Save TIFF to a file-like object not implemented') + + from silx.third_party.TiffIO import TiffIO + + tif = TiffIO(fileNameOrObj, mode='wb+') + tif.writeImage(data, info={'Title': 'PyMCA GL Snapshot'}) + + if fileObj != fileNameOrObj: + fileObj.close() diff --git a/silx/gui/plot/backends/glutils/__init__.py b/silx/gui/plot/backends/glutils/__init__.py new file mode 100644 index 0000000..771de39 --- /dev/null +++ b/silx/gui/plot/backends/glutils/__init__.py @@ -0,0 +1,44 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""This 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 .GLSupport import * # noqa +from .GLText import * # noqa +from .GLTexture import * # noqa |