diff options
Diffstat (limited to 'silx/gui/plot/tools/profile')
-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 |
7 files changed, 3241 insertions, 596 deletions
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) |