diff options
Diffstat (limited to 'silx/gui/plot3d/items/scatter.py')
-rw-r--r-- | silx/gui/plot3d/items/scatter.py | 474 |
1 files changed, 474 insertions, 0 deletions
diff --git a/silx/gui/plot3d/items/scatter.py b/silx/gui/plot3d/items/scatter.py new file mode 100644 index 0000000..5eea455 --- /dev/null +++ b/silx/gui/plot3d/items/scatter.py @@ -0,0 +1,474 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 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 2D and 3D scatter data item class. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "15/11/2017" + +import collections +import logging +import sys +import numpy + +from ..scene import function, primitives, utils + +from .core import DataItem3D, Item3DChangedType, ItemChangedType +from .mixins import ColormapMixIn, SymbolMixIn + + +_logger = logging.getLevelName(__name__) + + +class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn): + """Description of a 3D scatter plot. + + :param parent: The View widget this item belongs to. + """ + + # TODO supports different size for each point + + def __init__(self, parent=None): + DataItem3D.__init__(self, parent=parent) + ColormapMixIn.__init__(self) + SymbolMixIn.__init__(self) + + noData = numpy.zeros((0, 1), dtype=numpy.float32) + symbol, size = self._getSceneSymbol() + self._scatter = primitives.Points( + x=noData, y=noData, z=noData, value=noData, size=size) + self._scatter.marker = symbol + self._getScenePrimitive().children.append(self._scatter) + + # Connect scene primitive to mix-in class + ColormapMixIn._setSceneColormap(self, self._scatter.colormap) + + def _updated(self, event=None): + """Handle mix-in class updates""" + if event in (ItemChangedType.SYMBOL, ItemChangedType.SYMBOL_SIZE): + symbol, size = self._getSceneSymbol() + self._scatter.marker = symbol + self._scatter.setAttribute('size', size, copy=True) + + super(Scatter3D, self)._updated(event) + + def setData(self, x, y, z, value, copy=True): + """Set the data of the scatter plot + + :param numpy.ndarray x: Array of X coordinates (single value not accepted) + :param y: Points Y coordinate (array-like or single value) + :param z: Points Z coordinate (array-like or single value) + :param value: Points values (array-like or single value) + :param bool copy: + True (default) to copy the data, + False to use provided data (do not modify!) + """ + self._scatter.setAttribute('x', x, copy=copy) + self._scatter.setAttribute('y', y, copy=copy) + self._scatter.setAttribute('z', z, copy=copy) + self._scatter.setAttribute('value', value, copy=copy) + + ColormapMixIn._setRangeFromData(self, self.getValues(copy=False)) + self._updated(ItemChangedType.DATA) + + def getData(self, copy=True): + """Returns data as provided to :meth:`setData`. + + :param bool copy: True to get a copy, + False to return internal data (do not modify!) + :return: (x, y, z, value) + """ + return (self.getXData(copy), + self.getYData(copy), + self.getZData(copy), + self.getValues(copy)) + + def getXData(self, copy=True): + """Returns X data coordinates. + + :param bool copy: True to get a copy, + False to return internal array (do not modify!) + :return: X coordinates + :rtype: numpy.ndarray + """ + return self._scatter.getAttribute('x', copy=copy) + + def getYData(self, copy=True): + """Returns Y data coordinates. + + :param bool copy: True to get a copy, + False to return internal array (do not modify!) + :return: Y coordinates + :rtype: numpy.ndarray + """ + return self._scatter.getAttribute('y', copy=copy) + + def getZData(self, copy=True): + """Returns Z data coordinates. + + :param bool copy: True to get a copy, + False to return internal array (do not modify!) + :return: Z coordinates + :rtype: numpy.ndarray + """ + return self._scatter.getAttribute('z', copy=copy) + + def getValues(self, copy=True): + """Returns data values. + + :param bool copy: True to get a copy, + False to return internal array (do not modify!) + :return: data values + :rtype: numpy.ndarray + """ + return self._scatter.getAttribute('value', copy=copy) + + +class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn): + """2D scatter data with settable visualization mode. + + :param parent: The View widget this item belongs to. + """ + + _VISUALIZATION_PROPERTIES = { + 'points': ('symbol', 'symbolSize'), + 'lines': ('lineWidth',), + 'solid': (), + } + """Dict {visualization mode: property names used in this mode}""" + + def __init__(self, parent=None): + DataItem3D.__init__(self, parent=parent) + ColormapMixIn.__init__(self) + SymbolMixIn.__init__(self) + + self._visualizationMode = 'points' + self._heightMap = False + self._lineWidth = 1. + + self._x = numpy.zeros((0,), dtype=numpy.float32) + self._y = numpy.zeros((0,), dtype=numpy.float32) + self._value = numpy.zeros((0,), dtype=numpy.float32) + + self._cachedLinesIndices = None + self._cachedTrianglesIndices = None + + # Connect scene primitive to mix-in class + ColormapMixIn._setSceneColormap(self, function.Colormap()) + + def _updated(self, event=None): + """Handle mix-in class updates""" + if event in (ItemChangedType.SYMBOL, ItemChangedType.SYMBOL_SIZE): + symbol, size = self._getSceneSymbol() + for child in self._getScenePrimitive().children: + if isinstance(child, primitives.Points): + child.marker = symbol + child.setAttribute('size', size, copy=True) + + elif event == ItemChangedType.VISIBLE: + # TODO smart update?, need dirty flags + self._updateScene() + + super(Scatter2D, self)._updated(event) + + def supportedVisualizations(self): + """Returns the list of supported visualization modes. + + See :meth:`setVisualizationModes` + + :rtype: tuple of str + """ + return tuple(self._VISUALIZATION_PROPERTIES.keys()) + + def setVisualization(self, mode): + """Set the visualization mode of the data. + + Supported visualization modes are: + + - 'points': For scatter plot representation + - 'lines': For Delaunay tesselation-based wireframe representation + - 'solid': For Delaunay tesselation-based solid surface representation + + :param str mode: Mode of representation to use + """ + mode = str(mode) + assert mode in self.supportedVisualizations() + + if mode != self.getVisualization(): + self._visualizationMode = mode + self._updateScene() + self._updated(ItemChangedType.VISUALIZATION_MODE) + + def getVisualization(self): + """Returns the current visualization mode. + + See :meth:`setVisualization` + + :rtype: str + """ + return self._visualizationMode + + def isPropertyEnabled(self, name, visualization=None): + """Returns true if the property is used with visualization mode. + + :param str name: The name of the property to check, in: + 'lineWidth', 'symbol', 'symbolSize' + :param str visualization: + The visualization mode for which to get the info. + By default, it is the current visualization mode. + :return: + """ + assert name in ('lineWidth', 'symbol', 'symbolSize') + if visualization is None: + visualization = self.getVisualization() + assert visualization in self.supportedVisualizations() + return name in self._VISUALIZATION_PROPERTIES[visualization] + + def setHeightMap(self, heightMap): + """Set whether to display the data has a height map or not. + + When displayed as a height map, the data values are used as + z coordinates. + + :param bool heightMap: + True to display a height map, + False to display as 2D data with z=0 + """ + heightMap = bool(heightMap) + if heightMap != self.isHeightMap(): + self._heightMap = heightMap + self._updateScene() + self._updated(Item3DChangedType.HEIGHT_MAP) + + def isHeightMap(self): + """Returns True if data is displayed as a height map. + + :rtype: bool + """ + return self._heightMap + + def getLineWidth(self): + """Return the curve line width in pixels (float)""" + return self._lineWidth + + def setLineWidth(self, width): + """Set the width in pixel of the curve line + + See :meth:`getLineWidth`. + + :param float width: Width in pixels + """ + width = float(width) + assert width >= 1. + if width != self._lineWidth: + self._lineWidth = width + for child in self._getScenePrimitive().children: + if hasattr(child, 'lineWidth'): + child.lineWidth = width + self._updated(ItemChangedType.LINE_WIDTH) + + def setData(self, x, y, value, copy=True): + """Set the data represented by this item. + + Provided arrays must have the same length. + + :param numpy.ndarray x: X coordinates (array-like) + :param numpy.ndarray y: Y coordinates (array-like) + :param value: Points value: array-like or single scalar + :param bool copy: + True (default) to make a copy of the data, + False to avoid copy if possible (do not modify the arrays). + """ + x = numpy.array( + x, copy=copy, dtype=numpy.float32, order='C').reshape(-1) + y = numpy.array( + y, copy=copy, dtype=numpy.float32, order='C').reshape(-1) + assert len(x) == len(y) + + if isinstance(value, collections.Iterable): + value = numpy.array( + value, copy=copy, dtype=numpy.float32, order='C').reshape(-1) + assert len(value) == len(x) + else: # Single scalar + value = numpy.array((float(value),), dtype=numpy.float32) + + self._x = x + self._y = y + self._value = value + + # Reset cache + self._cachedLinesIndices = None + self._cachedTrianglesIndices = None + + # Store data range info + ColormapMixIn._setRangeFromData(self, self.getValues(copy=False)) + + self._updateScene() + + self._updated(ItemChangedType.DATA) + + def getData(self, copy=True): + """Returns data as provided to :meth:`setData`. + + :param bool copy: True to get a copy, + False to return internal data (do not modify!) + :return: (x, y, value) + """ + return (self.getXData(copy=copy), + self.getYData(copy=copy), + self.getValues(copy=copy)) + + def getXData(self, copy=True): + """Returns X data coordinates. + + :param bool copy: True to get a copy, + False to return internal array (do not modify!) + :return: X coordinates + :rtype: numpy.ndarray + """ + return numpy.array(self._x, copy=copy) + + def getYData(self, copy=True): + """Returns Y data coordinates. + + :param bool copy: True to get a copy, + False to return internal array (do not modify!) + :return: Y coordinates + :rtype: numpy.ndarray + """ + return numpy.array(self._y, copy=copy) + + def getValues(self, copy=True): + """Returns data values. + + :param bool copy: True to get a copy, + False to return internal array (do not modify!) + :return: data values + :rtype: numpy.ndarray + """ + return numpy.array(self._value, copy=copy) + + def _updateScene(self): + self._getScenePrimitive().children = [] # Remove previous primitives + + if not self.isVisible(): + return # Update when visible + + x, y, value = self.getData(copy=False) + if len(x) == 0: + return # Nothing to display + + mode = self.getVisualization() + heightMap = self.isHeightMap() + + if mode == 'points': + z = value if heightMap else 0. + symbol, size = self._getSceneSymbol() + primitive = primitives.Points( + x=x, y=y, z=z, value=value, + size=size, + colormap=self._getSceneColormap()) + primitive.marker = symbol + + else: + # TODO run delaunay in a thread + # Compute lines/triangles indices if not cached + if self._cachedTrianglesIndices is None: + coordinates = numpy.array((x, y)).T + + if len(coordinates) > 3: + # Enough points to try a Delaunay tesselation + + # Lazy loading of Delaunay + from silx.third_party.scipy_spatial import Delaunay as _Delaunay + + try: + tri = _Delaunay(coordinates) + except RuntimeError: + _logger.error("Delaunay tesselation failed: %s", + sys.exc_info()[1]) + return None + + self._cachedTrianglesIndices = numpy.ravel( + tri.simplices.astype(numpy.uint32)) + + else: + # 3 or less points: Draw one triangle + self._cachedTrianglesIndices = \ + numpy.arange(3, dtype=numpy.uint32) % len(coordinates) + + if mode == 'lines' and self._cachedLinesIndices is None: + # Compute line indices + self._cachedLinesIndices = utils.triangleToLineIndices( + self._cachedTrianglesIndices, unicity=True) + + if mode == 'lines': + indices = self._cachedLinesIndices + renderMode = 'lines' + else: + indices = self._cachedTrianglesIndices + renderMode = 'triangles' + + # TODO supports x, y instead of copy + if heightMap: + if len(value) == 1: + value = numpy.ones_like(x) * value + coordinates = numpy.array((x, y, value), dtype=numpy.float32).T + else: + coordinates = numpy.array((x, y), dtype=numpy.float32).T + + # TODO option to enable/disable light, cache normals + # TODO smooth surface + if mode == 'solid': + if heightMap: + coordinates = coordinates[indices] + if len(value) > 1: + value = value[indices] + triangleNormals = utils.trianglesNormal(coordinates) + normal = numpy.empty((len(triangleNormals) * 3, 3), + dtype=numpy.float32) + normal[0::3, :] = triangleNormals + normal[1::3, :] = triangleNormals + normal[2::3, :] = triangleNormals + indices = None + else: + normal = (0., 0., 1.) + else: + normal = None + + primitive = primitives.ColormapMesh3D( + coordinates, + value.reshape(-1, 1), # Makes it a 2D array + normal=normal, + colormap=self._getSceneColormap(), + indices=indices, + mode=renderMode) + primitive.lineWidth = self.getLineWidth() + primitive.lineSmooth = False + + self._getScenePrimitive().children = [primitive] |