diff options
Diffstat (limited to 'silx/gui/plot/tools')
-rw-r--r-- | silx/gui/plot/tools/profile/ScatterProfileToolBar.py | 362 | ||||
-rw-r--r-- | silx/gui/plot/tools/roi.py | 23 | ||||
-rw-r--r-- | silx/gui/plot/tools/test/testScatterProfileToolBar.py | 1 | ||||
-rw-r--r-- | silx/gui/plot/tools/test/testTools.py | 30 | ||||
-rw-r--r-- | silx/gui/plot/tools/toolbars.py | 28 |
5 files changed, 102 insertions, 342 deletions
diff --git a/silx/gui/plot/tools/profile/ScatterProfileToolBar.py b/silx/gui/plot/tools/profile/ScatterProfileToolBar.py index fd21515..0d30651 100644 --- a/silx/gui/plot/tools/profile/ScatterProfileToolBar.py +++ b/silx/gui/plot/tools/profile/ScatterProfileToolBar.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2018 European Synchrotron Radiation Facility +# 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 @@ -31,196 +31,18 @@ __date__ = "28/06/2018" import logging -import threading -import time +import weakref 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 +from ....utils.concurrent import submitToQtMainThread _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 @@ -233,49 +55,13 @@ class ScatterProfileToolBar(_BaseProfileToolBar): 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) + self.__scatterRef = None + self.__futureInterpolator = None - 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) + 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 @@ -283,35 +69,37 @@ class ScatterProfileToolBar(_BaseProfileToolBar): :param Union[str,None] previous: :param Union[str,None] legend: """ - self.__initThread.cancel() + plot = self.getPlotWidget() + if plot is None or legend is None: + scatter = None + else: + scatter = plot.getScatter(legend) + self._setScatterItem(scatter) - # Reset interpolator - self.__interpolator = None + def _getScatterItem(self): + """Returns the scatter item currently handled by this tool. - plot = self.getPlotWidget() - if plot is None: - _logger.error("Associated PlotWidget no longer exists") + :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: - 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) + self.__scatterRef = weakref.ref(scatter) + scatter.sigItemChanged.connect(self.__scatterItemChanged) # Refresh profile self.updateProfile() @@ -322,49 +110,15 @@ class ScatterProfileToolBar(_BaseProfileToolBar): :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() + self.updateProfile() # Refresh profile def hasPendingOperations(self): - return self.__initThread.isRunning() + """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 @@ -383,8 +137,9 @@ class ScatterProfileToolBar(_BaseProfileToolBar): npoints = int(npoints) if npoints < 1: raise ValueError("Unsupported number of points: %d" % npoints) - else: + elif npoints != self.__nPoints: self.__nPoints = npoints + self.updateProfile() # Overridden methods @@ -400,11 +155,16 @@ class ScatterProfileToolBar(_BaseProfileToolBar): """ 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 @@ -414,16 +174,32 @@ class ScatterProfileToolBar(_BaseProfileToolBar): :param float y1: Profile end point Y coord :return: (points, values) profile data or None """ - if self.__interpolator is None: + scatter = self._getScatterItem() + if scatter is None or self.hasPendingOperations(): return None - nPoints = self.getNPoints() + # 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 = self.__interpolator(points) + values = interpolator(points) if not numpy.any(numpy.isfinite(values)): return None # Profile outside convex hull diff --git a/silx/gui/plot/tools/roi.py b/silx/gui/plot/tools/roi.py index 98295ba..eb933a0 100644 --- a/silx/gui/plot/tools/roi.py +++ b/silx/gui/plot/tools/roi.py @@ -106,6 +106,9 @@ class RegionOfInterestManager(qt.QObject): self._rois = [] # List of ROIs self._drawnROI = None # New ROI being currently drawn + # Handle unique selection of interaction mode action + self._actionGroup = qt.QActionGroup(self) + self._roiClass = None self._color = rgba('red') @@ -158,6 +161,8 @@ class RegionOfInterestManager(qt.QObject): action.setChecked(self.getCurrentInteractionModeRoiClass() is roiClass) action.setToolTip(text) + self._actionGroup.addAction(action) + action.triggered[bool].connect(functools.partial( WeakMethodProxy(self._modeActionTriggered), roiClass=roiClass)) self._modeActions[roiClass] = action @@ -171,9 +176,6 @@ class RegionOfInterestManager(qt.QObject): """ if checked: self.start(roiClass) - else: # Keep action checked - action = self.sender() - action.setChecked(True) def _updateModeActions(self): """Check/Uncheck action corresponding to current mode""" @@ -781,9 +783,9 @@ class RegionOfInterestTableWidget(qt.QTableWidget): super(RegionOfInterestTableWidget, self).__init__(parent) self._roiManagerRef = None - self.setColumnCount(5) - self.setHorizontalHeaderLabels( - ['Label', 'Edit', 'Kind', 'Coordinates', '']) + headers = ['Label', 'Edit', 'Kind', 'Coordinates', ''] + self.setColumnCount(len(headers)) + self.setHorizontalHeaderLabels(headers) horizontalHeader = self.horizontalHeader() horizontalHeader.setDefaultAlignment(qt.Qt.AlignLeft) @@ -815,9 +817,10 @@ class RegionOfInterestTableWidget(qt.QTableWidget): manager = self.getRegionOfInterestManager() roi = manager.getRois()[index] else: - roi = None + return if column == 0: + roi.setVisible(item.checkState() == qt.Qt.Checked) roi.setLabel(item.text()) elif column == 1: roi.setEditable( @@ -884,11 +887,13 @@ class RegionOfInterestTableWidget(qt.QTableWidget): for index, roi in enumerate(rois): baseFlags = qt.Qt.ItemIsSelectable | qt.Qt.ItemIsEnabled - # Label + # Label and visible label = roi.getLabel() item = qt.QTableWidgetItem(label) - item.setFlags(baseFlags | qt.Qt.ItemIsEditable) + item.setFlags(baseFlags | qt.Qt.ItemIsEditable | qt.Qt.ItemIsUserCheckable) item.setData(qt.Qt.UserRole, index) + item.setCheckState( + qt.Qt.Checked if roi.isVisible() else qt.Qt.Unchecked) self.setItem(index, 0, item) # Editable diff --git a/silx/gui/plot/tools/test/testScatterProfileToolBar.py b/silx/gui/plot/tools/test/testScatterProfileToolBar.py index 0f4b668..714746a 100644 --- a/silx/gui/plot/tools/test/testScatterProfileToolBar.py +++ b/silx/gui/plot/tools/test/testScatterProfileToolBar.py @@ -101,6 +101,7 @@ class TestScatterProfileToolBar(TestCaseQt, ParametricTestCase): self.qWait(200) if not self.profile.hasPendingOperations(): break + self.qapp.processEvents() self.assertIsNotNone(self.profile.getProfileValues()) points = self.profile.getProfilePoints() diff --git a/silx/gui/plot/tools/test/testTools.py b/silx/gui/plot/tools/test/testTools.py index f4adda0..70c8105 100644 --- a/silx/gui/plot/tools/test/testTools.py +++ b/silx/gui/plot/tools/test/testTools.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2018 European Synchrotron Radiation Facility +# Copyright (c) 2016-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 @@ -41,34 +41,6 @@ from silx.gui.plot import tools from silx.gui.plot.test.utils import PlotWidgetTestCase -# Makes sure a QApplication exists -_qapp = qt.QApplication.instance() or qt.QApplication([]) - - -def _tearDownDocTest(docTest): - """Tear down to use for test from docstring. - - Checks that plot widget is displayed - """ - plot = docTest.globs['plot'] - qWaitForWindowExposedAndActivate(plot) - plot.setAttribute(qt.Qt.WA_DeleteOnClose) - plot.close() - del plot - -# Disable doctest because of -# "NameError: name 'numpy' is not defined" -# -# import doctest -# positionInfoTestSuite = doctest.DocTestSuite( -# PlotTools, tearDown=_tearDownDocTest, -# optionflags=doctest.ELLIPSIS) -# """Test suite of tests from PlotTools docstrings. -# -# Test PositionInfo and ProfileToolBar docstrings. -# """ - - class TestPositionInfo(PlotWidgetTestCase): """Tests for PositionInfo widget.""" diff --git a/silx/gui/plot/tools/toolbars.py b/silx/gui/plot/tools/toolbars.py index 28fb7f9..04d0cfc 100644 --- a/silx/gui/plot/tools/toolbars.py +++ b/silx/gui/plot/tools/toolbars.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2018 European Synchrotron Radiation Facility +# 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 @@ -34,6 +34,7 @@ from ... import qt from .. import actions from ..PlotWidget import PlotWidget from .. import PlotToolButtons +from ....utils.deprecation import deprecated class InteractiveModeToolBar(qt.QToolBar): @@ -302,9 +303,9 @@ class ScatterToolBar(qt.QToolBar): parent=self, plot=plot) self.addAction(self._colormapAction) - self._symbolToolButton = PlotToolButtons.SymbolToolButton( - parent=self, plot=plot) - self.addWidget(self._symbolToolButton) + self._visualizationToolButton = \ + PlotToolButtons.ScatterVisualizationToolButton(parent=self, plot=plot) + self.addWidget(self._visualizationToolButton) def getResetZoomAction(self): """Returns the QAction to reset the zoom. @@ -341,16 +342,21 @@ class ScatterToolBar(qt.QToolBar): """ return self._colormapAction - def getSymbolToolButton(self): - """Returns the QToolButton controlling symbol size and marker. - - :rtype: SymbolToolButton - """ - return self._symbolToolButton - def getKeepDataAspectRatioButton(self): """Returns the QToolButton controlling data aspect ratio. :rtype: QToolButton """ return self._keepDataAspectRatioButton + + def getScatterVisualizationToolButton(self): + """Returns the QToolButton controlling the visualization mode. + + :rtype: ScatterVisualizationToolButton + """ + return self._visualizationToolButton + + @deprecated(replacement='getScatterVisualizationToolButton', + since_version='0.11.0') + def getSymbolToolButton(self): + return self.getScatterVisualizationToolButton() |