diff options
Diffstat (limited to 'silx/gui/plot/tools')
-rw-r--r-- | silx/gui/plot/tools/CurveLegendsWidget.py | 8 | ||||
-rw-r--r-- | silx/gui/plot/tools/PositionInfo.py | 9 | ||||
-rw-r--r-- | silx/gui/plot/tools/profile/ScatterProfileToolBar.py | 179 | ||||
-rw-r--r-- | silx/gui/plot/tools/profile/_BaseProfileToolBar.py | 430 | ||||
-rw-r--r-- | silx/gui/plot/tools/profile/core.py | 522 | ||||
-rw-r--r-- | silx/gui/plot/tools/profile/editors.py | 307 | ||||
-rw-r--r-- | silx/gui/plot/tools/profile/manager.py | 1059 | ||||
-rw-r--r-- | silx/gui/plot/tools/profile/rois.py | 1168 | ||||
-rw-r--r-- | silx/gui/plot/tools/profile/toolbar.py | 172 | ||||
-rw-r--r-- | silx/gui/plot/tools/roi.py | 403 | ||||
-rw-r--r-- | silx/gui/plot/tools/test/__init__.py | 2 | ||||
-rw-r--r-- | silx/gui/plot/tools/test/testProfile.py | 673 | ||||
-rw-r--r-- | silx/gui/plot/tools/test/testROI.py | 191 | ||||
-rw-r--r-- | silx/gui/plot/tools/test/testScatterProfileToolBar.py | 137 | ||||
-rw-r--r-- | silx/gui/plot/tools/toolbars.py | 2 |
15 files changed, 4491 insertions, 771 deletions
diff --git a/silx/gui/plot/tools/CurveLegendsWidget.py b/silx/gui/plot/tools/CurveLegendsWidget.py index 7b63b29..4a517dd 100644 --- a/silx/gui/plot/tools/CurveLegendsWidget.py +++ b/silx/gui/plot/tools/CurveLegendsWidget.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2018 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 @@ -62,7 +62,7 @@ class _LegendWidget(qt.QWidget): icon = _LegendIcon(curve=curve) layout.addWidget(icon) - label = qt.QLabel(curve.getLegend()) + label = qt.QLabel(curve.getName()) label.setAlignment(qt.Qt.AlignLeft | qt.Qt.AlignVCenter) layout.addWidget(label) @@ -184,12 +184,12 @@ class CurveLegendsWidget(qt.QWidget): def _itemAdded(self, item): """Handle item added to the plot content""" if isinstance(item, items.Curve): - self._addLegend(item.getLegend()) + self._addLegend(item.getName()) def _itemRemoved(self, item): """Handle item removed from the plot content""" if isinstance(item, items.Curve): - self._removeLegend(item.getLegend()) + self._removeLegend(item.getName()) def _addLegend(self, legend): """Add a curve to the legends diff --git a/silx/gui/plot/tools/PositionInfo.py b/silx/gui/plot/tools/PositionInfo.py index fef11dd..4b63cdb 100644 --- a/silx/gui/plot/tools/PositionInfo.py +++ b/silx/gui/plot/tools/PositionInfo.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility +# Copyright (c) 2016-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 @@ -212,10 +212,11 @@ class PositionInfo(qt.QWidget): else: kinds = [] if snappingMode & self.SNAPPING_CURVE: - kinds.append('curve') + kinds.append(items.Curve) if snappingMode & self.SNAPPING_SCATTER: - kinds.append('scatter') - selectedItems = plot._getItems(kind=kinds) + kinds.append(items.Scatter) + selectedItems = [item for item in plot.getItems() + if isinstance(item, kinds) and item.isVisible()] # Compute distance threshold if qt.BINDING in ('PyQt5', 'PySide2'): diff --git a/silx/gui/plot/tools/profile/ScatterProfileToolBar.py b/silx/gui/plot/tools/profile/ScatterProfileToolBar.py index 0d30651..44187ef 100644 --- a/silx/gui/plot/tools/profile/ScatterProfileToolBar.py +++ b/silx/gui/plot/tools/profile/ScatterProfileToolBar.py @@ -30,20 +30,11 @@ __license__ = "MIT" __date__ = "28/06/2018" -import logging -import weakref +from silx.utils import deprecation +from . import toolbar -import numpy -from ._BaseProfileToolBar import _BaseProfileToolBar -from ... import items -from ....utils.concurrent import submitToQtMainThread - - -_logger = logging.getLogger(__name__) - - -class ScatterProfileToolBar(_BaseProfileToolBar): +class ScatterProfileToolBar(toolbar.ProfileToolBar): """QToolBar providing scatter plot profiling tools :param parent: See :class:`QToolBar`. @@ -51,157 +42,13 @@ class ScatterProfileToolBar(_BaseProfileToolBar): :param str title: See :class:`QToolBar`. """ - def __init__(self, parent=None, plot=None, title='Scatter Profile'): - super(ScatterProfileToolBar, self).__init__(parent, plot, title) - - self.__nPoints = 1024 - self.__scatterRef = None - self.__futureInterpolator = None - - plot = self.getPlotWidget() - if plot is not None: - self._setScatterItem(plot._getActiveItem(kind='scatter')) - plot.sigActiveScatterChanged.connect(self.__activeScatterChanged) - - def __activeScatterChanged(self, previous, legend): - """Handle change of active scatter - - :param Union[str,None] previous: - :param Union[str,None] legend: - """ - plot = self.getPlotWidget() - if plot is None or legend is None: - scatter = None - else: - scatter = plot.getScatter(legend) - self._setScatterItem(scatter) - - def _getScatterItem(self): - """Returns the scatter item currently handled by this tool. - - :rtype: ~silx.gui.plot.items.Scatter - """ - return None if self.__scatterRef is None else self.__scatterRef() - - def _setScatterItem(self, scatter): - """Set the scatter tracked by this tool - - :param Union[None,silx.gui.plot.items.Scatter] scatter: - """ - self.__futureInterpolator = None # Reset currently expected future - - previousScatter = self._getScatterItem() - if previousScatter is not None: - previousScatter.sigItemChanged.disconnect( - self.__scatterItemChanged) - - if scatter is None: - self.__scatterRef = None - else: - self.__scatterRef = weakref.ref(scatter) - scatter.sigItemChanged.connect(self.__scatterItemChanged) - - # Refresh profile - self.updateProfile() - - def __scatterItemChanged(self, event): - """Handle update of active scatter plot item - - :param ItemChangedType event: - """ - if event == items.ItemChangedType.DATA: - self.updateProfile() # Refresh profile - - def hasPendingOperations(self): - """Returns True if waiting for an interpolator to be ready - - :rtype: bool - """ - return (self.__futureInterpolator is not None and - not self.__futureInterpolator.done()) - - # Number of points - - def getNPoints(self): - """Returns the number of points of the profiles - - :rtype: int - """ - return self.__nPoints - - def setNPoints(self, npoints): - """Set the number of points of the profiles - - :param int npoints: - """ - npoints = int(npoints) - if npoints < 1: - raise ValueError("Unsupported number of points: %d" % npoints) - elif npoints != self.__nPoints: - self.__nPoints = npoints - self.updateProfile() - - # Overridden methods - - def computeProfileTitle(self, x0, y0, x1, y1): - """Compute corresponding plot title - - :param float x0: Profile start point X coord - :param float y0: Profile start point Y coord - :param float x1: Profile end point X coord - :param float y1: Profile end point Y coord - :return: Title to use - :rtype: str - """ - if self.hasPendingOperations(): - return 'Pre-processing data...' - else: - return super(ScatterProfileToolBar, self).computeProfileTitle( - x0, y0, x1, y1) - - def __futureDone(self, future): - """Handle completion of the interpolator creation""" - if future is self.__futureInterpolator: - # Only handle future callbacks for the current one - submitToQtMainThread(self.updateProfile) - - def computeProfile(self, x0, y0, x1, y1): - """Compute corresponding profile - - :param float x0: Profile start point X coord - :param float y0: Profile start point Y coord - :param float x1: Profile end point X coord - :param float y1: Profile end point Y coord - :return: (points, values) profile data or None - """ - scatter = self._getScatterItem() - if scatter is None or self.hasPendingOperations(): - return None - - # Lazy async request of the interpolator - future = scatter._getInterpolator() - if future is not self.__futureInterpolator: - # First time we request this interpolator - self.__futureInterpolator = future - if not future.done(): - future.add_done_callback(self.__futureDone) - return None - - if future.cancelled() or future.exception() is not None: - return None # Something went wrong - - interpolator = future.result() - if interpolator is None: - return None # Cannot init an interpolator - - nPoints = self.getNPoints() - points = numpy.transpose(( - numpy.linspace(x0, x1, nPoints, endpoint=True), - numpy.linspace(y0, y1, nPoints, endpoint=True))) - - values = interpolator(points) - - if not numpy.any(numpy.isfinite(values)): - return None # Profile outside convex hull - - return points, values + def __init__(self, parent=None, plot=None, title=None): + super(ScatterProfileToolBar, self).__init__(parent, plot) + if title is not None: + deprecation.deprecated_warning("Attribute", + name="title", + reason="removed", + since_version="0.13.0", + only_once=True, + skip_backtrace_count=1) + self.setScheme("scatter") diff --git a/silx/gui/plot/tools/profile/_BaseProfileToolBar.py b/silx/gui/plot/tools/profile/_BaseProfileToolBar.py deleted file mode 100644 index 75bb4c6..0000000 --- a/silx/gui/plot/tools/profile/_BaseProfileToolBar.py +++ /dev/null @@ -1,430 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2018-2019 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 the base class for profile toolbars.""" - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "28/06/2018" - - -import logging -import weakref - -import numpy - -from silx.utils.weakref import WeakMethodProxy -from silx.gui import qt, icons, colors -from silx.gui.plot import PlotWidget, items -from silx.gui.plot.ProfileMainWindow import ProfileMainWindow -from silx.gui.plot.tools.roi import RegionOfInterestManager -from silx.gui.plot.items import roi as roi_items - - -_logger = logging.getLogger(__name__) - - -class _BaseProfileToolBar(qt.QToolBar): - """Base class for QToolBar plot profiling tools - - :param parent: See :class:`QToolBar`. - :param plot: :class:`~silx.gui.plot.PlotWidget` on which to operate. - :param str title: See :class:`QToolBar`. - """ - - sigProfileChanged = qt.Signal() - """Signal emitted when the profile has changed""" - - def __init__(self, parent=None, plot=None, title=''): - super(_BaseProfileToolBar, self).__init__(title, parent) - - self.__profile = None - self.__profileTitle = '' - - assert isinstance(plot, PlotWidget) - self._plotRef = weakref.ref( - plot, WeakMethodProxy(self.__plotDestroyed)) - - self._profileWindow = None - - # Set-up interaction manager - roiManager = RegionOfInterestManager(plot) - self._roiManagerRef = weakref.ref(roiManager) - - roiManager.sigInteractiveModeFinished.connect(self.__interactionFinished) - roiManager.sigRoiChanged.connect(self.updateProfile) - roiManager.sigRoiAdded.connect(self.__roiAdded) - - # Add interactive mode actions - for kind, icon, tooltip in ( - (roi_items.HorizontalLineROI, 'shape-horizontal', - 'Enables horizontal line profile selection mode'), - (roi_items.VerticalLineROI, 'shape-vertical', - 'Enables vertical line profile selection mode'), - (roi_items.LineROI, 'shape-diagonal', - 'Enables line profile selection mode')): - action = roiManager.getInteractionModeAction(kind) - action.setIcon(icons.getQIcon(icon)) - action.setToolTip(tooltip) - self.addAction(action) - - # Add clear action - action = qt.QAction(icons.getQIcon('profile-clear'), - 'Clear Profile', self) - action.setToolTip('Clear the profile') - action.setCheckable(False) - action.triggered.connect(self.clearProfile) - self.addAction(action) - - # Initialize color - self._color = None - self.setColor('red') - - # Listen to plot limits changed - plot.getXAxis().sigLimitsChanged.connect(self.updateProfile) - plot.getYAxis().sigLimitsChanged.connect(self.updateProfile) - - # Listen to plot scale - plot.getXAxis().sigScaleChanged.connect(self.__plotAxisScaleChanged) - plot.getYAxis().sigScaleChanged.connect(self.__plotAxisScaleChanged) - - self.setDefaultProfileWindowEnabled(True) - - def getProfilePoints(self, copy=True): - """Returns the profile sampling points as (x, y) or None - - :param bool copy: True to get a copy, - False to get internal arrays (do not modify) - :rtype: Union[numpy.ndarray,None] - """ - if self.__profile is None: - return None - else: - return numpy.array(self.__profile[0], copy=copy) - - def getProfileValues(self, copy=True): - """Returns the values of the profile or None - - :param bool copy: True to get a copy, - False to get internal arrays (do not modify) - :rtype: Union[numpy.ndarray,None] - """ - if self.__profile is None: - return None - else: - return numpy.array(self.__profile[1], copy=copy) - - def getProfileTitle(self): - """Returns the profile title - - :rtype: str - """ - return self.__profileTitle - - # Handle plot reference - - def __plotDestroyed(self, ref): - """Handle finalization of PlotWidget - - :param ref: weakref to the plot - """ - self._plotRef = None - self.setEnabled(False) # Profile is pointless - for action in self.actions(): # TODO useful? - self.removeAction(action) - - def getPlotWidget(self): - """The :class:`~silx.gui.plot.PlotWidget` associated to the toolbar. - - :rtype: Union[~silx.gui.plot.PlotWidget,None] - """ - return None if self._plotRef is None else self._plotRef() - - def _getRoiManager(self): - """Returns the used ROI manager - - :rtype: RegionOfInterestManager - """ - return self._roiManagerRef() - - # Profile Plot - - def isDefaultProfileWindowEnabled(self): - """Returns True if the default floating profile window is used - - :rtype: bool - """ - return self.getDefaultProfileWindow() is not None - - def setDefaultProfileWindowEnabled(self, enabled): - """Set whether to use or not the default floating profile window. - - :param bool enabled: True to use, False to disable - """ - if self.isDefaultProfileWindowEnabled() != enabled: - if enabled: - self._profileWindow = ProfileMainWindow(self) - self._profileWindow.sigClose.connect(self.clearProfile) - self.sigProfileChanged.connect(self.__updateDefaultProfilePlot) - - else: - self.sigProfileChanged.disconnect(self.__updateDefaultProfilePlot) - self._profileWindow.sigClose.disconnect(self.clearProfile) - self._profileWindow.close() - self._profileWindow = None - - def getDefaultProfileWindow(self): - """Returns the default floating profile window if in use else None. - - See :meth:`isDefaultProfileWindowEnabled` - - :rtype: Union[ProfileMainWindow,None] - """ - return self._profileWindow - - def __updateDefaultProfilePlot(self): - """Update the plot of the default profile window""" - profileWindow = self.getDefaultProfileWindow() - if profileWindow is None: - return - - profilePlot = profileWindow.getPlot() - if profilePlot is None: - return - - profilePlot.clear() - profilePlot.setGraphTitle(self.getProfileTitle()) - - points = self.getProfilePoints(copy=False) - values = self.getProfileValues(copy=False) - - if points is not None and values is not None: - if (numpy.abs(points[-1, 0] - points[0, 0]) > - numpy.abs(points[-1, 1] - points[0, 1])): - xProfile = points[:, 0] - profilePlot.getXAxis().setLabel('X') - else: - xProfile = points[:, 1] - profilePlot.getXAxis().setLabel('Y') - - profilePlot.addCurve( - xProfile, values, legend='Profile', color=self._color) - - self._showDefaultProfileWindow() - - def _showDefaultProfileWindow(self): - """If profile window was created by this toolbar, - try to avoid overlapping with the toolbar's parent window. - """ - profileWindow = self.getDefaultProfileWindow() - roiManager = self._getRoiManager() - if profileWindow is None or roiManager is None: - return - - if roiManager.isStarted() and not profileWindow.isVisible(): - profileWindow.show() - profileWindow.raise_() - - window = self.window() - winGeom = window.frameGeometry() - qapp = qt.QApplication.instance() - desktop = qapp.desktop() - screenGeom = desktop.availableGeometry(self) - spaceOnLeftSide = winGeom.left() - spaceOnRightSide = screenGeom.width() - winGeom.right() - - frameGeometry = profileWindow.frameGeometry() - profileWindowWidth = frameGeometry.width() - if profileWindowWidth < spaceOnRightSide: - # Place profile on the right - profileWindow.move(winGeom.right(), winGeom.top()) - elif profileWindowWidth < spaceOnLeftSide: - # Place profile on the left - profileWindow.move( - max(0, winGeom.left() - profileWindowWidth), winGeom.top()) - - # Handle plot in log scale - - def __plotAxisScaleChanged(self, scale): - """Handle change of axis scale in the plot widget""" - plot = self.getPlotWidget() - if plot is None: - return - - xScale = plot.getXAxis().getScale() - yScale = plot.getYAxis().getScale() - - if xScale == items.Axis.LINEAR and yScale == items.Axis.LINEAR: - self.setEnabled(True) - - else: - roiManager = self._getRoiManager() - if roiManager is not None: - roiManager.stop() # Stop interactive mode - - self.clearProfile() - self.setEnabled(False) - - # Profile color - - def getColor(self): - """Returns the color used for the profile and ROI - - :rtype: QColor - """ - return qt.QColor.fromRgbF(*self._color) - - def setColor(self, color): - """Set the color to use for ROI and profile. - - :param color: - Either a color name, a QColor, a list of uint8 or float in [0, 1]. - """ - self._color = colors.rgba(color) - roiManager = self._getRoiManager() - if roiManager is not None: - roiManager.setColor(self._color) - for roi in roiManager.getRois(): - roi.setColor(self._color) - self.updateProfile() - - # Handle ROI manager - - def __interactionFinished(self): - """Handle end of interactive mode""" - self.clearProfile() - - profileWindow = self.getDefaultProfileWindow() - if profileWindow is not None: - profileWindow.hide() - - def __roiAdded(self, roi): - """Handle new ROI""" - roi.setName('Profile') - roi.setEditable(True) - - # Remove any other ROI - roiManager = self._getRoiManager() - if roiManager is not None: - for regionOfInterest in list(roiManager.getRois()): - if regionOfInterest is not roi: - roiManager.removeRoi(regionOfInterest) - - def computeProfile(self, x0, y0, x1, y1): - """Compute corresponding profile - - Override in subclass to compute profile - - :param float x0: Profile start point X coord - :param float y0: Profile start point Y coord - :param float x1: Profile end point X coord - :param float y1: Profile end point Y coord - :return: (points, values) profile data or None - """ - return None - - def computeProfileTitle(self, x0, y0, x1, y1): - """Compute corresponding plot title - - This can be overridden to change title behavior. - - :param float x0: Profile start point X coord - :param float y0: Profile start point Y coord - :param float x1: Profile end point X coord - :param float y1: Profile end point Y coord - :return: Title to use - :rtype: str - """ - if x0 == x1: - title = 'X = %g; Y = [%g, %g]' % (x0, y0, y1) - elif y0 == y1: - title = 'Y = %g; X = [%g, %g]' % (y0, x0, x1) - else: - m = (y1 - y0) / (x1 - x0) - b = y0 - m * x0 - title = 'Y = %g * X %+g' % (m, b) - - return title - - def updateProfile(self): - """Update profile according to current ROI""" - roiManager = self._getRoiManager() - if roiManager is None: - roi = None - else: - rois = roiManager.getRois() - roi = None if len(rois) == 0 else rois[0] - - if roi is None: - self._setProfile(profile=None, title='') - return - - # Get end points - if isinstance(roi, roi_items.LineROI): - points = roi.getEndPoints() - x0, y0 = points[0] - x1, y1 = points[1] - elif isinstance(roi, (roi_items.VerticalLineROI, roi_items.HorizontalLineROI)): - plot = self.getPlotWidget() - if plot is None: - self._setProfile(profile=None, title='') - return - - elif isinstance(roi, roi_items.HorizontalLineROI): - x0, x1 = plot.getXAxis().getLimits() - y0 = y1 = roi.getPosition() - - elif isinstance(roi, roi_items.VerticalLineROI): - x0 = x1 = roi.getPosition() - y0, y1 = plot.getYAxis().getLimits() - - else: - raise RuntimeError('Unsupported ROI for profile: {}'.format(roi.__class__)) - - if x1 < x0 or (x1 == x0 and y1 < y0): - # Invert points - x0, y0, x1, y1 = x1, y1, x0, y0 - - profile = self.computeProfile(x0, y0, x1, y1) - title = self.computeProfileTitle(x0, y0, x1, y1) - self._setProfile(profile=profile, title=title) - - def _setProfile(self, profile=None, title=''): - """Set profile data and emit signal. - - :param profile: points and profile values - :param str title: - """ - self.__profile = profile - self.__profileTitle = title - - self.sigProfileChanged.emit() - - def clearProfile(self): - """Clear the current line ROI and associated profile""" - roiManager = self._getRoiManager() - if roiManager is not None: - roiManager.clear() - - self._setProfile(profile=None, title='') diff --git a/silx/gui/plot/tools/profile/core.py b/silx/gui/plot/tools/profile/core.py new file mode 100644 index 0000000..1f883dc --- /dev/null +++ b/silx/gui/plot/tools/profile/core.py @@ -0,0 +1,522 @@ +# coding: utf-8 +# /*########################################################################## +# +# 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 +# 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 define core objects for profile tools. +""" + +__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel", "H. Payno", "V. Valls"] +__license__ = "MIT" +__date__ = "17/04/2020" + +import collections +import numpy +import weakref + +from silx.image.bilinear import BilinearImage +from silx.gui import qt + + +CurveProfileData = collections.namedtuple( + 'CurveProfileData', [ + "coords", + "profile", + "title", + "xLabel", + "yLabel", + ]) + +RgbaProfileData = collections.namedtuple( + 'RgbaProfileData', [ + "coords", + "profile", + "profile_r", + "profile_g", + "profile_b", + "profile_a", + "title", + "xLabel", + "yLabel", + ]) + +ImageProfileData = collections.namedtuple( + 'ImageProfileData', [ + 'coords', + 'profile', + 'title', + 'xLabel', + 'yLabel', + 'colormap', + ]) + + +class ProfileRoiMixIn: + """Base mix-in for ROI which can be used to select a profile. + + This mix-in have to be applied to a :class:`~silx.gui.plot.items.roi.RegionOfInterest` + in order to be usable by a :class:`~silx.gui.plot.tools.profile.manager.ProfileManager`. + """ + + ITEM_KIND = None + """Define the plot item which can be used with this profile ROI""" + + sigProfilePropertyChanged = qt.Signal() + """Emitted when a property of this profile have changed""" + + sigPlotItemChanged = qt.Signal() + """Emitted when the plot item linked to this profile have changed""" + + def __init__(self, parent=None): + self.__profileWindow = None + self.__profileManager = None + self.__plotItem = None + self.setName("Profile") + self.setEditable(True) + self.setSelectable(True) + + def invalidateProfile(self): + """Must be called by the implementation when the profile have to be + recomputed.""" + profileManager = self.getProfileManager() + if profileManager is not None: + profileManager.requestUpdateProfile(self) + + def invalidateProperties(self): + """Must be called when a property of the profile have changed.""" + self.sigProfilePropertyChanged.emit() + + def _setPlotItem(self, plotItem): + """Specify the plot item to use with this profile + + :param `~silx.gui.plot.items.item.Item` plotItem: A plot item + """ + previousPlotItem = self.getPlotItem() + if previousPlotItem is plotItem: + return + self.__plotItem = weakref.ref(plotItem) + self.sigPlotItemChanged.emit() + + def getPlotItem(self): + """Returns the plot item used by this profile + + :rtype: `~silx.gui.plot.items.item.Item` + """ + if self.__plotItem is None: + return None + plotItem = self.__plotItem() + if plotItem is None: + self.__plotItem = None + return plotItem + + def _setProfileManager(self, profileManager): + self.__profileManager = profileManager + + def getProfileManager(self): + """ + Returns the profile manager connected to this ROI. + + :rtype: ~silx.gui.plot.tools.profile.manager.ProfileManager + """ + return self.__profileManager + + def getProfileWindow(self): + """ + Returns the windows associated to this ROI, else None. + + :rtype: ProfileWindow + """ + return self.__profileWindow + + def setProfileWindow(self, profileWindow): + """ + Associate a window to this ROI. Can be None. + + :param ProfileWindow profileWindow: A main window + to display the profile. + """ + if profileWindow is self.__profileWindow: + return + if self.__profileWindow is not None: + self.__profileWindow.sigClose.disconnect(self.__profileWindowAboutToClose) + self.__profileWindow.setRoiProfile(None) + self.__profileWindow = profileWindow + if self.__profileWindow is not None: + self.__profileWindow.sigClose.connect(self.__profileWindowAboutToClose) + self.__profileWindow.setRoiProfile(self) + + def __profileWindowAboutToClose(self): + profileManager = self.getProfileManager() + roiManager = profileManager.getRoiManager() + roiManager.removeRoi(self) + + def computeProfile(self, item): + """ + Compute the profile which will be displayed. + + This method is not called from the main Qt thread, but from a thread + pool. + + :param ~silx.gui.plot.items.Item item: A plot item + :rtype: Union[CurveProfileData,ImageProfileData] + """ + raise NotImplementedError() + + +def _alignedFullProfile(data, origin, scale, position, roiWidth, axis, method): + """Get a profile along one axis on a stack of images + + :param numpy.ndarray data: 3D volume (stack of 2D images) + The first dimension is the image index. + :param origin: Origin of image in plot (ox, oy) + :param scale: Scale of image in plot (sx, sy) + :param float position: Position of profile line in plot coords + on the axis orthogonal to the profile direction. + :param int roiWidth: Width of the profile in image pixels. + :param int axis: 0 for horizontal profile, 1 for vertical. + :param str method: method to compute the profile. Can be 'mean' or 'sum' or + 'none' + :return: profile image + effective ROI area corners in plot coords + """ + assert axis in (0, 1) + assert len(data.shape) == 3 + assert method in ('mean', 'sum', 'none') + + # Convert from plot to image coords + imgPos = int((position - origin[1 - axis]) / scale[1 - axis]) + + if axis == 1: # Vertical profile + # Transpose image to always do a horizontal profile + data = numpy.transpose(data, (0, 2, 1)) + + nimages, height, width = data.shape + + roiWidth = min(height, roiWidth) # Clip roi width to image size + + # Get [start, end[ coords of the roi in the data + start = int(int(imgPos) + 0.5 - roiWidth / 2.) + start = min(max(0, start), height - roiWidth) + end = start + roiWidth + + if method == 'none': + profile = None + else: + if start < height and end > 0: + if method == 'mean': + fct = numpy.mean + elif method == 'sum': + fct = numpy.sum + else: + raise ValueError('method not managed') + profile = fct(data[:, max(0, start):min(end, height), :], axis=1).astype(numpy.float32) + else: + profile = numpy.zeros((nimages, width), dtype=numpy.float32) + + # Compute effective ROI in plot coords + profileBounds = numpy.array( + (0, width, width, 0), + dtype=numpy.float32) * scale[axis] + origin[axis] + roiBounds = numpy.array( + (start, start, end, end), + dtype=numpy.float32) * scale[1 - axis] + origin[1 - axis] + + if axis == 0: # Horizontal profile + area = profileBounds, roiBounds + else: # vertical profile + area = roiBounds, profileBounds + + return profile, area + + +def _alignedPartialProfile(data, rowRange, colRange, axis, method): + """Mean of a rectangular region (ROI) of a stack of images + along a given axis. + + Returned values and all parameters are in image coordinates. + + :param numpy.ndarray data: 3D volume (stack of 2D images) + The first dimension is the image index. + :param rowRange: [min, max[ of ROI rows (upper bound excluded). + :type rowRange: 2-tuple of int (min, max) with min < max + :param colRange: [min, max[ of ROI columns (upper bound excluded). + :type colRange: 2-tuple of int (min, max) with min < max + :param int axis: The axis along which to take the profile of the ROI. + 0: Sum rows along columns. + 1: Sum columns along rows. + :param str method: method to compute the profile. Can be 'mean' or 'sum' + :return: Profile image along the ROI as the mean of the intersection + of the ROI and the image. + """ + assert axis in (0, 1) + assert len(data.shape) == 3 + assert rowRange[0] < rowRange[1] + assert colRange[0] < colRange[1] + assert method in ('mean', 'sum') + + nimages, height, width = data.shape + + # Range aligned with the integration direction + profileRange = colRange if axis == 0 else rowRange + + profileLength = abs(profileRange[1] - profileRange[0]) + + # Subset of the image to use as intersection of ROI and image + rowStart = min(max(0, rowRange[0]), height) + rowEnd = min(max(0, rowRange[1]), height) + colStart = min(max(0, colRange[0]), width) + colEnd = min(max(0, colRange[1]), width) + + if method == 'mean': + _fct = numpy.mean + elif method == 'sum': + _fct = numpy.sum + else: + raise ValueError('method not managed') + + imgProfile = _fct(data[:, rowStart:rowEnd, colStart:colEnd], axis=axis + 1, + dtype=numpy.float32) + + # Profile including out of bound area + profile = numpy.zeros((nimages, profileLength), dtype=numpy.float32) + + # Place imgProfile in full profile + offset = - min(0, profileRange[0]) + profile[:, offset:offset + imgProfile.shape[1]] = imgProfile + + return profile + + +def createProfile(roiInfo, currentData, origin, scale, lineWidth, method): + """Create the profile line for the the given image. + + :param roiInfo: information about the ROI: start point, end point and + type ("X", "Y", "D") + :param numpy.ndarray currentData: the 2D image or the 3D stack of images + on which we compute the profile. + :param origin: (ox, oy) the offset from origin + :type origin: 2-tuple of float + :param scale: (sx, sy) the scale to use + :type scale: 2-tuple of float + :param int lineWidth: width of the profile line + :param str method: method to compute the profile. Can be 'mean' or 'sum' + or 'none': to compute everything except the profile + :return: `coords, profile, area, profileName, xLabel`, where: + - coords is the X coordinate to use to display the profile + - profile is a 2D array of the profiles of the stack of images. + For a single image, the profile is a curve, so this parameter + has a shape *(1, len(curve))* + - area is a tuple of two 1D arrays with 4 values each. They represent + the effective ROI area corners in plot coords. + - profileName is a string describing the ROI, meant to be used as + title of the profile plot + - xLabel the label for X in the profile window + + :rtype: tuple(ndarray,ndarray,(ndarray,ndarray),str) + """ + if currentData is None or roiInfo is None or lineWidth is None: + raise ValueError("createProfile called with invalide arguments") + + # force 3D data (stack of images) + if len(currentData.shape) == 2: + currentData3D = currentData.reshape((1,) + currentData.shape) + elif len(currentData.shape) == 3: + currentData3D = currentData + + roiWidth = max(1, lineWidth) + roiStart, roiEnd, lineProjectionMode = roiInfo + + if lineProjectionMode == 'X': # Horizontal profile on the whole image + profile, area = _alignedFullProfile(currentData3D, + origin, scale, + roiStart[1], roiWidth, + axis=0, + method=method) + + if method == 'none': + coords = None + else: + coords = numpy.arange(len(profile[0]), dtype=numpy.float32) + coords = coords * scale[0] + origin[0] + + yMin, yMax = min(area[1]), max(area[1]) - 1 + if roiWidth <= 1: + profileName = '{ylabel} = %g' % yMin + else: + profileName = '{ylabel} = [%g, %g]' % (yMin, yMax) + xLabel = '{xlabel}' + + elif lineProjectionMode == 'Y': # Vertical profile on the whole image + profile, area = _alignedFullProfile(currentData3D, + origin, scale, + roiStart[0], roiWidth, + axis=1, + method=method) + + if method == 'none': + coords = None + else: + coords = numpy.arange(len(profile[0]), dtype=numpy.float32) + coords = coords * scale[1] + origin[1] + + xMin, xMax = min(area[0]), max(area[0]) - 1 + if roiWidth <= 1: + profileName = '{xlabel} = %g' % xMin + else: + profileName = '{xlabel} = [%g, %g]' % (xMin, xMax) + xLabel = '{ylabel}' + + else: # Free line profile + + # Convert start and end points in image coords as (row, col) + startPt = ((roiStart[1] - origin[1]) / scale[1], + (roiStart[0] - origin[0]) / scale[0]) + endPt = ((roiEnd[1] - origin[1]) / scale[1], + (roiEnd[0] - origin[0]) / scale[0]) + + if (int(startPt[0]) == int(endPt[0]) or + int(startPt[1]) == int(endPt[1])): + # Profile is aligned with one of the axes + + # Convert to int + startPt = int(startPt[0]), int(startPt[1]) + endPt = int(endPt[0]), int(endPt[1]) + + # Ensure startPt <= endPt + if startPt[0] > endPt[0] or startPt[1] > endPt[1]: + startPt, endPt = endPt, startPt + + if startPt[0] == endPt[0]: # Row aligned + rowRange = (int(startPt[0] + 0.5 - 0.5 * roiWidth), + int(startPt[0] + 0.5 + 0.5 * roiWidth)) + colRange = startPt[1], endPt[1] + 1 + if method == 'none': + profile = None + else: + profile = _alignedPartialProfile(currentData3D, + rowRange, colRange, + axis=0, + method=method) + + else: # Column aligned + rowRange = startPt[0], endPt[0] + 1 + colRange = (int(startPt[1] + 0.5 - 0.5 * roiWidth), + int(startPt[1] + 0.5 + 0.5 * roiWidth)) + if method == 'none': + profile = None + else: + profile = _alignedPartialProfile(currentData3D, + rowRange, colRange, + axis=1, + method=method) + # Convert ranges to plot coords to draw ROI area + area = ( + numpy.array( + (colRange[0], colRange[1], colRange[1], colRange[0]), + dtype=numpy.float32) * scale[0] + origin[0], + numpy.array( + (rowRange[0], rowRange[0], rowRange[1], rowRange[1]), + dtype=numpy.float32) * scale[1] + origin[1]) + + else: # General case: use bilinear interpolation + + # Ensure startPt <= endPt + if (startPt[1] > endPt[1] or ( + startPt[1] == endPt[1] and startPt[0] > endPt[0])): + startPt, endPt = endPt, startPt + + if method == 'none': + profile = None + else: + profile = [] + for slice_idx in range(currentData3D.shape[0]): + bilinear = BilinearImage(currentData3D[slice_idx, :, :]) + + profile.append(bilinear.profile_line( + (startPt[0] - 0.5, startPt[1] - 0.5), + (endPt[0] - 0.5, endPt[1] - 0.5), + roiWidth, + method=method)) + profile = numpy.array(profile) + + # Extend ROI with half a pixel on each end, and + # Convert back to plot coords (x, y) + length = numpy.sqrt((endPt[0] - startPt[0]) ** 2 + + (endPt[1] - startPt[1]) ** 2) + dRow = (endPt[0] - startPt[0]) / length + dCol = (endPt[1] - startPt[1]) / length + + # Extend ROI with half a pixel on each end + roiStartPt = startPt[0] - 0.5 * dRow, startPt[1] - 0.5 * dCol + roiEndPt = endPt[0] + 0.5 * dRow, endPt[1] + 0.5 * dCol + + # Rotate deltas by 90 degrees to apply line width + dRow, dCol = dCol, -dRow + + area = ( + numpy.array((roiStartPt[1] - 0.5 * roiWidth * dCol, + roiStartPt[1] + 0.5 * roiWidth * dCol, + roiEndPt[1] + 0.5 * roiWidth * dCol, + roiEndPt[1] - 0.5 * roiWidth * dCol), + dtype=numpy.float32) * scale[0] + origin[0], + numpy.array((roiStartPt[0] - 0.5 * roiWidth * dRow, + roiStartPt[0] + 0.5 * roiWidth * dRow, + roiEndPt[0] + 0.5 * roiWidth * dRow, + roiEndPt[0] - 0.5 * roiWidth * dRow), + dtype=numpy.float32) * scale[1] + origin[1]) + + # Convert start and end points back to plot coords + y0 = startPt[0] * scale[1] + origin[1] + x0 = startPt[1] * scale[0] + origin[0] + y1 = endPt[0] * scale[1] + origin[1] + x1 = endPt[1] * scale[0] + origin[0] + + if startPt[1] == endPt[1]: + profileName = '{xlabel} = %g; {ylabel} = [%g, %g]' % (x0, y0, y1) + if method == 'none': + coords = None + else: + coords = numpy.arange(len(profile[0]), dtype=numpy.float32) + coords = coords * scale[1] + y0 + xLabel = '{ylabel}' + + elif startPt[0] == endPt[0]: + profileName = '{ylabel} = %g; {xlabel} = [%g, %g]' % (y0, x0, x1) + if method == 'none': + coords = None + else: + coords = numpy.arange(len(profile[0]), dtype=numpy.float32) + coords = coords * scale[0] + x0 + xLabel = '{xlabel}' + + else: + m = (y1 - y0) / (x1 - x0) + b = y0 - m * x0 + profileName = '{ylabel} = %g * {xlabel} %+g' % (m, b) + if method == 'none': + coords = None + else: + coords = numpy.linspace(x0, x1, len(profile[0]), + endpoint=True, + dtype=numpy.float32) + xLabel = '{xlabel}' + + return coords, profile, area, profileName, xLabel diff --git a/silx/gui/plot/tools/profile/editors.py b/silx/gui/plot/tools/profile/editors.py new file mode 100644 index 0000000..80e0452 --- /dev/null +++ b/silx/gui/plot/tools/profile/editors.py @@ -0,0 +1,307 @@ +# coding: utf-8 +# /*########################################################################## +# +# 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 +# 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 editors which are used to custom profile ROI properties. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "28/06/2018" + +import logging + +from silx.gui import qt + +from silx.gui.utils import blockSignals +from silx.gui.plot.PlotToolButtons import ProfileOptionToolButton +from silx.gui.plot.PlotToolButtons import ProfileToolButton +from . import rois +from . import core + + +_logger = logging.getLogger(__name__) + + +class _NoProfileRoiEditor(qt.QWidget): + + sigDataCommited = qt.Signal() + + def setEditorData(self, roi): + pass + + def setRoiData(self, roi): + pass + + +class _DefaultImageProfileRoiEditor(qt.QWidget): + + sigDataCommited = qt.Signal() + + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent=parent) + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self._initLayout(layout) + + def _initLayout(self, layout): + self._lineWidth = qt.QSpinBox(self) + self._lineWidth.setRange(1, 1000) + self._lineWidth.setValue(1) + self._lineWidth.valueChanged[int].connect(self._widgetChanged) + + self._methodsButton = ProfileOptionToolButton(parent=self, plot=None) + self._methodsButton.sigMethodChanged.connect(self._widgetChanged) + + label = qt.QLabel('W:') + label.setToolTip("Line width in pixels") + layout.addWidget(label) + layout.addWidget(self._lineWidth) + layout.addWidget(self._methodsButton) + + def _widgetChanged(self, value=None): + self.commitData() + + def commitData(self): + self.sigDataCommited.emit() + + def setEditorData(self, roi): + with blockSignals(self._lineWidth): + self._lineWidth.setValue(roi.getProfileLineWidth()) + with blockSignals(self._methodsButton): + method = roi.getProfileMethod() + self._methodsButton.setMethod(method) + + def setRoiData(self, roi): + lineWidth = self._lineWidth.value() + roi.setProfileLineWidth(lineWidth) + method = self._methodsButton.getMethod() + roi.setProfileMethod(method) + + +class _DefaultImageStackProfileRoiEditor(_DefaultImageProfileRoiEditor): + + def _initLayout(self, layout): + super(_DefaultImageStackProfileRoiEditor, self)._initLayout(layout) + self._profileDim = ProfileToolButton(parent=self, plot=None) + self._profileDim.sigDimensionChanged.connect(self._widgetChanged) + layout.addWidget(self._profileDim) + + def setEditorData(self, roi): + super(_DefaultImageStackProfileRoiEditor, self).setEditorData(roi) + with blockSignals(self._profileDim): + kind = roi.getProfileType() + dim = {"1D": 1, "2D": 2}[kind] + self._profileDim.setDimension(dim) + + def setRoiData(self, roi): + super(_DefaultImageStackProfileRoiEditor, self).setRoiData(roi) + dim = self._profileDim.getDimension() + kind = {1: "1D", 2: "2D"}[dim] + roi.setProfileType(kind) + + +class _DefaultScatterProfileRoiEditor(qt.QWidget): + + sigDataCommited = qt.Signal() + + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent=parent) + + self._nPoints = qt.QSpinBox(self) + self._nPoints.setRange(1, 9999) + self._nPoints.setValue(1024) + self._nPoints.valueChanged[int].connect(self.__widgetChanged) + + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + label = qt.QLabel('Samples:') + label.setToolTip("Number of sample points of the profile") + layout.addWidget(label) + layout.addWidget(self._nPoints) + + def __widgetChanged(self, value=None): + self.commitData() + + def commitData(self): + self.sigDataCommited.emit() + + def setEditorData(self, roi): + with blockSignals(self._nPoints): + self._nPoints.setValue(roi.getNPoints()) + + def setRoiData(self, roi): + nPoints = self._nPoints.value() + roi.setNPoints(nPoints) + + +class ProfileRoiEditorAction(qt.QWidgetAction): + """ + Action displaying GUI to edit the selected ROI. + + :param qt.QWidget parent: Parent widget + """ + def __init__(self, parent=None): + super(ProfileRoiEditorAction, self).__init__(parent) + self.__roiManager = None + self.__roi = None + self.__inhibiteReentance = None + + def createWidget(self, parent): + """Inherit the method to create a new editor""" + widget = qt.QWidget(parent) + layout = qt.QHBoxLayout(widget) + if isinstance(parent, qt.QMenu): + margins = layout.contentsMargins() + layout.setContentsMargins(margins.left(), 0, margins.right(), 0) + else: + layout.setContentsMargins(0, 0, 0, 0) + + editorClass = self.getEditorClass(self.__roi) + editor = editorClass(parent) + editor.setEditorData(self.__roi) + self.__setEditor(widget, editor) + return widget + + def deleteWidget(self, widget): + """Inherit the method to delete an editor""" + self.__setEditor(widget, None) + return qt.QWidgetAction.deleteWidget(self, widget) + + def _getEditor(self, widget): + """Returns the editor contained in the widget holder""" + layout = widget.layout() + if layout.count() == 0: + return None + return layout.itemAt(0).widget() + + def setRoiManager(self, roiManager): + """ + Connect this action to a ROI manager. + + :param RegionOfInterestManager roiManager: A ROI manager + """ + if self.__roiManager is roiManager: + return + if self.__roiManager is not None: + self.__roiManager.sigCurrentRoiChanged.disconnect(self.__currentRoiChanged) + self.__roiManager = roiManager + if self.__roiManager is not None: + self.__roiManager.sigCurrentRoiChanged.connect(self.__currentRoiChanged) + self.__currentRoiChanged(roiManager.getCurrentRoi()) + + def __currentRoiChanged(self, roi): + """Handle changes of the selected ROI""" + if roi is not None and not isinstance(roi, core.ProfileRoiMixIn): + return + self.setProfileRoi(roi) + + def setProfileRoi(self, roi): + """Set a profile ROI to edit. + + :param ProfileRoiMixIn roi: A profile ROI + """ + if self.__roi is roi: + return + if self.__roi is not None: + self.__roi.sigProfilePropertyChanged.disconnect(self.__roiPropertyChanged) + self.__roi = roi + if self.__roi is not None: + self.__roi.sigProfilePropertyChanged.connect(self.__roiPropertyChanged) + self._updateWidgets() + + def getRoiProfile(self): + """Returns the edited profile ROI. + + :rtype: ProfileRoiMixIn + """ + return self.__roi + + def __roiPropertyChanged(self): + """Handle changes on the property defining the ROI. + """ + self._updateWidgetValues() + + def __setEditor(self, widget, editor): + """Set the editor to display. + + :param qt.QWidget editor: The editor to display + """ + previousEditor = self._getEditor(widget) + if previousEditor is editor: + return + layout = widget.layout() + if previousEditor is not None: + previousEditor.sigDataCommited.disconnect(self._editorDataCommited) + layout.removeWidget(previousEditor) + previousEditor.deleteLater() + if editor is not None: + editor.sigDataCommited.connect(self._editorDataCommited) + layout.addWidget(editor) + + def getEditorClass(self, roi): + """Returns the editor class to use according to the ROI.""" + if roi is None: + editorClass = _NoProfileRoiEditor + elif isinstance(roi, (rois._DefaultImageStackProfileRoiMixIn, + rois.ProfileImageStackCrossROI)): + # Must be done before the default image ROI + # Cause ImageStack ROIs inherit from Image ROIs + editorClass = _DefaultImageStackProfileRoiEditor + elif isinstance(roi, (rois._DefaultImageProfileRoiMixIn, + rois.ProfileImageCrossROI)): + editorClass = _DefaultImageProfileRoiEditor + elif isinstance(roi, (rois._DefaultScatterProfileRoiMixIn, + rois.ProfileScatterCrossROI)): + editorClass = _DefaultScatterProfileRoiEditor + else: + # Unsupported + editorClass = _NoProfileRoiEditor + return editorClass + + def _updateWidgets(self): + """Update the kind of editor to display, according to the selected + profile ROI.""" + parent = self.parent() + editorClass = self.getEditorClass(self.__roi) + for widget in self.createdWidgets(): + editor = editorClass(parent) + editor.setEditorData(self.__roi) + self.__setEditor(widget, editor) + + def _updateWidgetValues(self): + """Update the content of the displayed editor, according to the + selected profile ROI.""" + for widget in self.createdWidgets(): + editor = self._getEditor(widget) + if self.__inhibiteReentance is editor: + continue + editor.setEditorData(self.__roi) + + def _editorDataCommited(self): + """Handle changes from the editor.""" + editor = self.sender() + if self.__roi is not None: + self.__inhibiteReentance = editor + editor.setRoiData(self.__roi) + self.__inhibiteReentance = None diff --git a/silx/gui/plot/tools/profile/manager.py b/silx/gui/plot/tools/profile/manager.py new file mode 100644 index 0000000..4d467f0 --- /dev/null +++ b/silx/gui/plot/tools/profile/manager.py @@ -0,0 +1,1059 @@ +# coding: utf-8 +# /*########################################################################## +# +# 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 +# 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 a manager to compute and display profiles. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "28/06/2018" + +import logging +import weakref + +from silx.gui import qt +from silx.gui import colors +from silx.gui import utils + +from silx.utils.weakref import WeakMethodProxy +from silx.gui import icons +from silx.gui.plot import PlotWidget +from silx.gui.plot.tools.roi import RegionOfInterestManager +from silx.gui.plot.tools.roi import CreateRoiModeAction +from silx.gui.plot import items +from silx.gui.qt import silxGlobalThreadPool +from silx.gui.qt import inspect +from . import rois +from . import core +from . import editors + + +_logger = logging.getLogger(__name__) + + +class _RunnableComputeProfile(qt.QRunnable): + """Runner to process profiles + + :param qt.QThreadPool threadPool: The thread which will be used to + execute this runner. It is used to update the used signals + :param ~silx.gui.plot.items.Item item: Item in which the profile is + computed + :param ~silx.gui.plot.tools.profile.core.ProfileRoiMixIn roi: ROI + defining the profile shape and other characteristics + """ + + class _Signals(qt.QObject): + """Signal holder""" + resultReady = qt.Signal(object, object) + runnerFinished = qt.Signal(object) + + def __init__(self, threadPool, item, roi): + """Constructor + """ + super(_RunnableComputeProfile, self).__init__() + self._signals = self._Signals() + self._signals.moveToThread(threadPool.thread()) + self._item = item + self._roi = roi + + def autoDelete(self): + return False + + def getRoi(self): + """Returns the ROI in which the runner will compute a profile. + + :rtype: ~silx.gui.plot.tools.profile.core.ProfileRoiMixIn + """ + return self._roi + + @property + def resultReady(self): + """Signal emitted when the result of the computation is available. + + This signal provides 2 values: The ROI, and the computation result. + """ + return self._signals.resultReady + + @property + def runnerFinished(self): + """Signal emitted when runner have finished. + + This signal provides a single value: the runner itself. + """ + return self._signals.runnerFinished + + def run(self): + """Process the profile computation. + """ + try: + profileData = self._roi.computeProfile(self._item) + except Exception: + _logger.error("Error while computing profile", exc_info=True) + else: + self.resultReady.emit(self._roi, profileData) + self.runnerFinished.emit(self) + + +class ProfileWindow(qt.QMainWindow): + """ + Display a computed profile. + + The content can be described using :meth:`setRoiProfile` if the source of + the profile is a profile ROI, and :meth:`setProfile` for the data content. + """ + + sigClose = qt.Signal() + """Emitted by :meth:`closeEvent` (e.g. when the window is closed + through the window manager's close icon).""" + + def __init__(self, parent=None, backend=None): + qt.QMainWindow.__init__(self, parent=parent, flags=qt.Qt.Dialog) + + self.setWindowTitle('Profile window') + self._plot1D = None + self._plot2D = None + self._backend = backend + self._data = None + + widget = qt.QWidget() + self._layout = qt.QStackedLayout(widget) + self._layout.setContentsMargins(0, 0, 0, 0) + self.setCentralWidget(widget) + + def prepareWidget(self, roi): + """Called before the show to prepare the window to use with + a specific ROI.""" + if isinstance(roi, rois._DefaultImageStackProfileRoiMixIn): + profileType = roi.getProfileType() + else: + profileType = "1D" + if profileType == "1D": + self.getPlot1D() + elif profileType == "2D": + self.getPlot2D() + + def createPlot1D(self, parent, backend): + """Inherit this function to create your own plot to render 1D + profiles. The default value is a `Plot1D`. + + :param parent: The parent of this widget or None. + :param backend: The backend to use for the plot. + See :class:`PlotWidget` for the list of supported backend. + :rtype: PlotWidget + """ + # import here to avoid circular import + from ...PlotWindow import Plot1D + plot = Plot1D(parent=parent, backend=backend) + plot.setDataMargins(yMinMargin=0.1, yMaxMargin=0.1) + plot.setGraphYLabel('Profile') + plot.setGraphXLabel('') + return plot + + def createPlot2D(self, parent, backend): + """Inherit this function to create your own plot to render 2D + profiles. The default value is a `Plot2D`. + + :param parent: The parent of this widget or None. + :param backend: The backend to use for the plot. + See :class:`PlotWidget` for the list of supported backend. + :rtype: PlotWidget + """ + # import here to avoid circular import + from ...PlotWindow import Plot2D + return Plot2D(parent=parent, backend=backend) + + def getPlot1D(self, init=True): + """Return the current plot used to display curves and create it if it + does not yet exists and `init` is True. Else returns None.""" + if not init: + return self._plot1D + if self._plot1D is None: + self._plot1D = self.createPlot1D(self, self._backend) + self._layout.addWidget(self._plot1D) + return self._plot1D + + def _showPlot1D(self): + plot = self.getPlot1D() + self._layout.setCurrentWidget(plot) + + def getPlot2D(self, init=True): + """Return the current plot used to display image and create it if it + does not yet exists and `init` is True. Else returns None.""" + if not init: + return self._plot2D + if self._plot2D is None: + self._plot2D = self.createPlot2D(parent=self, backend=self._backend) + self._layout.addWidget(self._plot2D) + return self._plot2D + + def _showPlot2D(self): + plot = self.getPlot2D() + self._layout.setCurrentWidget(plot) + + def getCurrentPlotWidget(self): + return self._layout.currentWidget() + + def closeEvent(self, qCloseEvent): + self.sigClose.emit() + qCloseEvent.accept() + + def setRoiProfile(self, roi): + """Set the profile ROI which it the source of the following data + to display. + + :param ProfileRoiMixIn roi: The profile ROI data source + """ + if roi is None: + return + self.__color = colors.rgba(roi.getColor()) + + def _setImageProfile(self, data): + """ + Setup the window to display a new profile data which is represented + by an image. + + :param core.ImageProfileData data: Computed data profile + """ + plot = self.getPlot2D() + + plot.clear() + plot.setGraphTitle(data.title) + plot.getXAxis().setLabel(data.xLabel) + + + coords = data.coords + colormap = data.colormap + profileScale = (coords[-1] - coords[0]) / data.profile.shape[1], 1 + plot.addImage(data.profile, + legend="profile", + colormap=colormap, + origin=(coords[0], 0), + scale=profileScale) + plot.getYAxis().setLabel("Frame index (depth)") + + self._showPlot2D() + + def _setCurveProfile(self, data): + """ + Setup the window to display a new profile data which is represented + by a curve. + + :param core.CurveProfileData data: Computed data profile + """ + plot = self.getPlot1D() + + plot.clear() + plot.setGraphTitle(data.title) + plot.getXAxis().setLabel(data.xLabel) + plot.getYAxis().setLabel(data.yLabel) + + plot.addCurve(data.coords, + data.profile, + legend="level", + color=self.__color) + + self._showPlot1D() + + def _setRgbaProfile(self, data): + """ + Setup the window to display a new profile data which is represented + by a curve. + + :param core.RgbaProfileData data: Computed data profile + """ + plot = self.getPlot1D() + + plot.clear() + plot.setGraphTitle(data.title) + plot.getXAxis().setLabel(data.xLabel) + plot.getYAxis().setLabel(data.yLabel) + + self._showPlot1D() + + plot.addCurve(data.coords, data.profile, + legend="level", color="black") + plot.addCurve(data.coords, data.profile_r, + legend="red", color="red") + plot.addCurve(data.coords, data.profile_g, + legend="green", color="green") + plot.addCurve(data.coords, data.profile_b, + legend="blue", color="blue") + if data.profile_a is not None: + plot.addCurve(data.coords, data.profile_a, legend="alpha", color="gray") + + def clear(self): + """Clear the window profile""" + plot = self.getPlot1D(init=False) + if plot is not None: + plot.clear() + plot = self.getPlot2D(init=False) + if plot is not None: + plot.clear() + + def getProfile(self): + """Returns the profile data which is displayed""" + return self.__data + + def setProfile(self, data): + """ + Setup the window to display a new profile data. + + This method dispatch the result to a specific method according to the + data type. + + :param data: Computed data profile + """ + self.__data = data + if data is None: + self.clear() + elif isinstance(data, core.ImageProfileData): + self._setImageProfile(data) + elif isinstance(data, core.RgbaProfileData): + self._setRgbaProfile(data) + elif isinstance(data, core.CurveProfileData): + self._setCurveProfile(data) + else: + raise TypeError("Unsupported type %s" % type(data)) + + +class _ClearAction(qt.QAction): + """Action to clear the profile manager + + The action is only enabled if something can be cleaned up. + """ + + def __init__(self, parent, profileManager): + super(_ClearAction, self).__init__(parent) + self.__profileManager = weakref.ref(profileManager) + icon = icons.getQIcon('profile-clear') + self.setIcon(icon) + self.setText('Clear profile') + self.setToolTip('Clear the profiles') + self.setCheckable(False) + self.setEnabled(False) + self.triggered.connect(profileManager.clearProfile) + plot = profileManager.getPlotWidget() + roiManager = profileManager.getRoiManager() + plot.sigInteractiveModeChanged.connect(self.__modeUpdated) + roiManager.sigRoiChanged.connect(self.__roiListUpdated) + + def getProfileManager(self): + return self.__profileManager() + + def __roiListUpdated(self): + self.__update() + + def __modeUpdated(self, source): + self.__update() + + def __update(self): + profileManager = self.getProfileManager() + if profileManager is None: + return + roiManager = profileManager.getRoiManager() + if roiManager is None: + return + enabled = roiManager.isStarted() or len(roiManager.getRois()) > 0 + self.setEnabled(enabled) + + +class _StoreLastParamBehavior(qt.QObject): + """This object allow to store and restore the properties of the ROI + profiles""" + + def __init__(self, parent): + assert isinstance(parent, ProfileManager) + super(_StoreLastParamBehavior, self).__init__(parent=parent) + self.__properties = {} + self.__profileRoi = None + self.__filter = utils.LockReentrant() + + def _roi(self): + """Return the spied ROI""" + if self.__profileRoi is None: + return None + roi = self.__profileRoi() + if roi is None: + self.__profileRoi = None + return roi + + def setProfileRoi(self, roi): + """Set a profile ROI to spy. + + :param ProfileRoiMixIn roi: A profile ROI + """ + previousRoi = self._roi() + if previousRoi is roi: + return + if previousRoi is not None: + previousRoi.sigProfilePropertyChanged.disconnect(self._profilePropertyChanged) + self.__profileRoi = None if roi is None else weakref.ref(roi) + if roi is not None: + roi.sigProfilePropertyChanged.connect(self._profilePropertyChanged) + + def _profilePropertyChanged(self): + """Handle changes on the properties defining the profile ROI. + """ + if self.__filter.locked(): + return + roi = self.sender() + self.storeProperties(roi) + + def storeProperties(self, roi): + if isinstance(roi, (rois._DefaultImageStackProfileRoiMixIn, + rois.ProfileImageStackCrossROI)): + self.__properties["method"] = roi.getProfileMethod() + self.__properties["line-width"] = roi.getProfileLineWidth() + self.__properties["type"] = roi.getProfileType() + elif isinstance(roi, (rois._DefaultImageProfileRoiMixIn, + rois.ProfileImageCrossROI)): + self.__properties["method"] = roi.getProfileMethod() + self.__properties["line-width"] = roi.getProfileLineWidth() + elif isinstance(roi, (rois._DefaultScatterProfileRoiMixIn, + rois.ProfileScatterCrossROI)): + self.__properties["npoints"] = roi.getNPoints() + + def restoreProperties(self, roi): + with self.__filter: + if isinstance(roi, (rois._DefaultImageStackProfileRoiMixIn, + rois.ProfileImageStackCrossROI)): + value = self.__properties.get("method", None) + if value is not None: + roi.setProfileMethod(value) + value = self.__properties.get("line-width", None) + if value is not None: + roi.setProfileLineWidth(value) + value = self.__properties.get("type", None) + if value is not None: + roi.setProfileType(value) + elif isinstance(roi, (rois._DefaultImageProfileRoiMixIn, + rois.ProfileImageCrossROI)): + value = self.__properties.get("method", None) + if value is not None: + roi.setProfileMethod(value) + value = self.__properties.get("line-width", None) + if value is not None: + roi.setProfileLineWidth(value) + elif isinstance(roi, (rois._DefaultScatterProfileRoiMixIn, + rois.ProfileScatterCrossROI)): + value = self.__properties.get("npoints", None) + if value is not None: + roi.setNPoints(value) + + +class ProfileManager(qt.QObject): + """Base class for profile management tools + + :param plot: :class:`~silx.gui.plot.PlotWidget` on which to operate. + :param plot: :class:`~silx.gui.plot.tools.roi.RegionOfInterestManager` + on which to operate. + """ + def __init__(self, parent=None, plot=None, roiManager=None): + super(ProfileManager, self).__init__(parent) + + assert isinstance(plot, PlotWidget) + self._plotRef = weakref.ref( + plot, WeakMethodProxy(self.__plotDestroyed)) + + # Set-up interaction manager + if roiManager is None: + roiManager = RegionOfInterestManager(plot) + + self._roiManagerRef = weakref.ref(roiManager) + self._rois = [] + self._pendingRunners = [] + """List of ROIs which have to be updated""" + + self.__reentrantResults = {} + """Store reentrant result to avoid to skip some of them + cause the implementation uses a QEventLoop.""" + + self._profileWindowClass = ProfileWindow + """Class used to display the profile results""" + + self._computedProfiles = 0 + """Statistics for tests""" + + self.__itemTypes = [] + """Kind of items to use""" + + self.__tracking = False + """Is the plot active items are tracked""" + + self.__useColorFromCursor = True + """If true, force the ROI color with the colormap marker color""" + + self._item = None + """The selected item""" + + self.__singleProfileAtATime = True + """When it's true, only a single profile is displayed at a time.""" + + self._previousWindowGeometry = [] + + self._storeProperties = _StoreLastParamBehavior(self) + """If defined the profile properties of the last ROI are reused to the + new created ones""" + + # Listen to plot limits changed + plot.getXAxis().sigLimitsChanged.connect(self.requestUpdateAllProfile) + plot.getYAxis().sigLimitsChanged.connect(self.requestUpdateAllProfile) + + roiManager.sigInteractiveModeFinished.connect(self.__interactionFinished) + roiManager.sigInteractiveRoiCreated.connect(self.__roiCreated) + roiManager.sigRoiAdded.connect(self.__roiAdded) + roiManager.sigRoiAboutToBeRemoved.connect(self.__roiRemoved) + + def setSingleProfile(self, enable): + """ + Enable or disable the single profile mode. + + In single mode, the manager enforce a single ROI at the same + time. A new one will remove the previous one. + + If this mode is not enabled, many ROIs can be created, and many + profile windows will be displayed. + """ + self.__singleProfileAtATime = enable + + def isSingleProfile(self): + """ + Returns true if the manager is in a single profile mode. + + :rtype: bool + """ + return self.__singleProfileAtATime + + def __interactionFinished(self): + """Handle end of interactive mode""" + pass + + def __roiAdded(self, roi): + """Handle new ROI""" + # Filter out non profile ROIs + if not isinstance(roi, core.ProfileRoiMixIn): + return + self.__addProfile(roi) + + def __roiRemoved(self, roi): + """Handle removed ROI""" + # Filter out non profile ROIs + if not isinstance(roi, core.ProfileRoiMixIn): + return + self.__removeProfile(roi) + + def createProfileAction(self, profileRoiClass, parent=None): + """Create an action from a class of ProfileRoi + + :param core.ProfileRoiMixIn profileRoiClass: A class of a profile ROI + :param qt.QObject parent: The parent of the created action. + :rtype: qt.QAction + """ + if not issubclass(profileRoiClass, core.ProfileRoiMixIn): + raise TypeError("Type %s not expected" % type(profileRoiClass)) + roiManager = self.getRoiManager() + action = CreateRoiModeAction(parent, roiManager, profileRoiClass) + if hasattr(profileRoiClass, "ICON"): + action.setIcon(icons.getQIcon(profileRoiClass.ICON)) + if hasattr(profileRoiClass, "NAME"): + def articulify(word): + """Add an an/a article in the front of the word""" + first = word[1] if word[0] == 'h' else word[0] + if first in "aeiou": + return "an " + word + return "a " + word + action.setText('Define %s' % articulify(profileRoiClass.NAME)) + action.setToolTip('Enables %s selection mode' % profileRoiClass.NAME) + action.setSingleShot(True) + return action + + def createClearAction(self, parent): + """Create an action to clean up the plot from the profile ROIs. + + :param qt.QObject parent: The parent of the created action. + :rtype: qt.QAction + """ + action = _ClearAction(parent, self) + return action + + def createImageActions(self, parent): + """Create actions designed for image items. This actions created + new ROIs. + + :param qt.QObject parent: The parent of the created action. + :rtype: List[qt.QAction] + """ + profileClasses = [ + rois.ProfileImageHorizontalLineROI, + rois.ProfileImageVerticalLineROI, + rois.ProfileImageLineROI, + rois.ProfileImageDirectedLineROI, + rois.ProfileImageCrossROI, + ] + return [self.createProfileAction(pc, parent=parent) for pc in profileClasses] + + def createScatterActions(self, parent): + """Create actions designed for scatter items. This actions created + new ROIs. + + :param qt.QObject parent: The parent of the created action. + :rtype: List[qt.QAction] + """ + profileClasses = [ + rois.ProfileScatterHorizontalLineROI, + rois.ProfileScatterVerticalLineROI, + rois.ProfileScatterLineROI, + rois.ProfileScatterCrossROI, + ] + return [self.createProfileAction(pc, parent=parent) for pc in profileClasses] + + def createScatterSliceActions(self, parent): + """Create actions designed for regular scatter items. This actions + created new ROIs. + + This ROIs was designed to use the input data without interpolation, + like you could do with an image. + + :param qt.QObject parent: The parent of the created action. + :rtype: List[qt.QAction] + """ + profileClasses = [ + rois.ProfileScatterHorizontalSliceROI, + rois.ProfileScatterVerticalSliceROI, + rois.ProfileScatterCrossSliceROI, + ] + return [self.createProfileAction(pc, parent=parent) for pc in profileClasses] + + def createImageStackActions(self, parent): + """Create actions designed for stack image items. This actions + created new ROIs. + + This ROIs was designed to create both profile on the displayed image + and profile on the full stack (2D result). + + :param qt.QObject parent: The parent of the created action. + :rtype: List[qt.QAction] + """ + profileClasses = [ + rois.ProfileImageStackHorizontalLineROI, + rois.ProfileImageStackVerticalLineROI, + rois.ProfileImageStackLineROI, + rois.ProfileImageStackCrossROI, + ] + return [self.createProfileAction(pc, parent=parent) for pc in profileClasses] + + def createEditorAction(self, parent): + """Create an action containing GUI to edit the selected profile ROI. + + :param qt.QObject parent: The parent of the created action. + :rtype: qt.QAction + """ + action = editors.ProfileRoiEditorAction(parent) + action.setRoiManager(self.getRoiManager()) + return action + + def setItemType(self, image=False, scatter=False): + """Set the item type to use and select the active one. + + :param bool image: Image item are allowed + :param bool scatter: Scatter item are allowed + """ + self.__itemTypes = [] + plot = self.getPlotWidget() + item = None + if image: + self.__itemTypes.append("image") + item = plot.getActiveImage() + if scatter: + self.__itemTypes.append("scatter") + if item is None: + item = plot.getActiveScatter() + self.setPlotItem(item) + + def setProfileWindowClass(self, profileWindowClass): + """Set the class which will be instantiated to display profile result. + """ + self._profileWindowClass = profileWindowClass + + def setActiveItemTracking(self, tracking): + """Enable/disable the tracking of the active item of the plot. + + :param bool tracking: Tracking mode + """ + if self.__tracking == tracking: + return + plot = self.getPlotWidget() + if self.__tracking: + plot.sigActiveImageChanged.disconnect(self._activeImageChanged) + plot.sigActiveScatterChanged.disconnect(self._activeScatterChanged) + self.__tracking = tracking + if self.__tracking: + plot.sigActiveImageChanged.connect(self.__activeImageChanged) + plot.sigActiveScatterChanged.connect(self.__activeScatterChanged) + + def setDefaultColorFromCursorColor(self, enabled): + """Enabled/disable the use of the colormap cursor color to display the + ROIs. + + If set, the manager will update the color of the profile ROIs using the + current colormap cursor color from the selected item. + """ + self.__useColorFromCursor = enabled + + def __activeImageChanged(self, previous, legend): + """Handle plot item selection""" + if "image" in self.__itemTypes: + plot = self.getPlotWidget() + item = plot.getImage(legend) + self.setPlotItem(item) + + def __activeScatterChanged(self, previous, legend): + """Handle plot item selection""" + if "scatter" in self.__itemTypes: + plot = self.getPlotWidget() + item = plot.getScatter(legend) + self.setPlotItem(item) + + def __roiCreated(self, roi): + """Handle ROI creation""" + # Filter out non profile ROIs + if isinstance(roi, core.ProfileRoiMixIn): + if self._storeProperties is not None: + # Initialize the properties with the previous ones + self._storeProperties.restoreProperties(roi) + + def __addProfile(self, profileRoi): + """Add a new ROI to the manager.""" + if profileRoi.getFocusProxy() is None: + if self._storeProperties is not None: + # Follow changes on properties + self._storeProperties.setProfileRoi(profileRoi) + if self.__singleProfileAtATime: + # FIXME: It would be good to reuse the windows to avoid blinking + self.clearProfile() + + profileRoi._setProfileManager(self) + self._updateRoiColor(profileRoi) + self._rois.append(profileRoi) + self.requestUpdateProfile(profileRoi) + + def __removeProfile(self, profileRoi): + """Remove a ROI from the manager.""" + window = self._disconnectProfileWindow(profileRoi) + if window is not None: + geometry = window.geometry() + self._previousWindowGeometry.append(geometry) + self.clearProfileWindow(window) + if profileRoi in self._rois: + self._rois.remove(profileRoi) + + def _disconnectProfileWindow(self, profileRoi): + """Handle profile window close.""" + window = profileRoi.getProfileWindow() + profileRoi.setProfileWindow(None) + return window + + def clearProfile(self): + """Clear the associated ROI profile""" + roiManager = self.getRoiManager() + for roi in list(self._rois): + if roi.getFocusProxy() is not None: + # Skip sub ROIs, it will be removed by their parents + continue + roiManager.removeRoi(roi) + + if not roiManager.isDrawing(): + # Clean the selected mode + roiManager.stop() + + def hasPendingOperations(self): + """Returns true if a thread is still computing or displaying a profile. + + :rtype: bool + """ + return len(self.__reentrantResults) > 0 or len(self._pendingRunners) > 0 + + def requestUpdateAllProfile(self): + """Request to update the profile of all the managed ROIs. + """ + for roi in self._rois: + self.requestUpdateProfile(roi) + + def requestUpdateProfile(self, profileRoi): + """Request to update a specific profile ROI. + + :param ~core.ProfileRoiMixIn profileRoi: + """ + if profileRoi.computeProfile is None: + return + threadPool = silxGlobalThreadPool() + + # Clean up deprecated runners + for runner in list(self._pendingRunners): + if not inspect.isValid(runner): + self._pendingRunners.remove(runner) + continue + if runner.getRoi() is profileRoi: + if threadPool.tryTake(runner): + self._pendingRunners.remove(runner) + + item = self.getPlotItem() + if item is None or not isinstance(item, profileRoi.ITEM_KIND): + # This item is not compatible with this profile + profileRoi._setPlotItem(None) + profileWindow = profileRoi.getProfileWindow() + if profileWindow is not None: + profileWindow.setProfile(None) + return + + profileRoi._setPlotItem(item) + runner = _RunnableComputeProfile(threadPool, item, profileRoi) + runner.runnerFinished.connect(self.__cleanUpRunner) + runner.resultReady.connect(self.__displayResult) + self._pendingRunners.append(runner) + threadPool.start(runner) + + def __cleanUpRunner(self, runner): + """Remove a thread pool runner from the list of hold tasks. + + Called at the termination of the runner. + """ + if runner in self._pendingRunners: + self._pendingRunners.remove(runner) + + def __displayResult(self, roi, profileData): + """Display the result of a ROI. + + :param ~core.ProfileRoiMixIn profileRoi: A managed ROI + :param ~core.CurveProfileData profileData: Computed data profile + """ + if roi in self.__reentrantResults: + # Store the data to process it in the main loop + # And not a sub loop created by initProfileWindow + # This also remove the duplicated requested + self.__reentrantResults[roi] = profileData + return + + self.__reentrantResults[roi] = profileData + self._computedProfiles = self._computedProfiles + 1 + window = roi.getProfileWindow() + if window is None: + plot = self.getPlotWidget() + window = self.createProfileWindow(plot, roi) + # roi.profileWindow have to be set before initializing the window + # Cause the initialization is using QEventLoop + roi.setProfileWindow(window) + self.initProfileWindow(window, roi) + window.show() + + lastData = self.__reentrantResults.pop(roi) + window.setProfile(lastData) + + def __plotDestroyed(self, ref): + """Handle finalization of PlotWidget + + :param ref: weakref to the plot + """ + self._plotRef = None + self._roiManagerRef = None + self._pendingRunners = [] + + def setPlotItem(self, item): + """Set the plot item focused by the profile manager. + + :param ~silx.gui.plot.items.Item item: A plot item + """ + previous = self.getPlotItem() + if previous is item: + return + if item is None: + self._item = None + else: + item.sigItemChanged.connect(self.__itemChanged) + self._item = weakref.ref(item) + self._updateRoiColors() + self.requestUpdateAllProfile() + + def getDefaultColor(self, item): + """Returns the default ROI color to use according to the given item. + + :param ~silx.gui.plot.items.item.Item item: AN item + :rtype: qt.QColor + """ + color = 'pink' + if isinstance(item, items.ColormapMixIn): + colormap = item.getColormap() + name = colormap.getName() + if name is not None: + color = colors.cursorColorForColormap(name) + color = colors.asQColor(color) + return color + + def _updateRoiColors(self): + """Update ROI color according to the item selection""" + if not self.__useColorFromCursor: + return + item = self.getPlotItem() + color = self.getDefaultColor(item) + for roi in self._rois: + roi.setColor(color) + + def _updateRoiColor(self, roi): + """Update a specific ROI according to the current selected item. + + :param RegionOfInterest roi: The ROI to update + """ + if not self.__useColorFromCursor: + return + item = self.getPlotItem() + color = self.getDefaultColor(item) + roi.setColor(color) + + def __itemChanged(self, changeType): + """Handle item changes. + """ + if changeType in (items.ItemChangedType.DATA, + items.ItemChangedType.POSITION, + items.ItemChangedType.SCALE): + self.requestUpdateAllProfile() + elif changeType == (items.ItemChangedType.COLORMAP): + self._updateRoiColors() + + def getPlotItem(self): + """Returns the item focused by the profile manager. + + :rtype: ~silx.gui.plot.items.Item + """ + if self._item is None: + return None + item = self._item() + if item is None: + self._item = None + return item + + def getPlotWidget(self): + """The plot associated to the profile manager. + + :rtype: ~silx.gui.plot.PlotWidget + """ + if self._plotRef is None: + return None + plot = self._plotRef() + if plot is None: + self._plotRef = None + return plot + + def getCurrentRoi(self): + """Returns the currently selected ROI, else None. + + :rtype: core.ProfileRoiMixIn + """ + roiManager = self.getRoiManager() + if roiManager is None: + return None + roi = roiManager.getCurrentRoi() + if not isinstance(roi, core.ProfileRoiMixIn): + return None + return roi + + def getRoiManager(self): + """Returns the used ROI manager + + :rtype: RegionOfInterestManager + """ + return self._roiManagerRef() + + def createProfileWindow(self, plot, roi): + """Create a new profile window. + + :param ~core.ProfileRoiMixIn roi: The plot containing the raw data + :param ~core.ProfileRoiMixIn roi: A managed ROI + :rtype: ~ProfileWindow + """ + return self._profileWindowClass(plot) + + def initProfileWindow(self, profileWindow, roi): + """This function is called just after the profile window creation in + order to initialize the window location. + + :param ~ProfileWindow profileWindow: + The profile window to initialize. + """ + # Enforce the use of one of the widgets + # To have the correct window size + profileWindow.prepareWidget(roi) + profileWindow.adjustSize() + + # Trick to avoid blinking while retrieving the right window size + # Display the window, hide it and wait for some event loops + profileWindow.show() + profileWindow.hide() + eventLoop = qt.QEventLoop(self) + for _ in range(10): + if not eventLoop.processEvents(): + break + + profileWindow.show() + if len(self._previousWindowGeometry) > 0: + geometry = self._previousWindowGeometry.pop() + profileWindow.setGeometry(geometry) + return + + window = self.getPlotWidget().window() + winGeom = window.frameGeometry() + qapp = qt.QApplication.instance() + desktop = qapp.desktop() + screenGeom = desktop.availableGeometry(window) + spaceOnLeftSide = winGeom.left() + spaceOnRightSide = screenGeom.width() - winGeom.right() + + profileGeom = profileWindow.frameGeometry() + profileWidth = profileGeom.width() + + # Align vertically to the center of the window + top = winGeom.top() + (winGeom.height() - profileGeom.height()) // 2 + + margin = 5 + if profileWidth < spaceOnRightSide: + # Place profile on the right + left = winGeom.right() + margin + elif profileWidth < spaceOnLeftSide: + # Place profile on the left + left = max(0, winGeom.left() - profileWidth - margin) + else: + # Move it as much as possible where there is more space + if spaceOnLeftSide > spaceOnRightSide: + left = 0 + else: + left = screenGeom.width() - profileGeom.width() + profileWindow.move(left, top) + + + def clearProfileWindow(self, profileWindow): + """Called when a profile window is not anymore needed. + + By default the window will be closed. But it can be + inherited to change this behavior. + """ + profileWindow.deleteLater() diff --git a/silx/gui/plot/tools/profile/rois.py b/silx/gui/plot/tools/profile/rois.py new file mode 100644 index 0000000..b49679c --- /dev/null +++ b/silx/gui/plot/tools/profile/rois.py @@ -0,0 +1,1168 @@ +# coding: utf-8 +# /*########################################################################## +# +# 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 +# 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 define ROIs for profile tools. + +.. inheritance-diagram:: + silx.gui.plot.tools.profile.rois + :top-classes: silx.gui.plot.tools.profile.core.ProfileRoiMixIn, silx.gui.plot.items.roi.RegionOfInterest + :parts: 1 + :private-bases: +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "03/04/2020" + +import numpy +import weakref +from concurrent.futures import CancelledError + +from silx.gui import colors + +from silx.gui.plot import items +from silx.gui.plot.items import roi as roi_items +from . import core +from silx.gui import utils +from .....utils.proxy import docstring + + +def _relabelAxes(plot, text): + """Relabel {xlabel} and {ylabel} from this text using the corresponding + plot axis label. If the axis label is empty, label it with "X" and "Y". + + :rtype: str + """ + xLabel = plot.getXAxis().getLabel() + if not xLabel: + xLabel = "X" + yLabel = plot.getYAxis().getLabel() + if not yLabel: + yLabel = "Y" + return text.format(xlabel=xLabel, ylabel=yLabel) + + +def _lineProfileTitle(x0, y0, x1, y1): + """Compute corresponding plot title + + This can be overridden to change title behavior. + + :param float x0: Profile start point X coord + :param float y0: Profile start point Y coord + :param float x1: Profile end point X coord + :param float y1: Profile end point Y coord + :return: Title to use + :rtype: str + """ + if x0 == x1: + title = '{xlabel} = %g; {ylabel} = [%g, %g]' % (x0, y0, y1) + elif y0 == y1: + title = '{ylabel} = %g; {xlabel} = [%g, %g]' % (y0, x0, x1) + else: + m = (y1 - y0) / (x1 - x0) + b = y0 - m * x0 + title = '{ylabel} = %g * {xlabel} %+g' % (m, b) + + return title + + +class _ImageProfileArea(items.Shape): + """This shape displays the location of pixels used to compute the + profile.""" + + def __init__(self, parentRoi): + items.Shape.__init__(self, "polygon") + color = colors.rgba(parentRoi.getColor()) + self.setColor(color) + self.setFill(True) + self.setOverlay(True) + self.setPoints([[0, 0], [0, 0]]) # Else it segfault + + self.__parentRoi = weakref.ref(parentRoi) + parentRoi.sigItemChanged.connect(self._updateAreaProperty) + parentRoi.sigRegionChanged.connect(self._updateArea) + parentRoi.sigProfilePropertyChanged.connect(self._updateArea) + parentRoi.sigPlotItemChanged.connect(self._updateArea) + + def getParentRoi(self): + if self.__parentRoi is None: + return None + parentRoi = self.__parentRoi() + if parentRoi is None: + self.__parentRoi = None + return parentRoi + + def _updateAreaProperty(self, event=None, checkVisibility=True): + parentRoi = self.sender() + if event == items.ItemChangedType.COLOR: + parentRoi._updateItemProperty(event, parentRoi, self) + elif event == items.ItemChangedType.VISIBLE: + if self.getPlotItem() is not None: + parentRoi._updateItemProperty(event, parentRoi, self) + + def _updateArea(self): + roi = self.getParentRoi() + item = roi.getPlotItem() + if item is None: + self.setVisible(False) + return + polygon = self._computePolygon(item) + self.setVisible(True) + polygon = numpy.array(polygon).T + self.setLineStyle("--") + self.setPoints(polygon, copy=False) + + def _computePolygon(self, item): + if not isinstance(item, items.ImageBase): + raise TypeError("Unexpected class %s" % type(item)) + + if isinstance(item, items.ImageData): + currentData = item.getData(copy=False) + elif isinstance(item, items.ImageRgba): + rgba = item.getData(copy=False) + currentData = rgba[..., 0] + + roi = self.getParentRoi() + origin = item.getOrigin() + scale = item.getScale() + _coords, _profile, area, _profileName, _xLabel = core.createProfile( + roiInfo=roi._getRoiInfo(), + currentData=currentData, + origin=origin, + scale=scale, + lineWidth=roi.getProfileLineWidth(), + method="none") + return area + + +class _SliceProfileArea(items.Shape): + """This shape displays the location a profile in a scatter. + + Each point used to compute the slice are linked together. + """ + + def __init__(self, parentRoi): + items.Shape.__init__(self, "polygon") + color = colors.rgba(parentRoi.getColor()) + self.setColor(color) + self.setFill(True) + self.setOverlay(True) + self.setPoints([[0, 0], [0, 0]]) # Else it segfault + + self.__parentRoi = weakref.ref(parentRoi) + parentRoi.sigItemChanged.connect(self._updateAreaProperty) + parentRoi.sigRegionChanged.connect(self._updateArea) + parentRoi.sigProfilePropertyChanged.connect(self._updateArea) + parentRoi.sigPlotItemChanged.connect(self._updateArea) + + def getParentRoi(self): + if self.__parentRoi is None: + return None + parentRoi = self.__parentRoi() + if parentRoi is None: + self.__parentRoi = None + return parentRoi + + def _updateAreaProperty(self, event=None, checkVisibility=True): + parentRoi = self.sender() + if event == items.ItemChangedType.COLOR: + parentRoi._updateItemProperty(event, parentRoi, self) + elif event == items.ItemChangedType.VISIBLE: + if self.getPlotItem() is not None: + parentRoi._updateItemProperty(event, parentRoi, self) + + def _updateArea(self): + roi = self.getParentRoi() + item = roi.getPlotItem() + if item is None: + self.setVisible(False) + return + polylines = self._computePolylines(roi, item) + if polylines is None: + self.setVisible(False) + return + self.setVisible(True) + self.setLineStyle("--") + self.setPoints(polylines, copy=False) + + def _computePolylines(self, roi, item): + slicing = roi._getSlice(item) + if slicing is None: + return None + xx, yy, _values, _xx_error, _yy_error = item.getData(copy=False) + xx, yy = xx[slicing], yy[slicing] + polylines = numpy.array((xx, yy)).T + if len(polylines) == 0: + return None + return polylines + + +class _DefaultImageProfileRoiMixIn(core.ProfileRoiMixIn): + """Provide common behavior for silx default image profile ROI. + """ + + ITEM_KIND = items.ImageBase + + def __init__(self, parent=None): + core.ProfileRoiMixIn.__init__(self, parent=parent) + self.__method = "mean" + self.__width = 1 + self.sigRegionChanged.connect(self.__regionChanged) + self.sigPlotItemChanged.connect(self.__updateArea) + self.__area = _ImageProfileArea(self) + self.addItem(self.__area) + + def __regionChanged(self): + self.invalidateProfile() + self.__updateArea() + + def setProfileMethod(self, method): + """ + :param str method: method to compute the profile. Can be 'mean' or 'sum' + """ + if self.__method == method: + return + self.__method = method + self.invalidateProperties() + self.invalidateProfile() + + def getProfileMethod(self): + return self.__method + + def setProfileLineWidth(self, width): + if self.__width == width: + return + self.__width = width + self.__updateArea() + self.invalidateProperties() + self.invalidateProfile() + + def getProfileLineWidth(self): + return self.__width + + def __updateArea(self): + plotItem = self.getPlotItem() + if plotItem is None: + self.setLineStyle("-") + else: + self.setLineStyle("--") + + def _getRoiInfo(self): + """Wrapper to allow to reuse the previous Profile code. + + It would be good to remove it at one point. + """ + if isinstance(self, roi_items.HorizontalLineROI): + lineProjectionMode = 'X' + y = self.getPosition() + roiStart = (0, y) + roiEnd = (1, y) + elif isinstance(self, roi_items.VerticalLineROI): + lineProjectionMode = 'Y' + x = self.getPosition() + roiStart = (x, 0) + roiEnd = (x, 1) + elif isinstance(self, roi_items.LineROI): + lineProjectionMode = 'D' + roiStart, roiEnd = self.getEndPoints() + else: + assert False + + return roiStart, roiEnd, lineProjectionMode + + def computeProfile(self, item): + if not isinstance(item, items.ImageBase): + raise TypeError("Unexpected class %s" % type(item)) + + origin = item.getOrigin() + scale = item.getScale() + method = self.getProfileMethod() + lineWidth = self.getProfileLineWidth() + + def createProfile2(currentData): + coords, profile, _area, profileName, xLabel = core.createProfile( + roiInfo=self._getRoiInfo(), + currentData=currentData, + origin=origin, + scale=scale, + lineWidth=lineWidth, + method=method) + return coords, profile, profileName, xLabel + + if isinstance(item, items.ImageData): + currentData = item.getData(copy=False) + elif isinstance(item, items.ImageRgba): + rgba = item.getData(copy=False) + is_uint8 = rgba.dtype.type == numpy.uint8 + # luminosity + if is_uint8: + rgba = rgba.astype(numpy.float) + currentData = 0.21 * rgba[..., 0] + 0.72 * rgba[..., 1] + 0.07 * rgba[..., 2] + + yLabel = "%s" % str(method).capitalize() + coords, profile, title, xLabel = createProfile2(currentData) + title = title + "; width = %d" % lineWidth + + # Use the axis names from the original plot + profileManager = self.getProfileManager() + plot = profileManager.getPlotWidget() + title = _relabelAxes(plot, title) + xLabel = _relabelAxes(plot, xLabel) + + if isinstance(item, items.ImageRgba): + rgba = item.getData(copy=False) + _coords, r, _profileName, _xLabel = createProfile2(rgba[..., 0]) + _coords, g, _profileName, _xLabel = createProfile2(rgba[..., 1]) + _coords, b, _profileName, _xLabel = createProfile2(rgba[..., 2]) + if rgba.shape[-1] == 4: + _coords, a, _profileName, _xLabel = createProfile2(rgba[..., 3]) + else: + a = [None] + data = core.RgbaProfileData( + coords=coords, + profile=profile[0], + profile_r=r[0], + profile_g=g[0], + profile_b=b[0], + profile_a=a[0], + title=title, + xLabel=xLabel, + yLabel=yLabel, + ) + else: + data = core.CurveProfileData( + coords=coords, + profile=profile[0], + title=title, + xLabel=xLabel, + yLabel=yLabel, + ) + return data + + +class ProfileImageHorizontalLineROI(roi_items.HorizontalLineROI, + _DefaultImageProfileRoiMixIn): + """ROI for an horizontal profile at a location of an image""" + + ICON = 'shape-horizontal' + NAME = 'horizontal line profile' + + def __init__(self, parent=None): + roi_items.HorizontalLineROI.__init__(self, parent=parent) + _DefaultImageProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileImageVerticalLineROI(roi_items.VerticalLineROI, + _DefaultImageProfileRoiMixIn): + """ROI for a vertical profile at a location of an image""" + + ICON = 'shape-vertical' + NAME = 'vertical line profile' + + def __init__(self, parent=None): + roi_items.VerticalLineROI.__init__(self, parent=parent) + _DefaultImageProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileImageLineROI(roi_items.LineROI, + _DefaultImageProfileRoiMixIn): + """ROI for an image profile between 2 points. + + The X profile of this ROI is the projecting into one of the x/y axes, + using its scale and its orientation. + """ + + ICON = 'shape-diagonal' + NAME = 'line profile' + + def __init__(self, parent=None): + roi_items.LineROI.__init__(self, parent=parent) + _DefaultImageProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileImageDirectedLineROI(roi_items.LineROI, + _DefaultImageProfileRoiMixIn): + """ROI for an image profile between 2 points. + + The X profile of the line is displayed projected into the line itself, + using its scale and its orientation. It's the distance from the origin. + """ + + ICON = 'shape-diagonal-directed' + NAME = 'directed line profile' + + def __init__(self, parent=None): + roi_items.LineROI.__init__(self, parent=parent) + _DefaultImageProfileRoiMixIn.__init__(self, parent=parent) + self._handleStart.setSymbol('o') + + def computeProfile(self, item): + if not isinstance(item, items.ImageBase): + raise TypeError("Unexpected class %s" % type(item)) + + from silx.image.bilinear import BilinearImage + + origin = item.getOrigin() + scale = item.getScale() + method = self.getProfileMethod() + lineWidth = self.getProfileLineWidth() + currentData = item.getData(copy=False) + + roiInfo = self._getRoiInfo() + roiStart, roiEnd, _lineProjectionMode = roiInfo + + startPt = ((roiStart[1] - origin[1]) / scale[1], + (roiStart[0] - origin[0]) / scale[0]) + endPt = ((roiEnd[1] - origin[1]) / scale[1], + (roiEnd[0] - origin[0]) / scale[0]) + + if numpy.array_equal(startPt, endPt): + return None + + bilinear = BilinearImage(currentData) + profile = bilinear.profile_line( + (startPt[0] - 0.5, startPt[1] - 0.5), + (endPt[0] - 0.5, endPt[1] - 0.5), + lineWidth, + method=method) + + # Compute the line size + lineSize = numpy.sqrt((roiEnd[1] - roiStart[1])**2 + + (roiEnd[0] - roiStart[0])**2) + coords = numpy.linspace(0, lineSize, len(profile), + endpoint=True, + dtype=numpy.float32) + + title = _lineProfileTitle(*roiStart, *roiEnd) + title = title + "; width = %d" % lineWidth + xLabel = "√({xlabel}²+{ylabel}²)" + yLabel = str(method).capitalize() + + # Use the axis names from the original plot + profileManager = self.getProfileManager() + plot = profileManager.getPlotWidget() + xLabel = _relabelAxes(plot, xLabel) + title = _relabelAxes(plot, title) + + data = core.CurveProfileData( + coords=coords, + profile=profile, + title=title, + xLabel=xLabel, + yLabel=yLabel, + ) + return data + + +class _ProfileCrossROI(roi_items.HandleBasedROI, core.ProfileRoiMixIn): + + """ROI to manage a cross of profiles + + It is managed using 2 sub ROIs for vertical and horizontal. + """ + + _kind = "Cross" + """Label for this kind of ROI""" + + _plotShape = "point" + """Plot shape which is used for the first interaction""" + + def __init__(self, parent=None): + roi_items.HandleBasedROI.__init__(self, parent=parent) + core.ProfileRoiMixIn.__init__(self, parent=parent) + self.sigRegionChanged.connect(self.__regionChanged) + self.sigAboutToBeRemoved.connect(self.__aboutToBeRemoved) + self.__position = 0, 0 + self.__vline = None + self.__hline = None + self.__handle = self.addHandle() + self.__handleLabel = self.addLabelHandle() + self.__handleLabel.setText(self.getName()) + self.__inhibitReentance = utils.LockReentrant() + self.computeProfile = None + self.sigItemChanged.connect(self.__updateLineProperty) + + # Make sure the marker is over the ROIs + self.__handle.setZValue(1) + # Create the vline and the hline + self._createSubRois() + + @docstring(roi_items.HandleBasedROI) + def contains(self, position): + roiPos = self.getPosition() + return position[0] == roiPos[0] or position[1] == roiPos[1] + + def setFirstShapePoints(self, points): + pos = points[0] + self.setPosition(pos) + + def getPosition(self): + """Returns the position of this ROI + + :rtype: numpy.ndarray + """ + return self.__position + + def setPosition(self, pos): + """Set the position of this ROI + + :param numpy.ndarray pos: 2d-coordinate of this point + """ + self.__position = pos + with utils.blockSignals(self.__handle): + self.__handle.setPosition(*pos) + with utils.blockSignals(self.__handleLabel): + self.__handleLabel.setPosition(*pos) + self.sigRegionChanged.emit() + + def handleDragUpdated(self, handle, origin, previous, current): + if handle is self.__handle: + self.setPosition(current) + + def __updateLineProperty(self, event=None, checkVisibility=True): + if event == items.ItemChangedType.NAME: + self.__handleLabel.setText(self.getName()) + elif event in [items.ItemChangedType.COLOR, + items.ItemChangedType.VISIBLE]: + lines = [] + if self.__vline: + lines.append(self.__vline) + if self.__hline: + lines.append(self.__hline) + self._updateItemProperty(event, self, lines) + + def _createLines(self, parent): + """Inherit this function to return 2 ROI objects for respectivly + the horizontal, and the vertical lines.""" + raise NotImplementedError() + + def _setProfileManager(self, profileManager): + core.ProfileRoiMixIn._setProfileManager(self, profileManager) + # Connecting the vline and the hline + roiManager = profileManager.getRoiManager() + roiManager.addRoi(self.__vline) + roiManager.addRoi(self.__hline) + + def _createSubRois(self): + hline, vline = self._createLines(parent=None) + for i, line in enumerate([vline, hline]): + line.setPosition(self.__position[i]) + line.setEditable(True) + line.setSelectable(True) + line.setFocusProxy(self) + line.setName("") + self.__vline = vline + self.__hline = hline + vline.sigAboutToBeRemoved.connect(self.__vlineRemoved) + vline.sigRegionChanged.connect(self.__vlineRegionChanged) + hline.sigAboutToBeRemoved.connect(self.__hlineRemoved) + hline.sigRegionChanged.connect(self.__hlineRegionChanged) + + def _getLines(self): + return self.__hline, self.__vline + + def __regionChanged(self): + if self.__inhibitReentance.locked(): + return + x, y = self.getPosition() + hline, vline = self._getLines() + if hline is None: + return + with self.__inhibitReentance: + hline.setPosition(y) + vline.setPosition(x) + + def __vlineRegionChanged(self): + if self.__inhibitReentance.locked(): + return + pos = self.getPosition() + vline = self.__vline + pos = vline.getPosition(), pos[1] + with self.__inhibitReentance: + self.setPosition(pos) + + def __hlineRegionChanged(self): + if self.__inhibitReentance.locked(): + return + pos = self.getPosition() + hline = self.__hline + pos = pos[0], hline.getPosition() + with self.__inhibitReentance: + self.setPosition(pos) + + def __aboutToBeRemoved(self): + vline = self.__vline + hline = self.__hline + # Avoid side remove signals + if hline is not None: + hline.sigAboutToBeRemoved.disconnect(self.__hlineRemoved) + hline.sigRegionChanged.disconnect(self.__hlineRegionChanged) + if vline is not None: + vline.sigAboutToBeRemoved.disconnect(self.__vlineRemoved) + vline.sigRegionChanged.disconnect(self.__vlineRegionChanged) + # Clean up the child + profileManager = self.getProfileManager() + roiManager = profileManager.getRoiManager() + if hline is not None: + roiManager.removeRoi(hline) + self.__hline = None + if vline is not None: + roiManager.removeRoi(vline) + self.__vline = None + + def __hlineRemoved(self): + self.__lineRemoved(isHline=True) + + def __vlineRemoved(self): + self.__lineRemoved(isHline=False) + + def __lineRemoved(self, isHline): + """If any of the lines is removed: disconnect this objects, and let the + other one persist""" + hline, vline = self._getLines() + + hline.sigAboutToBeRemoved.disconnect(self.__hlineRemoved) + vline.sigAboutToBeRemoved.disconnect(self.__vlineRemoved) + hline.sigRegionChanged.disconnect(self.__hlineRegionChanged) + vline.sigRegionChanged.disconnect(self.__vlineRegionChanged) + + self.__hline = None + self.__vline = None + profileManager = self.getProfileManager() + roiManager = profileManager.getRoiManager() + if isHline: + self.__releaseLine(vline) + else: + self.__releaseLine(hline) + roiManager.removeRoi(self) + + def __releaseLine(self, line): + """Release the line in order to make it independent""" + line.setFocusProxy(None) + line.setName(self.getName()) + line.setEditable(self.isEditable()) + line.setSelectable(self.isSelectable()) + + +class ProfileImageCrossROI(_ProfileCrossROI): + """ROI to manage a cross of profiles + + It is managed using 2 sub ROIs for vertical and horizontal. + """ + + ICON = 'shape-cross' + NAME = 'cross profile' + ITEM_KIND = items.ImageBase + + def _createLines(self, parent): + vline = ProfileImageVerticalLineROI(parent=parent) + hline = ProfileImageHorizontalLineROI(parent=parent) + return hline, vline + + def setProfileMethod(self, method): + """ + :param str method: method to compute the profile. Can be 'mean' or 'sum' + """ + hline, vline = self._getLines() + hline.setProfileMethod(method) + vline.setProfileMethod(method) + self.invalidateProperties() + + def getProfileMethod(self): + hline, _vline = self._getLines() + return hline.getProfileMethod() + + def setProfileLineWidth(self, width): + hline, vline = self._getLines() + hline.setProfileLineWidth(width) + vline.setProfileLineWidth(width) + self.invalidateProperties() + + def getProfileLineWidth(self): + hline, _vline = self._getLines() + return hline.getProfileLineWidth() + + +class _DefaultScatterProfileRoiMixIn(core.ProfileRoiMixIn): + """Provide common behavior for silx default scatter profile ROI. + """ + + ITEM_KIND = items.Scatter + + def __init__(self, parent=None): + core.ProfileRoiMixIn.__init__(self, parent=parent) + self.__nPoints = 1024 + self.sigRegionChanged.connect(self.__regionChanged) + + def __regionChanged(self): + self.invalidateProfile() + + # Number of points + + def getNPoints(self): + """Returns the number of points of the profiles + + :rtype: int + """ + return self.__nPoints + + def setNPoints(self, npoints): + """Set the number of points of the profiles + + :param int npoints: + """ + npoints = int(npoints) + if npoints < 1: + raise ValueError("Unsupported number of points: %d" % npoints) + elif npoints != self.__nPoints: + self.__nPoints = npoints + self.invalidateProperties() + self.invalidateProfile() + + def _computeProfile(self, scatter, x0, y0, x1, y1): + """Compute corresponding profile + + :param float x0: Profile start point X coord + :param float y0: Profile start point Y coord + :param float x1: Profile end point X coord + :param float y1: Profile end point Y coord + :return: (points, values) profile data or None + """ + future = scatter._getInterpolator() + try: + interpolator = future.result() + except CancelledError: + return None + if interpolator is None: + return None # Cannot init an interpolator + + nPoints = self.getNPoints() + points = numpy.transpose(( + numpy.linspace(x0, x1, nPoints, endpoint=True), + numpy.linspace(y0, y1, nPoints, endpoint=True))) + + values = interpolator(points) + + if not numpy.any(numpy.isfinite(values)): + return None # Profile outside convex hull + + return points, values + + def computeProfile(self, item): + """Update profile according to current ROI""" + if not isinstance(item, items.Scatter): + raise TypeError("Unexpected class %s" % type(item)) + + # Get end points + if isinstance(self, roi_items.LineROI): + points = self.getEndPoints() + x0, y0 = points[0] + x1, y1 = points[1] + elif isinstance(self, (roi_items.VerticalLineROI, roi_items.HorizontalLineROI)): + profileManager = self.getProfileManager() + plot = profileManager.getPlotWidget() + + if isinstance(self, roi_items.HorizontalLineROI): + x0, x1 = plot.getXAxis().getLimits() + y0 = y1 = self.getPosition() + + elif isinstance(self, roi_items.VerticalLineROI): + x0 = x1 = self.getPosition() + y0, y1 = plot.getYAxis().getLimits() + else: + raise RuntimeError('Unsupported ROI for profile: {}'.format(self.__class__)) + + if x1 < x0 or (x1 == x0 and y1 < y0): + # Invert points + x0, y0, x1, y1 = x1, y1, x0, y0 + + profile = self._computeProfile(item, x0, y0, x1, y1) + if profile is None: + return None + + title = _lineProfileTitle(x0, y0, x1, y1) + points = profile[0] + values = profile[1] + + if (numpy.abs(points[-1, 0] - points[0, 0]) > + numpy.abs(points[-1, 1] - points[0, 1])): + xProfile = points[:, 0] + xLabel = '{xlabel}' + else: + xProfile = points[:, 1] + xLabel = '{ylabel}' + + # Use the axis names from the original + profileManager = self.getProfileManager() + plot = profileManager.getPlotWidget() + title = _relabelAxes(plot, title) + xLabel = _relabelAxes(plot, xLabel) + + data = core.CurveProfileData( + coords=xProfile, + profile=values, + title=title, + xLabel=xLabel, + yLabel='Profile', + ) + return data + + +class ProfileScatterHorizontalLineROI(roi_items.HorizontalLineROI, + _DefaultScatterProfileRoiMixIn): + """ROI for an horizontal profile at a location of a scatter""" + + ICON = 'shape-horizontal' + NAME = 'horizontal line profile' + + def __init__(self, parent=None): + roi_items.HorizontalLineROI.__init__(self, parent=parent) + _DefaultScatterProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileScatterVerticalLineROI(roi_items.VerticalLineROI, + _DefaultScatterProfileRoiMixIn): + """ROI for an horizontal profile at a location of a scatter""" + + ICON = 'shape-vertical' + NAME = 'vertical line profile' + + def __init__(self, parent=None): + roi_items.VerticalLineROI.__init__(self, parent=parent) + _DefaultScatterProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileScatterLineROI(roi_items.LineROI, + _DefaultScatterProfileRoiMixIn): + """ROI for an horizontal profile at a location of a scatter""" + + ICON = 'shape-diagonal' + NAME = 'line profile' + + def __init__(self, parent=None): + roi_items.LineROI.__init__(self, parent=parent) + _DefaultScatterProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileScatterCrossROI(_ProfileCrossROI): + """ROI to manage a cross of profiles for scatters. + """ + + ICON = 'shape-cross' + NAME = 'cross profile' + ITEM_KIND = items.Scatter + + def _createLines(self, parent): + vline = ProfileScatterVerticalLineROI(parent=parent) + hline = ProfileScatterHorizontalLineROI(parent=parent) + return hline, vline + + def getNPoints(self): + """Returns the number of points of the profiles + + :rtype: int + """ + hline, _vline = self._getLines() + return hline.getNPoints() + + def setNPoints(self, npoints): + """Set the number of points of the profiles + + :param int npoints: + """ + hline, vline = self._getLines() + hline.setNPoints(npoints) + vline.setNPoints(npoints) + self.invalidateProperties() + + +class _DefaultScatterProfileSliceRoiMixIn(core.ProfileRoiMixIn): + """Default ROI to allow to slice in the scatter data.""" + + ITEM_KIND = items.Scatter + + def __init__(self, parent=None): + core.ProfileRoiMixIn.__init__(self, parent=parent) + self.__area = _SliceProfileArea(self) + self.addItem(self.__area) + self.sigRegionChanged.connect(self._regionChanged) + self.sigPlotItemChanged.connect(self._updateArea) + + def _regionChanged(self): + self.invalidateProfile() + self._updateArea() + + def _updateArea(self): + plotItem = self.getPlotItem() + if plotItem is None: + self.setLineStyle("-") + else: + self.setLineStyle("--") + + def _getSlice(self, item): + position = self.getPosition() + bounds = item.getCurrentVisualizationParameter(items.Scatter.VisualizationParameter.GRID_BOUNDS) + if isinstance(self, roi_items.HorizontalLineROI): + axis = 1 + elif isinstance(self, roi_items.VerticalLineROI): + axis = 0 + else: + assert False + if position < bounds[0][axis] or position > bounds[1][axis]: + # ROI outside of the scatter bound + return None + + major_order = item.getCurrentVisualizationParameter(items.Scatter.VisualizationParameter.GRID_MAJOR_ORDER) + assert major_order == 'row' + max_grid_yy, max_grid_xx = item.getCurrentVisualizationParameter(items.Scatter.VisualizationParameter.GRID_SHAPE) + + xx, yy, _values, _xx_error, _yy_error = item.getData(copy=False) + if isinstance(self, roi_items.HorizontalLineROI): + axis = yy + max_grid_first = max_grid_yy + max_grid_second = max_grid_xx + major_axis = major_order == 'column' + elif isinstance(self, roi_items.VerticalLineROI): + axis = xx + max_grid_first = max_grid_xx + max_grid_second = max_grid_yy + major_axis = major_order == 'row' + else: + assert False + + def argnearest(array, value): + array = numpy.abs(array - value) + return numpy.argmin(array) + + if major_axis: + # slice in the middle of the scatter + start = max_grid_second // 2 * max_grid_first + vslice = axis[start:start + max_grid_second] + index = argnearest(vslice, position) + slicing = slice(index, None, max_grid_first) + else: + # slice in the middle of the scatter + vslice = axis[max_grid_second // 2::max_grid_second] + index = argnearest(vslice, position) + start = index * max_grid_second + slicing = slice(start, start + max_grid_second) + + return slicing + + def computeProfile(self, item): + if not isinstance(item, items.Scatter): + raise TypeError("Unsupported %s item" % type(item)) + + slicing = self._getSlice(item) + if slicing is None: + # ROI out of bounds + return None + + _xx, _yy, values, _xx_error, _yy_error = item.getData(copy=False) + profile = values[slicing] + + if isinstance(self, roi_items.HorizontalLineROI): + title = "Horizontal slice" + xLabel = "{xlabel} index" + elif isinstance(self, roi_items.VerticalLineROI): + title = "Vertical slice" + xLabel = "{ylabel} index" + else: + assert False + + # Use the axis names from the original plot + profileManager = self.getProfileManager() + plot = profileManager.getPlotWidget() + xLabel = _relabelAxes(plot, xLabel) + + data = core.CurveProfileData( + coords=numpy.arange(len(profile)), + profile=profile, + title=title, + xLabel=xLabel, + yLabel="Profile", + ) + return data + + +class ProfileScatterHorizontalSliceROI(roi_items.HorizontalLineROI, + _DefaultScatterProfileSliceRoiMixIn): + """ROI for an horizontal profile at a location of a scatter + using data slicing. + """ + + ICON = 'slice-horizontal' + NAME = 'horizontal data slice profile' + + def __init__(self, parent=None): + roi_items.HorizontalLineROI.__init__(self, parent=parent) + _DefaultScatterProfileSliceRoiMixIn.__init__(self, parent=parent) + + +class ProfileScatterVerticalSliceROI(roi_items.VerticalLineROI, + _DefaultScatterProfileSliceRoiMixIn): + """ROI for a vertical profile at a location of a scatter + using data slicing. + """ + + ICON = 'slice-vertical' + NAME = 'vertical data slice profile' + + def __init__(self, parent=None): + roi_items.VerticalLineROI.__init__(self, parent=parent) + _DefaultScatterProfileSliceRoiMixIn.__init__(self, parent=parent) + + +class ProfileScatterCrossSliceROI(_ProfileCrossROI): + """ROI to manage a cross of slicing profiles on scatters. + """ + + ICON = 'slice-cross' + NAME = 'cross data slice profile' + ITEM_KIND = items.Scatter + + def _createLines(self, parent): + vline = ProfileScatterVerticalSliceROI(parent=parent) + hline = ProfileScatterHorizontalSliceROI(parent=parent) + return hline, vline + + +class _DefaultImageStackProfileRoiMixIn(_DefaultImageProfileRoiMixIn): + + ITEM_KIND = items.ImageStack + + def __init__(self, parent=None): + super(_DefaultImageStackProfileRoiMixIn, self).__init__(parent=parent) + self.__profileType = "1D" + """Kind of profile""" + + def getProfileType(self): + return self.__profileType + + def setProfileType(self, kind): + assert kind in ["1D", "2D"] + if self.__profileType == kind: + return + self.__profileType = kind + self.invalidateProperties() + self.invalidateProfile() + + def computeProfile(self, item): + if not isinstance(item, items.ImageStack): + raise TypeError("Unexpected class %s" % type(item)) + + kind = self.getProfileType() + if kind == "1D": + result = _DefaultImageProfileRoiMixIn.computeProfile(self, item) + # z = item.getStackPosition() + return result + + assert kind == "2D" + + def createProfile2(currentData): + coords, profile, _area, profileName, xLabel = core.createProfile( + roiInfo=self._getRoiInfo(), + currentData=currentData, + origin=origin, + scale=scale, + lineWidth=self.getProfileLineWidth(), + method=method) + return coords, profile, profileName, xLabel + + currentData = numpy.array(item.getStackData(copy=False)) + origin = item.getOrigin() + scale = item.getScale() + colormap = item.getColormap() + method = self.getProfileMethod() + + coords, profile, profileName, xLabel = createProfile2(currentData) + + data = core.ImageProfileData( + coords=coords, + profile=profile, + title=profileName, + xLabel=xLabel, + yLabel="Profile", + colormap=colormap, + ) + return data + + +class ProfileImageStackHorizontalLineROI(roi_items.HorizontalLineROI, + _DefaultImageStackProfileRoiMixIn): + """ROI for an horizontal profile at a location of a stack of images""" + + ICON = 'shape-horizontal' + NAME = 'horizontal line profile' + + def __init__(self, parent=None): + roi_items.HorizontalLineROI.__init__(self, parent=parent) + _DefaultImageStackProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileImageStackVerticalLineROI(roi_items.VerticalLineROI, + _DefaultImageStackProfileRoiMixIn): + """ROI for an vertical profile at a location of a stack of images""" + + ICON = 'shape-vertical' + NAME = 'vertical line profile' + + def __init__(self, parent=None): + roi_items.VerticalLineROI.__init__(self, parent=parent) + _DefaultImageStackProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileImageStackLineROI(roi_items.LineROI, + _DefaultImageStackProfileRoiMixIn): + """ROI for an vertical profile at a location of a stack of images""" + + ICON = 'shape-diagonal' + NAME = 'line profile' + + def __init__(self, parent=None): + roi_items.LineROI.__init__(self, parent=parent) + _DefaultImageStackProfileRoiMixIn.__init__(self, parent=parent) + + +class ProfileImageStackCrossROI(ProfileImageCrossROI): + """ROI for an vertical profile at a location of a stack of images""" + + ICON = 'shape-cross' + NAME = 'cross profile' + ITEM_KIND = items.ImageStack + + def _createLines(self, parent): + vline = ProfileImageStackVerticalLineROI(parent=parent) + hline = ProfileImageStackHorizontalLineROI(parent=parent) + return hline, vline + + def getProfileType(self): + hline, _vline = self._getLines() + return hline.getProfileType() + + def setProfileType(self, kind): + hline, vline = self._getLines() + hline.setProfileType(kind) + vline.setProfileType(kind) + self.invalidateProperties() diff --git a/silx/gui/plot/tools/profile/toolbar.py b/silx/gui/plot/tools/profile/toolbar.py new file mode 100644 index 0000000..4a9a195 --- /dev/null +++ b/silx/gui/plot/tools/profile/toolbar.py @@ -0,0 +1,172 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2019 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 tool bar helper. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "28/06/2018" + + +import logging +import weakref + +from silx.gui import qt +from silx.gui.widgets.MultiModeAction import MultiModeAction +from . import manager +from .. import roi as roi_mdl +from silx.gui.plot import items + + +_logger = logging.getLogger(__name__) + + +class ProfileToolBar(qt.QToolBar): + """Tool bar to provide profile for a plot. + + It is an helper class. For a dedicated application it would be better to + use an own tool bar in order in order have more flexibility. + """ + def __init__(self, parent=None, plot=None): + super(ProfileToolBar, self).__init__(parent=parent) + self.__scheme = None + self.__manager = None + self.__plot = weakref.ref(plot) + self.__multiAction = None + + def getPlotWidget(self): + """The :class:`~silx.gui.plot.PlotWidget` associated to the toolbar. + + :rtype: Union[~silx.gui.plot.PlotWidget,None] + """ + if self.__plot is None: + return None + plot = self.__plot() + if self.__plot is None: + self.__plot = None + return plot + + def setScheme(self, scheme): + """Initialize the tool bar using a configuration scheme. + + It have to be done once and only once. + + :param str scheme: One of "scatter", "image", "imagestack" + """ + assert self.__scheme is None + self.__scheme = scheme + + plot = self.getPlotWidget() + self.__manager = manager.ProfileManager(self, plot) + + if scheme == "image": + self.__manager.setItemType(image=True) + self.__manager.setActiveItemTracking(True) + + multiAction = MultiModeAction(self) + self.addAction(multiAction) + for action in self.__manager.createImageActions(self): + multiAction.addAction(action) + self.__multiAction = multiAction + + cleanAction = self.__manager.createClearAction(self) + self.addAction(cleanAction) + editorAction = self.__manager.createEditorAction(self) + self.addAction(editorAction) + + plot.sigActiveImageChanged.connect(self._activeImageChanged) + self._activeImageChanged() + + elif scheme == "scatter": + self.__manager.setItemType(scatter=True) + self.__manager.setActiveItemTracking(True) + + multiAction = MultiModeAction(self) + self.addAction(multiAction) + for action in self.__manager.createScatterActions(self): + multiAction.addAction(action) + for action in self.__manager.createScatterSliceActions(self): + multiAction.addAction(action) + self.__multiAction = multiAction + + cleanAction = self.__manager.createClearAction(self) + self.addAction(cleanAction) + editorAction = self.__manager.createEditorAction(self) + self.addAction(editorAction) + + plot.sigActiveScatterChanged.connect(self._activeScatterChanged) + self._activeScatterChanged() + + elif scheme == "imagestack": + self.__manager.setItemType(image=True) + self.__manager.setActiveItemTracking(True) + + multiAction = MultiModeAction(self) + self.addAction(multiAction) + for action in self.__manager.createImageStackActions(self): + multiAction.addAction(action) + self.__multiAction = multiAction + + cleanAction = self.__manager.createClearAction(self) + self.addAction(cleanAction) + editorAction = self.__manager.createEditorAction(self) + self.addAction(editorAction) + + plot.sigActiveImageChanged.connect(self._activeImageChanged) + self._activeImageChanged() + + else: + raise ValueError("Toolbar scheme %s unsupported" % scheme) + + def _setRoiActionEnabled(self, itemKind, enabled): + for action in self.__multiAction.getMenu().actions(): + if not isinstance(action, roi_mdl.CreateRoiModeAction): + continue + roiClass = action.getRoiClass() + if issubclass(itemKind, roiClass.ITEM_KIND): + action.setEnabled(enabled) + + def _activeImageChanged(self, previous=None, legend=None): + """Handle active image change to toggle actions""" + if legend is None: + self._setRoiActionEnabled(items.ImageStack, False) + self._setRoiActionEnabled(items.ImageBase, False) + else: + plot = self.getPlotWidget() + image = plot.getActiveImage() + # Disable for empty image + enabled = image.getData(copy=False).size > 0 + self._setRoiActionEnabled(type(image), enabled) + + def _activeScatterChanged(self, previous=None, legend=None): + """Handle active scatter change to toggle actions""" + if legend is None: + self._setRoiActionEnabled(items.Scatter, False) + else: + plot = self.getPlotWidget() + scatter = plot.getActiveScatter() + # Disable for empty image + enabled = scatter.getValueData(copy=False).size > 0 + self._setRoiActionEnabled(type(scatter), enabled) 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__ diff --git a/silx/gui/plot/tools/test/__init__.py b/silx/gui/plot/tools/test/__init__.py index 9cede27..1429545 100644 --- a/silx/gui/plot/tools/test/__init__.py +++ b/silx/gui/plot/tools/test/__init__.py @@ -33,6 +33,7 @@ from . import testROI from . import testTools from . import testScatterProfileToolBar from . import testCurveLegendsWidget +from . import testProfile def suite(): @@ -42,6 +43,7 @@ def suite(): testTools.suite(), testScatterProfileToolBar.suite(), testCurveLegendsWidget.suite(), + testProfile.suite(), ]) return test_suite diff --git a/silx/gui/plot/tools/test/testProfile.py b/silx/gui/plot/tools/test/testProfile.py new file mode 100644 index 0000000..444cfe0 --- /dev/null +++ b/silx/gui/plot/tools/test/testProfile.py @@ -0,0 +1,673 @@ +# 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. +# +# ###########################################################################*/ +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "28/06/2018" + + +import unittest +import contextlib +import numpy +import logging + +from silx.gui import qt +from silx.utils import deprecation +from silx.utils import testutils + +from silx.gui.utils.testutils import TestCaseQt +from silx.utils.testutils import ParametricTestCase +from silx.gui.plot import PlotWindow, Plot1D, Plot2D, Profile +from silx.gui.plot.StackView import StackView +from silx.gui.plot.tools.profile import rois +from silx.gui.plot.tools.profile import editors +from silx.gui.plot.items import roi as roi_items +from silx.gui.plot.tools.profile import manager +from silx.gui import plot as silx_plot + +_logger = logging.getLogger(__name__) + + +class TestRois(TestCaseQt): + + def test_init(self): + """Check that the constructor is not called twice""" + roi = rois.ProfileImageVerticalLineROI() + if qt.BINDING not in ["PySide", "PySide2"]: + # the profile ROI + the shape + self.assertEqual(roi.receivers(roi.sigRegionChanged), 2) + + +class TestInteractions(TestCaseQt): + + @contextlib.contextmanager + def defaultPlot(self): + try: + widget = silx_plot.PlotWidget() + widget.show() + self.qWaitForWindowExposed(widget) + yield widget + finally: + widget.close() + widget = None + self.qWait() + + @contextlib.contextmanager + def imagePlot(self): + try: + widget = silx_plot.Plot2D() + image = numpy.arange(10 * 10).reshape(10, -1) + widget.addImage(image) + widget.show() + self.qWaitForWindowExposed(widget) + yield widget + finally: + widget.close() + widget = None + self.qWait() + + @contextlib.contextmanager + def scatterPlot(self): + try: + widget = silx_plot.ScatterView() + + nbX, nbY = 7, 5 + yy = numpy.atleast_2d(numpy.ones(nbY)).T + xx = numpy.atleast_2d(numpy.ones(nbX)) + positionX = numpy.linspace(10, 50, nbX) * yy + positionX = positionX.reshape(nbX * nbY) + positionY = numpy.atleast_2d(numpy.linspace(20, 60, nbY)).T * xx + positionY = positionY.reshape(nbX * nbY) + values = numpy.arange(nbX * nbY) + + widget.setData(positionX, positionY, values) + widget.resetZoom() + widget.show() + self.qWaitForWindowExposed(widget) + yield widget.getPlotWidget() + finally: + widget.close() + widget = None + self.qWait() + + @contextlib.contextmanager + def stackPlot(self): + try: + widget = silx_plot.StackView() + image = numpy.arange(10 * 10).reshape(10, -1) + cube = numpy.array([image, image, image]) + widget.setStack(cube) + widget.resetZoom() + widget.show() + self.qWaitForWindowExposed(widget) + yield widget.getPlotWidget() + finally: + widget.close() + widget = None + self.qWait() + + def waitPendingOperations(self, proflie): + for _ in range(10): + if not proflie.hasPendingOperations(): + return + self.qWait(100) + _logger.error("The profile manager still have pending operations") + + def genericRoiTest(self, plot, roiClass): + profileManager = manager.ProfileManager(plot, plot) + profileManager.setItemType(image=True, scatter=True) + + try: + action = profileManager.createProfileAction(roiClass, plot) + action.triggered[bool].emit(True) + widget = plot.getWidgetHandle() + + # Do the mouse interaction + pos1 = widget.width() * 0.4, widget.height() * 0.4 + self.mouseMove(widget, pos=pos1) + self.mouseClick(widget, qt.Qt.LeftButton, pos=pos1) + + if issubclass(roiClass, roi_items.LineROI): + pos2 = widget.width() * 0.6, widget.height() * 0.6 + self.mouseMove(widget, pos=pos2) + self.mouseClick(widget, qt.Qt.LeftButton, pos=pos2) + + self.waitPendingOperations(profileManager) + + # Test that something was computed + if issubclass(roiClass, rois._ProfileCrossROI): + self.assertEqual(profileManager._computedProfiles, 2) + elif issubclass(roiClass, roi_items.LineROI): + self.assertGreaterEqual(profileManager._computedProfiles, 1) + else: + self.assertEqual(profileManager._computedProfiles, 1) + + # Test the created ROIs + profileRois = profileManager.getRoiManager().getRois() + if issubclass(roiClass, rois._ProfileCrossROI): + self.assertEqual(len(profileRois), 3) + else: + self.assertEqual(len(profileRois), 1) + # The first one should be the expected one + roi = profileRois[0] + + # Test that something was displayed + if issubclass(roiClass, rois._ProfileCrossROI): + profiles = roi._getLines() + window = profiles[0].getProfileWindow() + self.assertIsNotNone(window) + window = profiles[1].getProfileWindow() + self.assertIsNotNone(window) + else: + window = roi.getProfileWindow() + self.assertIsNotNone(window) + finally: + profileManager.clearProfile() + + def testImageActions(self): + roiClasses = [ + rois.ProfileImageHorizontalLineROI, + rois.ProfileImageVerticalLineROI, + rois.ProfileImageLineROI, + rois.ProfileImageCrossROI, + ] + with self.imagePlot() as plot: + for roiClass in roiClasses: + with self.subTest(roiClass=roiClass): + self.genericRoiTest(plot, roiClass) + + def testScatterActions(self): + roiClasses = [ + rois.ProfileScatterHorizontalLineROI, + rois.ProfileScatterVerticalLineROI, + rois.ProfileScatterLineROI, + rois.ProfileScatterCrossROI, + rois.ProfileScatterHorizontalSliceROI, + rois.ProfileScatterVerticalSliceROI, + rois.ProfileScatterCrossSliceROI, + ] + with self.scatterPlot() as plot: + for roiClass in roiClasses: + with self.subTest(roiClass=roiClass): + self.genericRoiTest(plot, roiClass) + + def testStackActions(self): + roiClasses = [ + rois.ProfileImageStackHorizontalLineROI, + rois.ProfileImageStackVerticalLineROI, + rois.ProfileImageStackLineROI, + rois.ProfileImageStackCrossROI, + ] + with self.stackPlot() as plot: + for roiClass in roiClasses: + with self.subTest(roiClass=roiClass): + self.genericRoiTest(plot, roiClass) + + def genericEditorTest(self, plot, roi, editor): + if isinstance(editor, editors._NoProfileRoiEditor): + pass + elif isinstance(editor, editors._DefaultImageStackProfileRoiEditor): + # GUI to ROI + editor._lineWidth.setValue(2) + self.assertEqual(roi.getProfileLineWidth(), 2) + editor._methodsButton.setMethod("sum") + self.assertEqual(roi.getProfileMethod(), "sum") + editor._profileDim.setDimension(1) + self.assertEqual(roi.getProfileType(), "1D") + # ROI to GUI + roi.setProfileLineWidth(3) + self.assertEqual(editor._lineWidth.value(), 3) + roi.setProfileMethod("mean") + self.assertEqual(editor._methodsButton.getMethod(), "mean") + roi.setProfileType("2D") + self.assertEqual(editor._profileDim.getDimension(), 2) + elif isinstance(editor, editors._DefaultImageProfileRoiEditor): + # GUI to ROI + editor._lineWidth.setValue(2) + self.assertEqual(roi.getProfileLineWidth(), 2) + editor._methodsButton.setMethod("sum") + self.assertEqual(roi.getProfileMethod(), "sum") + # ROI to GUI + roi.setProfileLineWidth(3) + self.assertEqual(editor._lineWidth.value(), 3) + roi.setProfileMethod("mean") + self.assertEqual(editor._methodsButton.getMethod(), "mean") + elif isinstance(editor, editors._DefaultScatterProfileRoiEditor): + # GUI to ROI + editor._nPoints.setValue(100) + self.assertEqual(roi.getNPoints(), 100) + # ROI to GUI + roi.setNPoints(200) + self.assertEqual(editor._nPoints.value(), 200) + else: + assert False + + def testEditors(self): + roiClasses = [ + (rois.ProfileImageHorizontalLineROI, editors._DefaultImageProfileRoiEditor), + (rois.ProfileImageVerticalLineROI, editors._DefaultImageProfileRoiEditor), + (rois.ProfileImageLineROI, editors._DefaultImageProfileRoiEditor), + (rois.ProfileImageCrossROI, editors._DefaultImageProfileRoiEditor), + (rois.ProfileScatterHorizontalLineROI, editors._DefaultScatterProfileRoiEditor), + (rois.ProfileScatterVerticalLineROI, editors._DefaultScatterProfileRoiEditor), + (rois.ProfileScatterLineROI, editors._DefaultScatterProfileRoiEditor), + (rois.ProfileScatterCrossROI, editors._DefaultScatterProfileRoiEditor), + (rois.ProfileScatterHorizontalSliceROI, editors._NoProfileRoiEditor), + (rois.ProfileScatterVerticalSliceROI, editors._NoProfileRoiEditor), + (rois.ProfileScatterCrossSliceROI, editors._NoProfileRoiEditor), + (rois.ProfileImageStackHorizontalLineROI, editors._DefaultImageStackProfileRoiEditor), + (rois.ProfileImageStackVerticalLineROI, editors._DefaultImageStackProfileRoiEditor), + (rois.ProfileImageStackLineROI, editors._DefaultImageStackProfileRoiEditor), + (rois.ProfileImageStackCrossROI, editors._DefaultImageStackProfileRoiEditor), + ] + with self.defaultPlot() as plot: + profileManager = manager.ProfileManager(plot, plot) + editorAction = profileManager.createEditorAction(parent=plot) + for roiClass, editorClass in roiClasses: + with self.subTest(roiClass=roiClass): + roi = roiClass() + roi._setProfileManager(profileManager) + try: + # Force widget creation + menu = qt.QMenu(plot) + menu.addAction(editorAction) + widgets = editorAction.createdWidgets() + self.assertGreater(len(widgets), 0) + + editorAction.setProfileRoi(roi) + editorWidget = editorAction._getEditor(widgets[0]) + self.assertIsInstance(editorWidget, editorClass) + self.genericEditorTest(plot, roi, editorWidget) + finally: + editorAction.setProfileRoi(None) + menu.deleteLater() + menu = None + self.qapp.processEvents() + + +class TestProfileToolBar(TestCaseQt, ParametricTestCase): + """Tests for ProfileToolBar widget.""" + + def setUp(self): + super(TestProfileToolBar, self).setUp() + self.plot = PlotWindow() + self.toolBar = Profile.ProfileToolBar(plot=self.plot) + self.plot.addToolBar(self.toolBar) + + self.plot.show() + self.qWaitForWindowExposed(self.plot) + + self.mouseMove(self.plot) # Move to center + self.qapp.processEvents() + deprecation.FORCE = True + + def tearDown(self): + deprecation.FORCE = False + self.qapp.processEvents() + profileManager = self.toolBar.getProfileManager() + profileManager.clearProfile() + profileManager = None + self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) + self.plot.close() + del self.plot + del self.toolBar + + super(TestProfileToolBar, self).tearDown() + + def testAlignedProfile(self): + """Test horizontal and vertical profile, without and with image""" + # Use Plot backend widget to submit mouse events + widget = self.plot.getWidgetHandle() + for method in ('sum', 'mean'): + with self.subTest(method=method): + # 2 positions to use for mouse events + pos1 = widget.width() * 0.4, widget.height() * 0.4 + pos2 = widget.width() * 0.6, widget.height() * 0.6 + + for action in (self.toolBar.hLineAction, self.toolBar.vLineAction): + with self.subTest(mode=action.text()): + # Trigger tool button for mode + action.trigger() + # Without image + self.mouseMove(widget, pos=pos1) + self.mouseClick(widget, qt.Qt.LeftButton, pos=pos1) + + # with image + self.plot.addImage( + numpy.arange(100 * 100).reshape(100, -1)) + self.mousePress(widget, qt.Qt.LeftButton, pos=pos1) + self.mouseMove(widget, pos=pos2) + self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2) + + self.mouseMove(widget) + self.mouseClick(widget, qt.Qt.LeftButton) + + manager = self.toolBar.getProfileManager() + for _ in range(20): + self.qWait(200) + if not manager.hasPendingOperations(): + break + + @testutils.test_logging(deprecation.depreclog.name, warning=4) + def testDiagonalProfile(self): + """Test diagonal profile, without and with image""" + # Use Plot backend widget to submit mouse events + widget = self.plot.getWidgetHandle() + + for method in ('sum', 'mean'): + for image in (False, True): + with self.subTest(method=method, image=image): + # 2 positions to use for mouse events + pos1 = widget.width() * 0.4, widget.height() * 0.4 + pos2 = widget.width() * 0.6, widget.height() * 0.6 + + if image: + self.plot.addImage( + numpy.arange(100 * 100).reshape(100, -1)) + + # Trigger tool button for diagonal profile mode + self.toolBar.lineAction.trigger() + + # draw profile line + widget.setFocus(qt.Qt.OtherFocusReason) + self.mouseMove(widget, pos=pos1) + self.qWait(100) + self.mousePress(widget, qt.Qt.LeftButton, pos=pos1) + self.qWait(100) + self.mouseMove(widget, pos=pos2) + self.qWait(100) + self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2) + self.qWait(100) + + manager = self.toolBar.getProfileManager() + + for _ in range(20): + self.qWait(200) + if not manager.hasPendingOperations(): + break + + roi = manager.getCurrentRoi() + self.assertIsNotNone(roi) + roi.setProfileLineWidth(3) + roi.setProfileMethod(method) + + for _ in range(20): + self.qWait(200) + if not manager.hasPendingOperations(): + break + + if image is True: + curveItem = self.toolBar.getProfilePlot().getAllCurves()[0] + if method == 'sum': + self.assertTrue(curveItem.getData()[1].max() > 10000) + elif method == 'mean': + self.assertTrue(curveItem.getData()[1].max() < 10000) + + # Remove the ROI so the profile window is also removed + roiManager = manager.getRoiManager() + roiManager.removeRoi(roi) + self.qWait(100) + + +class TestDeprecatedProfileToolBar(TestCaseQt): + """Tests old features of the ProfileToolBar widget.""" + + def setUp(self): + self.plot = None + super(TestDeprecatedProfileToolBar, self).setUp() + + def tearDown(self): + if self.plot is not None: + self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) + self.plot.close() + self.plot = None + self.qWait() + + super(TestDeprecatedProfileToolBar, self).tearDown() + + @testutils.test_logging(deprecation.depreclog.name, warning=2) + def testCustomProfileWindow(self): + from silx.gui.plot import ProfileMainWindow + + self.plot = PlotWindow() + profileWindow = ProfileMainWindow.ProfileMainWindow(self.plot) + toolBar = Profile.ProfileToolBar(parent=self.plot, + plot=self.plot, + profileWindow=profileWindow) + + self.plot.show() + self.qWaitForWindowExposed(self.plot) + profileWindow.show() + self.qWaitForWindowExposed(profileWindow) + self.qapp.processEvents() + + self.plot.addImage(numpy.arange(10 * 10).reshape(10, -1)) + profile = rois.ProfileImageHorizontalLineROI() + profile.setPosition(5) + toolBar.getProfileManager().getRoiManager().addRoi(profile) + toolBar.getProfileManager().getRoiManager().setCurrentRoi(profile) + + for _ in range(20): + self.qWait(200) + if not toolBar.getProfileManager().hasPendingOperations(): + break + + # There is a displayed profile + self.assertIsNotNone(profileWindow.getProfile()) + self.assertIs(toolBar.getProfileMainWindow(), profileWindow) + + # There is nothing anymore but the window is still there + toolBar.getProfileManager().clearProfile() + self.qapp.processEvents() + self.assertIsNone(profileWindow.getProfile()) + + +class TestProfile3DToolBar(TestCaseQt): + """Tests for Profile3DToolBar widget. + """ + def setUp(self): + super(TestProfile3DToolBar, self).setUp() + self.plot = StackView() + self.plot.show() + self.qWaitForWindowExposed(self.plot) + + self.plot.setStack(numpy.array([ + [[0, 1, 2], [3, 4, 5]], + [[6, 7, 8], [9, 10, 11]], + [[12, 13, 14], [15, 16, 17]] + ])) + deprecation.FORCE = True + + def tearDown(self): + deprecation.FORCE = False + profileManager = self.plot.getProfileToolbar().getProfileManager() + profileManager.clearProfile() + profileManager = None + self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) + self.plot.close() + self.plot = None + + super(TestProfile3DToolBar, self).tearDown() + + @testutils.test_logging(deprecation.depreclog.name, warning=2) + def testMethodProfile2D(self): + """Test that the profile can have a different method if we want to + compute then in 1D or in 2D""" + + toolBar = self.plot.getProfileToolbar() + + toolBar.vLineAction.trigger() + plot2D = self.plot.getPlotWidget().getWidgetHandle() + pos1 = plot2D.width() * 0.5, plot2D.height() * 0.5 + self.mouseClick(plot2D, qt.Qt.LeftButton, pos=pos1) + + manager = toolBar.getProfileManager() + roi = manager.getCurrentRoi() + roi.setProfileMethod("mean") + roi.setProfileType("2D") + roi.setProfileLineWidth(3) + + for _ in range(20): + self.qWait(200) + if not manager.hasPendingOperations(): + break + + # check 2D 'mean' profile + profilePlot = toolBar.getProfilePlot() + data = profilePlot.getAllImages()[0].getData() + expected = numpy.array([[1, 4], [7, 10], [13, 16]]) + numpy.testing.assert_almost_equal(data, expected) + + @testutils.test_logging(deprecation.depreclog.name, warning=2) + def testMethodSumLine(self): + """Simple interaction test to make sure the sum is correctly computed + """ + toolBar = self.plot.getProfileToolbar() + + toolBar.lineAction.trigger() + plot2D = self.plot.getPlotWidget().getWidgetHandle() + pos1 = plot2D.width() * 0.5, plot2D.height() * 0.2 + pos2 = plot2D.width() * 0.5, plot2D.height() * 0.8 + + self.mouseMove(plot2D, pos=pos1) + self.mousePress(plot2D, qt.Qt.LeftButton, pos=pos1) + self.mouseMove(plot2D, pos=pos2) + self.mouseRelease(plot2D, qt.Qt.LeftButton, pos=pos2) + + manager = toolBar.getProfileManager() + roi = manager.getCurrentRoi() + roi.setProfileMethod("sum") + roi.setProfileType("2D") + roi.setProfileLineWidth(3) + + for _ in range(20): + self.qWait(200) + if not manager.hasPendingOperations(): + break + + # check 2D 'sum' profile + profilePlot = toolBar.getProfilePlot() + data = profilePlot.getAllImages()[0].getData() + expected = numpy.array([[3, 12], [21, 30], [39, 48]]) + numpy.testing.assert_almost_equal(data, expected) + + +class TestGetProfilePlot(TestCaseQt): + + def setUp(self): + self.plot = None + super(TestGetProfilePlot, self).setUp() + + def tearDown(self): + if self.plot is not None: + manager = self.plot.getProfileToolbar().getProfileManager() + manager.clearProfile() + manager = None + self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) + self.plot.close() + self.plot = None + + super(TestGetProfilePlot, self).tearDown() + + def testProfile1D(self): + self.plot = Plot2D() + self.plot.show() + self.qWaitForWindowExposed(self.plot) + self.plot.addImage([[0, 1], [2, 3]]) + + toolBar = self.plot.getProfileToolbar() + + manager = toolBar.getProfileManager() + roiManager = manager.getRoiManager() + + roi = rois.ProfileImageHorizontalLineROI() + roi.setPosition(0.5) + roiManager.addRoi(roi) + roiManager.setCurrentRoi(roi) + + for _ in range(20): + self.qWait(200) + if not manager.hasPendingOperations(): + break + + profileWindow = roi.getProfileWindow() + self.assertIsInstance(roi.getProfileWindow(), qt.QMainWindow) + self.assertIsInstance(profileWindow.getCurrentPlotWidget(), Plot1D) + + def testProfile2D(self): + """Test that the profile plot associated to a stack view is either a + Plot1D or a plot 2D instance.""" + self.plot = StackView() + self.plot.show() + self.qWaitForWindowExposed(self.plot) + + self.plot.setStack(numpy.array([[[0, 1], [2, 3]], + [[4, 5], [6, 7]]])) + + toolBar = self.plot.getProfileToolbar() + + manager = toolBar.getProfileManager() + roiManager = manager.getRoiManager() + + roi = rois.ProfileImageStackHorizontalLineROI() + roi.setPosition(0.5) + roi.setProfileType("2D") + roiManager.addRoi(roi) + roiManager.setCurrentRoi(roi) + + for _ in range(20): + self.qWait(200) + if not manager.hasPendingOperations(): + break + + profileWindow = roi.getProfileWindow() + self.assertIsInstance(roi.getProfileWindow(), qt.QMainWindow) + self.assertIsInstance(profileWindow.getCurrentPlotWidget(), Plot2D) + + roi.setProfileType("1D") + + for _ in range(20): + self.qWait(200) + if not manager.hasPendingOperations(): + break + + profileWindow = roi.getProfileWindow() + self.assertIsInstance(roi.getProfileWindow(), qt.QMainWindow) + self.assertIsInstance(profileWindow.getCurrentPlotWidget(), Plot1D) + + +def suite(): + test_suite = unittest.TestSuite() + loadTests = unittest.defaultTestLoader.loadTestsFromTestCase + test_suite.addTest(loadTests(TestRois)) + test_suite.addTest(loadTests(TestInteractions)) + test_suite.addTest(loadTests(TestProfileToolBar)) + test_suite.addTest(loadTests(TestGetProfilePlot)) + test_suite.addTest(loadTests(TestProfile3DToolBar)) + test_suite.addTest(loadTests(TestDeprecatedProfileToolBar)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/plot/tools/test/testROI.py b/silx/gui/plot/tools/test/testROI.py index 8aec1d9..33a0000 100644 --- a/silx/gui/plot/tools/test/testROI.py +++ b/silx/gui/plot/tools/test/testROI.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2018 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 @@ -60,7 +60,7 @@ class TestRoiItems(TestCaseQt): def testPoint_geometry(self): point = numpy.array([1, 2]) - item = roi_items.VerticalLineROI() + item = roi_items.PointROI() item.setPosition(point) numpy.testing.assert_allclose(item.getPosition(), point) @@ -108,6 +108,43 @@ class TestRoiItems(TestCaseQt): numpy.testing.assert_allclose(item.getCenter(), expectedCenter) numpy.testing.assert_allclose(item.getSize(), size) + def testCircle_geometry(self): + center = numpy.array([0, 0]) + radius = 10. + item = roi_items.CircleROI() + item.setGeometry(center=center, radius=radius) + numpy.testing.assert_allclose(item.getCenter(), center) + numpy.testing.assert_allclose(item.getRadius(), radius) + + def testCircle_setCenter(self): + center = numpy.array([0, 0]) + radius = 10. + item = roi_items.CircleROI() + item.setGeometry(center=center, radius=radius) + newCenter = numpy.array([-10, 0]) + item.setCenter(newCenter) + numpy.testing.assert_allclose(item.getCenter(), newCenter) + numpy.testing.assert_allclose(item.getRadius(), radius) + + def testCircle_setRadius(self): + center = numpy.array([0, 0]) + radius = 10. + item = roi_items.CircleROI() + item.setGeometry(center=center, radius=radius) + newRadius = 5.1 + item.setRadius(newRadius) + numpy.testing.assert_allclose(item.getCenter(), center) + numpy.testing.assert_allclose(item.getRadius(), newRadius) + + def testRectangle_isIn(self): + origin = numpy.array([0, 0]) + size = numpy.array([10, 20]) + item = roi_items.RectangleROI() + item.setGeometry(origin=origin, size=size) + self.assertTrue(item.contains(position=(0, 0))) + self.assertTrue(item.contains(position=(2, 14))) + self.assertFalse(item.contains(position=(14, 12))) + def testPolygon_emptyGeometry(self): points = numpy.empty((0, 2)) item = roi_items.PolygonROI() @@ -120,6 +157,17 @@ class TestRoiItems(TestCaseQt): item.setPoints(points) numpy.testing.assert_allclose(item.getPoints(), points) + def testPolygon_isIn(self): + points = numpy.array([[0, 0], [0, 10], [5, 10]]) + item = roi_items.PolygonROI() + item.setPoints(points) + self.assertTrue(item.contains((0, 0))) + self.assertFalse(item.contains((6, 2))) + self.assertFalse(item.contains((-2, 5))) + self.assertFalse(item.contains((2, -1))) + self.assertFalse(item.contains((8, 1))) + self.assertTrue(item.contains((1, 8))) + def testArc_getToSetGeometry(self): """Test that we can use getGeometry as input to setGeometry""" item = roi_items.ArcROI() @@ -147,7 +195,7 @@ class TestRoiItems(TestCaseQt): self.assertAlmostEqual(item.getInnerRadius(), innerRadius) self.assertAlmostEqual(item.getOuterRadius(), outerRadius) self.assertAlmostEqual(item.getStartAngle(), item.getEndAngle() - numpy.pi * 2.0) - self.assertAlmostEqual(item.isClosed(), True) + self.assertTrue(item.isClosed()) def testArc_special_donut(self): item = roi_items.ArcROI() @@ -158,7 +206,7 @@ class TestRoiItems(TestCaseQt): self.assertAlmostEqual(item.getInnerRadius(), innerRadius) self.assertAlmostEqual(item.getOuterRadius(), outerRadius) self.assertAlmostEqual(item.getStartAngle(), item.getEndAngle() - numpy.pi * 2.0) - self.assertAlmostEqual(item.isClosed(), True) + self.assertTrue(item.isClosed()) def testArc_clockwiseGeometry(self): """Test that we can use getGeometry as input to setGeometry""" @@ -186,6 +234,15 @@ class TestRoiItems(TestCaseQt): self.assertAlmostEqual(item.getEndAngle(), endAngle) self.assertAlmostEqual(item.isClosed(), False) + def testHRange_geometry(self): + item = roi_items.HorizontalRangeROI() + vmin = 1 + vmax = 3 + item.setRange(vmin, vmax) + self.assertAlmostEqual(item.getMin(), vmin) + self.assertAlmostEqual(item.getMax(), vmax) + self.assertAlmostEqual(item.getCenter(), 2) + class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase): """Tests for RegionOfInterestManager class""" @@ -229,6 +286,9 @@ class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase): (roi_items.VerticalLineROI, numpy.array((((10., 20.), (10., 30.)), ((30., 40.), (30., 50.))))), + (roi_items.HorizontalLineROI, + numpy.array((((10., 20.), (10., 30.)), + ((30., 40.), (30., 50.))))), ) for roiClass, points in tests: @@ -246,7 +306,9 @@ class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase): manager.sigRoiChanged.connect(changedListener) # Add a point - manager.createRoi(roiClass, points[0]) + r = roiClass() + r.setFirstShapePoints(points[0]) + manager.addRoi(r) self.qapp.processEvents() self.assertTrue(len(manager.getRois()), 1) self.assertEqual(changedListener.callCount(), 1) @@ -257,9 +319,13 @@ class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase): self.assertEqual(changedListener.callCount(), 2) # Add two point - manager.createRoi(roiClass, points[0]) + r = roiClass() + r.setFirstShapePoints(points[0]) + manager.addRoi(r) self.qapp.processEvents() - manager.createRoi(roiClass, points[1]) + r = roiClass() + r.setFirstShapePoints(points[1]) + manager.addRoi(r) self.qapp.processEvents() self.assertTrue(len(manager.getRois()), 2) self.assertEqual(changedListener.callCount(), 4) @@ -273,9 +339,13 @@ class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase): changedListener.clear() # Add two point - manager.createRoi(roiClass, points[0]) + r = roiClass() + r.setFirstShapePoints(points[0]) + manager.addRoi(r) self.qapp.processEvents() - manager.createRoi(roiClass, points[1]) + r = roiClass() + r.setFirstShapePoints(points[1]) + manager.addRoi(r) self.qapp.processEvents() self.assertTrue(len(manager.getRois()), 2) self.assertEqual(changedListener.callCount(), 2) @@ -356,6 +426,10 @@ class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase): innerRadius, outerRadius, startAngle, endAngle = 1, 100, numpy.pi * 0.5, numpy.pi item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle) rois.append(item) + # Horizontal Range + item = roi_items.HorizontalRangeROI() + item.setRange(-1, 3) + rois.append(item) manager = roi.RegionOfInterestManager(self.plot) self.roiTableWidget.setRegionOfInterestManager(manager) @@ -370,6 +444,25 @@ class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase): manager.removeRoi(item) self.qapp.processEvents() + def testSelectionProxy(self): + item1 = roi_items.PointROI() + item1.setSelectable(True) + item2 = roi_items.PointROI() + item2.setSelectable(True) + item1.setFocusProxy(item2) + manager = roi.RegionOfInterestManager(self.plot) + manager.setCurrentRoi(item1) + self.assertIs(manager.getCurrentRoi(), item2) + + def testRemovedSelection(self): + item1 = roi_items.PointROI() + item1.setSelectable(True) + manager = roi.RegionOfInterestManager(self.plot) + manager.addRoi(item1) + manager.setCurrentRoi(item1) + manager.removeRoi(item1) + self.assertIs(manager.getCurrentRoi(), None) + def testMaxROI(self): """Test Max ROI""" origin1 = numpy.array([1., 10.]) @@ -443,6 +536,86 @@ class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase): manager.clear() + def testLineInteraction(self): + """This test make sure that a ROI based on handles can be edited with + the mouse.""" + xlimit = self.plot.getXAxis().getLimits() + ylimit = self.plot.getYAxis().getLimits() + points = numpy.array([xlimit, ylimit]).T + center = numpy.mean(points, axis=0) + + # Create the line + manager = roi.RegionOfInterestManager(self.plot) + item = roi_items.LineROI() + item.setEndPoints(points[0], points[1]) + item.setEditable(True) + manager.addRoi(item) + self.qapp.processEvents() + + # Drag the center + widget = self.plot.getWidgetHandle() + mx, my = self.plot.dataToPixel(*center) + self.mouseMove(widget, pos=(mx, my)) + self.mousePress(widget, qt.Qt.LeftButton, pos=(mx, my)) + self.mouseMove(widget, pos=(mx, my+50)) + self.mouseRelease(widget, qt.Qt.LeftButton, pos=(mx, my)) + + result = numpy.array(item.getEndPoints()) + # x location is still the same + numpy.testing.assert_allclose(points[:, 0], result[:, 0], atol=0.5) + # size is still the same + numpy.testing.assert_allclose(points[1] - points[0], + result[1] - result[0], atol=0.5) + # But Y is not the same + self.assertNotEqual(points[0, 1], result[0, 1]) + self.assertNotEqual(points[1, 1], result[1, 1]) + item = None + manager.clear() + self.qapp.processEvents() + + def testPlotWhenCleared(self): + """PlotWidget.clear should clean up the available ROIs""" + manager = roi.RegionOfInterestManager(self.plot) + item = roi_items.LineROI() + item.setEndPoints((0, 0), (1, 1)) + item.setEditable(True) + manager.addRoi(item) + self.qWait() + try: + # Make sure the test setup is fine + self.assertNotEqual(len(manager.getRois()), 0) + self.assertNotEqual(len(self.plot.getItems()), 0) + + # Call clear and test the expected state + self.plot.clear() + self.assertEqual(len(manager.getRois()), 0) + self.assertEqual(len(self.plot.getItems()), 0) + finally: + # Clean up + manager.clear() + + def testPlotWhenRoiRemoved(self): + """Make sure there is no remaining items in the plot when a ROI is removed""" + manager = roi.RegionOfInterestManager(self.plot) + item = roi_items.LineROI() + item.setEndPoints((0, 0), (1, 1)) + item.setEditable(True) + manager.addRoi(item) + self.qWait() + try: + # Make sure the test setup is fine + self.assertNotEqual(len(manager.getRois()), 0) + self.assertNotEqual(len(self.plot.getItems()), 0) + + # Call clear and test the expected state + manager.removeRoi(item) + self.assertEqual(len(manager.getRois()), 0) + self.assertEqual(len(self.plot.getItems()), 0) + finally: + # Clean up + manager.clear() + + def suite(): test_suite = unittest.TestSuite() diff --git a/silx/gui/plot/tools/test/testScatterProfileToolBar.py b/silx/gui/plot/tools/test/testScatterProfileToolBar.py index 714746a..b9f4885 100644 --- a/silx/gui/plot/tools/test/testScatterProfileToolBar.py +++ b/silx/gui/plot/tools/test/testScatterProfileToolBar.py @@ -34,8 +34,9 @@ from silx.gui import qt from silx.utils.testutils import ParametricTestCase from silx.gui.utils.testutils import TestCaseQt from silx.gui.plot import PlotWindow -from silx.gui.plot.tools import profile -import silx.gui.plot.items.roi as roi_items +from silx.gui.plot.tools.profile import manager +from silx.gui.plot.tools.profile import core +from silx.gui.plot.tools.profile import rois class TestScatterProfileToolBar(TestCaseQt, ParametricTestCase): @@ -45,40 +46,25 @@ class TestScatterProfileToolBar(TestCaseQt, ParametricTestCase): super(TestScatterProfileToolBar, self).setUp() self.plot = PlotWindow() - self.profile = profile.ScatterProfileToolBar(plot=self.plot) - - self.plot.addToolBar(self.profile) + self.manager = manager.ProfileManager(plot=self.plot) + self.manager.setItemType(scatter=True) + self.manager.setActiveItemTracking(True) self.plot.show() self.qWaitForWindowExposed(self.plot) def tearDown(self): - del self.profile + del self.manager self.qapp.processEvents() self.plot.setAttribute(qt.Qt.WA_DeleteOnClose) self.plot.close() del self.plot super(TestScatterProfileToolBar, self).tearDown() - def testNoProfile(self): - """Test ScatterProfileToolBar without profile""" - self.assertEqual(self.profile.getPlotWidget(), self.plot) - - # Add a scatter plot - self.plot.addScatter( - x=(0., 1., 1., 0.), y=(0., 0., 1., 1.), value=(0., 1., 2., 3.)) - self.plot.resetZoom(dataMargins=(.1, .1, .1, .1)) - self.qapp.processEvents() - - # Check that there is no profile - self.assertIsNone(self.profile.getProfileValues()) - self.assertIsNone(self.profile.getProfilePoints()) - def testHorizontalProfile(self): """Test ScatterProfileToolBar horizontal profile""" - nPoints = 8 - self.profile.setNPoints(nPoints) - self.assertEqual(self.profile.getNPoints(), nPoints) + + roiManager = self.manager.getRoiManager() # Add a scatter plot self.plot.addScatter( @@ -86,46 +72,39 @@ class TestScatterProfileToolBar(TestCaseQt, ParametricTestCase): self.plot.resetZoom(dataMargins=(.1, .1, .1, .1)) self.qapp.processEvents() - # Activate Horizontal profile - hlineAction = self.profile.actions()[0] - hlineAction.trigger() - self.qapp.processEvents() - # Set a ROI profile - roi = roi_items.HorizontalLineROI() + roi = rois.ProfileScatterHorizontalLineROI() roi.setPosition(0.5) - self.profile._getRoiManager().addRoi(roi) + roi.setNPoints(8) + roiManager.addRoi(roi) # Wait for async interpolator init for _ in range(20): self.qWait(200) - if not self.profile.hasPendingOperations(): + if not self.manager.hasPendingOperations(): break self.qapp.processEvents() - self.assertIsNotNone(self.profile.getProfileValues()) - points = self.profile.getProfilePoints() - self.assertEqual(len(points), nPoints) + window = roi.getProfileWindow() + self.assertIsNotNone(window) + data = window.getProfile() + self.assertIsInstance(data, core.CurveProfileData) + self.assertEqual(len(data.coords), 8) # Check that profile has same limits than Plot xLimits = self.plot.getXAxis().getLimits() - self.assertEqual(points[0, 0], xLimits[0]) - self.assertEqual(points[-1, 0], xLimits[1]) + self.assertEqual(data.coords[0], xLimits[0]) + self.assertEqual(data.coords[-1], xLimits[1]) # Clear the profile - clearAction = self.profile.actions()[-1] - clearAction.trigger() + self.manager.clearProfile() self.qapp.processEvents() - - self.assertIsNone(self.profile.getProfileValues()) - self.assertIsNone(self.profile.getProfilePoints()) - self.assertEqual(self.profile.getProfileTitle(), '') + self.assertIsNone(roi.getProfileWindow()) def testVerticalProfile(self): """Test ScatterProfileToolBar vertical profile""" - nPoints = 8 - self.profile.setNPoints(nPoints) - self.assertEqual(self.profile.getNPoints(), nPoints) + + roiManager = self.manager.getRoiManager() # Add a scatter plot self.plot.addScatter( @@ -133,55 +112,52 @@ class TestScatterProfileToolBar(TestCaseQt, ParametricTestCase): self.plot.resetZoom(dataMargins=(.1, .1, .1, .1)) self.qapp.processEvents() - # Activate vertical profile - vlineAction = self.profile.actions()[1] - vlineAction.trigger() - self.qapp.processEvents() - # Set a ROI profile - roi = roi_items.VerticalLineROI() + roi = rois.ProfileScatterVerticalLineROI() roi.setPosition(0.5) - self.profile._getRoiManager().addRoi(roi) + roi.setNPoints(8) + roiManager.addRoi(roi) # Wait for async interpolator init for _ in range(10): self.qWait(200) - if not self.profile.hasPendingOperations(): + if not self.manager.hasPendingOperations(): break - self.assertIsNotNone(self.profile.getProfileValues()) - points = self.profile.getProfilePoints() - self.assertEqual(len(points), nPoints) + window = roi.getProfileWindow() + self.assertIsNotNone(window) + data = window.getProfile() + self.assertIsInstance(data, core.CurveProfileData) + self.assertEqual(len(data.coords), 8) # Check that profile has same limits than Plot yLimits = self.plot.getYAxis().getLimits() - self.assertEqual(points[0, 1], yLimits[0]) - self.assertEqual(points[-1, 1], yLimits[1]) + self.assertEqual(data.coords[0], yLimits[0]) + self.assertEqual(data.coords[-1], yLimits[1]) # Check that profile limits are updated when changing limits self.plot.getYAxis().setLimits(yLimits[0] + 1, yLimits[1] + 10) - self.qapp.processEvents() + + # Wait for async interpolator init + for _ in range(10): + self.qWait(200) + if not self.manager.hasPendingOperations(): + break + yLimits = self.plot.getYAxis().getLimits() - points = self.profile.getProfilePoints() - self.assertEqual(points[0, 1], yLimits[0]) - self.assertEqual(points[-1, 1], yLimits[1]) + data = window.getProfile() + self.assertEqual(data.coords[0], yLimits[0]) + self.assertEqual(data.coords[-1], yLimits[1]) - # Clear the plot - self.plot.clear() + # Clear the profile + self.manager.clearProfile() self.qapp.processEvents() - self.assertIsNone(self.profile.getProfileValues()) - self.assertIsNone(self.profile.getProfilePoints()) + self.assertIsNone(roi.getProfileWindow()) def testLineProfile(self): """Test ScatterProfileToolBar line profile""" - nPoints = 8 - self.profile.setNPoints(nPoints) - self.assertEqual(self.profile.getNPoints(), nPoints) - # Activate line profile - lineAction = self.profile.actions()[2] - lineAction.trigger() - self.qapp.processEvents() + roiManager = self.manager.getRoiManager() # Add a scatter plot self.plot.addScatter( @@ -190,19 +166,22 @@ class TestScatterProfileToolBar(TestCaseQt, ParametricTestCase): self.qapp.processEvents() # Set a ROI profile - roi = roi_items.LineROI() + roi = rois.ProfileScatterLineROI() roi.setEndPoints(numpy.array([0., 0.]), numpy.array([1., 1.])) - self.profile._getRoiManager().addRoi(roi) + roi.setNPoints(8) + roiManager.addRoi(roi) # Wait for async interpolator init for _ in range(10): self.qWait(200) - if not self.profile.hasPendingOperations(): + if not self.manager.hasPendingOperations(): break - self.assertIsNotNone(self.profile.getProfileValues()) - points = self.profile.getProfilePoints() - self.assertEqual(len(points), nPoints) + window = roi.getProfileWindow() + self.assertIsNotNone(window) + data = window.getProfile() + self.assertIsInstance(data, core.CurveProfileData) + self.assertEqual(len(data.coords), 8) def suite(): diff --git a/silx/gui/plot/tools/toolbars.py b/silx/gui/plot/tools/toolbars.py index 04d0cfc..3df7d06 100644 --- a/silx/gui/plot/tools/toolbars.py +++ b/silx/gui/plot/tools/toolbars.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 |