summaryrefslogtreecommitdiff
path: root/src/silx/gui/plot/_BaseMaskToolsWidget.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/silx/gui/plot/_BaseMaskToolsWidget.py')
-rw-r--r--src/silx/gui/plot/_BaseMaskToolsWidget.py1311
1 files changed, 1311 insertions, 0 deletions
diff --git a/src/silx/gui/plot/_BaseMaskToolsWidget.py b/src/silx/gui/plot/_BaseMaskToolsWidget.py
new file mode 100644
index 0000000..6b98289
--- /dev/null
+++ b/src/silx/gui/plot/_BaseMaskToolsWidget.py
@@ -0,0 +1,1311 @@
+# /*##########################################################################
+#
+# Copyright (c) 2017-2022 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 is a collection of base classes used in modules
+:mod:`.MaskToolsWidget` (images) and :mod:`.ScatterMaskToolsWidget`
+"""
+
+__authors__ = ["T. Vincent", "P. Knobel"]
+__license__ = "MIT"
+__date__ = "08/12/2020"
+
+import os
+import weakref
+
+import numpy
+
+from silx.gui import qt, icons
+from silx.gui.widgets.FloatEdit import FloatEdit
+from silx.gui.colors import Colormap
+from silx.gui.colors import rgba
+from .actions.mode import PanModeAction
+
+
+class BaseMask(qt.QObject):
+ """Base class for :class:`ImageMask` and :class:`ScatterMask`
+
+ A mask field with update operations.
+
+ A mask is an array of the same shape as some underlying data. The mask
+ array stores integer values in the range 0-255, to allow for 254 levels
+ of mask (value 0 is reserved for unmasked data).
+
+ The mask is updated using spatial selection methods: data located inside
+ a selected area is masked with a specified mask level.
+
+ """
+
+ sigChanged = qt.Signal()
+ """Signal emitted when the mask has changed"""
+
+ sigStateChanged = qt.Signal()
+ """Signal emitted for each mask commit/undo/redo operation"""
+
+ sigUndoable = qt.Signal(bool)
+ """Signal emitted when undo becomes possible/impossible"""
+
+ sigRedoable = qt.Signal(bool)
+ """Signal emitted when redo becomes possible/impossible"""
+
+ def __init__(self, dataItem=None):
+ self.historyDepth = 10
+ """Maximum number of operation stored in history list for undo"""
+ # Init lists for undo/redo
+ self._history = []
+ self._redo = []
+
+ # Store the mask
+ self._mask = numpy.array((), dtype=numpy.uint8)
+
+ # Store the plot item to be masked
+ self._dataItem = None
+ if dataItem is not None:
+ self.setDataItem(dataItem)
+ self.reset(self.getDataValues().shape)
+ super(BaseMask, self).__init__()
+
+ def setDataItem(self, item):
+ """Set a data item
+
+ :param item: A plot item, subclass of :class:`silx.gui.plot.items.Item`
+ :return:
+ """
+ self._dataItem = item
+
+ def getDataItem(self):
+ """Returns current plot item the mask is on.
+
+ :rtype: Union[~silx.gui.plot.items.Item,None]
+ """
+ return self._dataItem
+
+ def getDataValues(self):
+ """Return data values, as a numpy array with the same shape
+ as the mask.
+
+ This method must be implemented in a subclass, as the way of
+ accessing data depends on the data item passed to :meth:`setDataItem`
+
+ :return: Data values associated with the data item.
+ :rtype: numpy.ndarray
+ """
+ raise NotImplementedError("To be implemented in subclass")
+
+ def _notify(self):
+ """Notify of mask change."""
+ self.sigChanged.emit()
+
+ def getMask(self, copy=True):
+ """Get the current mask as a numpy array.
+
+ :param bool copy: True (default) to get a copy of the mask.
+ If False, the returned array MUST not be modified.
+ :return: The array of the mask with dimension of the data to be masked.
+ :rtype: numpy.ndarray of uint8
+ """
+ return numpy.array(self._mask, copy=copy)
+
+ def setMask(self, mask, copy=True):
+ """Set the mask to a new array.
+
+ :param numpy.ndarray mask: The array to use for the mask.
+ :type mask: numpy.ndarray of uint8, C-contiguous.
+ Array of other types are converted.
+ :param bool copy: True (the default) to copy the array,
+ False to use it as is if possible.
+ """
+ self._mask = numpy.array(mask, copy=copy, order="C", dtype=numpy.uint8)
+ self._notify()
+
+ # History control
+ def resetHistory(self):
+ """Reset history"""
+ self._history = [numpy.array(self._mask, copy=True)]
+ self._redo = []
+ self.sigUndoable.emit(False)
+ self.sigRedoable.emit(False)
+
+ def commit(self):
+ """Append the current mask to history if changed"""
+ if (
+ not self._history
+ or self._redo
+ or not numpy.array_equal(self._mask, self._history[-1])
+ ):
+ if self._redo:
+ self._redo = [] # Reset redo as a new action as been performed
+ self.sigRedoable[bool].emit(False)
+
+ while len(self._history) >= self.historyDepth:
+ self._history.pop(0)
+ self._history.append(numpy.array(self._mask, copy=True))
+
+ if len(self._history) == 2:
+ self.sigUndoable.emit(True)
+ self.sigStateChanged.emit()
+
+ def undo(self):
+ """Restore previous mask if any"""
+ if len(self._history) > 1:
+ self._redo.append(self._history.pop())
+ self._mask = numpy.array(self._history[-1], copy=True)
+ self._notify() # Do not store this change in history
+
+ if len(self._redo) == 1: # First redo
+ self.sigRedoable.emit(True)
+ if len(self._history) == 1: # Last value in history
+ self.sigUndoable.emit(False)
+ self.sigStateChanged.emit()
+
+ def redo(self):
+ """Restore previously undone modification if any"""
+ if self._redo:
+ self._mask = self._redo.pop()
+ self._history.append(numpy.array(self._mask, copy=True))
+ self._notify()
+
+ if not self._redo: # No more redo
+ self.sigRedoable.emit(False)
+ if len(self._history) == 2: # Something to undo
+ self.sigUndoable.emit(True)
+ self.sigStateChanged.emit()
+
+ # Whole mask operations
+
+ def clear(self, level):
+ """Set all values of the given mask level to 0.
+
+ :param int level: Value of the mask to set to 0.
+ """
+ assert 0 < level < 256
+ self._mask[self._mask == level] = 0
+ self._notify()
+
+ def invert(self, level):
+ """Invert mask of the given mask level.
+
+ 0 values become level and level values become 0.
+
+ :param int level: The level to invert.
+ """
+ assert 0 < level < 256
+ masked = self._mask == level
+ self._mask[self._mask == 0] = level
+ self._mask[masked] = 0
+ self._notify()
+
+ def reset(self, shape=None):
+ """Reset the mask to zero and change its shape.
+
+ :param shape: Shape of the new mask with the correct dimensionality
+ with regards to the data dimensionality,
+ or None to have an empty mask
+ :type shape: tuple of int
+ """
+ if shape is None:
+ # assume dimensionality never changes
+ shape = (0,) * len(self._mask.shape) # empty array
+ shapeChanged = shape != self._mask.shape
+ self._mask = numpy.zeros(shape, dtype=numpy.uint8)
+ if shapeChanged:
+ self.resetHistory()
+
+ self._notify()
+
+ # To be implemented
+ def save(self, filename, kind):
+ """Save current mask in a file
+
+ :param str filename: The file where to save to mask
+ :param str kind: The kind of file to save (e.g 'npy')
+ :raise Exception: Raised if the file writing fail
+ """
+ raise NotImplementedError("To be implemented in subclass")
+
+ # update thresholds
+ def updateStencil(self, level, stencil, mask=True):
+ """Mask/Unmask points from boolean mask: all elements that are True
+ in the boolean mask are set to ``level`` (if ``mask=True``) or 0
+ (if ``mask=False``)
+
+ :param int level: Mask level to update.
+ :param stencil: Boolean mask.
+ :type stencil: numpy.array of same dimension as the mask
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ if mask:
+ self._mask[stencil] = level
+ else:
+ self._mask[numpy.logical_and(self._mask == level, stencil)] = 0
+ self._notify()
+
+ def updateBelowThreshold(self, level, threshold, mask=True):
+ """Mask/unmask all points whose values are below a threshold.
+
+ :param int level:
+ :param float threshold: Threshold
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ self.updateStencil(level, self.getDataValues() < threshold, mask)
+
+ def updateBetweenThresholds(self, level, min_, max_, mask=True):
+ """Mask/unmask all points whose values are in a range.
+
+ :param int level:
+ :param float min_: Lower threshold
+ :param float max_: Upper threshold
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ stencil = numpy.logical_and(
+ min_ <= self.getDataValues(), self.getDataValues() <= max_
+ )
+ self.updateStencil(level, stencil, mask)
+
+ def updateAboveThreshold(self, level, threshold, mask=True):
+ """Mask/unmask all points whose values are above a threshold.
+
+ :param int level: Mask level to update.
+ :param float threshold: Threshold.
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ self.updateStencil(level, self.getDataValues() > threshold, mask)
+
+ def updateNotFinite(self, level, mask=True):
+ """Mask/unmask all points whose values are not finite.
+
+ :param int level: Mask level to update.
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ self.updateStencil(
+ level, numpy.logical_not(numpy.isfinite(self.getDataValues())), mask
+ )
+
+ # Drawing operations:
+ def updateRectangle(self, level, row, col, height, width, mask=True):
+ """Mask/Unmask data inside a rectangle, with the given mask level.
+
+ :param int level: Mask level to update, in range 1-255.
+ :param row: Starting row/y of the rectangle
+ :param col: Starting column/x of the rectangle
+ :param height:
+ :param width:
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ raise NotImplementedError("To be implemented in subclass")
+
+ def updatePolygon(self, level, vertices, mask=True):
+ """Mask/Unmask data inside a polygon, with the given mask level.
+
+ :param int level: Mask level to update.
+ :param vertices: Nx2 array of polygon corners as (row, col) / (y, x)
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ raise NotImplementedError("To be implemented in subclass")
+
+ def updatePoints(self, level, rows, cols, mask=True):
+ """Mask/Unmask points with given coordinates.
+
+ :param int level: Mask level to update.
+ :param rows: Rows/ordinates (y) of selected points
+ :type rows: 1D numpy.ndarray
+ :param cols: Columns/abscissa (x) of selected points
+ :type cols: 1D numpy.ndarray
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ raise NotImplementedError("To be implemented in subclass")
+
+ def updateDisk(self, level, crow, ccol, radius, mask=True):
+ """Mask/Unmask data located inside a dick of the given mask level.
+
+ :param int level: Mask level to update.
+ :param crow: Disk center row/ordinate (y).
+ :param ccol: Disk center column/abscissa.
+ :param float radius: Radius of the disk in mask array unit
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ raise NotImplementedError("To be implemented in subclass")
+
+ def updateEllipse(self, level, crow, ccol, radius_r, radius_c, mask=True):
+ """Mask/Unmask a disk of the given mask level.
+
+ :param int level: Mask level to update.
+ :param int crow: Row of the center of the ellipse
+ :param int ccol: Column of the center of the ellipse
+ :param float radius_r: Radius of the ellipse in the row
+ :param float radius_c: Radius of the ellipse in the column
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ raise NotImplementedError("To be implemented in subclass")
+
+ def updateLine(self, level, row0, col0, row1, col1, width, mask=True):
+ """Mask/Unmask a line of the given mask level.
+
+ :param int level: Mask level to update.
+ :param row0: Row/y of the starting point.
+ :param col0: Column/x of the starting point.
+ :param row1: Row/y of the end point.
+ :param col1: Column/x of the end point.
+ :param width: Width of the line in mask array unit.
+ :param bool mask: True to mask (default), False to unmask.
+ """
+ raise NotImplementedError("To be implemented in subclass")
+
+
+class BaseMaskToolsWidget(qt.QWidget):
+ """Base class for :class:`MaskToolsWidget` (image mask) and
+ :class:`scatterMaskToolsWidget`"""
+
+ sigMaskChanged = qt.Signal()
+ _maxLevelNumber = 255
+
+ def __init__(self, parent=None, plot=None, mask=None):
+ """
+
+ :param parent: Parent QWidget
+ :param plot: Plot widget on which to operate
+ :param mask: Instance of subclass of :class:`BaseMask`
+ (e.g. :class:`ImageMask`)
+ """
+ super(BaseMaskToolsWidget, self).__init__(parent)
+ # register if the user as force a color for the corresponding mask level
+ self._defaultColors = numpy.ones((self._maxLevelNumber + 1), dtype=bool)
+ # overlays colors set by the user
+ self._overlayColors = numpy.zeros(
+ (self._maxLevelNumber + 1, 3), dtype=numpy.float32
+ )
+
+ # as parent have to be the first argument of the widget to fit
+ # QtDesigner need but here plot can't be None by default.
+ assert plot is not None
+ self._plotRef = weakref.ref(plot)
+ self._maskName = "__MASK_TOOLS_%d" % id(self) # Legend of the mask
+
+ self._colormap = Colormap(
+ normalization="linear", vmin=0, vmax=self._maxLevelNumber
+ )
+ self._defaultOverlayColor = rgba("gray") # Color of the mask
+ self._setMaskColors(1, 0.5) # Set the colormap LUT
+
+ if not isinstance(mask, BaseMask):
+ raise TypeError("mask is not an instance of BaseMask")
+ self._mask = mask
+
+ self._mask.sigChanged.connect(self._updatePlotMask)
+ self._mask.sigChanged.connect(self._emitSigMaskChanged)
+
+ self._drawingMode = None # Store current drawing mode
+ self._lastPencilPos = None
+ self._multipleMasks = "exclusive"
+
+ self._maskFileDir = qt.QDir.current().absolutePath()
+ self.plot.sigInteractiveModeChanged.connect(self._interactiveModeChanged)
+
+ self._initWidgets()
+
+ def _emitSigMaskChanged(self):
+ """Notify mask changes"""
+ self.sigMaskChanged.emit()
+
+ def getMaskedItem(self):
+ """Returns the item that is currently being masked
+
+ :rtype: Union[~silx.gui.plot.items.Item,None]
+ """
+ return self._mask.getDataItem()
+
+ def getSelectionMask(self, copy=True):
+ """Get the current mask as a numpy array.
+
+ :param bool copy: True (default) to get a copy of the mask.
+ If False, the returned array MUST not be modified.
+ :return: The mask (as an array of uint8) with dimension of
+ the 'active' plot item.
+ If there is no active image or scatter, it returns None.
+ :rtype: Union[numpy.ndarray,None]
+ """
+ mask = self._mask.getMask(copy=copy)
+ return None if mask.size == 0 else mask
+
+ def setSelectionMask(self, mask):
+ """Set the mask: Must be implemented in subclass"""
+ raise NotImplementedError()
+
+ def resetSelectionMask(self):
+ """Reset the mask: Must be implemented in subclass"""
+ raise NotImplementedError()
+
+ def multipleMasks(self):
+ """Return the current mode of multiple masks support.
+
+ See :meth:`setMultipleMasks`
+ """
+ return self._multipleMasks
+
+ def setMultipleMasks(self, mode):
+ """Set the mode of multiple masks support.
+
+ Available modes:
+
+ - 'single': Edit a single level of mask
+ - 'exclusive': Supports to 256 levels of non overlapping masks
+
+ :param str mode: The mode to use
+ """
+ assert mode in ("exclusive", "single")
+ if mode != self._multipleMasks:
+ self._multipleMasks = mode
+ self._levelWidget.setVisible(self._multipleMasks != "single")
+ self._clearAllBtn.setVisible(self._multipleMasks != "single")
+
+ def setMaskFileDirectory(self, path):
+ """Set the default directory to use by load/save GUI tools
+
+ The directory is also updated by the user, if he change the location
+ of the dialog.
+ """
+ self.maskFileDir = path
+
+ def getMaskFileDirectory(self):
+ """Get the default directory used by load/save GUI tools"""
+ return self.maskFileDir
+
+ @property
+ def maskFileDir(self):
+ """The directory from which to load/save mask from/to files."""
+ if not os.path.isdir(self._maskFileDir):
+ self._maskFileDir = qt.QDir.current().absolutePath()
+ return self._maskFileDir
+
+ @maskFileDir.setter
+ def maskFileDir(self, maskFileDir):
+ self._maskFileDir = str(maskFileDir)
+
+ @property
+ def plot(self):
+ """The :class:`.PlotWindow` this widget is attached to."""
+ plot = self._plotRef()
+ if plot is None:
+ raise RuntimeError(
+ "Mask widget attached to a PlotWidget that no longer exists"
+ )
+ return plot
+
+ def setDirection(self, direction=qt.QBoxLayout.LeftToRight):
+ """Set the direction of the layout of the widget
+
+ :param direction: QBoxLayout direction
+ """
+ self.layout().setDirection(direction)
+
+ def _initWidgets(self):
+ """Create widgets"""
+ layout = qt.QBoxLayout(qt.QBoxLayout.LeftToRight)
+ layout.addWidget(self._initMaskGroupBox())
+ layout.addWidget(self._initDrawGroupBox())
+ layout.addWidget(self._initThresholdGroupBox())
+ layout.addWidget(self._initOtherToolsGroupBox())
+ layout.addStretch(1)
+ self.setLayout(layout)
+
+ @staticmethod
+ def _hboxWidget(*widgets, **kwargs):
+ """Place widgets in widget with horizontal layout
+
+ :param widgets: Widgets to position horizontally
+ :param bool stretch: True for trailing stretch (default),
+ False for no trailing stretch
+ :return: A QWidget with a QHBoxLayout
+ """
+ stretch = kwargs.get("stretch", True)
+
+ layout = qt.QHBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ for widget in widgets:
+ layout.addWidget(widget)
+ if stretch:
+ layout.addStretch(1)
+ widget = qt.QWidget()
+ widget.setLayout(layout)
+ return widget
+
+ def _initTransparencyWidget(self):
+ """Init the mask transparency widget"""
+ transparencyWidget = qt.QWidget(parent=self)
+ grid = qt.QGridLayout()
+ grid.setContentsMargins(0, 0, 0, 0)
+ self.transparencySlider = qt.QSlider(
+ qt.Qt.Horizontal, parent=transparencyWidget
+ )
+ self.transparencySlider.setRange(3, 10)
+ self.transparencySlider.setValue(8)
+ self.transparencySlider.setToolTip("Set the transparency of the mask display")
+ self.transparencySlider.valueChanged.connect(self._updateColors)
+ grid.addWidget(qt.QLabel("Display:", parent=transparencyWidget), 0, 0)
+ grid.addWidget(self.transparencySlider, 0, 1, 1, 3)
+ grid.addWidget(
+ qt.QLabel("<small><b>Transparent</b></small>", parent=transparencyWidget),
+ 1,
+ 1,
+ )
+ grid.addWidget(
+ qt.QLabel("<small><b>Opaque</b></small>", parent=transparencyWidget), 1, 3
+ )
+ transparencyWidget.setLayout(grid)
+ return transparencyWidget
+
+ def _initMaskGroupBox(self):
+ """Init general mask operation widgets"""
+
+ # Mask level
+ self.levelSpinBox = qt.QSpinBox()
+ self.levelSpinBox.setRange(1, self._maxLevelNumber)
+ self.levelSpinBox.setToolTip(
+ "Choose which mask level is edited.\n"
+ "A mask can have up to 255 non-overlapping levels."
+ )
+ self.levelSpinBox.valueChanged[int].connect(self._updateColors)
+ self._levelWidget = self._hboxWidget(
+ qt.QLabel("Mask level:"), self.levelSpinBox
+ )
+ # Transparency
+ self._transparencyWidget = self._initTransparencyWidget()
+
+ style = qt.QApplication.style()
+
+ def getIcon(*identifiyers):
+ for i in identifiyers:
+ if isinstance(i, str):
+ if qt.QIcon.hasThemeIcon(i):
+ return qt.QIcon.fromTheme(i)
+ elif isinstance(i, qt.QIcon):
+ return i
+ else:
+ return style.standardIcon(i)
+ return qt.QIcon()
+
+ undoAction = qt.QAction(self)
+ undoAction.setText("Undo")
+ icon = getIcon("edit-undo", qt.QStyle.SP_ArrowBack)
+ undoAction.setIcon(icon)
+ undoAction.setShortcut(qt.QKeySequence.Undo)
+ undoAction.setToolTip(
+ "Undo last mask change <b>%s</b>" % undoAction.shortcut().toString()
+ )
+ self._mask.sigUndoable.connect(undoAction.setEnabled)
+ undoAction.triggered.connect(self._mask.undo)
+
+ redoAction = qt.QAction(self)
+ redoAction.setText("Redo")
+ icon = getIcon("edit-redo", qt.QStyle.SP_ArrowForward)
+ redoAction.setIcon(icon)
+ redoAction.setShortcut(qt.QKeySequence.Redo)
+ redoAction.setToolTip(
+ "Redo last undone mask change <b>%s</b>" % redoAction.shortcut().toString()
+ )
+ self._mask.sigRedoable.connect(redoAction.setEnabled)
+ redoAction.triggered.connect(self._mask.redo)
+
+ loadAction = qt.QAction(self)
+ loadAction.setText("Load...")
+ icon = icons.getQIcon("document-open")
+ loadAction.setIcon(icon)
+ loadAction.setToolTip("Load mask from file")
+ loadAction.triggered.connect(self._loadMask)
+
+ saveAction = qt.QAction(self)
+ saveAction.setText("Save...")
+ icon = icons.getQIcon("document-save")
+ saveAction.setIcon(icon)
+ saveAction.setToolTip("Save mask to file")
+ saveAction.triggered.connect(self._saveMask)
+
+ invertAction = qt.QAction(self)
+ invertAction.setText("Invert")
+ icon = icons.getQIcon("mask-invert")
+ invertAction.setIcon(icon)
+ invertAction.setShortcut(qt.QKeySequence(qt.Qt.CTRL | qt.Qt.Key_I))
+ invertAction.setToolTip(
+ "Invert current mask <b>%s</b>" % invertAction.shortcut().toString()
+ )
+ invertAction.triggered.connect(self._handleInvertMask)
+
+ clearAction = qt.QAction(self)
+ clearAction.setText("Clear")
+ icon = icons.getQIcon("mask-clear")
+ clearAction.setIcon(icon)
+ clearAction.setShortcut(qt.QKeySequence.Delete)
+ clearAction.setToolTip(
+ "Clear current mask level <b>%s</b>" % clearAction.shortcut().toString()
+ )
+ clearAction.triggered.connect(self._handleClearMask)
+
+ clearAllAction = qt.QAction(self)
+ clearAllAction.setText("Clear all")
+ icon = icons.getQIcon("mask-clear-all")
+ clearAllAction.setIcon(icon)
+ clearAllAction.setToolTip("Clear all mask levels")
+ clearAllAction.triggered.connect(self.resetSelectionMask)
+
+ # Buttons group
+ margin1 = qt.QWidget(self)
+ margin1.setMinimumWidth(6)
+ margin2 = qt.QWidget(self)
+ margin2.setMinimumWidth(6)
+
+ actions = (
+ loadAction,
+ saveAction,
+ margin1,
+ undoAction,
+ redoAction,
+ margin2,
+ invertAction,
+ clearAction,
+ clearAllAction,
+ )
+ widgets = []
+ for action in actions:
+ if isinstance(action, qt.QWidget):
+ widgets.append(action)
+ continue
+ btn = qt.QToolButton()
+ btn.setDefaultAction(action)
+ widgets.append(btn)
+ if action is clearAllAction:
+ self._clearAllBtn = btn
+ container = self._hboxWidget(*widgets)
+ container.layout().setSpacing(1)
+
+ layout = qt.QVBoxLayout()
+ layout.addWidget(container)
+ layout.addWidget(self._levelWidget)
+ layout.addWidget(self._transparencyWidget)
+ layout.addStretch(1)
+
+ maskGroup = qt.QGroupBox("Mask")
+ maskGroup.setLayout(layout)
+ return maskGroup
+
+ def isMaskInteractionActivated(self):
+ """Returns true if any mask interaction is activated"""
+ return self.drawActionGroup.checkedAction() is not None
+
+ def _initDrawGroupBox(self):
+ """Init drawing tools widgets"""
+ layout = qt.QVBoxLayout()
+
+ self.browseAction = PanModeAction(self.plot, self.plot)
+ self.addAction(self.browseAction)
+
+ # Draw tools
+ self.rectAction = qt.QAction(
+ icons.getQIcon("shape-rectangle"), "Rectangle selection", self
+ )
+ self.rectAction.setToolTip(
+ "Rectangle selection tool: (Un)Mask a rectangular region <b>R</b>"
+ )
+ self.rectAction.setShortcut(qt.QKeySequence(qt.Qt.Key_R))
+ self.rectAction.setCheckable(True)
+ self.rectAction.triggered.connect(self._activeRectMode)
+ self.addAction(self.rectAction)
+
+ self.ellipseAction = qt.QAction(
+ icons.getQIcon("shape-ellipse"), "Circle selection", self
+ )
+ self.ellipseAction.setToolTip(
+ "Rectangle selection tool: (Un)Mask a circle region <b>R</b>"
+ )
+ self.ellipseAction.setShortcut(qt.QKeySequence(qt.Qt.Key_R))
+ self.ellipseAction.setCheckable(True)
+ self.ellipseAction.triggered.connect(self._activeEllipseMode)
+ self.addAction(self.ellipseAction)
+
+ self.polygonAction = qt.QAction(
+ icons.getQIcon("shape-polygon"), "Polygon selection", self
+ )
+ self.polygonAction.setShortcut(qt.QKeySequence(qt.Qt.Key_S))
+ self.polygonAction.setToolTip(
+ "Polygon selection tool: (Un)Mask a polygonal region <b>S</b><br>"
+ "Left-click to place new polygon corners<br>"
+ "Left-click on first corner to close the polygon"
+ )
+ self.polygonAction.setCheckable(True)
+ self.polygonAction.triggered.connect(self._activePolygonMode)
+ self.addAction(self.polygonAction)
+
+ self.pencilAction = qt.QAction(
+ icons.getQIcon("draw-pencil"), "Pencil tool", self
+ )
+ self.pencilAction.setShortcut(qt.QKeySequence(qt.Qt.Key_P))
+ self.pencilAction.setToolTip("Pencil tool: (Un)Mask using a pencil <b>P</b>")
+ self.pencilAction.setCheckable(True)
+ self.pencilAction.triggered.connect(self._activePencilMode)
+ self.addAction(self.pencilAction)
+
+ self.drawActionGroup = qt.QActionGroup(self)
+ self.drawActionGroup.setExclusive(True)
+ self.drawActionGroup.addAction(self.rectAction)
+ self.drawActionGroup.addAction(self.ellipseAction)
+ self.drawActionGroup.addAction(self.polygonAction)
+ self.drawActionGroup.addAction(self.pencilAction)
+
+ actions = (
+ self.browseAction,
+ self.rectAction,
+ self.ellipseAction,
+ self.polygonAction,
+ self.pencilAction,
+ )
+ drawButtons = []
+ for action in actions:
+ btn = qt.QToolButton()
+ btn.setDefaultAction(action)
+ drawButtons.append(btn)
+ container = self._hboxWidget(*drawButtons)
+ layout.addWidget(container)
+
+ # Mask/Unmask radio buttons
+ maskRadioBtn = qt.QRadioButton("Mask")
+ maskRadioBtn.setToolTip(
+ "Drawing masks with current level. Press <b>Ctrl</b> to unmask"
+ )
+ maskRadioBtn.setChecked(True)
+
+ unmaskRadioBtn = qt.QRadioButton("Unmask")
+ unmaskRadioBtn.setToolTip(
+ "Drawing unmasks with current level. Press <b>Ctrl</b> to mask"
+ )
+
+ self.maskStateGroup = qt.QButtonGroup()
+ self.maskStateGroup.addButton(maskRadioBtn, 1)
+ self.maskStateGroup.addButton(unmaskRadioBtn, 0)
+
+ self.maskStateWidget = self._hboxWidget(maskRadioBtn, unmaskRadioBtn)
+ layout.addWidget(self.maskStateWidget)
+
+ self.maskStateWidget.setHidden(True)
+
+ # Pencil settings
+ self.pencilSetting = self._createPencilSettings(None)
+ self.pencilSetting.setVisible(False)
+ layout.addWidget(self.pencilSetting)
+
+ layout.addStretch(1)
+
+ drawGroup = qt.QGroupBox("Draw tools")
+ drawGroup.setLayout(layout)
+ return drawGroup
+
+ def _createPencilSettings(self, parent=None):
+ pencilSetting = qt.QWidget(parent)
+
+ self.pencilSpinBox = qt.QSpinBox(parent=pencilSetting)
+ self.pencilSpinBox.setRange(1, 1024)
+ pencilToolTip = """Set pencil drawing tool size in pixels of the image
+ on which to make the mask."""
+ self.pencilSpinBox.setToolTip(pencilToolTip)
+
+ self.pencilSlider = qt.QSlider(qt.Qt.Horizontal, parent=pencilSetting)
+ self.pencilSlider.setRange(1, 50)
+ self.pencilSlider.setToolTip(pencilToolTip)
+
+ pencilLabel = qt.QLabel("Pencil size:", parent=pencilSetting)
+
+ layout = qt.QGridLayout()
+ layout.addWidget(pencilLabel, 0, 0)
+ layout.addWidget(self.pencilSpinBox, 0, 1)
+ layout.addWidget(self.pencilSlider, 1, 1)
+ pencilSetting.setLayout(layout)
+
+ self.pencilSpinBox.valueChanged.connect(self._pencilWidthChanged)
+ self.pencilSlider.valueChanged.connect(self._pencilWidthChanged)
+
+ return pencilSetting
+
+ def _initThresholdGroupBox(self):
+ """Init thresholding widgets"""
+
+ self.belowThresholdAction = qt.QAction(
+ icons.getQIcon("plot-roi-below"), "Mask below threshold", self
+ )
+ self.belowThresholdAction.setToolTip(
+ "Mask image where values are below given threshold"
+ )
+ self.belowThresholdAction.setCheckable(True)
+ self.belowThresholdAction.setChecked(True)
+
+ self.betweenThresholdAction = qt.QAction(
+ icons.getQIcon("plot-roi-between"), "Mask within range", self
+ )
+ self.betweenThresholdAction.setToolTip(
+ "Mask image where values are within given range"
+ )
+ self.betweenThresholdAction.setCheckable(True)
+
+ self.aboveThresholdAction = qt.QAction(
+ icons.getQIcon("plot-roi-above"), "Mask above threshold", self
+ )
+ self.aboveThresholdAction.setToolTip(
+ "Mask image where values are above given threshold"
+ )
+ self.aboveThresholdAction.setCheckable(True)
+
+ self.thresholdActionGroup = qt.QActionGroup(self)
+ self.thresholdActionGroup.setExclusive(True)
+ self.thresholdActionGroup.addAction(self.belowThresholdAction)
+ self.thresholdActionGroup.addAction(self.betweenThresholdAction)
+ self.thresholdActionGroup.addAction(self.aboveThresholdAction)
+ self.thresholdActionGroup.triggered.connect(self._thresholdActionGroupTriggered)
+
+ self.loadColormapRangeAction = qt.QAction(
+ icons.getQIcon("view-refresh"), "Set min-max from colormap", self
+ )
+ self.loadColormapRangeAction.setToolTip(
+ "Set min and max values from current colormap range"
+ )
+ self.loadColormapRangeAction.setCheckable(False)
+ self.loadColormapRangeAction.triggered.connect(
+ self._loadRangeFromColormapTriggered
+ )
+
+ widgets = []
+ for action in self.thresholdActionGroup.actions():
+ btn = qt.QToolButton()
+ btn.setDefaultAction(action)
+ widgets.append(btn)
+
+ spacer = qt.QWidget(parent=self)
+ spacer.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Preferred)
+ widgets.append(spacer)
+
+ loadColormapRangeBtn = qt.QToolButton()
+ loadColormapRangeBtn.setDefaultAction(self.loadColormapRangeAction)
+ widgets.append(loadColormapRangeBtn)
+
+ toolBar = self._hboxWidget(*widgets, stretch=False)
+
+ config = qt.QGridLayout()
+ config.setContentsMargins(0, 0, 0, 0)
+
+ self.minLineLabel = qt.QLabel("Min:", self)
+ self.minLineEdit = FloatEdit(self, value=0)
+ config.addWidget(self.minLineLabel, 0, 0)
+ config.addWidget(self.minLineEdit, 0, 1)
+
+ self.maxLineLabel = qt.QLabel("Max:", self)
+ self.maxLineEdit = FloatEdit(self, value=0)
+ config.addWidget(self.maxLineLabel, 1, 0)
+ config.addWidget(self.maxLineEdit, 1, 1)
+
+ self.applyMaskBtn = qt.QPushButton("Apply mask")
+ self.applyMaskBtn.clicked.connect(self._maskBtnClicked)
+
+ layout = qt.QVBoxLayout()
+ layout.addWidget(toolBar)
+ layout.addLayout(config)
+ layout.addWidget(self.applyMaskBtn)
+ layout.addStretch(1)
+
+ self.thresholdGroup = qt.QGroupBox("Threshold")
+ self.thresholdGroup.setLayout(layout)
+
+ # Init widget state
+ self._thresholdActionGroupTriggered(self.belowThresholdAction)
+ return self.thresholdGroup
+
+ # track widget visibility and plot active image changes
+
+ def _initOtherToolsGroupBox(self):
+ layout = qt.QVBoxLayout()
+
+ self.maskNanBtn = qt.QPushButton("Mask not finite values")
+ self.maskNanBtn.setToolTip("Mask Not a Number and infinite values")
+ self.maskNanBtn.clicked.connect(self._maskNotFiniteBtnClicked)
+ layout.addWidget(self.maskNanBtn)
+ layout.addStretch(1)
+
+ self.otherToolGroup = qt.QGroupBox("Other tools")
+ self.otherToolGroup.setLayout(layout)
+ return self.otherToolGroup
+
+ def changeEvent(self, event):
+ """Reset drawing action when disabling widget"""
+ if (
+ event.type() == qt.QEvent.EnabledChange
+ and not self.isEnabled()
+ and self.drawActionGroup.checkedAction()
+ ):
+ # Disable drawing tool by reseting interaction to pan or zoom
+ self.plot.resetInteractiveMode()
+
+ def save(self, filename, kind):
+ """Save current mask in a file
+
+ :param str filename: The file where to save to mask
+ :param str kind: The kind of file to save in 'edf', 'tif', 'npy'
+ :raise Exception: Raised if the process fails
+ """
+ self._mask.save(filename, kind)
+
+ def getCurrentMaskColor(self):
+ """Returns the color of the current selected level.
+
+ :rtype: A tuple or a python array
+ """
+ currentLevel = self.levelSpinBox.value()
+ if self._defaultColors[currentLevel]:
+ return self._defaultOverlayColor
+ else:
+ return self._overlayColors[currentLevel].tolist()
+
+ def _setMaskColors(self, level, alpha):
+ """Set-up the mask colormap to highlight current mask level.
+
+ :param int level: The mask level to highlight
+ :param float alpha: Alpha level of mask in [0., 1.]
+ """
+ assert 0 < level <= self._maxLevelNumber
+
+ colors = numpy.empty((self._maxLevelNumber + 1, 4), dtype=numpy.float32)
+
+ # Set color
+ colors[:, :3] = self._defaultOverlayColor[:3]
+
+ # check if some colors has been directly set by the user
+ mask = numpy.equal(self._defaultColors, False)
+ colors[mask, :3] = self._overlayColors[mask, :3]
+
+ # Set alpha
+ colors[:, -1] = alpha / 2.0
+
+ # Set highlighted level color
+ colors[level, 3] = alpha
+
+ # Set no mask level
+ colors[0] = (0.0, 0.0, 0.0, 0.0)
+
+ self._colormap.setColormapLUT(colors)
+
+ def resetMaskColors(self, level=None):
+ """Reset the mask color at the given level to be defaultColors
+
+ :param level:
+ The index of the mask for which we want to reset the color.
+ If none we will reset color for all masks.
+ """
+ if level is None:
+ self._defaultColors[level] = True
+ else:
+ self._defaultColors[:] = True
+
+ self._updateColors()
+
+ def setMaskColors(self, rgb, level=None):
+ """Set the masks color
+
+ :param rgb: The rgb color
+ :param level:
+ The index of the mask for which we want to change the color.
+ If none set this color for all the masks
+ """
+ rgb = rgba(rgb)[0:3]
+ if level is None:
+ self._overlayColors[:] = rgb
+ self._defaultColors[:] = False
+ else:
+ self._overlayColors[level] = rgb
+ self._defaultColors[level] = False
+
+ self._updateColors()
+
+ def getMaskColors(self):
+ """masks colors getter"""
+ return self._overlayColors
+
+ def _updateColors(self, *args):
+ """Rebuild mask colormap when selected level or transparency change"""
+ self._setMaskColors(
+ self.levelSpinBox.value(),
+ self.transparencySlider.value() / self.transparencySlider.maximum(),
+ )
+ self._updatePlotMask()
+ self._updateInteractiveMode()
+
+ def _pencilWidthChanged(self, width):
+ old = self.pencilSpinBox.blockSignals(True)
+ try:
+ self.pencilSpinBox.setValue(width)
+ finally:
+ self.pencilSpinBox.blockSignals(old)
+
+ old = self.pencilSlider.blockSignals(True)
+ try:
+ self.pencilSlider.setValue(width)
+ finally:
+ self.pencilSlider.blockSignals(old)
+ self._updateInteractiveMode()
+
+ def _updateInteractiveMode(self):
+ """Update the current mode to the same if some cached data have to be
+ updated. It is the case for the color for example.
+ """
+ if self._drawingMode == "rectangle":
+ self._activeRectMode()
+ elif self._drawingMode == "ellipse":
+ self._activeEllipseMode()
+ elif self._drawingMode == "polygon":
+ self._activePolygonMode()
+ elif self._drawingMode == "pencil":
+ self._activePencilMode()
+
+ def _handleClearMask(self):
+ """Handle clear button clicked: reset current level mask"""
+ self._mask.clear(self.levelSpinBox.value())
+ self._mask.commit()
+
+ def _handleInvertMask(self):
+ """Invert the current mask level selection."""
+ self._mask.invert(self.levelSpinBox.value())
+ self._mask.commit()
+
+ # Handle drawing tools UI events
+
+ def _interactiveModeChanged(self, source):
+ """Handle plot interactive mode changed:
+
+ If changed from elsewhere, disable drawing tool
+ """
+ if source is not self:
+ self.pencilAction.setChecked(False)
+ self.rectAction.setChecked(False)
+ self.polygonAction.setChecked(False)
+ self._releaseDrawingMode()
+ self._updateDrawingModeWidgets()
+
+ def _releaseDrawingMode(self):
+ """Release the drawing mode if is was used"""
+ if self._drawingMode is None:
+ return
+ self.plot.sigPlotSignal.disconnect(self._plotDrawEvent)
+ self._drawingMode = None
+
+ def _activeRectMode(self):
+ """Handle rect action mode triggering"""
+ self._releaseDrawingMode()
+ self._drawingMode = "rectangle"
+ self.plot.sigPlotSignal.connect(self._plotDrawEvent)
+ color = self.getCurrentMaskColor()
+ self.plot.setInteractiveMode(
+ "draw", shape="rectangle", source=self, color=color
+ )
+ self._updateDrawingModeWidgets()
+
+ def _activeEllipseMode(self):
+ """Handle circle action mode triggering"""
+ self._releaseDrawingMode()
+ self._drawingMode = "ellipse"
+ self.plot.sigPlotSignal.connect(self._plotDrawEvent)
+ color = self.getCurrentMaskColor()
+ self.plot.setInteractiveMode("draw", shape="ellipse", source=self, color=color)
+ self._updateDrawingModeWidgets()
+
+ def _activePolygonMode(self):
+ """Handle polygon action mode triggering"""
+ self._releaseDrawingMode()
+ self._drawingMode = "polygon"
+ self.plot.sigPlotSignal.connect(self._plotDrawEvent)
+ color = self.getCurrentMaskColor()
+ self.plot.setInteractiveMode("draw", shape="polygon", source=self, color=color)
+ self._updateDrawingModeWidgets()
+
+ def _getPencilWidth(self):
+ """Returns the width of the pencil to use in data coordinates`
+
+ :rtype: float
+ """
+ return self.pencilSpinBox.value()
+
+ def _activePencilMode(self):
+ """Handle pencil action mode triggering"""
+ self._releaseDrawingMode()
+ self._drawingMode = "pencil"
+ self.plot.sigPlotSignal.connect(self._plotDrawEvent)
+ color = self.getCurrentMaskColor()
+ width = self._getPencilWidth()
+ self.plot.setInteractiveMode(
+ "draw", shape="pencil", source=self, color=color, width=width
+ )
+ self._updateDrawingModeWidgets()
+
+ def _updateDrawingModeWidgets(self):
+ self.maskStateWidget.setVisible(self._drawingMode is not None)
+ self.pencilSetting.setVisible(self._drawingMode == "pencil")
+
+ # Handle plot drawing events
+
+ def _isMasking(self):
+ """Returns true if the tool is used for masking, else it is used for
+ unmasking.
+
+ :rtype: bool"""
+ # First draw event, use current modifiers for all draw sequence
+ doMask = self.maskStateGroup.checkedId() == 1
+ if qt.QApplication.keyboardModifiers() & qt.Qt.ControlModifier:
+ doMask = not doMask
+ return doMask
+
+ # Handle threshold UI events
+
+ def _thresholdActionGroupTriggered(self, triggeredAction):
+ """Threshold action group listener."""
+ if triggeredAction is self.belowThresholdAction:
+ self.minLineLabel.setVisible(True)
+ self.maxLineLabel.setVisible(False)
+ self.minLineEdit.setVisible(True)
+ self.maxLineEdit.setVisible(False)
+ self.applyMaskBtn.setText("Mask below")
+ elif triggeredAction is self.betweenThresholdAction:
+ self.minLineLabel.setVisible(True)
+ self.maxLineLabel.setVisible(True)
+ self.minLineEdit.setVisible(True)
+ self.maxLineEdit.setVisible(True)
+ self.applyMaskBtn.setText("Mask between")
+ elif triggeredAction is self.aboveThresholdAction:
+ self.minLineLabel.setVisible(False)
+ self.maxLineLabel.setVisible(True)
+ self.minLineEdit.setVisible(False)
+ self.maxLineEdit.setVisible(True)
+ self.applyMaskBtn.setText("Mask above")
+ self.applyMaskBtn.setToolTip(triggeredAction.toolTip())
+
+ def _maskBtnClicked(self):
+ if self.belowThresholdAction.isChecked():
+ if self.minLineEdit.text():
+ self._mask.updateBelowThreshold(
+ self.levelSpinBox.value(), self.minLineEdit.value()
+ )
+ self._mask.commit()
+
+ elif self.betweenThresholdAction.isChecked():
+ if self.minLineEdit.text() and self.maxLineEdit.text():
+ min_ = self.minLineEdit.value()
+ max_ = self.maxLineEdit.value()
+ self._mask.updateBetweenThresholds(
+ self.levelSpinBox.value(), min_, max_
+ )
+ self._mask.commit()
+
+ elif self.aboveThresholdAction.isChecked():
+ if self.maxLineEdit.text():
+ max_ = float(self.maxLineEdit.value())
+ self._mask.updateAboveThreshold(self.levelSpinBox.value(), max_)
+ self._mask.commit()
+
+ def _maskNotFiniteBtnClicked(self):
+ """Handle not finite mask button clicked: mask NaNs and inf"""
+ self._mask.updateNotFinite(self.levelSpinBox.value())
+ self._mask.commit()
+
+
+class BaseMaskToolsDockWidget(qt.QDockWidget):
+ """Base class for :class:`MaskToolsWidget` and
+ :class:`ScatterMaskToolsWidget`.
+
+ For integration in a :class:`PlotWindow`.
+
+ :param parent: See :class:`QDockWidget`
+ :paran str name: The title of this widget
+ """
+
+ sigMaskChanged = qt.Signal()
+
+ def __init__(self, parent=None, name="Mask", widget=None):
+ super(BaseMaskToolsDockWidget, self).__init__(parent)
+ self.setWindowTitle(name)
+
+ if not isinstance(widget, BaseMaskToolsWidget):
+ raise TypeError("BaseMaskToolsDockWidget requires a MaskToolsWidget")
+ self.setWidget(widget)
+ self.widget().sigMaskChanged.connect(self._emitSigMaskChanged)
+
+ self.layout().setContentsMargins(0, 0, 0, 0)
+ self.dockLocationChanged.connect(self._dockLocationChanged)
+ self.topLevelChanged.connect(self._topLevelChanged)
+
+ def _emitSigMaskChanged(self):
+ """Notify mask changes"""
+ # must be connected to self.widget().sigMaskChanged in child class
+ self.sigMaskChanged.emit()
+
+ def getSelectionMask(self, copy=True):
+ """Get the current mask as a 2D array.
+
+ :param bool copy: True (default) to get a copy of the mask.
+ If False, the returned array MUST not be modified.
+ :return: The array of the mask with dimension of the 'active' image.
+ If there is no active image, an empty array is returned.
+ :rtype: 2D numpy.ndarray of uint8
+ """
+ return self.widget().getSelectionMask(copy=copy)
+
+ def setSelectionMask(self, mask, copy=True):
+ """Set the mask to a new array.
+
+ :param numpy.ndarray mask: The array to use for the mask.
+ :type mask: numpy.ndarray of uint8 of dimension 2, C-contiguous.
+ Array of other types are converted.
+ :param bool copy: True (the default) to copy the array,
+ False to use it as is if possible.
+ :return: None if failed, shape of mask as 2-tuple if successful.
+ The mask can be cropped or padded to fit active image,
+ the returned shape is that of the active image.
+ """
+ return self.widget().setSelectionMask(mask, copy=copy)
+
+ def resetSelectionMask(self):
+ """Reset the mask to an array of zeros with the shape of the
+ current data."""
+ self.widget().resetSelectionMask()
+
+ def toggleViewAction(self):
+ """Returns a checkable action that shows or closes this widget.
+
+ See :class:`QMainWindow`.
+ """
+ action = super(BaseMaskToolsDockWidget, self).toggleViewAction()
+ action.setIcon(icons.getQIcon("image-mask"))
+ action.setToolTip("Display/hide mask tools")
+ return action
+
+ def _dockLocationChanged(self, area):
+ if area in (qt.Qt.LeftDockWidgetArea, qt.Qt.RightDockWidgetArea):
+ direction = qt.QBoxLayout.TopToBottom
+ else:
+ direction = qt.QBoxLayout.LeftToRight
+ self.widget().setDirection(direction)
+
+ def _topLevelChanged(self, topLevel):
+ if topLevel:
+ self.widget().setDirection(qt.QBoxLayout.LeftToRight)
+ self.resize(self.widget().minimumSize())
+ self.adjustSize()