diff options
Diffstat (limited to 'src/silx/gui/plot/actions')
-rw-r--r-- | src/silx/gui/plot/actions/PlotAction.py | 78 | ||||
-rw-r--r-- | src/silx/gui/plot/actions/PlotToolAction.py | 150 | ||||
-rw-r--r-- | src/silx/gui/plot/actions/__init__.py | 42 | ||||
-rwxr-xr-x | src/silx/gui/plot/actions/control.py | 694 | ||||
-rw-r--r-- | src/silx/gui/plot/actions/fit.py | 485 | ||||
-rw-r--r-- | src/silx/gui/plot/actions/histogram.py | 542 | ||||
-rw-r--r-- | src/silx/gui/plot/actions/io.py | 819 | ||||
-rw-r--r-- | src/silx/gui/plot/actions/medfilt.py | 147 | ||||
-rw-r--r-- | src/silx/gui/plot/actions/mode.py | 104 |
9 files changed, 3061 insertions, 0 deletions
diff --git a/src/silx/gui/plot/actions/PlotAction.py b/src/silx/gui/plot/actions/PlotAction.py new file mode 100644 index 0000000..2983775 --- /dev/null +++ b/src/silx/gui/plot/actions/PlotAction.py @@ -0,0 +1,78 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2017 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. +# +# ###########################################################################*/ +""" +The class :class:`.PlotAction` help the creation of a qt.QAction associated +with a :class:`.PlotWidget`. +""" + +from __future__ import division + + +__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] +__license__ = "MIT" +__date__ = "03/01/2018" + + +import weakref +from silx.gui import icons +from silx.gui import qt + + +class PlotAction(qt.QAction): + """Base class for QAction that operates on a PlotWidget. + + :param plot: :class:`.PlotWidget` instance on which to operate. + :param icon: QIcon or str name of icon to use + :param str text: The name of this action to be used for menu label + :param str tooltip: The text of the tooltip + :param triggered: The callback to connect to the action's triggered + signal or None for no callback. + :param bool checkable: True for checkable action, False otherwise (default) + :param parent: See :class:`QAction`. + """ + + def __init__(self, plot, icon, text, tooltip=None, + triggered=None, checkable=False, parent=None): + assert plot is not None + self._plotRef = weakref.ref(plot) + + if not isinstance(icon, qt.QIcon): + # Try with icon as a string and load corresponding icon + icon = icons.getQIcon(icon) + + super(PlotAction, self).__init__(icon, text, parent) + + if tooltip is not None: + self.setToolTip(tooltip) + + self.setCheckable(checkable) + + if triggered is not None: + self.triggered[bool].connect(triggered) + + @property + def plot(self): + """The :class:`.PlotWidget` this action group is controlling.""" + return self._plotRef() diff --git a/src/silx/gui/plot/actions/PlotToolAction.py b/src/silx/gui/plot/actions/PlotToolAction.py new file mode 100644 index 0000000..fbb0b0f --- /dev/null +++ b/src/silx/gui/plot/actions/PlotToolAction.py @@ -0,0 +1,150 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +""" +The class :class:`.PlotToolAction` help the creation of a qt.QAction associating +a tool window with a :class:`.PlotWidget`. +""" + +from __future__ import division + + +__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] +__license__ = "MIT" +__date__ = "10/10/2018" + + +import weakref + +from .PlotAction import PlotAction +from silx.gui import qt + + +class PlotToolAction(PlotAction): + """Base class for QAction that maintain a tool window operating on a + PlotWidget.""" + + def __init__(self, plot, icon, text, tooltip=None, + triggered=None, checkable=False, parent=None): + PlotAction.__init__(self, + plot=plot, + icon=icon, + text=text, + tooltip=tooltip, + triggered=self._triggered, + parent=parent, + checkable=True) + self._previousGeometry = None + self._toolWindow = None + + def _triggered(self, checked): + """Update the plot of the histogram visibility status + + :param bool checked: status of the action button + """ + self._setToolWindowVisible(checked) + + def _setToolWindowVisible(self, visible): + """Set the tool window visible or hidden.""" + tool = self._getToolWindow() + if tool.isVisible() == visible: + # Nothing to do + return + + if visible: + self._connectPlot(tool) + tool.show() + if self._previousGeometry is not None: + # Restore the geometry + tool.setGeometry(self._previousGeometry) + else: + self._disconnectPlot(tool) + # Save the geometry + self._previousGeometry = tool.geometry() + tool.hide() + + def _connectPlot(self, window): + """Called if the tool is visible and have to be updated according to + event of the plot. + + :param qt.QWidget window: The tool window + """ + pass + + def _disconnectPlot(self, window): + """Called if the tool is not visible and dont have anymore to be updated + according to event of the plot. + + :param qt.QWidget window: The tool window + """ + pass + + def _isWindowInUse(self): + """Returns true if the tool window is currently in use.""" + if not self.isChecked(): + return False + return self._toolWindow is not None + + def _ownerVisibilityChanged(self, isVisible): + """Called when the visibility of the parent of the tool window changes + + :param bool isVisible: True if the parent became visible + """ + if self._isWindowInUse(): + self._setToolWindowVisible(isVisible) + + def eventFilter(self, qobject, event): + """Observe when the close event is emitted then + simply uncheck the action button + + :param qobject: the object observe + :param event: the event received by qobject + """ + if event.type() == qt.QEvent.Close: + if self._toolWindow is not None: + window = self._toolWindow() + self._previousGeometry = window.geometry() + window.hide() + self.setChecked(False) + + return PlotAction.eventFilter(self, qobject, event) + + def _getToolWindow(self): + """Returns the window containing the tool. + + It uses lazy loading to create this tool.. + """ + if self._toolWindow is None: + window = self._createToolWindow() + if self._previousGeometry is not None: + window.setGeometry(self._previousGeometry) + window.installEventFilter(self) + plot = self.plot + plot.sigVisibilityChanged.connect(self._ownerVisibilityChanged) + self._toolWindow = weakref.ref(window) + return self._toolWindow() + + def _createToolWindow(self): + """Create the tool window managing the plot.""" + raise NotImplementedError() diff --git a/src/silx/gui/plot/actions/__init__.py b/src/silx/gui/plot/actions/__init__.py new file mode 100644 index 0000000..930c728 --- /dev/null +++ b/src/silx/gui/plot/actions/__init__.py @@ -0,0 +1,42 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-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 package provides a set of QAction to use with +:class:`~silx.gui.plot.PlotWidget` + +Those actions are useful to add menu items or toolbar items +that interact with a :class:`~silx.gui.plot.PlotWidget`. + +It provides a base class used to define new plot actions: +:class:`~silx.gui.plot.actions.PlotAction`. +""" + +__authors__ = ["H. Payno"] +__license__ = "MIT" +__date__ = "16/08/2017" + +from .PlotAction import PlotAction +from . import control +from . import mode +from . import io diff --git a/src/silx/gui/plot/actions/control.py b/src/silx/gui/plot/actions/control.py new file mode 100755 index 0000000..439985e --- /dev/null +++ b/src/silx/gui/plot/actions/control.py @@ -0,0 +1,694 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +""" +:mod:`silx.gui.plot.actions.control` provides a set of QAction relative to control +of a :class:`.PlotWidget`. + +The following QAction are available: + +- :class:`ColormapAction` +- :class:`CrosshairAction` +- :class:`CurveStyleAction` +- :class:`GridAction` +- :class:`KeepAspectRatioAction` +- :class:`PanWithArrowKeysAction` +- :class:`ResetZoomAction` +- :class:`ShowAxisAction` +- :class:`XAxisLogarithmicAction` +- :class:`XAxisAutoScaleAction` +- :class:`YAxisInvertedAction` +- :class:`YAxisLogarithmicAction` +- :class:`YAxisAutoScaleAction` +- :class:`ZoomBackAction` +- :class:`ZoomInAction` +- :class:`ZoomOutAction` +""" + +from __future__ import division + +__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] +__license__ = "MIT" +__date__ = "27/11/2020" + +from . import PlotAction +import logging +from silx.gui.plot import items +from silx.gui.plot._utils import applyZoomToPlot as _applyZoomToPlot +from silx.gui import qt +from silx.gui import icons + +_logger = logging.getLogger(__name__) + + +class ResetZoomAction(PlotAction): + """QAction controlling reset zoom on a :class:`.PlotWidget`. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + super(ResetZoomAction, self).__init__( + plot, icon='zoom-original', text='Reset Zoom', + tooltip='Auto-scale the graph', + triggered=self._actionTriggered, + checkable=False, parent=parent) + self._autoscaleChanged(True) + plot.getXAxis().sigAutoScaleChanged.connect(self._autoscaleChanged) + plot.getYAxis().sigAutoScaleChanged.connect(self._autoscaleChanged) + + def _autoscaleChanged(self, enabled): + xAxis = self.plot.getXAxis() + yAxis = self.plot.getYAxis() + self.setEnabled(xAxis.isAutoScale() or yAxis.isAutoScale()) + + if xAxis.isAutoScale() and yAxis.isAutoScale(): + tooltip = 'Auto-scale the graph' + elif xAxis.isAutoScale(): # And not Y axis + tooltip = 'Auto-scale the x-axis of the graph only' + elif yAxis.isAutoScale(): # And not X axis + tooltip = 'Auto-scale the y-axis of the graph only' + else: # no axis in autoscale + tooltip = 'Auto-scale the graph' + self.setToolTip(tooltip) + + def _actionTriggered(self, checked=False): + self.plot.resetZoom() + + +class ZoomBackAction(PlotAction): + """QAction performing a zoom-back in :class:`.PlotWidget` limits history. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + super(ZoomBackAction, self).__init__( + plot, icon='zoom-back', text='Zoom Back', + tooltip='Zoom back the plot', + triggered=self._actionTriggered, + checkable=False, parent=parent) + self.setShortcutContext(qt.Qt.WidgetShortcut) + + def _actionTriggered(self, checked=False): + self.plot.getLimitsHistory().pop() + + +class ZoomInAction(PlotAction): + """QAction performing a zoom-in on a :class:`.PlotWidget`. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + super(ZoomInAction, self).__init__( + plot, icon='zoom-in', text='Zoom In', + tooltip='Zoom in the plot', + triggered=self._actionTriggered, + checkable=False, parent=parent) + self.setShortcut(qt.QKeySequence.ZoomIn) + self.setShortcutContext(qt.Qt.WidgetShortcut) + + def _actionTriggered(self, checked=False): + _applyZoomToPlot(self.plot, 1.1) + + +class ZoomOutAction(PlotAction): + """QAction performing a zoom-out on a :class:`.PlotWidget`. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + super(ZoomOutAction, self).__init__( + plot, icon='zoom-out', text='Zoom Out', + tooltip='Zoom out the plot', + triggered=self._actionTriggered, + checkable=False, parent=parent) + self.setShortcut(qt.QKeySequence.ZoomOut) + self.setShortcutContext(qt.Qt.WidgetShortcut) + + def _actionTriggered(self, checked=False): + _applyZoomToPlot(self.plot, 1. / 1.1) + + +class XAxisAutoScaleAction(PlotAction): + """QAction controlling X axis autoscale on a :class:`.PlotWidget`. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + super(XAxisAutoScaleAction, self).__init__( + plot, icon='plot-xauto', text='X Autoscale', + tooltip='Enable x-axis auto-scale when checked.\n' + 'If unchecked, x-axis does not change when reseting zoom.', + triggered=self._actionTriggered, + checkable=True, parent=parent) + self.setChecked(plot.getXAxis().isAutoScale()) + plot.getXAxis().sigAutoScaleChanged.connect(self.setChecked) + + def _actionTriggered(self, checked=False): + self.plot.getXAxis().setAutoScale(checked) + if checked: + self.plot.resetZoom() + + +class YAxisAutoScaleAction(PlotAction): + """QAction controlling Y axis autoscale on a :class:`.PlotWidget`. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + super(YAxisAutoScaleAction, self).__init__( + plot, icon='plot-yauto', text='Y Autoscale', + tooltip='Enable y-axis auto-scale when checked.\n' + 'If unchecked, y-axis does not change when reseting zoom.', + triggered=self._actionTriggered, + checkable=True, parent=parent) + self.setChecked(plot.getYAxis().isAutoScale()) + plot.getYAxis().sigAutoScaleChanged.connect(self.setChecked) + + def _actionTriggered(self, checked=False): + self.plot.getYAxis().setAutoScale(checked) + if checked: + self.plot.resetZoom() + + +class XAxisLogarithmicAction(PlotAction): + """QAction controlling X axis log scale on a :class:`.PlotWidget`. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + super(XAxisLogarithmicAction, self).__init__( + plot, icon='plot-xlog', text='X Log. scale', + tooltip='Logarithmic x-axis when checked', + triggered=self._actionTriggered, + checkable=True, parent=parent) + self.axis = plot.getXAxis() + self.setChecked(self.axis.getScale() == self.axis.LOGARITHMIC) + self.axis.sigScaleChanged.connect(self._setCheckedIfLogScale) + + def _setCheckedIfLogScale(self, scale): + self.setChecked(scale == self.axis.LOGARITHMIC) + + def _actionTriggered(self, checked=False): + scale = self.axis.LOGARITHMIC if checked else self.axis.LINEAR + self.axis.setScale(scale) + + +class YAxisLogarithmicAction(PlotAction): + """QAction controlling Y axis log scale on a :class:`.PlotWidget`. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + super(YAxisLogarithmicAction, self).__init__( + plot, icon='plot-ylog', text='Y Log. scale', + tooltip='Logarithmic y-axis when checked', + triggered=self._actionTriggered, + checkable=True, parent=parent) + self.axis = plot.getYAxis() + self.setChecked(self.axis.getScale() == self.axis.LOGARITHMIC) + self.axis.sigScaleChanged.connect(self._setCheckedIfLogScale) + + def _setCheckedIfLogScale(self, scale): + self.setChecked(scale == self.axis.LOGARITHMIC) + + def _actionTriggered(self, checked=False): + scale = self.axis.LOGARITHMIC if checked else self.axis.LINEAR + self.axis.setScale(scale) + + +class GridAction(PlotAction): + """QAction controlling grid mode on a :class:`.PlotWidget`. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param str gridMode: The grid mode to use in 'both', 'major'. + See :meth:`.PlotWidget.setGraphGrid` + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, gridMode='both', parent=None): + assert gridMode in ('both', 'major') + self._gridMode = gridMode + + super(GridAction, self).__init__( + plot, icon='plot-grid', text='Grid', + tooltip='Toggle grid (on/off)', + triggered=self._actionTriggered, + checkable=True, parent=parent) + self.setChecked(plot.getGraphGrid() is not None) + plot.sigSetGraphGrid.connect(self._gridChanged) + + def _gridChanged(self, which): + """Slot listening for PlotWidget grid mode change.""" + self.setChecked(which != 'None') + + def _actionTriggered(self, checked=False): + self.plot.setGraphGrid(self._gridMode if checked else None) + + +class CurveStyleAction(PlotAction): + """QAction controlling curve style on a :class:`.PlotWidget`. + + It changes the default line and markers style which updates all + curves on the plot. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + super(CurveStyleAction, self).__init__( + plot, icon='plot-toggle-points', text='Curve style', + tooltip='Change curve line and markers style', + triggered=self._actionTriggered, + checkable=False, parent=parent) + + def _actionTriggered(self, checked=False): + currentState = (self.plot.isDefaultPlotLines(), + self.plot.isDefaultPlotPoints()) + + if currentState == (False, False): + newState = True, False + else: + # line only, line and symbol, symbol only + states = (True, False), (True, True), (False, True) + newState = states[(states.index(currentState) + 1) % 3] + + self.plot.setDefaultPlotLines(newState[0]) + self.plot.setDefaultPlotPoints(newState[1]) + + +class ColormapAction(PlotAction): + """QAction opening a ColormapDialog to update the colormap. + + Both the active image colormap and the default colormap are updated. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + self._dialog = None # To store an instance of ColormapDialog + super(ColormapAction, self).__init__( + plot, icon='colormap', text='Colormap', + tooltip="Change colormap", + triggered=self._actionTriggered, + checkable=True, parent=parent) + self.plot.sigActiveImageChanged.connect(self._updateColormap) + self.plot.sigActiveScatterChanged.connect(self._updateColormap) + + def setColorDialog(self, colorDialog): + """Set a specific color dialog instead of using the default dialog.""" + assert(colorDialog is not None) + assert(self._dialog is None) + self._dialog = colorDialog + self._dialog.visibleChanged.connect(self._dialogVisibleChanged) + self.setChecked(self._dialog.isVisible()) + + @staticmethod + def _createDialog(parent): + """Create the dialog if not already existing + + :parent QWidget parent: Parent of the new colormap + :rtype: ColormapDialog + """ + from silx.gui.dialog.ColormapDialog import ColormapDialog + dialog = ColormapDialog(parent=parent) + dialog.setModal(False) + return dialog + + def _actionTriggered(self, checked=False): + """Create a cmap dialog and update active image and default cmap.""" + if self._dialog is None: + self._dialog = self._createDialog(self.plot) + self._dialog.visibleChanged.connect(self._dialogVisibleChanged) + + # Run the dialog listening to colormap change + if checked is True: + self._updateColormap() + self._dialog.show() + else: + self._dialog.hide() + + def _dialogVisibleChanged(self, isVisible): + self.setChecked(isVisible) + + def _updateColormap(self): + if self._dialog is None: + return + image = self.plot.getActiveImage() + + if isinstance(image, items.ColormapMixIn): + # Set dialog from active image + colormap = image.getColormap() + # Set histogram and range if any + self._dialog.setItem(image) + + else: + # No active image or active image is RGBA, + # Check for active scatter plot + scatter = self.plot._getActiveItem(kind='scatter') + if scatter is not None: + colormap = scatter.getColormap() + self._dialog.setItem(scatter) + + else: + # No active data image nor scatter, + # set dialog from default info + colormap = self.plot.getDefaultColormap() + # Reset histogram and range if any + self._dialog.setData(None) + + self._dialog.setColormap(colormap) + + +class ColorBarAction(PlotAction): + """QAction opening the ColorBarWidget of the specified plot. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + self._dialog = None # To store an instance of ColorBar + super(ColorBarAction, self).__init__( + plot, icon='colorbar', text='Colorbar', + tooltip="Show/Hide the colorbar", + triggered=self._actionTriggered, + checkable=True, parent=parent) + colorBarWidget = self.plot.getColorBarWidget() + old = self.blockSignals(True) + self.setChecked(colorBarWidget.isVisibleTo(self.plot)) + self.blockSignals(old) + colorBarWidget.sigVisibleChanged.connect(self._widgetVisibleChanged) + + def _widgetVisibleChanged(self, isVisible): + """Callback when the colorbar `visible` property change.""" + if self.isChecked() == isVisible: + return + self.setChecked(isVisible) + + def _actionTriggered(self, checked=False): + """Create a cmap dialog and update active image and default cmap.""" + colorBarWidget = self.plot.getColorBarWidget() + if not colorBarWidget.isHidden() == checked: + return + self.plot.getColorBarWidget().setVisible(checked) + + +class KeepAspectRatioAction(PlotAction): + """QAction controlling aspect ratio on a :class:`.PlotWidget`. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + # Uses two images for checked/unchecked states + self._states = { + False: (icons.getQIcon('shape-circle-solid'), + "Keep data aspect ratio"), + True: (icons.getQIcon('shape-ellipse-solid'), + "Do no keep data aspect ratio") + } + + icon, tooltip = self._states[plot.isKeepDataAspectRatio()] + super(KeepAspectRatioAction, self).__init__( + plot, + icon=icon, + text='Toggle keep aspect ratio', + tooltip=tooltip, + triggered=self._actionTriggered, + checkable=False, + parent=parent) + plot.sigSetKeepDataAspectRatio.connect( + self._keepDataAspectRatioChanged) + + def _keepDataAspectRatioChanged(self, aspectRatio): + """Handle Plot set keep aspect ratio signal""" + icon, tooltip = self._states[aspectRatio] + self.setIcon(icon) + self.setToolTip(tooltip) + + def _actionTriggered(self, checked=False): + # This will trigger _keepDataAspectRatioChanged + self.plot.setKeepDataAspectRatio(not self.plot.isKeepDataAspectRatio()) + + +class YAxisInvertedAction(PlotAction): + """QAction controlling Y orientation on a :class:`.PlotWidget`. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + # Uses two images for checked/unchecked states + self._states = { + False: (icons.getQIcon('plot-ydown'), + "Orient Y axis downward"), + True: (icons.getQIcon('plot-yup'), + "Orient Y axis upward"), + } + + icon, tooltip = self._states[plot.getYAxis().isInverted()] + super(YAxisInvertedAction, self).__init__( + plot, + icon=icon, + text='Invert Y Axis', + tooltip=tooltip, + triggered=self._actionTriggered, + checkable=False, + parent=parent) + plot.getYAxis().sigInvertedChanged.connect(self._yAxisInvertedChanged) + + def _yAxisInvertedChanged(self, inverted): + """Handle Plot set y axis inverted signal""" + icon, tooltip = self._states[inverted] + self.setIcon(icon) + self.setToolTip(tooltip) + + def _actionTriggered(self, checked=False): + # This will trigger _yAxisInvertedChanged + yAxis = self.plot.getYAxis() + yAxis.setInverted(not yAxis.isInverted()) + + +class CrosshairAction(PlotAction): + """QAction toggling crosshair cursor on a :class:`.PlotWidget`. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param str color: Color to use to draw the crosshair + :param int linewidth: Width of the crosshair cursor + :param str linestyle: Style of line. See :meth:`.Plot.setGraphCursor` + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, color='black', linewidth=1, linestyle='-', + parent=None): + self.color = color + """Color used to draw the crosshair (str).""" + + self.linewidth = linewidth + """Width of the crosshair cursor (int).""" + + self.linestyle = linestyle + """Style of line of the cursor (str).""" + + super(CrosshairAction, self).__init__( + plot, icon='crosshair', text='Crosshair Cursor', + tooltip='Enable crosshair cursor when checked', + triggered=self._actionTriggered, + checkable=True, parent=parent) + self.setChecked(plot.getGraphCursor() is not None) + plot.sigSetGraphCursor.connect(self.setChecked) + + def _actionTriggered(self, checked=False): + self.plot.setGraphCursor(checked, + color=self.color, + linestyle=self.linestyle, + linewidth=self.linewidth) + + +class PanWithArrowKeysAction(PlotAction): + """QAction toggling pan with arrow keys on a :class:`.PlotWidget`. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + + super(PanWithArrowKeysAction, self).__init__( + plot, icon='arrow-keys', text='Pan with arrow keys', + tooltip='Enable pan with arrow keys when checked', + triggered=self._actionTriggered, + checkable=True, parent=parent) + self.setChecked(plot.isPanWithArrowKeys()) + plot.sigSetPanWithArrowKeys.connect(self.setChecked) + + def _actionTriggered(self, checked=False): + self.plot.setPanWithArrowKeys(checked) + + +class ShowAxisAction(PlotAction): + """QAction controlling axis visibility on a :class:`.PlotWidget`. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + tooltip = 'Show plot axis when checked, otherwise hide them' + PlotAction.__init__(self, + plot, + icon='axis', + text='show axis', + tooltip=tooltip, + triggered=self._actionTriggered, + checkable=True, + parent=parent) + self.setChecked(self.plot.isAxesDisplayed()) + plot._sigAxesVisibilityChanged.connect(self.setChecked) + + def _actionTriggered(self, checked=False): + self.plot.setAxesDisplayed(checked) + + +class ClosePolygonInteractionAction(PlotAction): + """QAction controlling closure of a polygon in draw interaction mode + if the :class:`.PlotWidget`. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + tooltip = 'Close the current polygon drawn' + PlotAction.__init__(self, + plot, + icon='add-shape-polygon', + text='Close the polygon', + tooltip=tooltip, + triggered=self._actionTriggered, + checkable=True, + parent=parent) + self.plot.sigInteractiveModeChanged.connect(self._modeChanged) + self._modeChanged(None) + + def _modeChanged(self, source): + mode = self.plot.getInteractiveMode() + enabled = "shape" in mode and mode["shape"] == "polygon" + self.setEnabled(enabled) + + def _actionTriggered(self, checked=False): + self.plot._eventHandler.validate() + + +class OpenGLAction(PlotAction): + """QAction controlling rendering of a :class:`.PlotWidget`. + + For now it can enable or not the OpenGL backend. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + # Uses two images for checked/unchecked states + self._states = { + "opengl": (icons.getQIcon('backend-opengl'), + "OpenGL rendering (fast)\nClick to disable OpenGL"), + "matplotlib": (icons.getQIcon('backend-opengl'), + "Matplotlib rendering (safe)\nClick to enable OpenGL"), + "unknown": (icons.getQIcon('backend-opengl'), + "Custom rendering") + } + + name = self._getBackendName(plot) + self.__state = name + icon, tooltip = self._states[name] + super(OpenGLAction, self).__init__( + plot, + icon=icon, + text='Enable/disable OpenGL rendering', + tooltip=tooltip, + triggered=self._actionTriggered, + checkable=True, + parent=parent) + + def _backendUpdated(self): + name = self._getBackendName(self.plot) + self.__state = name + icon, tooltip = self._states[name] + self.setIcon(icon) + self.setToolTip(tooltip) + self.setChecked(name == "opengl") + + def _getBackendName(self, plot): + backend = plot.getBackend() + name = type(backend).__name__.lower() + if "opengl" in name: + return "opengl" + elif "matplotlib" in name: + return "matplotlib" + else: + return "unknown" + + def _actionTriggered(self, checked=False): + plot = self.plot + name = self._getBackendName(self.plot) + if self.__state != name: + # THere is no event to know the backend was updated + # So here we check if there is a mismatch between the displayed state + # and the real state of the widget + self._backendUpdated() + return + if name != "opengl": + from silx.gui.utils import glutils + result = glutils.isOpenGLAvailable() + if not result: + qt.QMessageBox.critical(plot, "OpenGL rendering not available", result.error) + # Uncheck if needed + self._backendUpdated() + return + plot.setBackend("opengl") + else: + plot.setBackend("matplotlib") + self._backendUpdated() diff --git a/src/silx/gui/plot/actions/fit.py b/src/silx/gui/plot/actions/fit.py new file mode 100644 index 0000000..e130b24 --- /dev/null +++ b/src/silx/gui/plot/actions/fit.py @@ -0,0 +1,485 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2021 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. +# +# ###########################################################################*/ +""" +:mod:`silx.gui.plot.actions.fit` module provides actions relative to fit. + +The following QAction are available: + +- :class:`.FitAction` + +.. autoclass:`.FitAction` +""" + +from __future__ import division + +__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] +__license__ = "MIT" +__date__ = "10/10/2018" + +import logging +import sys +import weakref +import numpy + +from .PlotToolAction import PlotToolAction +from .. import items +from ....utils.deprecation import deprecated +from silx.gui import qt +from silx.gui.plot.ItemsSelectionDialog import ItemsSelectionDialog + +_logger = logging.getLogger(__name__) + + +def _getUniqueCurveOrHistogram(plot): + """Returns unique :class:`Curve` or :class:`Histogram` in a `PlotWidget`. + + If there is an active curve, returns it, else return curve or histogram + only if alone in the plot. + + :param PlotWidget plot: + :rtype: Union[None,~silx.gui.plot.items.Curve,~silx.gui.plot.items.Histogram] + """ + curve = plot.getActiveCurve() + if curve is not None: + return curve + + visibleItems = [item for item in plot.getItems() if item.isVisible()] + histograms = [item for item in visibleItems + if isinstance(item, items.Histogram)] + curves = [item for item in visibleItems + if isinstance(item, items.Curve)] + + if len(histograms) == 1 and len(curves) == 0: + return histograms[0] + elif len(curves) == 1 and len(histograms) == 0: + return curves[0] + else: + return None + + +class _FitItemSelector(qt.QObject): + """ + :class:`PlotWidget` observer that emits signal when fit selection changes. + + Track active curve or unique curve or histogram. + """ + + sigCurrentItemChanged = qt.Signal(object) + """Signal emitted when the item to fit has changed""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__plotWidgetRef = None + self.__currentItem = None + + def getCurrentItem(self): + """Return currently selected item + + :rtype: Union[Item,None] + """ + return self.__currentItem + + def getPlotWidget(self): + """Return currently attached :class:`PlotWidget` + + :rtype: Union[PlotWidget,None] + """ + return None if self.__plotWidgetRef is None else self.__plotWidgetRef() + + def setPlotWidget(self, plotWidget): + """Set the :class:`PlotWidget` for which to track changes + + :param Union[PlotWidget,None] plotWidget: + The :class:`PlotWidget` to observe + """ + # disconnect from previous plot + previousPlotWidget = self.getPlotWidget() + if previousPlotWidget is not None: + previousPlotWidget.sigItemAdded.disconnect( + self.__plotWidgetUpdated) + previousPlotWidget.sigItemRemoved.disconnect( + self.__plotWidgetUpdated) + previousPlotWidget.sigActiveCurveChanged.disconnect( + self.__plotWidgetUpdated) + + if plotWidget is None: + self.__plotWidgetRef = None + self.__setCurrentItem(None) + return + self.__plotWidgetRef = weakref.ref(plotWidget, self.__plotDeleted) + + # connect to new plot + plotWidget.sigItemAdded.connect(self.__plotWidgetUpdated) + plotWidget.sigItemRemoved.connect(self.__plotWidgetUpdated) + plotWidget.sigActiveCurveChanged.connect(self.__plotWidgetUpdated) + self.__plotWidgetUpdated() + + def __plotDeleted(self): + """Handle deletion of PlotWidget""" + self.__setCurrentItem(None) + + def __plotWidgetUpdated(self, *args, **kwargs): + """Handle updates of PlotWidget content""" + plotWidget = self.getPlotWidget() + if plotWidget is None: + return + self.__setCurrentItem(_getUniqueCurveOrHistogram(plotWidget)) + + def __setCurrentItem(self, item): + """Handle change of current item""" + if sys.is_finalizing(): + return + + previousItem = self.getCurrentItem() + if item != previousItem: + if previousItem is not None: + previousItem.sigItemChanged.disconnect(self.__itemUpdated) + + self.__currentItem = item + + if self.__currentItem is not None: + self.__currentItem.sigItemChanged.connect(self.__itemUpdated) + self.sigCurrentItemChanged.emit(self.__currentItem) + + def __itemUpdated(self, event): + """Handle change on current item""" + if event == items.ItemChangedType.DATA: + self.sigCurrentItemChanged.emit(self.__currentItem) + + +class FitAction(PlotToolAction): + """QAction to open a :class:`FitWidget` and set its data to the + active curve if any, or to the first curve. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + self.__item = None + self.__activeCurveSynchroEnabled = False + self.__range = 0, 1 + self.__rangeAutoUpdate = False + self.__x, self.__y = None, None # Data to fit + self.__curveParams = {} # Store curve parameters to use for fit result + self.__legend = None + + super(FitAction, self).__init__( + plot, icon='math-fit', text='Fit curve', + tooltip='Open a fit dialog', + parent=parent) + + self.__fitItemSelector = _FitItemSelector() + self.__fitItemSelector.sigCurrentItemChanged.connect( + self._setFittedItem) + + + @property + @deprecated(replacement='getXRange()[0]', since_version='0.13.0') + def xmin(self): + return self.getXRange()[0] + + @property + @deprecated(replacement='getXRange()[1]', since_version='0.13.0') + def xmax(self): + return self.getXRange()[1] + + @property + @deprecated(replacement='getXData()', since_version='0.13.0') + def x(self): + return self.getXData() + + @property + @deprecated(replacement='getYData()', since_version='0.13.0') + def y(self): + return self.getYData() + + @property + @deprecated(since_version='0.13.0') + def xlabel(self): + return self.__curveParams.get('xlabel', None) + + @property + @deprecated(since_version='0.13.0') + def ylabel(self): + return self.__curveParams.get('ylabel', None) + + @property + @deprecated(since_version='0.13.0') + def legend(self): + return self.__legend + + def _createToolWindow(self): + # import done here rather than at module level to avoid circular import + # FitWidget -> BackgroundWidget -> PlotWindow -> actions -> fit -> FitWidget + from ...fit.FitWidget import FitWidget + + window = FitWidget(parent=self.plot) + window.setWindowFlags(qt.Qt.Dialog) + window.sigFitWidgetSignal.connect(self.handle_signal) + return window + + def _connectPlot(self, window): + if self.isXRangeUpdatedOnZoom(): + self.__setAutoXRangeEnabled(True) + else: + plot = self.plot + if plot is None: + _logger.error("No associated PlotWidget") + return + self._setXRange(*plot.getXAxis().getLimits()) + + if self.isFittedItemUpdatedFromActiveCurve(): + self.__setFittedItemAutoUpdateEnabled(True) + else: + # Wait for the next iteration, else the plot is not yet initialized + # No curve available + qt.QTimer.singleShot(10, self._initFit) + + def _disconnectPlot(self, window): + if self.isXRangeUpdatedOnZoom(): + self.__setAutoXRangeEnabled(False) + + if self.isFittedItemUpdatedFromActiveCurve(): + self.__setFittedItemAutoUpdateEnabled(False) + + def _initFit(self): + plot = self.plot + if plot is None: + _logger.error("No associated PlotWidget") + return + + item = _getUniqueCurveOrHistogram(plot) + if item is None: + # ambiguous case, we need to ask which plot item to fit + isd = ItemsSelectionDialog(parent=plot, plot=plot) + isd.setWindowTitle("Select item to be fitted") + isd.setItemsSelectionMode(qt.QTableWidget.SingleSelection) + isd.setAvailableKinds(["curve", "histogram"]) + isd.selectAllKinds() + + if not isd.exec(): # Cancel + self._getToolWindow().setVisible(False) + else: + selectedItems = isd.getSelectedItems() + item = selectedItems[0] if len(selectedItems) == 1 else None + + self._setXRange(*plot.getXAxis().getLimits()) + self._setFittedItem(item) + + def __updateFitWidget(self): + """Update the data/range used by the FitWidget""" + fitWidget = self._getToolWindow() + + item = self._getFittedItem() + xdata = self.getXData(copy=False) + ydata = self.getYData(copy=False) + if item is None or xdata is None or ydata is None: + fitWidget.setData(y=None) + fitWidget.setWindowTitle("No curve selected") + + else: + xmin, xmax = self.getXRange() + fitWidget.setData( + xdata, ydata, xmin=xmin, xmax=xmax) + fitWidget.setWindowTitle( + "Fitting " + item.getName() + + " on x range %f-%f" % (xmin, xmax)) + + # X Range management + + def getXRange(self): + """Returns the range on the X axis on which to perform the fit.""" + return self.__range + + def _setXRange(self, xmin, xmax): + """Set the range on which the fit is done. + + :param float xmin: + :param float xmax: + """ + range_ = float(xmin), float(xmax) + if self.__range != range_: + self.__range = range_ + self.__updateFitWidget() + + def __setAutoXRangeEnabled(self, enabled): + """Implement the change of update mode of the X range. + + :param bool enabled: + """ + plot = self.plot + if plot is None: + _logger.error("No associated PlotWidget") + return + + if enabled: + self._setXRange(*plot.getXAxis().getLimits()) + plot.getXAxis().sigLimitsChanged.connect(self._setXRange) + else: + plot.getXAxis().sigLimitsChanged.disconnect(self._setXRange) + + def setXRangeUpdatedOnZoom(self, enabled): + """Set whether or not to update the X range on zoom change. + + :param bool enabled: + """ + if enabled != self.__rangeAutoUpdate: + self.__rangeAutoUpdate = enabled + if self._getToolWindow().isVisible(): + self.__setAutoXRangeEnabled(enabled) + + def isXRangeUpdatedOnZoom(self): + """Returns the current mode of fitted data X range update. + + :rtype: bool + """ + return self.__rangeAutoUpdate + + # Fitted item update + + def getXData(self, copy=True): + """Returns the X data used for the fit or None if undefined. + + :param bool copy: + True to get a copy of the data, False to get the internal data. + :rtype: Union[numpy.ndarray,None] + """ + return None if self.__x is None else numpy.array(self.__x, copy=copy) + + def getYData(self, copy=True): + """Returns the Y data used for the fit or None if undefined. + + :param bool copy: + True to get a copy of the data, False to get the internal data. + :rtype: Union[numpy.ndarray,None] + """ + return None if self.__y is None else numpy.array(self.__y, copy=copy) + + def _getFittedItem(self): + """Returns the current item used for the fit + + :rtype: Union[~silx.gui.plot.items.Curve,~silx.gui.plot.items.Histogram,None] + """ + return self.__item + + def _setFittedItem(self, item): + """Set the curve to use for fitting. + + :param Union[~silx.gui.plot.items.Curve,~silx.gui.plot.items.Histogram,None] item: + """ + plot = self.plot + if plot is None: + _logger.error("No associated PlotWidget") + + if plot is None or item is None: + self.__item = None + self.__curveParams = {} + self.__updateFitWidget() + return + + axis = item.getYAxis() if isinstance(item, items.YAxisMixIn) else 'left' + self.__curveParams = { + 'yaxis': axis, + 'xlabel': plot.getXAxis().getLabel(), + 'ylabel': plot.getYAxis(axis).getLabel(), + } + self.__legend = item.getName() + + if isinstance(item, items.Histogram): + bin_edges = item.getBinEdgesData(copy=False) + # take the middle coordinate between adjacent bin edges + self.__x = (bin_edges[1:] + bin_edges[:-1]) / 2 + self.__y = item.getValueData(copy=False) + # else take the active curve, or else the unique curve + elif isinstance(item, items.Curve): + self.__x = item.getXData(copy=False) + self.__y = item.getYData(copy=False) + + self.__item = item + self.__updateFitWidget() + + def __setFittedItemAutoUpdateEnabled(self, enabled): + """Implement the change of fitted item update mode + + :param bool enabled: + """ + plot = self.plot + if plot is None: + _logger.error("No associated PlotWidget") + return + + self.__fitItemSelector.setPlotWidget(self.plot if enabled else None) + + def setFittedItemUpdatedFromActiveCurve(self, enabled): + """Toggle fitted data synchronization with plot active curve. + + :param bool enabled: + """ + enabled = bool(enabled) + if enabled != self.__activeCurveSynchroEnabled: + self.__activeCurveSynchroEnabled = enabled + if self._getToolWindow().isVisible(): + self.__setFittedItemAutoUpdateEnabled(enabled) + + def isFittedItemUpdatedFromActiveCurve(self): + """Returns True if fitted data is synchronized with plot. + + :rtype: bool + """ + return self.__activeCurveSynchroEnabled + + # Handle fit completed + + def handle_signal(self, ddict): + xdata = self.getXData(copy=False) + if xdata is None: + _logger.error("No reference data to display fit result for") + return + + xmin, xmax = self.getXRange() + x_fit = xdata[xmin <= xdata] + x_fit = x_fit[x_fit <= xmax] + fit_legend = "Fit <%s>" % self.__legend + fit_curve = self.plot.getCurve(fit_legend) + + if ddict["event"] == "FitFinished": + fit_widget = self._getToolWindow() + if fit_widget is None: + return + y_fit = fit_widget.fitmanager.gendata() + if fit_curve is None: + self.plot.addCurve(x_fit, y_fit, + fit_legend, + resetzoom=False, + **self.__curveParams) + else: + fit_curve.setData(x_fit, y_fit) + fit_curve.setVisible(True) + fit_curve.setYAxis(self.__curveParams.get('yaxis', 'left')) + + if ddict["event"] in ["FitStarted", "FitFailed"]: + if fit_curve is not None: + fit_curve.setVisible(False) diff --git a/src/silx/gui/plot/actions/histogram.py b/src/silx/gui/plot/actions/histogram.py new file mode 100644 index 0000000..be9f5a7 --- /dev/null +++ b/src/silx/gui/plot/actions/histogram.py @@ -0,0 +1,542 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2021 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. +# +# ###########################################################################*/ +""" +:mod:`silx.gui.plot.actions.histogram` provides actions relative to histograms +for :class:`.PlotWidget`. + +The following QAction are available: + +- :class:`PixelIntensitiesHistoAction` +""" + +from __future__ import division + +__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] +__date__ = "01/12/2020" +__license__ = "MIT" + +from typing import Optional, Tuple +import numpy +import logging +import weakref + +from .PlotToolAction import PlotToolAction + +from silx.math.histogram import Histogramnd +from silx.math.combo import min_max +from silx.gui import qt +from silx.gui.plot import items +from silx.gui.widgets.ElidedLabel import ElidedLabel +from silx.gui.widgets.RangeSlider import RangeSlider +from silx.utils.deprecation import deprecated + +_logger = logging.getLogger(__name__) + + +class _ElidedLabel(ElidedLabel): + """QLabel with a default size larger than what is displayed.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setTextInteractionFlags(qt.Qt.TextSelectableByMouse) + + def sizeHint(self): + hint = super().sizeHint() + nbchar = max(len(self.getText()), 12) + width = self.fontMetrics().boundingRect('#' * nbchar).width() + return qt.QSize(max(hint.width(), width), hint.height()) + + +class _StatWidget(qt.QWidget): + """Widget displaying a name and a value + + :param parent: + :param name: + """ + + def __init__(self, parent=None, name: str=''): + super().__init__(parent) + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + keyWidget = qt.QLabel(parent=self) + keyWidget.setText("<b>" + name.capitalize() + ":<b>") + layout.addWidget(keyWidget) + self.__valueWidget = _ElidedLabel(parent=self) + self.__valueWidget.setText("-") + self.__valueWidget.setTextInteractionFlags( + qt.Qt.TextSelectableByMouse | qt.Qt.TextSelectableByKeyboard) + layout.addWidget(self.__valueWidget) + + def setValue(self, value: Optional[float]): + """Set the displayed value + + :param value: + """ + self.__valueWidget.setText( + "-" if value is None else "{:.5g}".format(value)) + + +class _IntEdit(qt.QLineEdit): + """QLineEdit for integers with a default value and update on validation. + + :param QWidget parent: + """ + + sigValueChanged = qt.Signal(int) + """Signal emitted when the value has changed (on editing finished)""" + + def __init__(self, parent=None): + super().__init__(parent) + self.__value = None + self.setAlignment(qt.Qt.AlignRight) + validator = qt.QIntValidator() + self.setValidator(validator) + validator.bottomChanged.connect(self.__updateSize) + validator.topChanged.connect(self.__updateSize) + self.__updateSize() + + self.textEdited.connect(self.__textEdited) + + def __updateSize(self, *args): + """Update widget's maximum size according to bounds""" + bottom, top = self.getRange() + nbchar = max(len(str(bottom)), len(str(top))) + font = self.font() + font.setStyle(qt.QFont.StyleItalic) + fontMetrics = qt.QFontMetrics(font) + self.setMaximumWidth( + fontMetrics.boundingRect('0' * (nbchar + 1)).width() + ) + self.setMaxLength(nbchar) + + def __textEdited(self, _): + if self.font().style() != qt.QFont.StyleItalic: + font = self.font() + font.setStyle(qt.QFont.StyleItalic) + self.setFont(font) + + # Use events rather than editingFinished to also trigger with empty text + + def focusOutEvent(self, event): + self.__commitValue() + return super().focusOutEvent(event) + + def keyPressEvent(self, event): + if event.key() in (qt.Qt.Key_Enter, qt.Qt.Key_Return): + self.__commitValue() + return super().keyPressEvent(event) + + def __commitValue(self): + """Update the value returned by :meth:`getValue`""" + value = self.getCurrentValue() + if value is None: + value = self.getDefaultValue() + if value is None: + return # No value, keep previous one + + if self.font().style() != qt.QFont.StyleNormal: + font = self.font() + font.setStyle(qt.QFont.StyleNormal) + self.setFont(font) + + if value != self.__value: + self.__value = value + self.sigValueChanged.emit(value) + + def getValue(self) -> Optional[int]: + """Return current value (None if never set).""" + return self.__value + + def setRange(self, bottom: int, top: int): + """Set the range of valid values""" + self.validator().setRange(bottom, top) + + def getRange(self) -> Tuple[int, int]: + """Returns the current range of valid values + + :returns: (bottom, top) + """ + return self.validator().bottom(), self.validator().top() + + def __validate(self, value: int, extend_range: bool): + """Ensure value is in range + + :param int value: + :param bool extend_range: + True to extend range if needed. + False to clip value if needed. + """ + if extend_range: + bottom, top = self.getRange() + self.setRange(min(value, bottom), max(value, top)) + return numpy.clip(value, *self.getRange()) + + def setDefaultValue(self, value: int, extend_range: bool=False): + """Set default value when QLineEdit is empty + + :param int value: + :param bool extend_range: + True to extend range if needed. + False to clip value if needed + """ + self.setPlaceholderText(str(self.__validate(value, extend_range))) + if self.getCurrentValue() is None: + self.__commitValue() + + def getDefaultValue(self) -> Optional[int]: + """Return the default value or the bottom one if not set""" + try: + return int(self.placeholderText()) + except ValueError: + return None + + def setCurrentValue(self, value: int, extend_range: bool=False): + """Set the currently displayed value + + :param int value: + :param bool extend_range: + True to extend range if needed. + False to clip value if needed + """ + self.setText(str(self.__validate(value, extend_range))) + self.__commitValue() + + def getCurrentValue(self) -> Optional[int]: + """Returns the displayed value or None if not correct""" + try: + return int(self.text()) + except ValueError: + return None + + +class HistogramWidget(qt.QWidget): + """Widget displaying a histogram and some statistic indicators""" + + _SUPPORTED_ITEM_CLASS = items.ImageBase, items.Scatter + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setWindowTitle('Histogram') + + self.__itemRef = None # weakref on the item to track + + layout = qt.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Plot + # Lazy import to avoid circular dependencies + from silx.gui.plot.PlotWindow import Plot1D + self.__plot = Plot1D(self) + layout.addWidget(self.__plot) + + self.__plot.setDataMargins(0.1, 0.1, 0.1, 0.1) + self.__plot.getXAxis().setLabel("Value") + self.__plot.getYAxis().setLabel("Count") + posInfo = self.__plot.getPositionInfoWidget() + posInfo.setSnappingMode(posInfo.SNAPPING_CURVE) + + # Histogram controls + controlsWidget = qt.QWidget(self) + layout.addWidget(controlsWidget) + controlsLayout = qt.QHBoxLayout(controlsWidget) + controlsLayout.setContentsMargins(4, 4, 4, 4) + + controlsLayout.addWidget(qt.QLabel("<b>Histogram:<b>")) + controlsLayout.addWidget(qt.QLabel("N. bins:")) + self.__nbinsLineEdit = _IntEdit(self) + self.__nbinsLineEdit.setRange(2, 9999) + self.__nbinsLineEdit.sigValueChanged.connect( + self.__updateHistogramFromControls) + controlsLayout.addWidget(self.__nbinsLineEdit) + self.__rangeLabel = qt.QLabel("Range:") + controlsLayout.addWidget(self.__rangeLabel) + self.__rangeSlider = RangeSlider(parent=self) + self.__rangeSlider.sigValueChanged.connect( + self.__updateHistogramFromControls) + self.__rangeSlider.sigValueChanged.connect(self.__rangeChanged) + controlsLayout.addWidget(self.__rangeSlider) + controlsLayout.addStretch(1) + + # Stats display + statsWidget = qt.QWidget(self) + layout.addWidget(statsWidget) + statsLayout = qt.QHBoxLayout(statsWidget) + statsLayout.setContentsMargins(4, 4, 4, 4) + + self.__statsWidgets = dict( + (name, _StatWidget(parent=statsWidget, name=name)) + for name in ("min", "max", "mean", "std", "sum")) + + for widget in self.__statsWidgets.values(): + statsLayout.addWidget(widget) + statsLayout.addStretch(1) + + def getPlotWidget(self): + """Returns :class:`PlotWidget` use to display the histogram""" + return self.__plot + + def resetZoom(self): + """Reset PlotWidget zoom""" + self.getPlotWidget().resetZoom() + + def reset(self): + """Clear displayed information""" + self.getPlotWidget().clear() + self.setStatistics() + + def getItem(self) -> Optional[items.Item]: + """Returns item used to display histogram and statistics.""" + return None if self.__itemRef is None else self.__itemRef() + + def setItem(self, item: Optional[items.Item]): + """Set item from which to display histogram and statistics. + + :param item: + """ + previous = self.getItem() + if previous is not None: + previous.sigItemChanged.disconnect(self.__itemChanged) + + self.__itemRef = None if item is None else weakref.ref(item) + if item is not None: + if isinstance(item, self._SUPPORTED_ITEM_CLASS): + # Only listen signal for supported items + item.sigItemChanged.connect(self.__itemChanged) + self._updateFromItem() + + def __itemChanged(self, event): + """Handle update of the item""" + if event in (items.ItemChangedType.DATA, items.ItemChangedType.MASK): + self._updateFromItem() + + def __updateHistogramFromControls(self, *args): + """Handle udates coming from histogram control widgets""" + + hist = self.getHistogram(copy=False) + if hist is not None: + count, edges = hist + if (len(count) == self.__nbinsLineEdit.getValue() and + (edges[0], edges[-1]) == self.__rangeSlider.getValues()): + return # Nothing has changed + + self._updateFromItem() + + def __rangeChanged(self, first, second): + """Handle change of histogram range from the range slider""" + tooltip = "Histogram range:\n[%g, %g]" % (first, second) + self.__rangeSlider.setToolTip(tooltip) + self.__rangeLabel.setToolTip(tooltip) + + def _updateFromItem(self): + """Update histogram and stats from the item""" + item = self.getItem() + + if item is None: + self.reset() + return + + if not isinstance(item, self._SUPPORTED_ITEM_CLASS): + _logger.error("Unsupported item", item) + self.reset() + return + + # Compute histogram and stats + array = item.getValueData(copy=False) + + if array.size == 0: + self.reset() + return + + xmin, xmax = min_max(array, min_positive=False, finite=True) + if xmin is None or xmax is None: # All not finite data + self.reset() + return + guessed_nbins = min(1024, int(numpy.sqrt(array.size))) + + # bad hack: get 256 bins in the case we have a B&W + if numpy.issubdtype(array.dtype, numpy.integer): + if guessed_nbins > xmax - xmin: + guessed_nbins = xmax - xmin + guessed_nbins = max(2, guessed_nbins) + + # Set default nbins + self.__nbinsLineEdit.setDefaultValue(guessed_nbins, extend_range=True) + # Set slider range: do not keep the range value, but the relative pos. + previousPositions = self.__rangeSlider.getPositions() + if xmin == xmax: # Enlarge range is none + if xmin == 0: + range_ = -0.01, 0.01 + else: + range_ = sorted((xmin * .99, xmin * 1.01)) + else: + range_ = xmin, xmax + + self.__rangeSlider.setRange(*range_) + self.__rangeSlider.setPositions(*previousPositions) + + histogram = Histogramnd( + array.ravel().astype(numpy.float32), + n_bins=max(2, self.__nbinsLineEdit.getValue()), + histo_range=self.__rangeSlider.getValues(), + ) + if len(histogram.edges) != 1: + _logger.error("Error while computing the histogram") + self.reset() + return + + self.setHistogram(histogram.histo, histogram.edges[0]) + self.resetZoom() + self.setStatistics( + min_=xmin, + max_=xmax, + mean=numpy.nanmean(array), + std=numpy.nanstd(array), + sum_=numpy.nansum(array)) + + def setHistogram(self, histogram, edges): + """Set displayed histogram + + :param histogram: Bin values (N) + :param edges: Bin edges (N+1) + """ + # Only useful if setHistogram is called directly + # TODO + #nbins = len(histogram) + #if nbins != self.__nbinsLineEdit.getDefaultValue(): + # self.__nbinsLineEdit.setValue(nbins, extend_range=True) + #self.__rangeSlider.setValues(edges[0], edges[-1]) + + self.getPlotWidget().addHistogram( + histogram=histogram, + edges=edges, + legend='histogram', + fill=True, + color='#66aad7', + resetzoom=False) + + def getHistogram(self, copy: bool=True): + """Returns currently displayed histogram. + + :param copy: True to get a copy, + False to get internal representation (Do not modify!) + :return: (histogram, edges) or None + """ + for item in self.getPlotWidget().getItems(): + if item.getName() == 'histogram': + return (item.getValueData(copy=copy), + item.getBinEdgesData(copy=copy)) + else: + return None + + def setStatistics(self, + min_: Optional[float] = None, + max_: Optional[float] = None, + mean: Optional[float] = None, + std: Optional[float] = None, + sum_: Optional[float] = None): + """Set displayed statistic indicators.""" + self.__statsWidgets['min'].setValue(min_) + self.__statsWidgets['max'].setValue(max_) + self.__statsWidgets['mean'].setValue(mean) + self.__statsWidgets['std'].setValue(std) + self.__statsWidgets['sum'].setValue(sum_) + + +class PixelIntensitiesHistoAction(PlotToolAction): + """QAction to plot the pixels intensities diagram + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + PlotToolAction.__init__(self, + plot, + icon='pixel-intensities', + text='pixels intensity', + tooltip='Compute image intensity distribution', + parent=parent) + + def _connectPlot(self, window): + plot = self.plot + if plot is not None: + selection = plot.selection() + selection.sigSelectedItemsChanged.connect(self._selectedItemsChanged) + self._updateSelectedItem() + + PlotToolAction._connectPlot(self, window) + + def _disconnectPlot(self, window): + plot = self.plot + if plot is not None: + selection = self.plot.selection() + selection.sigSelectedItemsChanged.disconnect(self._selectedItemsChanged) + + PlotToolAction._disconnectPlot(self, window) + self.getHistogramWidget().setItem(None) + + def _updateSelectedItem(self): + """Synchronises selected item with plot widget.""" + plot = self.plot + if plot is not None: + selected = plot.selection().getSelectedItems() + # Give priority to image over scatter + for klass in (items.ImageBase, items.Scatter): + for item in selected: + if isinstance(item, klass): + # Found a matching item, use it + self.getHistogramWidget().setItem(item) + return + self.getHistogramWidget().setItem(None) + + def _selectedItemsChanged(self): + if self._isWindowInUse(): + self._updateSelectedItem() + + @deprecated(since_version='0.15.0') + def computeIntensityDistribution(self): + self.getHistogramWidget()._updateFromItem() + + def getHistogramWidget(self): + """Returns the widget displaying the histogram""" + return self._getToolWindow() + + @deprecated(since_version='0.15.0', + replacement='getHistogramWidget().getPlotWidget()') + def getHistogramPlotWidget(self): + return self._getToolWindow().getPlotWidget() + + def _createToolWindow(self): + return HistogramWidget(self.plot, qt.Qt.Window) + + def getHistogram(self) -> Optional[numpy.ndarray]: + """Return the last computed histogram + + :return: the histogram displayed in the HistogramWidget + """ + histogram = self.getHistogramWidget().getHistogram() + return None if histogram is None else histogram[0] diff --git a/src/silx/gui/plot/actions/io.py b/src/silx/gui/plot/actions/io.py new file mode 100644 index 0000000..7f4edd3 --- /dev/null +++ b/src/silx/gui/plot/actions/io.py @@ -0,0 +1,819 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2021 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. +# +# ###########################################################################*/ +""" +:mod:`silx.gui.plot.actions.io` provides a set of QAction relative of inputs +and outputs for a :class:`.PlotWidget`. + +The following QAction are available: + +- :class:`CopyAction` +- :class:`PrintAction` +- :class:`SaveAction` +""" + +from __future__ import division + +__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] +__license__ = "MIT" +__date__ = "25/09/2020" + +from . import PlotAction +from silx.io.utils import save1D, savespec, NEXUS_HDF5_EXT +from silx.io.nxdata import save_NXdata +import logging +import sys +import os.path +from collections import OrderedDict +import traceback +import numpy +from silx.utils.deprecation import deprecated +from silx.gui import qt, printer +from silx.gui.dialog.GroupDialog import GroupDialog +from silx.third_party.EdfFile import EdfFile +from silx.third_party.TiffIO import TiffIO +from ...utils.image import convertArrayToQImage +if sys.version_info[0] == 3: + from io import BytesIO +else: + import cStringIO as _StringIO + BytesIO = _StringIO.StringIO + +_logger = logging.getLogger(__name__) + +_NEXUS_HDF5_EXT_STR = ' '.join(['*' + ext for ext in NEXUS_HDF5_EXT]) + + +def selectOutputGroup(h5filename): + """Open a dialog to prompt the user to select a group in + which to output data. + + :param str h5filename: name of an existing HDF5 file + :rtype: str + :return: Name of output group, or None if the dialog was cancelled + """ + dialog = GroupDialog() + dialog.addFile(h5filename) + dialog.setWindowTitle("Select an output group") + if not dialog.exec(): + return None + return dialog.getSelectedDataUrl().data_path() + + +class SaveAction(PlotAction): + """QAction for saving Plot content. + + It opens a Save as... dialog. + + :param plot: :class:`.PlotWidget` instance on which to operate. + :param parent: See :class:`QAction`. + """ + + SNAPSHOT_FILTER_SVG = 'Plot Snapshot as SVG (*.svg)' + SNAPSHOT_FILTER_PNG = 'Plot Snapshot as PNG (*.png)' + + DEFAULT_ALL_FILTERS = (SNAPSHOT_FILTER_PNG, SNAPSHOT_FILTER_SVG) + + # Dict of curve filters with CSV-like format + # Using ordered dict to guarantee filters order + # Note: '%.18e' is numpy.savetxt default format + CURVE_FILTERS_TXT = OrderedDict(( + ('Curve as Raw ASCII (*.txt)', + {'fmt': '%.18e', 'delimiter': ' ', 'header': False}), + ('Curve as ";"-separated CSV (*.csv)', + {'fmt': '%.18e', 'delimiter': ';', 'header': True}), + ('Curve as ","-separated CSV (*.csv)', + {'fmt': '%.18e', 'delimiter': ',', 'header': True}), + ('Curve as tab-separated CSV (*.csv)', + {'fmt': '%.18e', 'delimiter': '\t', 'header': True}), + ('Curve as OMNIC CSV (*.csv)', + {'fmt': '%.7E', 'delimiter': ',', 'header': False}), + ('Curve as SpecFile (*.dat)', + {'fmt': '%.10g', 'delimiter': '', 'header': False}) + )) + + CURVE_FILTER_NPY = 'Curve as NumPy binary file (*.npy)' + + CURVE_FILTER_NXDATA = 'Curve as NXdata (%s)' % _NEXUS_HDF5_EXT_STR + + DEFAULT_CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [ + CURVE_FILTER_NPY, CURVE_FILTER_NXDATA] + + DEFAULT_ALL_CURVES_FILTERS = ("All curves as SpecFile (*.dat)",) + + IMAGE_FILTER_EDF = 'Image data as EDF (*.edf)' + IMAGE_FILTER_TIFF = 'Image data as TIFF (*.tif)' + IMAGE_FILTER_NUMPY = 'Image data as NumPy binary file (*.npy)' + IMAGE_FILTER_ASCII = 'Image data as ASCII (*.dat)' + IMAGE_FILTER_CSV_COMMA = 'Image data as ,-separated CSV (*.csv)' + IMAGE_FILTER_CSV_SEMICOLON = 'Image data as ;-separated CSV (*.csv)' + IMAGE_FILTER_CSV_TAB = 'Image data as tab-separated CSV (*.csv)' + IMAGE_FILTER_RGB_PNG = 'Image as PNG (*.png)' + IMAGE_FILTER_NXDATA = 'Image as NXdata (%s)' % _NEXUS_HDF5_EXT_STR + + DEFAULT_IMAGE_FILTERS = (IMAGE_FILTER_EDF, + IMAGE_FILTER_TIFF, + IMAGE_FILTER_NUMPY, + IMAGE_FILTER_ASCII, + IMAGE_FILTER_CSV_COMMA, + IMAGE_FILTER_CSV_SEMICOLON, + IMAGE_FILTER_CSV_TAB, + IMAGE_FILTER_RGB_PNG, + IMAGE_FILTER_NXDATA) + + SCATTER_FILTER_NXDATA = 'Scatter as NXdata (%s)' % _NEXUS_HDF5_EXT_STR + DEFAULT_SCATTER_FILTERS = (SCATTER_FILTER_NXDATA,) + + # filters for which we don't want an "overwrite existing file" warning + DEFAULT_APPEND_FILTERS = (CURVE_FILTER_NXDATA, IMAGE_FILTER_NXDATA, + SCATTER_FILTER_NXDATA) + + def __init__(self, plot, parent=None): + self._filters = { + 'all': OrderedDict(), + 'curve': OrderedDict(), + 'curves': OrderedDict(), + 'image': OrderedDict(), + 'scatter': OrderedDict()} + + self._appendFilters = list(self.DEFAULT_APPEND_FILTERS) + + # Initialize filters + for nameFilter in self.DEFAULT_ALL_FILTERS: + self.setFileFilter( + dataKind='all', nameFilter=nameFilter, func=self._saveSnapshot) + + for nameFilter in self.DEFAULT_CURVE_FILTERS: + self.setFileFilter( + dataKind='curve', nameFilter=nameFilter, func=self._saveCurve) + + for nameFilter in self.DEFAULT_ALL_CURVES_FILTERS: + self.setFileFilter( + dataKind='curves', nameFilter=nameFilter, func=self._saveCurves) + + for nameFilter in self.DEFAULT_IMAGE_FILTERS: + self.setFileFilter( + dataKind='image', nameFilter=nameFilter, func=self._saveImage) + + for nameFilter in self.DEFAULT_SCATTER_FILTERS: + self.setFileFilter( + dataKind='scatter', nameFilter=nameFilter, func=self._saveScatter) + + super(SaveAction, self).__init__( + plot, icon='document-save', text='Save as...', + tooltip='Save curve/image/plot snapshot dialog', + triggered=self._actionTriggered, + checkable=False, parent=parent) + self.setShortcut(qt.QKeySequence.Save) + self.setShortcutContext(qt.Qt.WidgetShortcut) + + @staticmethod + def _errorMessage(informativeText='', parent=None): + """Display an error message.""" + # TODO issue with QMessageBox size fixed and too small + msg = qt.QMessageBox(parent) + msg.setIcon(qt.QMessageBox.Critical) + msg.setInformativeText(informativeText + ' ' + str(sys.exc_info()[1])) + msg.setDetailedText(traceback.format_exc()) + msg.exec() + + def _saveSnapshot(self, plot, filename, nameFilter): + """Save a snapshot of the :class:`PlotWindow` widget. + + :param str filename: The name of the file to write + :param str nameFilter: The selected name filter + :return: False if format is not supported or save failed, + True otherwise. + """ + if nameFilter == self.SNAPSHOT_FILTER_PNG: + fileFormat = 'png' + elif nameFilter == self.SNAPSHOT_FILTER_SVG: + fileFormat = 'svg' + else: # Format not supported + _logger.error( + 'Saving plot snapshot failed: format not supported') + return False + + plot.saveGraph(filename, fileFormat=fileFormat) + return True + + def _getAxesLabels(self, item): + # If curve has no associated label, get the default from the plot + xlabel = item.getXLabel() or self.plot.getXAxis().getLabel() + ylabel = item.getYLabel() or self.plot.getYAxis().getLabel() + return xlabel, ylabel + + def _get1dData(self, item): + "provide xdata, [ydata], xlabel, [ylabel] and manages error bars" + xlabel, ylabel = self._getAxesLabels(item) + x_data = item.getXData(copy=False) + y_data = item.getYData(copy=False) + x_err = item.getXErrorData(copy=False) + y_err = item.getYErrorData(copy=False) + labels = [ylabel] + data = [y_data] + + if x_err is not None: + if numpy.isscalar(x_err): + data.append(numpy.zeros_like(y_data) + x_err) + labels.append(xlabel + "_errors") + elif x_err.ndim == 1: + data.append(x_err) + labels.append(xlabel + "_errors") + elif x_err.ndim == 2: + data.append(x_err[0]) + labels.append(xlabel + "_errors_below") + data.append(x_err[1]) + labels.append(xlabel + "_errors_above") + + if y_err is not None: + if numpy.isscalar(y_err): + data.append(numpy.zeros_like(y_data) + y_err) + labels.append(ylabel + "_errors") + elif y_err.ndim == 1: + data.append(y_err) + labels.append(ylabel + "_errors") + elif y_err.ndim == 2: + data.append(y_err[0]) + labels.append(ylabel + "_errors_below") + data.append(y_err[1]) + labels.append(ylabel + "_errors_above") + return x_data, data, xlabel, labels + + @staticmethod + def _selectWriteableOutputGroup(filename, parent): + if os.path.exists(filename) and os.path.isfile(filename) \ + and os.access(filename, os.W_OK): + entryPath = selectOutputGroup(filename) + if entryPath is None: + _logger.info("Save operation cancelled") + return None + return entryPath + elif not os.path.exists(filename): + # create new entry in new file + return "/entry" + else: + SaveAction._errorMessage('Save failed (file access issue)\n', parent=parent) + return None + + def _saveCurveAsNXdata(self, curve, filename): + entryPath = self._selectWriteableOutputGroup(filename, parent=self.plot) + if entryPath is None: + return False + + xlabel, ylabel = self._getAxesLabels(curve) + + return save_NXdata( + filename, + nxentry_name=entryPath, + signal=curve.getYData(copy=False), + axes=[curve.getXData(copy=False)], + signal_name="y", + axes_names=["x"], + signal_long_name=ylabel, + axes_long_names=[xlabel], + signal_errors=curve.getYErrorData(copy=False), + axes_errors=[curve.getXErrorData(copy=True)], + title=self.plot.getGraphTitle()) + + def _saveCurve(self, plot, filename, nameFilter): + """Save a curve from the plot. + + :param str filename: The name of the file to write + :param str nameFilter: The selected name filter + :return: False if format is not supported or save failed, + True otherwise. + """ + if nameFilter not in self.DEFAULT_CURVE_FILTERS: + return False + + # Check if a curve is to be saved + curve = plot.getActiveCurve() + # before calling _saveCurve, if there is no selected curve, we + # make sure there is only one curve on the graph + if curve is None: + curves = plot.getAllCurves() + if not curves: + self._errorMessage("No curve to be saved", parent=self.plot) + return False + curve = curves[0] + + if nameFilter in self.CURVE_FILTERS_TXT: + filter_ = self.CURVE_FILTERS_TXT[nameFilter] + fmt = filter_['fmt'] + csvdelim = filter_['delimiter'] + autoheader = filter_['header'] + else: + # .npy or nxdata + fmt, csvdelim, autoheader = ("", "", False) + + if nameFilter == self.CURVE_FILTER_NXDATA: + return self._saveCurveAsNXdata(curve, filename) + + xdata, data, xlabel, labels = self._get1dData(curve) + + try: + save1D(filename, + xdata, data, + xlabel, labels, + fmt=fmt, csvdelim=csvdelim, + autoheader=autoheader) + except IOError: + self._errorMessage('Save failed\n', parent=self.plot) + return False + + return True + + def _saveCurves(self, plot, filename, nameFilter): + """Save all curves from the plot. + + :param str filename: The name of the file to write + :param str nameFilter: The selected name filter + :return: False if format is not supported or save failed, + True otherwise. + """ + if nameFilter not in self.DEFAULT_ALL_CURVES_FILTERS: + return False + + curves = plot.getAllCurves() + if not curves: + self._errorMessage("No curves to be saved", parent=self.plot) + return False + + curve = curves[0] + scanno = 1 + try: + xdata, data, xlabel, labels = self._get1dData(curve) + + specfile = savespec(filename, + xdata, data, + xlabel, labels, + fmt="%.7g", scan_number=1, mode="w", + write_file_header=True, + close_file=False) + except IOError: + self._errorMessage('Save failed\n', parent=self.plot) + return False + + for curve in curves[1:]: + try: + scanno += 1 + xdata, data, xlabel, labels = self._get1dData(curve) + specfile = savespec(specfile, + xdata, data, + xlabel, labels, + fmt="%.7g", scan_number=scanno, + write_file_header=False, + close_file=False) + except IOError: + self._errorMessage('Save failed\n', parent=self.plot) + return False + specfile.close() + + return True + + def _saveImage(self, plot, filename, nameFilter): + """Save an image from the plot. + + :param str filename: The name of the file to write + :param str nameFilter: The selected name filter + :return: False if format is not supported or save failed, + True otherwise. + """ + if nameFilter not in self.DEFAULT_IMAGE_FILTERS: + return False + + image = plot.getActiveImage() + if image is None: + qt.QMessageBox.warning( + plot, "No Data", "No image to be saved") + return False + + data = image.getData(copy=False) + + # TODO Use silx.io for writing files + if nameFilter == self.IMAGE_FILTER_EDF: + edfFile = EdfFile(filename, access="w+") + edfFile.WriteImage({}, data, Append=0) + return True + + elif nameFilter == self.IMAGE_FILTER_TIFF: + tiffFile = TiffIO(filename, mode='w') + tiffFile.writeImage(data, software='silx') + return True + + elif nameFilter == self.IMAGE_FILTER_NUMPY: + try: + numpy.save(filename, data) + except IOError: + self._errorMessage('Save failed\n', parent=self.plot) + return False + return True + + elif nameFilter == self.IMAGE_FILTER_NXDATA: + entryPath = self._selectWriteableOutputGroup(filename, parent=self.plot) + if entryPath is None: + return False + xorigin, yorigin = image.getOrigin() + xscale, yscale = image.getScale() + xaxis = xorigin + xscale * numpy.arange(data.shape[1]) + yaxis = yorigin + yscale * numpy.arange(data.shape[0]) + xlabel, ylabel = self._getAxesLabels(image) + interpretation = "image" if len(data.shape) == 2 else "rgba-image" + + return save_NXdata(filename, + nxentry_name=entryPath, + signal=data, + axes=[yaxis, xaxis], + signal_name="image", + axes_names=["y", "x"], + axes_long_names=[ylabel, xlabel], + title=plot.getGraphTitle(), + interpretation=interpretation) + + elif nameFilter in (self.IMAGE_FILTER_ASCII, + self.IMAGE_FILTER_CSV_COMMA, + self.IMAGE_FILTER_CSV_SEMICOLON, + self.IMAGE_FILTER_CSV_TAB): + csvdelim, filetype = { + self.IMAGE_FILTER_ASCII: (' ', 'txt'), + self.IMAGE_FILTER_CSV_COMMA: (',', 'csv'), + self.IMAGE_FILTER_CSV_SEMICOLON: (';', 'csv'), + self.IMAGE_FILTER_CSV_TAB: ('\t', 'csv'), + }[nameFilter] + + height, width = data.shape + rows, cols = numpy.mgrid[0:height, 0:width] + try: + save1D(filename, rows.ravel(), (cols.ravel(), data.ravel()), + filetype=filetype, + xlabel='row', + ylabels=['column', 'value'], + csvdelim=csvdelim, + autoheader=True) + + except IOError: + self._errorMessage('Save failed\n', parent=self.plot) + return False + return True + + elif nameFilter == self.IMAGE_FILTER_RGB_PNG: + # Get displayed image + rgbaImage = image.getRgbaImageData(copy=False) + # Convert RGB QImage + qimage = convertArrayToQImage(rgbaImage[:, :, :3]) + + if qimage.save(filename, 'PNG'): + return True + else: + _logger.error('Failed to save image as %s', filename) + qt.QMessageBox.critical( + self.parent(), + 'Save image as', + 'Failed to save image') + + return False + + def _saveScatter(self, plot, filename, nameFilter): + """Save an image from the plot. + + :param str filename: The name of the file to write + :param str nameFilter: The selected name filter + :return: False if format is not supported or save failed, + True otherwise. + """ + if nameFilter not in self.DEFAULT_SCATTER_FILTERS: + return False + + if nameFilter == self.SCATTER_FILTER_NXDATA: + entryPath = self._selectWriteableOutputGroup(filename, parent=self.plot) + if entryPath is None: + return False + scatter = plot.getScatter() + + x = scatter.getXData(copy=False) + y = scatter.getYData(copy=False) + z = scatter.getValueData(copy=False) + + xerror = scatter.getXErrorData(copy=False) + if isinstance(xerror, float): + xerror = xerror * numpy.ones(x.shape, dtype=numpy.float32) + + yerror = scatter.getYErrorData(copy=False) + if isinstance(yerror, float): + yerror = yerror * numpy.ones(x.shape, dtype=numpy.float32) + + xlabel = plot.getGraphXLabel() + ylabel = plot.getGraphYLabel() + + return save_NXdata( + filename, + nxentry_name=entryPath, + signal=z, + axes=[x, y], + signal_name="values", + axes_names=["x", "y"], + axes_long_names=[xlabel, ylabel], + axes_errors=[xerror, yerror], + title=plot.getGraphTitle()) + + def setFileFilter(self, dataKind, nameFilter, func, index=None, appendToFile=False): + """Set a name filter to add/replace a file format support + + :param str dataKind: + The kind of data for which the provided filter is valid. + One of: 'all', 'curve', 'curves', 'image', 'scatter' + :param str nameFilter: The name filter in the QFileDialog. + See :meth:`QFileDialog.setNameFilters`. + :param callable func: The function to call to perform saving. + Expected signature is: + bool func(PlotWidget plot, str filename, str nameFilter) + :param bool appendToFile: True to append the data into the selected + file. + :param integer index: Index of the filter in the final list (or None) + """ + assert dataKind in ('all', 'curve', 'curves', 'image', 'scatter') + + if appendToFile: + self._appendFilters.append(nameFilter) + + # first append or replace the new filter to prevent colissions + self._filters[dataKind][nameFilter] = func + if index is None: + # we are already done + return + + # get the current ordered list of keys + keyList = list(self._filters[dataKind].keys()) + + # deal with negative indices + if index < 0: + index = len(keyList) + index + if index < 0: + index = 0 + + if index >= len(keyList): + # nothing to be done, already at the end + txt = 'Requested index %d impossible, already at the end' % index + _logger.info(txt) + return + + # get the new ordered list + oldIndex = keyList.index(nameFilter) + del keyList[oldIndex] + keyList.insert(index, nameFilter) + + # build the new filters + newFilters = OrderedDict() + for key in keyList: + newFilters[key] = self._filters[dataKind][key] + + # and update the filters + self._filters[dataKind] = newFilters + return + + def getFileFilters(self, dataKind): + """Returns the nameFilter and associated function for a kind of data. + + :param str dataKind: + The kind of data for which the provided filter is valid. + On of: 'all', 'curve', 'curves', 'image', 'scatter' + :return: {nameFilter: function} associations. + :rtype: collections.OrderedDict + """ + assert dataKind in ('all', 'curve', 'curves', 'image', 'scatter') + + return self._filters[dataKind].copy() + + def _actionTriggered(self, checked=False): + """Handle save action.""" + # Set-up filters + filters = OrderedDict() + + # Add image filters if there is an active image + if self.plot.getActiveImage() is not None: + filters.update(self._filters['image'].items()) + + # Add curve filters if there is a curve to save + if (self.plot.getActiveCurve() is not None or + len(self.plot.getAllCurves()) == 1): + filters.update(self._filters['curve'].items()) + if len(self.plot.getAllCurves()) >= 1: + filters.update(self._filters['curves'].items()) + + # Add scatter filters if there is a scatter + # todo: CSV + if self.plot.getScatter() is not None: + filters.update(self._filters['scatter'].items()) + + filters.update(self._filters['all'].items()) + + # Create and run File dialog + dialog = qt.QFileDialog(self.plot) + dialog.setOption(dialog.DontUseNativeDialog) + dialog.setWindowTitle("Output File Selection") + dialog.setModal(1) + dialog.setNameFilters(list(filters.keys())) + + dialog.setFileMode(dialog.AnyFile) + dialog.setAcceptMode(dialog.AcceptSave) + + def onFilterSelection(filt_): + # disable overwrite confirmation for NXdata types, + # because we append the data to existing files + if filt_ in self._appendFilters: + dialog.setOption(dialog.DontConfirmOverwrite) + else: + dialog.setOption(dialog.DontConfirmOverwrite, False) + + dialog.filterSelected.connect(onFilterSelection) + + if not dialog.exec(): + return False + + nameFilter = dialog.selectedNameFilter() + filename = dialog.selectedFiles()[0] + dialog.close() + + if '(' in nameFilter and ')' == nameFilter.strip()[-1]: + # Check for correct file extension + # Extract file extensions as .something + extensions = [ext[ext.find('.'):] for ext in + nameFilter[nameFilter.find('(') + 1:-1].split()] + for ext in extensions: + if (len(filename) > len(ext) and + filename[-len(ext):].lower() == ext.lower()): + break + else: # filename has no extension supported in nameFilter, add one + if len(extensions) >= 1: + filename += extensions[0] + + # Handle save + func = filters.get(nameFilter, None) + if func is not None: + return func(self.plot, filename, nameFilter) + else: + _logger.error('Unsupported file filter: %s', nameFilter) + return False + + +def _plotAsPNG(plot): + """Save a :class:`Plot` as PNG and return the payload. + + :param plot: The :class:`Plot` to save + """ + pngFile = BytesIO() + plot.saveGraph(pngFile, fileFormat='png') + pngFile.flush() + pngFile.seek(0) + data = pngFile.read() + pngFile.close() + return data + + +class PrintAction(PlotAction): + """QAction for printing the plot. + + It opens a Print dialog. + + Current implementation print a bitmap of the plot area and not vector + graphics, so printing quality is not great. + + :param plot: :class:`.PlotWidget` instance on which to operate. + :param parent: See :class:`QAction`. + """ + + def __init__(self, plot, parent=None): + super(PrintAction, self).__init__( + plot, icon='document-print', text='Print...', + tooltip='Open print dialog', + triggered=self.printPlot, + checkable=False, parent=parent) + self.setShortcut(qt.QKeySequence.Print) + self.setShortcutContext(qt.Qt.WidgetShortcut) + + def getPrinter(self): + """The QPrinter instance used by the PrintAction. + + :rtype: QPrinter + """ + return printer.getDefaultPrinter() + + @property + @deprecated(replacement="getPrinter()", since_version="0.8.0") + def printer(self): + return self.getPrinter() + + def printPlotAsWidget(self): + """Open the print dialog and print the plot. + + Use :meth:`QWidget.render` to print the plot + + :return: True if successful + """ + dialog = qt.QPrintDialog(self.getPrinter(), self.plot) + dialog.setWindowTitle('Print Plot') + if not dialog.exec(): + return False + + # Print a snapshot of the plot widget at the top of the page + widget = self.plot.centralWidget() + + painter = qt.QPainter() + if not painter.begin(self.getPrinter()): + return False + + pageRect = self.getPrinter().pageRect(qt.QPrinter.DevicePixel) + xScale = pageRect.width() / widget.width() + yScale = pageRect.height() / widget.height() + scale = min(xScale, yScale) + + painter.translate(pageRect.width() / 2., 0.) + painter.scale(scale, scale) + painter.translate(-widget.width() / 2., 0.) + widget.render(painter) + painter.end() + + return True + + def printPlot(self): + """Open the print dialog and print the plot. + + Use :meth:`Plot.saveGraph` to print the plot. + + :return: True if successful + """ + # Init printer and start printer dialog + dialog = qt.QPrintDialog(self.getPrinter(), self.plot) + dialog.setWindowTitle('Print Plot') + if not dialog.exec(): + return False + + # Save Plot as PNG and make a pixmap from it with default dpi + pngData = _plotAsPNG(self.plot) + + pixmap = qt.QPixmap() + pixmap.loadFromData(pngData, 'png') + + pageRect = self.getPrinter().pageRect(qt.QPrinter.DevicePixel) + xScale = pageRect.width() / pixmap.width() + yScale = pageRect.height() / pixmap.height() + scale = min(xScale, yScale) + + # Draw pixmap with painter + painter = qt.QPainter() + if not painter.begin(self.getPrinter()): + return False + + painter.drawPixmap(0, 0, + pixmap.width() * scale, + pixmap.height() * scale, + pixmap) + painter.end() + + return True + + +class CopyAction(PlotAction): + """QAction to copy :class:`.PlotWidget` content to clipboard. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + super(CopyAction, self).__init__( + plot, icon='edit-copy', text='Copy plot', + tooltip='Copy a snapshot of the plot into the clipboard', + triggered=self.copyPlot, + checkable=False, parent=parent) + self.setShortcut(qt.QKeySequence.Copy) + self.setShortcutContext(qt.Qt.WidgetShortcut) + + def copyPlot(self): + """Copy plot content to the clipboard as a bitmap.""" + # Save Plot as PNG and make a QImage from it with default dpi + pngData = _plotAsPNG(self.plot) + image = qt.QImage.fromData(pngData, 'png') + qt.QApplication.clipboard().setImage(image) diff --git a/src/silx/gui/plot/actions/medfilt.py b/src/silx/gui/plot/actions/medfilt.py new file mode 100644 index 0000000..f86a377 --- /dev/null +++ b/src/silx/gui/plot/actions/medfilt.py @@ -0,0 +1,147 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +""" +:mod:`silx.gui.plot.actions.medfilt` provides a set of QAction to apply filter +on data contained in a :class:`.PlotWidget`. + +The following QAction are available: + +- :class:`MedianFilterAction` +- :class:`MedianFilter1DAction` +- :class:`MedianFilter2DAction` + +""" + +from __future__ import division + +__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] +__license__ = "MIT" + +__date__ = "10/10/2018" + +from .PlotToolAction import PlotToolAction +from silx.gui.widgets.MedianFilterDialog import MedianFilterDialog +from silx.math.medianfilter import medfilt2d +import logging + +_logger = logging.getLogger(__name__) + + +class MedianFilterAction(PlotToolAction): + """QAction to plot the pixels intensities diagram + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + PlotToolAction.__init__(self, + plot, + icon='median-filter', + text='median filter', + tooltip='Apply a median filter on the image', + parent=parent) + self._originalImage = None + self._legend = None + self._filteredImage = None + + def _createToolWindow(self): + popup = MedianFilterDialog(parent=self.plot) + popup.sigFilterOptChanged.connect(self._updateFilter) + return popup + + def _connectPlot(self, window): + PlotToolAction._connectPlot(self, window) + self.plot.sigActiveImageChanged.connect(self._updateActiveImage) + self._updateActiveImage() + + def _disconnectPlot(self, window): + PlotToolAction._disconnectPlot(self, window) + self.plot.sigActiveImageChanged.disconnect(self._updateActiveImage) + + def _updateActiveImage(self): + """Set _activeImageLegend and _originalImage from the active image""" + self._activeImageLegend = self.plot.getActiveImage(just_legend=True) + if self._activeImageLegend is None: + self._originalImage = None + self._legend = None + else: + self._originalImage = self.plot.getImage(self._activeImageLegend).getData(copy=False) + self._legend = self.plot.getImage(self._activeImageLegend).getName() + + def _updateFilter(self, kernelWidth, conditional=False): + if self._originalImage is None: + return + + self.plot.sigActiveImageChanged.disconnect(self._updateActiveImage) + filteredImage = self._computeFilteredImage(kernelWidth, conditional) + self.plot.addImage(data=filteredImage, + legend=self._legend, + replace=True) + self.plot.sigActiveImageChanged.connect(self._updateActiveImage) + + def _computeFilteredImage(self, kernelWidth, conditional): + raise NotImplementedError('MedianFilterAction is a an abstract class') + + def getFilteredImage(self): + """ + :return: the image with the median filter apply on""" + return self._filteredImage + + +class MedianFilter1DAction(MedianFilterAction): + """Define the MedianFilterAction for 1D + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + def __init__(self, plot, parent=None): + MedianFilterAction.__init__(self, + plot, + parent=parent) + + def _computeFilteredImage(self, kernelWidth, conditional): + assert(self.plot is not None) + return medfilt2d(self._originalImage, + (kernelWidth, 1), + conditional) + + +class MedianFilter2DAction(MedianFilterAction): + """Define the MedianFilterAction for 2D + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + def __init__(self, plot, parent=None): + MedianFilterAction.__init__(self, + plot, + parent=parent) + + def _computeFilteredImage(self, kernelWidth, conditional): + assert(self.plot is not None) + return medfilt2d(self._originalImage, + (kernelWidth, kernelWidth), + conditional) diff --git a/src/silx/gui/plot/actions/mode.py b/src/silx/gui/plot/actions/mode.py new file mode 100644 index 0000000..ee05256 --- /dev/null +++ b/src/silx/gui/plot/actions/mode.py @@ -0,0 +1,104 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-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. +# +# ###########################################################################*/ +""" +:mod:`silx.gui.plot.actions.mode` provides a set of QAction relative to mouse +mode of a :class:`.PlotWidget`. + +The following QAction are available: + +- :class:`ZoomModeAction` +- :class:`PanModeAction` +""" + +from __future__ import division + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "16/08/2017" + +from . import PlotAction +import logging + +_logger = logging.getLogger(__name__) + + +class ZoomModeAction(PlotAction): + """QAction controlling the zoom mode of a :class:`.PlotWidget`. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + super(ZoomModeAction, self).__init__( + plot, icon='zoom', text='Zoom mode', + tooltip='Zoom in or out', + triggered=self._actionTriggered, + checkable=True, parent=parent) + # Listen to mode change + self.plot.sigInteractiveModeChanged.connect(self._modeChanged) + # Init the state + self._modeChanged(None) + + def _modeChanged(self, source): + modeDict = self.plot.getInteractiveMode() + old = self.blockSignals(True) + self.setChecked(modeDict["mode"] == "zoom") + self.blockSignals(old) + + def _actionTriggered(self, checked=False): + plot = self.plot + if plot is not None: + plot.setInteractiveMode('zoom', source=self) + + +class PanModeAction(PlotAction): + """QAction controlling the pan mode of a :class:`.PlotWidget`. + + :param plot: :class:`.PlotWidget` instance on which to operate + :param parent: See :class:`QAction` + """ + + def __init__(self, plot, parent=None): + super(PanModeAction, self).__init__( + plot, icon='pan', text='Pan mode', + tooltip='Pan the view', + triggered=self._actionTriggered, + checkable=True, parent=parent) + # Listen to mode change + self.plot.sigInteractiveModeChanged.connect(self._modeChanged) + # Init the state + self._modeChanged(None) + + def _modeChanged(self, source): + modeDict = self.plot.getInteractiveMode() + old = self.blockSignals(True) + self.setChecked(modeDict["mode"] == "pan") + self.blockSignals(old) + + def _actionTriggered(self, checked=False): + plot = self.plot + if plot is not None: + plot.setInteractiveMode('pan', source=self) |