summaryrefslogtreecommitdiff
path: root/silx/gui/plot3d/ScalarFieldView.py
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/plot3d/ScalarFieldView.py')
-rw-r--r--silx/gui/plot3d/ScalarFieldView.py1531
1 files changed, 0 insertions, 1531 deletions
diff --git a/silx/gui/plot3d/ScalarFieldView.py b/silx/gui/plot3d/ScalarFieldView.py
deleted file mode 100644
index e5e680c..0000000
--- a/silx/gui/plot3d/ScalarFieldView.py
+++ /dev/null
@@ -1,1531 +0,0 @@
-# coding: utf-8
-# /*##########################################################################
-#
-# Copyright (c) 2015-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 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__ = "14/06/2018"
-
-import re
-import logging
-import time
-from collections import deque
-
-import numpy
-
-from silx.gui import qt, icons
-from silx.gui.colors import rgba
-from silx.gui.colors import Colormap
-
-from silx.math.marchingcubes import MarchingCubes
-from silx.math.combo import min_max
-
-from .scene import axes, cutplane, interaction, primitives, transform
-from . import scene
-from .Plot3DWindow import Plot3DWindow
-from .tools import InteractiveModeToolBar
-
-_logger = logging.getLogger(__name__)
-
-
-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 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._group.children = [mesh]
-
-
-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 dataBBox: Bounding box of the selection in data coordinates
- ((xmin, xmax), (ymin, ymax), (zmin, zmax))
- :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, dataBBox,
- 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._dataRange = dataBBox
-
- 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,)
-
- 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
-
- When the transform matrix is not the identity matrix
- (e.g., rotation, skew) the returned range is the one of the selected
- region bounding box in data coordinates.
-
- :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 ~silx.gui.plot3d.ScalarFieldView.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(Colormap)
- """Signal emitted when the colormap has changed
-
- This signal provides the new colormap.
- """
-
- sigTransparencyChanged = qt.Signal()
- """Signal emitted when the transparency of the plane has changed.
-
- This signal is emitted when calling :meth:`setDisplayValuesBelowMin`.
- """
-
- 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._dataRange = None
- self._visible = False
-
- self.__syncPlane = True
-
- # Plane stroke on the outer bounding box
- self._planeStroke = primitives.PlaneInGroup(normal=(0, 1, 0))
- self._planeStroke.visible = self._visible
- self._planeStroke.addListener(self._planeChanged)
- self._planeStroke.plane.addListener(self._planePositionChanged)
-
- # Plane with texture on the data bounding box
- self._dataPlane = cutplane.CutPlane(normal=(0, 1, 0))
- self._dataPlane.strokeVisible = False
- self._dataPlane.alpha = 1.
- self._dataPlane.visible = self._visible
- self._dataPlane.plane.addListener(self._planePositionChanged)
-
- self._colormap = Colormap(
- name='gray', normalization='linear', vmin=None, vmax=None)
- self.getColormap().sigChanged.connect(self._colormapChanged)
- self._updateSceneColormap()
-
- sfView.sigDataChanged.connect(self._sfViewDataChanged)
- sfView.sigTransformChanged.connect(self._sfViewTransformChanged)
-
- def _get3DPrimitives(self):
- """Return the cut plane scene node."""
- return self._planeStroke, self._dataPlane
-
- def _keepPlaneInBBox(self):
- """Makes sure the plane intersect its parent bounding box if any"""
- bounds = self._planeStroke.parent.bounds(dataBounds=True)
- if bounds is not None:
- self._planeStroke.plane.point = numpy.clip(
- self._planeStroke.plane.point,
- a_min=bounds[0], a_max=bounds[1])
-
- @staticmethod
- def _syncPlanes(master, slave):
- """Move slave PlaneInGroup so that it is coplanar with master.
-
- :param PlaneInGroup master: Reference PlaneInGroup
- :param PlaneInGroup slave: PlaneInGroup to align
- """
- masterToSlave = transform.StaticTransformList([
- slave.objectToSceneTransform.inverse(),
- master.objectToSceneTransform])
-
- point = masterToSlave.transformPoint(
- master.plane.point)
- normal = masterToSlave.transformNormal(
- master.plane.normal)
- slave.plane.setPlane(point, normal)
-
- def _sfViewDataChanged(self):
- """Handle data change in the ScalarFieldView this plane belongs to"""
- self._dataPlane.setData(self.sender().getData(), copy=False)
-
- # Store data range info as 3-tuple of values
- self._dataRange = self.sender().getDataRange()
-
- self.sigDataChanged.emit()
-
- # Update colormap range when autoscale
- if self.getColormap().isAutoscale():
- self._updateSceneColormap()
-
- self._keepPlaneInBBox()
-
- def _sfViewTransformChanged(self):
- """Handle transform changed in the ScalarFieldView"""
- self._keepPlaneInBBox()
- self._syncPlanes(master=self._planeStroke,
- slave=self._dataPlane)
- self.sigPlaneChanged.emit()
-
- 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.__syncPlane:
- self.__syncPlane = False
- if source is self._planeStroke.plane:
- self._syncPlanes(master=self._planeStroke,
- slave=self._dataPlane)
- elif source is self._dataPlane.plane:
- self._syncPlanes(master=self._dataPlane,
- slave=self._planeStroke)
- else:
- _logger.error('Received an unknown object %s',
- str(source))
-
- if self._planeStroke.visible or self._dataPlane.visible:
- self.sigPlaneChanged.emit()
-
- self.__syncPlane = True
-
- # Plane position
-
- def moveToCenter(self):
- """Move cut plane to center of data set"""
- self._planeStroke.moveToCenter()
-
- def isValid(self):
- """Returns whether the cut plane is defined or not (bool)"""
- return self._planeStroke.isValid
-
- def _plane(self, coordinates='array'):
- """Returns the scene plane to set.
-
- :param str coordinates: The coordinate system to use:
- Either 'scene' or 'array' (default)
- :rtype: Plane
- :raise ValueError: If coordinates is not correct
- """
- if coordinates == 'scene':
- return self._planeStroke.plane
- elif coordinates == 'array':
- return self._dataPlane.plane
- else:
- raise ValueError(
- 'Unsupported coordinates: %s' % str(coordinates))
-
- def getNormal(self, coordinates='array'):
- """Returns the normal of the plane (as a unit vector)
-
- :param str coordinates: The coordinate system to use:
- Either 'scene' or 'array' (default)
- :return: Normal (nx, ny, nz), vector is 0 if no plane is defined
- :rtype: numpy.ndarray
- :raise ValueError: If coordinates is not correct
- """
- return self._plane(coordinates).normal
-
- def setNormal(self, normal, coordinates='array'):
- """Set the normal of the plane.
-
- :param normal: 3-tuple of float: nx, ny, nz
- :param str coordinates: The coordinate system to use:
- Either 'scene' or 'array' (default)
- :raise ValueError: If coordinates is not correct
- """
- self._plane(coordinates).normal = normal
-
- def getPoint(self, coordinates='array'):
- """Returns a point on the plane.
-
- :param str coordinates: The coordinate system to use:
- Either 'scene' or 'array' (default)
- :return: (x, y, z)
- :rtype: numpy.ndarray
- :raise ValueError: If coordinates is not correct
- """
- return self._plane(coordinates).point
-
- def setPoint(self, point, constraint=True, coordinates='array'):
- """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
- :param bool constraint:
- True (default) to make sure the plane intersect data bounding box,
- False to set the plane without any constraint.
- :raise ValueError: If coordinates is not correc
- """
- self._plane(coordinates).point = point
- if constraint:
- self._keepPlaneInBBox()
-
- def getParameters(self, coordinates='array'):
- """Returns the plane equation parameters: a*x + b*y + c*z + d = 0
-
- :param str coordinates: The coordinate system to use:
- Either 'scene' or 'array' (default)
- :return: Plane equation parameters: (a, b, c, d)
- :rtype: numpy.ndarray
- :raise ValueError: If coordinates is not correct
- """
- return self._plane(coordinates).parameters
-
- def setParameters(self, parameters, constraint=True, coordinates='array'):
- """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.
-
- :param parameters: (a, b, c, d) plane equation parameters.
- :type parameters: 4-tuple of float
- :param bool constraint:
- True (default) to make sure the plane intersect data bounding box,
- False to set the plane without any constraint.
- :raise ValueError: If coordinates is not correc
- """
- self._plane(coordinates).parameters = parameters
- if constraint:
- self._keepPlaneInBBox()
-
- # Visibility
-
- def isVisible(self):
- """Returns True if the plane is visible, False otherwise"""
- return self._planeStroke.visible
-
- def setVisible(self, visible):
- """Set the visibility of the plane
-
- :param bool visible: True to make plane visible
- """
- visible = bool(visible)
- self._planeStroke.visible = visible
- self._dataPlane.visible = visible
-
- # Border stroke
-
- def getStrokeColor(self):
- """Returns the color of the plane border (QColor)"""
- return qt.QColor.fromRgbF(*self._planeStroke.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
- """
- color = rgba(color)
- self._planeStroke.color = color
- self._dataPlane.color = 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._dataPlane.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._dataPlane.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 getDisplayValuesBelowMin(self):
- """Return whether values <= colormap min are displayed or not.
-
- :rtype: bool
- """
- return self._dataPlane.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._dataPlane.colormap.displayValuesBelowMin = display
- self.sigTransparencyChanged.emit()
-
- def getColormap(self):
- """Returns the colormap set by :meth:`setColormap`.
-
- :return: The colormap
- :rtype: ~silx.gui.colors.Colormap
- """
- return self._colormap
-
- def setColormap(self,
- name='gray',
- norm=None,
- vmin=None,
- vmax=None):
- """Set the colormap to use.
-
- By either providing a :class:`Colormap` object or
- its name, normalization and range.
-
- :param name: Name of the colormap in
- 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue'.
- Or Colormap object.
- :type name: str or ~silx.gui.colors.Colormap
- :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, str(norm), str(vmin), str(vmax))
-
- self._colormap.sigChanged.disconnect(self._colormapChanged)
-
- if isinstance(name, Colormap): # Use it as it is
- assert (norm, vmin, vmax) == (None, None, None)
- self._colormap = name
- else:
- if norm is None:
- norm = 'linear'
- self._colormap = Colormap(
- name=name, normalization=norm, vmin=vmin, vmax=vmax)
-
- self._colormap.sigChanged.connect(self._colormapChanged)
- self._colormapChanged()
-
- 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._dataPlane.colormap.range_
-
- def _updateSceneColormap(self):
- """Synchronizes scene's colormap with Colormap object"""
- colormap = self.getColormap()
- sceneCMap = self._dataPlane.colormap
-
- sceneCMap.colormap = colormap.getNColors()
-
- sceneCMap.norm = colormap.getNormalization()
- range_ = colormap.getColormapRange(data=self._dataRange)
- sceneCMap.range_ = range_
-
- def _colormapChanged(self):
- """Handle update of Colormap object"""
- self._updateSceneColormap()
- # Forward colormap changed event
- self.sigColormapChanged.emit(self.getColormap())
-
-
-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.zeros((0, 0), dtype=numpy.float32)
- self._index = 0
- self._xLabel = ''
- self._yLabel = ''
- self._normalLabel = ''
- self._scale = float('nan'), float('nan')
- self._translation = float('nan'), float('nan')
- self._position = float('nan')
-
- 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(coordinates='array')
- point = cutPlane.getPoint(coordinates='array')
-
- if numpy.linalg.norm(numpy.cross(normal, (1., 0., 0.))) < 0.0017:
- if not 0 <= point[0] <= data.shape[2]:
- _logger.info("Plane outside dataset")
- return
- index = max(0, min(int(point[0]), data.shape[2] - 1))
- slice_ = data[:, :, index]
- xAxisIndex, yAxisIndex, normalAxisIndex = 1, 2, 0 # y, z, x
-
- elif numpy.linalg.norm(numpy.cross(normal, (0., 1., 0.))) < 0.0017:
- if not 0 <= point[1] <= data.shape[1]:
- _logger.info("Plane outside dataset")
- return
- index = max(0, min(int(point[1]), data.shape[1] - 1))
- slice_ = numpy.transpose(data[:, index, :])
- xAxisIndex, yAxisIndex, normalAxisIndex = 2, 0, 1 # z, x, y
-
- elif numpy.linalg.norm(numpy.cross(normal, (0., 0., 1.))) < 0.0017:
- if not 0 <= point[2] <= data.shape[0]:
- _logger.info("Plane outside dataset")
- return
- index = max(0, min(int(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)
- self._index = index
-
- # Only store extra information when no transform matrix is set
- # Otherwise this information can be meaningless
- if numpy.all(numpy.equal(sfView.getTransformMatrix(),
- numpy.identity(3, dtype=numpy.float32))):
- labels = sfView.getAxesLabels()
- self._xLabel = labels[xAxisIndex]
- self._yLabel = labels[yAxisIndex]
- self._normalLabel = labels[normalAxisIndex]
-
- scale = sfView.getScale()
- self._scale = scale[xAxisIndex], scale[yAxisIndex]
-
- translation = sfView.getTranslation()
- self._translation = translation[xAxisIndex], translation[yAxisIndex]
-
- 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."""
-
- sigTransformChanged = qt.Signal()
- """Signal emitted when the transformation has changed.
-
- It is emitted by :meth:`setTranslation`, :meth:`setTransformMatrix`,
- :meth:`setScale`.
- """
-
- 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', normalization='linear', vmin=None, vmax=None)
- self._selectedRange = None
-
- # Store iso-surfaces
- self._isosurfaces = []
-
- # Transformations
- self._dataScale = transform.Scale()
- self._dataTranslate = transform.Translate()
- self._dataTransform = transform.Matrix() # default to identity
-
- self._foregroundColor = 1., 1., 1., 1.
- self._highlightColor = 0.7, 0.7, 0., 1.
-
- self._data = None
- self._dataRange = None
-
- self._group = primitives.BoundedGroup()
- self._group.transforms = [
- self._dataTranslate, self._dataTransform, self._dataScale]
-
- self._bbox = axes.LabelledAxes()
- self._bbox.children = [self._group]
- self.getPlot3DWidget().viewport.scene.children.append(self._bbox)
-
- 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)
- planeStroke, dataPlane = self._cutPlane._get3DPrimitives()
- self._bbox.children.append(planeStroke)
- self._group.children.append(dataPlane)
-
- 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._initPanPlaneAction()
-
- 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 _initPanPlaneAction(self):
- """Creates and init the pan plane action"""
- self._panPlaneAction = qt.QAction(self)
- self._panPlaneAction.setIcon(icons.getQIcon('3d-plane-pan'))
- self._panPlaneAction.setText('Pan plane')
- self._panPlaneAction.setCheckable(True)
- self._panPlaneAction.setToolTip(
- 'Pan the cutting plane. Press <b>Ctrl</b> to rotate the scene.')
- self._panPlaneAction.setEnabled(False)
-
- self._panPlaneAction.triggered[bool].connect(self._planeActionTriggered)
- self.getPlot3DWidget().sigInteractiveModeChanged.connect(
- self._interactiveModeChanged)
-
- toolbar = self.findChild(InteractiveModeToolBar)
- if toolbar is not None:
- toolbar.addAction(self._panPlaneAction)
-
- def _planeActionTriggered(self, checked=False):
- self._panPlaneAction.setChecked(True)
- self.setInteractiveMode('plane')
-
- def _interactiveModeChanged(self):
- self._panPlaneAction.setChecked(self.getInteractiveMode() == 'plane')
- self._updateColors()
-
- def _planeVisibilityChanged(self, visible):
- """Handle visibility events from the plane"""
- if visible != self._panPlaneAction.isEnabled():
- self._panPlaneAction.setEnabled(visible)
- if visible:
- self.setInteractiveMode('plane')
- elif self._panPlaneAction.isChecked():
- self.setInteractiveMode('rotate')
-
- def setInteractiveMode(self, mode):
- """Choose the current interaction.
-
- :param str mode: Either rotate, pan or plane
- """
- if mode == self.getInteractiveMode():
- return
-
- sceneScale = self.getPlot3DWidget().viewport.scene.transforms[0]
- if mode == 'plane':
- mode = interaction.PanPlaneZoomOnWheelControl(
- self.getPlot3DWidget().viewport,
- self._cutPlane._get3DPrimitives()[0],
- mode='position',
- orbitAroundCenter=False,
- scaleTransform=sceneScale)
-
- self.getPlot3DWidget().setInteractiveMode(mode)
- self._updateColors()
-
- def getInteractiveMode(self):
- """Returns the current interaction mode, see :meth:`setInteractiveMode`
- """
- if isinstance(self.getPlot3DWidget().eventHandler,
- interaction.PanPlaneZoomOnWheelControl):
- return 'plane'
- else:
- return self.getPlot3DWidget().getInteractiveMode()
-
- # 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
-
- # 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
-
- 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 3-tuple of values.
-
- positive min is NaN if no data is positive.
-
- :return: (min, positive min, max) or None.
- """
- 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.sigTransformChanged.emit()
- 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.sigTransformChanged.emit()
- self.centerScene() # Reset viewpoint
-
- def getTranslation(self):
- """Returns the offset set by :meth:`setTranslation` as a numpy.ndarray.
- """
- return self._dataTranslate.translation
-
- def setTransformMatrix(self, matrix3x3):
- """Set the transform matrix applied to the data.
-
- :param numpy.ndarray matrix: 3x3 transform matrix
- """
- matrix3x3 = numpy.array(matrix3x3, copy=True, dtype=numpy.float32)
- if not numpy.all(numpy.equal(matrix3x3, self.getTransformMatrix())):
- matrix = numpy.identity(4, dtype=numpy.float32)
- matrix[:3, :3] = matrix3x3
- self._dataTransform.setMatrix(matrix)
- self.sigTransformChanged.emit()
- self.centerScene() # Reset viewpoint
-
- def getTransformMatrix(self):
- """Returns the transform matrix applied to the data.
-
- See :meth:`setTransformMatrix`.
-
- :rtype: numpy.ndarray
- """
- return self._dataTransform.getMatrix()[:3, :3]
-
- # Axes labels
-
- def isBoundingBoxVisible(self):
- """Returns axes labels, grid and bounding box visibility.
-
- :rtype: bool
- """
- return self._bbox.boxVisible
-
- def setBoundingBoxVisible(self, visible):
- """Set axes labels, grid and bounding box visibility.
-
- :param bool visible: True to show axes, False to hide
- """
- visible = bool(visible)
- self._bbox.boxVisible = visible
-
- 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:
- dataBBox = self._group.transforms.transformBounds(
- self._selectedRange[::-1].T).T
- return SelectedRegion(self._selectedRange, dataBBox,
- 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]