diff options
Diffstat (limited to 'src/silx/gui/plot3d/scene/function.py')
-rw-r--r-- | src/silx/gui/plot3d/scene/function.py | 654 |
1 files changed, 654 insertions, 0 deletions
diff --git a/src/silx/gui/plot3d/scene/function.py b/src/silx/gui/plot3d/scene/function.py new file mode 100644 index 0000000..2deb785 --- /dev/null +++ b/src/silx/gui/plot3d/scene/function.py @@ -0,0 +1,654 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-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 functions to add to shaders.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "17/07/2018" + + +import contextlib +import logging +import string +import numpy + +from ... import _glutils +from ..._glutils import gl + +from . import event +from . import utils + + +_logger = logging.getLogger(__name__) + + +class ProgramFunction(object): + """Class providing a function to add to a GLProgram shaders. + """ + + def setupProgram(self, context, program): + """Sets-up uniforms of a program using this shader function. + + :param RenderContext context: The current rendering context + :param GLProgram program: The program to set-up. + It MUST be in use and using this function. + """ + pass + + +class Fog(event.Notifier, ProgramFunction): + """Linear fog over the whole scene content. + + The background of the viewport is used as fog color, + otherwise it defaults to white. + """ + # TODO: add more controls (set fog range), add more fog modes + + _fragDecl = """ + /* (1/(far - near) or 0, near) z in [0 (camera), -inf[ */ + uniform vec2 fogExtentInfo; + + /* Color to use as fog color */ + uniform vec3 fogColor; + + vec4 fog(vec4 color, vec4 cameraPosition) { + /* d = (pos - near) / (far - near) */ + float distance = fogExtentInfo.x * (cameraPosition.z/cameraPosition.w - fogExtentInfo.y); + float fogFactor = clamp(distance, 0.0, 1.0); + vec3 rgb = mix(color.rgb, fogColor, fogFactor); + return vec4(rgb.r, rgb.g, rgb.b, color.a); + } + """ + + _fragDeclNoop = """ + vec4 fog(vec4 color, vec4 cameraPosition) { + return color; + } + """ + + def __init__(self): + super(Fog, self).__init__() + self._isOn = True + + @property + def isOn(self): + """True to enable fog, False to disable (bool)""" + return self._isOn + + @isOn.setter + def isOn(self, isOn): + isOn = bool(isOn) + if self._isOn != isOn: + self._isOn = bool(isOn) + self.notify() + + @property + def fragDecl(self): + return self._fragDecl if self.isOn else self._fragDeclNoop + + @property + def fragCall(self): + return "fog" + + @staticmethod + def _zExtentCamera(viewport): + """Return (far, near) planes Z in camera coordinates. + + :param Viewport viewport: + :return: (far, near) position in camera coords (from 0 to -inf) + """ + # Provide scene z extent in camera coords + bounds = viewport.camera.extrinsic.transformBounds( + viewport.scene.bounds(transformed=True, dataBounds=True)) + return bounds[:, 2] + + def setupProgram(self, context, program): + if not self.isOn: + return + + far, near = context.cache(key='zExtentCamera', + factory=self._zExtentCamera, + viewport=context.viewport) + extent = far - near + gl.glUniform2f(program.uniforms['fogExtentInfo'], + 0.9/extent if extent != 0. else 0., + near) + + # Use background color as fog color + bgColor = context.viewport.background + if bgColor is None: + bgColor = 1., 1., 1. + gl.glUniform3f(program.uniforms['fogColor'], *bgColor[:3]) + + +class ClippingPlane(ProgramFunction): + """Description of a clipping plane and rendering. + + Convention: Clipping is performed in camera/eye space. + + :param point: Local coordinates of a point on the plane. + :type point: numpy.ndarray-like of 3 float32 + :param normal: Local coordinates of the plane normal. + :type normal: numpy.ndarray-like of 3 float32 + """ + + _fragDecl = """ + /* Clipping plane */ + /* as rx + gy + bz + a > 0, clipping all positive */ + uniform vec4 planeEq; + + /* Position is in camera/eye coordinates */ + + bool isClipped(vec4 position) { + vec4 tmp = planeEq * position; + float value = tmp.x + tmp.y + tmp.z + planeEq.a; + return (value < 0.0001); + } + + void clipping(vec4 position) { + if (isClipped(position)) { + discard; + } + } + /* End of clipping */ + """ + + _fragDeclNoop = """ + bool isClipped(vec4 position) + { + return false; + } + + void clipping(vec4 position) {} + """ + + def __init__(self, point=(0., 0., 0.), normal=(0., 0., 0.)): + self._plane = utils.Plane(point, normal) + + @property + def plane(self): + """Plane parameters in camera space.""" + return self._plane + + # GL2 + + @property + def fragDecl(self): + return self._fragDecl if self.plane.isPlane else self._fragDeclNoop + + @property + def fragCall(self): + return "clipping" + + def setupProgram(self, context, program): + """Sets-up uniforms of a program using this shader function. + + :param RenderContext context: The current rendering context + :param GLProgram program: The program to set-up. + It MUST be in use and using this function. + """ + if self.plane.isPlane: + gl.glUniform4f(program.uniforms['planeEq'], *self.plane.parameters) + + +class DirectionalLight(event.Notifier, ProgramFunction): + """Description of a directional Phong light. + + :param direction: The direction of the light or None to disable light + :type direction: ndarray of 3 floats or None + :param ambient: RGB ambient light + :type ambient: ndarray of 3 floats in [0, 1], default: (1., 1., 1.) + :param diffuse: RGB diffuse light parameter + :type diffuse: ndarray of 3 floats in [0, 1], default: (0., 0., 0.) + :param specular: RGB specular light parameter + :type specular: ndarray of 3 floats in [0, 1], default: (1., 1., 1.) + :param int shininess: The shininess of the material for specular term, + default: 0 which disables specular component. + """ + + fragmentShaderFunction = """ + /* Lighting */ + struct DLight { + vec3 lightDir; // Direction of light in object space + vec3 ambient; + vec3 diffuse; + vec3 specular; + float shininess; + vec3 viewPos; // Camera position in object space + }; + + uniform DLight dLight; + + vec4 lighting(vec4 color, vec3 position, vec3 normal) + { + normal = normalize(normal); + // 1-sided + float nDotL = max(0.0, dot(normal, - dLight.lightDir)); + + // 2-sided + //float nDotL = dot(normal, - dLight.lightDir); + //if (nDotL < 0.) { + // nDotL = - nDotL; + // normal = - normal; + //} + + float specFactor = 0.; + if (dLight.shininess > 0. && nDotL > 0.) { + vec3 reflection = reflect(dLight.lightDir, normal); + vec3 viewDir = normalize(dLight.viewPos - position); + specFactor = max(0.0, dot(reflection, viewDir)); + if (specFactor > 0.) { + specFactor = pow(specFactor, dLight.shininess); + } + } + + vec3 enlightedColor = color.rgb * (dLight.ambient + + dLight.diffuse * nDotL) + + dLight.specular * specFactor; + + return vec4(enlightedColor.rgb, color.a); + } + /* End of Lighting */ + """ + + fragmentShaderFunctionNoop = """ + vec4 lighting(vec4 color, vec3 position, vec3 normal) + { + return color; + } + """ + + def __init__(self, direction=None, + ambient=(1., 1., 1.), diffuse=(0., 0., 0.), + specular=(1., 1., 1.), shininess=0): + super(DirectionalLight, self).__init__() + self._direction = None + self.direction = direction # Set _direction + self._isOn = True + self._ambient = ambient + self._diffuse = diffuse + self._specular = specular + self._shininess = shininess + + ambient = event.notifyProperty('_ambient') + diffuse = event.notifyProperty('_diffuse') + specular = event.notifyProperty('_specular') + shininess = event.notifyProperty('_shininess') + + @property + def isOn(self): + """True if light is on, False otherwise.""" + return self._isOn and self._direction is not None + + @isOn.setter + def isOn(self, isOn): + self._isOn = bool(isOn) + + @contextlib.contextmanager + def turnOff(self): + """Context manager to temporary turn off lighting during rendering. + + >>> with light.turnOff(): + ... # Do some rendering without lighting + """ + wason = self._isOn + self._isOn = False + yield + self._isOn = wason + + @property + def direction(self): + """The direction of the light, or None if light is not on.""" + return self._direction + + @direction.setter + def direction(self, direction): + if direction is None: + self._direction = None + else: + assert len(direction) == 3 + direction = numpy.array(direction, dtype=numpy.float32, copy=True) + norm = numpy.linalg.norm(direction) + assert norm != 0 + self._direction = direction / norm + self.notify() + + # GL2 + + @property + def fragmentDef(self): + """Definition to add to fragment shader""" + if self.isOn: + return self.fragmentShaderFunction + else: + return self.fragmentShaderFunctionNoop + + @property + def fragmentCall(self): + """Function name to call in fragment shader""" + return "lighting" + + def setupProgram(self, context, program): + """Sets-up uniforms of a program using this shader function. + + :param RenderContext context: The current rendering context + :param GLProgram program: The program to set-up. + It MUST be in use and using this function. + """ + if self.isOn and self._direction is not None: + # Transform light direction from camera space to object coords + lightdir = context.objectToCamera.transformDir( + self._direction, direct=False) + lightdir /= numpy.linalg.norm(lightdir) + + gl.glUniform3f(program.uniforms['dLight.lightDir'], *lightdir) + + # Convert view position to object coords + viewpos = context.objectToCamera.transformPoint( + numpy.array((0., 0., 0., 1.), dtype=numpy.float32), + direct=False, + perspectiveDivide=True)[:3] + gl.glUniform3f(program.uniforms['dLight.viewPos'], *viewpos) + + gl.glUniform3f(program.uniforms['dLight.ambient'], *self.ambient) + gl.glUniform3f(program.uniforms['dLight.diffuse'], *self.diffuse) + gl.glUniform3f(program.uniforms['dLight.specular'], *self.specular) + gl.glUniform1f(program.uniforms['dLight.shininess'], + self.shininess) + + +class Colormap(event.Notifier, ProgramFunction): + + _declTemplate = string.Template(""" + uniform sampler2D cmap_texture; + uniform int cmap_normalization; + uniform float cmap_parameter; + uniform float cmap_min; + uniform float cmap_oneOverRange; + uniform vec4 nancolor; + + const float oneOverLog10 = 0.43429448190325176; + + vec4 colormap(float value) { + float data = value; /* Keep original input value for isnan test */ + + if (cmap_normalization == 1) { /* Log10 mapping */ + if (value > 0.0) { + value = clamp(cmap_oneOverRange * + (oneOverLog10 * log(value) - cmap_min), + 0.0, 1.0); + } else { + value = 0.0; + } + } else if (cmap_normalization == 2) { /* Sqrt mapping */ + if (value > 0.0) { + value = clamp(cmap_oneOverRange * (sqrt(value) - cmap_min), + 0.0, 1.0); + } else { + value = 0.0; + } + } else if (cmap_normalization == 3) { /*Gamma correction mapping*/ + value = pow( + clamp(cmap_oneOverRange * (value - cmap_min), 0.0, 1.0), + cmap_parameter); + } else if (cmap_normalization == 4) { /* arcsinh mapping */ + /* asinh = log(x + sqrt(x*x + 1) for compatibility with GLSL 1.20 */ + value = clamp(cmap_oneOverRange * (log(value + sqrt(value*value + 1.0)) - cmap_min), 0.0, 1.0); + } else { /* Linear mapping */ + value = clamp(cmap_oneOverRange * (value - cmap_min), 0.0, 1.0); + } + + $discard + + vec4 color; + if (data != data) { /* isnan alternative for compatibility with GLSL 1.20 */ + color = nancolor; + } else { + color = texture2D(cmap_texture, vec2(value, 0.5)); + } + return color; + } + """) + + _discardCode = """ + if (value == 0.) { + discard; + } + """ + + call = "colormap" + + NORMS = 'linear', 'log', 'sqrt', 'gamma', 'arcsinh' + """Tuple of supported normalizations.""" + + _COLORMAP_TEXTURE_UNIT = 1 + """Texture unit to use for storing the colormap""" + + def __init__(self, colormap=None, norm='linear', gamma=0., range_=(1., 10.)): + """Shader function to apply a colormap to a value. + + :param colormap: RGB(A) color look-up table (default: gray) + :param colormap: numpy.ndarray of numpy.uint8 of dimension Nx3 or Nx4 + :param str norm: Normalization to apply: see :attr:`NORMS`. + :param float gamma: Gamma normalization parameter + :param range_: Range of value to map to the colormap. + :type range_: 2-tuple of float (begin, end). + """ + super(Colormap, self).__init__() + + # Init privates to default + self._colormap = None + self._norm = 'linear' + self._gamma = -1. + self._range = 1., 10. + self._displayValuesBelowMin = True + self._nancolor = numpy.array((1., 1., 1., 0.), dtype=numpy.float32) + + self._texture = None + self._textureToDiscard = None + + if colormap is None: + # default colormap + colormap = numpy.empty((256, 3), dtype=numpy.uint8) + colormap[:] = numpy.arange(256, + dtype=numpy.uint8)[:, numpy.newaxis] + + # Set to values through properties to perform asserts and updates + self.colormap = colormap + self.norm = norm + self.gamma = gamma + self.range_ = range_ + + @property + def decl(self): + """Source code of the function declaration""" + return self._declTemplate.substitute( + discard="" if self.displayValuesBelowMin else self._discardCode) + + @property + def colormap(self): + """Color look-up table to use.""" + return numpy.array(self._colormap, copy=True) + + @colormap.setter + def colormap(self, colormap): + colormap = numpy.array(colormap, copy=True) + assert colormap.ndim == 2 + assert colormap.shape[1] in (3, 4) + self._colormap = colormap + + if self._texture is not None and self._texture.name is not None: + self._textureToDiscard = self._texture + + data = numpy.empty( + (16, self._colormap.shape[0], self._colormap.shape[1]), + dtype=self._colormap.dtype) + data[:] = self._colormap + + format_ = gl.GL_RGBA if data.shape[-1] == 4 else gl.GL_RGB + + self._texture = _glutils.Texture( + format_, data, format_, + texUnit=self._COLORMAP_TEXTURE_UNIT, + minFilter=gl.GL_NEAREST, + magFilter=gl.GL_NEAREST, + wrap=gl.GL_CLAMP_TO_EDGE) + + self.notify() + + @property + def nancolor(self): + """RGBA color to use for Not-A-Number values as 4 float in [0., 1.]""" + return self._nancolor + + @nancolor.setter + def nancolor(self, color): + color = numpy.clip(numpy.array(color, dtype=numpy.float32), 0., 1.) + assert color.ndim == 1 + assert len(color) == 4 + if not numpy.array_equal(self._nancolor, color): + self._nancolor = color + self.notify() + + @property + def norm(self): + """Normalization to use for colormap mapping. + + One of 'linear' (the default), 'log' for log10 mapping or 'sqrt'. + Invalid values (e.g., negative values with 'log' or 'sqrt') are mapped to 0. + """ + return self._norm + + @norm.setter + def norm(self, norm): + if norm != self._norm: + assert norm in self.NORMS + self._norm = norm + if norm in ('log', 'sqrt'): + self.range_ = self.range_ # To test for positive range_ + self.notify() + + @property + def gamma(self): + """Gamma correction normalization parameter (float >= 0.)""" + return self._gamma + + @gamma.setter + def gamma(self, gamma): + if gamma != self._gamma: + assert gamma >= 0. + self._gamma = gamma + self.notify() + + @property + def range_(self): + """Range of values to map to the colormap. + + 2-tuple of floats: (begin, end). + The begin value is mapped to the origin of the colormap and the + end value is mapped to the other end of the colormap. + The colormap is reversed if begin > end. + """ + return self._range + + @range_.setter + def range_(self, range_): + assert len(range_) == 2 + range_ = float(range_[0]), float(range_[1]) + + if self.norm == 'log' and (range_[0] <= 0. or range_[1] <= 0.): + _logger.warning( + "Log normalization and negative range: updating range.") + minPos = numpy.finfo(numpy.float32).tiny + range_ = max(range_[0], minPos), max(range_[1], minPos) + elif self.norm == 'sqrt' and (range_[0] < 0. or range_[1] < 0.): + _logger.warning( + "Sqrt normalization and negative range: updating range.") + range_ = max(range_[0], 0.), max(range_[1], 0.) + + if range_ != self._range: + self._range = range_ + self.notify() + + @property + def displayValuesBelowMin(self): + """True to display values below colormap min, False to discard them. + """ + return self._displayValuesBelowMin + + @displayValuesBelowMin.setter + def displayValuesBelowMin(self, displayValuesBelowMin): + displayValuesBelowMin = bool(displayValuesBelowMin) + if self._displayValuesBelowMin != displayValuesBelowMin: + self._displayValuesBelowMin = displayValuesBelowMin + self.notify() + + def setupProgram(self, context, program): + """Sets-up uniforms of a program using this shader function. + + :param RenderContext context: The current rendering context + :param GLProgram program: The program to set-up. + It MUST be in use and using this function. + """ + self.prepareGL2(context) # TODO see how to handle + + self._texture.bind() + + gl.glUniform1i(program.uniforms['cmap_texture'], + self._texture.texUnit) + + min_, max_ = self.range_ + param = 0. + if self._norm == 'log': + min_, max_ = numpy.log10(min_), numpy.log10(max_) + normID = 1 + elif self._norm == 'sqrt': + min_, max_ = numpy.sqrt(min_), numpy.sqrt(max_) + normID = 2 + elif self._norm == 'gamma': + # Keep min_, max_ as is + param = self._gamma + normID = 3 + elif self._norm == 'arcsinh': + min_, max_ = numpy.arcsinh(min_), numpy.arcsinh(max_) + normID = 4 + else: # Linear + normID = 0 + + gl.glUniform1i(program.uniforms['cmap_normalization'], normID) + gl.glUniform1f(program.uniforms['cmap_parameter'], param) + gl.glUniform1f(program.uniforms['cmap_min'], min_) + gl.glUniform1f(program.uniforms['cmap_oneOverRange'], + (1. / (max_ - min_)) if max_ != min_ else 0.) + gl.glUniform4f(program.uniforms['nancolor'], *self._nancolor) + + def prepareGL2(self, context): + if self._textureToDiscard is not None: + self._textureToDiscard.discard() + self._textureToDiscard = None + + self._texture.prepare() |