diff options
author | Picca Frédéric-Emmanuel <picca@debian.org> | 2018-03-04 10:22:38 +0100 |
---|---|---|
committer | Picca Frédéric-Emmanuel <picca@debian.org> | 2018-03-04 10:22:38 +0100 |
commit | ff4fbae651b05b715916e6e3f04126d24fbc391d (patch) | |
tree | 4d52921a0c25f47129ef21844d33744cca91fcfa /silx/gui/plot3d/items | |
parent | 94b03dc6b5dfc0613957cd8f585461282375126e (diff) | |
parent | 270d5ddc31c26b62379e3caa9044dd75ccc71847 (diff) |
Update upstream source from tag 'upstream/0.7.0+dfsg'
Update to upstream version '0.7.0+dfsg'
with Debian dir fa8c5340967c8a813b9eb3e27b9f41d777ea47c6
Diffstat (limited to 'silx/gui/plot3d/items')
-rw-r--r-- | silx/gui/plot3d/items/__init__.py | 43 | ||||
-rw-r--r-- | silx/gui/plot3d/items/clipplane.py | 50 | ||||
-rw-r--r-- | silx/gui/plot3d/items/core.py | 622 | ||||
-rw-r--r-- | silx/gui/plot3d/items/image.py | 126 | ||||
-rw-r--r-- | silx/gui/plot3d/items/mesh.py | 145 | ||||
-rw-r--r-- | silx/gui/plot3d/items/mixins.py | 302 | ||||
-rw-r--r-- | silx/gui/plot3d/items/scatter.py | 474 | ||||
-rw-r--r-- | silx/gui/plot3d/items/volume.py | 463 |
8 files changed, 2225 insertions, 0 deletions
diff --git a/silx/gui/plot3d/items/__init__.py b/silx/gui/plot3d/items/__init__.py new file mode 100644 index 0000000..b50ea5a --- /dev/null +++ b/silx/gui/plot3d/items/__init__.py @@ -0,0 +1,43 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This package provides classes that describes :class:`.SceneWidget` content. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "15/11/2017" + + +from .core import DataItem3D, Item3D, GroupItem, GroupWithAxesItem # noqa +from .core import ItemChangedType, Item3DChangedType # noqa +from .mixins import (ColormapMixIn, InterpolationMixIn, # noqa + PlaneMixIn, SymbolMixIn) # noqa +from .clipplane import ClipPlane # noqa +from .image import ImageData, ImageRgba # noqa +from .mesh import Mesh # noqa +from .scatter import Scatter2D, Scatter3D # noqa +from .volume import ScalarField3D # noqa diff --git a/silx/gui/plot3d/items/clipplane.py b/silx/gui/plot3d/items/clipplane.py new file mode 100644 index 0000000..a5ba0e6 --- /dev/null +++ b/silx/gui/plot3d/items/clipplane.py @@ -0,0 +1,50 @@ +# 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 a scene clip plane class. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "15/11/2017" + + +from ..scene import primitives + +from .core import Item3D +from .mixins import PlaneMixIn + + +class ClipPlane(Item3D, PlaneMixIn): + """Represents a clipping plane that clips following items within the group. + + For now only on clip plane is allowed at once in a scene. + """ + + def __init__(self, parent=None): + plane = primitives.ClipPlane() + Item3D.__init__(self, parent=parent, primitive=plane) + PlaneMixIn.__init__(self, plane=plane) diff --git a/silx/gui/plot3d/items/core.py b/silx/gui/plot3d/items/core.py new file mode 100644 index 0000000..e549e59 --- /dev/null +++ b/silx/gui/plot3d/items/core.py @@ -0,0 +1,622 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides the base class for items of the :class:`.SceneWidget`. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "15/11/2017" + +from collections import defaultdict + +import numpy + +from silx.third_party import enum, six + +from ... import qt +from ...plot.items import ItemChangedType +from .. import scene +from ..scene import axes, primitives, transform + + +@enum.unique +class Item3DChangedType(enum.Enum): + """Type of modification provided by :attr:`Item3D.sigItemChanged` signal.""" + + INTERPOLATION = 'interpolationChanged' + """Item3D image interpolation changed flag.""" + + TRANSFORM = 'transformChanged' + """Item3D transform changed flag.""" + + HEIGHT_MAP = 'heightMapChanged' + """Item3D height map changed flag.""" + + ISO_LEVEL = 'isoLevelChanged' + """Isosurface level changed flag.""" + + LABEL = 'labelChanged' + """Item's label changed flag.""" + + BOUNDING_BOX_VISIBLE = 'boundingBoxVisibleChanged' + """Item's bounding box visibility changed""" + + ROOT_ITEM = 'rootItemChanged' + """Item's root changed flag.""" + + +class Item3D(qt.QObject): + """Base class representing an item in the scene. + + :param parent: The View widget this item belongs to. + :param primitive: An optional primitive to use as scene primitive + """ + + _LABEL_INDICES = defaultdict(int) + """Store per class label indices""" + + sigItemChanged = qt.Signal(object) + """Signal emitted when an item's property has changed. + + It provides a flag describing which property of the item has changed. + See :class:`ItemChangedType` and :class:`Item3DChangedType` + for flags description. + """ + + def __init__(self, parent, primitive=None): + qt.QObject.__init__(self, parent) + + if primitive is None: + primitive = scene.Group() + + self._primitive = primitive + + self.__syncForegroundColor() + + labelIndex = self._LABEL_INDICES[self.__class__] + self._label = six.text_type(self.__class__.__name__) + if labelIndex != 0: + self._label += u' %d' % labelIndex + self._LABEL_INDICES[self.__class__] += 1 + + if isinstance(parent, Item3D): + parent.sigItemChanged.connect(self.__parentItemChanged) + + def setParent(self, parent): + """Override set parent to handle root item change""" + previousParent = self.parent() + if isinstance(previousParent, Item3D): + previousParent.sigItemChanged.disconnect(self.__parentItemChanged) + + super(Item3D, self).setParent(parent) + + if isinstance(parent, Item3D): + parent.sigItemChanged.connect(self.__parentItemChanged) + + self._updated(Item3DChangedType.ROOT_ITEM) + + def __parentItemChanged(self, event): + """Handle updates of the parent if it is an Item3D + + :param Item3DChangedType event: + """ + if event == Item3DChangedType.ROOT_ITEM: + self._updated(Item3DChangedType.ROOT_ITEM) + + def root(self): + """Returns the root of the scene this item belongs to. + + The root is the up-most Item3D in the scene tree hierarchy. + + :rtype: Union[Item3D, None] + """ + root = None + ancestor = self.parent() + while isinstance(ancestor, Item3D): + root = ancestor + ancestor = ancestor.parent() + + return root + + def _getScenePrimitive(self): + """Return the group containing the item rendering""" + return self._primitive + + def _updated(self, event=None): + """Handle MixIn class updates. + + :param event: The event to send to :attr:`sigItemChanged` signal. + """ + if event == Item3DChangedType.ROOT_ITEM: + self.__syncForegroundColor() + + if event is not None: + self.sigItemChanged.emit(event) + + # Label + + def getLabel(self): + """Returns the label associated to this item. + + :rtype: str + """ + return self._label + + def setLabel(self, label): + """Set the label associated to this item. + + :param str label: + """ + label = six.text_type(label) + if label != self._label: + self._label = label + self._updated(Item3DChangedType.LABEL) + + # Visibility + + def isVisible(self): + """Returns True if item is visible, else False + + :rtype: bool + """ + return self._getScenePrimitive().visible + + def setVisible(self, visible=True): + """Set the visibility of the item in the scene. + + :param bool visible: True (default) to show the item, False to hide + """ + visible = bool(visible) + primitive = self._getScenePrimitive() + if visible != primitive.visible: + primitive.visible = visible + self._updated(ItemChangedType.VISIBLE) + + # Foreground color + + def _setForegroundColor(self, color): + """Set the foreground color of the item. + + The default implementation does nothing, override it in subclass. + + :param color: RGBA color + :type color: tuple of 4 float in [0., 1.] + """ + if hasattr(super(Item3D, self), '_setForegroundColor'): + super(Item3D, self)._setForegroundColor(color) + + def __syncForegroundColor(self): + """Retrieve foreground color from parent and update this item""" + # Look-up for SceneWidget to get its foreground color + root = self.root() + if root is not None: + widget = root.parent() + if isinstance(widget, qt.QWidget): + self._setForegroundColor( + widget.getForegroundColor().getRgbF()) + + +class DataItem3D(Item3D): + """Base class representing a data item with transform in the scene. + + :param parent: The View widget this item belongs to. + :param Union[GroupBBox, None] group: + The scene group to use for rendering + """ + + def __init__(self, parent, group=None): + if group is None: + group = primitives.GroupBBox() + + # Set-up bounding box + group.boxVisible = False + group.axesVisible = False + else: + assert isinstance(group, primitives.GroupBBox) + + Item3D.__init__(self, parent=parent, primitive=group) + + # Transformations + self._translate = transform.Translate() + self._rotateForwardTranslation = transform.Translate() + self._rotate = transform.Rotate() + self._rotateBackwardTranslation = transform.Translate() + self._translateFromRotationCenter = transform.Translate() + self._matrix = transform.Matrix() + self._scale = transform.Scale() + # Group transforms to do to data before rotation + # This is useful to handle rotation center relative to bbox + self._transformObjectToRotate = transform.TransformList( + [self._matrix, self._scale]) + self._transformObjectToRotate.addListener(self._updateRotationCenter) + + self._rotationCenter = 0., 0., 0. + + self._getScenePrimitive().transforms = [ + self._translate, + self._rotateForwardTranslation, + self._rotate, + self._rotateBackwardTranslation, + self._transformObjectToRotate] + + def _updated(self, event=None): + """Handle MixIn class updates. + + :param event: The event to send to :attr:`sigItemChanged` signal. + """ + if event == ItemChangedType.DATA: + self._updateRotationCenter() + super(DataItem3D, self)._updated(event) + + # Transformations + + def setScale(self, sx=1., sy=1., sz=1.): + """Set the scale of the item in the scene. + + :param float sx: Scale factor along the X axis + :param float sy: Scale factor along the Y axis + :param float sz: Scale factor along the Z axis + """ + scale = numpy.array((sx, sy, sz), dtype=numpy.float32) + if not numpy.all(numpy.equal(scale, self.getScale())): + self._scale.scale = scale + self._updated(Item3DChangedType.TRANSFORM) + + def getScale(self): + """Returns the scales provided by :meth:`setScale`. + + :rtype: numpy.ndarray + """ + return self._scale.scale + + def setTranslation(self, x=0., y=0., z=0.): + """Set the translation of the origin of the item in the scene. + + :param float x: Offset of the data origin on the X axis + :param float y: Offset of the data origin on the Y axis + :param float z: Offset of the data origin on the Z axis + """ + translation = numpy.array((x, y, z), dtype=numpy.float32) + if not numpy.all(numpy.equal(translation, self.getTranslation())): + self._translate.translation = translation + self._updated(Item3DChangedType.TRANSFORM) + + def getTranslation(self): + """Returns the offset set by :meth:`setTranslation`. + + :rtype: numpy.ndarray + """ + return self._translate.translation + + _ROTATION_CENTER_TAGS = 'lower', 'center', 'upper' + + def _updateRotationCenter(self, *args, **kwargs): + """Update rotation center relative to bounding box""" + center = [] + for index, position in enumerate(self.getRotationCenter()): + # Patch position relative to bounding box + if position in self._ROTATION_CENTER_TAGS: + bounds = self._getScenePrimitive().bounds( + transformed=False, dataBounds=True) + bounds = self._transformObjectToRotate.transformBounds(bounds) + + if bounds is None: + position = 0. + elif position == 'lower': + position = bounds[0, index] + elif position == 'center': + position = 0.5 * (bounds[0, index] + bounds[1, index]) + elif position == 'upper': + position = bounds[1, index] + + center.append(position) + + if not numpy.all(numpy.equal( + center, self._rotateForwardTranslation.translation)): + self._rotateForwardTranslation.translation = center + self._rotateBackwardTranslation.translation = \ + - self._rotateForwardTranslation.translation + self._updated(Item3DChangedType.TRANSFORM) + + def setRotationCenter(self, x=0., y=0., z=0.): + """Set the center of rotation of the item. + + Position of the rotation center is either a float + for an absolute position or one of the following + string to define a position relative to the item's bounding box: + 'lower', 'center', 'upper' + + :param x: rotation center position on the X axis + :rtype: float or str + :param y: rotation center position on the Y axis + :rtype: float or str + :param z: rotation center position on the Z axis + :rtype: float or str + """ + center = [] + for position in (x, y, z): + if isinstance(position, six.string_types): + assert position in self._ROTATION_CENTER_TAGS + else: + position = float(position) + center.append(position) + center = tuple(center) + + if center != self._rotationCenter: + self._rotationCenter = center + self._updateRotationCenter() + + def getRotationCenter(self): + """Returns the rotation center set by :meth:`setRotationCenter`. + + :rtype: 3-tuple of float or str + """ + return self._rotationCenter + + def setRotation(self, angle=0., axis=(0., 0., 1.)): + """Set the rotation of the item in the scene + + :param float angle: The rotation angle in degrees. + :param axis: The (x, y, z) coordinates of the rotation axis. + """ + axis = numpy.array(axis, dtype=numpy.float32) + assert axis.ndim == 1 + assert axis.size == 3 + if (self._rotate.angle != angle or + not numpy.all(numpy.equal(axis, self._rotate.axis))): + self._rotate.setAngleAxis(angle, axis) + self._updated(Item3DChangedType.TRANSFORM) + + def getRotation(self): + """Returns the rotation set by :meth:`setRotation`. + + :return: (angle, axis) + :rtype: 2-tuple (float, numpy.ndarray) + """ + return self._rotate.angle, self._rotate.axis + + def setMatrix(self, matrix=None): + """Set the transform matrix + + :param numpy.ndarray matrix: 3x3 transform matrix + """ + matrix4x4 = numpy.identity(4, dtype=numpy.float32) + + if matrix is not None: + matrix = numpy.array(matrix, dtype=numpy.float32) + assert matrix.shape == (3, 3) + matrix4x4[:3, :3] = matrix + + if not numpy.all(numpy.equal(matrix4x4, self._matrix.getMatrix())): + self._matrix.setMatrix(matrix4x4) + self._updated(Item3DChangedType.TRANSFORM) + + def getMatrix(self): + """Returns the matrix set by :meth:`setMatrix` + + :return: 3x3 matrix + :rtype: numpy.ndarray""" + return self._matrix.getMatrix(copy=True)[:3, :3] + + # Bounding box + + def _setForegroundColor(self, color): + """Set the color of the bounding box + + :param color: RGBA color as 4 floats in [0, 1] + """ + self._getScenePrimitive().color = color + super(DataItem3D, self)._setForegroundColor(color) + + def isBoundingBoxVisible(self): + """Returns item's bounding box visibility. + + :rtype: bool + """ + return self._getScenePrimitive().boxVisible + + def setBoundingBoxVisible(self, visible): + """Set item's bounding box visibility. + + :param bool visible: + True to show the bounding box, False (default) to hide it + """ + visible = bool(visible) + primitive = self._getScenePrimitive() + if visible != primitive.boxVisible: + primitive.boxVisible = visible + self._updated(Item3DChangedType.BOUNDING_BOX_VISIBLE) + + +class _BaseGroupItem(DataItem3D): + """Base class for group of items sharing a common transform.""" + + sigItemAdded = qt.Signal(object) + """Signal emitted when a new item is added to the group. + + The newly added item is provided by this signal + """ + + sigItemRemoved = qt.Signal(object) + """Signal emitted when an item is removed from the group. + + The removed item is provided by this signal. + """ + + def __init__(self, parent=None, group=None): + """Base class representing a group of items in the scene. + + :param parent: The View widget this item belongs to. + :param Union[GroupBBox, None] group: + The scene group to use for rendering + """ + DataItem3D.__init__(self, parent=parent, group=group) + self._items = [] + + def addItem(self, item, index=None): + """Add an item to the group + + :param Item3D item: The item to add + :param int index: The index at which to place the item. + By default it is appended to the end of the list. + :raise ValueError: If the item is already in the group. + """ + assert isinstance(item, Item3D) + assert item.parent() in (None, self) + + if item in self.getItems(): + raise ValueError("Item3D already in group: %s" % item) + + item.setParent(self) + if index is None: + self._getScenePrimitive().children.append( + item._getScenePrimitive()) + self._items.append(item) + else: + self._getScenePrimitive().children.insert( + index, item._getScenePrimitive()) + self._items.insert(index, item) + self.sigItemAdded.emit(item) + + def getItems(self): + """Returns the list of items currently present in the group. + + :rtype: tuple + """ + return tuple(self._items) + + def removeItem(self, item): + """Remove an item from the scene. + + :param Item3D item: The item to remove from the scene + :raises ValueError: If the item does not belong to the group + """ + if item not in self.getItems(): + raise ValueError("Item3D not in group: %s" % str(item)) + + self._getScenePrimitive().children.remove(item._getScenePrimitive()) + self._items.remove(item) + item.setParent(None) + self.sigItemRemoved.emit(item) + + def clearItems(self): + """Remove all item from the group.""" + for item in self.getItems(): + self.removeItem(item) + + def visit(self, included=True): + """Generator visiting the group content. + + It traverses the group sub-tree in a top-down left-to-right way. + + :param bool included: True (default) to include self in visit + """ + if included: + yield self + for child in self.getItems(): + yield child + if hasattr(child, 'visit'): + for item in child.visit(included=False): + yield item + + +class GroupItem(_BaseGroupItem): + """Group of items sharing a common transform.""" + + def __init__(self, parent=None): + super(GroupItem, self).__init__(parent=parent) + + +class GroupWithAxesItem(_BaseGroupItem): + """ + Group of items sharing a common transform surrounded with labelled axes. + """ + + def __init__(self, parent=None): + """Class representing a group of items in the scene with labelled axes. + + :param parent: The View widget this item belongs to. + """ + super(GroupWithAxesItem, self).__init__(parent=parent, + group=axes.LabelledAxes()) + + # Axes labels + + def setAxesLabels(self, xlabel=None, ylabel=None, zlabel=None): + """Set the text labels of the axes. + + :param str xlabel: Label of the X axis, None to leave unchanged. + :param str ylabel: Label of the Y axis, None to leave unchanged. + :param str zlabel: Label of the Z axis, None to leave unchanged. + """ + labelledAxes = self._getScenePrimitive() + if xlabel is not None: + labelledAxes.xlabel = xlabel + + if ylabel is not None: + labelledAxes.ylabel = ylabel + + if zlabel is not None: + labelledAxes.zlabel = zlabel + + class _Labels(tuple): + """Return type of :meth:`getAxesLabels`""" + + def getXLabel(self): + """Label of the X axis (str)""" + return self[0] + + def getYLabel(self): + """Label of the Y axis (str)""" + return self[1] + + def getZLabel(self): + """Label of the Z axis (str)""" + return self[2] + + def getAxesLabels(self): + """Returns the text labels of the axes + + >>> group = GroupWithAxesItem() + >>> group.setAxesLabels(xlabel='X') + + You can get the labels either as a 3-tuple: + + >>> xlabel, ylabel, zlabel = group.getAxesLabels() + + Or as an object with methods getXLabel, getYLabel and getZLabel: + + >>> labels = group.getAxesLabels() + >>> labels.getXLabel() + ... 'X' + + :return: object describing the labels + """ + labelledAxes = self._getScenePrimitive() + return self._Labels((labelledAxes.xlabel, + labelledAxes.ylabel, + labelledAxes.zlabel)) diff --git a/silx/gui/plot3d/items/image.py b/silx/gui/plot3d/items/image.py new file mode 100644 index 0000000..9e8bf1e --- /dev/null +++ b/silx/gui/plot3d/items/image.py @@ -0,0 +1,126 @@ +# 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 data and RGB(A) image item class. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "15/11/2017" + +import numpy + +from ..scene import primitives +from .core import DataItem3D, ItemChangedType +from .mixins import ColormapMixIn, InterpolationMixIn + + +class ImageData(DataItem3D, ColormapMixIn, InterpolationMixIn): + """Description of a 2D image data. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + DataItem3D.__init__(self, parent=parent) + ColormapMixIn.__init__(self) + InterpolationMixIn.__init__(self) + + self._data = numpy.zeros((0, 0), dtype=numpy.float32) + + self._image = primitives.ImageData(self._data) + self._getScenePrimitive().children.append(self._image) + + # Connect scene primitive to mix-in class + ColormapMixIn._setSceneColormap(self, self._image.colormap) + InterpolationMixIn._setPrimitive(self, self._image) + + def setData(self, data, copy=True): + """Set the image data to display. + + The data will be casted to float32. + + :param numpy.ndarray data: The image data + :param bool copy: True (default) to copy the data, + False to use as is (do not modify!). + """ + self._image.setData(data, copy=copy) + ColormapMixIn._setRangeFromData(self, self.getData(copy=False)) + self._updated(ItemChangedType.DATA) + + def getData(self, copy=True): + """Get the image data. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :rtype: numpy.ndarray + :return: The image data + """ + return self._image.getData(copy=copy) + + +class ImageRgba(DataItem3D, InterpolationMixIn): + """Description of a 2D data RGB(A) image. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + DataItem3D.__init__(self, parent=parent) + InterpolationMixIn.__init__(self) + + self._data = numpy.zeros((0, 0, 3), dtype=numpy.float32) + + self._image = primitives.ImageRgba(self._data) + self._getScenePrimitive().children.append(self._image) + + # Connect scene primitive to mix-in class + InterpolationMixIn._setPrimitive(self, self._image) + + def setData(self, data, copy=True): + """Set the RGB(A) image data to display. + + Supported array format: float32 in [0, 1], uint8. + + :param numpy.ndarray data: + The RGBA image data as an array of shape (H, W, Channels) + :param bool copy: True (default) to copy the data, + False to use as is (do not modify!). + """ + self._image.setData(data, copy=copy) + self._updated(ItemChangedType.DATA) + + def getData(self, copy=True): + """Get the image data. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :rtype: numpy.ndarray + :return: The image data + """ + return self._image.getData(copy=copy) diff --git a/silx/gui/plot3d/items/mesh.py b/silx/gui/plot3d/items/mesh.py new file mode 100644 index 0000000..8535728 --- /dev/null +++ b/silx/gui/plot3d/items/mesh.py @@ -0,0 +1,145 @@ +# 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 regular mesh item class. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "15/11/2017" + +import numpy + +from ..scene import primitives +from .core import DataItem3D, ItemChangedType + + +class Mesh(DataItem3D): + """Description of mesh. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + DataItem3D.__init__(self, parent=parent) + self._mesh = None + + def setData(self, + position, + color, + normal=None, + mode='triangles', + copy=True): + """Set mesh geometry data. + + Supported drawing modes are: + + - For points: 'points' + - For lines: 'lines', 'line_strip', 'loop' + - For triangles: 'triangles', 'triangle_strip', 'fan' + + :param numpy.ndarray position: + Position (x, y, z) of each vertex as a (N, 3) array + :param numpy.ndarray color: Colors for each point or a single color + :param numpy.ndarray normal: Normals for each point or None (default) + :param str mode: The drawing mode. + :param bool copy: True (default) to copy the data, + False to use as is (do not modify!). + """ + self._getScenePrimitive().children = [] # Remove any previous mesh + + if position is None or len(position) == 0: + self._mesh = 0 + else: + self._mesh = primitives.Mesh3D( + position, color, normal, mode=mode, copy=copy) + self._getScenePrimitive().children.append(self._mesh) + + self.sigItemChanged.emit(ItemChangedType.DATA) + + def getData(self, copy=True): + """Get the mesh geometry. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :return: The positions, colors, normals and mode + :rtype: tuple of numpy.ndarray + """ + return (self.getPositionData(copy=copy), + self.getColorData(copy=copy), + self.getNormalData(copy=copy), + self.getDrawMode()) + + def getPositionData(self, copy=True): + """Get the mesh vertex positions. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :return: The (x, y, z) positions as a (N, 3) array + :rtype: numpy.ndarray + """ + if self._mesh is None: + return numpy.empty((0, 3), dtype=numpy.float32) + else: + return self._mesh.getAttribute('position', copy=copy) + + def getColorData(self, copy=True): + """Get the mesh vertex colors. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :return: The RGBA colors as a (N, 4) array or a single color + :rtype: numpy.ndarray + """ + if self._mesh is None: + return numpy.empty((0, 4), dtype=numpy.float32) + else: + return self._mesh.getAttribute('color', copy=copy) + + def getNormalData(self, copy=True): + """Get the mesh vertex normals. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :return: The normals as a (N, 3) array, a single normal or None + :rtype: numpy.ndarray or None + """ + if self._mesh is None: + return None + else: + return self._mesh.getAttribute('normal', copy=copy) + + def getDrawMode(self): + """Get mesh rendering mode. + + :return: The drawing mode of this primitive + :rtype: str + """ + return self._mesh.drawMode diff --git a/silx/gui/plot3d/items/mixins.py b/silx/gui/plot3d/items/mixins.py new file mode 100644 index 0000000..41ad3c3 --- /dev/null +++ b/silx/gui/plot3d/items/mixins.py @@ -0,0 +1,302 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides mix-in classes for :class:`Item3D`. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "15/11/2017" + + +import collections +import numpy + +from silx.math.combo import min_max + +from ...plot.items.core import ItemMixInBase +from ...plot.items.core import ColormapMixIn as _ColormapMixIn +from ...plot.items.core import SymbolMixIn as _SymbolMixIn +from ...plot.Colors import rgba + +from ..scene import primitives +from .core import Item3DChangedType, ItemChangedType + + +class InterpolationMixIn(ItemMixInBase): + """Mix-in class for image interpolation mode + + :param str mode: 'linear' (default) or 'nearest' + :param primitive: + scene object for which to sync interpolation mode. + This object MUST have an interpolation property that is updated. + """ + + NEAREST_INTERPOLATION = 'nearest' + """Nearest interpolation mode (see :meth:`setInterpolation`)""" + + LINEAR_INTERPOLATION = 'linear' + """Linear interpolation mode (see :meth:`setInterpolation`)""" + + INTERPOLATION_MODES = NEAREST_INTERPOLATION, LINEAR_INTERPOLATION + """Supported interpolation modes for :meth:`setInterpolation`""" + + def __init__(self, mode=NEAREST_INTERPOLATION, primitive=None): + self.__primitive = primitive + self._syncPrimitiveInterpolation() + + self.__interpolationMode = None + self.setInterpolation(mode) + + def _setPrimitive(self, primitive): + + """Set the scene object for which to sync interpolation""" + self.__primitive = primitive + self._syncPrimitiveInterpolation() + + def _syncPrimitiveInterpolation(self): + """Synchronize scene object's interpolation""" + if self.__primitive is not None: + self.__primitive.interpolation = self.getInterpolation() + + def setInterpolation(self, mode): + """Set image interpolation mode + + :param str mode: 'nearest' or 'linear' + """ + mode = str(mode) + assert mode in self.INTERPOLATION_MODES + if mode != self.__interpolationMode: + self.__interpolationMode = mode + self._syncPrimitiveInterpolation() + self._updated(Item3DChangedType.INTERPOLATION) + + def getInterpolation(self): + """Returns the interpolation mode set by :meth:`setInterpolation` + + :rtype: str + """ + return self.__interpolationMode + + +class ColormapMixIn(_ColormapMixIn): + """Mix-in class for Item3D object with a colormap + + :param sceneColormap: + The plot3d scene colormap to sync with Colormap object. + """ + + def __init__(self, sceneColormap=None): + super(ColormapMixIn, self).__init__() + + self._dataRange = None + self.__sceneColormap = sceneColormap + self._syncSceneColormap() + + self.sigItemChanged.connect(self.__colormapUpdated) + + def __colormapUpdated(self, event): + """Handle colormap updates""" + if event == ItemChangedType.COLORMAP: + self._syncSceneColormap() + + def _setRangeFromData(self, data=None): + """Compute the data range the colormap should use from provided data. + + :param data: Data set from which to compute the range or None + """ + if data is None or len(data) == 0: + dataRange = None + else: + dataRange = min_max(data, min_positive=True, finite=True) + if dataRange.minimum is None: # Only non-finite data + dataRange = None + + if dataRange is not None: + min_positive = dataRange.min_positive + if min_positive is None: + min_positive = float('nan') + dataRange = dataRange.minimum, min_positive, dataRange.maximum + + self._dataRange = dataRange + + if self.getColormap().isAutoscale(): + self._syncSceneColormap() + + def _setSceneColormap(self, sceneColormap): + """Set the scene colormap to sync with Colormap object. + + :param sceneColormap: + The plot3d scene colormap to sync with Colormap object. + """ + self.__sceneColormap = sceneColormap + self._syncSceneColormap() + + def _getSceneColormap(self): + """Returns scene colormap that is sync""" + return self.__sceneColormap + + def _syncSceneColormap(self): + """Synchronizes scene's colormap with Colormap object""" + if self.__sceneColormap is not None: + colormap = self.getColormap() + + self.__sceneColormap.colormap = colormap.getNColors() + self.__sceneColormap.norm = colormap.getNormalization() + range_ = colormap.getColormapRange(data=self._dataRange) + self.__sceneColormap.range_ = range_ + + +class SymbolMixIn(_SymbolMixIn): + """Mix-in class for symbol and symbolSize properties for Item3D""" + + _DEFAULT_SYMBOL = 'o' + _DEFAULT_SYMBOL_SIZE = 7.0 + _SUPPORTED_SYMBOLS = collections.OrderedDict(( + ('o', 'Circle'), + ('d', 'Diamond'), + ('s', 'Square'), + ('+', 'Plus'), + ('x', 'Cross'), + ('*', 'Star'), + ('|', 'Vertical Line'), + ('_', 'Horizontal Line'), + ('.', 'Point'), + (',', 'Pixel'))) + + def _getSceneSymbol(self): + """Returns a symbol name and size suitable for scene primitives. + + :return: (symbol, size) + """ + symbol = self.getSymbol() + size = self.getSymbolSize() + if symbol == ',': # pixel + return 's', 1. + elif symbol == '.': # point + # Size as in plot OpenGL backend, mimic matplotlib + return 'o', numpy.ceil(0.5 * size) + 1. + else: + return symbol, size + + +class PlaneMixIn(ItemMixInBase): + """Mix-in class for plane items (based on PlaneInGroup primitive)""" + + def __init__(self, plane): + assert isinstance(plane, primitives.PlaneInGroup) + self.__plane = plane + self.__plane.alpha = 1. + self.__plane.addListener(self._planeChanged) + self.__plane.plane.addListener(self._planePositionChanged) + + def _getPlane(self): + """Returns plane primitive + + :rtype: primitives.PlaneInGroup + """ + return self.__plane + + def _planeChanged(self, source, *args, **kwargs): + """Handle events from the plane primitive""" + # Sync visibility + if source.visible != self.isVisible(): + self.setVisible(source.visible) + + def _planePositionChanged(self, source, *args, **kwargs): + """Handle update of cut plane position and normal""" + if self.__plane.visible: # TODO send even if hidden? or send also when showing if moved while hidden + self._updated(ItemChangedType.POSITION) + + # Plane position + + def moveToCenter(self): + """Move cut plane to center of data set""" + self.__plane.moveToCenter() + + def isValid(self): + """Returns whether the cut plane is defined or not (bool)""" + return self.__plane.isValid + + def getNormal(self): + """Returns the normal of the plane (as a unit vector) + + :return: Normal (nx, ny, nz), vector is 0 if no plane is defined + :rtype: numpy.ndarray + """ + return self.__plane.plane.normal + + def setNormal(self, normal): + """Set the normal of the plane + + :param normal: 3-tuple of float: nx, ny, nz + """ + self.__plane.plane.normal = normal + + def getPoint(self): + """Returns a point on the plane + + :return: (x, y, z) + :rtype: numpy.ndarray + """ + return self.__plane.plane.point + + def setPoint(self, point): + """Set a point contained in the plane. + + Warning: The plane might not intersect the bounding box of the data. + + :param point: (x, y, z) position + :type point: 3-tuple of float + """ + self.__plane.plane.point = point # TODO rework according to PR #1303 + + def getParameters(self): + """Returns the plane equation parameters: a*x + b*y + c*z + d = 0 + + :return: Plane equation parameters: (a, b, c, d) + :rtype: numpy.ndarray + """ + return self.__plane.plane.parameters + + def setParameters(self, parameters): + """Set the plane equation parameters: a*x + b*y + c*z + d = 0 + + Warning: The plane might not intersect the bounding box of the data. + The given parameters will be normalized. + + :param parameters: (a, b, c, d) equation parameters + """ + self.__plane.plane.parameters = parameters + + # Border stroke + + def _setForegroundColor(self, color): + """Set the color of the plane border. + + :param color: RGBA color as 4 floats in [0, 1] + """ + self.__plane.color = rgba(color) + if hasattr(super(PlaneMixIn, self), '_setForegroundColor'): + super(PlaneMixIn, self)._setForegroundColor(color) 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] diff --git a/silx/gui/plot3d/items/volume.py b/silx/gui/plot3d/items/volume.py new file mode 100644 index 0000000..a1f40f7 --- /dev/null +++ b/silx/gui/plot3d/items/volume.py @@ -0,0 +1,463 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides 3D array item class and its sub-items. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "15/11/2017" + +import logging +import time +import numpy + +from silx.math.combo import min_max +from silx.math.marchingcubes import MarchingCubes + +from ... import qt +from ...plot.Colors import rgba + +from ..scene import cutplane, primitives, transform + +from .core import DataItem3D, Item3D, ItemChangedType, Item3DChangedType +from .mixins import ColormapMixIn, InterpolationMixIn, PlaneMixIn + + +_logger = logging.getLogger(__name__) + + +class CutPlane(Item3D, ColormapMixIn, InterpolationMixIn, PlaneMixIn): + """Class representing a cutting plane in a :class:`ScalarField3D` item. + + :param parent: 3D Data set in which the cut plane is applied. + """ + + def __init__(self, parent): + plane = cutplane.CutPlane(normal=(0, 1, 0)) + + Item3D.__init__(self, parent=parent) + ColormapMixIn.__init__(self) + InterpolationMixIn.__init__(self) + PlaneMixIn.__init__(self, plane=plane) + + self._dataRange = None + + self._getScenePrimitive().children = [plane] + + # Connect scene primitive to mix-in class + ColormapMixIn._setSceneColormap(self, plane.colormap) + InterpolationMixIn._setPrimitive(self, plane) + + parent.sigItemChanged.connect(self._parentChanged) + + def _parentChanged(self, event): + """Handle data change in the parent this plane belongs to""" + if event == ItemChangedType.DATA: + self._getPlane().setData(self.sender().getData(), copy=False) + + # Store data range info as 3-tuple of values + self._dataRange = self.sender().getDataRange() + + self.sigItemChanged.emit(ItemChangedType.DATA) + + # Colormap + + def getDisplayValuesBelowMin(self): + """Return whether values <= colormap min are displayed or not. + + :rtype: bool + """ + return self._getPlane().colormap.displayValuesBelowMin + + def setDisplayValuesBelowMin(self, display): + """Set whether to display values <= colormap min. + + :param bool display: True to show values below min, + False to discard them + """ + display = bool(display) + if display != self.getDisplayValuesBelowMin(): + self._getPlane().colormap.displayValuesBelowMin = display + self.sigItemChanged.emit(ItemChangedType.ALPHA) + + def getDataRange(self): + """Return the range of the data as a 3-tuple of values. + + positive min is NaN if no data is positive. + + :return: (min, positive min, max) or None. + """ + return self._dataRange + + +class Isosurface(Item3D): + """Class representing an iso-surface in a :class:`ScalarField3D` item. + + :param parent: The DataItem3D this iso-surface belongs to + """ + + def __init__(self, parent): + Item3D.__init__(self, parent=parent) + self._level = float('nan') + self._autoLevelFunction = None + self._color = rgba('#FFD700FF') + self._data = None + + # TODO register to ScalarField3D signal instead? + def _setData(self, data, copy=True): + """Set the data set from which to build the iso-surface. + + :param numpy.ndarray data: The 3D data set or None + :param bool copy: True to make a copy, False to use as is if possible + """ + if data is None: + self._data = None + else: + self._data = numpy.array(data, copy=copy, order='C') + + self._updateScenePrimitive() + + def getLevel(self): + """Return the level of this iso-surface (float)""" + return self._level + + def setLevel(self, level): + """Set the value at which to build the iso-surface. + + Setting this value reset auto-level function + + :param float level: The value at which to build the iso-surface + """ + self._autoLevelFunction = None + level = float(level) + if level != self._level: + self._level = level + self._updateScenePrimitive() + self._updated(Item3DChangedType.ISO_LEVEL) + + def isAutoLevel(self): + """True if iso-level is rebuild for each data set.""" + return self.getAutoLevelFunction() is not None + + def getAutoLevelFunction(self): + """Return the function computing the iso-level (callable or None)""" + return self._autoLevelFunction + + def setAutoLevelFunction(self, autoLevel): + """Set the function used to compute the iso-level. + + WARNING: The function might get called in a thread. + + :param callable autoLevel: + A function taking a 3D numpy.ndarray of float32 and returning + a float used as iso-level. + Example: numpy.mean(data) + numpy.std(data) + """ + assert callable(autoLevel) + self._autoLevelFunction = autoLevel + self._updateScenePrimitive() + + def getColor(self): + """Return the color of this iso-surface (QColor)""" + return qt.QColor.fromRgbF(*self._color) + + def setColor(self, color): + """Set the color of the iso-surface + + :param color: RGBA color of the isosurface + :type color: QColor, str or array-like of 4 float in [0., 1.] + """ + color = rgba(color) + if color != self._color: + self._color = color + primitive = self._getScenePrimitive() + if len(primitive.children) != 0: + primitive.children[0].setAttribute('color', self._color) + self._updated(ItemChangedType.COLOR) + + def _updateScenePrimitive(self): + """Update underlying mesh""" + self._getScenePrimitive().children = [] + + if self._data is None: + if self.isAutoLevel(): + self._level = float('nan') + + else: + if self.isAutoLevel(): + st = time.time() + try: + level = float(self.getAutoLevelFunction()(self._data)) + + except Exception: + module_ = self.getAutoLevelFunction().__module__ + name = self.getAutoLevelFunction().__name__ + _logger.error( + "Error while executing iso level function %s.%s", + module_, + name, + exc_info=True) + level = float('nan') + + else: + _logger.info( + 'Computed iso-level in %f s.', time.time() - st) + + if level != self._level: + self._level = level + self._updated(Item3DChangedType.ISO_LEVEL) + + if not numpy.isfinite(self._level): + return + + st = time.time() + vertices, normals, indices = MarchingCubes( + self._data, + isolevel=self._level) + _logger.info('Computed iso-surface in %f s.', time.time() - st) + + if len(vertices) == 0: + return + else: + mesh = primitives.Mesh3D(vertices, + colors=self._color, + normals=normals, + mode='triangles', + indices=indices) + self._getScenePrimitive().children = [mesh] + + +class ScalarField3D(DataItem3D): + """3D scalar field on a regular grid. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + DataItem3D.__init__(self, parent=parent) + + # Gives this item the shape of the data, no matter + # of the isosurface/cut plane size + self._boundedGroup = primitives.BoundedGroup() + + # Store iso-surfaces + self._isosurfaces = [] + + self._data = None + self._dataRange = None + + self._cutPlane = CutPlane(parent=self) + self._cutPlane.setVisible(False) + + self._isogroup = primitives.GroupDepthOffset() + self._isogroup.transforms = [ + # Convert from z, y, x from marching cubes to x, y, z + transform.Matrix(( + (0., 0., 1., 0.), + (0., 1., 0., 0.), + (1., 0., 0., 0.), + (0., 0., 0., 1.))), + # Offset to match cutting plane coords + transform.Translate(0.5, 0.5, 0.5) + ] + + self._getScenePrimitive().children = [ + self._boundedGroup, + self._cutPlane._getScenePrimitive(), + self._isogroup] + + def setData(self, data, copy=True): + """Set the 3D scalar data represented by this item. + + Dataset order is zyx (i.e., first dimension is z). + + :param data: 3D array + :type data: 3D numpy.ndarray of float32 with shape at least (2, 2, 2) + :param bool copy: + True (default) to make a copy, + False to avoid copy (DO NOT MODIFY data afterwards) + """ + if data is None: + self._data = None + self._dataRange = None + self._boundedGroup.shape = None + + else: + data = numpy.array(data, copy=copy, dtype=numpy.float32, order='C') + assert data.ndim == 3 + assert min(data.shape) >= 2 + + self._data = data + + # Store data range info + dataRange = min_max(self._data, min_positive=True, finite=True) + if dataRange.minimum is None: # Only non-finite data + dataRange = None + + if dataRange is not None: + min_positive = dataRange.min_positive + if min_positive is None: + min_positive = float('nan') + dataRange = dataRange.minimum, min_positive, dataRange.maximum + self._dataRange = dataRange + + self._boundedGroup.shape = self._data.shape + + # Update iso-surfaces + for isosurface in self.getIsosurfaces(): + isosurface._setData(self._data, copy=False) + + self._updated(ItemChangedType.DATA) + + def getData(self, copy=True): + """Return 3D dataset. + + :param bool copy: + True (default) to get a copy, + False to get the internal data (DO NOT modify!) + :return: The data set (or None if not set) + """ + if self._data is None: + return None + else: + return numpy.array(self._data, copy=copy) + + def getDataRange(self): + """Return the range of the data as a 3-tuple of values. + + positive min is NaN if no data is positive. + + :return: (min, positive min, max) or None. + """ + return self._dataRange + + # Cut Plane + + def getCutPlanes(self): + """Return an iterable of all :class:`CutPlane` of this item. + + This includes hidden cut planes. + + For now, there is always one cut plane. + """ + return (self._cutPlane,) + + # Handle iso-surfaces + + # TODO rename to sigItemAdded|Removed? + sigIsosurfaceAdded = qt.Signal(object) + """Signal emitted when a new iso-surface is added to the view. + + The newly added iso-surface is provided by this signal + """ + + sigIsosurfaceRemoved = qt.Signal(object) + """Signal emitted when an iso-surface is removed from the view + + The removed iso-surface is provided by this signal. + """ + + def addIsosurface(self, level, color): + """Add an isosurface to this item. + + :param level: + The value at which to build the iso-surface or a callable + (e.g., a function) taking a 3D numpy.ndarray as input and + returning a float. + Example: numpy.mean(data) + numpy.std(data) + :type level: float or callable + :param color: RGBA color of the isosurface + :type color: str or array-like of 4 float in [0., 1.] + :return: isosurface object + :rtype: ~silx.gui.plot3d.items.volume.Isosurface + """ + isosurface = Isosurface(parent=self) + isosurface.setColor(color) + if callable(level): + isosurface.setAutoLevelFunction(level) + else: + isosurface.setLevel(level) + isosurface._setData(self._data, copy=False) + isosurface.sigItemChanged.connect(self._isosurfaceItemChanged) + + self._isosurfaces.append(isosurface) + + self._updateIsosurfaces() + + self.sigIsosurfaceAdded.emit(isosurface) + return isosurface + + def getIsosurfaces(self): + """Return an iterable of all :class:`.Isosurface` instance of this item""" + return tuple(self._isosurfaces) + + def removeIsosurface(self, isosurface): + """Remove an iso-surface from this item. + + :param ~silx.gui.plot3d.Plot3DWidget.Isosurface isosurface: + The isosurface object to remove + """ + if isosurface not in self.getIsosurfaces(): + _logger.warning( + "Try to remove isosurface that is not in the list: %s", + str(isosurface)) + else: + isosurface.sigItemChanged.disconnect(self._isosurfaceItemChanged) + self._isosurfaces.remove(isosurface) + self._updateIsosurfaces() + self.sigIsosurfaceRemoved.emit(isosurface) + + def clearIsosurfaces(self): + """Remove all :class:`.Isosurface` instances from this item.""" + for isosurface in self.getIsosurfaces(): + self.removeIsosurface(isosurface) + + def _isosurfaceItemChanged(self, event): + """Handle update of isosurfaces upon level changed""" + if event == Item3DChangedType.ISO_LEVEL: + self._updateIsosurfaces() + + def _updateIsosurfaces(self): + """Handle updates of iso-surfaces level and add/remove""" + # Sorting using minus, this supposes data 'object' to be max values + sortedIso = sorted(self.getIsosurfaces(), + key=lambda isosurface: - isosurface.getLevel()) + self._isogroup.children = [iso._getScenePrimitive() for iso in sortedIso] + + def visit(self, included=True): + """Generator visiting the ScalarField3D content. + + It first access cut planes and then isosurface + + :param bool included: True (default) to include self in visit + """ + if included: + yield self + for cutPlane in self.getCutPlanes(): + yield cutPlane + for isosurface in self.getIsosurfaces(): + yield isosurface |