diff options
Diffstat (limited to 'src/silx/gui/_glutils')
-rw-r--r-- | src/silx/gui/_glutils/Context.py | 75 | ||||
-rw-r--r-- | src/silx/gui/_glutils/FramebufferTexture.py | 168 | ||||
-rw-r--r-- | src/silx/gui/_glutils/OpenGLWidget.py | 422 | ||||
-rw-r--r-- | src/silx/gui/_glutils/Program.py | 202 | ||||
-rw-r--r-- | src/silx/gui/_glutils/Texture.py | 352 | ||||
-rw-r--r-- | src/silx/gui/_glutils/VertexBuffer.py | 266 | ||||
-rw-r--r-- | src/silx/gui/_glutils/__init__.py | 43 | ||||
-rw-r--r-- | src/silx/gui/_glutils/font.py | 156 | ||||
-rw-r--r-- | src/silx/gui/_glutils/gl.py | 168 | ||||
-rw-r--r-- | src/silx/gui/_glutils/utils.py | 123 |
10 files changed, 1975 insertions, 0 deletions
diff --git a/src/silx/gui/_glutils/Context.py b/src/silx/gui/_glutils/Context.py new file mode 100644 index 0000000..c62dbb9 --- /dev/null +++ b/src/silx/gui/_glutils/Context.py @@ -0,0 +1,75 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2019 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. +# +# ###########################################################################*/ +"""Abstraction of OpenGL context. + +It defines a way to get current OpenGL context to support multiple +OpenGL contexts. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + +import contextlib + + +class _DEFAULT_CONTEXT(object): + """The default value for OpenGL context""" + pass + +_context = _DEFAULT_CONTEXT +"""The current OpenGL context""" + + +def getCurrent(): + """Returns platform dependent object of current OpenGL context. + + This is useful to associate OpenGL resources with the context they are + created in. + + :return: Platform specific OpenGL context + """ + return _context + + +def setCurrent(context=_DEFAULT_CONTEXT): + """Set a platform dependent OpenGL context + + :param context: Platform dependent GL context + """ + global _context + _context = context + + +@contextlib.contextmanager +def current(context): + """Context manager setting the platform-dependent GL context + + :param context: Platform dependent GL context + """ + previous_context = getCurrent() + setCurrent(context) + yield + setCurrent(previous_context) diff --git a/src/silx/gui/_glutils/FramebufferTexture.py b/src/silx/gui/_glutils/FramebufferTexture.py new file mode 100644 index 0000000..d12a6e0 --- /dev/null +++ b/src/silx/gui/_glutils/FramebufferTexture.py @@ -0,0 +1,168 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Association of a texture and a framebuffer object for off-screen rendering. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import logging + +from . import gl +from .Texture import Texture + + +_logger = logging.getLogger(__name__) + + +class FramebufferTexture(object): + """Framebuffer with a texture. + + Aimed at off-screen rendering to texture. + + :param internalFormat: OpenGL texture internal format + :param shape: Shape (height, width) of the framebuffer and texture + :type shape: 2-tuple of int + :param stencilFormat: Stencil renderbuffer format + :param depthFormat: Depth renderbuffer format + :param kwargs: Extra arguments for :class:`Texture` constructor + """ + + _PACKED_FORMAT = gl.GL_DEPTH24_STENCIL8, gl.GL_DEPTH_STENCIL + + def __init__(self, + internalFormat, + shape, + stencilFormat=gl.GL_DEPTH24_STENCIL8, + depthFormat=gl.GL_DEPTH24_STENCIL8, + **kwargs): + + self._texture = Texture(internalFormat, shape=shape, **kwargs) + self._texture.prepare() + + self._previousFramebuffer = 0 # Used by with statement + + self._name = gl.glGenFramebuffers(1) + + with self: # Bind FBO + # Attachments + gl.glFramebufferTexture2D(gl.GL_FRAMEBUFFER, + gl.GL_COLOR_ATTACHMENT0, + gl.GL_TEXTURE_2D, + self._texture.name, + 0) + + height, width = self._texture.shape + + if stencilFormat is not None: + self._stencilId = gl.glGenRenderbuffers(1) + gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._stencilId) + gl.glRenderbufferStorage(gl.GL_RENDERBUFFER, + stencilFormat, + width, height) + gl.glFramebufferRenderbuffer(gl.GL_FRAMEBUFFER, + gl.GL_STENCIL_ATTACHMENT, + gl.GL_RENDERBUFFER, + self._stencilId) + else: + self._stencilId = None + + if depthFormat is not None: + if self._stencilId and depthFormat in self._PACKED_FORMAT: + self._depthId = self._stencilId + else: + self._depthId = gl.glGenRenderbuffers(1) + gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._depthId) + gl.glRenderbufferStorage(gl.GL_RENDERBUFFER, + depthFormat, + width, height) + gl.glFramebufferRenderbuffer(gl.GL_FRAMEBUFFER, + gl.GL_DEPTH_ATTACHMENT, + gl.GL_RENDERBUFFER, + self._depthId) + else: + self._depthId = None + + status = gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER) + if status != gl.GL_FRAMEBUFFER_COMPLETE: + _logger.error( + "OpenGL framebuffer initialization not complete, display may fail (error %d)", + status) + + @property + def shape(self): + """Shape of the framebuffer (height, width)""" + return self._texture.shape + + @property + def texture(self): + """The texture this framebuffer is rendering to. + + The life-cycle of the texture is managed by this object""" + return self._texture + + @property + def name(self): + """OpenGL name of the framebuffer""" + if self._name is not None: + return self._name + else: + raise RuntimeError("No OpenGL framebuffer resource, \ + discard has already been called") + + def bind(self): + """Bind this framebuffer for rendering""" + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.name) + + # with statement + + def __enter__(self): + self._previousFramebuffer = gl.glGetInteger(gl.GL_FRAMEBUFFER_BINDING) + self.bind() + + def __exit__(self, exctype, excvalue, traceback): + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self._previousFramebuffer) + self._previousFramebuffer = None + + def discard(self): + """Delete associated OpenGL resources including texture""" + if self._name is not None: + gl.glDeleteFramebuffers(self._name) + self._name = None + + if self._stencilId is not None: + gl.glDeleteRenderbuffers(self._stencilId) + if self._stencilId == self._depthId: + self._depthId = None + self._stencilId = None + if self._depthId is not None: + gl.glDeleteRenderbuffers(self._depthId) + self._depthId = None + + self._texture.discard() # Also discard the texture + else: + _logger.warning("Discard has already been called") diff --git a/src/silx/gui/_glutils/OpenGLWidget.py b/src/silx/gui/_glutils/OpenGLWidget.py new file mode 100644 index 0000000..2ca4649 --- /dev/null +++ b/src/silx/gui/_glutils/OpenGLWidget.py @@ -0,0 +1,422 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This package provides a compatibility layer for OpenGL widget. + +It provides a compatibility layer for Qt OpenGL widget used in silx +across Qt<=5.3 QtOpenGL.QGLWidget and QOpenGLWidget. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "22/11/2019" + + +import logging +import sys + +from .. import qt +from ..utils.glutils import isOpenGLAvailable +from .._glutils import gl + + +_logger = logging.getLogger(__name__) + + +if not hasattr(qt, 'QOpenGLWidget') and not hasattr(qt, 'QGLWidget'): + OpenGLWidget = None + +else: + if hasattr(qt, 'QOpenGLWidget'): # PyQt>=5.4 + _logger.info('Using QOpenGLWidget') + _BaseOpenGLWidget = qt.QOpenGLWidget + + else: + _logger.info('Using QGLWidget') + _BaseOpenGLWidget = qt.QGLWidget + + class _OpenGLWidget(_BaseOpenGLWidget): + """Wrapper over QOpenGLWidget and QGLWidget""" + + sigOpenGLContextError = qt.Signal(str) + """Signal emitted when an OpenGL context error is detected at runtime. + + It provides the error reason as a str. + """ + + def __init__(self, parent, + alphaBufferSize=0, + depthBufferSize=24, + stencilBufferSize=8, + version=(2, 0), + f=qt.Qt.WindowFlags()): + # True if using QGLWidget, False if using QOpenGLWidget + self.__legacy = not hasattr(qt, 'QOpenGLWidget') + + self.__devicePixelRatio = 1.0 + self.__requestedOpenGLVersion = int(version[0]), int(version[1]) + self.__isValid = False + + if self.__legacy: # QGLWidget + format_ = qt.QGLFormat() + format_.setAlphaBufferSize(alphaBufferSize) + format_.setAlpha(alphaBufferSize != 0) + format_.setDepthBufferSize(depthBufferSize) + format_.setDepth(depthBufferSize != 0) + format_.setStencilBufferSize(stencilBufferSize) + format_.setStencil(stencilBufferSize != 0) + format_.setVersion(*self.__requestedOpenGLVersion) + format_.setDoubleBuffer(True) + + super(_OpenGLWidget, self).__init__(format_, parent, None, f) + + else: # QOpenGLWidget + super(_OpenGLWidget, self).__init__(parent, f) + + format_ = qt.QSurfaceFormat() + format_.setAlphaBufferSize(alphaBufferSize) + format_.setDepthBufferSize(depthBufferSize) + format_.setStencilBufferSize(stencilBufferSize) + format_.setVersion(*self.__requestedOpenGLVersion) + format_.setSwapBehavior(qt.QSurfaceFormat.DoubleBuffer) + self.setFormat(format_) + + # Enable receiving mouse move events when no buttons are pressed + self.setMouseTracking(True) + + def getDevicePixelRatio(self): + """Returns the ratio device-independent / device pixel size + + It should be either 1.0 or 2.0. + + :return: Scale factor between screen and Qt units + :rtype: float + """ + return self.__devicePixelRatio + + def getRequestedOpenGLVersion(self): + """Returns the requested OpenGL version. + + :return: (major, minor) + :rtype: 2-tuple of int""" + return self.__requestedOpenGLVersion + + def getOpenGLVersion(self): + """Returns the available OpenGL version. + + :return: (major, minor) + :rtype: 2-tuple of int""" + if self.__legacy: # QGLWidget + supportedVersion = 0, 0 + + # Go through all OpenGL version flags checking support + flags = self.format().openGLVersionFlags() + for version in ((1, 1), (1, 2), (1, 3), (1, 4), (1, 5), + (2, 0), (2, 1), + (3, 0), (3, 1), (3, 2), (3, 3), + (4, 0)): + versionFlag = getattr(qt.QGLFormat, + 'OpenGL_Version_%d_%d' % version) + if not versionFlag & flags: + break + supportedVersion = version + return supportedVersion + + else: # QOpenGLWidget + return self.format().version() + + # QOpenGLWidget methods + + def isValid(self): + """Returns True if OpenGL is available. + + This adds extra checks to Qt isValid method. + + :rtype: bool + """ + return self.__isValid and super(_OpenGLWidget, self).isValid() + + def defaultFramebufferObject(self): + """Returns the framebuffer object handle. + + See :meth:`QOpenGLWidget.defaultFramebufferObject` + """ + if self.__legacy: # QGLWidget + return 0 + else: # QOpenGLWidget + return super(_OpenGLWidget, self).defaultFramebufferObject() + + # *GL overridden methods + + def initializeGL(self): + parent = self.parent() + if parent is None: + _logger.error('_OpenGLWidget has no parent') + return + + # Check OpenGL version + if self.getOpenGLVersion() >= self.getRequestedOpenGLVersion(): + try: + gl.glGetError() # clear any previous error (if any) + version = gl.glGetString(gl.GL_VERSION) + except: + version = None + + if version: + self.__isValid = True + else: + errMsg = 'OpenGL not available' + if sys.platform.startswith('linux'): + errMsg += ': If connected remotely, ' \ + 'GLX forwarding might be disabled.' + _logger.error(errMsg) + self.sigOpenGLContextError.emit(errMsg) + self.__isValid = False + + else: + errMsg = 'OpenGL %d.%d not available' % \ + self.getRequestedOpenGLVersion() + _logger.error('OpenGL widget disabled: %s', errMsg) + self.sigOpenGLContextError.emit(errMsg) + self.__isValid = False + + if self.isValid(): + parent.initializeGL() + + def paintGL(self): + parent = self.parent() + if parent is None: + _logger.error('_OpenGLWidget has no parent') + return + + devicePixelRatio = self.window().windowHandle().devicePixelRatio() + + if devicePixelRatio != self.getDevicePixelRatio(): + # Update devicePixelRatio and call resizeOpenGL + # as resizeGL is not always called. + self.__devicePixelRatio = devicePixelRatio + self.makeCurrent() + parent.resizeGL(self.width(), self.height()) + + if self.isValid(): + parent.paintGL() + + def resizeGL(self, width, height): + parent = self.parent() + if parent is None: + _logger.error('_OpenGLWidget has no parent') + return + + if self.isValid(): + # Call parent resizeGL with device-independent pixel unit + # This works over both QGLWidget and QOpenGLWidget + parent.resizeGL(self.width(), self.height()) + + +class OpenGLWidget(qt.QWidget): + """OpenGL widget wrapper over QGLWidget and QOpenGLWidget + + This wrapper API implements a subset of QOpenGLWidget API. + The constructor takes a different set of arguments. + Methods returning object like :meth:`context` returns either + QGL* or QOpenGL* objects. + + :param parent: Parent widget see :class:`QWidget` + :param int alphaBufferSize: + Size in bits of the alpha channel (default: 0). + Set to 0 to disable alpha channel. + :param int depthBufferSize: + Size in bits of the depth buffer (default: 24). + Set to 0 to disable depth buffer. + :param int stencilBufferSize: + Size in bits of the stencil buffer (default: 8). + Set to 0 to disable stencil buffer + :param version: Requested OpenGL version (default: (2, 0)). + :type version: 2-tuple of int + :param f: see :class:`QWidget` + """ + + def __init__(self, parent=None, + alphaBufferSize=0, + depthBufferSize=24, + stencilBufferSize=8, + version=(2, 0), + f=qt.Qt.WindowFlags()): + super(OpenGLWidget, self).__init__(parent, f) + + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + + self.__context = None + + _check = isOpenGLAvailable(version=version, runtimeCheck=False) + if _OpenGLWidget is None or not _check: + _logger.error('OpenGL-based widget disabled: %s', _check.error) + self.__openGLWidget = None + label = self._createErrorQLabel(_check.error) + self.layout().addWidget(label) + + else: + self.__openGLWidget = _OpenGLWidget( + parent=self, + alphaBufferSize=alphaBufferSize, + depthBufferSize=depthBufferSize, + stencilBufferSize=stencilBufferSize, + version=version, + f=f) + # Async connection need, otherwise issue when hiding OpenGL + # widget while doing the rendering.. + self.__openGLWidget.sigOpenGLContextError.connect( + self._handleOpenGLInitError, qt.Qt.QueuedConnection) + self.layout().addWidget(self.__openGLWidget) + + @staticmethod + def _createErrorQLabel(error): + """Create QLabel displaying error message in place of OpenGL widget + + :param str error: The error message to display""" + label = qt.QLabel() + label.setText('OpenGL-based widget disabled:\n%s' % error) + label.setAlignment(qt.Qt.AlignCenter) + label.setWordWrap(True) + return label + + def _handleOpenGLInitError(self, error): + """Handle runtime errors in OpenGL widget""" + if self.__openGLWidget is not None: + self.__openGLWidget.setVisible(False) + self.__openGLWidget.setParent(None) + self.__openGLWidget = None + + label = self._createErrorQLabel(error) + self.layout().addWidget(label) + + # Additional API + + def getDevicePixelRatio(self): + """Returns the ratio device-independent / device pixel size + + It should be either 1.0 or 2.0. + + :return: Scale factor between screen and Qt units + :rtype: float + """ + if self.__openGLWidget is None: + return 1. + else: + return self.__openGLWidget.getDevicePixelRatio() + + def getDotsPerInch(self): + """Returns current screen resolution as device pixels per inch. + + :rtype: float + """ + screen = self.window().windowHandle().screen() + if screen is not None: + # TODO check if this is correct on different OS/screen + # OK on macOS10.12/qt5.13.2 + dpi = screen.physicalDotsPerInch() * self.getDevicePixelRatio() + else: # Fallback + dpi = 96. * self.getDevicePixelRatio() + return dpi + + def getOpenGLVersion(self): + """Returns the available OpenGL version. + + :return: (major, minor) + :rtype: 2-tuple of int""" + if self.__openGLWidget is None: + return 0, 0 + else: + return self.__openGLWidget.getOpenGLVersion() + + # QOpenGLWidget API + + def isValid(self): + """Returns True if OpenGL with the requested version is available. + + :rtype: bool + """ + if self.__openGLWidget is None: + return False + else: + return self.__openGLWidget.isValid() + + def context(self): + """Return Qt OpenGL context object or None. + + See :meth:`QOpenGLWidget.context` and :meth:`QGLWidget.context` + """ + if self.__openGLWidget is None: + return None + else: + # Keep a reference on QOpenGLContext to make + # else PyQt5 keeps creating a new one. + self.__context = self.__openGLWidget.context() + return self.__context + + def defaultFramebufferObject(self): + """Returns the framebuffer object handle. + + See :meth:`QOpenGLWidget.defaultFramebufferObject` + """ + if self.__openGLWidget is None: + return 0 + else: + return self.__openGLWidget.defaultFramebufferObject() + + def makeCurrent(self): + """Make the underlying OpenGL widget's context current. + + See :meth:`QOpenGLWidget.makeCurrent` + """ + if self.__openGLWidget is not None: + self.__openGLWidget.makeCurrent() + + def update(self): + """Async update of the OpenGL widget. + + See :meth:`QOpenGLWidget.update` + """ + if self.__openGLWidget is not None: + self.__openGLWidget.update() + + # QOpenGLWidget API to override + + def initializeGL(self): + """Override to implement OpenGL initialization.""" + pass + + def paintGL(self): + """Override to implement OpenGL rendering.""" + pass + + def resizeGL(self, width, height): + """Override to implement resize of OpenGL framebuffer. + + :param int width: Width in device-independent pixels + :param int height: Height in device-independent pixels + """ + pass diff --git a/src/silx/gui/_glutils/Program.py b/src/silx/gui/_glutils/Program.py new file mode 100644 index 0000000..87eec5f --- /dev/null +++ b/src/silx/gui/_glutils/Program.py @@ -0,0 +1,202 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2019 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 handle shader program compilation.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import logging +import weakref + +import numpy + +from . import Context, gl + +_logger = logging.getLogger(__name__) + + +class Program(object): + """Wrap OpenGL shader program. + + The program is compiled lazily (i.e., at first program :meth:`use`). + When the program is compiled, it stores attributes and uniforms locations. + So, attributes and uniforms must be used after :meth:`use`. + + This object supports multiple OpenGL contexts. + + :param str vertexShader: The source of the vertex shader. + :param str fragmentShader: The source of the fragment shader. + :param str attrib0: + Attribute's name to bind to position 0 (default: 'position'). + On certain platform, this attribute MUST be active and with an + array attached to it in order for the rendering to occur.... + """ + + def __init__(self, vertexShader, fragmentShader, + attrib0='position'): + self._vertexShader = vertexShader + self._fragmentShader = fragmentShader + self._attrib0 = attrib0 + self._programs = weakref.WeakKeyDictionary() + + @staticmethod + def _compileGL(vertexShader, fragmentShader, attrib0): + program = gl.glCreateProgram() + + gl.glBindAttribLocation(program, 0, attrib0.encode('ascii')) + + vertex = gl.glCreateShader(gl.GL_VERTEX_SHADER) + gl.glShaderSource(vertex, vertexShader) + gl.glCompileShader(vertex) + if gl.glGetShaderiv(vertex, gl.GL_COMPILE_STATUS) != gl.GL_TRUE: + raise RuntimeError(gl.glGetShaderInfoLog(vertex)) + gl.glAttachShader(program, vertex) + gl.glDeleteShader(vertex) + + fragment = gl.glCreateShader(gl.GL_FRAGMENT_SHADER) + gl.glShaderSource(fragment, fragmentShader) + gl.glCompileShader(fragment) + if gl.glGetShaderiv(fragment, + gl.GL_COMPILE_STATUS) != gl.GL_TRUE: + raise RuntimeError(gl.glGetShaderInfoLog(fragment)) + gl.glAttachShader(program, fragment) + gl.glDeleteShader(fragment) + + gl.glLinkProgram(program) + if gl.glGetProgramiv(program, gl.GL_LINK_STATUS) != gl.GL_TRUE: + raise RuntimeError(gl.glGetProgramInfoLog(program)) + + attributes = {} + for index in range(gl.glGetProgramiv(program, + gl.GL_ACTIVE_ATTRIBUTES)): + name = gl.glGetActiveAttrib(program, index)[0] + namestr = name.decode('ascii') + attributes[namestr] = gl.glGetAttribLocation(program, name) + + uniforms = {} + for index in range(gl.glGetProgramiv(program, gl.GL_ACTIVE_UNIFORMS)): + name = gl.glGetActiveUniform(program, index)[0] + namestr = name.decode('ascii') + uniforms[namestr] = gl.glGetUniformLocation(program, name) + + return program, attributes, uniforms + + def _getProgramInfo(self): + glcontext = Context.getCurrent() + if glcontext not in self._programs: + raise RuntimeError( + "Program was not compiled for current OpenGL context.") + return self._programs[glcontext] + + @property + def attributes(self): + """Vertex attributes names and locations as a dict of {str: int}. + + WARNING: + Read-only usage. + To use only with a valid OpenGL context and after :meth:`use` + has been called for this context. + """ + return self._getProgramInfo()[1] + + @property + def uniforms(self): + """Program uniforms names and locations as a dict of {str: int}. + + WARNING: + Read-only usage. + To use only with a valid OpenGL context and after :meth:`use` + has been called for this context. + """ + return self._getProgramInfo()[2] + + @property + def program(self): + """OpenGL id of the program. + + WARNING: + To use only with a valid OpenGL context and after :meth:`use` + has been called for this context. + """ + return self._getProgramInfo()[0] + + # def discard(self): + # pass # Not implemented yet + + def use(self): + """Make use of the program, compiling it if necessary""" + glcontext = Context.getCurrent() + + if glcontext not in self._programs: + self._programs[glcontext] = self._compileGL( + self._vertexShader, + self._fragmentShader, + self._attrib0) + + if _logger.getEffectiveLevel() <= logging.DEBUG: + gl.glValidateProgram(self.program) + if gl.glGetProgramiv( + self.program, gl.GL_VALIDATE_STATUS) != gl.GL_TRUE: + _logger.debug('Cannot validate program: %s', + gl.glGetProgramInfoLog(self.program)) + + gl.glUseProgram(self.program) + + def setUniformMatrix(self, name, value, transpose=True, safe=False): + """Wrap glUniformMatrix[2|3|4]fv + + :param str name: The name of the uniform. + :param value: The 2D matrix (or the array of matrices, 3D). + Matrices are 2x2, 3x3 or 4x4. + :type value: numpy.ndarray with 2 or 3 dimensions of float32 + :param bool transpose: Whether to transpose (True, default) or not. + :param bool safe: False: raise an error if no uniform with this name; + True: silently ignores it. + + :raises KeyError: if no uniform corresponds to name. + """ + assert value.dtype == numpy.float32 + + shape = value.shape + assert len(shape) in (2, 3) + assert shape[-1] in (2, 3, 4) + assert shape[-1] == shape[-2] # As in OpenGL|ES 2.0 + + location = self.uniforms.get(name) + if location is not None: + count = 1 if len(shape) == 2 else shape[0] + transpose = gl.GL_TRUE if transpose else gl.GL_FALSE + + if shape[-1] == 2: + gl.glUniformMatrix2fv(location, count, transpose, value) + elif shape[-1] == 3: + gl.glUniformMatrix3fv(location, count, transpose, value) + elif shape[-1] == 4: + gl.glUniformMatrix4fv(location, count, transpose, value) + + elif not safe: + raise KeyError('No uniform: %s' % name) diff --git a/src/silx/gui/_glutils/Texture.py b/src/silx/gui/_glutils/Texture.py new file mode 100644 index 0000000..c72135a --- /dev/null +++ b/src/silx/gui/_glutils/Texture.py @@ -0,0 +1,352 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides a class wrapping OpenGL 2D and 3D texture.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "04/10/2016" + + +try: + from collections import abc +except ImportError: # Python2 support + import collections as abc + +from ctypes import c_void_p +import logging + +import numpy + +from . import gl, utils + + +_logger = logging.getLogger(__name__) + + +class Texture(object): + """Base class to wrap OpenGL 2D and 3D texture + + :param internalFormat: OpenGL texture internal format + :param data: The data to copy to the texture or None for an empty texture + :type data: numpy.ndarray or None + :param format_: Input data format if different from internalFormat + :param shape: If data is None, shape of the texture + (height, width) or (depth, height, width) + :type shape: List[int] + :param int texUnit: The texture unit to use + :param minFilter: OpenGL texture minimization filter (default: GL_NEAREST) + :param magFilter: OpenGL texture magnification filter (default: GL_LINEAR) + :param wrap: Texture wrap mode for dimensions: (t, s) or (r, t, s) + If a single value is provided, it used for all dimensions. + :type wrap: OpenGL wrap mode or 2 or 3-tuple of wrap mode + """ + + def __init__(self, internalFormat, data=None, format_=None, + shape=None, texUnit=0, + minFilter=None, magFilter=None, wrap=None): + + self._internalFormat = internalFormat + if format_ is None: + format_ = self.internalFormat + + if data is None: + assert shape is not None + else: + assert shape is None + data = numpy.array(data, copy=False, order='C') + if format_ != gl.GL_RED: + shape = data.shape[:-1] # Last dimension is channels + else: + shape = data.shape + + self._deferredUpdates = [(format_, data, None)] + + assert len(shape) in (2, 3) + self._shape = tuple(shape) + self._ndim = len(shape) + + self.texUnit = texUnit + + self._texParameterUpdates = {} # Store texture params to update + + self._minFilter = minFilter if minFilter is not None else gl.GL_NEAREST + self._texParameterUpdates[gl.GL_TEXTURE_MIN_FILTER] = self._minFilter + + self._magFilter = magFilter if magFilter is not None else gl.GL_LINEAR + self._texParameterUpdates[gl.GL_TEXTURE_MAG_FILTER] = self._magFilter + + self._name = None # Store texture ID + + if wrap is not None: + if not isinstance(wrap, abc.Iterable): + wrap = [wrap] * self.ndim + + assert len(wrap) == self.ndim + + self._texParameterUpdates[gl.GL_TEXTURE_WRAP_S] = wrap[-1] + self._texParameterUpdates[gl.GL_TEXTURE_WRAP_T] = wrap[-2] + if self.ndim == 3: + self._texParameterUpdates[gl.GL_TEXTURE_WRAP_R] = wrap[0] + + @property + def target(self): + """OpenGL target type of this texture""" + return gl.GL_TEXTURE_2D if self.ndim == 2 else gl.GL_TEXTURE_3D + + @property + def ndim(self): + """The number of dimensions: 2 or 3""" + return self._ndim + + @property + def internalFormat(self): + """Texture internal format""" + return self._internalFormat + + @property + def shape(self): + """Shape of the texture: (height, width) or (depth, height, width)""" + return self._shape + + @property + def name(self): + """OpenGL texture name. + + It is None if not initialized or already discarded. + """ + return self._name + + @property + def minFilter(self): + """Minifying function parameter (GL_TEXTURE_MIN_FILTER)""" + return self._minFilter + + @minFilter.setter + def minFilter(self, minFilter): + if minFilter != self.minFilter: + self._minFilter = minFilter + self._texParameterUpdates[gl.GL_TEXTURE_MIN_FILTER] = minFilter + + @property + def magFilter(self): + """Magnification function parameter (GL_TEXTURE_MAG_FILTER)""" + return self._magFilter + + @magFilter.setter + def magFilter(self, magFilter): + if magFilter != self.magFilter: + self._magFilter = magFilter + self._texParameterUpdates[gl.GL_TEXTURE_MAG_FILTER] = magFilter + + def _isPrepareRequired(self) -> bool: + """Returns True if OpenGL texture needs to be updated. + + :rtype: bool + """ + return (self._name is None or + self._texParameterUpdates or + self._deferredUpdates) + + def _prepareAndBind(self, texUnit=None): + """Synchronizes the OpenGL texture""" + if self._name is None: + self._name = gl.glGenTextures(1) + + self._bind(texUnit) + + # Synchronizes texture parameters + for pname, param in self._texParameterUpdates.items(): + gl.glTexParameter(self.target, pname, param) + self._texParameterUpdates = {} + + # Copy data to texture + for format_, data, offset in self._deferredUpdates: + gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) + + # This are the defaults, useless to set if not modified + # gl.glPixelStorei(gl.GL_UNPACK_ROW_LENGTH, 0) + # gl.glPixelStorei(gl.GL_UNPACK_SKIP_PIXELS, 0) + # gl.glPixelStorei(gl.GL_UNPACK_SKIP_ROWS, 0) + # gl.glPixelStorei(gl.GL_UNPACK_IMAGE_HEIGHT, 0) + # gl.glPixelStorei(gl.GL_UNPACK_SKIP_IMAGES, 0) + + if data is None: + data = c_void_p(0) + type_ = gl.GL_UNSIGNED_BYTE + else: + type_ = utils.numpyToGLType(data.dtype) + + if offset is None: # Initialize texture + if self.ndim == 2: + _logger.debug( + 'Creating 2D texture shape: (%d, %d),' + ' internal format: %s, format: %s, type: %s', + self.shape[0], self.shape[1], + str(self.internalFormat), str(format_), str(type_)) + + gl.glTexImage2D( + gl.GL_TEXTURE_2D, + 0, + self.internalFormat, + self.shape[1], + self.shape[0], + 0, + format_, + type_, + data) + + else: + _logger.debug( + 'Creating 3D texture shape: (%d, %d, %d),' + ' internal format: %s, format: %s, type: %s', + self.shape[0], self.shape[1], self.shape[2], + str(self.internalFormat), str(format_), str(type_)) + + gl.glTexImage3D( + gl.GL_TEXTURE_3D, + 0, + self.internalFormat, + self.shape[2], + self.shape[1], + self.shape[0], + 0, + format_, + type_, + data) + + else: # Update already existing texture + if self.ndim == 2: + gl.glTexSubImage2D(gl.GL_TEXTURE_2D, + 0, + offset[1], + offset[0], + data.shape[1], + data.shape[0], + format_, + type_, + data) + + else: + gl.glTexSubImage3D(gl.GL_TEXTURE_3D, + 0, + offset[2], + offset[1], + offset[0], + data.shape[2], + data.shape[1], + data.shape[0], + format_, + type_, + data) + + self._deferredUpdates = [] + + def _bind(self, texUnit=None): + """Bind the texture to a texture unit. + + :param int texUnit: The texture unit to use + """ + if texUnit is None: + texUnit = self.texUnit + gl.glActiveTexture(gl.GL_TEXTURE0 + texUnit) + gl.glBindTexture(self.target, self.name) + + def _unbind(self, texUnit=None): + """Reset texture binding to a texture unit. + + :param int texUnit: The texture unit to use + """ + if texUnit is None: + texUnit = self.texUnit + gl.glActiveTexture(gl.GL_TEXTURE0 + texUnit) + gl.glBindTexture(self.target, 0) + + def prepare(self): + """Synchronizes the OpenGL texture. + + This method must be called with a current OpenGL context. + """ + if self._isPrepareRequired(): + self._prepareAndBind() + self._unbind() + + def bind(self, texUnit=None): + """Bind the texture to a texture unit. + + The OpenGL texture is updated if needed. + + This method must be called with a current OpenGL context. + + :param int texUnit: The texture unit to use + """ + if self._isPrepareRequired(): + self._prepareAndBind(texUnit) + else: + self._bind(texUnit) + + def discard(self): + """Delete associated OpenGL texture. + + This method must be called with a current OpenGL context. + """ + if self._name is not None: + gl.glDeleteTextures(self._name) + self._name = None + else: + _logger.warning("Texture not initialized or already discarded") + + # with statement + + def __enter__(self): + self.bind() + + def __exit__(self, exc_type, exc_val, exc_tb): + self._unbind() + + def update(self, format_, data, offset=(0, 0, 0), copy=True): + """Update the content of the texture. + + Texture is not resized, so data must fit into texture with the + given offset. + + This update is performed lazily during next call to + :meth:`prepare` or :meth:`bind`. + Data MUST not be changed until then. + + :param format_: The OpenGL format of the data + :param data: The data to use to update the texture + :param List[int] offset: Offset in the texture where to copy the data + :param bool copy: + True (default) to copy data, False to use as is (do not modify) + """ + data = numpy.array(data, copy=copy, order='C') + offset = tuple(offset) + + assert data.ndim == self.ndim + assert len(offset) >= self.ndim + for i in range(self.ndim): + assert offset[i] + data.shape[i] <= self.shape[i] + + self._deferredUpdates.append((format_, data, offset)) diff --git a/src/silx/gui/_glutils/VertexBuffer.py b/src/silx/gui/_glutils/VertexBuffer.py new file mode 100644 index 0000000..b74b748 --- /dev/null +++ b/src/silx/gui/_glutils/VertexBuffer.py @@ -0,0 +1,266 @@ +# 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 managing an OpenGL vertex buffer.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "10/01/2017" + + +import logging +from ctypes import c_void_p +import numpy + +from . import gl +from .utils import numpyToGLType, sizeofGLType + + +_logger = logging.getLogger(__name__) + + +class VertexBuffer(object): + """Object handling an OpenGL vertex buffer object + + :param data: Data used to fill the vertex buffer + :type data: numpy.ndarray or None + :param int size: Size in bytes of the buffer or None for data size + :param usage: OpenGL vertex buffer expected usage pattern: + GL_STREAM_DRAW, GL_STATIC_DRAW (default) or GL_DYNAMIC_DRAW + :param target: Target buffer: + GL_ARRAY_BUFFER (default) or GL_ELEMENT_ARRAY_BUFFER + """ + # OpenGL|ES 2.0 subset: + _USAGES = gl.GL_STREAM_DRAW, gl.GL_STATIC_DRAW, gl.GL_DYNAMIC_DRAW + _TARGETS = gl.GL_ARRAY_BUFFER, gl.GL_ELEMENT_ARRAY_BUFFER + + def __init__(self, + data=None, + size=None, + usage=None, + target=None): + if usage is None: + usage = gl.GL_STATIC_DRAW + assert usage in self._USAGES + + if target is None: + target = gl.GL_ARRAY_BUFFER + assert target in self._TARGETS + + self._target = target + self._usage = usage + + self._name = gl.glGenBuffers(1) + self.bind() + + if data is None: + assert size is not None + self._size = size + gl.glBufferData(self._target, + self._size, + c_void_p(0), + self._usage) + else: + data = numpy.array(data, copy=False, order='C') + if size is not None: + assert size <= data.nbytes + + self._size = size or data.nbytes + gl.glBufferData(self._target, + self._size, + data, + self._usage) + + gl.glBindBuffer(self._target, 0) + + @property + def target(self): + """The target buffer of the vertex buffer""" + return self._target + + @property + def usage(self): + """The expected usage of the vertex buffer""" + return self._usage + + @property + def name(self): + """OpenGL Vertex Buffer object name (int)""" + if self._name is not None: + return self._name + else: + raise RuntimeError("No OpenGL buffer resource, \ + discard has already been called") + + @property + def size(self): + """Size in bytes of the Vertex Buffer Object (int)""" + if self._size is not None: + return self._size + else: + raise RuntimeError("No OpenGL buffer resource, \ + discard has already been called") + + def bind(self): + """Bind the vertex buffer""" + gl.glBindBuffer(self._target, self.name) + + def update(self, data, offset=0, size=None): + """Update vertex buffer content. + + :param numpy.ndarray data: The data to put in the vertex buffer + :param int offset: Offset in bytes in the buffer where to put the data + :param int size: If provided, size of data to copy + """ + data = numpy.array(data, copy=False, order='C') + if size is None: + size = data.nbytes + assert offset + size <= self.size + with self: + gl.glBufferSubData(self._target, offset, size, data) + + def discard(self): + """Delete the vertex buffer""" + if self._name is not None: + gl.glDeleteBuffers(self._name) + self._name = None + self._size = None + else: + _logger.warning("Discard has already been called") + + # with statement + + def __enter__(self): + self.bind() + + def __exit__(self, exctype, excvalue, traceback): + gl.glBindBuffer(self._target, 0) + + +class VertexBufferAttrib(object): + """Describes data stored in a vertex buffer + + Convenient class to store info for glVertexAttribPointer calls + + :param VertexBuffer vbo: The vertex buffer storing the data + :param int type_: The OpenGL type of the data + :param int size: The number of data elements stored in the VBO + :param int dimension: The number of `type_` element(s) in [1, 4] + :param int offset: Start offset of data in the vertex buffer + :param int stride: Data stride in the vertex buffer + """ + + _GL_TYPES = gl.GL_UNSIGNED_BYTE, gl.GL_FLOAT, gl.GL_INT + + def __init__(self, + vbo, + type_, + size, + dimension=1, + offset=0, + stride=0, + normalization=False): + self.vbo = vbo + assert type_ in self._GL_TYPES + self.type_ = type_ + self.size = size + assert 1 <= dimension <= 4 + self.dimension = dimension + self.offset = offset + self.stride = stride + self.normalization = bool(normalization) + + @property + def itemsize(self): + """Size in bytes of a vertex buffer element (int)""" + return self.dimension * sizeofGLType(self.type_) + + itemSize = itemsize # Backward compatibility + + def setVertexAttrib(self, attribute): + """Call glVertexAttribPointer with objects information""" + normalization = gl.GL_TRUE if self.normalization else gl.GL_FALSE + with self.vbo: + gl.glVertexAttribPointer(attribute, + self.dimension, + self.type_, + normalization, + self.stride, + c_void_p(self.offset)) + + def copy(self): + return VertexBufferAttrib(self.vbo, + self.type_, + self.size, + self.dimension, + self.offset, + self.stride, + self.normalization) + + +def vertexBuffer(arrays, prefix=None, suffix=None, usage=None): + """Create a single vertex buffer from multiple 1D or 2D numpy arrays. + + It is possible to reserve memory before and after each array in the VBO + + :param arrays: Arrays of data to store + :type arrays: Iterable of numpy.ndarray + :param prefix: If given, number of elements to reserve before each array + :type prefix: Iterable of int or None + :param suffix: If given, number of elements to reserve after each array + :type suffix: Iterable of int or None + :param int usage: vertex buffer expected usage or None for default + :returns: List of VertexBufferAttrib objects sharing the same vertex buffer + """ + info = [] + vbosize = 0 + + if prefix is None: + prefix = (0,) * len(arrays) + if suffix is None: + suffix = (0,) * len(arrays) + + for data, pre, post in zip(arrays, prefix, suffix): + data = numpy.array(data, copy=False, order='C') + shape = data.shape + assert len(shape) <= 2 + type_ = numpyToGLType(data.dtype) + size = shape[0] + pre + post + dimension = 1 if len(shape) == 1 else shape[1] + sizeinbytes = size * dimension * sizeofGLType(type_) + sizeinbytes = 4 * ((sizeinbytes + 3) >> 2) # 4 bytes alignment + copyoffset = vbosize + pre * dimension * sizeofGLType(type_) + info.append((data, type_, size, dimension, + vbosize, sizeinbytes, copyoffset)) + vbosize += sizeinbytes + + vbo = VertexBuffer(size=vbosize, usage=usage) + + result = [] + for data, type_, size, dimension, offset, sizeinbytes, copyoffset in info: + copysize = data.shape[0] * dimension * sizeofGLType(type_) + vbo.update(data, offset=copyoffset, size=copysize) + result.append( + VertexBufferAttrib(vbo, type_, size, dimension, offset, 0)) + return result diff --git a/src/silx/gui/_glutils/__init__.py b/src/silx/gui/_glutils/__init__.py new file mode 100644 index 0000000..e88affd --- /dev/null +++ b/src/silx/gui/_glutils/__init__.py @@ -0,0 +1,43 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This package provides utility functions to handle OpenGL resources. + +The :mod:`gl` module provides a wrapper to OpenGL based on PyOpenGL. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +# OpenGL convenient functions +from .OpenGLWidget import OpenGLWidget # noqa +from . import Context # noqa +from .FramebufferTexture import FramebufferTexture # noqa +from .Program import Program # noqa +from .Texture import Texture # noqa +from .VertexBuffer import VertexBuffer, VertexBufferAttrib, vertexBuffer # noqa +from .utils import sizeofGLType, isSupportedGLType, numpyToGLType # noqa +from .utils import segmentTrianglesIntersection # noqa diff --git a/src/silx/gui/_glutils/font.py b/src/silx/gui/_glutils/font.py new file mode 100644 index 0000000..3ea474d --- /dev/null +++ b/src/silx/gui/_glutils/font.py @@ -0,0 +1,156 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Text rasterisation feature leveraging Qt font and text layout support.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "13/10/2016" + + +import logging +import numpy + +from ..utils.image import convertQImageToArray +from .. import qt + +_logger = logging.getLogger(__name__) + + +def getDefaultFontFamily(): + """Returns the default font family of the application""" + return qt.QApplication.instance().font().family() + + +# Font weights +ULTRA_LIGHT = 0 +"""Lightest characters: Minimum font weight""" + +LIGHT = 25 +"""Light characters""" + +NORMAL = 50 +"""Normal characters""" + +SEMI_BOLD = 63 +"""Between normal and bold characters""" + +BOLD = 74 +"""Thicker characters""" + +BLACK = 87 +"""Really thick characters""" + +ULTRA_BLACK = 99 +"""Thickest characters: Maximum font weight""" + + +def rasterText(text, font, + size=-1, + weight=-1, + italic=False, + devicePixelRatio=1.0): + """Raster text using Qt. + + It supports multiple lines. + + :param str text: The text to raster + :param font: Font name or QFont to use + :type font: str or :class:`QFont` + :param int size: + Font size in points + Used only if font is given as name. + :param int weight: + Font weight in [0, 99], see QFont.Weight. + Used only if font is given as name. + :param bool italic: + True for italic font (default: False). + Used only if font is given as name. + :param float devicePixelRatio: + The current ratio between device and device-independent pixel + (default: 1.0) + :return: Corresponding image in gray scale and baseline offset from top + :rtype: (HxW numpy.ndarray of uint8, int) + """ + if not text: + _logger.info("Trying to raster empty text, replaced by white space") + text = ' ' # Replace empty text by white space to produce an image + + if not isinstance(font, qt.QFont): + font = qt.QFont(font, size, weight, italic) + + # get text size + image = qt.QImage(1, 1, qt.QImage.Format_RGB888) + painter = qt.QPainter() + painter.begin(image) + painter.setPen(qt.Qt.white) + painter.setFont(font) + bounds = painter.boundingRect( + qt.QRect(0, 0, 4096, 4096), qt.Qt.TextExpandTabs, text) + painter.end() + + metrics = qt.QFontMetrics(font) + + # This does not provide the correct text bbox on macOS + # size = metrics.size(qt.Qt.TextExpandTabs, text) + # bounds = metrics.boundingRect( + # qt.QRect(0, 0, size.width(), size.height()), + # qt.Qt.TextExpandTabs, + # text) + + # Add extra border and handle devicePixelRatio + width = bounds.width() * devicePixelRatio + 2 + # align line size to 32 bits to ease conversion to numpy array + width = 4 * ((width + 3) // 4) + image = qt.QImage(int(width), + int(bounds.height() * devicePixelRatio + 2), + qt.QImage.Format_RGB888) + image.setDevicePixelRatio(devicePixelRatio) + + # TODO if Qt5 use Format_Grayscale8 instead + image.fill(0) + + # Raster text + painter = qt.QPainter() + painter.begin(image) + painter.setPen(qt.Qt.white) + painter.setFont(font) + painter.drawText(bounds, qt.Qt.TextExpandTabs, text) + painter.end() + + array = convertQImageToArray(image) + + # RGB to R + array = numpy.ascontiguousarray(array[:, :, 0]) + + # Remove leading and trailing empty columns but one on each side + column_cumsum = numpy.cumsum(numpy.sum(array, axis=0)) + array = array[:, column_cumsum.argmin():column_cumsum.argmax() + 2] + + # Remove leading and trailing empty rows but one on each side + row_cumsum = numpy.cumsum(numpy.sum(array, axis=1)) + min_row = row_cumsum.argmin() + array = array[min_row:row_cumsum.argmax() + 2, :] + + return array, metrics.ascent() - min_row diff --git a/src/silx/gui/_glutils/gl.py b/src/silx/gui/_glutils/gl.py new file mode 100644 index 0000000..608d9ce --- /dev/null +++ b/src/silx/gui/_glutils/gl.py @@ -0,0 +1,168 @@ +# 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 loads PyOpenGL and provides a namespace for OpenGL.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +from contextlib import contextmanager as _contextmanager +from ctypes import c_uint +import logging + +_logger = logging.getLogger(__name__) + +import OpenGL +# Set the following to true for debugging +if _logger.getEffectiveLevel() <= logging.DEBUG: + _logger.debug('Enabling PyOpenGL debug flags') + OpenGL.ERROR_LOGGING = True + OpenGL.ERROR_CHECKING = True + OpenGL.ERROR_ON_COPY = True +else: + OpenGL.ERROR_LOGGING = False + OpenGL.ERROR_CHECKING = False + OpenGL.ERROR_ON_COPY = False + +import OpenGL.GL as _GL +from OpenGL.GL import * # noqa + +# Extentions core in OpenGL 3 +from OpenGL.GL.ARB import framebuffer_object as _FBO +from OpenGL.GL.ARB.framebuffer_object import * # noqa +from OpenGL.GL.ARB.texture_rg import GL_R32F, GL_R16F # noqa +from OpenGL.GL.ARB.texture_rg import GL_R16, GL_R8 # noqa + +# PyOpenGL 3.0.1 does not define it +try: + GLchar +except NameError: + from ctypes import c_char + GLchar = c_char + + +def testGL(): + """Test if required OpenGL version and extensions are available. + + This MUST be run with an active OpenGL context. + """ + version = glGetString(GL_VERSION).split()[0] # get version number + major, minor = int(version[0]), int(version[2]) + if major < 2 or (major == 2 and minor < 1): + raise RuntimeError( + "Requires at least OpenGL version 2.1, running with %s" % version) + + from OpenGL.GL.ARB.framebuffer_object import glInitFramebufferObjectARB + from OpenGL.GL.ARB.texture_rg import glInitTextureRgARB + + if not glInitFramebufferObjectARB(): + raise RuntimeError( + "OpenGL GL_ARB_framebuffer_object extension required !") + + if not glInitTextureRgARB(): + raise RuntimeError("OpenGL GL_ARB_texture_rg extension required !") + + +# Additional setup +if hasattr(glget, 'addGLGetConstant'): + glget.addGLGetConstant(GL_FRAMEBUFFER_BINDING, (1,)) + + +@_contextmanager +def enabled(capacity, enable=True): + """Context manager enabling an OpenGL capacity. + + This is not checking the current state of the capacity. + + :param capacity: The OpenGL capacity enum to enable/disable + :param bool enable: + True (default) to enable during context, False to disable + """ + if bool(enable) == glGetBoolean(capacity): + # Already in the right state: noop + yield + elif enable: + glEnable(capacity) + yield + glDisable(capacity) + else: + glDisable(capacity) + yield + glEnable(capacity) + + +def disabled(capacity, disable=True): + """Context manager disabling an OpenGL capacity. + + This is not checking the current state of the capacity. + + :param capacity: The OpenGL capacity enum to disable/enable + :param bool disable: + True (default) to disable during context, False to enable + """ + return enabled(capacity, not disable) + + +# Additional OpenGL wrapping + +def glGetActiveAttrib(program, index): + """Wrap PyOpenGL glGetActiveAttrib""" + bufsize = glGetProgramiv(program, GL_ACTIVE_ATTRIBUTE_MAX_LENGTH) + length = GLsizei() + size = GLint() + type_ = GLenum() + name = (GLchar * bufsize)() + + _GL.glGetActiveAttrib(program, index, bufsize, length, size, type_, name) + return name.value, size.value, type_.value + + +def glDeleteRenderbuffers(buffers): + if not hasattr(buffers, '__len__'): # Support single int argument + buffers = [buffers] + length = len(buffers) + _FBO.glDeleteRenderbuffers(length, (c_uint * length)(*buffers)) + + +def glDeleteFramebuffers(buffers): + if not hasattr(buffers, '__len__'): # Support single int argument + buffers = [buffers] + length = len(buffers) + _FBO.glDeleteFramebuffers(length, (c_uint * length)(*buffers)) + + +def glDeleteBuffers(buffers): + if not hasattr(buffers, '__len__'): # Support single int argument + buffers = [buffers] + length = len(buffers) + _GL.glDeleteBuffers(length, (c_uint * length)(*buffers)) + + +def glDeleteTextures(textures): + if not hasattr(textures, '__len__'): # Support single int argument + textures = [textures] + length = len(textures) + _GL.glDeleteTextures((c_uint * length)(*textures)) diff --git a/src/silx/gui/_glutils/utils.py b/src/silx/gui/_glutils/utils.py new file mode 100644 index 0000000..5886599 --- /dev/null +++ b/src/silx/gui/_glutils/utils.py @@ -0,0 +1,123 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides conversion functions between OpenGL and numpy types. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "10/01/2017" + +import numpy + +from OpenGL.constants import BYTE_SIZES as _BYTE_SIZES +from OpenGL.constants import ARRAY_TO_GL_TYPE_MAPPING as _ARRAY_TO_GL_TYPE_MAPPING + + +def sizeofGLType(type_): + """Returns the size in bytes of an element of type `type_`""" + return _BYTE_SIZES[type_] + + +def isSupportedGLType(type_): + """Test if a numpy type or dtype can be converted to a GL type.""" + return numpy.dtype(type_).char in _ARRAY_TO_GL_TYPE_MAPPING + + +def numpyToGLType(type_): + """Returns the GL type corresponding the provided numpy type or dtype.""" + return _ARRAY_TO_GL_TYPE_MAPPING[numpy.dtype(type_).char] + + +def segmentTrianglesIntersection(segment, triangles): + """Check for segment/triangles intersection. + + This is based on signed tetrahedron volume comparison. + + See A. Kensler, A., Shirley, P. + Optimizing Ray-Triangle Intersection via Automated Search. + Symposium on Interactive Ray Tracing, vol. 0, p33-38 (2006) + + :param numpy.ndarray segment: + Segment end points as a 2x3 array of coordinates + :param numpy.ndarray triangles: + Nx3x3 array of triangles + :return: (triangle indices, segment parameter, barycentric coord) + Indices of intersected triangles, "depth" along the segment + of the intersection point and barycentric coordinates of intersection + point in the triangle. + :rtype: List[numpy.ndarray] + """ + # TODO triangles from vertices + indices + # TODO early rejection? e.g., check segment bbox vs triangle bbox + segment = numpy.asarray(segment) + assert segment.ndim == 2 + assert segment.shape == (2, 3) + + triangles = numpy.asarray(triangles) + assert triangles.ndim == 3 + assert triangles.shape[1] == 3 + + # Test line/triangles intersection + d = segment[1] - segment[0] + t0s0 = segment[0] - triangles[:, 0, :] + edge01 = triangles[:, 1, :] - triangles[:, 0, :] + edge02 = triangles[:, 2, :] - triangles[:, 0, :] + + dCrossEdge02 = numpy.cross(d, edge02) + t0s0CrossEdge01 = numpy.cross(t0s0, edge01) + volume = numpy.sum(dCrossEdge02 * edge01, axis=1) + del edge01 + subVolumes = numpy.empty((len(triangles), 3), dtype=triangles.dtype) + subVolumes[:, 1] = numpy.sum(dCrossEdge02 * t0s0, axis=1) + del dCrossEdge02 + subVolumes[:, 2] = numpy.sum(t0s0CrossEdge01 * d, axis=1) + subVolumes[:, 0] = volume - subVolumes[:, 1] - subVolumes[:, 2] + intersect = numpy.logical_or( + numpy.all(subVolumes >= 0., axis=1), # All positive + numpy.all(subVolumes <= 0., axis=1)) # All negative + intersect = numpy.where(intersect)[0] # Indices of intersected triangles + + # Get barycentric coordinates + with numpy.errstate(invalid="ignore"): + barycentric = subVolumes[intersect] / volume[intersect].reshape(-1, 1) + del subVolumes + + # Test segment/triangles intersection + volAlpha = numpy.sum(t0s0CrossEdge01[intersect] * edge02[intersect], axis=1) + with numpy.errstate(invalid="ignore"): + t = volAlpha / volume[intersect] # segment parameter of intersected triangles + del t0s0CrossEdge01 + del edge02 + del volAlpha + del volume + + inSegmentMask = numpy.logical_and(t >= 0., t <= 1.) + intersect = intersect[inSegmentMask] + t = t[inSegmentMask] + barycentric = barycentric[inSegmentMask] + + # Sort intersecting triangles by t + indices = numpy.argsort(t) + return intersect[indices], t[indices], barycentric[indices] |