# coding: utf-8
# /*##########################################################################
#
# Copyright (c) 2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
"""This module provides ROI interaction for :class:`~silx.gui.plot.PlotWidget`.
"""
__authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "28/06/2018"
import collections
import functools
import logging
import time
import weakref
import numpy
from ....third_party import enum
from ....utils.weakref import WeakMethodProxy
from ... import qt, icons
from .. import PlotWidget
from ..items import roi as roi_items
from ...colors import rgba
logger = logging.getLogger(__name__)
[docs]class RegionOfInterestManager(qt.QObject):
"""Class handling ROI interaction on a PlotWidget.
It supports the multiple ROIs: points, rectangles, polygons,
lines, horizontal and vertical lines.
See ``plotInteractiveImageROI.py`` sample code (:ref:`sample-code`).
:param silx.gui.plot.PlotWidget parent:
The plot widget in which to control the ROIs.
"""
sigRoiAdded = qt.Signal(roi_items.RegionOfInterest)
"""Signal emitted when a new ROI has been added.
It provides the newly add :class:`RegionOfInterest` object.
"""
sigRoiAboutToBeRemoved = qt.Signal(roi_items.RegionOfInterest)
"""Signal emitted just before a ROI is removed.
It provides the :class:`RegionOfInterest` object that is about to be removed.
"""
sigRoiChanged = qt.Signal()
"""Signal emitted whenever the ROIs have changed."""
sigInteractiveModeStarted = qt.Signal(object)
"""Signal emitted when switching to ROI drawing interactive mode.
It provides the class of the ROI which will be created by the interactive
mode.
"""
sigInteractiveModeFinished = qt.Signal()
"""Signal emitted when leaving and interactive ROI drawing.
It provides the list of ROIs.
"""
_MODE_ACTIONS_PARAMS = collections.OrderedDict()
# Interactive mode: (icon name, text)
_MODE_ACTIONS_PARAMS[roi_items.PointROI] = 'add-shape-point', 'Add point markers'
_MODE_ACTIONS_PARAMS[roi_items.RectangleROI] = 'add-shape-rectangle', 'Add rectangle ROI'
_MODE_ACTIONS_PARAMS[roi_items.PolygonROI] = 'add-shape-polygon', 'Add polygon ROI'
_MODE_ACTIONS_PARAMS[roi_items.LineROI] = 'add-shape-diagonal', 'Add line ROI'
_MODE_ACTIONS_PARAMS[roi_items.HorizontalLineROI] = 'add-shape-horizontal', 'Add horizontal line ROI'
_MODE_ACTIONS_PARAMS[roi_items.VerticalLineROI] = 'add-shape-vertical', 'Add vertical line ROI'
_MODE_ACTIONS_PARAMS[roi_items.ArcROI] = 'add-shape-arc', 'Add arc ROI'
def __init__(self, parent):
assert isinstance(parent, PlotWidget)
super(RegionOfInterestManager, self).__init__(parent)
self._rois = [] # List of ROIs
self._drawnROI = None # New ROI being currently drawn
self._roiClass = None
self._color = rgba('red')
self._label = "__RegionOfInterestManager__%d" % id(self)
self._eventLoop = None
self._modeActions = {}
parent.sigInteractiveModeChanged.connect(
self._plotInteractiveModeChanged)
@classmethod
[docs] def getSupportedRoiClasses(cls):
"""Returns the default available ROI classes
:rtype: List[class]
"""
return tuple(cls._MODE_ACTIONS_PARAMS.keys())
# Associated QActions
[docs] def getInteractionModeAction(self, roiClass):
"""Returns the QAction corresponding to a kind of ROI
The QAction allows to enable the corresponding drawing
interactive mode.
:param str roiClass: The ROI class which will be crated by this action.
:rtype: QAction
:raise ValueError: If kind is not supported
"""
if not issubclass(roiClass, roi_items.RegionOfInterest):
raise ValueError('Unsupported ROI class %s' % roiClass)
action = self._modeActions.get(roiClass, None)
if action is None: # Lazy-loading
if roiClass in self._MODE_ACTIONS_PARAMS:
iconName, text = self._MODE_ACTIONS_PARAMS[roiClass]
else:
iconName = "add-shape-unknown"
name = roiClass._getKind()
if name is None:
name = roiClass.__name__
text = 'Add %s' % name
action = qt.QAction(self)
action.setIcon(icons.getQIcon(iconName))
action.setText(text)
action.setCheckable(True)
action.setChecked(self.getCurrentInteractionModeRoiClass() is roiClass)
action.setToolTip(text)
action.triggered[bool].connect(functools.partial(
WeakMethodProxy(self._modeActionTriggered), roiClass=roiClass))
self._modeActions[roiClass] = action
return action
def _modeActionTriggered(self, checked, roiClass):
"""Handle mode actions being checked by the user
:param bool checked:
:param str kind: Corresponding shape kind
"""
if checked:
self.start(roiClass)
else: # Keep action checked
action = self.sender()
action.setChecked(True)
def _updateModeActions(self):
"""Check/Uncheck action corresponding to current mode"""
for roiClass, action in self._modeActions.items():
action.setChecked(roiClass == self.getCurrentInteractionModeRoiClass())
# PlotWidget eventFilter and listeners
def _plotInteractiveModeChanged(self, source):
"""Handle change of interactive mode in the plot"""
if source is not self:
self.__roiInteractiveModeEnded()
else: # Check the corresponding action
self._updateModeActions()
# Handle ROI interaction
def _handleInteraction(self, event):
"""Handle mouse interaction for ROI addition"""
roiClass = self.getCurrentInteractionModeRoiClass()
if roiClass is None:
return # Should not happen
kind = roiClass.getFirstInteractionShape()
if kind == 'point':
if event['event'] == 'mouseClicked' and event['button'] == 'left':
points = numpy.array([(event['x'], event['y'])],
dtype=numpy.float64)
self.createRoi(roiClass, points=points)
else: # other shapes
if (event['event'] in ('drawingProgress', 'drawingFinished') and
event['parameters']['label'] == self._label):
points = numpy.array((event['xdata'], event['ydata']),
dtype=numpy.float64).T
if self._drawnROI is None: # Create new ROI
self._drawnROI = self.createRoi(roiClass, points=points)
else:
self._drawnROI.setFirstShapePoints(points)
if event['event'] == 'drawingFinished':
if kind == 'polygon' and len(points) > 1:
self._drawnROI.setFirstShapePoints(points[:-1])
self._drawnROI = None # Stop drawing
# RegionOfInterest API
[docs] def getRois(self):
"""Returns the list of ROIs.
It returns an empty tuple if there is currently no ROI.
:return: Tuple of arrays of objects describing the ROIs
:rtype: List[RegionOfInterest]
"""
return tuple(self._rois)
[docs] def clear(self):
"""Reset current ROIs
:return: True if ROIs were reset.
:rtype: bool
"""
if self.getRois(): # Something to reset
for roi in self._rois:
roi.sigRegionChanged.disconnect(
self._regionOfInterestChanged)
roi.setParent(None)
self._rois = []
self._roisUpdated()
return True
else:
return False
def _regionOfInterestChanged(self):
"""Handle ROI object changed"""
self.sigRoiChanged.emit()
[docs] def createRoi(self, roiClass, points, label='', index=None):
"""Create a new ROI and add it to list of ROIs.
:param class roiClass: The class of the ROI to create
:param numpy.ndarray points: The first shape used to create the ROI
:param str label: The label to display along with the ROI.
:param int index: The position where to insert the ROI.
By default it is appended to the end of the list.
:return: The created ROI object
:rtype: roi_items.RegionOfInterest
:raise RuntimeError: When ROI cannot be added because the maximum
number of ROIs has been reached.
"""
roi = roiClass(parent=None)
roi.setLabel(str(label))
roi.setFirstShapePoints(points)
self.addRoi(roi, index)
return roi
[docs] def addRoi(self, roi, index=None, useManagerColor=True):
"""Add the ROI to the list of ROIs.
:param roi_items.RegionOfInterest roi: The ROI to add
:param int index: The position where to insert the ROI,
By default it is appended to the end of the list of ROIs
:raise RuntimeError: When ROI cannot be added because the maximum
number of ROIs has been reached.
"""
plot = self.parent()
if plot is None:
raise RuntimeError(
'Cannot add ROI: PlotWidget no more available')
roi.setParent(self)
if useManagerColor:
roi.setColor(self.getColor())
roi.sigRegionChanged.connect(self._regionOfInterestChanged)
if index is None:
self._rois.append(roi)
else:
self._rois.insert(index, roi)
self.sigRoiAdded.emit(roi)
self._roisUpdated()
[docs] def removeRoi(self, roi):
"""Remove a ROI from the list of ROIs.
:param roi_items.RegionOfInterest roi: The ROI to remove
:raise ValueError: When ROI does not belong to this object
"""
if not (isinstance(roi, roi_items.RegionOfInterest) and
roi.parent() is self and
roi in self._rois):
raise ValueError(
'RegionOfInterest does not belong to this instance')
self.sigRoiAboutToBeRemoved.emit(roi)
self._rois.remove(roi)
roi.sigRegionChanged.disconnect(self._regionOfInterestChanged)
roi.setParent(None)
self._roisUpdated()
def _roisUpdated(self):
"""Handle update of the ROI list"""
self.sigRoiChanged.emit()
# RegionOfInterest parameters
[docs] def getColor(self):
"""Return the default color of created ROIs
:rtype: QColor
"""
return qt.QColor.fromRgbF(*self._color)
[docs] def setColor(self, color):
"""Set the default color to use when creating ROIs.
Existing ROIs are not affected.
:param color: The color to use for displaying ROIs as
either a color name, a QColor, a list of uint8 or float in [0, 1].
"""
self._color = rgba(color)
# Control ROI
[docs] def getCurrentInteractionModeRoiClass(self):
"""Returns the current ROI class used by the interactive drawing mode.
Returns None if the ROI manager is not in an interactive mode.
:rtype: Union[class,None]
"""
return self._roiClass
[docs] def isStarted(self):
"""Returns True if an interactive ROI drawing mode is active.
:rtype: bool
"""
return self._roiClass is not None
[docs] def start(self, roiClass):
"""Start an interactive ROI drawing mode.
:param class roiClass: The ROI class to create. It have to inherite from
`roi_items.RegionOfInterest`.
:return: True if interactive ROI drawing was started, False otherwise
:rtype: bool
:raise ValueError: If roiClass is not supported
"""
self.stop()
if not issubclass(roiClass, roi_items.RegionOfInterest):
raise ValueError('Unsupported ROI class %s' % roiClass)
plot = self.parent()
if plot is None:
return False
self._roiClass = roiClass
firstInteractionShapeKind = roiClass.getFirstInteractionShape()
if firstInteractionShapeKind == 'point':
plot.setInteractiveMode(mode='select', source=self)
else:
if roiClass.showFirstInteractionShape():
color = rgba(self.getColor())
else:
color = None
plot.setInteractiveMode(mode='select-draw',
source=self,
shape=firstInteractionShapeKind,
color=color,
label=self._label)
plot.sigPlotSignal.connect(self._handleInteraction)
self.sigInteractiveModeStarted.emit(roiClass)
return True
def __roiInteractiveModeEnded(self):
"""Handle end of ROI draw interactive mode"""
if self.isStarted():
self._roiClass = None
if self._drawnROI is not None:
# Cancel ROI create
self.removeRoi(self._drawnROI)
self._drawnROI = None
plot = self.parent()
if plot is not None:
plot.sigPlotSignal.disconnect(self._handleInteraction)
self._updateModeActions()
self.sigInteractiveModeFinished.emit()
[docs] def stop(self):
"""Stop interactive ROI drawing mode.
:return: True if an interactive ROI drawing mode was actually stopped
:rtype: bool
"""
if not self.isStarted():
return False
plot = self.parent()
if plot is not None:
# This leads to call __roiInteractiveModeEnded through
# interactive mode changed signal
plot.setInteractiveMode(mode='zoom', source=None)
else: # Fallback
self.__roiInteractiveModeEnded()
return True
[docs] def exec_(self, roiClass):
"""Block until :meth:`quit` is called.
:param class kind: The class of the ROI which have to be created.
See `silx.gui.plot.items.roi`.
:return: The list of ROIs
:rtype: tuple
"""
self.start(roiClass)
plot = self.parent()
plot.show()
plot.raise_()
self._eventLoop = qt.QEventLoop()
self._eventLoop.exec_()
self._eventLoop = None
self.stop()
rois = self.getRois()
self.clear()
return rois
[docs] def quit(self):
"""Stop a blocking :meth:`exec_` and call :meth:`stop`"""
if self._eventLoop is not None:
self._eventLoop.quit()
self._eventLoop = None
self.stop()
[docs]class InteractiveRegionOfInterestManager(RegionOfInterestManager):
"""RegionOfInterestManager with features for use from interpreter.
It is meant to be used through the :meth:`exec_`.
It provides some messages to display in a status bar and
different modes to end blocking calls to :meth:`exec_`.
:param parent: See QObject
"""
sigMessageChanged = qt.Signal(str)
"""Signal emitted when a new message should be displayed to the user
It provides the message as a str.
"""
def __init__(self, parent):
super(InteractiveRegionOfInterestManager, self).__init__(parent)
self._maxROI = None
self.__timeoutEndTime = None
self.__message = ''
self.__validationMode = self.ValidationMode.ENTER
self.__execClass = None
self.sigRoiAdded.connect(self.__added)
self.sigRoiAboutToBeRemoved.connect(self.__aboutToBeRemoved)
self.sigInteractiveModeStarted.connect(self.__started)
self.sigInteractiveModeFinished.connect(self.__finished)
# Max ROI
[docs] def getMaxRois(self):
"""Returns the maximum number of ROIs or None if no limit.
:rtype: Union[int,None]
"""
return self._maxROI
[docs] def setMaxRois(self, max_):
"""Set the maximum number of ROIs.
:param Union[int,None] max_: The max limit or None for no limit.
:raise ValueError: If there is more ROIs than max value
"""
if max_ is not None:
max_ = int(max_)
if max_ <= 0:
raise ValueError('Max limit must be strictly positive')
if len(self.getRois()) > max_:
raise ValueError(
'Cannot set max limit: Already too many ROIs')
self._maxROI = max_
[docs] def isMaxRois(self):
"""Returns True if the maximum number of ROIs is reached.
:rtype: bool
"""
max_ = self.getMaxRois()
return max_ is not None and len(self.getRois()) >= max_
# Validation mode
@enum.unique
[docs] class ValidationMode(enum.Enum):
"""Mode of validation to leave blocking :meth:`exec_`"""
AUTO = 'auto'
"""Automatically ends the interactive mode once
the user terminates the last ROI shape."""
ENTER = 'enter'
"""Ends the interactive mode when the *Enter* key is pressed."""
AUTO_ENTER = 'auto_enter'
"""Ends the interactive mode when reaching max ROIs or
when the *Enter* key is pressed.
"""
NONE = 'none'
"""Do not provide the user a way to end the interactive mode.
The end of :meth:`exec_` is done through :meth:`quit` or timeout.
"""
[docs] def getValidationMode(self):
"""Returns the interactive mode validation in use.
:rtype: ValidationMode
"""
return self.__validationMode
[docs] def setValidationMode(self, mode):
"""Set the way to perform interactive mode validation.
See :class:`ValidationMode` enumeration for the supported
validation modes.
:param ValidationMode mode: The interactive mode validation to use.
"""
assert isinstance(mode, self.ValidationMode)
if mode != self.__validationMode:
self.__validationMode = mode
if self.isExec():
if (self.isMaxRois() and self.getValidationMode() in
(self.ValidationMode.AUTO,
self.ValidationMode.AUTO_ENTER)):
self.quit()
self.__updateMessage()
def eventFilter(self, obj, event):
if event.type() == qt.QEvent.Hide:
self.quit()
if event.type() == qt.QEvent.KeyPress:
key = event.key()
if (key in (qt.Qt.Key_Return, qt.Qt.Key_Enter) and
self.getValidationMode() in (
self.ValidationMode.ENTER,
self.ValidationMode.AUTO_ENTER)):
# Stop on return key pressed
self.quit()
return True # Stop further handling of this keys
if (key in (qt.Qt.Key_Delete, qt.Qt.Key_Backspace) or (
key == qt.Qt.Key_Z and
event.modifiers() & qt.Qt.ControlModifier)):
rois = self.getRois()
if rois: # Something to undo
self.removeRoi(rois[-1])
# Stop further handling of keys if something was undone
return True
return super(InteractiveRegionOfInterestManager, self).eventFilter(obj, event)
# Message API
[docs] def getMessage(self):
"""Returns the current status message.
This message is meant to be displayed in a status bar.
:rtype: str
"""
if self.__timeoutEndTime is None:
return self.__message
else:
remaining = self.__timeoutEndTime - time.time()
return self.__message + (' - %d seconds remaining' %
max(1, int(remaining)))
# Listen to ROI updates
def __added(self, *args, **kwargs):
"""Handle new ROI added"""
max_ = self.getMaxRois()
if max_ is not None:
# When reaching max number of ROIs, redo last one
while len(self.getRois()) > max_:
self.removeRoi(self.getRois()[-2])
self.__updateMessage()
if (self.isMaxRois() and
self.getValidationMode() in (self.ValidationMode.AUTO,
self.ValidationMode.AUTO_ENTER)):
self.quit()
def __aboutToBeRemoved(self, *args, **kwargs):
"""Handle removal of a ROI"""
# RegionOfInterest not removed yet
self.__updateMessage(nbrois=len(self.getRois()) - 1)
def __started(self, roiKind):
"""Handle interactive mode started"""
self.__updateMessage()
def __finished(self):
"""Handle interactive mode finished"""
self.__updateMessage()
def __updateMessage(self, nbrois=None):
"""Update message"""
if not self.isExec():
message = 'Done'
elif not self.isStarted():
message = 'Use %s ROI edition mode' % self.__execClass
else:
if nbrois is None:
nbrois = len(self.getRois())
kind = self.__execClass._getKind()
max_ = self.getMaxRois()
if max_ is None:
message = 'Select %ss (%d selected)' % (kind, nbrois)
elif max_ <= 1:
message = 'Select a %s' % kind
else:
message = 'Select %d/%d %ss' % (nbrois, max_, kind)
if (self.getValidationMode() == self.ValidationMode.ENTER and
self.isMaxRois()):
message += ' - Press Enter to confirm'
if message != self.__message:
self.__message = message
# Use getMessage to add timeout message
self.sigMessageChanged.emit(self.getMessage())
# Handle blocking call
def __timeoutUpdate(self):
"""Handle update of timeout"""
if (self.__timeoutEndTime is not None and
(self.__timeoutEndTime - time.time()) > 0):
self.sigMessageChanged.emit(self.getMessage())
else: # Stop interactive mode and message timer
timer = self.sender()
if timer is not None:
timer.stop()
self.__timeoutEndTime = None
self.quit()
[docs] def isExec(self):
"""Returns True if :meth:`exec_` is currently running.
:rtype: bool"""
return self.__execClass is not None
[docs] def exec_(self, roiClass, timeout=0):
"""Block until ROI selection is done or timeout is elapsed.
:meth:`quit` also ends this blocking call.
:param class roiClass: The class of the ROI which have to be created.
See `silx.gui.plot.items.roi`.
:param int timeout: Maximum duration in seconds to block.
Default: No timeout
:return: The list of ROIs
:rtype: List[RegionOfInterest]
"""
plot = self.parent()
if plot is None:
return
self.__execClass = roiClass
plot.installEventFilter(self)
if timeout > 0:
self.__timeoutEndTime = time.time() + timeout
timer = qt.QTimer(self)
timer.timeout.connect(self.__timeoutUpdate)
timer.start(1000)
rois = super(InteractiveRegionOfInterestManager, self).exec_(roiClass)
timer.stop()
self.__timeoutEndTime = None
else:
rois = super(InteractiveRegionOfInterestManager, self).exec_(roiClass)
plot.removeEventFilter(self)
self.__execClass = None
self.__updateMessage()
return rois
class _DeleteRegionOfInterestToolButton(qt.QToolButton):
"""Tool button deleting a ROI object
:param parent: See QWidget
:param RegionOfInterest roi: The ROI to delete
"""
def __init__(self, parent, roi):
super(_DeleteRegionOfInterestToolButton, self).__init__(parent)
self.setIcon(icons.getQIcon('remove'))
self.setToolTip("Remove this ROI")
self.__roiRef = roi if roi is None else weakref.ref(roi)
self.clicked.connect(self.__clicked)
def __clicked(self, checked):
"""Handle button clicked"""
roi = None if self.__roiRef is None else self.__roiRef()
if roi is not None:
manager = roi.parent()
if manager is not None:
manager.removeRoi(roi)
self.__roiRef = None