diff options
Diffstat (limited to 'src/silx/gui/plot/StackView.py')
-rw-r--r-- | src/silx/gui/plot/StackView.py | 1254 |
1 files changed, 1254 insertions, 0 deletions
diff --git a/src/silx/gui/plot/StackView.py b/src/silx/gui/plot/StackView.py new file mode 100644 index 0000000..56793d7 --- /dev/null +++ b/src/silx/gui/plot/StackView.py @@ -0,0 +1,1254 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2021 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. +# +# ###########################################################################*/ +"""QWidget displaying a 3D volume as a stack of 2D images. + +The :class:`StackView` class implements this widget. + +Basic usage of :class:`StackView` is through the following methods: + +- :meth:`StackView.getColormap`, :meth:`StackView.setColormap` to update the + default colormap to use and update the currently displayed image. +- :meth:`StackView.setStack` to update the displayed image. + +The :class:`StackView` uses :class:`PlotWindow` and also +exposes a subset of the :class:`silx.gui.plot.Plot` API for further control +(plot title, axes labels, ...). + +The :class:`StackViewMainWindow` class implements a widget that adds a status +bar displaying the 3D index and the value under the mouse cursor. + +Example:: + + import numpy + import sys + from silx.gui import qt + from silx.gui.plot.StackView import StackViewMainWindow + + + app = qt.QApplication(sys.argv[1:]) + + # synthetic data, stack of 100 images of size 200x300 + mystack = numpy.fromfunction( + lambda i, j, k: numpy.sin(i/15.) + numpy.cos(j/4.) + 2 * numpy.sin(k/6.), + (100, 200, 300) + ) + + + sv = StackViewMainWindow() + sv.setColormap("jet", autoscale=True) + sv.setStack(mystack) + sv.setLabels(["1st dim (0-99)", "2nd dim (0-199)", + "3rd dim (0-299)"]) + sv.show() + + app.exec() + +""" + +__authors__ = ["P. Knobel", "H. Payno"] +__license__ = "MIT" +__date__ = "10/10/2018" + +import numpy +import logging + +import silx +from silx.gui import qt +from .. import icons +from . import items, PlotWindow, actions +from .items.image import ImageStack +from ..colors import Colormap +from ..colors import cursorColorForColormap +from .tools import LimitsToolBar +from .Profile import Profile3DToolBar +from ..widgets.FrameBrowser import HorizontalSliderWithBrowser + +from silx.gui.plot.actions import control as actions_control +from silx.gui.plot.actions import io as silx_io +from silx.io.nxdata import save_NXdata +from silx.utils.array_like import DatasetView, ListOfImages +from silx.math import calibration +from silx.utils.deprecation import deprecated_warning +from silx.utils.deprecation import deprecated + +import h5py +from silx.io.utils import is_dataset + +_logger = logging.getLogger(__name__) + + +class StackView(qt.QMainWindow): + """Stack view widget, to display and browse through stack of + images. + + The profile tool can be switched to "3D" mode, to compute the profile + on each image of the stack (not only the active image currently displayed) + and display the result as a slice. + + :param QWidget parent: the Qt parent, or None + :param backend: The backend to use for the plot (default: matplotlib). + See :class:`.PlotWidget` for the list of supported backend. + :type backend: str or :class:`BackendBase.BackendBase` + :param bool resetzoom: Toggle visibility of reset zoom action. + :param bool autoScale: Toggle visibility of axes autoscale actions. + :param bool logScale: Toggle visibility of axes log scale actions. + :param bool grid: Toggle visibility of grid mode action. + :param bool colormap: Toggle visibility of colormap action. + :param bool aspectRatio: Toggle visibility of aspect ratio button. + :param bool yInverted: Toggle visibility of Y axis direction button. + :param bool copy: Toggle visibility of copy action. + :param bool save: Toggle visibility of save action. + :param bool print_: Toggle visibility of print action. + :param bool control: True to display an Options button with a sub-menu + to show legends, toggle crosshair and pan with arrows. + (Default: False) + :param position: True to display widget with (x, y) mouse position + (Default: False). + It also supports a list of (name, funct(x, y)->value) + to customize the displayed values. + See :class:`silx.gui.plot.PlotTools.PositionInfo`. + :param bool mask: Toggle visibilty of mask action. + """ + # Qt signals + valueChanged = qt.Signal(object, object, object) + """Signals that the data value under the cursor has changed. + + It provides: row, column, data value. + """ + + sigPlaneSelectionChanged = qt.Signal(int) + """Signal emitted when there is a change is perspective/displayed axes. + + It provides the perspective as an integer, with the following meaning: + + - 0: axis Y is the 2nd dimension, axis X is the 3rd dimension + - 1: axis Y is the 1st dimension, axis X is the 3rd dimension + - 2: axis Y is the 1st dimension, axis X is the 2nd dimension + """ + + sigStackChanged = qt.Signal(int) + """Signal emitted when the stack is changed. + This happens when a new volume is loaded, or when the current volume + is transposed (change in perspective). + + The signal provides the size (number of pixels) of the stack. + This will be 0 if the stack is cleared, else it will be a positive + integer. + """ + + sigFrameChanged = qt.Signal(int) + """Signal emitter when the frame number has changed. + + This signal provides the current frame number. + """ + + IMAGE_STACK_FILTER_NXDATA = 'Stack of images as NXdata (%s)' % silx_io._NEXUS_HDF5_EXT_STR + + + def __init__(self, parent=None, resetzoom=True, backend=None, + autoScale=False, logScale=False, grid=False, + colormap=True, aspectRatio=True, yinverted=True, + copy=True, save=True, print_=True, control=False, + position=None, mask=True): + qt.QMainWindow.__init__(self, parent) + if parent is not None: + # behave as a widget + self.setWindowFlags(qt.Qt.Widget) + else: + self.setWindowTitle('StackView') + + self._stack = None + """Loaded stack, as a 3D array, a 3D dataset or a list of 2D arrays.""" + self.__transposed_view = None + """View on :attr:`_stack` with the axes sorted, to have + the orthogonal dimension first""" + self._perspective = 0 + """Orthogonal dimension (depth) in :attr:`_stack`""" + + self._stackItem = ImageStack() + """Hold the item displaying the stack""" + imageLegend = '__StackView__image' + str(id(self)) + self._stackItem.setName(imageLegend) + + self.__autoscaleCmap = False + """Flag to disable/enable colormap auto-scaling + based on the min/max values of the entire 3D volume""" + self.__dimensionsLabels = ["Dimension 0", "Dimension 1", + "Dimension 2"] + """These labels are displayed on the X and Y axes. + :meth:`setLabels` updates this attribute.""" + + self._first_stack_dimension = 0 + """Used for dimension labels and combobox""" + + self._titleCallback = self._defaultTitleCallback + """Function returning the plot title based on the frame index. + It can be set to a custom function using :meth:`setTitleCallback`""" + + self.calibrations3D = (calibration.NoCalibration(), + calibration.NoCalibration(), + calibration.NoCalibration()) + + central_widget = qt.QWidget(self) + + self._plot = PlotWindow(parent=central_widget, backend=backend, + resetzoom=resetzoom, autoScale=autoScale, + logScale=logScale, grid=grid, + curveStyle=False, colormap=colormap, + aspectRatio=aspectRatio, yInverted=yinverted, + copy=copy, save=save, print_=print_, + control=control, position=position, + roi=False, mask=mask) + self._plot.addItem(self._stackItem) + self._plot.getIntensityHistogramAction().setVisible(True) + self.sigInteractiveModeChanged = self._plot.sigInteractiveModeChanged + self.sigActiveImageChanged = self._plot.sigActiveImageChanged + self.sigPlotSignal = self._plot.sigPlotSignal + + if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward': + self._plot.getYAxis().setInverted(True) + + self._addColorBarAction() + + self._profileToolBar = Profile3DToolBar(parent=self._plot, + stackview=self) + self._plot.addToolBar(self._profileToolBar) + self._plot.getXAxis().setLabel('Columns') + self._plot.getYAxis().setLabel('Rows') + self._plot.sigPlotSignal.connect(self._plotCallback) + self._plot.getSaveAction().setFileFilter('image', self.IMAGE_STACK_FILTER_NXDATA, func=self._saveImageStack, appendToFile=True) + + self.__planeSelection = PlanesWidget(self._plot) + self.__planeSelection.sigPlaneSelectionChanged.connect(self.setPerspective) + + self._browser_label = qt.QLabel("Image index (Dim0):") + + self._browser = HorizontalSliderWithBrowser(central_widget) + self._browser.setRange(0, 0) + self._browser.valueChanged[int].connect(self.__updateFrameNumber) + self._browser.setEnabled(False) + + layout = qt.QGridLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._plot, 0, 0, 1, 3) + layout.addWidget(self.__planeSelection, 1, 0) + layout.addWidget(self._browser_label, 1, 1) + layout.addWidget(self._browser, 1, 2) + + central_widget.setLayout(layout) + self.setCentralWidget(central_widget) + + # clear profile lines when the perspective changes (plane browsed changed) + self.__planeSelection.sigPlaneSelectionChanged.connect( + self._profileToolBar.clearProfile) + + def _saveImageStack(self, plot, filename, nameFilter): + """Save all images from the stack into a volume. + + :param str filename: The name of the file to write + :param str nameFilter: The selected name filter + :return: False if format is not supported or save failed, + True otherwise. + :raises: ValueError if nameFilter is invalid + """ + if not nameFilter == self.IMAGE_STACK_FILTER_NXDATA: + raise ValueError('Wrong callback') + entryPath = silx_io.SaveAction._selectWriteableOutputGroup(filename, parent=self) + if entryPath is None: + return False + return save_NXdata(filename, + nxentry_name=entryPath, + signal=self.getStack(copy=False, returnNumpyArray=True)[0], + signal_name="image_stack") + + def _addColorBarAction(self): + self._plot.getColorBarWidget().setVisible(True) + actions = self._plot.toolBar().actions() + for index, action in enumerate(actions): + if action is self._plot.getColormapAction(): + break + self._colorbarAction = actions_control.ColorBarAction(self._plot, self._plot) + self._plot.toolBar().insertAction(actions[index + 1], self._colorbarAction) + + def _plotCallback(self, eventDict): + """Callback for plot events. + + Emit :attr:`valueChanged` signal, with (x, y, value) tuple of the + cursor location in the plot.""" + if eventDict['event'] == 'mouseMoved': + activeImage = self.getActiveImage() + if activeImage is not None: + data = activeImage.getData() + height, width = data.shape + + # Get corresponding coordinate in image + origin = activeImage.getOrigin() + scale = activeImage.getScale() + x = int((eventDict['x'] - origin[0]) / scale[0]) + y = int((eventDict['y'] - origin[1]) / scale[1]) + + if 0 <= x < width and 0 <= y < height: + self.valueChanged.emit(float(x), float(y), + data[y][x]) + else: + self.valueChanged.emit(float(x), float(y), + None) + + def getPerspective(self): + """Returns the index of the dimension the stack is browsed with + + Possible values are: 0, 1, or 2. + + :rtype: int + """ + return self._perspective + + def setPerspective(self, perspective): + """Set the index of the dimension the stack is browsed with: + + - slice plane Dim1-Dim2: perspective 0 + - slice plane Dim0-Dim2: perspective 1 + - slice plane Dim0-Dim1: perspective 2 + + :param int perspective: Orthogonal dimension number (0, 1, or 2) + """ + if perspective == self._perspective: + return + else: + if perspective > 2 or perspective < 0: + raise ValueError( + "Perspective must be 0, 1 or 2, not %s" % perspective) + + self._perspective = int(perspective) + self.__createTransposedView() + self.__updateFrameNumber(self._browser.value()) + self._plot.resetZoom() + self.__updatePlotLabels() + self._updateTitle() + self._browser_label.setText("Image index (Dim%d):" % + (self._first_stack_dimension + perspective)) + + self.sigPlaneSelectionChanged.emit(perspective) + self.sigStackChanged.emit(self._stack.size if + self._stack is not None else 0) + self.__planeSelection.sigPlaneSelectionChanged.disconnect(self.setPerspective) + self.__planeSelection.setPerspective(self._perspective) + self.__planeSelection.sigPlaneSelectionChanged.connect(self.setPerspective) + + def __updatePlotLabels(self): + """Update plot axes labels depending on perspective""" + y, x = (1, 2) if self._perspective == 0 else \ + (0, 2) if self._perspective == 1 else (0, 1) + self.setGraphXLabel(self.__dimensionsLabels[x]) + self.setGraphYLabel(self.__dimensionsLabels[y]) + + def __createTransposedView(self): + """Create the new view on the stack depending on the perspective + (set orthogonal axis browsed on the viewer as first dimension) + """ + assert self._stack is not None + assert 0 <= self._perspective < 3 + + # ensure we have the stack encapsulated in an array-like object + # having a transpose() method + if isinstance(self._stack, numpy.ndarray): + self.__transposed_view = self._stack + + elif is_dataset(self._stack) or isinstance(self._stack, DatasetView): + self.__transposed_view = DatasetView(self._stack) + + elif isinstance(self._stack, ListOfImages): + self.__transposed_view = ListOfImages(self._stack) + + # transpose the array-like object if necessary + if self._perspective == 1: + self.__transposed_view = self.__transposed_view.transpose((1, 0, 2)) + elif self._perspective == 2: + self.__transposed_view = self.__transposed_view.transpose((2, 0, 1)) + + self._browser.setRange(0, self.__transposed_view.shape[0] - 1) + self._browser.setValue(0) + + # Update the item structure + self._stackItem.setStackData(self.__transposed_view, 0, copy=False) + self._stackItem.setColormap(self.getColormap()) + self._stackItem.setOrigin(self._getImageOrigin()) + self._stackItem.setScale(self._getImageScale()) + + def __updateFrameNumber(self, index): + """Update the current image. + + :param index: index of the frame to be displayed + """ + if self.__transposed_view is None: + # no data set + return + + self._stackItem.setStackPosition(index) + + self._updateTitle() + self.sigFrameChanged.emit(index) + + def _set3DScaleAndOrigin(self, calibrations): + """Set scale and origin for all 3 axes, to be used when plotting + an image. + + See setStack for parameter documentation + """ + if calibrations is None: + self.calibrations3D = (calibration.NoCalibration(), + calibration.NoCalibration(), + calibration.NoCalibration()) + else: + self.calibrations3D = [] + for i, calib in enumerate(calibrations): + if hasattr(calib, "__len__") and len(calib) == 2: + calib = calibration.LinearCalibration(calib[0], calib[1]) + elif calib is None: + calib = calibration.NoCalibration() + elif not isinstance(calib, calibration.AbstractCalibration): + raise TypeError("calibration must be a 2-tuple, None or" + + " an instance of an AbstractCalibration " + + "subclass") + elif not calib.is_affine(): + _logger.warning( + "Calibration for dimension %d is not linear, " + "it will be ignored for scaling the graph axes.", + i) + self.calibrations3D.append(calib) + + def getCalibrations(self, order='array'): + """Returns currently used calibrations for each axis + + Returned calibrations might differ from the ones that were set as + non-linear calibrations used for image axes are temporarily ignored. + + :param str order: + 'array' to sort calibrations as data array (dim0, dim1, dim2), + 'axes' to sort calibrations as currently selected x, y and z axes. + :return: Calibrations ordered depending on order + :rtype: List[~silx.math.calibration.AbstractCalibration] + """ + assert order in ('array', 'axes') + calibs = [] + + # filter out non-linear calibration for graph axes + for index, calib in enumerate(self.calibrations3D): + if index != self._perspective and not calib.is_affine(): + calib = calibration.NoCalibration() + calibs.append(calib) + + if order == 'axes': # Move 'z' axis to the end + xy_dims = [d for d in (0, 1, 2) if d != self._perspective] + calibs = [calibs[max(xy_dims)], + calibs[min(xy_dims)], + calibs[self._perspective]] + + return tuple(calibs) + + def _getImageScale(self): + """ + :return: 2-tuple (XScale, YScale) for current image view + """ + xcalib, ycalib, _zcalib = self.getCalibrations(order='axes') + return xcalib.get_slope(), ycalib.get_slope() + + def _getImageOrigin(self): + """ + :return: 2-tuple (XOrigin, YOrigin) for current image view + """ + xcalib, ycalib, _zcalib = self.getCalibrations(order='axes') + return xcalib(0), ycalib(0) + + def _getImageZ(self, index): + """ + :param idx: 0-based image index in the stack + :return: calibrated Z value corresponding to the image idx + """ + _xcalib, _ycalib, zcalib = self.getCalibrations(order='axes') + return zcalib(index) + + def _updateTitle(self): + frame_idx = self._browser.value() + self._plot.setGraphTitle(self._titleCallback(frame_idx)) + + def _defaultTitleCallback(self, index): + return "Image z=%g" % self._getImageZ(index) + + # public API, stack specific methods + def setStack(self, stack, perspective=None, reset=True, calibrations=None): + """Set the 3D stack. + + The perspective parameter is used to define which dimension of the 3D + array is to be used as frame index. The lowest remaining dimension + number is the row index of the displayed image (Y axis), and the highest + remaining dimension is the column index (X axis). + + :param stack: 3D stack, or `None` to clear plot. + :type stack: 3D numpy.ndarray, or 3D h5py.Dataset, or list/tuple of 2D + numpy arrays, or None. + :param int perspective: Dimension for the frame index: 0, 1 or 2. + Use ``None`` to keep the current perspective (default). + :param bool reset: Whether to reset zoom or not. + :param calibrations: Sequence of 3 calibration objects for each axis. + These objects can be a subclass of :class:`AbstractCalibration`, + or 2-tuples *(a, b)* where *a* is the y-intercept and *b* is the + slope of a linear calibration (:math:`x \\mapsto a + b x`) + """ + if stack is None: + self.clear() + self.sigStackChanged.emit(0) + return + + self._set3DScaleAndOrigin(calibrations) + + # stack as list of 2D arrays: must be converted into an array_like + if not isinstance(stack, numpy.ndarray): + if not is_dataset(stack): + try: + assert hasattr(stack, "__len__") + for img in stack: + assert hasattr(img, "shape") + assert len(img.shape) == 2 + except AssertionError: + raise ValueError( + "Stack must be a 3D array/dataset or a list of " + + "2D arrays.") + stack = ListOfImages(stack) + + assert len(stack.shape) == 3, "data must be 3D" + + self._stack = stack + self.__createTransposedView() + + perspective_changed = False + if perspective not in [None, self._perspective]: + perspective_changed = True + self.setPerspective(perspective) + + if self.__autoscaleCmap: + self.scaleColormapRangeToStack() + + # init plot + self._stackItem.setStackData(self.__transposed_view, 0, copy=False) + self._stackItem.setColormap(self.getColormap()) + self._stackItem.setOrigin(self._getImageOrigin()) + self._stackItem.setScale(self._getImageScale()) + self._stackItem.setVisible(True) + + # Put back the item in the plot in case it was cleared + exists = self._plot.getImage(self._stackItem.getName()) + if exists is None: + self._plot.addItem(self._stackItem) + + self._plot.setActiveImage(self._stackItem.getName()) + self.__updatePlotLabels() + self._updateTitle() + + if reset: + self._plot.resetZoom() + + # enable and init browser + self._browser.setEnabled(True) + + if not perspective_changed: # avoid double signal (see self.setPerspective) + self.sigStackChanged.emit(stack.size) + + def getStack(self, copy=True, returnNumpyArray=False): + """Get the original stack, as a 3D array or dataset. + + The output has the form: [data, params] + where params is a dictionary containing display parameters. + + :param bool copy: If True (default), then the object is copied + and returned as a numpy array. + Else, a reference to original data is returned, if possible. + If the original data is not a numpy array and parameter + returnNumpyArray is True, a copy will be made anyway. + :param bool returnNumpyArray: If True, the returned object is + guaranteed to be a numpy array. + :return: 3D stack and parameters. + :rtype: (numpy.ndarray, dict) + """ + if self._stack is None: + return None + + image = self._stackItem + colormap = image.getColormap() + + params = { + 'info': image.getInfo(), + 'origin': image.getOrigin(), + 'scale': image.getScale(), + 'z': image.getZValue(), + 'selectable': image.isSelectable(), + 'draggable': image.isDraggable(), + 'colormap': colormap, + 'xlabel': image.getXLabel(), + 'ylabel': image.getYLabel(), + } + if returnNumpyArray or copy: + return numpy.array(self._stack, copy=copy), params + + # if a list of 2D arrays was cast into a ListOfImages, + # return the original list + if isinstance(self._stack, ListOfImages): + return self._stack.images, params + + return self._stack, params + + def getCurrentView(self, copy=True, returnNumpyArray=False): + """Get the stack, as it is currently displayed. + + The first index of the returned stack is always the frame + index. If the perspective has been changed in the widget since the + data was first loaded, this will be reflected in the order of the + dimensions of the returned object. + + The output has the form: [data, params] + where params is a dictionary containing display parameters. + + :param bool copy: If True (default), then the object is copied + and returned as a numpy array. + Else, a reference to original data is returned, if possible. + If the original data is not a numpy array and parameter + `returnNumpyArray` is `True`, a copy will be made anyway. + :param bool returnNumpyArray: If `True`, the returned object is + guaranteed to be a numpy array. + :return: 3D stack and parameters. + :rtype: (numpy.ndarray, dict) + """ + image = self.getActiveImage() + if image is None: + return None + + if isinstance(image, items.ColormapMixIn): + colormap = image.getColormap() + else: + colormap = None + + params = { + 'info': image.getInfo(), + 'origin': image.getOrigin(), + 'scale': image.getScale(), + 'z': image.getZValue(), + 'selectable': image.isSelectable(), + 'draggable': image.isDraggable(), + 'colormap': colormap, + 'xlabel': image.getXLabel(), + 'ylabel': image.getYLabel(), + } + if returnNumpyArray or copy: + return numpy.array(self.__transposed_view, copy=copy), params + return self.__transposed_view, params + + def setFrameNumber(self, number): + """Set the frame selection to a specific value + + :param int number: Number of the frame + """ + self._browser.setValue(number) + + def getFrameNumber(self): + """Set the frame selection to a specific value + + :return: Index of currently displayed frame + :rtype: int + """ + return self._browser.value() + + def setFirstStackDimension(self, first_stack_dimension): + """When viewing the last 3 dimensions of an n-D array (n>3), you can + use this method to change the text in the combobox. + + For instance, for a 7-D array, first stack dim is 4, so the default + "Dim1-Dim2" text should be replaced with "Dim5-Dim6" (dimensions + numbers are 0-based). + + :param int first_stack_dim: First stack dimension (n-3) when viewing the + last 3 dimensions of an n-D array. + """ + old_state = self.__planeSelection.blockSignals(True) + self.__planeSelection.setFirstStackDimension(first_stack_dimension) + self.__planeSelection.blockSignals(old_state) + self._first_stack_dimension = first_stack_dimension + self._browser_label.setText("Image index (Dim%d):" % first_stack_dimension) + + def setTitleCallback(self, callback): + """Set a user defined function to generate the plot title based on the + image/frame index. + + The callback function must accept an integer as a its first positional + parameter and must not require any other mandatory parameter. + It must return a string. + + To switch back the default behavior, you can pass ``None``:: + + mystackview.setTitleCallback(None) + + To have no title, pass a function that returns an empty string:: + + mystackview.setTitleCallback(lambda idx: "") + + :param callback: Callback function generating the stack title based + on the frame number. + """ + + if callback is None: + self._titleCallback = self._defaultTitleCallback + elif callable(callback): + self._titleCallback = callback + else: + raise TypeError("Provided callback is not callable") + self._updateTitle() + + def clear(self): + """Clear the widget: + + - clear the plot + - clear the loaded data volume + """ + self._stack = None + self.__transposed_view = None + self._perspective = 0 + self._browser.setEnabled(False) + # reset browser range + self._browser.setRange(0, 0) + self._plot.clear() + + def setLabels(self, labels=None): + """Set the labels to be displayed on the plot axes. + + You must provide a sequence of 3 strings, corresponding to the 3 + dimensions of the original data volume. + The proper label will automatically be selected for each plot axis + when the volume is rotated (when different axes are selected as the + X and Y axes). + + :param List[str] labels: 3 labels corresponding to the 3 dimensions + of the data volumes. + """ + + default_labels = ["Dimension %d" % self._first_stack_dimension, + "Dimension %d" % (self._first_stack_dimension + 1), + "Dimension %d" % (self._first_stack_dimension + 2)] + if labels is None: + new_labels = default_labels + else: + # filter-out None + new_labels = [] + for i, label in enumerate(labels): + new_labels.append(label or default_labels[i]) + + self.__dimensionsLabels = new_labels + self.__updatePlotLabels() + + def getLabels(self): + """Return dimension labels displayed on the plot axes + + :return: List of three strings corresponding to the 3 dimensions + of the stack: (name_dim0, name_dim1, name_dim2) + """ + return self.__dimensionsLabels + + def getColormap(self): + """Get the current colormap description. + + :return: A description of the current colormap. + See :meth:`setColormap` for details. + :rtype: dict + """ + # "default" colormap used by addImage when image is added without + # specifying a special colormap + return self._plot.getDefaultColormap() + + def scaleColormapRangeToStack(self): + """Scale colormap range according to current stack data. + + If no stack has been set through :meth:`setStack`, this has no effect. + + The range scaling mode is given by current :class:`Colormap`'s + :meth:`Colormap.getAutoscaleMode`. + """ + stack = self.getStack(copy=False, returnNumpyArray=True) + if stack is None: + return # No-op + + colormap = self.getColormap() + vmin, vmax = colormap.getColormapRange(data=stack[0]) + colormap.setVRange(vmin=vmin, vmax=vmax) + + def setColormap(self, colormap=None, normalization=None, + autoscale=None, vmin=None, vmax=None, colors=None): + """Set the colormap and update active image. + + Parameters that are not provided are taken from the current colormap. + + The colormap parameter can also be a dict with the following keys: + + - *name*: string. The colormap to use: + 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue'. + - *normalization*: string. The mapping to use for the colormap: + either 'linear' or 'log'. + - *autoscale*: bool. Whether to use autoscale (True) or range + provided by keys + 'vmin' and 'vmax' (False). + - *vmin*: float. The minimum value of the range to use if 'autoscale' + is False. + - *vmax*: float. The maximum value of the range to use if 'autoscale' + is False. + - *colors*: optional. Nx3 or Nx4 array of float in [0, 1] or uint8. + List of RGB or RGBA colors to use (only if name is None) + + :param colormap: Name of the colormap in + 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue'. + Or a :class`.Colormap` object. + :type colormap: dict or str. + :param str normalization: Colormap mapping: 'linear' or 'log'. + :param bool autoscale: Whether to use autoscale or [vmin, vmax] range. + Default value of autoscale is False. This option is not compatible + with h5py datasets. + :param float vmin: The minimum value of the range to use if + 'autoscale' is False. + :param float vmax: The maximum value of the range to use if + 'autoscale' is False. + :param numpy.ndarray colors: Only used if name is None. + Custom colormap colors as Nx3 or Nx4 RGB or RGBA arrays + """ + # if is a colormap object or a dictionary + if isinstance(colormap, Colormap) or isinstance(colormap, dict): + # Support colormap parameter as a dict + errmsg = "If colormap is provided as a Colormap object, all other parameters" + errmsg += " must not be specified when calling setColormap" + assert normalization is None, errmsg + assert autoscale is None, errmsg + assert vmin is None, errmsg + assert vmax is None, errmsg + assert colors is None, errmsg + + if isinstance(colormap, dict): + reason = 'colormap parameter should now be an object' + replacement = 'Colormap()' + since_version = '0.6' + deprecated_warning(type_='function', + name='setColormap', + reason=reason, + replacement=replacement, + since_version=since_version) + _colormap = Colormap._fromDict(colormap) + else: + _colormap = colormap + else: + norm = normalization if normalization is not None else 'linear' + name = colormap if colormap is not None else 'gray' + _colormap = Colormap(name=name, + normalization=norm, + vmin=vmin, + vmax=vmax, + colors=colors) + + if autoscale is not None: + deprecated_warning( + type_='function', + name='setColormap', + reason='autoscale argument is replaced by a method', + replacement='scaleColormapRangeToStack', + since_version='0.14') + self.__autoscaleCmap = bool(autoscale) + + cursorColor = cursorColorForColormap(_colormap.getName()) + self._plot.setInteractiveMode('zoom', color=cursorColor) + + self._plot.setDefaultColormap(_colormap) + + # Update active image colormap + activeImage = self.getActiveImage() + if isinstance(activeImage, items.ColormapMixIn): + activeImage.setColormap(self.getColormap()) + + if self.__autoscaleCmap: + # scaleColormapRangeToStack needs to be called **after** + # setDefaultColormap so getColormap returns the right colormap + self.scaleColormapRangeToStack() + + + @deprecated(replacement="getPlotWidget", since_version="0.13") + def getPlot(self): + return self.getPlotWidget() + + def getPlotWidget(self): + """Return the :class:`PlotWidget`. + + This gives access to advanced plot configuration options. + Be warned that modifying the plot can cause issues, and some changes + you make to the plot could be overwritten by the :class:`StackView` + widget's internal methods and callbacks. + + :return: instance of :class:`PlotWidget` used in widget + """ + return self._plot + + def setOptionVisible(self, isVisible): + """ + Set the visibility of the browsing options. + + :param bool isVisible: True to have the options visible, else False + """ + self._browser.setVisible(isVisible) + self.__planeSelection.setVisible(isVisible) + + # proxies to PlotWidget or PlotWindow methods + def getProfileToolbar(self): + """Profile tools attached to this plot + """ + return self._profileToolBar + + def getGraphTitle(self): + """Return the plot main title as a str. + """ + return self._plot.getGraphTitle() + + def setGraphTitle(self, title=""): + """Set the plot main title. + + :param str title: Main title of the plot (default: '') + """ + return self._plot.setGraphTitle(title) + + def getGraphXLabel(self): + """Return the current horizontal axis label as a str. + """ + return self._plot.getXAxis().getLabel() + + def setGraphXLabel(self, label=None): + """Set the plot horizontal axis label. + + :param str label: The horizontal axis label + """ + if label is None: + label = self.__dimensionsLabels[1 if self._perspective == 2 else 2] + self._plot.getXAxis().setLabel(label) + + def getGraphYLabel(self, axis='left'): + """Return the current vertical axis label as a str. + + :param str axis: The Y axis for which to get the label (left or right) + """ + return self._plot.getYAxis().getLabel(axis) + + def setGraphYLabel(self, label=None, axis='left'): + """Set the vertical axis label on the plot. + + :param str label: The Y axis label + :param str axis: The Y axis for which to set the label (left or right) + """ + if label is None: + label = self.__dimensionsLabels[1 if self._perspective == 0 else 0] + self._plot.getYAxis(axis=axis).setLabel(label) + + def resetZoom(self): + """Reset the plot limits to the bounds of the data and redraw the plot. + + This method is a simple proxy to the legacy :class:`PlotWidget` method + of the same name. Using the object oriented approach is now + preferred:: + + stackview.getPlot().resetZoom() + """ + self._plot.resetZoom() + + def setYAxisInverted(self, flag=True): + """Set the Y axis orientation. + + This method is a simple proxy to the legacy :class:`PlotWidget` method + of the same name. Using the object oriented approach is now + preferred:: + + stackview.getPlot().setYAxisInverted(flag) + + :param bool flag: True for Y axis going from top to bottom, + False for Y axis going from bottom to top + """ + self._plot.setYAxisInverted(flag) + + def isYAxisInverted(self): + """Return True if Y axis goes from top to bottom, False otherwise. + + This method is a simple proxy to the legacy :class:`PlotWidget` method + of the same name. Using the object oriented approach is now + preferred:: + + stackview.getPlot().isYAxisInverted()""" + return self._plot.isYAxisInverted() + + def getSupportedColormaps(self): + """Get the supported colormap names as a tuple of str. + + The list should at least contain and start by: + ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue') + + This method is a simple proxy to the legacy :class:`PlotWidget` method + of the same name. Using the object oriented approach is now + preferred:: + + stackview.getPlot().getSupportedColormaps() + """ + return self._plot.getSupportedColormaps() + + def isKeepDataAspectRatio(self): + """Returns whether the plot is keeping data aspect ratio or not. + + This method is a simple proxy to the legacy :class:`PlotWidget` method + of the same name. Using the object oriented approach is now + preferred:: + + stackview.getPlot().isKeepDataAspectRatio()""" + return self._plot.isKeepDataAspectRatio() + + def setKeepDataAspectRatio(self, flag=True): + """Set whether the plot keeps data aspect ratio or not. + + This method is a simple proxy to the legacy :class:`PlotWidget` method + of the same name. Using the object oriented approach is now + preferred:: + + stackview.getPlot().setKeepDataAspectRatio(flag) + + :param bool flag: True to respect data aspect ratio + """ + self._plot.setKeepDataAspectRatio(flag) + + # kind of private methods, but needed by Profile + def getActiveImage(self, just_legend=False): + """Returns the stack image object. + """ + if just_legend: + return self._stackItem.getName() + return self._stackItem + + def getColorBarAction(self): + """Returns the action managing the visibility of the colorbar. + + .. warning:: to show/hide the plot colorbar call directly the ColorBar + widget using getColorBarWidget() + + :rtype: QAction + """ + return self._colorbarAction + + def remove(self, legend=None, + kind=('curve', 'image', 'item', 'marker')): + """See :meth:`Plot.Plot.remove`""" + self._plot.remove(legend, kind) + + def setInteractiveMode(self, *args, **kwargs): + """ + See :meth:`Plot.Plot.setInteractiveMode` + """ + self._plot.setInteractiveMode(*args, **kwargs) + + @deprecated(replacement="addShape", since_version="0.13") + def addItem(self, *args, **kwargs): + self.addShape(*args, **kwargs) + + def addShape(self, *args, **kwargs): + """ + See :meth:`Plot.Plot.addShape` + """ + self._plot.addShape(*args, **kwargs) + + +class PlanesWidget(qt.QWidget): + """Widget for the plane/perspective selection + + :param parent: the parent QWidget + """ + sigPlaneSelectionChanged = qt.Signal(int) + + def __init__(self, parent): + super(PlanesWidget, self).__init__(parent) + + self.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Minimum) + layout0 = qt.QHBoxLayout() + self.setLayout(layout0) + layout0.setContentsMargins(0, 0, 0, 0) + + layout0.addWidget(qt.QLabel("Axes selection:")) + + # By default, the first dimension (dim0) is the frame index/depth/z, + # the second dimension is the image row number/y axis + # and the third dimension is the image column index/x axis + + # 1 + # | 0 + # |/__2 + self.qcbAxisSelection = qt.QComboBox(self) + self._setCBChoices(first_stack_dimension=0) + self.qcbAxisSelection.currentIndexChanged[int].connect( + self.__planeSelectionChanged) + + layout0.addWidget(self.qcbAxisSelection) + + def __planeSelectionChanged(self, idx): + """Callback function when the combobox selection changes + + idx is the dimension number orthogonal to the slice plane, + following the convention: + + - slice plane Dim1-Dim2: perspective 0 + - slice plane Dim0-Dim2: perspective 1 + - slice plane Dim0-Dim1: perspective 2 + """ + self.sigPlaneSelectionChanged.emit(idx) + + def _setCBChoices(self, first_stack_dimension): + self.qcbAxisSelection.clear() + + dim1dim2 = 'Dim%d-Dim%d' % (first_stack_dimension + 1, + first_stack_dimension + 2) + dim0dim2 = 'Dim%d-Dim%d' % (first_stack_dimension, + first_stack_dimension + 2) + dim0dim1 = 'Dim%d-Dim%d' % (first_stack_dimension, + first_stack_dimension + 1) + + self.qcbAxisSelection.addItem(icons.getQIcon("cube-front"), dim1dim2) + self.qcbAxisSelection.addItem(icons.getQIcon("cube-bottom"), dim0dim2) + self.qcbAxisSelection.addItem(icons.getQIcon("cube-left"), dim0dim1) + + def setFirstStackDimension(self, first_stack_dim): + """When viewing the last 3 dimensions of an n-D array (n>3), you can + use this method to change the text in the combobox. + + For instance, for a 7-D array, first stack dim is 4, so the default + "Dim1-Dim2" text should be replaced with "Dim5-Dim6" (dimensions + numbers are 0-based). + + :param int first_stack_dim: First stack dimension (n-3) when viewing the + last 3 dimensions of an n-D array. + """ + self._setCBChoices(first_stack_dim) + + def setPerspective(self, perspective): + """Update the combobox selection. + + - slice plane Dim1-Dim2: perspective 0 + - slice plane Dim0-Dim2: perspective 1 + - slice plane Dim0-Dim1: perspective 2 + + :param perspective: Orthogonal dimension number (0, 1, or 2) + """ + self.qcbAxisSelection.setCurrentIndex(perspective) + + +class StackViewMainWindow(StackView): + """This class is a :class:`StackView` with a menu, an additional toolbar + to set the plot limits, and a status bar to display the value and 3D + index of the data samples hovered by the mouse cursor. + + :param QWidget parent: Parent widget, or None + """ + def __init__(self, parent=None): + self._dataInfo = None + super(StackViewMainWindow, self).__init__(parent) + self.setWindowFlags(qt.Qt.Window) + + # Add toolbars and status bar + self.addToolBar(qt.Qt.BottomToolBarArea, + LimitsToolBar(plot=self._plot)) + + self.statusBar() + + menu = self.menuBar().addMenu('File') + menu.addAction(self._plot.getOutputToolBar().getSaveAction()) + menu.addAction(self._plot.getOutputToolBar().getPrintAction()) + menu.addSeparator() + action = menu.addAction('Quit') + action.triggered[bool].connect(qt.QApplication.instance().quit) + + menu = self.menuBar().addMenu('Edit') + menu.addAction(self._plot.getOutputToolBar().getCopyAction()) + menu.addSeparator() + menu.addAction(self._plot.getResetZoomAction()) + menu.addAction(self._plot.getColormapAction()) + menu.addAction(self.getColorBarAction()) + + menu.addAction(actions.control.KeepAspectRatioAction(self._plot, self)) + menu.addAction(actions.control.YAxisInvertedAction(self._plot, self)) + + menu = self.menuBar().addMenu('Profile') + profileToolBar = self._profileToolBar + menu.addAction(profileToolBar.hLineAction) + menu.addAction(profileToolBar.vLineAction) + menu.addAction(profileToolBar.lineAction) + menu.addAction(profileToolBar.crossAction) + menu.addSeparator() + menu.addAction(profileToolBar._editor) + menu.addSeparator() + menu.addAction(profileToolBar.clearAction) + + # Connect to StackView's signal + self.valueChanged.connect(self._statusBarSlot) + + def _statusBarSlot(self, x, y, value): + """Update status bar with coordinates/value from plots.""" + # todo (after implementing calibration): + # - use floats for (x, y, z) + # - display both indices (dim0, dim1, dim2) and (x, y, z) + msg = "Cursor out of range" + if x is not None and y is not None: + img_idx = self._browser.value() + + if self._perspective == 0: + dim0, dim1, dim2 = img_idx, int(y), int(x) + elif self._perspective == 1: + dim0, dim1, dim2 = int(y), img_idx, int(x) + elif self._perspective == 2: + dim0, dim1, dim2 = int(y), int(x), img_idx + + msg = 'Position: (%d, %d, %d)' % (dim0, dim1, dim2) + if value is not None: + msg += ', Value: %g' % value + if self._dataInfo is not None: + msg = self._dataInfo + ', ' + msg + + self.statusBar().showMessage(msg) + + def setStack(self, stack, *args, **kwargs): + """Set the displayed stack. + + See :meth:`StackView.setStack` for details. + """ + if hasattr(stack, 'dtype') and hasattr(stack, 'shape'): + assert len(stack.shape) == 3 + nframes, height, width = stack.shape + self._dataInfo = 'Data: %dx%dx%d (%s)' % (nframes, height, width, + str(stack.dtype)) + self.statusBar().showMessage(self._dataInfo) + else: + self._dataInfo = None + + # Set the new stack in StackView widget + super(StackViewMainWindow, self).setStack(stack, *args, **kwargs) + self.setStatusBar(None) |