diff options
Diffstat (limited to 'silx/gui/plot3d/ScalarFieldView.py')
-rw-r--r-- | silx/gui/plot3d/ScalarFieldView.py | 1385 |
1 files changed, 1385 insertions, 0 deletions
diff --git a/silx/gui/plot3d/ScalarFieldView.py b/silx/gui/plot3d/ScalarFieldView.py new file mode 100644 index 0000000..2eb54a3 --- /dev/null +++ b/silx/gui/plot3d/ScalarFieldView.py @@ -0,0 +1,1385 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-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 window to view a 3D scalar field. + +It supports iso-surfaces, a cutting plane and the definition of +a region of interest. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "10/01/2017" + +import re +import logging +import time +from collections import deque + +import numpy + +from silx.gui import qt +from silx.gui.plot.Colors import rgba + +from silx.math.marchingcubes import MarchingCubes + +from .scene import axes, cutplane, function, interaction, primitives, transform +from . import scene +from .Plot3DWindow import Plot3DWindow + + +_logger = logging.getLogger(__name__) + + +class _BoundedGroup(scene.Group): + """Group with data bounds""" + + _shape = None # To provide a default value without overriding __init__ + + @property + def shape(self): + """Data shape (depth, height, width) of this group or None""" + return self._shape + + @shape.setter + def shape(self, shape): + if shape is None: + self._shape = None + else: + depth, height, width = shape + self._shape = float(depth), float(height), float(width) + + @property + def size(self): + """Data size (width, height, depth) of this group or None""" + shape = self.shape + if shape is None: + return None + else: + return shape[2], shape[1], shape[0] + + @size.setter + def size(self, size): + if size is None: + self.shape = None + else: + self.shape = size[2], size[1], size[0] + + def _bounds(self, dataBounds=False): + if dataBounds and self.size is not None: + return numpy.array(((0., 0., 0.), self.size), + dtype=numpy.float32) + else: + return super(_BoundedGroup, self)._bounds(dataBounds) + + +class Isosurface(qt.QObject): + """Class representing an iso-surface + + :param parent: The View widget this iso-surface belongs to + """ + + sigLevelChanged = qt.Signal(float) + """Signal emitted when the iso-surface level has changed. + + This signal provides the new level value (might be nan). + """ + + sigColorChanged = qt.Signal() + """Signal emitted when the iso-surface color has changed""" + + sigVisibilityChanged = qt.Signal(bool) + """Signal emitted when the iso-surface visibility has changed. + + This signal provides the new visibility status. + """ + + def __init__(self, parent): + super(Isosurface, self).__init__(parent=parent) + self._level = float('nan') + self._autoLevelFunction = None + self._color = rgba('#FFD700FF') + self._data = None + self._group = scene.Group() + + def _setData(self, data, copy=True): + """Set the data set from which to build the iso-surface. + + :param numpy.ndarray data: The 3D dataset 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._update() + + def _get3DPrimitive(self): + """Return the group containing the mesh of the iso-surface if any""" + return self._group + + def isVisible(self): + """Returns True if iso-surface is visible, else False""" + return self._group.visible + + def setVisible(self, visible): + """Set the visibility of the iso-surface in the view. + + :param bool visible: True to show the iso-surface, False to hide + """ + visible = bool(visible) + if visible != self._group.visible: + self._group.visible = visible + self.sigVisibilityChanged.emit(visible) + + 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._update() + self.sigLevelChanged.emit(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._update() + + 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 + if len(self._group.children) != 0: + self._group.children[0].setAttribute('color', self._color) + self.sigColorChanged.emit() + + def _update(self): + """Update underlying mesh""" + self._group.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.sigLevelChanged.emit(level) + + if numpy.isnan(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._group.children = [mesh] + + +class Colormap(object): + """Description of a colormap + + :param str name: Name of the colormap + :param str norm: Normalization: 'linear' (default) or 'log' + :param float vmin: + Lower bound of the colormap or None for autoscale (default) + :param float vmax: + Upper bounds of the colormap or None for autoscale (default) + """ + + def __init__(self, name, norm='linear', vmin=None, vmax=None): + assert name in function.Colormap.COLORMAPS + self._name = str(name) + + assert norm in ('linear', 'log') + self._norm = str(norm) + + self._vmin = float(vmin) if vmin is not None else None + self._vmax = float(vmax) if vmax is not None else None + + def isAutoscale(self): + """True if both min and max are in autoscale mode""" + return self._vmin is None or self._vmax is None + + def getName(self): + """Return the name of the colormap (str)""" + return self._name + + def getNorm(self): + """Return the normalization of the colormap (str)""" + return self._norm + + def getVMin(self): + """Return the lower bound of the colormap or None""" + return self._vmin + + def getVMax(self): + """Return the upper bounds of the colormap or None""" + return self._vmax + + +class SelectedRegion(object): + """Selection of a 3D region aligned with the axis. + + :param arrayRange: Range of the selection in the array + ((zmin, zmax), (ymin, ymax), (xmin, xmax)) + :param translation: Offset from array to data coordinates (ox, oy, oz) + :param scale: Scale from array to data coordinates (sx, sy, sz) + """ + + def __init__(self, arrayRange, + translation=(0., 0., 0.), + scale=(1., 1., 1.)): + self._arrayRange = numpy.array(arrayRange, copy=True, dtype=numpy.int) + assert self._arrayRange.shape == (3, 2) + assert numpy.all(self._arrayRange[:, 1] >= self._arrayRange[:, 0]) + self._translation = numpy.array(translation, dtype=numpy.float32) + assert self._translation.shape == (3,) + self._scale = numpy.array(scale, dtype=numpy.float32) + assert self._scale.shape == (3,) + + self._dataRange = (self._translation.reshape(3, -1) + + self._arrayRange[::-1] * self._scale.reshape(3, -1)) + + def getArrayRange(self): + """Returns array ranges of the selection: 3x2 array of int + + :return: A numpy array with ((zmin, zmax), (ymin, ymax), (xmin, xmax)) + :rtype: numpy.ndarray + """ + return self._arrayRange.copy() + + def getArraySlices(self): + """Slices corresponding to the selected range in the array + + :return: A numpy array with (zslice, yslice, zslice) + :rtype: numpy.ndarray + """ + return (slice(*self._arrayRange[0]), + slice(*self._arrayRange[1]), + slice(*self._arrayRange[2])) + + def getDataRange(self): + """Range in the data coordinates of the selection: 3x2 array of float + + :return: A numpy array with ((xmin, xmax), (ymin, ymax), (zmin, zmax)) + :rtype: numpy.ndarray + """ + return self._dataRange.copy() + + def getDataScale(self): + """Scale from array to data coordinates: (sx, sy, sz) + + :return: A numpy array with (sx, sy, sz) + :rtype: numpy.ndarray + """ + return self._scale.copy() + + def getDataTranslation(self): + """Offset from array to data coordinates: (ox, oy, oz) + + :return: A numpy array with (ox, oy, oz) + :rtype: numpy.ndarray + """ + return self._translation.copy() + + +class CutPlane(qt.QObject): + """Class representing a cutting plane + + :param ScalarFieldView sfView: Widget in which the cut plane is applied. + """ + + sigVisibilityChanged = qt.Signal(bool) + """Signal emitted when the cut visibility has changed. + + This signal provides the new visibility status. + """ + + sigDataChanged = qt.Signal() + """Signal emitted when the data this plane is cutting has changed.""" + + sigPlaneChanged = qt.Signal() + """Signal emitted when the cut plane has moved""" + + sigColormapChanged = qt.Signal(object) + """Signal emitted when the colormap has changed + + This signal provides the new colormap. + """ + + sigInterpolationChanged = qt.Signal(str) + """Signal emitted when the cut plane interpolation has changed + + This signal provides the new interpolation mode. + """ + + def __init__(self, sfView): + super(CutPlane, self).__init__(parent=sfView) + + self._colormap = Colormap( + name='gray', norm='linear', vmin=None, vmax=None) + + self._dataRange = None + self._positiveMin = None + + self._plane = cutplane.CutPlane(normal=(0, 1, 0)) + self._plane.alpha = 1. + self._plane.visible = self._visible = False + self._plane.addListener(self._planeChanged) + self._plane.plane.addListener(self._planePositionChanged) + + sfView.sigDataChanged.connect(self._sfViewDataChanged) + + def _get3DPrimitive(self): + """Return the cut plane scene node""" + return self._plane + + def _sfViewDataChanged(self): + """Handle data change in the ScalarFieldView this plane belongs to""" + self._plane.setData(self.sender().getData(), copy=False) + self._dataRange = self.sender().getDataRange() + self._positiveMin = None + self.sigDataChanged.emit() + + # Update colormap range when autoscale + if self.getColormap().isAutoscale(): + self._updateColormapRange() + + def _planeChanged(self, source, *args, **kwargs): + """Handle events from the plane primitive""" + # Using _visible for now, until scene as more info in events + if source.visible != self._visible: + self._visible = source.visible + self.sigVisibilityChanged.emit(source.visible) + + def _planePositionChanged(self, source, *args, **kwargs): + """Handle update of cut plane position and normal""" + if self._plane.visible: + self.sigPlaneChanged.emit() + + # 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 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 + + # Visibility + + def isVisible(self): + """Returns True if the plane is visible, False otherwise""" + return self._plane.visible + + def setVisible(self, visible): + """Set the visibility of the plane + + :param bool visible: True to make plane visible + """ + self._plane.visible = visible + + # Border stroke + + def getStrokeColor(self): + """Returns the color of the plane border (QColor)""" + return qt.QColor.fromRgbF(*self._plane.color) + + def setStrokeColor(self, color): + """Set the color of the plane border. + + :param color: RGB color: name, #RRGGBB or RGB values + :type color: + QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8 + """ + self._plane.color = rgba(color) + + # Data + + def getImageData(self): + """Returns the data and information corresponding to the cut plane. + + The returned data is not interpolated, + it is a slice of the 3D scalar field. + + Image data axes are so that plane normal is towards the point of view. + + :return: An object containing the 2D data slice and information + """ + return _CutPlaneImage(self) + + # Interpolation + + def getInterpolation(self): + """Returns the interpolation used to display to cut plane. + + :return: 'nearest' or 'linear' + :rtype: str + """ + return self._plane.interpolation + + def setInterpolation(self, interpolation): + """Set the interpolation used to display to cut plane + + The default interpolation is 'linear' + + :param str interpolation: 'nearest' or 'linear' + """ + if interpolation != self.getInterpolation(): + self._plane.interpolation = interpolation + self.sigInterpolationChanged.emit(interpolation) + + # Colormap + + # def getAlpha(self): + # """Returns the transparency of the plane as a float in [0., 1.]""" + # return self._plane.alpha + + # def setAlpha(self, alpha): + # """Set the plane transparency. + # + # :param float alpha: Transparency in [0., 1] + # """ + # self._plane.alpha = alpha + + def getColormap(self): + """Returns the colormap set by :meth:`getColormap`. + + :return: The colormap + :rtype: Colormap + """ + return self._colormap + + def setColormap(self, + name='gray', + norm='linear', + vmin=None, + vmax=None): + """Set the colormap to use. + + :param str name: Name of the colormap in + 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue'. + :param str norm: Colormap mapping: 'linear' or 'log'. + :param float vmin: The minimum value of the range or None for autoscale + :param float vmax: The maximum value of the range or None for autoscale + """ + _logger.debug('setColormap %s %s (%s, %s)', + name, norm, str(vmin), str(vmax)) + + self._colormap = Colormap( + name=name, norm=norm, vmin=vmin, vmax=vmax) + + self._updateColormapRange() + self.sigColormapChanged.emit(self.getColormap()) + + def getColormapEffectiveRange(self): + """Returns the currently used range of the colormap. + + This range is computed from the data set if colormap is in autoscale. + Range is clipped to positive values when using log scale. + + :return: 2-tuple of float + """ + return self._plane.colormap.range_ + + def _updateColormapRange(self): + """Update the colormap range""" + colormap = self.getColormap() + + self._plane.colormap.name = colormap.getName() + if colormap.isAutoscale(): + range_ = self._dataRange + if range_ is None: # No data, use a default range + range_ = 1., 10. + else: + range_ = colormap.getVMin(), colormap.getVMax() + + if colormap.getNorm() == 'linear': + self._plane.colormap.norm = 'linear' + self._plane.colormap.range_ = range_ + + else: # Log + # Make sure range is strictly positive + if range_[0] <= 0.: + data = self._plane.getData(copy=False) + if data is not None: + if self._positiveMin is None: + # TODO compute this with the range as a combo operation + self._positiveMin = numpy.min(data[data > 0.]) + range_ = (self._positiveMin, + max(range_[1], self._positiveMin)) + + self._plane.colormap.range_ = range_ + self._plane.colormap.norm = colormap.getNorm() + + +class _CutPlaneImage(object): + """Object representing the data sliced by a cut plane + + :param CutPlane cutPlane: The CutPlane from which to generate image info + """ + + def __init__(self, cutPlane): + # Init attributes with default values + self._isValid = False + self._data = numpy.array([]) + self._xLabel = '' + self._yLabel = '' + self._normalLabel = '' + self._scale = 1., 1. + self._translation = 0., 0. + self._index = 0 + self._position = 0. + + sfView = cutPlane.parent() + if not sfView or not cutPlane.isValid(): + _logger.info("No plane available") + return + + data = sfView.getData(copy=False) + if data is None: + _logger.info("No data available") + return + + normal = cutPlane.getNormal() + point = numpy.array(cutPlane.getPoint(), dtype=numpy.int) + + if numpy.all(numpy.equal(normal, (1., 0., 0.))): + index = max(0, min(point[0], data.shape[2] - 1)) + slice_ = data[:, :, index] + xAxisIndex, yAxisIndex, normalAxisIndex = 1, 2, 0 # y, z, x + elif numpy.all(numpy.equal(normal, (0., 1., 0.))): + index = max(0, min(point[1], data.shape[1] - 1)) + slice_ = numpy.transpose(data[:, index, :]) + xAxisIndex, yAxisIndex, normalAxisIndex = 2, 0, 1 # z, x, y + elif numpy.all(numpy.equal(normal, (0., 0., 1.))): + index = max(0, min(point[2], data.shape[0] - 1)) + slice_ = data[index, :, :] + xAxisIndex, yAxisIndex, normalAxisIndex = 0, 1, 2 # x, y, z + else: + _logger.warning('Unsupported normal: (%f, %f, %f)', + normal[0], normal[1], normal[2]) + return + + # Store cut plane image info + + self._isValid = True + self._data = numpy.array(slice_, copy=True) + + labels = sfView.getAxesLabels() + scale = sfView.getScale() + translation = sfView.getTranslation() + + self._xLabel = labels[xAxisIndex] + self._yLabel = labels[yAxisIndex] + self._normalLabel = labels[normalAxisIndex] + + self._scale = scale[xAxisIndex], scale[yAxisIndex] + self._translation = translation[xAxisIndex], translation[yAxisIndex] + + self._index = index + self._position = float(index * scale[normalAxisIndex] + + translation[normalAxisIndex]) + + def isValid(self): + """Returns True if the cut plane image is defined (bool)""" + return self._isValid + + def getData(self, copy=True): + """Returns the image data sliced by the cut plane. + + :param bool copy: True to get a copy, False otherwise + :return: The 2D image data corresponding to the cut plane + :rtype: numpy.ndarray + """ + return numpy.array(self._data, copy=copy) + + def getXLabel(self): + """Returns the label associated to the X axis of the image (str)""" + return self._xLabel + + def getYLabel(self): + """Returns the label associated to the Y axis of the image (str)""" + return self._yLabel + + def getNormalLabel(self): + """Returns the label of the 3D axis of the plane normal (str)""" + return self._normalLabel + + def getScale(self): + """Returns the scales of the data as a 2-tuple of float (sx, sy)""" + return self._scale + + def getTranslation(self): + """Returns the offset of the data as a 2-tuple of float (ox, oy)""" + return self._translation + + def getIndex(self): + """Returns the index in the data array of the cut plane (int)""" + return self._index + + def getPosition(self): + """Returns the cut plane position along the normal axis (flaot)""" + return self._position + + +class ScalarFieldView(Plot3DWindow): + """Widget computing and displaying an iso-surface from a 3D scalar dataset. + + Limitation: Currently, iso-surfaces are generated with higher values + than the iso-level 'inside' the surface. + + :param parent: See :class:`QMainWindow` + """ + + sigDataChanged = qt.Signal() + """Signal emitted when the scalar data field has changed.""" + + sigSelectedRegionChanged = qt.Signal(object) + """Signal emitted when the selected region has changed. + + This signal provides the new selected region. + """ + + def __init__(self, parent=None): + super(ScalarFieldView, self).__init__(parent) + self._colormap = Colormap( + name='gray', norm='linear', vmin=None, vmax=None) + self._selectedRange = None + + # Store iso-surfaces + self._isosurfaces = [] + + # Transformations + self._dataScale = transform.Scale() + self._dataTranslate = transform.Translate() + + self._foregroundColor = 1., 1., 1., 1. + self._highlightColor = 0.7, 0.7, 0., 1. + + self._data = None + self._dataRange = None + + self._group = _BoundedGroup() + self._group.transforms = [self._dataTranslate, self._dataScale] + + self._selectionBox = primitives.Box() + self._selectionBox.strokeSmooth = False + self._selectionBox.strokeWidth = 1. + # self._selectionBox.fillColor = 1., 1., 1., 0.3 + # self._selectionBox.fillCulling = 'back' + self._selectionBox.visible = False + self._group.children.append(self._selectionBox) + + self._cutPlane = CutPlane(sfView=self) + self._cutPlane.sigVisibilityChanged.connect( + self._planeVisibilityChanged) + self._group.children.append(self._cutPlane._get3DPrimitive()) + + 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._group.children.append(self._isogroup) + + self._bbox = axes.LabelledAxes() + self._bbox.children = [self._group] + self.getPlot3DWidget().viewport.scene.children.append(self._bbox) + + self._initInteractionToolBar() + + self._updateColors() + + self.getPlot3DWidget().viewport.light.shininess = 32 + + def saveConfig(self, ioDevice): + """ + Saves this view state. Only isosurfaces at the moment. Does not save + the isosurface's function. + + :param qt.QIODevice ioDevice: A `qt.QIODevice`. + """ + + stream = qt.QDataStream(ioDevice) + + stream.writeString('<ScalarFieldView>') + + isoSurfaces = self.getIsosurfaces() + + nIsoSurfaces = len(isoSurfaces) + + # TODO : delegate the serialization to the serialized items + # isosurfaces + if nIsoSurfaces: + tagIn = '<IsoSurfaces nIso={0}>'.format(nIsoSurfaces) + stream.writeString(tagIn) + + for surface in isoSurfaces: + color = surface.getColor() + level = surface.getLevel() + visible = surface.isVisible() + stream << color + stream.writeDouble(level) + stream.writeBool(visible) + + stream.writeString('</IsoSurfaces>') + + stream.writeString('<Style>') + background = self.getBackgroundColor() + foreground = self.getForegroundColor() + highlight = self.getHighlightColor() + stream << background << foreground << highlight + stream.writeString('</Style>') + + stream.writeString('</ScalarFieldView>') + + def loadConfig(self, ioDevice): + """ + Loads this view state. + See ScalarFieldView.saveView to know what is supported at the moment. + + :param qt.QIODevice ioDevice: A `qt.QIODevice`. + """ + + tagStack = deque() + + tagInRegex = re.compile('<(?P<itemId>[^ /]*) *' + '(?P<args>.*)>') + + tagOutRegex = re.compile('</(?P<itemId>[^ ]*)>') + + tagRootInRegex = re.compile('<ScalarFieldView>') + + isoSurfaceArgsRegex = re.compile('nIso=(?P<nIso>[0-9]*)') + + stream = qt.QDataStream(ioDevice) + + tag = stream.readString() + tagMatch = tagRootInRegex.match(tag) + + if tagMatch is None: + # TODO : explicit error + raise ValueError('Unknown data.') + + itemId = 'ScalarFieldView' + + tagStack.append(itemId) + + while True: + + tag = stream.readString() + + tagMatch = tagOutRegex.match(tag) + if tagMatch: + closeId = tagMatch.groupdict()['itemId'] + if closeId != itemId: + # TODO : explicit error + raise ValueError('Unexpected closing tag {0} ' + '(expected {1})' + ''.format(closeId, itemId)) + + if itemId == 'ScalarFieldView': + # reached end + break + else: + itemId = tagStack.pop() + # fetching next tag + continue + + tagMatch = tagInRegex.match(tag) + + if tagMatch is None: + # TODO : explicit error + raise ValueError('Unknown data.') + + tagStack.append(itemId) + + matchDict = tagMatch.groupdict() + + itemId = matchDict['itemId'] + + # TODO : delegate the deserialization to the serialized items + if itemId == 'IsoSurfaces': + argsMatch = isoSurfaceArgsRegex.match(matchDict['args']) + if not argsMatch: + # TODO : explicit error + raise ValueError('Failed to parse args "{0}".' + ''.format(matchDict['args'])) + argsDict = argsMatch.groupdict() + nIso = int(argsDict['nIso']) + if nIso: + for surface in self.getIsosurfaces(): + self.removeIsosurface(surface) + for isoIdx in range(nIso): + color = qt.QColor() + stream >> color + level = stream.readDouble() + visible = stream.readBool() + surface = self.addIsosurface(level, color=color) + surface.setVisible(visible) + elif itemId == 'Style': + background = qt.QColor() + foreground = qt.QColor() + highlight = qt.QColor() + stream >> background >> foreground >> highlight + self.setBackgroundColor(background) + self.setForegroundColor(foreground) + self.setHighlightColor(highlight) + else: + raise ValueError('Unknown entry tag {0}.' + ''.format(itemId)) + + def _initInteractionToolBar(self): + self._interactionToolbar = qt.QToolBar() + self._interactionToolbar.setEnabled(False) + + group = qt.QActionGroup(self._interactionToolbar) + group.setExclusive(True) + + self._cameraAction = qt.QAction(None) + self._cameraAction.setText('camera') + self._cameraAction.setCheckable(True) + self._cameraAction.setToolTip('Control camera') + self._cameraAction.setChecked(True) + group.addAction(self._cameraAction) + + self._planeAction = qt.QAction(None) + self._planeAction.setText('plane') + self._planeAction.setCheckable(True) + self._planeAction.setToolTip('Control cutting plane') + group.addAction(self._planeAction) + group.triggered.connect(self._interactionChanged) + + self._interactionToolbar.addActions(group.actions()) + self.addToolBar(self._interactionToolbar) + + def _planeVisibilityChanged(self, visible): + """Handle visibility events from the plane""" + if visible != self._interactionToolbar.isEnabled(): + if visible: + self._interactionToolbar.setEnabled(True) + self.setInteractiveMode('plane') + else: + self._interactionToolbar.setEnabled(False) + self.setInteractiveMode('camera') + + def _interactionChanged(self, action): + self.setInteractiveMode(action.text()) + + def setInteractiveMode(self, mode): + """Choose the current interaction. + + :param str mode: Either plane or camera + """ + if mode == self.getInteractiveMode(): + return + + sceneScale = self.getPlot3DWidget().viewport.scene.transforms[0] + if mode == 'plane': + self.getPlot3DWidget().eventHandler = \ + interaction.PanPlaneRotateCameraControl( + self.getPlot3DWidget().viewport, + self._cutPlane._get3DPrimitive(), + mode='position', + scaleTransform=sceneScale) + self._planeAction.setChecked(True) + elif mode == 'camera': + self.getPlot3DWidget().eventHandler = interaction.CameraControl( + self.getPlot3DWidget().viewport, orbitAroundCenter=False, + mode='position', scaleTransform=sceneScale, + selectCB=None) + self._cameraAction.setChecked(True) + else: + raise ValueError('Unsupported interactive mode %s', str(mode)) + self._updateColors() + + def getInteractiveMode(self): + """Returns the current interaction mode, see :meth:`setInteractiveMode` + """ + if isinstance(self.getPlot3DWidget().eventHandler, + interaction.PanPlaneRotateCameraControl): + return 'plane' + elif isinstance(self.getPlot3DWidget().eventHandler, + interaction.CameraControl): + return 'camera' + else: + raise RuntimeError('Unknown interactive mode') + + # Handle scalar field + + def setData(self, data, copy=True): + """Set the 3D scalar data set to use for building the iso-surface. + + Dataset order is zyx (i.e., first dimension is z). + + :param data: scalar field from which to extract the iso-surface + :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.setSelectedRegion(zrange=None, yrange=None, xrange_=None) + self._group.shape = None + self.centerScene() + + else: + data = numpy.array(data, copy=copy, dtype=numpy.float32, order='C') + assert data.ndim == 3 + assert min(data.shape) >= 2 + + wasData = self._data is not None + previousSelectedRegion = self.getSelectedRegion() + + self._data = data + self._dataRange = self._data.min(), self._data.max() + + if previousSelectedRegion is not None: + # Update selected region to ensure it is clipped to array range + self.setSelectedRegion(*previousSelectedRegion.getArrayRange()) + + self._group.shape = self._data.shape + + if not wasData: + self.centerScene() # Reset viewpoint the first time only + + # Update iso-surfaces + for isosurface in self.getIsosurfaces(): + isosurface._setData(self._data, copy=False) + + self.sigDataChanged.emit() + + def getData(self, copy=True): + """Get the 3D scalar data currently used to build the iso-surface. + + :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 2-tuple (min, max)""" + return self._dataRange + + # Transformations + + def setScale(self, sx=1., sy=1., sz=1.): + """Set the scale of the 3D scalar field (i.e., size of a voxel). + + :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._dataScale.scale = scale + self.centerScene() # Reset viewpoint + + def getScale(self): + """Returns the scales provided by :meth:`setScale` as a numpy.ndarray. + """ + return self._dataScale.scale + + def setTranslation(self, x=0., y=0., z=0.): + """Set the translation of the origin of the data array in data coordinates. + + :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._dataTranslate.translation = translation + self.centerScene() # Reset viewpoint + + def getTranslation(self): + """Returns the offset set by :meth:`setTranslation` as a numpy.ndarray. + """ + return self._dataTranslate.translation + + # 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. + """ + if xlabel is not None: + self._bbox.xlabel = xlabel + + if ylabel is not None: + self._bbox.ylabel = ylabel + + if zlabel is not None: + self._bbox.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 + + >>> widget = ScalarFieldView() + >>> widget.setAxesLabels(xlabel='X') + + You can get the labels either as a 3-tuple: + + >>> xlabel, ylabel, zlabel = widget.getAxesLabels() + + Or as an object with methods getXLabel, getYLabel and getZLabel: + + >>> labels = widget.getAxesLabels() + >>> labels.getXLabel() + ... 'X' + + :return: object describing the labels + """ + return self._Labels((self._bbox.xlabel, + self._bbox.ylabel, + self._bbox.zlabel)) + + # Colors + + def _updateColors(self): + """Update item depending on foreground/highlight color""" + self._bbox.tickColor = self._foregroundColor + self._selectionBox.strokeColor = self._foregroundColor + if self.getInteractiveMode() == 'plane': + self._cutPlane.setStrokeColor(self._highlightColor) + self._bbox.color = self._foregroundColor + else: + self._cutPlane.setStrokeColor(self._foregroundColor) + self._bbox.color = self._highlightColor + + def getForegroundColor(self): + """Return color used for text and bounding box (QColor)""" + return qt.QColor.fromRgbF(*self._foregroundColor) + + def setForegroundColor(self, color): + """Set the foreground color. + + :param color: RGB color: name, #RRGGBB or RGB values + :type color: + QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8 + """ + color = rgba(color) + if color != self._foregroundColor: + self._foregroundColor = color + self._updateColors() + + def getHighlightColor(self): + """Return color used for highlighted item bounding box (QColor)""" + return qt.QColor.fromRgbF(*self._highlightColor) + + def setHighlightColor(self, color): + """Set hightlighted item color. + + :param color: RGB color: name, #RRGGBB or RGB values + :type color: + QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8 + """ + color = rgba(color) + if color != self._highlightColor: + self._highlightColor = color + self._updateColors() + + # Cut Plane + + def getCutPlanes(self): + """Return an iterable of all cut planes of the view. + + This includes hidden cut planes. + + For now, there is always one cut plane. + """ + return (self._cutPlane,) + + # Selection + + def setSelectedRegion(self, zrange=None, yrange=None, xrange_=None): + """Set the 3D selected region aligned with the axes. + + Provided range are array indices range. + The provided ranges are clipped to the data. + If a range is None, the range of the array on this dimension is used. + + :param zrange: (zmin, zmax) range of the selection + :param yrange: (ymin, ymax) range of the selection + :param xrange_: (xmin, xmax) range of the selection + """ + # No range given: unset selection + if zrange is None and yrange is None and xrange_ is None: + selectedRange = None + + else: + # Handle default ranges + if self._data is not None: + if zrange is None: + zrange = 0, self._data.shape[0] + if yrange is None: + yrange = 0, self._data.shape[1] + if xrange_ is None: + xrange_ = 0, self._data.shape[2] + + elif None in (xrange_, yrange, zrange): + # One of the range is None and no data available + raise RuntimeError( + 'Data is not set, cannot get default range from it.') + + # Clip selected region to data shape and make sure min <= max + selectedRange = numpy.array(( + (max(0, min(*zrange)), + min(self._data.shape[0], max(*zrange))), + (max(0, min(*yrange)), + min(self._data.shape[1], max(*yrange))), + (max(0, min(*xrange_)), + min(self._data.shape[2], max(*xrange_))), + ), dtype=numpy.int) + + # numpy.equal supports None + if not numpy.all(numpy.equal(selectedRange, self._selectedRange)): + self._selectedRange = selectedRange + + # Update scene accordingly + if self._selectedRange is None: + self._selectionBox.visible = False + else: + self._selectionBox.visible = True + scales = self._selectedRange[:, 1] - self._selectedRange[:, 0] + self._selectionBox.size = scales[::-1] + self._selectionBox.transforms = [ + transform.Translate(*self._selectedRange[::-1, 0])] + + self.sigSelectedRegionChanged.emit(self.getSelectedRegion()) + + def getSelectedRegion(self): + """Returns the currently selected region or None.""" + if self._selectedRange is None: + return None + else: + return SelectedRegion(self._selectedRange, + translation=self.getTranslation(), + scale=self.getScale()) + + # Handle iso-surfaces + + 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 iso-surface to the view. + + :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 describing this 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.sigLevelChanged.connect(self._updateIsosurfaces) + + self._isosurfaces.append(isosurface) + + self._updateIsosurfaces() + + self.sigIsosurfaceAdded.emit(isosurface) + return isosurface + + def getIsosurfaces(self): + """Return an iterable of all iso-surfaces of the view""" + return tuple(self._isosurfaces) + + def removeIsosurface(self, isosurface): + """Remove an iso-surface from the view. + + :param 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.sigLevelChanged.disconnect(self._updateIsosurfaces) + self._isosurfaces.remove(isosurface) + self._updateIsosurfaces() + self.sigIsosurfaceRemoved.emit(isosurface) + + def clearIsosurfaces(self): + """Remove all iso-surfaces from the view.""" + for isosurface in self.getIsosurfaces(): + self.removeIsosurface(isosurface) + + def _updateIsosurfaces(self, level=None): + """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 iso: - iso.getLevel()) + self._isogroup.children = [iso._get3DPrimitive() for iso in sortedIso] |