Source code for silx.gui.plot._BaseMaskToolsWidget
# coding: utf-8
# /*##########################################################################
#
# Copyright (c) 2017 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
"""This module is a collection of base classes used in modules
:mod:`.MaskToolsWidget` (images) and :mod:`.ScatterMaskToolsWidget`
"""
from __future__ import division
__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
__date__ = "02/10/2017"
import os
import numpy
from silx.gui import qt, icons
from silx.gui.widgets.FloatEdit import FloatEdit
from silx.gui.plot.Colormap import Colormap
from silx.gui.plot.Colors import rgba
from .actions.mode import PanModeAction
[docs]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"""
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__()
[docs] 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
[docs] 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()
[docs] 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)
[docs] 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
[docs] def resetHistory(self):
"""Reset history"""
self._history = [numpy.array(self._mask, copy=True)]
self._redo = []
self.sigUndoable.emit(False)
self.sigRedoable.emit(False)
[docs] def commit(self):
"""Append the current mask to history if changed"""
if (not self._history or self._redo or
not numpy.all(numpy.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)
[docs] 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)
[docs] 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)
# Whole mask operations
[docs] 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()
[docs] 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()
[docs] 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
[docs] 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
[docs] 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()
[docs] 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)
[docs] 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)
[docs] 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)
[docs] 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:
[docs] 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")
[docs] 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")
[docs] 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")
[docs] def updateDisk(self, level, crow, ccol, radius, mask=True):
"""Mask/Unmask data located inside a disk 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")
[docs] 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")
[docs]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=numpy.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._plot = plot
self._maskName = '__MASK_TOOLS_%d' % id(self) # Legend of the mask
self._colormap = Colormap(name="",
normalization='linear',
vmin=0,
vmax=self._maxLevelNumber,
colors=None)
self._defaultOverlayColor = rgba('gray') # Color of the mask
self._setMaskColors(1, 0.5)
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.home().absolutePath()
self.plot.sigInteractiveModeChanged.connect(
self._interactiveModeChanged)
self._initWidgets()
def _emitSigMaskChanged(self):
"""Notify mask changes"""
self.sigMaskChanged.emit()
[docs] 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 array of the mask with dimension of the 'active' plot item.
If there is no active image or scatter, an empty array is
returned.
:rtype: numpy.ndarray of uint8
"""
return self._mask.getMask(copy=copy)
[docs] def multipleMasks(self):
"""Return the current mode of multiple masks support.
See :meth:`setMultipleMasks`
"""
return self._multipleMasks
[docs] 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')
@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.home().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."""
return self._plot
[docs] 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.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(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()
# Buttons group
invertBtn = qt.QPushButton('Invert')
invertBtn.setShortcut(qt.Qt.CTRL + qt.Qt.Key_I)
invertBtn.setToolTip('Invert current mask <b>%s</b>' %
invertBtn.shortcut().toString())
invertBtn.clicked.connect(self._handleInvertMask)
clearBtn = qt.QPushButton('Clear')
clearBtn.setShortcut(qt.QKeySequence.Delete)
clearBtn.setToolTip('Clear current mask level <b>%s</b>' %
clearBtn.shortcut().toString())
clearBtn.clicked.connect(self._handleClearMask)
invertClearWidget = self._hboxWidget(
invertBtn, clearBtn, stretch=False)
undoBtn = qt.QPushButton('Undo')
undoBtn.setShortcut(qt.QKeySequence.Undo)
undoBtn.setToolTip('Undo last mask change <b>%s</b>' %
undoBtn.shortcut().toString())
self._mask.sigUndoable.connect(undoBtn.setEnabled)
undoBtn.clicked.connect(self._mask.undo)
redoBtn = qt.QPushButton('Redo')
redoBtn.setShortcut(qt.QKeySequence.Redo)
redoBtn.setToolTip('Redo last undone mask change <b>%s</b>' %
redoBtn.shortcut().toString())
self._mask.sigRedoable.connect(redoBtn.setEnabled)
redoBtn.clicked.connect(self._mask.redo)
undoRedoWidget = self._hboxWidget(undoBtn, redoBtn, stretch=False)
self.clearAllBtn = qt.QPushButton('Clear all')
self.clearAllBtn.setToolTip('Clear all mask levels')
self.clearAllBtn.clicked.connect(self.resetSelectionMask)
loadBtn = qt.QPushButton('Load...')
loadBtn.clicked.connect(self._loadMask)
saveBtn = qt.QPushButton('Save...')
saveBtn.clicked.connect(self._saveMask)
self.loadSaveWidget = self._hboxWidget(loadBtn, saveBtn, stretch=False)
layout = qt.QVBoxLayout()
layout.addWidget(self.levelWidget)
layout.addWidget(self.transparencyWidget)
layout.addWidget(invertClearWidget)
layout.addWidget(undoRedoWidget)
layout.addWidget(self.clearAllBtn)
layout.addWidget(self.loadSaveWidget)
layout.addStretch(1)
maskGroup = qt.QGroupBox('Mask')
maskGroup.setLayout(layout)
return maskGroup
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', None)
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.polygonAction = qt.QAction(
icons.getQIcon('shape-polygon'), 'Polygon selection', None)
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 polygon corners<br>'
'Right-click to place the last corner')
self.polygonAction.setCheckable(True)
self.polygonAction.triggered.connect(self._activePolygonMode)
self.addAction(self.polygonAction)
self.pencilAction = qt.QAction(
icons.getQIcon('draw-pencil'), 'Pencil tool', None)
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.polygonAction)
self.drawActionGroup.addAction(self.pencilAction)
actions = (self.browseAction, self.rectAction,
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"""
layout = qt.QVBoxLayout()
# Thresholing
self.belowThresholdAction = qt.QAction(
icons.getQIcon('plot-roi-below'), 'Mask below threshold', None)
self.belowThresholdAction.setToolTip(
'Mask image where values are below given threshold')
self.belowThresholdAction.setCheckable(True)
self.belowThresholdAction.triggered[bool].connect(
self._belowThresholdActionTriggered)
self.betweenThresholdAction = qt.QAction(
icons.getQIcon('plot-roi-between'), 'Mask within range', None)
self.betweenThresholdAction.setToolTip(
'Mask image where values are within given range')
self.betweenThresholdAction.setCheckable(True)
self.betweenThresholdAction.triggered[bool].connect(
self._betweenThresholdActionTriggered)
self.aboveThresholdAction = qt.QAction(
icons.getQIcon('plot-roi-above'), 'Mask above threshold', None)
self.aboveThresholdAction.setToolTip(
'Mask image where values are above given threshold')
self.aboveThresholdAction.setCheckable(True)
self.aboveThresholdAction.triggered[bool].connect(
self._aboveThresholdActionTriggered)
self.thresholdActionGroup = qt.QActionGroup(self)
self.thresholdActionGroup.setExclusive(False)
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', None)
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()
spacer.setSizePolicy(qt.QSizePolicy.Expanding,
qt.QSizePolicy.Preferred)
widgets.append(spacer)
loadColormapRangeBtn = qt.QToolButton()
loadColormapRangeBtn.setDefaultAction(self.loadColormapRangeAction)
widgets.append(loadColormapRangeBtn)
container = self._hboxWidget(*widgets, stretch=False)
layout.addWidget(container)
form = qt.QFormLayout()
self.minLineEdit = FloatEdit(self, value=0)
self.minLineEdit.setEnabled(False)
form.addRow('Min:', self.minLineEdit)
self.maxLineEdit = FloatEdit(self, value=0)
self.maxLineEdit.setEnabled(False)
form.addRow('Max:', self.maxLineEdit)
self.applyMaskBtn = qt.QPushButton('Apply mask')
self.applyMaskBtn.clicked.connect(self._maskBtnClicked)
self.applyMaskBtn.setEnabled(False)
form.addRow(self.applyMaskBtn)
self.maskNanBtn = qt.QPushButton('Mask not finite values')
self.maskNanBtn.setToolTip('Mask Not a Number and infinite values')
self.maskNanBtn.clicked.connect(self._maskNotFiniteBtnClicked)
form.addRow(self.maskNanBtn)
thresholdWidget = qt.QWidget()
thresholdWidget.setLayout(form)
layout.addWidget(thresholdWidget)
layout.addStretch(1)
self.thresholdGroup = qt.QGroupBox('Threshold')
self.thresholdGroup.setLayout(layout)
return self.thresholdGroup
# track widget visibility and plot active image changes
[docs] 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 setting interaction to zoom
self.browseAction.trigger()
[docs] 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)
[docs] 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.
# Set highlighted level color
colors[level, 3] = alpha
# Set no mask level
colors[0] = (0., 0., 0., 0.)
self._colormap.setColormapLUT(colors)
[docs] 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()
[docs] 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
"""
if level is None:
self._overlayColors[:] = rgb
self._defaultColors[:] = False
else:
self._overlayColors[level] = rgb
self._defaultColors[level] = False
self._updateColors()
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 == '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 _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 _activePencilMode(self):
"""Handle pencil action mode triggering"""
self._releaseDrawingMode()
self._drawingMode = 'pencil'
self.plot.sigPlotSignal.connect(self._plotDrawEvent)
color = self.getCurrentMaskColor()
width = self.pencilSpinBox.value()
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 _belowThresholdActionTriggered(self, triggered):
if triggered:
self.minLineEdit.setEnabled(True)
self.maxLineEdit.setEnabled(False)
self.applyMaskBtn.setEnabled(True)
def _betweenThresholdActionTriggered(self, triggered):
if triggered:
self.minLineEdit.setEnabled(True)
self.maxLineEdit.setEnabled(True)
self.applyMaskBtn.setEnabled(True)
def _aboveThresholdActionTriggered(self, triggered):
if triggered:
self.minLineEdit.setEnabled(False)
self.maxLineEdit.setEnabled(True)
self.applyMaskBtn.setEnabled(True)
def _thresholdActionGroupTriggered(self, triggeredAction):
"""Threshold action group listener."""
if triggeredAction.isChecked():
# Uncheck other actions
for action in self.thresholdActionGroup.actions():
if action is not triggeredAction and action.isChecked():
action.setChecked(False)
else:
# Disable min/max edit
self.minLineEdit.setEnabled(False)
self.maxLineEdit.setEnabled(False)
self.applyMaskBtn.setEnabled(False)
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()
[docs]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()
[docs] 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)
[docs] 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)
[docs] def resetSelectionMask(self):
"""Reset the mask to an array of zeros with the shape of the
current data."""
self.widget().resetSelectionMask()
[docs] 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()
[docs] def showEvent(self, event):
"""Make sure this widget is raised when it is shown
(when it is first created as a tab in PlotWindow or when it is shown
again after hiding).
"""
self.raise_()