diff options
author | Alexandre Marie <alexandre.marie@synchrotron-soleil.fr> | 2020-07-21 14:45:14 +0200 |
---|---|---|
committer | Alexandre Marie <alexandre.marie@synchrotron-soleil.fr> | 2020-07-21 14:45:14 +0200 |
commit | 328032e2317e3ac4859196bbf12bdb71795302fe (patch) | |
tree | 8cd13462beab109e3cb53410c42335b6d1e00ee6 /silx/gui/plot/tools/roi.py | |
parent | 33ed2a64c92b0311ae35456c016eb284e426afc2 (diff) |
New upstream version 0.13.0+dfsg
Diffstat (limited to 'silx/gui/plot/tools/roi.py')
-rw-r--r-- | silx/gui/plot/tools/roi.py | 403 |
1 files changed, 325 insertions, 78 deletions
diff --git a/silx/gui/plot/tools/roi.py b/silx/gui/plot/tools/roi.py index 3535097..431ecb2 100644 --- a/silx/gui/plot/tools/roi.py +++ b/silx/gui/plot/tools/roi.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2018-2019 European Synchrotron Radiation Facility +# Copyright (c) 2018-2020 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 @@ -30,16 +30,13 @@ __license__ = "MIT" __date__ = "28/06/2018" -import collections import enum -import functools import logging import time import weakref import numpy -from ....utils.weakref import WeakMethodProxy from ... import qt, icons from .. import PlotWidget from ..items import roi as roi_items @@ -50,6 +47,122 @@ from ...colors import rgba logger = logging.getLogger(__name__) +class CreateRoiModeAction(qt.QAction): + """ + This action is a plot mode which allows to create new ROIs using a ROI + manager. + + A ROI is created using a specific `roiClass`. `initRoi` and `finalizeRoi` + can be inherited to custom the ROI initialization. + + :param class roiClass: The ROI class which will be created by this action. + :param qt.QObject parent: The action parent + :param RegionOfInterestManager roiManager: The ROI manager + """ + + def __init__(self, parent, roiManager, roiClass): + assert roiManager is not None + assert roiClass is not None + qt.QAction.__init__(self, parent=parent) + self._roiManager = weakref.ref(roiManager) + self._roiClass = roiClass + self._singleShot = False + self._initAction() + self.triggered[bool].connect(self._actionTriggered) + + def _initAction(self): + """Default initialization of the action""" + roiClass = self._roiClass + + name = None + iconName = None + if hasattr(roiClass, "NAME"): + name = roiClass.NAME + if hasattr(roiClass, "ICON"): + iconName = roiClass.ICON + + if iconName is None: + iconName = "add-shape-unknown" + if name is None: + name = roiClass.__name__ + text = 'Add %s' % name + self.setIcon(icons.getQIcon(iconName)) + self.setText(text) + self.setCheckable(True) + self.setToolTip(text) + + def getRoiClass(self): + """Return the ROI class used by this action to create ROIs""" + return self._roiClass + + def getRoiManager(self): + return self._roiManager() + + def setSingleShot(self, singleShot): + """Set it to True to deactivate the action after the first creation + of a ROI. + + :param bool singleShot: New single short state + """ + self._singleShot = singleShot + + def getSingleShot(self): + """If True, after the first creation of a ROI with this mode, + the mode is deactivated. + + :rtype: bool + """ + return self._singleShot + + def _actionTriggered(self, checked): + """Handle mode actions being checked by the user + + :param bool checked: + :param str kind: Corresponding shape kind + """ + roiManager = self.getRoiManager() + if roiManager is None: + return + + if checked: + roiManager.start(self._roiClass, self) + self.__interactiveModeStarted(roiManager) + else: + source = roiManager.getInteractionSource() + if source is self: + roiManager.stop() + + def __interactiveModeStarted(self, roiManager): + roiManager.sigInteractiveRoiCreated.connect(self.initRoi) + roiManager.sigInteractiveRoiFinalized.connect(self.__finalizeRoi) + roiManager.sigInteractiveModeFinished.connect(self.__interactiveModeFinished) + + def __interactiveModeFinished(self): + roiManager = self.getRoiManager() + if roiManager is not None: + roiManager.sigInteractiveRoiCreated.disconnect(self.initRoi) + roiManager.sigInteractiveRoiFinalized.disconnect(self.__finalizeRoi) + roiManager.sigInteractiveModeFinished.disconnect(self.__interactiveModeFinished) + self.setChecked(False) + + def initRoi(self, roi): + """Inherit it to custom the new ROI at it's creation during the + interaction.""" + pass + + def __finalizeRoi(self, roi): + self.finalizeRoi(roi) + if self._singleShot: + roiManager = self.getRoiManager() + if roiManager is not None: + roiManager.stop() + + def finalizeRoi(self, roi): + """Inherit it to custom the new ROI after it's creation when the + interaction is finalized.""" + pass + + class RegionOfInterestManager(qt.QObject): """Class handling ROI interaction on a PlotWidget. @@ -77,6 +190,9 @@ class RegionOfInterestManager(qt.QObject): sigRoiChanged = qt.Signal() """Signal emitted whenever the ROIs have changed.""" + sigCurrentRoiChanged = qt.Signal(object) + """Signal emitted whenever a ROI is selected.""" + sigInteractiveModeStarted = qt.Signal(object) """Signal emitted when switching to ROI drawing interactive mode. @@ -84,21 +200,36 @@ class RegionOfInterestManager(qt.QObject): mode. """ - sigInteractiveModeFinished = qt.Signal() - """Signal emitted when leaving and interactive ROI drawing. + sigInteractiveRoiCreated = qt.Signal(object) + """Signal emitted when a ROI is created during the interaction. + The interaction is still incomplete and can be aborted. - It provides the list of ROIs. + It provides the ROI object which was just been created. """ - _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' + sigInteractiveRoiFinalized = qt.Signal(object) + """Signal emitted when a ROI creation is complet. + + It provides the ROI object which was just been created. + """ + + sigInteractiveModeFinished = qt.Signal() + """Signal emitted when leaving interactive ROI drawing mode. + """ + + ROI_CLASSES = ( + roi_items.PointROI, + roi_items.CrossROI, + roi_items.RectangleROI, + roi_items.CircleROI, + roi_items.EllipseROI, + roi_items.PolygonROI, + roi_items.LineROI, + roi_items.HorizontalLineROI, + roi_items.VerticalLineROI, + roi_items.ArcROI, + roi_items.HorizontalRangeROI, + ) def __init__(self, parent): assert isinstance(parent, PlotWidget) @@ -106,28 +237,33 @@ class RegionOfInterestManager(qt.QObject): self._rois = [] # List of ROIs self._drawnROI = None # New ROI being currently drawn - # Handle unique selection of interaction mode action - self._actionGroup = qt.QActionGroup(self) - self._roiClass = None + self._source = None self._color = rgba('red') self._label = "__RegionOfInterestManager__%d" % id(self) + self._currentRoi = None + """Hold currently selected ROI""" + self._eventLoop = None self._modeActions = {} + parent.sigPlotSignal.connect(self._plotSignals) + parent.sigInteractiveModeChanged.connect( self._plotInteractiveModeChanged) + parent.sigItemRemoved.connect(self._itemRemoved) + @classmethod def getSupportedRoiClasses(cls): """Returns the default available ROI classes :rtype: List[class] """ - return tuple(cls._MODE_ACTIONS_PARAMS.keys()) + return tuple(cls.ROI_CLASSES) # Associated QActions @@ -137,7 +273,7 @@ class RegionOfInterestManager(qt.QObject): The QAction allows to enable the corresponding drawing interactive mode. - :param str roiClass: The ROI class which will be crated by this action. + :param class roiClass: The ROI class which will be created by this action. :rtype: QAction :raise ValueError: If kind is not supported """ @@ -146,42 +282,10 @@ class RegionOfInterestManager(qt.QObject): 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) - - self._actionGroup.addAction(action) - - action.triggered[bool].connect(functools.partial( - WeakMethodProxy(self._modeActionTriggered), roiClass=roiClass)) + action = CreateRoiModeAction(self, self, 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) - - 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): @@ -189,8 +293,25 @@ class RegionOfInterestManager(qt.QObject): if source is not self: self.__roiInteractiveModeEnded() - else: # Check the corresponding action - self._updateModeActions() + def _getRoiFromItem(self, item): + """Returns the ROI which own this item, else None + if this manager do not have knowledge of this ROI.""" + for roi in self._rois: + if isinstance(roi, roi_items.RegionOfInterest): + for child in roi.getItems(): + if child is item: + return roi + return None + + def _itemRemoved(self, item): + """Called after an item was removed from the plot.""" + if not hasattr(item, "_roiGroup"): + # Early break to avoid to use _getRoiFromItem + # And to avoid reentrant signal when the ROI remove the item itself + return + roi = self._getRoiFromItem(item) + if roi is not None: + self.removeRoi(roi) # Handle ROI interaction @@ -205,8 +326,10 @@ class RegionOfInterestManager(qt.QObject): if event['event'] == 'mouseClicked' and event['button'] == 'left': points = numpy.array([(event['x'], event['y'])], dtype=numpy.float64) - self.createRoi(roiClass, points=points) - + # Not an interactive creation + roi = self._createInteractiveRoi(roiClass, points=points) + roi.creationFinalized() + self.sigInteractiveRoiFinalized.emit(roi) else: # other shapes if (event['event'] in ('drawingProgress', 'drawingFinished') and event['parameters']['label'] == self._label): @@ -214,14 +337,88 @@ class RegionOfInterestManager(qt.QObject): dtype=numpy.float64).T if self._drawnROI is None: # Create new ROI - self._drawnROI = self.createRoi(roiClass, points=points) + # NOTE: Set something before createRoi, so isDrawing is True + self._drawnROI = object() + self._drawnROI = self._createInteractiveRoi(roiClass, points=points) else: self._drawnROI.setFirstShapePoints(points) if event['event'] == 'drawingFinished': if kind == 'polygon' and len(points) > 1: self._drawnROI.setFirstShapePoints(points[:-1]) + roi = self._drawnROI self._drawnROI = None # Stop drawing + roi.creationFinalized() + self.sigInteractiveRoiFinalized.emit(roi) + + # RegionOfInterest selection + + def __getRoiFromMarker(self, marker): + """Returns a ROI from a marker, else None""" + # This should be speed up + for roi in self._rois: + if isinstance(roi, roi_items.HandleBasedROI): + for m in roi.getHandles(): + if m is marker: + return roi + else: + for m in roi.getItems(): + if m is marker: + return roi + return None + + def setCurrentRoi(self, roi): + """Set the currently selected ROI, and emit a signal. + + :param Union[RegionOfInterest,None] roi: The ROI to select + """ + if self._currentRoi is roi: + return + if roi is not None: + # Note: Fixed range to avoid infinite loops + for _ in range(10): + target = roi.getFocusProxy() + if target is None: + break + roi = target + else: + raise RuntimeError("Max selection proxy depth (10) reached.") + + if self._currentRoi is not None: + self._currentRoi.setHighlighted(False) + self._currentRoi = roi + if self._currentRoi is not None: + self._currentRoi.setHighlighted(True) + self.sigCurrentRoiChanged.emit(roi) + + def getCurrentRoi(self): + """Returns the currently selected ROI, else None. + + :rtype: Union[RegionOfInterest,None] + """ + return self._currentRoi + + def _plotSignals(self, event): + """Handle mouse interaction for ROI addition""" + if event['event'] in ('markerClicked', 'markerMoving'): + plot = self.parent() + legend = event['label'] + marker = plot._getMarker(legend=legend) + roi = self.__getRoiFromMarker(marker) + if roi is not None and roi.isSelectable(): + self.setCurrentRoi(roi) + else: + self.setCurrentRoi(None) + elif event['event'] == 'mouseClicked' and event['button'] == 'left': + # Marker click is only for dnd + # This also can click on a marker + plot = self.parent() + marker = plot._getMarkerAt(event['xpixel'], event['ypixel']) + roi = self.__getRoiFromMarker(marker) + if roi is not None and roi.isSelectable(): + self.setCurrentRoi(roi) + else: + self.setCurrentRoi(None) # RegionOfInterest API @@ -257,8 +454,8 @@ class RegionOfInterestManager(qt.QObject): """Handle ROI object changed""" self.sigRoiChanged.emit() - def createRoi(self, roiClass, points, label='', index=None): - """Create a new ROI and add it to list of ROIs. + def _createInteractiveRoi(self, roiClass, points, label=None, index=None): + """Create a new ROI with interactive creation. :param class roiClass: The class of the ROI to create :param numpy.ndarray points: The first shape used to create the ROI @@ -271,12 +468,25 @@ class RegionOfInterestManager(qt.QObject): number of ROIs has been reached. """ roi = roiClass(parent=None) - roi.setName(str(label)) + if label is not None: + roi.setName(str(label)) + roi.creationStarted() roi.setFirstShapePoints(points) self.addRoi(roi, index) + if roi.isSelectable(): + self.setCurrentRoi(roi) + self.sigInteractiveRoiCreated.emit(roi) return roi + def containsRoi(self, roi): + """Returns true if the ROI is part of this manager. + + :param roi_items.RegionOfInterest roi: The ROI to add + :rtype: bool + """ + return roi in self._rois + def addRoi(self, roi, index=None, useManagerColor=True): """Add the ROI to the list of ROIs. @@ -321,14 +531,25 @@ class RegionOfInterestManager(qt.QObject): raise ValueError( 'RegionOfInterest does not belong to this instance') + roi.sigAboutToBeRemoved.emit() self.sigRoiAboutToBeRemoved.emit(roi) + if roi is self._currentRoi: + self.setCurrentRoi(None) + + mustRestart = False + if roi is self._drawnROI: + self._drawnROI = None + mustRestart = True self._rois.remove(roi) roi.sigRegionChanged.disconnect(self._regionOfInterestChanged) roi.sigItemChanged.disconnect(self._regionOfInterestChanged) roi.setParent(None) self._roisUpdated() + if mustRestart: + self._restart() + def _roisUpdated(self): """Handle update of the ROI list""" self.sigRoiChanged.emit() @@ -363,6 +584,15 @@ class RegionOfInterestManager(qt.QObject): """ return self._roiClass + def getInteractionSource(self): + """Returns the object which have requested the ROI creation. + + Returns None if the ROI manager is not in an interactive mode. + + :rtype: Union[object,None] + """ + return self._source + def isStarted(self): """Returns True if an interactive ROI drawing mode is active. @@ -370,11 +600,19 @@ class RegionOfInterestManager(qt.QObject): """ return self._roiClass is not None - def start(self, roiClass): + def isDrawing(self): + """Returns True if an interactive ROI is drawing. + + :rtype: bool + """ + return self._drawnROI is not None + + def start(self, roiClass, source=None): """Start an interactive ROI drawing mode. :param class roiClass: The ROI class to create. It have to inherite from `roi_items.RegionOfInterest`. + :param object source: SOurce of the ROI interaction. :return: True if interactive ROI drawing was started, False otherwise :rtype: bool :raise ValueError: If roiClass is not supported @@ -389,6 +627,22 @@ class RegionOfInterestManager(qt.QObject): return False self._roiClass = roiClass + self._source = source + + self._restart() + + plot.sigPlotSignal.connect(self._handleInteraction) + + self.sigInteractiveModeStarted.emit(roiClass) + + return True + + def _restart(self): + """Restart the plot interaction without changing the + source or the ROI class. + """ + roiClass = self._roiClass + plot = self.parent() firstInteractionShapeKind = roiClass.getFirstInteractionShape() if firstInteractionShapeKind == 'point': @@ -404,16 +658,11 @@ class RegionOfInterestManager(qt.QObject): 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 + self._source = None if self._drawnROI is not None: # Cancel ROI create @@ -424,8 +673,6 @@ class RegionOfInterestManager(qt.QObject): if plot is not None: plot.sigPlotSignal.disconnect(self._handleInteraction) - self._updateModeActions() - self.sigInteractiveModeFinished.emit() def stop(self): @@ -441,7 +688,7 @@ class RegionOfInterestManager(qt.QObject): if plot is not None: # This leads to call __roiInteractiveModeEnded through # interactive mode changed signal - plot.setInteractiveMode(mode='zoom', source=None) + plot.resetInteractiveMode() else: # Fallback self.__roiInteractiveModeEnded() @@ -675,16 +922,16 @@ class InteractiveRegionOfInterestManager(RegionOfInterestManager): if nbrois is None: nbrois = len(self.getRois()) - kind = self.__execClass._getKind() - max_ = self.getMaxRois() + name = self.__execClass._getShortName() + max_ = self.getMaxRois() if max_ is None: - message = 'Select %ss (%d selected)' % (kind, nbrois) + message = 'Select %ss (%d selected)' % (name, nbrois) elif max_ <= 1: - message = 'Select a %s' % kind + message = 'Select a %s' % name else: - message = 'Select %d/%d %ss' % (nbrois, max_, kind) + message = 'Select %d/%d %ss' % (nbrois, max_, name) if (self.getValidationMode() == self.ValidationMode.ENTER and self.isMaxRois()): @@ -915,7 +1162,7 @@ class RegionOfInterestTableWidget(qt.QTableWidget): item.setText(None) # Kind - label = roi._getKind() + label = roi._getShortName() if label is None: # Default value if kind is not overrided label = roi.__class__.__name__ |