diff options
Diffstat (limited to 'silx/gui/plot3d')
-rw-r--r-- | silx/gui/plot3d/ParamTreeView.py | 2 | ||||
-rw-r--r-- | silx/gui/plot3d/ScalarFieldView.py | 21 | ||||
-rw-r--r-- | silx/gui/plot3d/SceneWidget.py | 30 | ||||
-rw-r--r-- | silx/gui/plot3d/_model/items.py | 67 | ||||
-rw-r--r-- | silx/gui/plot3d/items/__init__.py | 4 | ||||
-rw-r--r-- | silx/gui/plot3d/items/core.py | 4 | ||||
-rw-r--r-- | silx/gui/plot3d/items/mesh.py | 281 | ||||
-rw-r--r-- | silx/gui/plot3d/items/mixins.py | 21 | ||||
-rw-r--r-- | silx/gui/plot3d/items/scatter.py | 39 | ||||
-rw-r--r-- | silx/gui/plot3d/items/volume.py | 12 | ||||
-rw-r--r-- | silx/gui/plot3d/scene/primitives.py | 8 | ||||
-rw-r--r-- | silx/gui/plot3d/test/__init__.py | 4 | ||||
-rw-r--r-- | silx/gui/plot3d/test/testSceneWidgetPicking.py | 53 | ||||
-rw-r--r-- | silx/gui/plot3d/test/testStatsWidget.py | 213 |
14 files changed, 600 insertions, 159 deletions
diff --git a/silx/gui/plot3d/ParamTreeView.py b/silx/gui/plot3d/ParamTreeView.py index ee0c876..8cf2b90 100644 --- a/silx/gui/plot3d/ParamTreeView.py +++ b/silx/gui/plot3d/ParamTreeView.py @@ -43,7 +43,7 @@ __date__ = "05/12/2017" import numbers import sys -from silx.third_party import six +import six from .. import qt from ..widgets.FloatEdit import FloatEdit as _FloatEdit diff --git a/silx/gui/plot3d/ScalarFieldView.py b/silx/gui/plot3d/ScalarFieldView.py index e5e680c..50cba05 100644 --- a/silx/gui/plot3d/ScalarFieldView.py +++ b/silx/gui/plot3d/ScalarFieldView.py @@ -886,6 +886,8 @@ class ScalarFieldView(Plot3DWindow): self._bbox = axes.LabelledAxes() self._bbox.children = [self._group] + self._outerScale = transform.Scale(1., 1., 1.) + self._bbox.transforms = [self._outerScale] self.getPlot3DWidget().viewport.scene.children.append(self._bbox) self._selectionBox = primitives.Box() @@ -1204,6 +1206,25 @@ class ScalarFieldView(Plot3DWindow): # Transformations + def setOuterScale(self, sx=1., sy=1., sz=1.): + """Set the scale to apply to the whole scene including the axes. + + This is useful when axis lengths in data space are really different. + + :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 + """ + self._outerScale.setScale(sx, sy, sz) + self.centerScene() + + def getOuterScale(self): + """Returns the scales provided by :meth:`setOuterScale`. + + :rtype: numpy.ndarray + """ + return self._outerScale.scale + def setScale(self, sx=1., sy=1., sz=1.): """Set the scale of the 3D scalar field (i.e., size of a voxel). diff --git a/silx/gui/plot3d/SceneWidget.py b/silx/gui/plot3d/SceneWidget.py index 4a824d7..e60dcfc 100644 --- a/silx/gui/plot3d/SceneWidget.py +++ b/silx/gui/plot3d/SceneWidget.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# Copyright (c) 2017-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -30,10 +30,11 @@ __authors__ = ["T. Vincent"] __license__ = "MIT" __date__ = "24/04/2018" -import numpy +import enum import weakref -from silx.third_party import enum +import numpy + from .. import qt from ..colors import rgba @@ -229,6 +230,9 @@ class SceneSelection(qt.QObject): :raise ValueError: If the item is not the widget's scene """ previous = self.getCurrentItem() + if item is previous: + return # Fast path, nothing to do + if previous is not None: previous.sigItemChanged.disconnect(self.__currentChanged) @@ -252,15 +256,18 @@ class SceneSelection(qt.QObject): 'Not an Item3D: %s' % str(item)) current = self.getCurrentItem() - if current is not previous: - self.sigCurrentChanged.emit(current, previous) - self.__updateSelectionModel() + self.sigCurrentChanged.emit(current, previous) + self.__updateSelectionModel() def __currentChanged(self, event): """Handle updates of the selected item""" if event == items.Item3DChangedType.ROOT_ITEM: item = self.sender() - if item.root() != self.getSceneGroup(): + + parent = self.parent() + assert isinstance(parent, SceneWidget) + + if item.root() != parent.getSceneGroup(): self.setSelectedItem(None) # Synchronization with QItemSelectionModel @@ -488,7 +495,8 @@ class SceneWidget(Plot3DWidget): :param int index: The index at which to place the item. By default it is appended to the end of the list. :return: The newly created scalar volume item - :rtype: items.ScalarField3D + :rtype: ~silx.gui.plot3d.items.volume.ScalarField3D + """ volume = items.ScalarField3D() volume.setData(data, copy=copy) @@ -508,7 +516,7 @@ class SceneWidget(Plot3DWidget): :param int index: The index at which to place the item. By default it is appended to the end of the list. :return: The newly created 3D scatter item - :rtype: items.Scatter3D + :rtype: ~silx.gui.plot3d.items.scatter.Scatter3D """ scatter3d = items.Scatter3D() scatter3d.setData(x=x, y=y, z=z, value=value, copy=copy) @@ -528,7 +536,7 @@ class SceneWidget(Plot3DWidget): :param int index: The index at which to place the item. By default it is appended to the end of the list. :return: The newly created 2D scatter item - :rtype: items.Scatter2D + :rtype: ~silx.gui.plot3d.items.scatter.Scatter2D """ scatter2d = items.Scatter2D() scatter2d.setData(x=x, y=y, value=value, copy=copy) @@ -548,7 +556,7 @@ class SceneWidget(Plot3DWidget): :param int index: The index at which to place the item. By default it is appended to the end of the list. :return: The newly created image item - :rtype: items.ImageData or items.ImageRgba + :rtype: ~silx.gui.plot3d.items.image.ImageData or ~silx.gui.plot3d.items.image.ImageRgba :raise ValueError: For arrays of unsupported dimensions """ data = numpy.array(data, copy=False) diff --git a/silx/gui/plot3d/_model/items.py b/silx/gui/plot3d/_model/items.py index b09f29a..7e58d14 100644 --- a/silx/gui/plot3d/_model/items.py +++ b/silx/gui/plot3d/_model/items.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# Copyright (c) 2017-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -38,8 +38,7 @@ import logging import weakref import numpy - -from silx.third_party import six +import six from ...utils.image import convertArrayToQImage from ...colors import preferredColormaps @@ -202,7 +201,7 @@ class Settings(StaticRow): super(Settings, self).__init__(('Settings', None), children=children) -class Item3DRow(StaticRow): +class Item3DRow(BaseRow): """Represents an :class:`Item3D` with checkable visibility :param Item3D item: The scene item to represent. @@ -210,9 +209,8 @@ class Item3DRow(StaticRow): """ def __init__(self, item, name=None): - if name is None: - name = item.getLabel() - super(Item3DRow, self).__init__((name, None)) + self.__name = None if name is None else six.text_type(name) + super(Item3DRow, self).__init__() self.setFlags( self.flags(0) | qt.Qt.ItemIsUserCheckable | qt.Qt.ItemIsSelectable, @@ -224,7 +222,8 @@ class Item3DRow(StaticRow): def _itemChanged(self, event): """Handle visibility change""" - if event == items.ItemChangedType.VISIBLE: + if event in (items.ItemChangedType.VISIBLE, + items.Item3DChangedType.LABEL): model = self.model() if model is not None: index = self.index(column=1) @@ -235,16 +234,25 @@ class Item3DRow(StaticRow): return self._item() def data(self, column, role): - if column == 0 and role == qt.Qt.CheckStateRole: - item = self.item() - if item is not None and item.isVisible(): - return qt.Qt.Checked - else: - return qt.Qt.Unchecked - elif column == 0 and role == qt.Qt.DecorationRole: - return icons.getQIcon('item-3dim') - else: - return super(Item3DRow, self).data(column, role) + if column == 0: + if role == qt.Qt.CheckStateRole: + item = self.item() + if item is not None and item.isVisible(): + return qt.Qt.Checked + else: + return qt.Qt.Unchecked + + elif role == qt.Qt.DecorationRole: + return icons.getQIcon('item-3dim') + + elif role == qt.Qt.DisplayRole: + if self.__name is None: + item = self.item() + return '' if item is None else item.getLabel() + else: + return self.__name + + return super(Item3DRow, self).data(column, role) def setData(self, column, value, role): if column == 0 and role == qt.Qt.CheckStateRole: @@ -256,6 +264,9 @@ class Item3DRow(StaticRow): return False return super(Item3DRow, self).setData(column, value, role) + def columnCount(self): + return 2 + class DataItem3DBoundingBoxRow(ProxyRow): """Represents :class:`DataItem3D` bounding box visibility @@ -562,7 +573,6 @@ class _ColormapBaseProxyRow(ProxyRow): """Signal used internally to notify colormap (or data) update""" def __init__(self, item, *args, **kwargs): - self._dataRange = None self._item = weakref.ref(item) self._colormap = item.getColormap() @@ -581,19 +591,11 @@ class _ColormapBaseProxyRow(ProxyRow): :return: Colormap range (min, max) """ - if self._dataRange is None: - item = self.item() - if item is not None and self._colormap is not None: - if hasattr(item, 'getDataRange'): - data = item.getDataRange() - else: - data = item.getData(copy=False) - - self._dataRange = self._colormap.getColormapRange(data) - - else: # Fallback - self._dataRange = 1, 100 - return self._dataRange + item = self.item() + if item is not None and self._colormap is not None: + return self._colormap.getColormapRange(item._getDataRange()) + else: + return 1, 100 # Fallback def _modelUpdated(self, *args, **kwargs): """Emit dataChanged in the model""" @@ -624,7 +626,6 @@ class _ColormapBaseProxyRow(ProxyRow): self._colormap = None elif event == items.ItemChangedType.DATA: - self._dataRange = None self._sigColormapChanged.emit() diff --git a/silx/gui/plot3d/items/__init__.py b/silx/gui/plot3d/items/__init__.py index b2a9dab..58eee9c 100644 --- a/silx/gui/plot3d/items/__init__.py +++ b/silx/gui/plot3d/items/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# Copyright (c) 2017-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -38,6 +38,6 @@ from .mixins import (ColormapMixIn, InterpolationMixIn, # noqa PlaneMixIn, SymbolMixIn) # noqa from .clipplane import ClipPlane # noqa from .image import ImageData, ImageRgba # noqa -from .mesh import Mesh, Box, Cylinder, Hexagon # noqa +from .mesh import Mesh, ColormapMesh, Box, Cylinder, Hexagon # noqa from .scatter import Scatter2D, Scatter3D # noqa from .volume import ScalarField3D # noqa diff --git a/silx/gui/plot3d/items/core.py b/silx/gui/plot3d/items/core.py index 0aefced..1745b2b 100644 --- a/silx/gui/plot3d/items/core.py +++ b/silx/gui/plot3d/items/core.py @@ -32,10 +32,10 @@ __license__ = "MIT" __date__ = "15/11/2017" from collections import defaultdict +import enum import numpy - -from silx.third_party import enum, six +import six from ... import qt from ...plot.items import ItemChangedType diff --git a/silx/gui/plot3d/items/mesh.py b/silx/gui/plot3d/items/mesh.py index 21936ea..d3f5e38 100644 --- a/silx/gui/plot3d/items/mesh.py +++ b/silx/gui/plot3d/items/mesh.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# Copyright (c) 2017-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -35,17 +35,18 @@ __date__ = "17/07/2018" import logging import numpy -from ..scene import primitives, utils +from ..scene import primitives, utils, function from ..scene.transform import Rotate from .core import DataItem3D, ItemChangedType +from .mixins import ColormapMixIn from ._pick import PickingResult _logger = logging.getLogger(__name__) -class Mesh(DataItem3D): - """Description of mesh. +class _MeshBase(DataItem3D): + """Base class for :class:`Mesh' and :class:`ColormapMesh`. :param parent: The View widget this item belongs to. """ @@ -54,48 +55,22 @@ class Mesh(DataItem3D): 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: 'triangles', 'triangle_strip', 'fan' + def _setMesh(self, mesh): + """Set mesh primitive - :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!). + :param Union[None,Geometry] mesh: The scene primitive """ self._getScenePrimitive().children = [] # Remove any previous mesh - if position is None or len(position) == 0: - self._mesh = None - else: - self._mesh = primitives.Mesh3D( - position, color, normal, mode=mode, copy=copy) + self._mesh = mesh + if self._mesh is not None: self._getScenePrimitive().children.append(self._mesh) - self.sigItemChanged.emit(ItemChangedType.DATA) + self._updated(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 _getMesh(self): + """Returns the underlying Mesh scene primitive""" + return self._mesh def getPositionData(self, copy=True): """Get the mesh vertex positions. @@ -106,38 +81,38 @@ class Mesh(DataItem3D): :return: The (x, y, z) positions as a (N, 3) array :rtype: numpy.ndarray """ - if self._mesh is None: + if self._getMesh() is None: return numpy.empty((0, 3), dtype=numpy.float32) else: - return self._mesh.getAttribute('position', copy=copy) + return self._getMesh().getAttribute('position', copy=copy) - def getColorData(self, copy=True): - """Get the mesh vertex colors. + 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 RGBA colors as a (N, 4) array or a single color - :rtype: numpy.ndarray + :return: The normals as a (N, 3) array, a single normal or None + :rtype: Union[numpy.ndarray,None] """ - if self._mesh is None: - return numpy.empty((0, 4), dtype=numpy.float32) + if self._getMesh() is None: + return None else: - return self._mesh.getAttribute('color', copy=copy) + return self._getMesh().getAttribute('normal', copy=copy) - def getNormalData(self, copy=True): - """Get the mesh vertex normals. + def getIndices(self, copy=True): + """Get the vertex indices. :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 + :return: The vertex indices as an array or None. + :rtype: Union[numpy.ndarray,None] """ - if self._mesh is None: + if self._getMesh() is None: return None else: - return self._mesh.getAttribute('normal', copy=copy) + return self._getMesh().getIndices(copy=copy) def getDrawMode(self): """Get mesh rendering mode. @@ -145,7 +120,7 @@ class Mesh(DataItem3D): :return: The drawing mode of this primitive :rtype: str """ - return self._mesh.drawMode + return self._getMesh().drawMode def _pickFull(self, context): """Perform precise picking in this item at given widget position. @@ -164,28 +139,34 @@ class Mesh(DataItem3D): return None mode = self.getDrawMode() - if mode == 'triangles': - triangles = positions.reshape(-1, 3, 3) - - elif mode == 'triangle_strip': - # Expand strip - triangles = numpy.empty((len(positions) - 2, 3, 3), - dtype=positions.dtype) - triangles[:, 0] = positions[:-2] - triangles[:, 1] = positions[1:-1] - triangles[:, 2] = positions[2:] - - elif mode == 'fan': - # Expand fan - triangles = numpy.empty((len(positions) - 2, 3, 3), - dtype=positions.dtype) - triangles[:, 0] = positions[0] - triangles[:, 1] = positions[1:-1] - triangles[:, 2] = positions[2:] + vertexIndices = self.getIndices(copy=False) + if vertexIndices is not None: # Expand indices + positions = utils.unindexArrays(mode, vertexIndices, positions)[0] + triangles = positions.reshape(-1, 3, 3) else: - _logger.warning("Unsupported draw mode: %s" % mode) - return None + if mode == 'triangles': + triangles = positions.reshape(-1, 3, 3) + + elif mode == 'triangle_strip': + # Expand strip + triangles = numpy.empty((len(positions) - 2, 3, 3), + dtype=positions.dtype) + triangles[:, 0] = positions[:-2] + triangles[:, 1] = positions[1:-1] + triangles[:, 2] = positions[2:] + + elif mode == 'fan': + # Expand fan + triangles = numpy.empty((len(positions) - 2, 3, 3), + dtype=positions.dtype) + triangles[:, 0] = positions[0] + triangles[:, 1] = positions[1:-1] + triangles[:, 2] = positions[2:] + + else: + _logger.warning("Unsupported draw mode: %s" % mode) + return None trianglesIndices, t, barycentric = utils.segmentTrianglesIntersection( rayObject, triangles) @@ -208,12 +189,160 @@ class Mesh(DataItem3D): indices = trianglesIndices + closest # For corners 1 and 2 indices[closest == 0] = 0 # For first corner (common) + if vertexIndices is not None: + # Convert from indices in expanded triangles to input vertices + indices = vertexIndices[indices] + return PickingResult(self, positions=points, indices=indices, fetchdata=self.getPositionData) +class Mesh(_MeshBase): + """Description of mesh. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + _MeshBase.__init__(self, parent=parent) + + def setData(self, + position, + color, + normal=None, + mode='triangles', + indices=None, + copy=True): + """Set mesh geometry data. + + Supported drawing modes are: '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 Union[numpy.ndarray,None] normal: Normals for each point or None (default) + :param str mode: The drawing mode. + :param Union[List[int],None] indices: + Array of vertex indices or None to use arrays directly. + :param bool copy: True (default) to copy the data, + False to use as is (do not modify!). + """ + assert mode in ('triangles', 'triangle_strip', 'fan') + if position is None or len(position) == 0: + mesh = None + else: + mesh = primitives.Mesh3D( + position, color, normal, mode=mode, indices=indices, copy=copy) + self._setMesh(mesh) + + 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 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._getMesh() is None: + return numpy.empty((0, 4), dtype=numpy.float32) + else: + return self._getMesh().getAttribute('color', copy=copy) + + +class ColormapMesh(_MeshBase, ColormapMixIn): + """Description of mesh which color is defined by scalar and a colormap. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + _MeshBase.__init__(self, parent=parent) + ColormapMixIn.__init__(self, function.Colormap()) + + def setData(self, + position, + value, + normal=None, + mode='triangles', + indices=None, + copy=True): + """Set mesh geometry data. + + Supported drawing modes are: 'triangles', 'triangle_strip', 'fan' + + :param numpy.ndarray position: + Position (x, y, z) of each vertex as a (N, 3) array + :param numpy.ndarray value: Data value for each vertex. + :param Union[numpy.ndarray,None] normal: Normals for each point or None (default) + :param str mode: The drawing mode. + :param Union[List[int],None] indices: + Array of vertex indices or None to use arrays directly. + :param bool copy: True (default) to copy the data, + False to use as is (do not modify!). + """ + assert mode in ('triangles', 'triangle_strip', 'fan') + if position is None or len(position) == 0: + mesh = None + else: + mesh = primitives.ColormapMesh3D( + position=position, + value=numpy.array(value, copy=False).reshape(-1, 1), # Make it a 2D array + colormap=self._getSceneColormap(), + normal=normal, + mode=mode, + indices=indices, + copy=copy) + self._setMesh(mesh) + + # Store data range info + ColormapMixIn._setRangeFromData(self, self.getValueData(copy=False)) + + 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, values, normals and mode + :rtype: tuple of numpy.ndarray + """ + return (self.getPositionData(copy=copy), + self.getValueData(copy=copy), + self.getNormalData(copy=copy), + self.getDrawMode()) + + def getValueData(self, copy=True): + """Get the mesh vertex values. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :return: Array of data values + :rtype: numpy.ndarray + """ + if self._getMesh() is None: + return numpy.empty((0,), dtype=numpy.float32) + else: + return self._getMesh().getAttribute('value', copy=copy) + + class _CylindricalVolume(DataItem3D): """Class that represents a volume with a rotational symmetry along z @@ -345,7 +474,7 @@ class _CylindricalVolume(DataItem3D): vertices, color, normals, mode='triangles', copy=False) self._getScenePrimitive().children.append(self._mesh) - self.sigItemChanged.emit(ItemChangedType.DATA) + self._updated(ItemChangedType.DATA) def _pickFull(self, context): """Perform precise picking in this item at given widget position. diff --git a/silx/gui/plot3d/items/mixins.py b/silx/gui/plot3d/items/mixins.py index 8e96441..40b8438 100644 --- a/silx/gui/plot3d/items/mixins.py +++ b/silx/gui/plot3d/items/mixins.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# Copyright (c) 2017-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -114,19 +114,17 @@ class ColormapMixIn(_ColormapMixIn): self.__sceneColormap = sceneColormap self._syncSceneColormap() - self.sigItemChanged.connect(self.__colormapUpdated) - - def __colormapUpdated(self, event): + def _colormapChanged(self): """Handle colormap updates""" - if event == ItemChangedType.COLORMAP: - self._syncSceneColormap() + self._syncSceneColormap() + super(ColormapMixIn, self)._colormapChanged() 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: + if data is None or data.size == 0: dataRange = None else: dataRange = min_max(data, min_positive=True, finite=True) @@ -144,6 +142,13 @@ class ColormapMixIn(_ColormapMixIn): if self.getColormap().isAutoscale(): self._syncSceneColormap() + def _getDataRange(self): + """Returns the data range as used in the scene for colormap + + :rtype: Union[List[float],None] + """ + return self._dataRange + def _setSceneColormap(self, sceneColormap): """Set the scene colormap to sync with Colormap object. @@ -171,8 +176,6 @@ class ColormapMixIn(_ColormapMixIn): 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'), diff --git a/silx/gui/plot3d/items/scatter.py b/silx/gui/plot3d/items/scatter.py index a13c3db..b7bcd09 100644 --- a/silx/gui/plot3d/items/scatter.py +++ b/silx/gui/plot3d/items/scatter.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# Copyright (c) 2017-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -36,6 +36,7 @@ import logging import sys import numpy +from ....utils.deprecation import deprecated from ..scene import function, primitives, utils from .core import DataItem3D, Item3DChangedType, ItemChangedType @@ -43,7 +44,7 @@ from .mixins import ColormapMixIn, SymbolMixIn from ._pick import PickingResult -_logger = logging.getLevelName(__name__) +_logger = logging.getLogger(__name__) class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn): @@ -94,7 +95,7 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn): self._scatter.setAttribute('z', z, copy=copy) self._scatter.setAttribute('value', value, copy=copy) - ColormapMixIn._setRangeFromData(self, self.getValues(copy=False)) + ColormapMixIn._setRangeFromData(self, self.getValueData(copy=False)) self._updated(ItemChangedType.DATA) def getData(self, copy=True): @@ -107,7 +108,7 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn): return (self.getXData(copy), self.getYData(copy), self.getZData(copy), - self.getValues(copy)) + self.getValueData(copy)) def getXData(self, copy=True): """Returns X data coordinates. @@ -139,7 +140,7 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn): """ return self._scatter.getAttribute('z', copy=copy).reshape(-1) - def getValues(self, copy=True): + def getValueData(self, copy=True): """Returns data values. :param bool copy: True to get a copy, @@ -149,6 +150,11 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn): """ return self._scatter.getAttribute('value', copy=copy).reshape(-1) + @deprecated(reason="Consistency with PlotWidget items", + replacement="getValueData", since_version="0.10.0") + def getValues(self, copy=True): + return self.getValueData(copy) + def _pickFull(self, context, threshold=0., sort='depth'): """Perform picking in this item at given widget position. @@ -202,7 +208,7 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn): return PickingResult(self, positions=dataPoints[picked, :3], indices=picked, - fetchdata=self.getValues) + fetchdata=self.getValueData) else: return None @@ -269,8 +275,8 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn): Supported visualization modes are: - 'points': For scatter plot representation - - 'lines': For Delaunay tesselation-based wireframe representation - - 'solid': For Delaunay tesselation-based solid surface representation + - 'lines': For Delaunay tessellation-based wireframe representation + - 'solid': For Delaunay tessellation-based solid surface representation :param str mode: Mode of representation to use """ @@ -384,7 +390,7 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn): self._cachedTrianglesIndices = None # Store data range info - ColormapMixIn._setRangeFromData(self, self.getValues(copy=False)) + ColormapMixIn._setRangeFromData(self, self.getValueData(copy=False)) self._updateScene() @@ -399,7 +405,7 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn): """ return (self.getXData(copy=copy), self.getYData(copy=copy), - self.getValues(copy=copy)) + self.getValueData(copy=copy)) def getXData(self, copy=True): """Returns X data coordinates. @@ -421,7 +427,7 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn): """ return numpy.array(self._y, copy=copy) - def getValues(self, copy=True): + def getValueData(self, copy=True): """Returns data values. :param bool copy: True to get a copy, @@ -431,6 +437,11 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn): """ return numpy.array(self._value, copy=copy) + @deprecated(reason="Consistency with PlotWidget items", + replacement="getValueData", since_version="0.10.0") + def getValues(self, copy=True): + return self.getValueData(copy) + def _pickPoints(self, context, points, threshold=1., sort='depth'): """Perform picking while in 'points' visualization mode @@ -472,7 +483,7 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn): return PickingResult(self, positions=points[picked, :3], indices=picked, - fetchdata=self.getValues) + fetchdata=self.getValueData) else: return None @@ -507,7 +518,7 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn): return PickingResult(self, positions=positions, indices=indices, - fetchdata=self.getValues) + fetchdata=self.getValueData) def _pickFull(self, context): """Perform picking in this item at given widget position. @@ -521,7 +532,7 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn): return None if self.isHeightMap(): - zData = self.getValues(copy=False) + zData = self.getValueData(copy=False) else: zData = numpy.zeros_like(xData) diff --git a/silx/gui/plot3d/items/volume.py b/silx/gui/plot3d/items/volume.py index ca22f1f..08ad02a 100644 --- a/silx/gui/plot3d/items/volume.py +++ b/silx/gui/plot3d/items/volume.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# Copyright (c) 2017-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -78,13 +78,15 @@ class CutPlane(Item3D, ColormapMixIn, InterpolationMixIn, PlaneMixIn): 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), - copy=False) + data = self.sender().getData(copy=False) + self._getPlane().setData(data, copy=False) # Store data range info as 3-tuple of values self._dataRange = self.sender().getDataRange() + self._setRangeFromData( + None if self._dataRange is None else numpy.array(self._dataRange)) - self.sigItemChanged.emit(ItemChangedType.DATA) + self._updated(ItemChangedType.DATA) # Colormap @@ -104,7 +106,7 @@ class CutPlane(Item3D, ColormapMixIn, InterpolationMixIn, PlaneMixIn): display = bool(display) if display != self.getDisplayValuesBelowMin(): self._getPlane().colormap.displayValuesBelowMin = display - self.sigItemChanged.emit(ItemChangedType.ALPHA) + self._updated(ItemChangedType.ALPHA) def getDataRange(self): """Return the range of the data as a 3-tuple of values. diff --git a/silx/gui/plot3d/scene/primitives.py b/silx/gui/plot3d/scene/primitives.py index 474581a..ca06e30 100644 --- a/silx/gui/plot3d/scene/primitives.py +++ b/silx/gui/plot3d/scene/primitives.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2015-2018 European Synchrotron Radiation Facility +# Copyright (c) 2015-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -1878,11 +1878,13 @@ class ColormapMesh3D(Geometry): colormap=None, normal=None, mode='triangles', - indices=None): + indices=None, + copy=True): super(ColormapMesh3D, self).__init__(mode, indices, position=position, normal=normal, - value=value) + value=value, + copy=copy) self._lineWidth = 1.0 self._lineSmooth = True diff --git a/silx/gui/plot3d/test/__init__.py b/silx/gui/plot3d/test/__init__.py index c58f307..8825cf4 100644 --- a/silx/gui/plot3d/test/__init__.py +++ b/silx/gui/plot3d/test/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2015-2018 European Synchrotron Radiation Facility +# Copyright (c) 2015-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -59,6 +59,7 @@ def suite(): from .testGL import suite as testGLSuite from .testScalarFieldView import suite as testScalarFieldViewSuite from .testSceneWidgetPicking import suite as testSceneWidgetPickingSuite + from .testStatsWidget import suite as testStatsWidgetSuite testsuite = unittest.TestSuite() testsuite.addTest(testGLSuite()) @@ -66,4 +67,5 @@ def suite(): testsuite.addTest(testScalarFieldViewSuite()) testsuite.addTest(testSceneWidgetPickingSuite()) testsuite.addTest(toolsTestSuite()) + testsuite.addTest(testStatsWidgetSuite()) return testsuite diff --git a/silx/gui/plot3d/test/testSceneWidgetPicking.py b/silx/gui/plot3d/test/testSceneWidgetPicking.py index d0c6467..649fb47 100644 --- a/silx/gui/plot3d/test/testSceneWidgetPicking.py +++ b/silx/gui/plot3d/test/testSceneWidgetPicking.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2018 European Synchrotron Radiation Facility +# Copyright (c) 2018-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -122,7 +122,7 @@ class TestSceneWidgetPicking(TestCaseQt, ParametricTestCase): self.assertEqual(nbPos, len(data)) self.assertTrue(numpy.array_equal( data, - item.getValues()[picking[0].getIndices()])) + item.getValueData()[picking[0].getIndices()])) # Picking outside data picking = list(self.widget.pickItems(1, 1)) @@ -217,6 +217,55 @@ class TestSceneWidgetPicking(TestCaseQt, ParametricTestCase): picking = list(self.widget.pickItems(1, 1)) self.assertEqual(len(picking), 0) + def testPickMeshWithIndices(self): + """Test picking of Mesh items defined by indices""" + + triangles = items.Mesh() + triangles.setData( + position=((0, 0, 0), (1, 0, 0), (0, 1, 0), (1, 1, 0)), + color=(1, 0, 0, 1), + indices=numpy.array( # dummy triangles and square + (0, 0, 1, 0, 1, 2, 1, 2, 3), dtype=numpy.uint8), + mode='triangles') + triangleStrip = items.Mesh() + triangleStrip.setData( + position=((0, 0, 0), (1, 0, 0), (0, 1, 0), (1, 1, 0)), + color=(0, 1, 0, 1), + indices=numpy.array( # dummy triangles and square + (1, 0, 0, 1, 2, 3), dtype=numpy.uint8), + mode='triangle_strip') + triangleFan = items.Mesh() + triangleFan.setData( + position=((0, 0, 0), (1, 0, 0), (0, 1, 0), (1, 1, 0)), + color=(0, 0, 1, 1), + indices=numpy.array( # dummy triangle, square, dummy + (1, 1, 0, 2, 3, 3), dtype=numpy.uint8), + mode='fan') + + for item in (triangles, triangleStrip, triangleFan): + with self.subTest(mode=item.getDrawMode()): + # Add item + self.widget.clearItems() + self.widget.addItem(item) + self.widget.resetZoom('front') + self.qapp.processEvents() + + # Picking on data (at widget center) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), item) + nbPos = len(picking[0].getPositions()) + data = picking[0].getData() + self.assertEqual(nbPos, len(data)) + self.assertTrue(numpy.array_equal( + data, + item.getPositionData()[picking[0].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) + def testPickCylindricalMesh(self): """Test picking of Box, Cylinder and Hexagon items""" diff --git a/silx/gui/plot3d/test/testStatsWidget.py b/silx/gui/plot3d/test/testStatsWidget.py new file mode 100644 index 0000000..1157aec --- /dev/null +++ b/silx/gui/plot3d/test/testStatsWidget.py @@ -0,0 +1,213 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# ###########################################################################*/ +"""Test silx.gui.plot.StatsWidget with SceneWidget and ScalarFieldView""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/01/2019" + + +import unittest + +import numpy + +from silx.utils.testutils import ParametricTestCase +from silx.gui.utils.testutils import TestCaseQt +from silx.gui import qt + +from silx.gui.plot.StatsWidget import BasicStatsWidget + +from silx.gui.plot3d.ScalarFieldView import ScalarFieldView +from silx.gui.plot3d.SceneWidget import SceneWidget, items + + +class TestSceneWidget(TestCaseQt, ParametricTestCase): + """Tests StatsWidget combined with SceneWidget""" + + def setUp(self): + super(TestSceneWidget, self).setUp() + self.sceneWidget = SceneWidget() + self.sceneWidget.resize(300, 300) + self.sceneWidget.show() + self.statsWidget = BasicStatsWidget() + self.statsWidget.setPlot(self.sceneWidget) + # self.qWaitForWindowExposed(self.sceneWidget) + + def tearDown(self): + self.qapp.processEvents() + self.sceneWidget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.sceneWidget.close() + del self.sceneWidget + self.statsWidget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.statsWidget.close() + del self.statsWidget + super(TestSceneWidget, self).tearDown() + + def test(self): + """Test StatsWidget with SceneWidget""" + # Prepare scene + + # Data image + image = self.sceneWidget.addImage(numpy.arange(100).reshape(10, 10)) + image.setLabel('Image') + # RGB image + imageRGB = self.sceneWidget.addImage( + numpy.arange(300, dtype=numpy.uint8).reshape(10, 10, 3)) + imageRGB.setLabel('RGB Image') + # 2D scatter + data = numpy.arange(100) + scatter2D = self.sceneWidget.add2DScatter(x=data, y=data, value=data) + scatter2D.setLabel('2D Scatter') + # 3D scatter + scatter3D = self.sceneWidget.add3DScatter(x=data, y=data, z=data, value=data) + scatter3D.setLabel('3D Scatter') + # Add a group + group = items.GroupItem() + self.sceneWidget.addItem(group) + # 3D scalar field + data = numpy.arange(64**3).reshape(64, 64, 64) + scalarField = items.ScalarField3D() + scalarField.setData(data, copy=False) + scalarField.setLabel('3D Scalar field') + group.addItem(scalarField) + + statsTable = self.statsWidget._getStatsTable() + + # Test selection only + self.statsWidget.setDisplayOnlyActiveItem(True) + self.assertEqual(statsTable.rowCount(), 0) + + self.sceneWidget.selection().setCurrentItem(group) + self.assertEqual(statsTable.rowCount(), 0) + + for item in (image, scatter2D, scatter3D, scalarField): + with self.subTest('selection only', item=item.getLabel()): + self.sceneWidget.selection().setCurrentItem(item) + self.assertEqual(statsTable.rowCount(), 1) + self._checkItem(item) + + # Test all data + self.statsWidget.setDisplayOnlyActiveItem(False) + self.assertEqual(statsTable.rowCount(), 4) + + for item in (image, scatter2D, scatter3D, scalarField): + with self.subTest('all items', item=item.getLabel()): + self._checkItem(item) + + def _checkItem(self, item): + """Check that item is in StatsTable and that stats are OK + + :param silx.gui.plot3d.items.Item3D item: + """ + if isinstance(item, (items.Scatter2D, items.Scatter3D)): + data = item.getValueData(copy=False) + else: + data = item.getData(copy=False) + + statsTable = self.statsWidget._getStatsTable() + tableItems = statsTable._itemToTableItems(item) + self.assertTrue(len(tableItems) > 0) + self.assertEqual(tableItems['legend'].text(), item.getLabel()) + self.assertEqual(float(tableItems['min'].text()), numpy.min(data)) + self.assertEqual(float(tableItems['max'].text()), numpy.max(data)) + # TODO + + +class TestScalarFieldView(TestCaseQt): + """Tests StatsWidget combined with ScalarFieldView""" + + def setUp(self): + super(TestScalarFieldView, self).setUp() + self.scalarFieldView = ScalarFieldView() + self.scalarFieldView.resize(300, 300) + self.scalarFieldView.show() + self.statsWidget = BasicStatsWidget() + self.statsWidget.setPlot(self.scalarFieldView) + # self.qWaitForWindowExposed(self.sceneWidget) + + def tearDown(self): + self.qapp.processEvents() + self.scalarFieldView.setAttribute(qt.Qt.WA_DeleteOnClose) + self.scalarFieldView.close() + del self.scalarFieldView + self.statsWidget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.statsWidget.close() + del self.statsWidget + super(TestScalarFieldView, self).tearDown() + + def _getTextFor(self, row, name): + """Returns text in table at given row for column name + + :param int row: Row number in the table + :param str name: Column id + :rtype: Union[str,None] + """ + statsTable = self.statsWidget._getStatsTable() + + for column in range(statsTable.columnCount()): + headerItem = statsTable.horizontalHeaderItem(column) + if headerItem.data(qt.Qt.UserRole) == name: + tableItem = statsTable.item(row, column) + return tableItem.text() + + return None + + def test(self): + """Test StatsWidget with ScalarFieldView""" + data = numpy.arange(64**3, dtype=numpy.float64).reshape(64, 64, 64) + self.scalarFieldView.setData(data) + + statsTable = self.statsWidget._getStatsTable() + + # Test selection only + self.statsWidget.setDisplayOnlyActiveItem(True) + self.assertEqual(statsTable.rowCount(), 1) + + # Test all data + self.statsWidget.setDisplayOnlyActiveItem(False) + self.assertEqual(statsTable.rowCount(), 1) + + for column in range(statsTable.columnCount()): + self.assertEqual(float(self._getTextFor(0, 'min')), numpy.min(data)) + self.assertEqual(float(self._getTextFor(0, 'max')), numpy.max(data)) + sum_ = numpy.sum(data) + comz = numpy.sum(numpy.arange(data.shape[0]) * numpy.sum(data, axis=(1, 2))) / sum_ + comy = numpy.sum(numpy.arange(data.shape[1]) * numpy.sum(data, axis=(0, 2))) / sum_ + comx = numpy.sum(numpy.arange(data.shape[2]) * numpy.sum(data, axis=(0, 1))) / sum_ + self.assertEqual(self._getTextFor(0, 'COM'), str((comx, comy, comz))) + + +def suite(): + testsuite = unittest.TestSuite() + testsuite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase( + TestSceneWidget)) + testsuite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase( + TestScalarFieldView)) + return testsuite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') |