summaryrefslogtreecommitdiff
path: root/silx/gui/plot/tools/profile
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/plot/tools/profile')
-rw-r--r--silx/gui/plot/tools/profile/ImageProfileToolBar.py271
-rw-r--r--silx/gui/plot/tools/profile/ScatterProfileToolBar.py431
-rw-r--r--silx/gui/plot/tools/profile/_BaseProfileToolBar.py430
-rw-r--r--silx/gui/plot/tools/profile/__init__.py38
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