diff options
Diffstat (limited to 'silx/gui/plot/tools/profile')
-rw-r--r-- | silx/gui/plot/tools/profile/ImageProfileToolBar.py | 271 | ||||
-rw-r--r-- | silx/gui/plot/tools/profile/ScatterProfileToolBar.py | 431 | ||||
-rw-r--r-- | silx/gui/plot/tools/profile/_BaseProfileToolBar.py | 430 | ||||
-rw-r--r-- | silx/gui/plot/tools/profile/__init__.py | 38 |
4 files changed, 1170 insertions, 0 deletions
diff --git a/silx/gui/plot/tools/profile/ImageProfileToolBar.py b/silx/gui/plot/tools/profile/ImageProfileToolBar.py new file mode 100644 index 0000000..207a2e2 --- /dev/null +++ b/silx/gui/plot/tools/profile/ImageProfileToolBar.py @@ -0,0 +1,271 @@ +# TODO quick & dirty proof of concept + +import numpy + +from silx.gui.plot.tools.profile.ScatterProfileToolBar import _BaseProfileToolBar +from .. import items +from ...colors import cursorColorForColormap +from ....image.bilinear import BilinearImage + + +def _alignedPartialProfile(data, rowRange, colRange, axis): + """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. + :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] + + 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) + + imgProfile = numpy.mean(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(points, data, origin, scale, lineWidth): + """Create the profile line for the the given image. + + :param points: Coords of profile end points: (x0, y0, x1, y1) + :param numpy.ndarray data: 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 + :return: `profile, area`, where: + - 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. + + :rtype: tuple(ndarray, (ndarray, ndarray), str, str) + """ + if data is None or points is None or lineWidth is None: + raise ValueError("createProfile called with invalid arguments") + + # force 3D data (stack of images) + if len(data.shape) == 2: + data3D = data.reshape((1,) + data.shape) + elif len(data.shape) == 3: + data3D = data + + roiWidth = max(1, lineWidth) + x0, y0, x1, y1 = points + + # Convert start and end points in image coords as (row, col) + startPt = ((y0 - origin[1]) / scale[1], + (x0 - origin[0]) / scale[0]) + endPt = ((y1 - origin[1]) / scale[1], + (x1 - 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 + profile = _alignedPartialProfile(data3D, + rowRange, colRange, + axis=0) + + 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)) + profile = _alignedPartialProfile(data3D, + rowRange, colRange, + axis=1) + + # 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 + + profile = [] + for slice_idx in range(data3D.shape[0]): + bilinear = BilinearImage(data3D[slice_idx, :, :]) + + profile.append(bilinear.profile_line( + (startPt[0] - 0.5, startPt[1] - 0.5), + (endPt[0] - 0.5, endPt[1] - 0.5), + roiWidth)) + 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 + startPt = startPt[0] - 0.5 * dRow, startPt[1] - 0.5 * dCol + endPt = 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((startPt[1] - 0.5 * roiWidth * dCol, + startPt[1] + 0.5 * roiWidth * dCol, + endPt[1] + 0.5 * roiWidth * dCol, + endPt[1] - 0.5 * roiWidth * dCol), + dtype=numpy.float32) * scale[0] + origin[0], + numpy.array((startPt[0] - 0.5 * roiWidth * dRow, + startPt[0] + 0.5 * roiWidth * dRow, + endPt[0] + 0.5 * roiWidth * dRow, + endPt[0] - 0.5 * roiWidth * dRow), + dtype=numpy.float32) * scale[1] + origin[1]) + + xProfile = numpy.arange(len(profile[0]), dtype=numpy.float64) + + return (xProfile, profile[0]), area + + +class ImageProfileToolBar(_BaseProfileToolBar): + + def __init__(self, parent=None, plot=None, title='Image Profile'): + super(ImageProfileToolBar, self).__init__(parent, plot, title) + plot.sigActiveImageChanged.connect(self.__activeImageChanged) + + roiManager = self._getRoiManager() + if roiManager is None: + _logger.error( + "Error during scatter profile toolbar initialisation") + else: + roiManager.sigInteractiveModeStarted.connect( + self.__interactionStarted) + roiManager.sigInteractiveModeFinished.connect( + self.__interactionFinished) + if roiManager.isStarted(): + self.__interactionStarted(roiManager.getRegionOfInterestKind()) + + def __interactionStarted(self, kind): + """Handle start of ROI interaction""" + plot = self.getPlotWidget() + if plot is None: + return + + plot.sigActiveImageChanged.connect(self.__activeImageChanged) + + image = plot.getActiveImage() + legend = None if image is None else image.getLegend() + self.__activeImageChanged(None, legend) + + def __interactionFinished(self, rois): + """Handle end of ROI interaction""" + plot = self.getPlotWidget() + if plot is None: + return + + plot.sigActiveImageChanged.disconnect(self.__activeImageChanged) + + image = plot.getActiveImage() + legend = None if image is None else image.getLegend() + self.__activeImageChanged(legend, None) + + def __activeImageChanged(self, previous, legend): + """Handle active image change: toggle enabled toolbar, update curve""" + plot = self.getPlotWidget() + if plot is None: + return + + activeImage = plot.getActiveImage() + if activeImage is None: + self.setEnabled(False) + else: + # Disable for empty image + self.setEnabled(activeImage.getData(copy=False).size > 0) + + # Update default profile color + if isinstance(activeImage, items.ColormapMixIn): + self.setColor(cursorColorForColormap( + activeImage.getColormap()['name'])) # TODO change thsi + else: + self.setColor('black') + + 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: (x, y) profile data or None + """ + plot = self.getPlotWidget() + if plot is None: + return None + + image = plot.getActiveImage() + if image is None: + return None + + profile, area = createProfile( + points=(x0, y0, x1, y1), + data=image.getData(copy=False), + origin=image.getOrigin(), + scale=image.getScale(), + lineWidth=1) # TODO + + return profile
\ No newline at end of file diff --git a/silx/gui/plot/tools/profile/ScatterProfileToolBar.py b/silx/gui/plot/tools/profile/ScatterProfileToolBar.py new file mode 100644 index 0000000..fd21515 --- /dev/null +++ b/silx/gui/plot/tools/profile/ScatterProfileToolBar.py @@ -0,0 +1,431 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module profile tools for scatter plots. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "28/06/2018" + + +import logging +import threading +import time + +import numpy + +try: + from scipy.interpolate import LinearNDInterpolator +except ImportError: + LinearNDInterpolator = None + + # Fallback using local Delaunay and matplotlib interpolator + from silx.third_party.scipy_spatial import Delaunay + import matplotlib.tri + +from ._BaseProfileToolBar import _BaseProfileToolBar +from .... import qt +from ... import items + + +_logger = logging.getLogger(__name__) + + +# TODO support log scale + + +class _InterpolatorInitThread(qt.QThread): + """Thread building a scatter interpolator + + This works in greedy mode in that the signal is only emitted + when no other request is pending + """ + + sigInterpolatorReady = qt.Signal(object) + """Signal emitted whenever an interpolator is ready + + It provides a 3-tuple (points, values, interpolator) + """ + + _RUNNING_THREADS_TO_DELETE = [] + """Store reference of no more used threads but still running""" + + def __init__(self): + super(_InterpolatorInitThread, self).__init__() + self._lock = threading.RLock() + self._pendingData = None + self._firstFallbackRun = True + + def discard(self, obj=None): + """Wait for pending thread to complete and delete then + + Connect this to the destroyed signal of widget using this thread + """ + if self.isRunning(): + self.cancel() + self._RUNNING_THREADS_TO_DELETE.append(self) # Keep a reference + self.finished.connect(self.__finished) + + def __finished(self): + """Handle finished signal of threads to delete""" + try: + self._RUNNING_THREADS_TO_DELETE.remove(self) + except ValueError: + _logger.warning('Finished thread no longer in reference list') + + def request(self, points, values): + """Request new initialisation of interpolator + + :param numpy.ndarray points: Point coordinates (N, D) + :param numpy.ndarray values: Values the N points (1D array) + """ + with self._lock: + # Possibly replace already pending data + self._pendingData = points, values + + if not self.isRunning(): + self.start() + + def cancel(self): + """Cancel any running/pending requests""" + with self._lock: + self._pendingData = 'cancelled' + + def run(self): + """Run the init of the scatter interpolator""" + if LinearNDInterpolator is None: + self.run_matplotlib() + else: + self.run_scipy() + + def run_matplotlib(self): + """Run the init of the scatter interpolator""" + if self._firstFallbackRun: + self._firstFallbackRun = False + _logger.warning( + "scipy.spatial.LinearNDInterpolator not available: " + "Scatter plot interpolator initialisation can freeze the GUI.") + + while True: + with self._lock: + data = self._pendingData + self._pendingData = None + + if data in (None, 'cancelled'): + return + + points, values = data + + startTime = time.time() + try: + delaunay = Delaunay(points) + except: + _logger.warning( + "Cannot triangulate scatter data") + else: + with self._lock: + data = self._pendingData + + if data is not None: # Break point + _logger.info('Interpolator discarded after %f s', + time.time() - startTime) + else: + + x, y = points.T + triangulation = matplotlib.tri.Triangulation( + x, y, triangles=delaunay.simplices) + + interpolator = matplotlib.tri.LinearTriInterpolator( + triangulation, values) + + with self._lock: + data = self._pendingData + + if data is not None: + _logger.info('Interpolator discarded after %f s', + time.time() - startTime) + else: + # No other processing requested: emit the signal + _logger.info("Interpolator initialised in %f s", + time.time() - startTime) + + # Wrap interpolator to have same API as scipy's one + def wrapper(points): + return interpolator(*points.T) + + self.sigInterpolatorReady.emit( + (points, values, wrapper)) + + def run_scipy(self): + """Run the init of the scatter interpolator""" + while True: + with self._lock: + data = self._pendingData + self._pendingData = None + + if data in (None, 'cancelled'): + return + + points, values = data + + startTime = time.time() + try: + interpolator = LinearNDInterpolator(points, values) + except: + _logger.warning( + "Cannot initialise scatter profile interpolator") + else: + with self._lock: + data = self._pendingData + + if data is not None: # Break point + _logger.info('Interpolator discarded after %f s', + time.time() - startTime) + else: + # First call takes a while, do it here + interpolator([(0., 0.)]) + + with self._lock: + data = self._pendingData + + if data is not None: + _logger.info('Interpolator discarded after %f s', + time.time() - startTime) + else: + # No other processing requested: emit the signal + _logger.info("Interpolator initialised in %f s", + time.time() - startTime) + self.sigInterpolatorReady.emit( + (points, values, interpolator)) + + +class ScatterProfileToolBar(_BaseProfileToolBar): + """QToolBar providing scatter 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`. + """ + + def __init__(self, parent=None, plot=None, title='Scatter Profile'): + super(ScatterProfileToolBar, self).__init__(parent, plot, title) + + self.__nPoints = 1024 + self.__interpolator = None + self.__interpolatorCache = None # points, values, interpolator + + self.__initThread = _InterpolatorInitThread() + self.destroyed.connect(self.__initThread.discard) + self.__initThread.sigInterpolatorReady.connect( + self.__interpolatorReady) + + roiManager = self._getRoiManager() + if roiManager is None: + _logger.error( + "Error during scatter profile toolbar initialisation") + else: + roiManager.sigInteractiveModeStarted.connect( + self.__interactionStarted) + roiManager.sigInteractiveModeFinished.connect( + self.__interactionFinished) + if roiManager.isStarted(): + self.__interactionStarted(roiManager.getCurrentInteractionModeRoiClass()) + + def __interactionStarted(self, roiClass): + """Handle start of ROI interaction""" + plot = self.getPlotWidget() + if plot is None: + return + + plot.sigActiveScatterChanged.connect(self.__activeScatterChanged) + + scatter = plot._getActiveItem(kind='scatter') + legend = None if scatter is None else scatter.getLegend() + self.__activeScatterChanged(None, legend) + + def __interactionFinished(self): + """Handle end of ROI interaction""" + plot = self.getPlotWidget() + if plot is None: + return + + plot.sigActiveScatterChanged.disconnect(self.__activeScatterChanged) + + scatter = plot._getActiveItem(kind='scatter') + legend = None if scatter is None else scatter.getLegend() + self.__activeScatterChanged(legend, None) + + def __activeScatterChanged(self, previous, legend): + """Handle change of active scatter + + :param Union[str,None] previous: + :param Union[str,None] legend: + """ + self.__initThread.cancel() + + # Reset interpolator + self.__interpolator = None + + plot = self.getPlotWidget() + if plot is None: + _logger.error("Associated PlotWidget no longer exists") + + else: + if previous is not None: # Disconnect signal + scatter = plot.getScatter(previous) + if scatter is not None: + scatter.sigItemChanged.disconnect( + self.__scatterItemChanged) + + if legend is not None: + scatter = plot.getScatter(legend) + if scatter is None: + _logger.error("Cannot retrieve active scatter") + + else: + scatter.sigItemChanged.connect(self.__scatterItemChanged) + points = numpy.transpose(numpy.array(( + scatter.getXData(copy=False), + scatter.getYData(copy=False)))) + values = scatter.getValueData(copy=False) + + self.__updateInterpolator(points, values) + + # Refresh profile + self.updateProfile() + + def __scatterItemChanged(self, event): + """Handle update of active scatter plot item + + :param ItemChangedType event: + """ + if event == items.ItemChangedType.DATA: + self.__interpolator = None + scatter = self.sender() + if scatter is None: + _logger.error("Cannot retrieve updated scatter item") + + else: + points = numpy.transpose(numpy.array(( + scatter.getXData(copy=False), + scatter.getYData(copy=False)))) + values = scatter.getValueData(copy=False) + + self.__updateInterpolator(points, values) + + # Handle interpolator init thread + + def __updateInterpolator(self, points, values): + """Update used interpolator with new data""" + if (self.__interpolatorCache is not None and + len(points) == len(self.__interpolatorCache[0]) and + numpy.all(numpy.equal(self.__interpolatorCache[0], points)) and + numpy.all(numpy.equal(self.__interpolatorCache[1], values))): + # Reuse previous interpolator + _logger.info( + 'Scatter changed: Reuse previous interpolator') + self.__interpolator = self.__interpolatorCache[2] + + else: + # Interpolator needs update: Start background processing + _logger.info( + 'Scatter changed: Rebuild interpolator') + self.__interpolator = None + self.__interpolatorCache = None + self.__initThread.request(points, values) + + def __interpolatorReady(self, data): + """Handle end of init interpolator thread""" + points, values, interpolator = data + self.__interpolator = interpolator + self.__interpolatorCache = None if interpolator is None else data + self.updateProfile() + + def hasPendingOperations(self): + return self.__initThread.isRunning() + + # 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) + else: + self.__nPoints = npoints + + # 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 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 + """ + if self.__interpolator is None: + return None + + nPoints = self.getNPoints() + + points = numpy.transpose(( + numpy.linspace(x0, x1, nPoints, endpoint=True), + numpy.linspace(y0, y1, nPoints, endpoint=True))) + + values = self.__interpolator(points) + + if not numpy.any(numpy.isfinite(values)): + return None # Profile outside convex hull + + return points, values diff --git a/silx/gui/plot/tools/profile/_BaseProfileToolBar.py b/silx/gui/plot/tools/profile/_BaseProfileToolBar.py new file mode 100644 index 0000000..6d9d6d4 --- /dev/null +++ b/silx/gui/plot/tools/profile/_BaseProfileToolBar.py @@ -0,0 +1,430 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides 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.setLabel('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/__init__.py b/silx/gui/plot/tools/profile/__init__.py new file mode 100644 index 0000000..d91191e --- /dev/null +++ b/silx/gui/plot/tools/profile/__init__.py @@ -0,0 +1,38 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides tools to get profiles on plot data. + +It provides: + +- :class:`ScatterProfileToolBar`: a QToolBar to handle profile on scatter data + +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "07/06/2018" + + +from .ScatterProfileToolBar import ScatterProfileToolBar # noqa |