summaryrefslogtreecommitdiff
path: root/silx/gui/plot
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/plot')
-rw-r--r--silx/gui/plot/ColorBar.py12
-rw-r--r--silx/gui/plot/CompareImages.py1190
-rw-r--r--silx/gui/plot/ImageView.py9
-rw-r--r--silx/gui/plot/LegendSelector.py134
-rw-r--r--silx/gui/plot/MaskToolsWidget.py129
-rw-r--r--silx/gui/plot/PlotToolButtons.py56
-rw-r--r--silx/gui/plot/PlotWidget.py183
-rw-r--r--silx/gui/plot/PlotWindow.py5
-rw-r--r--silx/gui/plot/PrintPreviewToolButton.py5
-rw-r--r--silx/gui/plot/Profile.py89
-rw-r--r--silx/gui/plot/ProfileMainWindow.py16
-rw-r--r--silx/gui/plot/ScatterMaskToolsWidget.py61
-rw-r--r--silx/gui/plot/ScatterView.py10
-rw-r--r--silx/gui/plot/StackView.py100
-rw-r--r--silx/gui/plot/StatsWidget.py18
-rw-r--r--silx/gui/plot/_BaseMaskToolsWidget.py6
-rw-r--r--silx/gui/plot/_utils/test/testColormap.py648
-rw-r--r--silx/gui/plot/actions/PlotToolAction.py150
-rw-r--r--silx/gui/plot/actions/control.py1
-rw-r--r--silx/gui/plot/actions/fit.py63
-rw-r--r--silx/gui/plot/actions/histogram.py88
-rw-r--r--silx/gui/plot/actions/io.py12
-rw-r--r--silx/gui/plot/actions/medfilt.py38
-rw-r--r--silx/gui/plot/backends/BackendBase.py17
-rw-r--r--silx/gui/plot/backends/BackendMatplotlib.py28
-rw-r--r--silx/gui/plot/backends/BackendOpenGL.py17
-rw-r--r--silx/gui/plot/backends/glutils/GLText.py46
-rw-r--r--silx/gui/plot/items/core.py23
-rw-r--r--silx/gui/plot/items/curve.py213
-rw-r--r--silx/gui/plot/items/histogram.py7
-rw-r--r--silx/gui/plot/items/marker.py42
-rw-r--r--silx/gui/plot/items/scatter.py30
-rw-r--r--silx/gui/plot/matplotlib/ModestImage.py174
-rw-r--r--silx/gui/plot/test/__init__.py6
-rw-r--r--silx/gui/plot/test/testAlphaSlider.py2
-rw-r--r--silx/gui/plot/test/testColorBar.py2
-rw-r--r--silx/gui/plot/test/testCompareImages.py117
-rw-r--r--silx/gui/plot/test/testCurvesROIWidget.py2
-rw-r--r--silx/gui/plot/test/testImageView.py2
-rw-r--r--silx/gui/plot/test/testItem.py2
-rw-r--r--silx/gui/plot/test/testLegendSelector.py2
-rw-r--r--silx/gui/plot/test/testMaskToolsWidget.py8
-rw-r--r--silx/gui/plot/test/testPixelIntensityHistoAction.py2
-rw-r--r--silx/gui/plot/test/testPlotWidget.py203
-rw-r--r--silx/gui/plot/test/testPlotWindow.py2
-rw-r--r--silx/gui/plot/test/testProfile.py198
-rw-r--r--silx/gui/plot/test/testScatterMaskToolsWidget.py8
-rw-r--r--silx/gui/plot/test/testScatterView.py19
-rw-r--r--silx/gui/plot/test/testStackView.py7
-rw-r--r--silx/gui/plot/test/testStats.py3
-rw-r--r--silx/gui/plot/test/testUtilsAxis.py2
-rw-r--r--silx/gui/plot/test/utils.py2
-rw-r--r--silx/gui/plot/tools/CurveLegendsWidget.py247
-rw-r--r--silx/gui/plot/tools/profile/ImageProfileToolBar.py271
-rw-r--r--silx/gui/plot/tools/test/__init__.py2
-rw-r--r--silx/gui/plot/tools/test/testCurveLegendsWidget.py125
-rw-r--r--silx/gui/plot/tools/test/testROI.py2
-rw-r--r--silx/gui/plot/tools/test/testScatterProfileToolBar.py2
-rw-r--r--silx/gui/plot/tools/test/testTools.py2
-rw-r--r--silx/gui/plot/utils/axis.py9
60 files changed, 3315 insertions, 1554 deletions
diff --git a/silx/gui/plot/ColorBar.py b/silx/gui/plot/ColorBar.py
index 0941e82..fd4d34e 100644
--- a/silx/gui/plot/ColorBar.py
+++ b/silx/gui/plot/ColorBar.py
@@ -155,19 +155,17 @@ class ColorBarWidget(qt.QWidget):
self._disconnectPlot()
def getColormap(self):
- """
-
- :return: the :class:`.Colormap` colormap displayed in the colorbar.
+ """Returns the colormap displayed in the colorbar.
+ :rtype: ~silx.gui.colors.Colormap
"""
return self.getColorScaleBar().getColormap()
def setColormap(self, colormap, data=None):
"""Set the colormap to be displayed.
- :param colormap: The colormap to apply on the
- ColorBarWidget
- :type colormap: :class:`.Colormap`
+ :param ~silx.gui.colors.Colormap colormap:
+ The colormap to apply on the ColorBarWidget
:param numpy.ndarray data: the data to display, needed if the colormap
require an autoscale
"""
@@ -207,7 +205,7 @@ class ColorBarWidget(qt.QWidget):
:return: return the legend displayed along the colorbar
:rtype: str
"""
- return self.legend.getText()
+ return self.legend.text()
def _activeScatterChanged(self, previous, legend):
"""Handle plot active scatter changed"""
diff --git a/silx/gui/plot/CompareImages.py b/silx/gui/plot/CompareImages.py
new file mode 100644
index 0000000..88b257d
--- /dev/null
+++ b/silx/gui/plot/CompareImages.py
@@ -0,0 +1,1190 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""A widget dedicated to compare 2 images.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "23/07/2018"
+
+
+import logging
+import numpy
+import weakref
+import collections
+import math
+
+import silx.image.bilinear
+from silx.gui import qt
+from silx.gui import plot
+from silx.gui import icons
+from silx.gui.colors import Colormap
+from silx.gui.plot import tools
+from silx.third_party import enum
+
+_logger = logging.getLogger(__name__)
+
+from silx.opencl import ocl
+if ocl is not None:
+ from silx.opencl import sift
+else: # No OpenCL device or no pyopencl
+ sift = None
+
+
+@enum.unique
+class VisualizationMode(enum.Enum):
+ """Enum for each visualization mode available."""
+ ONLY_A = 'a'
+ ONLY_B = 'b'
+ VERTICAL_LINE = 'vline'
+ HORIZONTAL_LINE = 'hline'
+ COMPOSITE_RED_BLUE_GRAY = "rbgchannel"
+ COMPOSITE_RED_BLUE_GRAY_NEG = "rbgnegchannel"
+
+
+@enum.unique
+class AlignmentMode(enum.Enum):
+ """Enum for each alignment mode available."""
+ ORIGIN = 'origin'
+ CENTER = 'center'
+ STRETCH = 'stretch'
+ AUTO = 'auto'
+
+
+AffineTransformation = collections.namedtuple("AffineTransformation",
+ ["tx", "ty", "sx", "sy", "rot"])
+"""Contains a 2D affine transformation: translation, scale and rotation"""
+
+
+class CompareImagesToolBar(qt.QToolBar):
+ """ToolBar containing specific tools to custom the configuration of a
+ :class:`CompareImages` widget
+
+ Use :meth:`setCompareWidget` to connect this toolbar to a specific
+ :class:`CompareImages` widget.
+
+ :param Union[qt.QWidget,None] parent: Parent of this widget.
+ """
+ def __init__(self, parent=None):
+ qt.QToolBar.__init__(self, parent)
+
+ self.__compareWidget = None
+
+ menu = qt.QMenu(self)
+ self.__visualizationAction = qt.QAction(self)
+ self.__visualizationAction.setMenu(menu)
+ self.__visualizationAction.setCheckable(False)
+ self.addAction(self.__visualizationAction)
+ self.__visualizationGroup = qt.QActionGroup(self)
+ self.__visualizationGroup.setExclusive(True)
+ self.__visualizationGroup.triggered.connect(self.__visualizationModeChanged)
+
+ icon = icons.getQIcon("compare-mode-a")
+ action = qt.QAction(icon, "Display the first image only", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_A))
+ action.setProperty("mode", VisualizationMode.ONLY_A)
+ menu.addAction(action)
+ self.__aModeAction = action
+ self.__visualizationGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-mode-b")
+ action = qt.QAction(icon, "Display the second image only", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_B))
+ action.setProperty("mode", VisualizationMode.ONLY_B)
+ menu.addAction(action)
+ self.__bModeAction = action
+ self.__visualizationGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-mode-vline")
+ action = qt.QAction(icon, "Vertical compare mode", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_V))
+ action.setProperty("mode", VisualizationMode.VERTICAL_LINE)
+ menu.addAction(action)
+ self.__vlineModeAction = action
+ self.__visualizationGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-mode-hline")
+ action = qt.QAction(icon, "Horizontal compare mode", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_H))
+ action.setProperty("mode", VisualizationMode.HORIZONTAL_LINE)
+ menu.addAction(action)
+ self.__hlineModeAction = action
+ self.__visualizationGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-mode-rb-channel")
+ action = qt.QAction(icon, "Blue/red compare mode (additive mode)", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_C))
+ action.setProperty("mode", VisualizationMode.COMPOSITE_RED_BLUE_GRAY)
+ menu.addAction(action)
+ self.__brChannelModeAction = action
+ self.__visualizationGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-mode-rbneg-channel")
+ action = qt.QAction(icon, "Yellow/cyan compare mode (subtractive mode)", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_W))
+ action.setProperty("mode", VisualizationMode.COMPOSITE_RED_BLUE_GRAY_NEG)
+ menu.addAction(action)
+ self.__ycChannelModeAction = action
+ self.__visualizationGroup.addAction(action)
+
+ menu = qt.QMenu(self)
+ self.__alignmentAction = qt.QAction(self)
+ self.__alignmentAction.setMenu(menu)
+ self.__alignmentAction.setIconVisibleInMenu(True)
+ self.addAction(self.__alignmentAction)
+ self.__alignmentGroup = qt.QActionGroup(self)
+ self.__alignmentGroup.setExclusive(True)
+ self.__alignmentGroup.triggered.connect(self.__alignmentModeChanged)
+
+ icon = icons.getQIcon("compare-align-origin")
+ action = qt.QAction(icon, "Align images on their upper-left pixel", self)
+ action.setProperty("mode", AlignmentMode.ORIGIN)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ self.__originAlignAction = action
+ menu.addAction(action)
+ self.__alignmentGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-align-center")
+ action = qt.QAction(icon, "Center images", self)
+ action.setProperty("mode", AlignmentMode.CENTER)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ self.__centerAlignAction = action
+ menu.addAction(action)
+ self.__alignmentGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-align-stretch")
+ action = qt.QAction(icon, "Stretch the second image on the first one", self)
+ action.setProperty("mode", AlignmentMode.STRETCH)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ self.__stretchAlignAction = action
+ menu.addAction(action)
+ self.__alignmentGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-align-auto")
+ action = qt.QAction(icon, "Auto-alignment of the second image", self)
+ action.setProperty("mode", AlignmentMode.AUTO)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ self.__autoAlignAction = action
+ menu.addAction(action)
+ if sift is None:
+ action.setEnabled(False)
+ action.setToolTip("Sift module is not available")
+ self.__alignmentGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-keypoints")
+ action = qt.QAction(icon, "Display/hide alignment keypoints", self)
+ action.setCheckable(True)
+ action.triggered.connect(self.__keypointVisibilityChanged)
+ self.addAction(action)
+ self.__displayKeypoints = action
+
+ def setCompareWidget(self, widget):
+ """
+ Connect this tool bar to a specific :class:`CompareImages` widget.
+
+ :param Union[None,CompareImages] widget: The widget to connect with.
+ """
+ compareWidget = self.getCompareWidget()
+ if compareWidget is not None:
+ compareWidget.sigConfigurationChanged.disconnect(self.__updateSelectedActions)
+ compareWidget = widget
+ if compareWidget is None:
+ self.__compareWidget = None
+ else:
+ self.__compareWidget = weakref.ref(compareWidget)
+ if compareWidget is not None:
+ widget.sigConfigurationChanged.connect(self.__updateSelectedActions)
+ self.__updateSelectedActions()
+
+ def getCompareWidget(self):
+ """Returns the connected widget.
+
+ :rtype: CompareImages
+ """
+ if self.__compareWidget is None:
+ return None
+ else:
+ return self.__compareWidget()
+
+ def __updateSelectedActions(self):
+ """
+ Update the state of this tool bar according to the state of the
+ connected :class:`CompareImages` widget.
+ """
+ widget = self.getCompareWidget()
+ if widget is None:
+ return
+
+ mode = widget.getVisualizationMode()
+ action = None
+ for a in self.__visualizationGroup.actions():
+ actionMode = a.property("mode")
+ if mode == actionMode:
+ action = a
+ break
+ old = self.__visualizationGroup.blockSignals(True)
+ if action is not None:
+ # Check this action
+ action.setChecked(True)
+ else:
+ action = self.__visualizationGroup.checkedAction()
+ if action is not None:
+ # Uncheck this action
+ action.setChecked(False)
+ self.__updateVisualizationMenu()
+ self.__visualizationGroup.blockSignals(old)
+
+ mode = widget.getAlignmentMode()
+ action = None
+ for a in self.__alignmentGroup.actions():
+ actionMode = a.property("mode")
+ if mode == actionMode:
+ action = a
+ break
+ old = self.__alignmentGroup.blockSignals(True)
+ if action is not None:
+ # Check this action
+ action.setChecked(True)
+ else:
+ action = self.__alignmentGroup.checkedAction()
+ if action is not None:
+ # Uncheck this action
+ action.setChecked(False)
+ self.__updateAlignmentMenu()
+ self.__alignmentGroup.blockSignals(old)
+
+ def __visualizationModeChanged(self, selectedAction):
+ """Called when user requesting changes of the visualization mode.
+ """
+ self.__updateVisualizationMenu()
+ widget = self.getCompareWidget()
+ if widget is not None:
+ mode = selectedAction.property("mode")
+ widget.setVisualizationMode(mode)
+
+ def __updateVisualizationMenu(self):
+ """Update the state of the action containing visualization menu.
+ """
+ selectedAction = self.__visualizationGroup.checkedAction()
+ if selectedAction is not None:
+ self.__visualizationAction.setText(selectedAction.text())
+ self.__visualizationAction.setIcon(selectedAction.icon())
+ self.__visualizationAction.setToolTip(selectedAction.toolTip())
+ else:
+ self.__visualizationAction.setText("")
+ self.__visualizationAction.setIcon(qt.QIcon())
+ self.__visualizationAction.setToolTip("")
+
+ def __alignmentModeChanged(self, selectedAction):
+ """Called when user requesting changes of the alignment mode.
+ """
+ self.__updateAlignmentMenu()
+ widget = self.getCompareWidget()
+ if widget is not None:
+ mode = selectedAction.property("mode")
+ widget.setAlignmentMode(mode)
+
+ def __updateAlignmentMenu(self):
+ """Update the state of the action containing alignment menu.
+ """
+ selectedAction = self.__alignmentGroup.checkedAction()
+ if selectedAction is not None:
+ self.__alignmentAction.setText(selectedAction.text())
+ self.__alignmentAction.setIcon(selectedAction.icon())
+ self.__alignmentAction.setToolTip(selectedAction.toolTip())
+ else:
+ self.__alignmentAction.setText("")
+ self.__alignmentAction.setIcon(qt.QIcon())
+ self.__alignmentAction.setToolTip("")
+
+ def __keypointVisibilityChanged(self):
+ """Called when action managing keypoints visibility changes"""
+ widget = self.getCompareWidget()
+ if widget is not None:
+ keypointsVisible = self.__displayKeypoints.isChecked()
+ widget.setKeypointsVisible(keypointsVisible)
+
+
+class CompareImagesStatusBar(qt.QStatusBar):
+ """StatusBar containing specific information contained in a
+ :class:`CompareImages` widget
+
+ Use :meth:`setCompareWidget` to connect this toolbar to a specific
+ :class:`CompareImages` widget.
+
+ :param Union[qt.QWidget,None] parent: Parent of this widget.
+ """
+ def __init__(self, parent=None):
+ qt.QStatusBar.__init__(self, parent)
+ self.setSizeGripEnabled(False)
+ self.layout().setSpacing(0)
+ self.__compareWidget = None
+ self._label1 = qt.QLabel(self)
+ self._label1.setFrameShape(qt.QFrame.WinPanel)
+ self._label1.setFrameShadow(qt.QFrame.Sunken)
+ self._label2 = qt.QLabel(self)
+ self._label2.setFrameShape(qt.QFrame.WinPanel)
+ self._label2.setFrameShadow(qt.QFrame.Sunken)
+ self._transform = qt.QLabel(self)
+ self._transform.setFrameShape(qt.QFrame.WinPanel)
+ self._transform.setFrameShadow(qt.QFrame.Sunken)
+ self.addWidget(self._label1)
+ self.addWidget(self._label2)
+ self.addWidget(self._transform)
+ self._pos = None
+ self._updateStatusBar()
+
+ def setCompareWidget(self, widget):
+ """
+ Connect this tool bar to a specific :class:`CompareImages` widget.
+
+ :param Union[None,CompareImages] widget: The widget to connect with.
+ """
+ compareWidget = self.getCompareWidget()
+ if compareWidget is not None:
+ compareWidget.getPlot().sigPlotSignal.disconnect(self.__plotSignalReceived)
+ compareWidget.sigConfigurationChanged.disconnect(self.__dataChanged)
+ compareWidget = widget
+ if compareWidget is None:
+ self.__compareWidget = None
+ else:
+ self.__compareWidget = weakref.ref(compareWidget)
+ if compareWidget is not None:
+ compareWidget.getPlot().sigPlotSignal.connect(self.__plotSignalReceived)
+ compareWidget.sigConfigurationChanged.connect(self.__dataChanged)
+
+ def getCompareWidget(self):
+ """Returns the connected widget.
+
+ :rtype: CompareImages
+ """
+ if self.__compareWidget is None:
+ return None
+ else:
+ return self.__compareWidget()
+
+ def __plotSignalReceived(self, event):
+ """Called when old style signals at emmited from the plot."""
+ if event["event"] == "mouseMoved":
+ x, y = event["x"], event["y"]
+ self.__mouseMoved(x, y)
+
+ def __mouseMoved(self, x, y):
+ """Called when mouse move over the plot."""
+ self._pos = x, y
+ self._updateStatusBar()
+
+ def __dataChanged(self):
+ """Called when internal data from the connected widget changes."""
+ self._updateStatusBar()
+
+ def _formatData(self, data):
+ """Format pixel of an image.
+
+ It supports intensity, RGB, and RGBA.
+
+ :param Union[int,float,numpy.ndarray,str]: Value of a pixel
+ :rtype: str
+ """
+ if data is None:
+ return "No data"
+ if isinstance(data, (int, numpy.integer)):
+ return "%d" % data
+ if isinstance(data, (float, numpy.floating)):
+ return "%f" % data
+ if isinstance(data, numpy.ndarray):
+ # RGBA value
+ if data.shape == (3,):
+ return "R:%d G:%d B:%d" % (data[0], data[1], data[2])
+ elif data.shape == (4,):
+ return "R:%d G:%d B:%d A:%d" % (data[0], data[1], data[2], data[3])
+ _logger.debug("Unsupported data format %s. Cast it to string.", type(data))
+ return str(data)
+
+ def _updateStatusBar(self):
+ """Update the content of the status bar"""
+ widget = self.getCompareWidget()
+ if widget is None:
+ self._label1.setText("Image1: NA")
+ self._label2.setText("Image2: NA")
+ self._transform.setVisible(False)
+ else:
+ transform = widget.getTransformation()
+ self._transform.setVisible(transform is not None)
+ if transform is not None:
+ has_notable_translation = not numpy.isclose(transform.tx, 0.0, atol=0.01) \
+ or not numpy.isclose(transform.ty, 0.0, atol=0.01)
+ has_notable_scale = not numpy.isclose(transform.sx, 1.0, atol=0.01) \
+ or not numpy.isclose(transform.sy, 1.0, atol=0.01)
+ has_notable_rotation = not numpy.isclose(transform.rot, 0.0, atol=0.01)
+
+ strings = []
+ if has_notable_translation:
+ strings.append("Translation")
+ if has_notable_scale:
+ strings.append("Scale")
+ if has_notable_rotation:
+ strings.append("Rotation")
+ if strings == []:
+ has_translation = not numpy.isclose(transform.tx, 0.0) \
+ or not numpy.isclose(transform.ty, 0.0)
+ has_scale = not numpy.isclose(transform.sx, 1.0) \
+ or not numpy.isclose(transform.sy, 1.0)
+ has_rotation = not numpy.isclose(transform.rot, 0.0)
+ if has_translation or has_scale or has_rotation:
+ text = "No big changes"
+ else:
+ text = "No changes"
+ else:
+ text = "+".join(strings)
+ self._transform.setText("Align: " + text)
+
+ strings = []
+ if not numpy.isclose(transform.ty, 0.0):
+ strings.append("Translation x: %0.3fpx" % transform.tx)
+ if not numpy.isclose(transform.ty, 0.0):
+ strings.append("Translation y: %0.3fpx" % transform.ty)
+ if not numpy.isclose(transform.sx, 1.0):
+ strings.append("Scale x: %0.3f" % transform.sx)
+ if not numpy.isclose(transform.sy, 1.0):
+ strings.append("Scale y: %0.3f" % transform.sy)
+ if not numpy.isclose(transform.rot, 0.0):
+ strings.append("Rotation: %0.3fdeg" % (transform.rot * 180 / numpy.pi))
+ if strings == []:
+ text = "No transformation"
+ else:
+ text = "\n".join(strings)
+ self._transform.setToolTip(text)
+
+ if self._pos is None:
+ self._label1.setText("Image1: NA")
+ self._label2.setText("Image2: NA")
+ else:
+ data1, data2 = widget.getRawPixelData(self._pos[0], self._pos[1])
+ if isinstance(data1, str):
+ self._label1.setToolTip(data1)
+ text1 = "NA"
+ else:
+ self._label1.setToolTip("")
+ text1 = self._formatData(data1)
+ if isinstance(data2, str):
+ self._label2.setToolTip(data2)
+ text2 = "NA"
+ else:
+ self._label2.setToolTip("")
+ text2 = self._formatData(data2)
+ self._label1.setText("Image1: %s" % text1)
+ self._label2.setText("Image2: %s" % text2)
+
+
+class CompareImages(qt.QMainWindow):
+ """Widget providing tools to compare 2 images.
+
+ .. image:: img/CompareImages.png
+
+ :param Union[qt.QWidget,None] parent: Parent of this widget.
+ :param backend: The backend to use, in:
+ 'matplotlib' (default), 'mpl', 'opengl', 'gl', 'none'
+ or a :class:`BackendBase.BackendBase` class
+ :type backend: str or :class:`BackendBase.BackendBase`
+ """
+
+ VisualizationMode = VisualizationMode
+ """Available visualization modes"""
+
+ AlignmentMode = AlignmentMode
+ """Available alignment modes"""
+
+ sigConfigurationChanged = qt.Signal()
+ """Emitted when the configuration of the widget (visualization mode,
+ alignement mode...) have changed."""
+
+ def __init__(self, parent=None, backend=None):
+ qt.QMainWindow.__init__(self, parent)
+
+ if parent is None:
+ self.setWindowTitle('Compare images')
+ else:
+ self.setWindowFlags(qt.Qt.Widget)
+
+ self.__transformation = None
+ self.__raw1 = None
+ self.__raw2 = None
+ self.__data1 = None
+ self.__data2 = None
+ self.__previousSeparatorPosition = None
+
+ self.__plot = plot.PlotWidget(parent=self, backend=backend)
+ self.__plot.getXAxis().setLabel('Columns')
+ self.__plot.getYAxis().setLabel('Rows')
+ if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward':
+ self.__plot.getYAxis().setInverted(True)
+
+ self.__plot.setKeepDataAspectRatio(True)
+ self.__plot.sigPlotSignal.connect(self.__plotSlot)
+ self.__plot.setAxesDisplayed(False)
+
+ self.setCentralWidget(self.__plot)
+
+ legend = VisualizationMode.VERTICAL_LINE.name
+ self.__plot.addXMarker(
+ 0,
+ legend=legend,
+ text='',
+ draggable=True,
+ color='blue',
+ constraint=self.__separatorConstraint)
+ self.__vline = self.__plot._getMarker(legend)
+
+ legend = VisualizationMode.HORIZONTAL_LINE.name
+ self.__plot.addYMarker(
+ 0,
+ legend=legend,
+ text='',
+ draggable=True,
+ color='blue',
+ constraint=self.__separatorConstraint)
+ self.__hline = self.__plot._getMarker(legend)
+
+ # default values
+ self.__visualizationMode = ""
+ self.__alignmentMode = ""
+ self.__keypointsVisible = True
+
+ self.setAlignmentMode(AlignmentMode.ORIGIN)
+ self.setVisualizationMode(VisualizationMode.VERTICAL_LINE)
+ self.setKeypointsVisible(False)
+
+ # Toolbars
+
+ self._createToolBars(self.__plot)
+ if self._interactiveModeToolBar is not None:
+ self.addToolBar(self._interactiveModeToolBar)
+ if self._imageToolBar is not None:
+ self.addToolBar(self._imageToolBar)
+ if self._compareToolBar is not None:
+ self.addToolBar(self._compareToolBar)
+
+ # Statusbar
+
+ self._createStatusBar(self.__plot)
+ if self._statusBar is not None:
+ self.setStatusBar(self._statusBar)
+
+ def _createStatusBar(self, plot):
+ self._statusBar = CompareImagesStatusBar(self)
+ self._statusBar.setCompareWidget(self)
+
+ def _createToolBars(self, plot):
+ """Create tool bars displayed by the widget"""
+ toolBar = tools.InteractiveModeToolBar(parent=self, plot=plot)
+ self._interactiveModeToolBar = toolBar
+ toolBar = tools.ImageToolBar(parent=self, plot=plot)
+ self._imageToolBar = toolBar
+ toolBar = CompareImagesToolBar(self)
+ toolBar.setCompareWidget(self)
+ self._compareToolBar = toolBar
+
+ def getPlot(self):
+ """Returns the plot which is used to display the images.
+
+ :rtype: silx.gui.plot.PlotWidget
+ """
+ return self.__plot
+
+ def getRawPixelData(self, x, y):
+ """Return the raw pixel of each image data from axes positions.
+
+ If the coordinate is outside of the image it returns None element in
+ the tuple.
+
+ The pixel is reach from the raw data image without filter or
+ transformation. But the coordinate x and y are in the reference of the
+ current displayed mode.
+
+ :param float x: X-coordinate of the pixel in the current displayed plot
+ :param float y: Y-coordinate of the pixel in the current displayed plot
+ :return: A tuple of for each images containing pixel information. It
+ could be a scalar value or an array in case of RGB/RGBA informations.
+ It also could be a string containing information is some cases.
+ :rtype: Tuple(Union[int,float,numpy.ndarray,str],Union[int,float,numpy.ndarray,str])
+ """
+ data2 = None
+ alignmentMode = self.__alignmentMode
+ raw1, raw2 = self.__raw1, self.__raw2
+ if alignmentMode == AlignmentMode.ORIGIN:
+ x1 = x
+ y1 = y
+ x2 = x
+ y2 = y
+ elif alignmentMode == AlignmentMode.CENTER:
+ yy = max(raw1.shape[0], raw2.shape[0])
+ xx = max(raw1.shape[1], raw2.shape[1])
+ x1 = x - (xx - raw1.shape[1]) * 0.5
+ x2 = x - (xx - raw2.shape[1]) * 0.5
+ y1 = y - (yy - raw1.shape[0]) * 0.5
+ y2 = y - (yy - raw2.shape[0]) * 0.5
+ elif alignmentMode == AlignmentMode.STRETCH:
+ x1 = x
+ y1 = y
+ x2 = x * raw2.shape[1] / raw1.shape[1]
+ y2 = x * raw2.shape[1] / raw1.shape[1]
+ elif alignmentMode == AlignmentMode.AUTO:
+ x1 = x
+ y1 = y
+ # Not implemented
+ data2 = "Not implemented with sift"
+ else:
+ assert(False)
+
+ x1, y1 = int(x1), int(y1)
+ if raw1 is None or y1 < 0 or y1 >= raw1.shape[0] or x1 < 0 or x1 >= raw1.shape[1]:
+ data1 = None
+ else:
+ data1 = raw1[y1, x1]
+
+ if data2 is None:
+ x2, y2 = int(x2), int(y2)
+ if raw2 is None or y2 < 0 or y2 >= raw2.shape[0] or x2 < 0 or x2 >= raw2.shape[1]:
+ data2 = None
+ else:
+ data2 = raw2[y2, x2]
+
+ return data1, data2
+
+ def setVisualizationMode(self, mode):
+ """Set the visualization mode.
+
+ :param str mode: New visualization to display the image comparison
+ """
+ if self.__visualizationMode == mode:
+ return
+ self.__visualizationMode = mode
+ mode = self.getVisualizationMode()
+ self.__vline.setVisible(mode == VisualizationMode.VERTICAL_LINE)
+ self.__hline.setVisible(mode == VisualizationMode.HORIZONTAL_LINE)
+ self.__updateData()
+ self.sigConfigurationChanged.emit()
+
+ def getVisualizationMode(self):
+ """Returns the current interaction mode."""
+ return self.__visualizationMode
+
+ def setAlignmentMode(self, mode):
+ """Set the alignment mode.
+
+ :param str mode: New alignement to apply to images
+ """
+ if self.__alignmentMode == mode:
+ return
+ self.__alignmentMode = mode
+ self.__updateData()
+ self.sigConfigurationChanged.emit()
+
+ def getAlignmentMode(self):
+ """Returns the current selected alignemnt mode."""
+ return self.__alignmentMode
+
+ def setKeypointsVisible(self, isVisible):
+ """Set keypoints visibility.
+
+ :param bool isVisible: If True, keypoints are displayed (if some)
+ """
+ if self.__keypointsVisible == isVisible:
+ return
+ self.__keypointsVisible = isVisible
+ self.__updateKeyPoints()
+ self.sigConfigurationChanged.emit()
+
+ def __setDefaultAlignmentMode(self):
+ """Reset the alignemnt mode to the default value"""
+ self.setAlignmentMode(AlignmentMode.ORIGIN)
+
+ def __plotSlot(self, event):
+ """Handle events from the plot"""
+ if event['event'] in ('markerMoving', 'markerMoved'):
+ mode = self.getVisualizationMode()
+ legend = mode.name
+ if event['label'] == legend:
+ if mode == VisualizationMode.VERTICAL_LINE:
+ value = int(float(str(event['xdata'])))
+ elif mode == VisualizationMode.HORIZONTAL_LINE:
+ value = int(float(str(event['ydata'])))
+ else:
+ assert(False)
+ if self.__previousSeparatorPosition != value:
+ self.__separatorMoved(value)
+ self.__previousSeparatorPosition = value
+
+ def __separatorConstraint(self, x, y):
+ """Manage contains on the separators to clamp them inside the images."""
+ if self.__data1 is None:
+ return 0, 0
+ x = int(x)
+ if x < 0:
+ x = 0
+ elif x > self.__data1.shape[1]:
+ x = self.__data1.shape[1]
+ y = int(y)
+ if y < 0:
+ y = 0
+ elif y > self.__data1.shape[0]:
+ y = self.__data1.shape[0]
+ return x, y
+
+ def __updateSeparators(self):
+ """Redraw images according to the current state of the separators.
+ """
+ mode = self.getVisualizationMode()
+ if mode == VisualizationMode.VERTICAL_LINE:
+ pos = self.__vline.getXPosition()
+ self.__separatorMoved(pos)
+ self.__previousSeparatorPosition = pos
+ elif mode == VisualizationMode.HORIZONTAL_LINE:
+ pos = self.__hline.getYPosition()
+ self.__separatorMoved(pos)
+ self.__previousSeparatorPosition = pos
+ else:
+ self.__image1.setOrigin((0, 0))
+ self.__image2.setOrigin((0, 0))
+
+ def __separatorMoved(self, pos):
+ """Called when vertical or horizontal separators have moved.
+
+ Update the displayed images.
+ """
+ if self.__data1 is None:
+ return
+
+ mode = self.getVisualizationMode()
+ if mode == VisualizationMode.VERTICAL_LINE:
+ pos = int(pos)
+ if pos <= 0:
+ pos = 0
+ elif pos >= self.__data1.shape[1]:
+ pos = self.__data1.shape[1]
+ data1 = self.__data1[:, 0:pos]
+ data2 = self.__data2[:, pos:]
+ self.__image1.setData(data1, copy=False)
+ self.__image2.setData(data2, copy=False)
+ self.__image2.setOrigin((pos, 0))
+ elif mode == VisualizationMode.HORIZONTAL_LINE:
+ pos = int(pos)
+ if pos <= 0:
+ pos = 0
+ elif pos >= self.__data1.shape[0]:
+ pos = self.__data1.shape[0]
+ data1 = self.__data1[0:pos, :]
+ data2 = self.__data2[pos:, :]
+ self.__image1.setData(data1, copy=False)
+ self.__image2.setData(data2, copy=False)
+ self.__image2.setOrigin((0, pos))
+ else:
+ assert(False)
+
+ def setData(self, image1, image2):
+ """Set images to compare.
+
+ Images can contains floating-point or integer values, or RGB and RGBA
+ values, but should have comparable intensities.
+
+ RGB and RGBA images are provided as an array as `[width,height,channels]`
+ of usigned integer 8-bits or floating-points between 0.0 to 1.0.
+
+ :param numpy.ndarray image1: The first image
+ :param numpy.ndarray image2: The second image
+ """
+ self.__raw1 = image1
+ self.__raw2 = image2
+ self.__updateData()
+ self.__plot.resetZoom()
+
+ def setImage1(self, image1):
+ """Set image1 to be compared.
+
+ Images can contains floating-point or integer values, or RGB and RGBA
+ values, but should have comparable intensities.
+
+ RGB and RGBA images are provided as an array as `[width,height,channels]`
+ of usigned integer 8-bits or floating-points between 0.0 to 1.0.
+
+ :param numpy.ndarray image1: The first image
+ """
+ self.__raw1 = image1
+ self.__updateData()
+ self.__plot.resetZoom()
+
+ def setImage2(self, image2):
+ """Set image2 to be compared.
+
+ Images can contains floating-point or integer values, or RGB and RGBA
+ values, but should have comparable intensities.
+
+ RGB and RGBA images are provided as an array as `[width,height,channels]`
+ of usigned integer 8-bits or floating-points between 0.0 to 1.0.
+
+ :param numpy.ndarray image2: The second image
+ """
+ self.__raw2 = image2
+ self.__updateData()
+ self.__plot.resetZoom()
+
+ def __updateKeyPoints(self):
+ """Update the displayed keypoints using cached keypoints.
+ """
+ if self.__keypointsVisible:
+ data = self.__matching_keypoints
+ else:
+ data = [], [], []
+ self.__plot.addScatter(x=data[0],
+ y=data[1],
+ z=1,
+ value=data[2],
+ legend="keypoints",
+ colormap=Colormap("spring"))
+
+ def __updateData(self):
+ """Compute aligned image when the alignement mode changes.
+
+ This function cache input images which are used when
+ vertical/horizontal separators moves.
+ """
+ raw1, raw2 = self.__raw1, self.__raw2
+ if raw1 is None or raw2 is None:
+ return
+
+ alignmentMode = self.getAlignmentMode()
+ self.__transformation = None
+
+ if alignmentMode == AlignmentMode.ORIGIN:
+ yy = max(raw1.shape[0], raw2.shape[0])
+ xx = max(raw1.shape[1], raw2.shape[1])
+ size = yy, xx
+ data1 = self.__createMarginImage(raw1, size, transparent=True)
+ data2 = self.__createMarginImage(raw2, size, transparent=True)
+ self.__matching_keypoints = [0.0], [0.0], [1.0]
+ elif alignmentMode == AlignmentMode.CENTER:
+ yy = max(raw1.shape[0], raw2.shape[0])
+ xx = max(raw1.shape[1], raw2.shape[1])
+ size = yy, xx
+ data1 = self.__createMarginImage(raw1, size, transparent=True, center=True)
+ data2 = self.__createMarginImage(raw2, size, transparent=True, center=True)
+ self.__matching_keypoints = ([data1.shape[1] // 2],
+ [data1.shape[0] // 2],
+ [1.0])
+ elif alignmentMode == AlignmentMode.STRETCH:
+ data1 = raw1
+ data2 = self.__rescaleImage(raw2, data1.shape)
+ self.__matching_keypoints = ([0, data1.shape[1], data1.shape[1], 0],
+ [0, 0, data1.shape[0], data1.shape[0]],
+ [1.0, 1.0, 1.0, 1.0])
+ elif alignmentMode == AlignmentMode.AUTO:
+ # TODO: sift implementation do not support RGBA images
+ yy = max(raw1.shape[0], raw2.shape[0])
+ xx = max(raw1.shape[1], raw2.shape[1])
+ size = yy, xx
+ data1 = self.__createMarginImage(raw1, size)
+ data2 = self.__createMarginImage(raw2, size)
+ self.__matching_keypoints = [0.0], [0.0], [1.0]
+ try:
+ data1, data2 = self.__createSiftData(data1, data2)
+ if data2 is None:
+ raise ValueError("Unexpected None value")
+ except Exception as e:
+ # TODO: Display it on the GUI
+ _logger.error(e)
+ self.__setDefaultAlignmentMode()
+ return
+ else:
+ assert(False)
+
+ mode = self.getVisualizationMode()
+ if mode == VisualizationMode.COMPOSITE_RED_BLUE_GRAY_NEG:
+ data1 = self.__composeImage(data1, data2, mode)
+ data2 = numpy.empty((0, 0))
+ elif mode == VisualizationMode.COMPOSITE_RED_BLUE_GRAY:
+ data1 = self.__composeImage(data1, data2, mode)
+ data2 = numpy.empty((0, 0))
+ elif mode == VisualizationMode.ONLY_A:
+ data2 = numpy.empty((0, 0))
+ elif mode == VisualizationMode.ONLY_B:
+ data1 = numpy.empty((0, 0))
+
+ self.__data1, self.__data2 = data1, data2
+ self.__plot.addImage(data1, z=0, legend="image1", resetzoom=False)
+ self.__plot.addImage(data2, z=0, legend="image2", resetzoom=False)
+ self.__image1 = self.__plot.getImage("image1")
+ self.__image2 = self.__plot.getImage("image2")
+ self.__updateKeyPoints()
+
+ # Set the separator into the middle
+ if self.__previousSeparatorPosition is None:
+ value = self.__data1.shape[1] // 2
+ self.__vline.setPosition(value, 0)
+ value = self.__data1.shape[0] // 2
+ self.__hline.setPosition(0, value)
+ self.__updateSeparators()
+
+ # Avoid to change the colormap range when the separator is moving
+ # TODO: The colormap histogram will still be wrong
+ mode1 = self.__getImageMode(data1)
+ mode2 = self.__getImageMode(data2)
+ if mode1 == "intensity" and mode1 == mode2:
+ if self.__data1.size == 0:
+ vmin = self.__data2.min()
+ vmax = self.__data2.max()
+ elif self.__data2.size == 0:
+ vmin = self.__data1.min()
+ vmax = self.__data1.max()
+ else:
+ vmin = min(self.__data1.min(), self.__data2.min())
+ vmax = max(self.__data1.max(), self.__data2.max())
+ colormap = Colormap(vmin=vmin, vmax=vmax)
+ self.__image1.setColormap(colormap)
+ self.__image2.setColormap(colormap)
+
+ def __getImageMode(self, image):
+ """Returns a value identifying the way the image is stored in the
+ array.
+
+ :param numpy.ndarray image: Image to check
+ :rtype: str
+ """
+ if len(image.shape) == 2:
+ return "intensity"
+ elif len(image.shape) == 3:
+ if image.shape[2] == 3:
+ return "rgb"
+ elif image.shape[2] == 4:
+ return "rgba"
+ raise TypeError("'image' argument is not an image.")
+
+ def __rescaleImage(self, image, shape):
+ """Rescale an image to the requested shape.
+
+ :rtype: numpy.ndarray
+ """
+ mode = self.__getImageMode(image)
+ if mode == "intensity":
+ data = self.__rescaleArray(image, shape)
+ elif mode == "rgb":
+ data = numpy.empty((shape[0], shape[1], 3), dtype=image.dtype)
+ for c in range(3):
+ data[:, :, c] = self.__rescaleArray(image[:, :, c], shape)
+ elif mode == "rgba":
+ data = numpy.empty((shape[0], shape[1], 4), dtype=image.dtype)
+ for c in range(4):
+ data[:, :, c] = self.__rescaleArray(image[:, :, c], shape)
+ return data
+
+ def __composeImage(self, data1, data2, mode):
+ """Returns an RBG image containing composition of data1 and data2 in 2
+ different channels
+
+ :param numpy.ndarray data1: First image
+ :param numpy.ndarray data1: Second image
+ :param VisualizationMode mode: Composition mode.
+ :rtype: numpy.ndarray
+ """
+ assert(data1.shape[0:2] == data2.shape[0:2])
+ mode1 = self.__getImageMode(data1)
+ if mode1 in ["rgb", "rgba"]:
+ intensity1 = self.__luminosityImage(data1)
+ vmin1, vmax1 = 0.0, 1.0
+ else:
+ intensity1 = data1
+ vmin1, vmax1 = data1.min(), data1.max()
+
+ mode2 = self.__getImageMode(data2)
+ if mode2 in ["rgb", "rgba"]:
+ intensity2 = self.__luminosityImage(data2)
+ vmin2, vmax2 = 0.0, 1.0
+ else:
+ intensity2 = data2
+ vmin2, vmax2 = data2.min(), data2.max()
+
+ vmin, vmax = min(vmin1, vmin2) * 1.0, max(vmax1, vmax2) * 1.0
+ shape = data1.shape
+ result = numpy.empty((shape[0], shape[1], 3), dtype=numpy.uint8)
+ a = (intensity1 - vmin) * (1.0 / (vmax - vmin)) * 255.0
+ b = (intensity2 - vmin) * (1.0 / (vmax - vmin)) * 255.0
+ if mode == VisualizationMode.COMPOSITE_RED_BLUE_GRAY:
+ result[:, :, 0] = a
+ result[:, :, 1] = (a + b) / 2
+ result[:, :, 2] = b
+ elif mode == VisualizationMode.COMPOSITE_RED_BLUE_GRAY_NEG:
+ result[:, :, 0] = 255 - b
+ result[:, :, 1] = 255 - (a + b) / 2
+ result[:, :, 2] = 255 - a
+ return result
+
+ def __luminosityImage(self, image):
+ """Returns the luminosity channel from an RBG(A) image.
+ The alpha channel is ignored.
+
+ :rtype: numpy.ndarray
+ """
+ mode = self.__getImageMode(image)
+ assert(mode in ["rgb", "rgba"])
+ is_uint8 = image.dtype.type == numpy.uint8
+ # luminosity
+ image = 0.21 * image[..., 0] + 0.72 * image[..., 1] + 0.07 * image[..., 2]
+ if is_uint8:
+ image = image / 255.0
+ return image
+
+ def __rescaleArray(self, image, shape):
+ """Rescale a 2D array to the requested shape.
+
+ :rtype: numpy.ndarray
+ """
+ y, x = numpy.ogrid[:shape[0], :shape[1]]
+ y, x = y * 1.0 * (image.shape[0] - 1) / (shape[0] - 1), x * 1.0 * (image.shape[1] - 1) / (shape[1] - 1)
+ b = silx.image.bilinear.BilinearImage(image)
+ # TODO: could be optimized using strides
+ x2d = numpy.zeros_like(y) + x
+ y2d = numpy.zeros_like(x) + y
+ result = b.map_coordinates((y2d, x2d))
+ return result
+
+ def __createMarginImage(self, image, size, transparent=False, center=False):
+ """Returns a new image with margin to respect the requested size.
+
+ :rtype: numpy.ndarray
+ """
+ assert(image.shape[0] <= size[0])
+ assert(image.shape[1] <= size[1])
+ if image.shape == size:
+ return image
+ mode = self.__getImageMode(image)
+
+ if center:
+ pos0 = size[0] // 2 - image.shape[0] // 2
+ pos1 = size[1] // 2 - image.shape[1] // 2
+ else:
+ pos0, pos1 = 0, 0
+
+ if mode == "intensity":
+ data = numpy.zeros(size, dtype=image.dtype)
+ data[pos0:pos0 + image.shape[0], pos1:pos1 + image.shape[1]] = image
+ # TODO: It is maybe possible to put NaN on the margin
+ else:
+ if transparent:
+ data = numpy.zeros((size[0], size[1], 4), dtype=numpy.uint8)
+ else:
+ data = numpy.zeros((size[0], size[1], 3), dtype=numpy.uint8)
+ depth = min(data.shape[2], image.shape[2])
+ data[pos0:pos0 + image.shape[0], pos1:pos1 + image.shape[1], 0:depth] = image[:, :, 0:depth]
+ if transparent and depth == 3:
+ data[pos0:pos0 + image.shape[0], pos1:pos1 + image.shape[1], 3] = 255
+ return data
+
+ def __toAffineTransformation(self, sift_result):
+ """Returns an affine transformation from the sift result.
+
+ :param dict sift_result: Result of sift when using `all_result=True`
+ :rtype: AffineTransformation
+ """
+ offset = sift_result["offset"]
+ matrix = sift_result["matrix"]
+
+ tx = offset[0]
+ ty = offset[1]
+ a = matrix[0, 0]
+ b = matrix[0, 1]
+ c = matrix[1, 0]
+ d = matrix[1, 1]
+ rot = math.atan2(-b, a)
+ sx = (-1.0 if a < 0 else 1.0) * math.sqrt(a**2 + b**2)
+ sy = (-1.0 if d < 0 else 1.0) * math.sqrt(c**2 + d**2)
+ return AffineTransformation(tx, ty, sx, sy, rot)
+
+ def getTransformation(self):
+ """Retuns the affine transformation applied to the second image to align
+ it to the first image.
+
+ This result is only valid for sift alignment.
+
+ :rtype: Union[None,AffineTransformation]
+ """
+ return self.__transformation
+
+ def __createSiftData(self, image, second_image):
+ """Generate key points and aligned images from 2 images.
+
+ If no keypoints matches, unaligned data are anyway returns.
+
+ :rtype: Tuple(numpy.ndarray,numpy.ndarray)
+ """
+ devicetype = "GPU"
+
+ # Compute base image
+ sift_ocl = sift.SiftPlan(template=image, devicetype=devicetype)
+ keypoints = sift_ocl(image)
+
+ # Check image compatibility
+ second_keypoints = sift_ocl(second_image)
+ mp = sift.MatchPlan()
+ match = mp(keypoints, second_keypoints)
+ _logger.info("Number of Keypoints within image 1: %i" % keypoints.size)
+ _logger.info(" within image 2: %i" % second_keypoints.size)
+
+ self.__matching_keypoints = (match[:].x[:, 0],
+ match[:].y[:, 0],
+ match[:].scale[:, 0])
+ matching_keypoints = match.shape[0]
+ _logger.info("Matching keypoints: %i" % matching_keypoints)
+ if matching_keypoints == 0:
+ return image, second_image
+
+ # TODO: Problem here is we have to compute 2 time sift
+ # The first time to extract matching keypoints, second time
+ # to extract the aligned image.
+
+ # Normalize the second image
+ sa = sift.LinearAlign(image, devicetype=devicetype)
+ data1 = image
+ # TODO: Create a sift issue: if data1 is RGB and data2 intensity
+ # it returns None, while extracting manually keypoints (above) works
+ result = sa.align(second_image, return_all=True)
+ data2 = result["result"]
+ self.__transformation = self.__toAffineTransformation(result)
+ return data1, data2
diff --git a/silx/gui/plot/ImageView.py b/silx/gui/plot/ImageView.py
index c28ffca..eba9bc6 100644
--- a/silx/gui/plot/ImageView.py
+++ b/silx/gui/plot/ImageView.py
@@ -315,7 +315,7 @@ class ImageView(PlotWindow):
def _initWidgets(self, backend):
"""Set-up layout and plots."""
- self._histoHPlot = PlotWidget(backend=backend)
+ self._histoHPlot = PlotWidget(backend=backend, parent=self)
self._histoHPlot.getWidgetHandle().setMinimumHeight(
self.HISTOGRAMS_HEIGHT)
self._histoHPlot.getWidgetHandle().setMaximumHeight(
@@ -330,7 +330,7 @@ class ImageView(PlotWindow):
self.getYAxis().sigInvertedChanged.connect(self._updateYAxisInverted)
self.sigActiveImageChanged.connect(self._activeImageChangedSlot)
- self._histoVPlot = PlotWidget(backend=backend)
+ self._histoVPlot = PlotWidget(backend=backend, parent=self)
self._histoVPlot.getWidgetHandle().setMinimumWidth(
self.HISTOGRAMS_HEIGHT)
self._histoVPlot.getWidgetHandle().setMaximumWidth(
@@ -338,14 +338,15 @@ class ImageView(PlotWindow):
self._histoVPlot.setInteractiveMode('zoom')
self._histoVPlot.sigPlotSignal.connect(self._histoVPlotCB)
- self._radarView = RadarView()
+ self._radarView = RadarView(parent=self)
self._radarView.visibleRectDragged.connect(self._radarViewCB)
layout = qt.QGridLayout()
layout.addWidget(self.getWidgetHandle(), 0, 0)
layout.addWidget(self._histoVPlot.getWidgetHandle(), 0, 1)
layout.addWidget(self._histoHPlot.getWidgetHandle(), 1, 0)
- layout.addWidget(self._radarView, 1, 1)
+ layout.addWidget(self._radarView, 1, 1, 1, 2)
+ layout.addWidget(self.getColorBarWidget(), 0, 2)
layout.setColumnMinimumWidth(0, self.IMAGE_MIN_SIZE)
layout.setColumnStretch(0, 1)
diff --git a/silx/gui/plot/LegendSelector.py b/silx/gui/plot/LegendSelector.py
index e9cfd1d..b9d0fd3 100644
--- a/silx/gui/plot/LegendSelector.py
+++ b/silx/gui/plot/LegendSelector.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# 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
@@ -35,7 +35,10 @@ __data__ = "16/10/2017"
import logging
import weakref
-from .. import qt
+import numpy
+
+from .. import qt, colors
+from . import items
_logger = logging.getLogger(__name__)
@@ -92,10 +95,16 @@ NoLineStyle = (None, 'None', 'none', '', ' ')
class LegendIcon(qt.QWidget):
- """Object displaying a curve linestyle and symbol."""
+ """Object displaying a curve linestyle and symbol.
+
+ :param QWidget parent: See :class:`QWidget`
+ :param Union[~silx.gui.plot.items.Curve,None] curve:
+ Curve with which to synchronize
+ """
- def __init__(self, parent=None):
+ def __init__(self, parent=None, curve=None):
super(LegendIcon, self).__init__(parent)
+ self._curveRef = None
# Visibilities
self.showLine = True
@@ -118,9 +127,85 @@ class LegendIcon(qt.QWidget):
self.setSizePolicy(qt.QSizePolicy.Fixed,
qt.QSizePolicy.Fixed)
+ self.setCurve(curve)
+
def sizeHint(self):
return qt.QSize(50, 15)
+ # Synchronize with a curve
+
+ def getCurve(self):
+ """Returns curve associated to this widget
+
+ :rtype: Union[~silx.gui.plot.items.Curve,None]
+ """
+ return None if self._curveRef is None else self._curveRef()
+
+ def setCurve(self, curve):
+ """Set the curve with which to synchronize this widget.
+
+ :param curve: Union[~silx.gui.plot.items.Curve,None]
+ """
+ assert curve is None or isinstance(curve, items.Curve)
+
+ previousCurve = self.getCurve()
+ if curve == previousCurve:
+ return
+
+ if previousCurve is not None:
+ previousCurve.sigItemChanged.disconnect(self._curveChanged)
+
+ self._curveRef = None if curve is None else weakref.ref(curve)
+
+ if curve is not None:
+ curve.sigItemChanged.connect(self._curveChanged)
+
+ self._update()
+
+ def _update(self):
+ """Update widget according to current curve state.
+ """
+ curve = self.getCurve()
+ if curve is None:
+ _logger.error('Curve no more exists')
+ self.setEnabled(False)
+ return
+
+ style = curve.getCurrentStyle()
+
+ self.setEnabled(curve.isVisible())
+ self.setSymbol(style.getSymbol())
+ self.setLineWidth(style.getLineWidth())
+ self.setLineStyle(style.getLineStyle())
+
+ color = style.getColor()
+ if numpy.array(color, copy=False).ndim != 1:
+ # array of colors, use transparent black
+ color = 0., 0., 0., 0.
+ color = colors.rgba(color) # Make sure it is float in [0, 1]
+ alpha = curve.getAlpha()
+ color = qt.QColor.fromRgbF(
+ color[0], color[1], color[2], color[3] * alpha)
+ self.setLineColor(color)
+ self.setSymbolColor(color)
+ self.update() # TODO this should not be needed
+
+ def _curveChanged(self, event):
+ """Handle update of curve item
+
+ :param event: Kind of change
+ """
+ if event in (items.ItemChangedType.VISIBLE,
+ items.ItemChangedType.SYMBOL,
+ items.ItemChangedType.SYMBOL_SIZE,
+ items.ItemChangedType.LINE_WIDTH,
+ items.ItemChangedType.LINE_STYLE,
+ items.ItemChangedType.COLOR,
+ items.ItemChangedType.ALPHA,
+ items.ItemChangedType.HIGHLIGHTED,
+ items.ItemChangedType.HIGHLIGHTED_STYLE):
+ self._update()
+
# Modify Symbol
def setSymbol(self, symbol):
symbol = str(symbol)
@@ -185,6 +270,14 @@ class LegendIcon(qt.QWidget):
symbolOffset = qt.QPointF(.5 * (ratio - 1.), 0.)
# Determine and scale offset
offset = qt.QPointF(float(rect.left()) / scale, float(rect.top()) / scale)
+
+ # Override color when disabled
+ if self.isEnabled():
+ overrideColor = None
+ else:
+ overrideColor = palette.color(qt.QPalette.Disabled,
+ qt.QPalette.WindowText)
+
# Draw BG rectangle (for debugging)
# bottomRight = qt.QPointF(
# float(rect.right())/scale,
@@ -197,15 +290,15 @@ class LegendIcon(qt.QWidget):
linePath.moveTo(0., 0.5)
linePath.lineTo(ratio, 0.5)
# linePath.lineTo(2.5, 0.5)
+ lineBrush = qt.QBrush(
+ self.lineColor if overrideColor is None else overrideColor)
linePen = qt.QPen(
- qt.QBrush(self.lineColor),
+ lineBrush,
(self.lineWidth / self.height()),
self.lineStyle,
qt.Qt.FlatCap
)
- llist.append((linePath,
- linePen,
- qt.QBrush(self.lineColor)))
+ llist.append((linePath, linePen, lineBrush))
if (self.showSymbol and len(self.symbol) and
self.symbol not in NoSymbols):
# PITFALL ahead: Let this be a warning to others
@@ -214,9 +307,8 @@ class LegendIcon(qt.QWidget):
symbolPath = qt.QPainterPath(Symbols[self.symbol])
symbolPath.translate(symbolOffset)
symbolBrush = qt.QBrush(
- self.symbolColor,
- self.symbolStyle
- )
+ self.symbolColor if overrideColor is None else overrideColor,
+ self.symbolStyle)
symbolPen = qt.QPen(
self.symbolOutlineBrush, # Brush
1. / self.height(), # Width
@@ -1062,18 +1154,18 @@ class LegendsDockWidget(qt.QDockWidget):
for curve in self.plot.getAllCurves(withhidden=True):
legend = curve.getLegend()
# Use active color if curve is active
- if legend == self.plot.getActiveCurve(just_legend=True):
- color = qt.QColor(self.plot.getActiveCurveColor())
- isActive = True
- else:
- color = qt.QColor.fromRgbF(*curve.getColor())
- isActive = False
+ isActive = legend == self.plot.getActiveCurve(just_legend=True)
+ style = curve.getCurrentStyle()
+ color = style.getColor()
+ if numpy.array(color, copy=False).ndim != 1:
+ # array of colors, use transparent black
+ color = 0., 0., 0., 0.
curveInfo = {
- 'color': color,
- 'linewidth': curve.getLineWidth(),
- 'linestyle': curve.getLineStyle(),
- 'symbol': curve.getSymbol(),
+ 'color': qt.QColor.fromRgbF(*color),
+ 'linewidth': style.getLineWidth(),
+ 'linestyle': style.getLineStyle(),
+ 'symbol': style.getSymbol(),
'selected': not self.plot.isCurveHidden(legend),
'active': isActive}
legendList.append((legend, curveInfo))
diff --git a/silx/gui/plot/MaskToolsWidget.py b/silx/gui/plot/MaskToolsWidget.py
index 797068e..990e479 100644
--- a/silx/gui/plot/MaskToolsWidget.py
+++ b/silx/gui/plot/MaskToolsWidget.py
@@ -35,7 +35,7 @@ from __future__ import division
__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "24/04/2018"
+__date__ = "29/08/2018"
import os
@@ -43,8 +43,11 @@ import sys
import numpy
import logging
import collections
+import h5py
from silx.image import shapes
+from silx.io.utils import NEXUS_HDF5_EXT, is_dataset
+from silx.gui.dialog.DatasetDialog import DatasetDialog
from ._BaseMaskToolsWidget import BaseMask, BaseMaskToolsWidget, BaseMaskToolsDockWidget
from . import items
@@ -63,6 +66,27 @@ except ImportError:
_logger = logging.getLogger(__name__)
+_HDF5_EXT_STR = ' '.join(['*' + ext for ext in NEXUS_HDF5_EXT])
+
+
+def _selectDataset(filename, mode=DatasetDialog.SaveMode):
+ """Open a dialog to prompt the user to select a dataset in
+ a hdf5 file.
+
+ :param str filename: name of an existing HDF5 file
+ :param mode: DatasetDialog.SaveMode or DatasetDialog.LoadMode
+ :rtype: str
+ :return: Name of selected dataset
+ """
+ dialog = DatasetDialog()
+ dialog.addFile(filename)
+ dialog.setWindowTitle("Select a 2D dataset")
+ dialog.setMode(mode)
+ if not dialog.exec_():
+ return None
+ return dialog.getSelectedDataUrl().data_path()
+
+
class ImageMask(BaseMask):
"""A 2D mask field with update operations.
@@ -89,7 +113,7 @@ class ImageMask(BaseMask):
"""Save current mask in a file
:param str filename: The file where to save to mask
- :param str kind: The kind of file to save in 'edf', 'tif', 'npy',
+ :param str kind: The kind of file to save in 'edf', 'tif', 'npy', 'h5'
or 'msk' (if FabIO is installed)
:raise Exception: Raised if the file writing fail
"""
@@ -107,6 +131,9 @@ class ImageMask(BaseMask):
except IOError:
raise RuntimeError("Mask file can't be written")
+ elif ("." + kind) in NEXUS_HDF5_EXT:
+ self._saveToHdf5(filename, self.getMask(copy=False))
+
elif kind == 'msk':
if fabio is None:
raise ImportError("Fit2d mask files can't be written: Fabio module is not available")
@@ -118,10 +145,41 @@ class ImageMask(BaseMask):
except Exception:
_logger.debug("Backtrace", exc_info=True)
raise RuntimeError("Mask file can't be written")
-
else:
raise ValueError("Format '%s' is not supported" % kind)
+ @staticmethod
+ def _saveToHdf5(filename, mask):
+ """Save a mask array to a HDF5 file.
+
+ :param str filename: name of an existing HDF5 file
+ :param numpy.ndarray mask: Mask array.
+ :returns: True if operation succeeded, False otherwise.
+ """
+ if not os.path.exists(filename):
+ # create new file
+ with h5py.File(filename, "w") as _h5f:
+ pass
+ dataPath = _selectDataset(filename)
+ if dataPath is None:
+ return False
+ with h5py.File(filename, "a") as h5f:
+ existing_ds = h5f.get(dataPath)
+ if existing_ds is not None:
+ reply = qt.QMessageBox.question(
+ None,
+ "Confirm overwrite",
+ "Do you want to overwrite an existing dataset?",
+ qt.QMessageBox.Yes | qt.QMessageBox.No)
+ if reply != qt.QMessageBox.Yes:
+ return False
+ del h5f[dataPath]
+ try:
+ h5f.create_dataset(dataPath, data=mask)
+ except Exception:
+ return False
+ return True
+
# Drawing operations
def updateRectangle(self, level, row, col, height, width, mask=True):
"""Mask/Unmask a rectangle of the given mask level.
@@ -310,8 +368,9 @@ class MaskToolsWidget(BaseMaskToolsWidget):
self._activeImageChanged)
except (RuntimeError, TypeError):
pass
- if not self.browseAction.isChecked():
- self.browseAction.trigger() # Disable drawing tool
+ if self.isMaskInteractionActivated():
+ # Disable drawing tool
+ self.browseAction.trigger()
if self.getSelectionMask(copy=False) is not None:
self.plot.sigActiveImageChanged.connect(
@@ -450,6 +509,10 @@ class MaskToolsWidget(BaseMaskToolsWidget):
_logger.error("Can't load fit2d mask file")
_logger.debug("Backtrace", exc_info=True)
raise e
+ elif ("." + extension) in NEXUS_HDF5_EXT:
+ mask = self._loadFromHdf5(filename)
+ if mask is None:
+ raise IOError("Could not load mask from HDF5 dataset")
else:
msg = "Extension '%s' is not supported."
raise RuntimeError(msg % extension)
@@ -472,6 +535,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
extensions["EDF files"] = "*.edf"
extensions["TIFF files"] = "*.tif *.tiff"
extensions["NumPy binary files"] = "*.npy"
+ extensions["HDF5 files"] = _HDF5_EXT_STR
# Fit2D mask is displayed anyway fabio is here or not
# to show to the user that the option exists
extensions["Fit2D mask files"] = "*.msk"
@@ -508,15 +572,37 @@ class MaskToolsWidget(BaseMaskToolsWidget):
msg.setText("Cannot load mask from file. " + message)
msg.exec_()
+ @staticmethod
+ def _loadFromHdf5(filename):
+ """Load a mask array from a HDF5 file.
+
+ :param str filename: name of an existing HDF5 file
+ :returns: A mask as a numpy array, or None if the interactive dialog
+ was cancelled
+ """
+ dataPath = _selectDataset(filename, mode=DatasetDialog.LoadMode)
+ if dataPath is None:
+ return None
+
+ with h5py.File(filename, "r") as h5f:
+ dataset = h5f.get(dataPath)
+ if not is_dataset(dataset):
+ raise IOError("%s is not a dataset" % dataPath)
+ mask = dataset[()]
+ return mask
+
def _saveMask(self):
"""Open Save mask dialog"""
dialog = qt.QFileDialog(self)
dialog.setWindowTitle("Save Mask")
+ dialog.setOption(dialog.DontUseNativeDialog)
dialog.setModal(1)
+ hdf5Filter = 'HDF5 (%s)' % _HDF5_EXT_STR
filters = [
'EDF (*.edf)',
'TIFF (*.tif)',
'NumPy binary file (*.npy)',
+ hdf5Filter,
# Fit2D mask is displayed anyway fabio is here or not
# to show to the user that the option exists
'Fit2D mask (*.msk)',
@@ -525,19 +611,41 @@ class MaskToolsWidget(BaseMaskToolsWidget):
dialog.setFileMode(qt.QFileDialog.AnyFile)
dialog.setAcceptMode(qt.QFileDialog.AcceptSave)
dialog.setDirectory(self.maskFileDir)
+
+ def onFilterSelection(filt_):
+ # disable overwrite confirmation for HDF5,
+ # because we append the data to existing files
+ if filt_ == hdf5Filter:
+ dialog.setOption(dialog.DontConfirmOverwrite)
+ else:
+ dialog.setOption(dialog.DontConfirmOverwrite, False)
+
+ dialog.filterSelected.connect(onFilterSelection)
if not dialog.exec_():
dialog.close()
return
- # convert filter name to extension name with the .
- extension = dialog.selectedNameFilter().split()[-1][2:-1]
+ nameFilter = dialog.selectedNameFilter()
filename = dialog.selectedFiles()[0]
dialog.close()
- if not filename.lower().endswith(extension):
- filename += extension
+ if "HDF5" in nameFilter:
+ has_allowed_ext = False
+ for ext in NEXUS_HDF5_EXT:
+ if (len(filename) > len(ext) and
+ filename[-len(ext):].lower() == ext.lower()):
+ has_allowed_ext = True
+ extension = ext
+ if not has_allowed_ext:
+ extension = ".h5"
+ filename += ".h5"
+ else:
+ # convert filter name to extension name with the .
+ extension = nameFilter.split()[-1][2:-1]
+ if not filename.lower().endswith(extension):
+ filename += extension
- if os.path.exists(filename):
+ if os.path.exists(filename) and "HDF5" not in nameFilter:
try:
os.remove(filename)
except IOError:
@@ -552,6 +660,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
try:
self.save(filename, extension[1:])
except Exception as e:
+ raise
msg = qt.QMessageBox(self)
msg.setIcon(qt.QMessageBox.Critical)
msg.setText("Cannot save file %s\n%s" % (filename, e.args[0]))
diff --git a/silx/gui/plot/PlotToolButtons.py b/silx/gui/plot/PlotToolButtons.py
index e354877..f6291b5 100644
--- a/silx/gui/plot/PlotToolButtons.py
+++ b/silx/gui/plot/PlotToolButtons.py
@@ -240,6 +240,62 @@ class YAxisOriginToolButton(PlotToolButton):
self.setToolTip(toolTip)
+class ProfileOptionToolButton(PlotToolButton):
+ """Button to define option on the profile"""
+ sigMethodChanged = qt.Signal(str)
+
+ def __init__(self, parent=None, plot=None):
+ PlotToolButton.__init__(self, parent=parent, plot=plot)
+
+ self.STATE = {}
+ # is down
+ self.STATE['sum', "icon"] = icons.getQIcon('math-sigma')
+ self.STATE['sum', "state"] = "compute profile sum"
+ self.STATE['sum', "action"] = "compute profile sum"
+ # keep ration
+ self.STATE['mean', "icon"] = icons.getQIcon('math-mean')
+ self.STATE['mean', "state"] = "compute profile mean"
+ self.STATE['mean', "action"] = "compute profile mean"
+
+ sumAction = self._createAction('sum')
+ sumAction.triggered.connect(self.setSum)
+ sumAction.setIconVisibleInMenu(True)
+
+ meanAction = self._createAction('mean')
+ meanAction.triggered.connect(self.setMean)
+ meanAction.setIconVisibleInMenu(True)
+
+ menu = qt.QMenu(self)
+ menu.addAction(sumAction)
+ menu.addAction(meanAction)
+ self.setMenu(menu)
+ self.setPopupMode(qt.QToolButton.InstantPopup)
+ self.setMean()
+
+ def _createAction(self, method):
+ icon = self.STATE[method, "icon"]
+ text = self.STATE[method, "action"]
+ return qt.QAction(icon, text, self)
+
+ def setSum(self):
+ """Configure the plot to use y-axis upward"""
+ self._method = 'sum'
+ self.sigMethodChanged.emit(self._method)
+ self._update()
+
+ def _update(self):
+ icon = self.STATE[self._method, "icon"]
+ toolTip = self.STATE[self._method, "state"]
+ self.setIcon(icon)
+ self.setToolTip(toolTip)
+
+ def setMean(self):
+ """Configure the plot to use y-axis downward"""
+ self._method = 'mean'
+ self.sigMethodChanged.emit(self._method)
+ self._update()
+
+
class ProfileToolButton(PlotToolButton):
"""Button used in Profile3DToolbar to switch between 2D profile
and 1D profile."""
diff --git a/silx/gui/plot/PlotWidget.py b/silx/gui/plot/PlotWidget.py
index 2f7132c..e023a21 100644
--- a/silx/gui/plot/PlotWidget.py
+++ b/silx/gui/plot/PlotWidget.py
@@ -31,7 +31,7 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "14/06/2018"
+__date__ = "12/10/2018"
from collections import OrderedDict, namedtuple
@@ -58,7 +58,8 @@ from .LimitsHistory import LimitsHistory
from . import _utils
from . import items
-from .items.axis import TickMode
+from .items.curve import CurveStyle
+from .items.axis import TickMode # noqa
from .. import qt
from ._utils.panzoom import ViewConstraints
@@ -68,27 +69,7 @@ _logger = logging.getLogger(__name__)
_COLORDICT = colors.COLORDICT
-_COLORLIST = [_COLORDICT['black'],
- _COLORDICT['blue'],
- _COLORDICT['red'],
- _COLORDICT['green'],
- _COLORDICT['pink'],
- _COLORDICT['yellow'],
- _COLORDICT['brown'],
- _COLORDICT['cyan'],
- _COLORDICT['magenta'],
- _COLORDICT['orange'],
- _COLORDICT['violet'],
- # _COLORDICT['bluegreen'],
- _COLORDICT['grey'],
- _COLORDICT['darkBlue'],
- _COLORDICT['darkRed'],
- _COLORDICT['darkGreen'],
- _COLORDICT['darkCyan'],
- _COLORDICT['darkMagenta'],
- _COLORDICT['darkYellow'],
- _COLORDICT['darkBrown']]
-
+_COLORLIST = silx.config.DEFAULT_PLOT_CURVE_COLORS
"""
Object returned when requesting the data range.
@@ -193,6 +174,25 @@ class PlotWidget(qt.QMainWindow):
It provides the source as passed to :meth:`setInteractiveMode`.
"""
+ sigItemAdded = qt.Signal(items.Item)
+ """Signal emitted when an item was just added to the plot
+
+ It provides the added item.
+ """
+
+ sigItemAboutToBeRemoved = qt.Signal(items.Item)
+ """Signal emitted right before an item is removed from the plot.
+
+ It provides the item that will be removed.
+ """
+
+ sigVisibilityChanged = qt.Signal(bool)
+ """Signal emitted when the widget becomes visible (or invisible).
+ This happens when the widget is hidden or shown.
+
+ It provides the visible state.
+ """
+
def __init__(self, parent=None, backend=None,
legends=False, callback=None, **kw):
self._autoreplot = False
@@ -253,8 +253,8 @@ class PlotWidget(qt.QMainWindow):
self._colorIndex = 0
self._styleIndex = 0
- self._activeCurveHandling = True
- self._activeCurveColor = "#000000"
+ self._activeCurveSelectionMode = "atmostone"
+ self._activeCurveStyle = CurveStyle(color='#000000')
self._activeLegend = {'curve': None, 'image': None,
'scatter': None}
@@ -346,8 +346,18 @@ class PlotWidget(qt.QMainWindow):
else:
self._dirty = True
- if self._autoreplot and not wasDirty:
+ if self._autoreplot and not wasDirty and self.isVisible():
+ self._backend.postRedisplay()
+
+ def showEvent(self, event):
+ if self._autoreplot and self._dirty:
self._backend.postRedisplay()
+ super(PlotWidget, self).showEvent(event)
+ self.sigVisibilityChanged.emit(True)
+
+ def hideEvent(self, event):
+ super(PlotWidget, self).hideEvent(event)
+ self.sigVisibilityChanged.emit(False)
def _invalidateDataRange(self):
"""
@@ -447,6 +457,7 @@ class PlotWidget(qt.QMainWindow):
self._invalidateDataRange() # TODO handle this automatically
self._notifyContentChanged(item)
+ self.sigItemAdded.emit(item)
def _notifyContentChanged(self, item):
legend, kind = self._itemKey(item)
@@ -461,6 +472,8 @@ class PlotWidget(qt.QMainWindow):
if key not in self._content:
raise RuntimeError('Item not in the plot')
+ self.sigItemAboutToBeRemoved.emit(item)
+
legend, kind = key
if kind in self._ACTIVE_ITEM_KINDS:
@@ -721,6 +734,12 @@ class PlotWidget(qt.QMainWindow):
if wasActive:
self.setActiveCurve(curve.getLegend())
+ elif self.getActiveCurveSelectionMode() == "legacy":
+ if self.getActiveCurve(just_legend=True) is None:
+ if len(self.getAllCurves(just_legend=True,
+ withhidden=False)) == 1:
+ if curve.isVisible():
+ self.setActiveCurve(curve.getLegend())
if resetzoom:
# We ask for a zoom reset in order to handle the plot scaling
@@ -840,10 +859,9 @@ class PlotWidget(qt.QMainWindow):
(default: False)
:param bool draggable: Indicate if the image can be moved.
(default: False)
- :param colormap: Description of the :class:`.Colormap` to use
- (or None).
- This is ignored if data is a RGB(A) image.
- :type colormap: Union[silx.gui.colors.Colormap, dict]
+ :param colormap: Colormap object to use (or None).
+ This is ignored if data is a RGB(A) image.
+ :type colormap: Union[~silx.gui.colors.Colormap, dict]
:param pixmap: Pixmap representation of the data (if any)
:type pixmap: (nrows, ncolumns, RGBA) ubyte array or None (default)
:param str xlabel: X axis label to show when this curve is active,
@@ -986,8 +1004,8 @@ class PlotWidget(qt.QMainWindow):
:param numpy.ndarray y: The data corresponding to the y coordinates
:param numpy.ndarray value: The data value associated with each point
:param str legend: The legend to be associated to the scatter (or None)
- :param silx.gui.colors.Colormap colormap:
- The :class:`.Colormap`. to be used for the scatter (or None)
+ :param ~silx.gui.colors.Colormap colormap:
+ Colormap object to be used for the scatter (or None)
:param info: User-defined information associated to the curve
:param str symbol: Symbol to be drawn at each (x, y) position::
@@ -1560,26 +1578,59 @@ class PlotWidget(qt.QMainWindow):
# Active Curve/Image
def isActiveCurveHandling(self):
- """Returns True if active curve selection is enabled."""
- return self._activeCurveHandling
+ """Returns True if active curve selection is enabled.
+
+ :rtype: bool
+ """
+ return self.getActiveCurveSelectionMode() != 'none'
def setActiveCurveHandling(self, flag=True):
"""Enable/Disable active curve selection.
- :param bool flag: True (the default) to enable active curve selection.
+ :param bool flag: True to enable 'atmostone' active curve selection,
+ False to disable active curve selection.
+ """
+ self.setActiveCurveSelectionMode('atmostone' if flag else 'none')
+
+ def getActiveCurveStyle(self):
+ """Returns the current style applied to active curve
+
+ :rtype: CurveStyle
"""
- if not flag:
- self.setActiveCurve(None) # Reset active curve
+ return self._activeCurveStyle
- self._activeCurveHandling = bool(flag)
+ def setActiveCurveStyle(self,
+ color=None,
+ linewidth=None,
+ linestyle=None,
+ symbol=None,
+ symbolsize=None):
+ """Set the style of active curve
+ :param color: Color
+ :param Union[str,None] linestyle: Style of the line
+ :param Union[float,None] linewidth: Width of the line
+ :param Union[str,None] symbol: Symbol of the markers
+ :param Union[float,None] symbolsize: Size of the symbols
+ """
+ self._activeCurveStyle = CurveStyle(color=color,
+ linewidth=linewidth,
+ linestyle=linestyle,
+ symbol=symbol,
+ symbolsize=symbolsize)
+ curve = self.getActiveCurve()
+ if curve is not None:
+ curve.setHighlightedStyle(self.getActiveCurveStyle())
+
+ @deprecated(replacement="getActiveCurveStyle", since_version="0.9")
def getActiveCurveColor(self):
"""Get the color used to display the currently active curve.
See :meth:`setActiveCurveColor`.
"""
- return self._activeCurveColor
+ return self._activeCurveStyle.getColor()
+ @deprecated(replacement="setActiveCurveStyle", since_version="0.9")
def setActiveCurveColor(self, color="#000000"):
"""Set the color to use to display the currently active curve.
@@ -1590,7 +1641,7 @@ class PlotWidget(qt.QMainWindow):
color = "black"
if color in self.colorDict:
color = self.colorDict[color]
- self._activeCurveColor = color
+ self.setActiveCurveStyle(color=color)
def getActiveCurve(self, just_legend=False):
"""Return the currently active curve.
@@ -1621,9 +1672,43 @@ class PlotWidget(qt.QMainWindow):
if not self.isActiveCurveHandling():
return
+ if legend is None and self.getActiveCurveSelectionMode() == "legacy":
+ _logger.info(
+ 'setActiveCurve(None) ignored due to active curve selection mode')
+ return
return self._setActiveItem(kind='curve', legend=legend)
+ def setActiveCurveSelectionMode(self, mode):
+ """Sets the current selection mode.
+
+ :param str mode: The active curve selection mode to use.
+ It can be: 'legacy', 'atmostone' or 'none'.
+ """
+ assert mode in ('legacy', 'atmostone', 'none')
+
+ if mode != self._activeCurveSelectionMode:
+ self._activeCurveSelectionMode = mode
+ if mode == 'none': # reset active curve
+ self._setActiveItem(kind='curve', legend=None)
+
+ elif mode == 'legacy' and self.getActiveCurve() is None:
+ # Select an active curve
+ curves = self.getAllCurves(just_legend=False,
+ withhidden=False)
+ if len(curves) == 1:
+ if curves[0].isVisible():
+ self.setActiveCurve(curves[0].getLegend())
+
+ def getActiveCurveSelectionMode(self):
+ """Returns the current selection mode.
+
+ It can be "atmostone", "legacy" or "none".
+
+ :rtype: str
+ """
+ return self._activeCurveSelectionMode
+
def getActiveImage(self, just_legend=False):
"""Returns the currently active image.
@@ -1707,7 +1792,7 @@ class PlotWidget(qt.QMainWindow):
# Curve specific: handle highlight
if kind == 'curve':
- item.setHighlightedColor(self.getActiveCurveColor())
+ item.setHighlightedStyle(self.getActiveCurveStyle())
item.setHighlighted(True)
if isinstance(item, items.LabelsMixIn):
@@ -1761,6 +1846,13 @@ class PlotWidget(qt.QMainWindow):
# Getters
+ def getItems(self):
+ """Returns the list of items in the plot
+
+ :rtype: List[silx.gui.plot.items.Item]
+ """
+ return tuple(self._content.values())
+
def getAllCurves(self, just_legend=False, withhidden=False):
"""Returns all curves legend or info and data.
@@ -2273,8 +2365,9 @@ class PlotWidget(qt.QMainWindow):
curve.setLineStyle(linestyle)
def getDefaultColormap(self):
- """Return the default :class:`.Colormap` used by :meth:`addImage`.
+ """Return the default colormap used by :meth:`addImage`.
+ :rtype: ~silx.gui.colors.Colormap
"""
return self._defaultColormap
@@ -2286,9 +2379,9 @@ class PlotWidget(qt.QMainWindow):
It only affects future calls to :meth:`addImage` without the colormap
parameter.
- :param silx.gui.colors.Colormap colormap:
+ :param ~silx.gui.colors.Colormap colormap:
The description of the default colormap, or
- None to set the :class:`.Colormap` to a linear
+ None to set the colormap to a linear
autoscale gray colormap.
"""
if colormap is None:
@@ -2328,7 +2421,7 @@ class PlotWidget(qt.QMainWindow):
self._styleIndex = (self._styleIndex + 1) % len(self._styleList)
# If color is the one of active curve, take the next one
- if color == self.getActiveCurveColor():
+ if colors.rgba(color) == self.getActiveCurveStyle().getColor():
color, style = self._getColorAndStyle()
if not self._plotLines:
diff --git a/silx/gui/plot/PlotWindow.py b/silx/gui/plot/PlotWindow.py
index 459ffdc..23ea399 100644
--- a/silx/gui/plot/PlotWindow.py
+++ b/silx/gui/plot/PlotWindow.py
@@ -29,7 +29,7 @@ The :class:`PlotWindow` is a subclass of :class:`.PlotWidget`.
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "05/06/2018"
+__date__ = "12/10/2018"
import collections
import logging
@@ -439,7 +439,7 @@ class PlotWindow(PlotWidget):
# The first created dock widget must be added to a Widget area
width = self.centralWidget().width()
height = self.centralWidget().height()
- if width > (2.0 * height) and width > 1000:
+ if width > (1.25 * height):
area = qt.Qt.RightDockWidgetArea
else:
area = qt.Qt.BottomDockWidgetArea
@@ -520,6 +520,7 @@ class PlotWindow(PlotWidget):
dockWidget.setWindowTitle("Curves stats")
dockWidget.layout().setContentsMargins(0, 0, 0, 0)
self._statsWidget = BasicStatsWidget(parent=self, plot=self)
+ self._statsWidget.sigVisibilityChanged.connect(self.getStatsAction().setChecked)
dockWidget.setWidget(self._statsWidget)
dockWidget.hide()
self.addTabbedDockWidget(dockWidget)
diff --git a/silx/gui/plot/PrintPreviewToolButton.py b/silx/gui/plot/PrintPreviewToolButton.py
index c5479b8..b48505d 100644
--- a/silx/gui/plot/PrintPreviewToolButton.py
+++ b/silx/gui/plot/PrintPreviewToolButton.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# 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
@@ -175,7 +175,8 @@ class PrintPreviewToolButton(qt.QToolButton):
def _plotToPrintPreview(self):
"""Grab the plot widget and send it to the print preview dialog.
Make sure the print preview dialog is shown and raised."""
- self.printPreviewDialog.ensurePrinterIsSet()
+ if not self.printPreviewDialog.ensurePrinterIsSet():
+ return
if qt.HAS_SVG:
svgRenderer, viewBox = self._getSvgRendererAndViewbox()
diff --git a/silx/gui/plot/Profile.py b/silx/gui/plot/Profile.py
index 5a733fe..182cf60 100644
--- a/silx/gui/plot/Profile.py
+++ b/silx/gui/plot/Profile.py
@@ -28,7 +28,7 @@ and stacks of images"""
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel", "H. Payno"]
__license__ = "MIT"
-__date__ = "24/04/2018"
+__date__ = "24/07/2018"
import weakref
@@ -42,13 +42,13 @@ from .. import qt
from . import items
from ..colors import cursorColorForColormap
from . import actions
-from .PlotToolButtons import ProfileToolButton
+from .PlotToolButtons import ProfileToolButton, ProfileOptionToolButton
from .ProfileMainWindow import ProfileMainWindow
from silx.utils.deprecation import deprecated
-def _alignedFullProfile(data, origin, scale, position, roiWidth, axis):
+def _alignedFullProfile(data, origin, scale, position, roiWidth, axis, method):
"""Get a profile along one axis on a stack of images
:param numpy.ndarray data: 3D volume (stack of 2D images)
@@ -59,10 +59,12 @@ def _alignedFullProfile(data, origin, scale, position, roiWidth, axis):
on the axis orthogonal to the profile direction.
:param int roiWidth: Width of the profile in image pixels.
:param int axis: 0 for horizontal profile, 1 for vertical.
+ :param str method: method to compute the profile. Can be 'mean' or 'sum'
:return: profile image + effective ROI area corners in plot coords
"""
assert axis in (0, 1)
assert len(data.shape) == 3
+ assert method in ('mean', 'sum')
# Convert from plot to image coords
imgPos = int((position - origin[1 - axis]) / scale[1 - axis])
@@ -81,8 +83,13 @@ def _alignedFullProfile(data, origin, scale, position, roiWidth, axis):
end = start + roiWidth
if start < height and end > 0:
- profile = data[:, max(0, start):min(end, height), :].mean(
- axis=1, dtype=numpy.float32)
+ if method == 'mean':
+ _fct = numpy.mean
+ elif method == 'sum':
+ _fct = numpy.sum
+ else:
+ raise ValueError('method not managed')
+ profile = _fct(data[:, max(0, start):min(end, height), :], axis=1).astype(numpy.float32)
else:
profile = numpy.zeros((nimages, width), dtype=numpy.float32)
@@ -102,7 +109,7 @@ def _alignedFullProfile(data, origin, scale, position, roiWidth, axis):
return profile, area
-def _alignedPartialProfile(data, rowRange, colRange, axis):
+def _alignedPartialProfile(data, rowRange, colRange, axis, method):
"""Mean of a rectangular region (ROI) of a stack of images
along a given axis.
@@ -117,6 +124,7 @@ def _alignedPartialProfile(data, rowRange, colRange, axis):
:param int axis: The axis along which to take the profile of the ROI.
0: Sum rows along columns.
1: Sum columns along rows.
+ :param str method: method to compute the profile. Can be 'mean' or 'sum'
:return: Profile image along the ROI as the mean of the intersection
of the ROI and the image.
"""
@@ -124,6 +132,7 @@ def _alignedPartialProfile(data, rowRange, colRange, axis):
assert len(data.shape) == 3
assert rowRange[0] < rowRange[1]
assert colRange[0] < colRange[1]
+ assert method in ('mean', 'sum')
nimages, height, width = data.shape
@@ -138,8 +147,15 @@ def _alignedPartialProfile(data, rowRange, colRange, axis):
colStart = min(max(0, colRange[0]), width)
colEnd = min(max(0, colRange[1]), width)
- imgProfile = numpy.mean(data[:, rowStart:rowEnd, colStart:colEnd],
- axis=axis + 1, dtype=numpy.float32)
+ if method == 'mean':
+ _fct = numpy.mean
+ elif method == 'sum':
+ _fct = numpy.sum
+ else:
+ raise ValueError('method not managed')
+
+ imgProfile = _fct(data[:, rowStart:rowEnd, colStart:colEnd], axis=axis + 1,
+ dtype=numpy.float32)
# Profile including out of bound area
profile = numpy.zeros((nimages, profileLength), dtype=numpy.float32)
@@ -151,7 +167,7 @@ def _alignedPartialProfile(data, rowRange, colRange, axis):
return profile
-def createProfile(roiInfo, currentData, origin, scale, lineWidth):
+def createProfile(roiInfo, currentData, origin, scale, lineWidth, method):
"""Create the profile line for the the given image.
:param roiInfo: information about the ROI: start point, end point and
@@ -163,6 +179,7 @@ def createProfile(roiInfo, currentData, origin, scale, lineWidth):
:param scale: (sx, sy) the scale to use
:type scale: 2-tuple of float
:param int lineWidth: width of the profile line
+ :param str method: method to compute the profile. Can be 'mean' or 'sum'
:return: `profile, area, profileName, xLabel`, where:
- profile is a 2D array of the profiles of the stack of images.
For a single image, the profile is a curve, so this parameter
@@ -192,7 +209,8 @@ def createProfile(roiInfo, currentData, origin, scale, lineWidth):
profile, area = _alignedFullProfile(currentData3D,
origin, scale,
roiStart[1], roiWidth,
- axis=0)
+ axis=0,
+ method=method)
yMin, yMax = min(area[1]), max(area[1]) - 1
if roiWidth <= 1:
@@ -205,7 +223,8 @@ def createProfile(roiInfo, currentData, origin, scale, lineWidth):
profile, area = _alignedFullProfile(currentData3D,
origin, scale,
roiStart[0], roiWidth,
- axis=1)
+ axis=1,
+ method=method)
xMin, xMax = min(area[0]), max(area[0]) - 1
if roiWidth <= 1:
@@ -240,7 +259,8 @@ def createProfile(roiInfo, currentData, origin, scale, lineWidth):
colRange = startPt[1], endPt[1] + 1
profile = _alignedPartialProfile(currentData3D,
rowRange, colRange,
- axis=0)
+ axis=0,
+ method=method)
else: # Column aligned
rowRange = startPt[0], endPt[0] + 1
@@ -248,7 +268,8 @@ def createProfile(roiInfo, currentData, origin, scale, lineWidth):
int(startPt[1] + 0.5 + 0.5 * roiWidth))
profile = _alignedPartialProfile(currentData3D,
rowRange, colRange,
- axis=1)
+ axis=1,
+ method=method)
# Convert ranges to plot coords to draw ROI area
area = (
@@ -273,7 +294,8 @@ def createProfile(roiInfo, currentData, origin, scale, lineWidth):
profile.append(bilinear.profile_line(
(startPt[0] - 0.5, startPt[1] - 0.5),
(endPt[0] - 0.5, endPt[1] - 0.5),
- roiWidth))
+ roiWidth,
+ method=method))
profile = numpy.array(profile)
# Extend ROI with half a pixel on each end, and
@@ -346,6 +368,8 @@ class ProfileToolBar(qt.QToolBar):
_POLYGON_LEGEND = '__ProfileToolBar_ROI_Polygon'
+ DEFAULT_PROF_METHOD = 'mean'
+
def __init__(self, parent=None, plot=None, profileWindow=None,
title='Profile Selection'):
super(ProfileToolBar, self).__init__(title, parent)
@@ -354,6 +378,7 @@ class ProfileToolBar(qt.QToolBar):
self._overlayColor = None
self._defaultOverlayColor = 'red' # update when active image change
+ self._method = self.DEFAULT_PROF_METHOD
self._roiInfo = None # Store start and end points and type of ROI
@@ -426,12 +451,17 @@ class ProfileToolBar(qt.QToolBar):
# Add width spin box to toolbar
self.addWidget(qt.QLabel('W:'))
self.lineWidthSpinBox = qt.QSpinBox(self)
- self.lineWidthSpinBox.setRange(0, 1000)
+ self.lineWidthSpinBox.setRange(1, 1000)
self.lineWidthSpinBox.setValue(1)
self.lineWidthSpinBox.valueChanged[int].connect(
self._lineWidthSpinBoxValueChangedSlot)
self.addWidget(self.lineWidthSpinBox)
+ self.methodsButton = ProfileOptionToolButton(parent=self, plot=self)
+ self.addWidget(self.methodsButton)
+ # TODO: add connection with the signal
+ self.methodsButton.sigMethodChanged.connect(self.setProfileMethod)
+
self.plot.sigInteractiveModeChanged.connect(
self._interactiveModeChanged)
@@ -602,9 +632,10 @@ class ProfileToolBar(qt.QToolBar):
origin=image.getOrigin(),
scale=image.getScale(),
colormap=None, # Not used for 2D data
- z=image.getZValue())
+ z=image.getZValue(),
+ method=self.getProfileMethod())
- def _createProfile(self, currentData, origin, scale, colormap, z):
+ def _createProfile(self, currentData, origin, scale, colormap, z, method):
"""Create the profile line for the the given image.
:param numpy.ndarray currentData: the image or the stack of images
@@ -624,7 +655,8 @@ class ProfileToolBar(qt.QToolBar):
currentData=currentData,
origin=origin,
scale=scale,
- lineWidth=self.lineWidthSpinBox.value())
+ lineWidth=self.lineWidthSpinBox.value(),
+ method=method)
self.getProfilePlot().setGraphTitle(profileName)
@@ -692,6 +724,14 @@ class ProfileToolBar(qt.QToolBar):
if self.getProfileMainWindow() is not None:
self.getProfileMainWindow().hide()
+ def setProfileMethod(self, method):
+ assert method in ('sum', 'mean')
+ self._method = method
+ self.updateProfile()
+
+ def getProfileMethod(self):
+ return self._method
+
class Profile3DToolBar(ProfileToolBar):
def __init__(self, parent=None, stackview=None,
@@ -720,6 +760,7 @@ class Profile3DToolBar(ProfileToolBar):
# create the 3D toolbar
self._profileType = None
self._setProfileType(2)
+ self._method3D = 'sum'
def _setProfileType(self, dimensions):
"""Set the profile type: "1D" for a curve (profile on a single image)
@@ -750,12 +791,20 @@ class Profile3DToolBar(ProfileToolBar):
self.getProfilePlot().setGraphTitle('')
self.getProfilePlot().getXAxis().setLabel('X')
self.getProfilePlot().getYAxis().setLabel('Y')
-
self._createProfile(currentData=stackData[0],
origin=stackData[1]['origin'],
scale=stackData[1]['scale'],
colormap=stackData[1]['colormap'],
- z=stackData[1]['z'])
+ z=stackData[1]['z'],
+ method=self.getProfileMethod())
else:
raise ValueError(
"Profile type must be 1D or 2D, not %s" % self._profileType)
+
+ def setProfileMethod(self, method):
+ assert method in ('sum', 'mean')
+ self._method3D = method
+ self.updateProfile()
+
+ def getProfileMethod(self):
+ return self._method3D
diff --git a/silx/gui/plot/ProfileMainWindow.py b/silx/gui/plot/ProfileMainWindow.py
index 3738511..caa076c 100644
--- a/silx/gui/plot/ProfileMainWindow.py
+++ b/silx/gui/plot/ProfileMainWindow.py
@@ -47,6 +47,10 @@ class ProfileMainWindow(qt.QMainWindow):
"""Emitted by :meth:`closeEvent` (e.g. when the window is closed
through the window manager's close icon)."""
+ sigProfileMethodChanged = qt.Signal(str)
+ """Emitted when the method to compute the profile changed (for now can be
+ sum or mean)"""
+
def __init__(self, parent=None):
qt.QMainWindow.__init__(self, parent=parent)
@@ -57,6 +61,7 @@ class ProfileMainWindow(qt.QMainWindow):
# by default, profile is assumed to be a 1D curve
self._profileType = None
self.setProfileType("1D")
+ self.setProfileMethod('sum')
def setProfileType(self, profileType):
"""Set which profile plot widget (1D or 2D) is to be used
@@ -67,7 +72,6 @@ class ProfileMainWindow(qt.QMainWindow):
# import here to avoid circular import
from .PlotWindow import Plot1D, Plot2D # noqa
self._profileType = profileType
-
if self._profileType == "1D":
if self._plot2D is not None:
self._plot2D.setParent(None) # necessary to avoid widget destruction
@@ -99,3 +103,13 @@ class ProfileMainWindow(qt.QMainWindow):
def closeEvent(self, qCloseEvent):
self.sigClose.emit()
qCloseEvent.accept()
+
+ def setProfileMethod(self, method):
+ """
+
+ :param str method: method to manage the 'width' in the profile
+ (computing mean or sum).
+ """
+ assert method in ('sum', 'mean')
+ self._method = method
+ self.sigProfileMethodChanged.emit(self._method)
diff --git a/silx/gui/plot/ScatterMaskToolsWidget.py b/silx/gui/plot/ScatterMaskToolsWidget.py
index 2a10f6d..de645be 100644
--- a/silx/gui/plot/ScatterMaskToolsWidget.py
+++ b/silx/gui/plot/ScatterMaskToolsWidget.py
@@ -207,6 +207,13 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
The mask can be cropped or padded to fit active scatter,
the returned shape is that of the scatter data.
"""
+ if self._data_scatter is None:
+ # this can happen if the mask tools widget has never been shown
+ self._data_scatter = self.plot._getActiveItem(kind="scatter")
+ if self._data_scatter is None:
+ return None
+ self._adjustColorAndBrushSize(self._data_scatter)
+
if mask is None:
self.resetSelectionMask()
return self._data_scatter.getXData(copy=False).shape
@@ -261,6 +268,26 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
self.plot.sigActiveScatterChanged.connect(
self._activeScatterChangedAfterCare)
+ def _adjustColorAndBrushSize(self, activeScatter):
+ colormap = activeScatter.getColormap()
+ self._defaultOverlayColor = rgba(cursorColorForColormap(colormap['name']))
+ self._setMaskColors(self.levelSpinBox.value(),
+ self.transparencySlider.value() /
+ self.transparencySlider.maximum())
+ self._z = activeScatter.getZValue() + 1
+ self._data_scatter = activeScatter
+
+ # Adjust brush size to data range
+ xData = self._data_scatter.getXData(copy=False)
+ yData = self._data_scatter.getYData(copy=False)
+ # Adjust brush size to data range
+ if xData.size > 0 and yData.size > 0:
+ xMin, xMax = min_max(xData)
+ yMin, yMax = min_max(yData)
+ self._data_extent = max(xMax - xMin, yMax - yMin)
+ else:
+ self._data_extent = None
+
def _activeScatterChangedAfterCare(self, previous, next):
"""Check synchro of active scatter and mask when mask widget is hidden.
@@ -278,19 +305,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
self._data_scatter = None
else:
- colormap = activeScatter.getColormap()
- self._defaultOverlayColor = rgba(cursorColorForColormap(colormap['name']))
- self._setMaskColors(self.levelSpinBox.value(),
- self.transparencySlider.value() /
- self.transparencySlider.maximum())
-
- self._z = activeScatter.getZValue() + 1
- self._data_scatter = activeScatter
-
- # Adjust brush size to data range
- xMin, xMax = min_max(self._data_scatter.getXData(copy=False))
- yMin, yMax = min_max(self._data_scatter.getYData(copy=False))
- self._data_extent = max(xMax - xMin, yMax - yMin)
+ self._adjustColorAndBrushSize(activeScatter)
if self._data_scatter.getXData(copy=False).shape != self._mask.getMask(copy=False).shape:
# scatter has not the same size, remove mask and stop listening
@@ -322,25 +337,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
else: # There is an active scatter
self.setEnabled(True)
-
- colormap = activeScatter.getColormap()
- self._defaultOverlayColor = rgba(cursorColorForColormap(colormap['name']))
- self._setMaskColors(self.levelSpinBox.value(),
- self.transparencySlider.value() /
- self.transparencySlider.maximum())
-
- self._z = activeScatter.getZValue() + 1
- self._data_scatter = activeScatter
-
- # Adjust brush size to data range
- xData = self._data_scatter.getXData(copy=False)
- yData = self._data_scatter.getYData(copy=False)
- if xData.size > 0 and yData.size > 0:
- xMin, xMax = min_max(xData)
- yMin, yMax = min_max(yData)
- self._data_extent = max(xMax - xMin, yMax - yMin)
- else:
- self._data_extent = None
+ self._adjustColorAndBrushSize(activeScatter)
self._mask.setDataItem(self._data_scatter)
if self._data_scatter.getXData(copy=False).shape != self._mask.getMask(copy=False).shape:
diff --git a/silx/gui/plot/ScatterView.py b/silx/gui/plot/ScatterView.py
index f830cb3..ae79cf9 100644
--- a/silx/gui/plot/ScatterView.py
+++ b/silx/gui/plot/ScatterView.py
@@ -268,16 +268,16 @@ class ScatterView(qt.QMainWindow):
self.getPlotWidget().setDefaultColormap(colormap)
def getColormap(self):
- """Return the :class:`.Colormap` in use.
+ """Return the colormap object in use.
:return: Colormap currently in use
:rtype: ~silx.gui.colors.Colormap
"""
- self.getScatterItem().getColormap()
+ return self.getScatterItem().getColormap()
# Control displayed scatter plot
- def setData(self, x, y, value, xerror=None, yerror=None, copy=True):
+ def setData(self, x, y, value, xerror=None, yerror=None, alpha=None, copy=True):
"""Set the data of the scatter plot.
To reset the scatter plot, set x, y and value to None.
@@ -295,6 +295,8 @@ class ScatterView(qt.QMainWindow):
:param yerror: Values with the uncertainties on the y values
:type yerror: A float, or a numpy.ndarray of float32. See xerror.
+ :param alpha: Values with the transparency (between 0 and 1)
+ :type alpha: A float, or a numpy.ndarray of float32
:param bool copy: True make a copy of the data (default),
False to use provided arrays.
"""
@@ -303,7 +305,7 @@ class ScatterView(qt.QMainWindow):
value = () if value is None else value
self.getScatterItem().setData(
- x=x, y=y, value=value, xerror=xerror, yerror=yerror, copy=copy)
+ x=x, y=y, value=value, xerror=xerror, yerror=yerror, alpha=alpha, copy=copy)
def getData(self, *args, **kwargs):
return self.getScatterItem().getData(*args, **kwargs)
diff --git a/silx/gui/plot/StackView.py b/silx/gui/plot/StackView.py
index d1e8e3c..72b6cd4 100644
--- a/silx/gui/plot/StackView.py
+++ b/silx/gui/plot/StackView.py
@@ -69,7 +69,7 @@ Example::
__authors__ = ["P. Knobel", "H. Payno"]
__license__ = "MIT"
-__date__ = "26/04/2018"
+__date__ = "10/10/2018"
import numpy
import logging
@@ -202,6 +202,10 @@ class StackView(qt.QMainWindow):
"""Function returning the plot title based on the frame index.
It can be set to a custom function using :meth:`setTitleCallback`"""
+ self.calibrations3D = (calibration.NoCalibration(),
+ calibration.NoCalibration(),
+ calibration.NoCalibration())
+
central_widget = qt.QWidget(self)
self._plot = PlotWindow(parent=central_widget, backend=backend,
@@ -212,6 +216,7 @@ class StackView(qt.QMainWindow):
copy=copy, save=save, print_=print_,
control=control, position=position,
roi=False, mask=mask)
+ self._plot.getIntensityHistogramAction().setVisible(True)
self.sigInteractiveModeChanged = self._plot.sigInteractiveModeChanged
self.sigActiveImageChanged = self._plot.sigActiveImageChanged
self.sigPlotSignal = self._plot.sigPlotSignal
@@ -229,7 +234,7 @@ class StackView(qt.QMainWindow):
self._plot.sigPlotSignal.connect(self._plotCallback)
self.__planeSelection = PlanesWidget(self._plot)
- self.__planeSelection.sigPlaneSelectionChanged.connect(self.__setPerspective)
+ self.__planeSelection.sigPlaneSelectionChanged.connect(self.setPerspective)
self._browser_label = qt.QLabel("Image index (Dim0):")
@@ -287,12 +292,23 @@ class StackView(qt.QMainWindow):
self.valueChanged.emit(float(x), float(y),
None)
- def __setPerspective(self, perspective):
- """Function called when the browsed/orthogonal dimension changes.
- Updates :attr:`_perspective`, transposes data, updates the plot,
- emits :attr:`sigPlaneSelectionChanged` and :attr:`sigStackChanged`.
+ def getPerspective(self):
+ """Returns the index of the dimension the stack is browsed with
+
+ Possible values are: 0, 1, or 2.
- :param int perspective: the new browsed dimension
+ :rtype: int
+ """
+ return self._perspective
+
+ def setPerspective(self, perspective):
+ """Set the index of the dimension the stack is browsed with:
+
+ - slice plane Dim1-Dim2: perspective 0
+ - slice plane Dim0-Dim2: perspective 1
+ - slice plane Dim0-Dim1: perspective 2
+
+ :param int perspective: Orthogonal dimension number (0, 1, or 2)
"""
if perspective == self._perspective:
return
@@ -301,17 +317,21 @@ class StackView(qt.QMainWindow):
raise ValueError(
"Perspective must be 0, 1 or 2, not %s" % perspective)
- self._perspective = perspective
+ self._perspective = int(perspective)
self.__createTransposedView()
self.__updateFrameNumber(self._browser.value())
self._plot.resetZoom()
self.__updatePlotLabels()
+ self._updateTitle()
self._browser_label.setText("Image index (Dim%d):" %
(self._first_stack_dimension + perspective))
self.sigPlaneSelectionChanged.emit(perspective)
self.sigStackChanged.emit(self._stack.size if
self._stack is not None else 0)
+ self.__planeSelection.sigPlaneSelectionChanged.disconnect(self.setPerspective)
+ self.__planeSelection.setPerspective(self._perspective)
+ self.__planeSelection.sigPlaneSelectionChanged.connect(self.setPerspective)
def __updatePlotLabels(self):
"""Update plot axes labels depending on perspective"""
@@ -391,39 +411,47 @@ class StackView(qt.QMainWindow):
i)
self.calibrations3D.append(calib)
- def _getXYZCalibs(self):
- """Return calibrations sorted in the XYZ graph order.
+ def getCalibrations(self, order='array'):
+ """Returns currently used calibrations for each axis
- If the X or Y calibration is not linear, it will be replaced
- with a :class:`calibration.NoCalibration` object
- and as a result the corresponding axis will not be scaled."""
- xy_dims = [0, 1, 2]
- xy_dims.remove(self._perspective)
+ Returned calibrations might differ from the ones that were set as
+ non-linear calibrations used for image axes are temporarily ignored.
- xcalib = self.calibrations3D[max(xy_dims)]
- ycalib = self.calibrations3D[min(xy_dims)]
- zcalib = self.calibrations3D[self._perspective]
+ :param str order:
+ 'array' to sort calibrations as data array (dim0, dim1, dim2),
+ 'axes' to sort calibrations as currently selected x, y and z axes.
+ :return: Calibrations ordered depending on order
+ :rtype: List[~silx.math.calibration.AbstractCalibration]
+ """
+ assert order in ('array', 'axes')
+ calibs = []
# filter out non-linear calibration for graph axes
- if not xcalib.is_affine():
- xcalib = calibration.NoCalibration()
- if not ycalib.is_affine():
- ycalib = calibration.NoCalibration()
+ for index, calib in enumerate(self.calibrations3D):
+ if index != self._perspective and not calib.is_affine():
+ calib = calibration.NoCalibration()
+ calibs.append(calib)
+
+ if order == 'axes': # Move 'z' axis to the end
+ xy_dims = [d for d in (0, 1, 2) if d != self._perspective]
+ calibs = [calibs[max(xy_dims)],
+ calibs[min(xy_dims)],
+ calibs[self._perspective]]
- return xcalib, ycalib, zcalib
+ return tuple(calibs)
def _getImageScale(self):
"""
:return: 2-tuple (XScale, YScale) for current image view
"""
- xcalib, ycalib, _zcalib = self._getXYZCalibs()
+ xcalib, ycalib, _zcalib = self.getCalibrations(order='axes')
return xcalib.get_slope(), ycalib.get_slope()
def _getImageOrigin(self):
"""
:return: 2-tuple (XOrigin, YOrigin) for current image view
"""
- xcalib, ycalib, _zcalib = self._getXYZCalibs()
+ xcalib, ycalib, _zcalib = self.getCalibrations(order='axes')
return xcalib(0), ycalib(0)
def _getImageZ(self, index):
@@ -431,7 +459,7 @@ class StackView(qt.QMainWindow):
:param idx: 0-based image index in the stack
:return: calibrated Z value corresponding to the image idx
"""
- _xcalib, _ycalib, zcalib = self._getXYZCalibs()
+ _xcalib, _ycalib, zcalib = self.getCalibrations(order='axes')
return zcalib(index)
def _updateTitle(self):
@@ -442,7 +470,7 @@ class StackView(qt.QMainWindow):
return "Image z=%g" % self._getImageZ(index)
# public API, stack specific methods
- def setStack(self, stack, perspective=0, reset=True, calibrations=None):
+ def setStack(self, stack, perspective=None, reset=True, calibrations=None):
"""Set the 3D stack.
The perspective parameter is used to define which dimension of the 3D
@@ -454,8 +482,7 @@ class StackView(qt.QMainWindow):
:type stack: 3D numpy.ndarray, or 3D h5py.Dataset, or list/tuple of 2D
numpy arrays, or None.
:param int perspective: Dimension for the frame index: 0, 1 or 2.
- By default, the dimension for the image index is the first
- dimension of the 3D stack (``perspective=0``).
+ Use ``None`` to keep the current perspective (default).
:param bool reset: Whether to reset zoom or not.
:param calibrations: Sequence of 3 calibration objects for each axis.
These objects can be a subclass of :class:`AbstractCalibration`,
@@ -488,8 +515,10 @@ class StackView(qt.QMainWindow):
self._stack = stack
self.__createTransposedView()
- if perspective != self._perspective:
- self.__setPerspective(perspective)
+ perspective_changed = False
+ if perspective not in [None, self._perspective]:
+ perspective_changed = True
+ self.setPerspective(perspective)
# This call to setColormap redefines the meaning of autoscale
# for 3D volume: take global min/max rather than frame min/max
@@ -505,8 +534,8 @@ class StackView(qt.QMainWindow):
replace=True,
resetzoom=False)
self._plot.setActiveImage(self.__imageLegend)
- self._plot.setGraphTitle("Image z=%g" % self._getImageZ(0))
self.__updatePlotLabels()
+ self._updateTitle()
if reset:
self._plot.resetZoom()
@@ -514,12 +543,7 @@ class StackView(qt.QMainWindow):
# enable and init browser
self._browser.setEnabled(True)
- if perspective != self._perspective:
- self.__planeSelection.setPerspective(perspective)
- # this causes self.__setPerspective to be called, which emits
- # sigStackChanged and sigPlaneSelectionChanged
-
- else:
+ if not perspective_changed: # avoid double signal (see self.setPerspective)
self.sigStackChanged.emit(stack.size)
def getStack(self, copy=True, returnNumpyArray=False):
diff --git a/silx/gui/plot/StatsWidget.py b/silx/gui/plot/StatsWidget.py
index a36dd9f..bb66613 100644
--- a/silx/gui/plot/StatsWidget.py
+++ b/silx/gui/plot/StatsWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# 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
@@ -28,7 +28,7 @@ Module containing widgets displaying stats from items of a plot.
__authors__ = ["H. Payno"]
__license__ = "MIT"
-__date__ = "12/06/2018"
+__date__ = "24/07/2018"
import functools
@@ -63,6 +63,8 @@ class StatsWidget(qt.QWidget):
:param plot: the plot containing items on which we want statistics.
"""
+ sigVisibilityChanged = qt.Signal(bool)
+
NUMBER_FORMAT = '{0:.3f}'
class OptionsWidget(qt.QToolBar):
@@ -151,6 +153,14 @@ class StatsWidget(qt.QWidget):
self.setDisplayOnlyActiveItem = self._statsTable.setDisplayOnlyActiveItem
self.setStatsOnVisibleData = self._statsTable.setStatsOnVisibleData
+ def showEvent(self, event):
+ self.sigVisibilityChanged.emit(True)
+ qt.QWidget.showEvent(self, event)
+
+ def hideEvent(self, event):
+ self.sigVisibilityChanged.emit(False)
+ qt.QWidget.hideEvent(self, event)
+
def _optSelectionChanged(self, action=None):
self._statsTable.setDisplayOnlyActiveItem(self._options.isActiveItemMode())
@@ -366,7 +376,7 @@ class StatsTable(TableWidget):
self.setRowCount(0)
# It have to called befor3e accessing to the header items
- self.setHorizontalHeaderLabels(self._columns)
+ self.setHorizontalHeaderLabels(list(self._columns))
if self._statsHandler is not None:
for columnId, name in enumerate(self._columns):
@@ -539,7 +549,7 @@ class StatsTable(TableWidget):
self._statsOnVisibleData = b
self._updateCurrentStats()
- def _activeItemChanged(self, kind):
+ def _activeItemChanged(self, kind, previous, current):
"""Callback used when plotting only the active item"""
assert kind in ('curve', 'image', 'scatter', 'histogram')
self._updateItemObserve()
diff --git a/silx/gui/plot/_BaseMaskToolsWidget.py b/silx/gui/plot/_BaseMaskToolsWidget.py
index da0dbf5..e087354 100644
--- a/silx/gui/plot/_BaseMaskToolsWidget.py
+++ b/silx/gui/plot/_BaseMaskToolsWidget.py
@@ -29,7 +29,7 @@ from __future__ import division
__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "24/04/2018"
+__date__ = "29/08/2018"
import os
import weakref
@@ -596,6 +596,10 @@ class BaseMaskToolsWidget(qt.QWidget):
maskGroup.setLayout(layout)
return maskGroup
+ def isMaskInteractionActivated(self):
+ """Returns true if any mask interaction is activated"""
+ return self.drawActionGroup.checkedAction() is not None
+
def _initDrawGroupBox(self):
"""Init drawing tools widgets"""
layout = qt.QVBoxLayout()
diff --git a/silx/gui/plot/_utils/test/testColormap.py b/silx/gui/plot/_utils/test/testColormap.py
deleted file mode 100644
index d77fa65..0000000
--- a/silx/gui/plot/_utils/test/testColormap.py
+++ /dev/null
@@ -1,648 +0,0 @@
-# coding: utf-8
-# /*##########################################################################
-#
-# Copyright (c) 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.
-#
-# ###########################################################################*/
-
-import logging
-import time
-import unittest
-
-import numpy
-from PyMca5 import spslut
-
-from silx.image.colormap import dataToRGBAColormap
-
-_logger = logging.getLogger(__name__)
-
-# TODOs:
-# what to do with max < min: as SPS LUT or also invert outside boundaries?
-# test usedMin and usedMax
-# benchmark
-
-
-# common ######################################################################
-
-class _TestColormap(unittest.TestCase):
- # Array data types to test
- FLOATING_DTYPES = numpy.float16, numpy.float32, numpy.float64
- SIGNED_DTYPES = FLOATING_DTYPES + (numpy.int8, numpy.int16,
- numpy.int32, numpy.int64)
- UNSIGNED_DTYPES = numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64
- DTYPES = SIGNED_DTYPES + UNSIGNED_DTYPES
-
- # Array sizes to test
- SIZES = 2, 10, 256, 1024 # , 2048, 4096
-
- # Colormaps definitions
- _LUT_RED_256 = numpy.zeros((256, 4), dtype=numpy.uint8)
- _LUT_RED_256[:, 0] = numpy.arange(256, dtype=numpy.uint8)
- _LUT_RED_256[:, 3] = 255
-
- _LUT_RGB_3 = numpy.array(((255, 0, 0, 255),
- (0, 255, 0, 255),
- (0, 0, 255, 255)), dtype=numpy.uint8)
-
- _LUT_RGB_768 = numpy.zeros((768, 4), dtype=numpy.uint8)
- _LUT_RGB_768[0:256, 0] = numpy.arange(256, dtype=numpy.uint8)
- _LUT_RGB_768[256:512, 1] = numpy.arange(256, dtype=numpy.uint8)
- _LUT_RGB_768[512:768, 1] = numpy.arange(256, dtype=numpy.uint8)
- _LUT_RGB_768[:, 3] = 255
-
- COLORMAPS = {
- 'red 256': _LUT_RED_256,
- 'rgb 3': _LUT_RGB_3,
- 'rgb 768': _LUT_RGB_768,
- }
-
- @staticmethod
- def _log(*args):
- """Logging used by test for debugging."""
- _logger.debug(str(args))
-
- @staticmethod
- def buildControlPixmap(data, colormap, start=None, end=None,
- isLog10=False):
- """Generate a pixmap used to test C pixmap."""
- if isLog10: # Convert to log
- if start is None:
- posValue = data[numpy.nonzero(data > 0)]
- if posValue.size != 0:
- start = numpy.nanmin(posValue)
- else:
- start = 0.
-
- if end is None:
- end = numpy.nanmax(data)
-
- start = 0. if start <= 0. else numpy.log10(start,
- dtype=numpy.float64)
- end = 0. if end <= 0. else numpy.log10(end,
- dtype=numpy.float64)
-
- data = numpy.log10(data, dtype=numpy.float64)
- else:
- if start is None:
- start = numpy.nanmin(data)
- if end is None:
- end = numpy.nanmax(data)
-
- start, end = float(start), float(end)
- min_, max_ = min(start, end), max(start, end)
-
- if start == end:
- indices = numpy.asarray((len(colormap) - 1) * (data >= max_),
- dtype=numpy.int)
- else:
- clipData = numpy.clip(data, min_, max_) # Clip first avoid overflow
- scale = len(colormap) / (end - start)
- normData = scale * (numpy.asarray(clipData, numpy.float64) - start)
-
- # Clip again to makes sure <= len(colormap) - 1
- indices = numpy.asarray(numpy.clip(normData,
- 0, len(colormap) - 1),
- dtype=numpy.uint32)
-
- pixmap = numpy.take(colormap, indices, axis=0)
- pixmap.shape = data.shape + (4,)
- return numpy.ascontiguousarray(pixmap)
-
- @staticmethod
- def buildSPSLUTRedPixmap(data, start=None, end=None, isLog10=False):
- """Generate a pixmap with SPS LUT.
- Only supports red colormap with 256 colors.
- """
- colormap = spslut.RED
- mapping = spslut.LOG if isLog10 else spslut.LINEAR
-
- if start is None and end is None:
- autoScale = 1
- start, end = 0, 1
- else:
- autoScale = 0
- if start is None:
- start = data.min()
- if end is None:
- end = data.max()
-
- pixmap, size, minMax = spslut.transform(data,
- (1, 0),
- (mapping, 3.0),
- 'RGBX',
- colormap,
- autoScale,
- (start, end),
- (0, 255),
- 1)
- pixmap.shape = data.shape[0], data.shape[1], 4
-
- return pixmap
-
- def _testColormap(self, data, colormap, start, end, control=None,
- isLog10=False, nanColor=None):
- """Test pixmap built with C code against SPS LUT if possible,
- else against Python control code."""
- startTime = time.time()
- pixmap = dataToRGBAColormap(data,
- colormap,
- start,
- end,
- isLog10,
- nanColor)
- duration = time.time() - startTime
-
- # Compare with result
- controlType = 'array'
- if control is None:
- startTime = time.time()
-
- # Compare with SPS LUT if possible
- if (colormap.shape == self.COLORMAPS['red 256'].shape and
- numpy.all(numpy.equal(colormap, self.COLORMAPS['red 256'])) and
- data.size % 2 == 0 and
- data.dtype in (numpy.float32, numpy.float64)):
- # Only works with red colormap and even size
- # as it needs 2D data
- if len(data.shape) == 1:
- data.shape = data.size // 2, -1
- pixmap.shape = data.shape + (4,)
- control = self.buildSPSLUTRedPixmap(data, start, end, isLog10)
- controlType = 'SPS LUT'
-
- # Compare with python test implementation
- else:
- control = self.buildControlPixmap(data, colormap, start, end,
- isLog10)
- controlType = 'Python control code'
-
- controlDuration = time.time() - startTime
- if duration >= controlDuration:
- self._log('duration', duration, 'control', controlDuration)
- # Allows duration to be 20% over SPS LUT duration
- # self.assertTrue(duration < 1.2 * controlDuration)
-
- difference = numpy.fabs(numpy.asarray(pixmap, dtype=numpy.float64) -
- numpy.asarray(control, dtype=numpy.float64))
- if numpy.any(difference != 0.0):
- self._log('control', controlType)
- self._log('data', data)
- self._log('pixmap', pixmap)
- self._log('control', control)
- self._log('errors', numpy.ravel(difference))
- self._log('errors', difference[difference != 0])
- self._log('in pixmap', pixmap[difference != 0])
- self._log('in control', control[difference != 0])
- self._log('Max error', difference.max())
-
- # Allows a difference of 1 per channel
- self.assertTrue(numpy.all(difference <= 1.0))
-
- return duration
-
-
-# TestColormap ################################################################
-
-class TestColormap(_TestColormap):
- """Test common limit case for colormap in C with both linear and log mode.
-
- Test with different: data types, sizes, colormaps (with different sizes),
- mapping range.
- """
-
- def testNoData(self):
- """Test pixmap generation with empty data."""
- self._log("TestColormap.testNoData")
- cmapName = 'red 256'
- colormap = self.COLORMAPS[cmapName]
-
- for dtype in self.DTYPES:
- for isLog10 in (False, True):
- data = numpy.array((), dtype=dtype)
- result = numpy.array((), dtype=numpy.uint8)
- result.shape = 0, 4
- duration = self._testColormap(data, colormap,
- None, None, result, isLog10)
- self._log('No data', 'red 256', dtype, len(data), (None, None),
- 'isLog10:', isLog10, duration)
-
- def testNaN(self):
- """Test pixmap generation with NaN values and no NaN color."""
- self._log("TestColormap.testNaN")
- cmapName = 'red 256'
- colormap = self.COLORMAPS[cmapName]
-
- for dtype in self.FLOATING_DTYPES:
- for isLog10 in (False, True):
- # All NaNs
- data = numpy.array((float('nan'),) * 4, dtype=dtype)
- result = numpy.array(((0, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, colormap,
- None, None, result, isLog10)
- self._log('All NaNs', 'red 256', dtype, len(data),
- (None, None), 'isLog10:', isLog10, duration)
-
- # Some NaNs
- data = numpy.array((1., float('nan'), 0., float('nan')),
- dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, colormap,
- None, None, result, isLog10)
- self._log('Some NaNs', 'red 256', dtype, len(data),
- (None, None), 'isLog10:', isLog10, duration)
-
- def testNaNWithColor(self):
- """Test pixmap generation with NaN values with a NaN color."""
- self._log("TestColormap.testNaNWithColor")
- cmapName = 'red 256'
- colormap = self.COLORMAPS[cmapName]
-
- for dtype in self.FLOATING_DTYPES:
- for isLog10 in (False, True):
- # All NaNs
- data = numpy.array((float('nan'),) * 4, dtype=dtype)
- result = numpy.array(((128, 128, 128, 255),
- (128, 128, 128, 255),
- (128, 128, 128, 255),
- (128, 128, 128, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, colormap,
- None, None, result, isLog10,
- nanColor=(128, 128, 128, 255))
- self._log('All NaNs', 'red 256', dtype, len(data),
- (None, None), 'isLog10:', isLog10, duration)
-
- # Some NaNs
- data = numpy.array((1., float('nan'), 0., float('nan')),
- dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (128, 128, 128, 255),
- (0, 0, 0, 255),
- (128, 128, 128, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, colormap,
- None, None, result, isLog10,
- nanColor=(128, 128, 128, 255))
- self._log('Some NaNs', 'red 256', dtype, len(data),
- (None, None), 'isLog10:', isLog10, duration)
-
-
-# TestLinearColormap ##########################################################
-
-class TestLinearColormap(_TestColormap):
- """Test fill pixmap with colormap in C with linear mode.
-
- Test with different: data types, sizes, colormaps (with different sizes),
- mapping range.
- """
-
- # Colormap ranges to map
- RANGES = (None, None), (1, 10)
-
- def test1DData(self):
- """Test pixmap generation for 1D data of different size and types."""
- self._log("TestLinearColormap.test1DData")
- for cmapName, colormap in self.COLORMAPS.items():
- for size in self.SIZES:
- for dtype in self.DTYPES:
- for start, end in self.RANGES:
- # Increasing values
- data = numpy.arange(size, dtype=dtype)
- duration = self._testColormap(data, colormap,
- start, end)
-
- self._log('1D', cmapName, dtype, size, (start, end),
- duration)
-
- # Reverse order
- data = data[::-1]
- duration = self._testColormap(data, colormap,
- start, end)
-
- self._log('1D', cmapName, dtype, size, (start, end),
- duration)
-
- def test2DData(self):
- """Test pixmap generation for 2D data of different size and types."""
- self._log("TestLinearColormap.test2DData")
- for cmapName, colormap in self.COLORMAPS.items():
- for size in self.SIZES:
- for dtype in self.DTYPES:
- for start, end in self.RANGES:
- # Increasing values
- data = numpy.arange(size * size, dtype=dtype)
- data = numpy.nan_to_num(data)
- data.shape = size, size
- duration = self._testColormap(data, colormap,
- start, end)
-
- self._log('2D', cmapName, dtype, size, (start, end),
- duration)
-
- # Reverse order
- data = data[::-1, ::-1]
- duration = self._testColormap(data, colormap,
- start, end)
-
- self._log('2D', cmapName, dtype, size, (start, end),
- duration)
-
- def testInf(self):
- """Test pixmap generation with Inf values."""
- self._log("TestLinearColormap.testInf")
-
- for dtype in self.FLOATING_DTYPES:
- # All positive Inf
- data = numpy.array((float('inf'),) * 4, dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (255, 0, 0, 255),
- (255, 0, 0, 255),
- (255, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, self.COLORMAPS['red 256'],
- None, None, result)
- self._log('All +Inf', 'red 256', dtype, len(data), (None, None),
- duration)
-
- # All negative Inf
- data = numpy.array((float('-inf'),) * 4, dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (255, 0, 0, 255),
- (255, 0, 0, 255),
- (255, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, self.COLORMAPS['red 256'],
- None, None, result)
- self._log('All -Inf', 'red 256', dtype, len(data), (None, None),
- duration)
-
- # All +/-Inf
- data = numpy.array((float('inf'), float('-inf'),
- float('-inf'), float('inf')), dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255),
- (255, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, self.COLORMAPS['red 256'],
- None, None, result)
- self._log('All +/-Inf', 'red 256', dtype, len(data), (None, None),
- duration)
-
- # Some +/-Inf
- data = numpy.array((float('inf'), 0., float('-inf'), -10.),
- dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, self.COLORMAPS['red 256'],
- None, None,
- result) # Seg Fault with SPS
- self._log('Some +/-Inf', 'red 256', dtype, len(data), (None, None),
- duration)
-
- @unittest.skip("Not for reproductible tests")
- def test1DDataRandom(self):
- """Test pixmap generation for 1D data of different size and types."""
- self._log("TestLinearColormap.test1DDataRandom")
- for cmapName, colormap in self.COLORMAPS.items():
- for size in self.SIZES:
- for dtype in self.DTYPES:
- for start, end in self.RANGES:
- try:
- dtypeMax = numpy.iinfo(dtype).max
- except ValueError:
- dtypeMax = numpy.finfo(dtype).max
- data = numpy.asarray(numpy.random.rand(size) * dtypeMax,
- dtype=dtype)
- duration = self._testColormap(data, colormap,
- start, end)
-
- self._log('1D Random', cmapName, dtype, size,
- (start, end), duration)
-
-
-# TestLog10Colormap ###########################################################
-
-class TestLog10Colormap(_TestColormap):
- """Test fill pixmap with colormap in C with log mode.
-
- Test with different: data types, sizes, colormaps (with different sizes),
- mapping range.
- """
- # Colormap ranges to map
- RANGES = (None, None), (1, 10) # , (10, 1)
-
- def test1DDataAllPositive(self):
- """Test pixmap generation for all positive 1D data."""
- self._log("TestLog10Colormap.test1DDataAllPositive")
- for cmapName, colormap in self.COLORMAPS.items():
- for size in self.SIZES:
- for dtype in self.DTYPES:
- for start, end in self.RANGES:
- # Increasing values
- data = numpy.arange(size, dtype=dtype) + 1
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('1D', cmapName, dtype, size, (start, end),
- duration)
-
- # Reverse order
- data = data[::-1]
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('1D', cmapName, dtype, size, (start, end),
- duration)
-
- def test2DDataAllPositive(self):
- """Test pixmap generation for all positive 2D data."""
- self._log("TestLog10Colormap.test2DDataAllPositive")
- for cmapName, colormap in self.COLORMAPS.items():
- for size in self.SIZES:
- for dtype in self.DTYPES:
- for start, end in self.RANGES:
- # Increasing values
- data = numpy.arange(size * size, dtype=dtype) + 1
- data = numpy.nan_to_num(data)
- data.shape = size, size
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('2D', cmapName, dtype, size, (start, end),
- duration)
-
- # Reverse order
- data = data[::-1, ::-1]
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('2D', cmapName, dtype, size, (start, end),
- duration)
-
- def testAllNegative(self):
- """Test pixmap generation for all negative 1D data."""
- self._log("TestLog10Colormap.testAllNegative")
- for cmapName, colormap in self.COLORMAPS.items():
- for size in self.SIZES:
- for dtype in self.SIGNED_DTYPES:
- for start, end in self.RANGES:
- # Increasing values
- data = numpy.arange(-size, 0, dtype=dtype)
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('1D', cmapName, dtype, size, (start, end),
- duration)
-
- # Reverse order
- data = data[::-1]
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('1D', cmapName, dtype, size, (start, end),
- duration)
-
- def testCrossingZero(self):
- """Test pixmap generation for 1D data with negative and zero."""
- self._log("TestLog10Colormap.testCrossingZero")
- for cmapName, colormap in self.COLORMAPS.items():
- for size in self.SIZES:
- for dtype in self.SIGNED_DTYPES:
- for start, end in self.RANGES:
- # Increasing values
- data = numpy.arange(-size/2, size/2 + 1, dtype=dtype)
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('1D', cmapName, dtype, size, (start, end),
- duration)
-
- # Reverse order
- data = data[::-1]
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('1D', cmapName, dtype, size, (start, end),
- duration)
-
- @unittest.skip("Not for reproductible tests")
- def test1DDataRandom(self):
- """Test pixmap generation for 1D data of different size and types."""
- self._log("TestLog10Colormap.test1DDataRandom")
- for cmapName, colormap in self.COLORMAPS.items():
- for size in self.SIZES:
- for dtype in self.DTYPES:
- for start, end in self.RANGES:
- try:
- dtypeMax = numpy.iinfo(dtype).max
- dtypeMin = numpy.iinfo(dtype).min
- except ValueError:
- dtypeMax = numpy.finfo(dtype).max
- dtypeMin = numpy.finfo(dtype).min
- if dtypeMin < 0:
- data = numpy.asarray(-dtypeMax/2. +
- numpy.random.rand(size) * dtypeMax,
- dtype=dtype)
- else:
- data = numpy.asarray(numpy.random.rand(size) * dtypeMax,
- dtype=dtype)
-
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('1D Random', cmapName, dtype, size,
- (start, end), duration)
-
- def testInf(self):
- """Test pixmap generation with Inf values."""
- self._log("TestLog10Colormap.testInf")
-
- for dtype in self.FLOATING_DTYPES:
- # All positive Inf
- data = numpy.array((float('inf'),) * 4, dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (255, 0, 0, 255),
- (255, 0, 0, 255),
- (255, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, self.COLORMAPS['red 256'],
- None, None, result, isLog10=True)
- self._log('All +Inf', 'red 256', dtype, len(data), (None, None),
- duration)
-
- # All negative Inf
- data = numpy.array((float('-inf'),) * 4, dtype=dtype)
- result = numpy.array(((0, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, self.COLORMAPS['red 256'],
- None, None, result, isLog10=True)
- self._log('All -Inf', 'red 256', dtype, len(data), (None, None),
- duration)
-
- # All +/-Inf
- data = numpy.array((float('inf'), float('-inf'),
- float('-inf'), float('inf')), dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255),
- (255, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, self.COLORMAPS['red 256'],
- None, None, result, isLog10=True)
- self._log('All +/-Inf', 'red 256', dtype, len(data), (None, None),
- duration)
-
- # Some +/-Inf
- data = numpy.array((float('inf'), 0., float('-inf'), -10.),
- dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, self.COLORMAPS['red 256'],
- None, None, result, isLog10=True)
- self._log('Some +/-Inf', 'red 256', dtype, len(data), (None, None),
- duration)
-
-
-def suite():
- testSuite = unittest.TestSuite()
- for testClass in (TestColormap, TestLinearColormap): # , TestLog10Colormap):
- testSuite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(testClass))
- return testSuite
-
-
-if __name__ == '__main__':
- unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/actions/PlotToolAction.py b/silx/gui/plot/actions/PlotToolAction.py
new file mode 100644
index 0000000..77e8be2
--- /dev/null
+++ b/silx/gui/plot/actions/PlotToolAction.py
@@ -0,0 +1,150 @@
+# 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:`.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 containg tohe 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/silx/gui/plot/actions/control.py b/silx/gui/plot/actions/control.py
index 6e08f21..10df130 100644
--- a/silx/gui/plot/actions/control.py
+++ b/silx/gui/plot/actions/control.py
@@ -601,3 +601,4 @@ class ShowAxisAction(PlotAction):
def _actionTriggered(self, checked=False):
self.plot.setAxesDisplayed(checked)
+
diff --git a/silx/gui/plot/actions/fit.py b/silx/gui/plot/actions/fit.py
index 5ca649c..cb70733 100644
--- a/silx/gui/plot/actions/fit.py
+++ b/silx/gui/plot/actions/fit.py
@@ -36,9 +36,9 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "03/01/2018"
+__date__ = "10/10/2018"
-from . import PlotAction
+from .PlotToolAction import PlotToolAction
import logging
from silx.gui import qt
from silx.gui.plot.ItemsSelectionDialog import ItemsSelectionDialog
@@ -86,7 +86,7 @@ def _getUniqueHistogram(plt):
return histograms[0]
-class FitAction(PlotAction):
+class FitAction(PlotToolAction):
"""QAction to open a :class:`FitWidget` and set its data to the
active curve if any, or to the first curve.
@@ -97,21 +97,38 @@ class FitAction(PlotAction):
super(FitAction, self).__init__(
plot, icon='math-fit', text='Fit curve',
tooltip='Open a fit dialog',
- triggered=self._getFitWindow,
- checkable=False, parent=parent)
- self.fit_window = None
-
- def _getFitWindow(self):
- self.xlabel = self.plot.getXAxis().getLabel()
- self.ylabel = self.plot.getYAxis().getLabel()
- self.xmin, self.xmax = self.plot.getXAxis().getLimits()
+ parent=parent)
+ self.fit_widget = None
+
+ def _createToolWindow(self):
+ window = qt.QMainWindow(parent=self.plot)
+ # import done here rather than at module level to avoid circular import
+ # FitWidget -> BackgroundWidget -> PlotWindow -> actions -> fit -> FitWidget
+ from ...fit.FitWidget import FitWidget
+ fit_widget = FitWidget(parent=window)
+ window.setCentralWidget(fit_widget)
+ fit_widget.guibuttons.DismissButton.clicked.connect(window.close)
+ fit_widget.sigFitWidgetSignal.connect(self.handle_signal)
+ self.fit_widget = fit_widget
+ return window
+
+ def _connectPlot(self, window):
+ # Wait for the next iteration, else the plot is not yet initialized
+ # No curve available
+ qt.QTimer.singleShot(10, lambda: self._initFit(window))
+
+ def _initFit(self, window):
+ plot = self.plot
+ self.xlabel = plot.getXAxis().getLabel()
+ self.ylabel = plot.getYAxis().getLabel()
+ self.xmin, self.xmax = plot.getXAxis().getLimits()
histo = _getUniqueHistogram(self.plot)
curve = _getUniqueCurve(self.plot)
if histo is None and curve is None:
# ambiguous case, we need to ask which plot item to fit
- isd = ItemsSelectionDialog(parent=self.plot, plot=self.plot)
+ isd = ItemsSelectionDialog(parent=plot, plot=self.plot)
isd.setWindowTitle("Select item to be fitted")
isd.setItemsSelectionMode(qt.QTableWidget.SingleSelection)
isd.setAvailableKinds(["curve", "histogram"])
@@ -141,29 +158,9 @@ class FitAction(PlotAction):
self.x = item.getXData(copy=False)
self.y = item.getYData(copy=False)
- # open a window with a FitWidget
- if self.fit_window is None:
- self.fit_window = qt.QMainWindow()
- # import done here rather than at module level to avoid circular import
- # FitWidget -> BackgroundWidget -> PlotWindow -> actions -> fit -> FitWidget
- from ...fit.FitWidget import FitWidget
- self.fit_widget = FitWidget(parent=self.fit_window)
- self.fit_window.setCentralWidget(
- self.fit_widget)
- self.fit_widget.guibuttons.DismissButton.clicked.connect(
- self.fit_window.close)
- self.fit_widget.sigFitWidgetSignal.connect(
- self.handle_signal)
- self.fit_window.show()
- else:
- if self.fit_window.isHidden():
- self.fit_window.show()
- self.fit_widget.show()
- self.fit_window.raise_()
-
self.fit_widget.setData(self.x, self.y,
xmin=self.xmin, xmax=self.xmax)
- self.fit_window.setWindowTitle(
+ window.setWindowTitle(
"Fitting " + self.legend +
" on x range %f-%f" % (self.xmin, self.xmax))
diff --git a/silx/gui/plot/actions/histogram.py b/silx/gui/plot/actions/histogram.py
index d6e3269..9181f53 100644
--- a/silx/gui/plot/actions/histogram.py
+++ b/silx/gui/plot/actions/histogram.py
@@ -34,10 +34,10 @@ The following QAction are available:
from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
-__date__ = "30/04/2018"
+__date__ = "10/10/2018"
__license__ = "MIT"
-from . import PlotAction
+from .PlotToolAction import PlotToolAction
from silx.math.histogram import Histogramnd
from silx.math.combo import min_max
import numpy
@@ -47,7 +47,7 @@ from silx.gui import qt
_logger = logging.getLogger(__name__)
-class PixelIntensitiesHistoAction(PlotAction):
+class PixelIntensitiesHistoAction(PlotToolAction):
"""QAction to plot the pixels intensities diagram
:param plot: :class:`.PlotWidget` instance on which to operate
@@ -55,43 +55,33 @@ class PixelIntensitiesHistoAction(PlotAction):
"""
def __init__(self, plot, parent=None):
- PlotAction.__init__(self,
- plot,
- icon='pixel-intensities',
- text='pixels intensity',
- tooltip='Compute image intensity distribution',
- triggered=self._triggered,
- parent=parent,
- checkable=True)
- self._plotHistogram = None
+ PlotToolAction.__init__(self,
+ plot,
+ icon='pixel-intensities',
+ text='pixels intensity',
+ tooltip='Compute image intensity distribution',
+ parent=parent)
self._connectedToActiveImage = False
self._histo = None
- def _triggered(self, checked):
- """Update the plot of the histogram visibility status
-
- :param bool checked: status of the action button
- """
- if checked:
- if not self._connectedToActiveImage:
- self.plot.sigActiveImageChanged.connect(
- self._activeImageChanged)
- self._connectedToActiveImage = True
- self.computeIntensityDistribution()
-
- self.getHistogramPlotWidget().show()
-
- else:
- if self._connectedToActiveImage:
- self.plot.sigActiveImageChanged.disconnect(
- self._activeImageChanged)
- self._connectedToActiveImage = False
+ def _connectPlot(self, window):
+ if not self._connectedToActiveImage:
+ self.plot.sigActiveImageChanged.connect(
+ self._activeImageChanged)
+ self._connectedToActiveImage = True
+ self.computeIntensityDistribution()
+ PlotToolAction._connectPlot(self, window)
- self.getHistogramPlotWidget().hide()
+ def _disconnectPlot(self, window):
+ if self._connectedToActiveImage:
+ self.plot.sigActiveImageChanged.disconnect(
+ self._activeImageChanged)
+ self._connectedToActiveImage = False
+ PlotToolAction._disconnectPlot(self, window)
def _activeImageChanged(self, previous, legend):
"""Handle active image change: toggle enabled toolbar, update curve"""
- if self.isChecked():
+ if self._isWindowInUse():
self.computeIntensityDistribution()
def computeIntensityDistribution(self):
@@ -132,35 +122,21 @@ class PixelIntensitiesHistoAction(PlotAction):
color='#66aad7')
plot.resetZoom()
- 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._plotHistogram is not None:
- self._plotHistogram.hide()
- self.setChecked(False)
-
- return PlotAction.eventFilter(self, qobject, event)
-
def getHistogramPlotWidget(self):
"""Create the plot histogram if needed, otherwise create it
:return: the PlotWidget showing the histogram of the pixel intensities
"""
+ return self._getToolWindow()
+
+ def _createToolWindow(self):
from silx.gui.plot.PlotWindow import Plot1D
- if self._plotHistogram is None:
- self._plotHistogram = Plot1D(parent=self.plot)
- self._plotHistogram.setWindowFlags(qt.Qt.Window)
- self._plotHistogram.setWindowTitle('Image Intensity Histogram')
- self._plotHistogram.installEventFilter(self)
- self._plotHistogram.getXAxis().setLabel("Value")
- self._plotHistogram.getYAxis().setLabel("Count")
-
- return self._plotHistogram
+ window = Plot1D(parent=self.plot)
+ window.setWindowFlags(qt.Qt.Window)
+ window.setWindowTitle('Image Intensity Histogram')
+ window.getXAxis().setLabel("Value")
+ window.getYAxis().setLabel("Count")
+ return window
def getHistogram(self):
"""Return the last computed histogram
diff --git a/silx/gui/plot/actions/io.py b/silx/gui/plot/actions/io.py
index ac06942..97de527 100644
--- a/silx/gui/plot/actions/io.py
+++ b/silx/gui/plot/actions/io.py
@@ -37,10 +37,10 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "02/02/2018"
+__date__ = "12/07/2018"
from . import PlotAction
-from silx.io.utils import save1D, savespec
+from silx.io.utils import save1D, savespec, NEXUS_HDF5_EXT
from silx.io.nxdata import save_NXdata
import logging
import sys
@@ -53,7 +53,7 @@ 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
+from ...utils.image import convertArrayToQImage
if sys.version_info[0] == 3:
from io import BytesIO
else:
@@ -62,9 +62,7 @@ else:
_logger = logging.getLogger(__name__)
-
-_NEXUS_HDF5_EXT = [".h5", ".nx5", ".nxs", ".hdf", ".hdf5", ".cxi"]
-_NEXUS_HDF5_EXT_STR = ' '.join(['*' + ext for ext in _NEXUS_HDF5_EXT])
+_NEXUS_HDF5_EXT_STR = ' '.join(['*' + ext for ext in NEXUS_HDF5_EXT])
def selectOutputGroup(h5filename):
@@ -546,7 +544,7 @@ class SaveAction(PlotAction):
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:
+ if len(self.plot.getAllCurves()) >= 1:
filters.update(self._filters['curves'].items())
# Add scatter filters if there is a scatter
diff --git a/silx/gui/plot/actions/medfilt.py b/silx/gui/plot/actions/medfilt.py
index 4284a8b..276f970 100644
--- a/silx/gui/plot/actions/medfilt.py
+++ b/silx/gui/plot/actions/medfilt.py
@@ -39,9 +39,9 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "03/01/2018"
+__date__ = "10/10/2018"
-from . import PlotAction
+from .PlotToolAction import PlotToolAction
from silx.gui.widgets.MedianFilterDialog import MedianFilterDialog
from silx.math.medianfilter import medfilt2d
import logging
@@ -49,7 +49,7 @@ import logging
_logger = logging.getLogger(__name__)
-class MedianFilterAction(PlotAction):
+class MedianFilterAction(PlotToolAction):
"""QAction to plot the pixels intensities diagram
:param plot: :class:`.PlotWidget` instance on which to operate
@@ -57,27 +57,29 @@ class MedianFilterAction(PlotAction):
"""
def __init__(self, plot, parent=None):
- PlotAction.__init__(self,
- plot,
- icon='median-filter',
- text='median filter',
- tooltip='Apply a median filter on the image',
- triggered=self._triggered,
- parent=parent)
+ 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
- self._popup = MedianFilterDialog(parent=plot)
- self._popup.sigFilterOptChanged.connect(self._updateFilter)
+
+ 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 _triggered(self, checked):
- """Update the plot of the histogram visibility status
-
- :param bool checked: status of the action button
- """
- self._popup.show()
+ 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"""
diff --git a/silx/gui/plot/backends/BackendBase.py b/silx/gui/plot/backends/BackendBase.py
index 8352ea0..7fb8be0 100644
--- a/silx/gui/plot/backends/BackendBase.py
+++ b/silx/gui/plot/backends/BackendBase.py
@@ -163,9 +163,8 @@ class BackendBase(object):
:param int z: Layer on which to draw the image
:param bool selectable: indicate if the image can be selected
:param bool draggable: indicate if the image can be moved
- :param colormap: :class:`.Colormap` describing the colormap to use.
- Ignored if data is RGB(A).
- :type colormap: :class:`.Colormap`
+ :param ~silx.gui.colors.Colormap colormap: Colormap object to use.
+ Ignored if data is RGB(A).
:param float alpha: Opacity of the image, as a float in range [0, 1].
:returns: The handle used by the backend to univocally access the image
"""
@@ -189,7 +188,7 @@ class BackendBase(object):
def addMarker(self, x, y, legend, text, color,
selectable, draggable,
- symbol, constraint):
+ symbol, linestyle, linewidth, constraint):
"""Add a point, vertical line or horizontal line marker to the plot.
:param float x: Horizontal position of the marker in graph coordinates.
@@ -212,7 +211,17 @@ class BackendBase(object):
- 'x' x-cross
- 'd' diamond
- 's' square
+ :param str linestyle: Style of the line.
+ Only relevant for line markers where X or Y is None.
+ Value in:
+ - ' ' no line
+ - '-' solid line
+ - '--' dashed line
+ - '-.' dash-dot line
+ - ':' dotted line
+ :param float linewidth: Width of the line.
+ Only relevant for line markers where X or Y is None.
:param constraint: A function filtering marker displacement by
dragging operations or None for no filter.
This function is called each time a marker is
diff --git a/silx/gui/plot/backends/BackendMatplotlib.py b/silx/gui/plot/backends/BackendMatplotlib.py
index 49c4540..3b1d6dd 100644
--- a/silx/gui/plot/backends/BackendMatplotlib.py
+++ b/silx/gui/plot/backends/BackendMatplotlib.py
@@ -28,7 +28,7 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent, H. Payno"]
__license__ = "MIT"
-__date__ = "18/10/2017"
+__date__ = "01/08/2018"
import logging
@@ -56,8 +56,7 @@ from matplotlib.collections import PathCollection, LineCollection
from matplotlib.ticker import Formatter, ScalarFormatter, Locator
-
-from ..matplotlib.ModestImage import ModestImage
+from ....third_party.modest_image import ModestImage
from . import BackendBase
from .._utils import FLOAT32_MINPOS
from .._utils.dtime_ticklayout import calcTicks, bestFormatString, timestamp
@@ -520,7 +519,7 @@ class BackendMatplotlib(BackendBase.BackendBase):
def addMarker(self, x, y, legend, text, color,
selectable, draggable,
- symbol, constraint):
+ symbol, linestyle, linewidth, constraint):
legend = "__MARKER__" + legend
textArtist = None
@@ -548,7 +547,11 @@ class BackendMatplotlib(BackendBase.BackendBase):
verticalalignment=valign)
elif x is not None:
- line = self.ax.axvline(x, label=legend, color=color)
+ line = self.ax.axvline(x,
+ label=legend,
+ color=color,
+ linewidth=linewidth,
+ linestyle=linestyle)
if text is not None:
# Y position will be updated in updateMarkerText call
textArtist = self.ax.text(x, 1., " " + text,
@@ -557,7 +560,11 @@ class BackendMatplotlib(BackendBase.BackendBase):
verticalalignment='top')
elif y is not None:
- line = self.ax.axhline(y, label=legend, color=color)
+ line = self.ax.axhline(y,
+ label=legend,
+ color=color,
+ linewidth=linewidth,
+ linestyle=linestyle)
if text is not None:
# X position will be updated in updateMarkerText call
@@ -1117,7 +1124,6 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
# cursor
_QT_CURSORS = {
- None: qt.Qt.ArrowCursor,
BackendBase.CURSOR_DEFAULT: qt.Qt.ArrowCursor,
BackendBase.CURSOR_POINTING: qt.Qt.PointingHandCursor,
BackendBase.CURSOR_SIZE_HOR: qt.Qt.SizeHorCursor,
@@ -1126,6 +1132,8 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
}
def setGraphCursorShape(self, cursor):
- cursor = self._QT_CURSORS[cursor]
-
- FigureCanvasQTAgg.setCursor(self, qt.QCursor(cursor))
+ if cursor is None:
+ FigureCanvasQTAgg.unsetCursor(self)
+ else:
+ cursor = self._QT_CURSORS[cursor]
+ FigureCanvasQTAgg.setCursor(self, qt.QCursor(cursor))
diff --git a/silx/gui/plot/backends/BackendOpenGL.py b/silx/gui/plot/backends/BackendOpenGL.py
index 0001bb9..9e2cb73 100644
--- a/silx/gui/plot/backends/BackendOpenGL.py
+++ b/silx/gui/plot/backends/BackendOpenGL.py
@@ -28,7 +28,7 @@ from __future__ import division
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "24/04/2018"
+__date__ = "01/08/2018"
from collections import OrderedDict, namedtuple
from ctypes import c_void_p
@@ -1161,11 +1161,15 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
def addMarker(self, x, y, legend, text, color,
selectable, draggable,
- symbol, constraint):
+ symbol, linestyle, linewidth, constraint):
if symbol is None:
symbol = '+'
+ if linestyle != '-' or linewidth != 1:
+ _logger.warning(
+ 'OpenGL backend does not support marker line style and width.')
+
behaviors = set()
if selectable:
behaviors.add('selectable')
@@ -1223,7 +1227,6 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
# Interaction methods
_QT_CURSORS = {
- None: qt.Qt.ArrowCursor,
BackendBase.CURSOR_DEFAULT: qt.Qt.ArrowCursor,
BackendBase.CURSOR_POINTING: qt.Qt.PointingHandCursor,
BackendBase.CURSOR_SIZE_HOR: qt.Qt.SizeHorCursor,
@@ -1232,9 +1235,11 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
}
def setGraphCursorShape(self, cursor):
- cursor = self._QT_CURSORS[cursor]
-
- super(BackendOpenGL, self).setCursor(qt.QCursor(cursor))
+ if cursor is None:
+ super(BackendOpenGL, self).unsetCursor()
+ else:
+ cursor = self._QT_CURSORS[cursor]
+ super(BackendOpenGL, self).setCursor(qt.QCursor(cursor))
def setGraphCursor(self, flag, color, linewidth, linestyle):
if linestyle is not '-':
diff --git a/silx/gui/plot/backends/glutils/GLText.py b/silx/gui/plot/backends/glutils/GLText.py
index 1540e26..3d262bc 100644
--- a/silx/gui/plot/backends/glutils/GLText.py
+++ b/silx/gui/plot/backends/glutils/GLText.py
@@ -32,6 +32,7 @@ __license__ = "MIT"
__date__ = "03/04/2017"
+from collections import OrderedDict
import numpy
from ...._glutils import font, gl, getGLContext, Program, Texture
@@ -41,6 +42,45 @@ from .GLSupport import mat4Translate
# TODO: Font should be configurable by the main program: using mpl.rcParams?
+class _Cache(object):
+ """LRU (Least Recent Used) cache.
+
+ :param int maxsize: Maximum number of (key, value) pairs in the cache
+ :param callable callback:
+ Called when a (key, value) pair is removed from the cache.
+ It must take 2 arguments: key and value.
+ """
+
+ def __init__(self, maxsize=128, callback=None):
+ self._maxsize = int(maxsize)
+ self._callback = callback
+ self._cache = OrderedDict()
+
+ def __contains__(self, item):
+ return item in self._cache
+
+ def __getitem__(self, key):
+ if key in self._cache:
+ # Remove/add key from ordered dict to store last access info
+ value = self._cache.pop(key)
+ self._cache[key] = value
+ return value
+ else:
+ raise KeyError
+
+ def __setitem__(self, key, value):
+ """Add a key, value pair to the cache.
+
+ :param key: The key to set
+ :param value: The corresponding value
+ """
+ if key not in self._cache and len(self._cache) >= self._maxsize:
+ removedKey, removedValue = self._cache.popitem(last=False)
+ if self._callback is not None:
+ self._callback(removedKey, removedValue)
+ self._cache[key] = value
+
+
# Text2D ######################################################################
LEFT, CENTER, RIGHT = 'left', 'center', 'right'
@@ -87,11 +127,11 @@ class Text2D(object):
_SHADERS['fragment'],
attrib0='position')
- _textures = {}
+ # Discard texture objects when removed from the cache
+ _textures = _Cache(callback=lambda key, value: value[0].discard())
"""Cache already created textures"""
- # TODO limit cache size and discard least recent used
- _sizes = {}
+ _sizes = _Cache()
"""Cache already computed sizes"""
def __init__(self, text, x=0, y=0,
diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py
index 4ed0914..e000751 100644
--- a/silx/gui/plot/items/core.py
+++ b/silx/gui/plot/items/core.py
@@ -98,7 +98,10 @@ class ItemChangedType(enum.Enum):
"""Item's highlight state changed flag."""
HIGHLIGHTED_COLOR = 'highlightedColorChanged'
- """Item's highlighted color changed flag."""
+ """Deprecated, use HIGHLIGHTED_STYLE instead."""
+
+ HIGHLIGHTED_STYLE = 'highlightedStyleChanged'
+ """Item's highlighted style changed flag."""
SCALE = 'scaleChanged'
"""Item's scale changed flag."""
@@ -548,12 +551,26 @@ class LineMixIn(ItemMixInBase):
_DEFAULT_LINESTYLE = '-'
"""Default line style"""
+ _SUPPORTED_LINESTYLE = '', ' ', '-', '--', '-.', ':', None
+ """Supported line styles"""
+
def __init__(self):
self._linewidth = self._DEFAULT_LINEWIDTH
self._linestyle = self._DEFAULT_LINESTYLE
+ @classmethod
+ def getSupportedLineStyles(cls):
+ """Returns list of supported line styles.
+
+ :rtype: List[str,None]
+ """
+ return cls._SUPPORTED_LINESTYLE
+
def getLineWidth(self):
- """Return the curve line width in pixels (int)"""
+ """Return the curve line width in pixels
+
+ :rtype: float
+ """
return self._linewidth
def setLineWidth(self, width):
@@ -591,7 +608,7 @@ class LineMixIn(ItemMixInBase):
:param str style: Line style
"""
style = str(style)
- assert style in ('', ' ', '-', '--', '-.', ':', None)
+ assert style in self.getSupportedLineStyles()
if style is None:
style = self._DEFAULT_LINESTYLE
if style != self._linestyle:
diff --git a/silx/gui/plot/items/curve.py b/silx/gui/plot/items/curve.py
index 50ad86d..80d9dea 100644
--- a/silx/gui/plot/items/curve.py
+++ b/silx/gui/plot/items/curve.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# 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
@@ -33,14 +33,123 @@ __date__ = "24/04/2018"
import logging
import numpy
+from silx.third_party import six
+from ....utils.deprecation import deprecated
from ... import colors
from .core import (Points, LabelsMixIn, ColorMixIn, YAxisMixIn,
- FillMixIn, LineMixIn, ItemChangedType)
+ FillMixIn, LineMixIn, SymbolMixIn, ItemChangedType)
_logger = logging.getLogger(__name__)
+class CurveStyle(object):
+ """Object storing the style of a curve.
+
+ Set a value to None to use the default
+
+ :param color: Color
+ :param Union[str,None] linestyle: Style of the line
+ :param Union[float,None] linewidth: Width of the line
+ :param Union[str,None] symbol: Symbol for markers
+ :param Union[float,None] symbolsize: Size of the markers
+ """
+
+ def __init__(self, color=None, linestyle=None, linewidth=None,
+ symbol=None, symbolsize=None):
+ if color is None:
+ self._color = None
+ else:
+ if isinstance(color, six.string_types):
+ color = colors.rgba(color)
+ else: # array-like expected
+ color = numpy.array(color, copy=False)
+ if color.ndim == 1: # Array is 1D, this is a single color
+ color = colors.rgba(color)
+ self._color = color
+
+ if linestyle is not None:
+ assert linestyle in LineMixIn.getSupportedLineStyles()
+ self._linestyle = linestyle
+
+ self._linewidth = None if linewidth is None else float(linewidth)
+
+ if symbol is not None:
+ assert symbol in SymbolMixIn.getSupportedSymbols()
+ self._symbol = symbol
+
+ self._symbolsize = None if symbolsize is None else float(symbolsize)
+
+ def getColor(self, copy=True):
+ """Returns the color or None if not set.
+
+ :param bool copy: True to get a copy (default),
+ False to get internal representation (do not modify!)
+
+ :rtype: Union[List[float],None]
+ """
+ if isinstance(self._color, numpy.ndarray):
+ return numpy.array(self._color, copy=copy)
+ else:
+ return self._color
+
+ def getLineStyle(self):
+ """Return the type of the line or None if not set.
+
+ Type of line::
+
+ - ' ' no line
+ - '-' solid line
+ - '--' dashed line
+ - '-.' dash-dot line
+ - ':' dotted line
+
+ :rtype: Union[str,None]
+ """
+ return self._linestyle
+
+ def getLineWidth(self):
+ """Return the curve line width in pixels or None if not set.
+
+ :rtype: Union[float,None]
+ """
+ return self._linewidth
+
+ def getSymbol(self):
+ """Return the point marker type.
+
+ Marker type::
+
+ - 'o' circle
+ - '.' point
+ - ',' pixel
+ - '+' cross
+ - 'x' x-cross
+ - 'd' diamond
+ - 's' square
+
+ :rtype: Union[str,None]
+ """
+ return self._symbol
+
+ def getSymbolSize(self):
+ """Return the point marker size in points.
+
+ :rtype: Union[float,None]
+ """
+ return self._symbolsize
+
+ def __eq__(self, other):
+ if isinstance(other, CurveStyle):
+ return (numpy.array_equal(self.getColor(), other.getColor()) and
+ self.getLineStyle() == other.getLineStyle() and
+ self.getLineWidth() == other.getLineWidth() and
+ self.getSymbol() == other.getSymbol() and
+ self.getSymbolSize() == other.getSymbolSize())
+ else:
+ return False
+
+
class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
"""Description of a curve"""
@@ -56,8 +165,8 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
_DEFAULT_LINESTYLE = '-'
"""Default line style of the curve"""
- _DEFAULT_HIGHLIGHT_COLOR = (0, 0, 0, 255)
- """Default highlight color of the item"""
+ _DEFAULT_HIGHLIGHT_STYLE = CurveStyle(color='black')
+ """Default highlight style of the item"""
def __init__(self):
Points.__init__(self)
@@ -67,9 +176,18 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
LabelsMixIn.__init__(self)
LineMixIn.__init__(self)
- self._highlightColor = self._DEFAULT_HIGHLIGHT_COLOR
+ self._highlightStyle = self._DEFAULT_HIGHLIGHT_STYLE
self._highlighted = False
+ self.sigItemChanged.connect(self.__itemChanged)
+
+ def __itemChanged(self, event):
+ if event == ItemChangedType.YAXIS:
+ # TODO hackish data range implementation
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
+
def _addBackendRenderer(self, backend):
"""Update backend renderer"""
# Filter-out values <= 0
@@ -79,11 +197,13 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
if len(xFiltered) == 0 or not numpy.any(numpy.isfinite(xFiltered)):
return None # No data to display, do not add renderer to backend
+ style = self.getCurrentStyle()
+
return backend.addCurve(xFiltered, yFiltered, self.getLegend(),
- color=self.getCurrentColor(),
- symbol=self.getSymbol(),
- linestyle=self.getLineStyle(),
- linewidth=self.getLineWidth(),
+ color=style.getColor(),
+ symbol=style.getSymbol(),
+ linestyle=style.getLineStyle(),
+ linewidth=style.getLineWidth(),
yaxis=self.getYAxis(),
xerror=xerror,
yerror=yerror,
@@ -91,7 +211,7 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
selectable=self.isSelectable(),
fill=self.isFill(),
alpha=self.getAlpha(),
- symbolsize=self.getSymbolSize())
+ symbolsize=style.getSymbolSize())
def __getitem__(self, item):
"""Compatibility with PyMca and silx <= 0.4.0"""
@@ -158,13 +278,39 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
# TODO inefficient: better to use backend's setCurveColor
self._updated(ItemChangedType.HIGHLIGHTED)
+ def getHighlightedStyle(self):
+ """Returns the highlighted style in use
+
+ :rtype: CurveStyle
+ """
+ return self._highlightStyle
+
+ def setHighlightedStyle(self, style):
+ """Set the style to use for highlighting
+
+ :param CurveStyle style: New style to use
+ """
+ previous = self.getHighlightedStyle()
+ if style != previous:
+ assert isinstance(style, CurveStyle)
+ self._highlightStyle = style
+ self._updated(ItemChangedType.HIGHLIGHTED_STYLE)
+
+ # Backward compatibility event
+ if previous.getColor() != style.getColor():
+ self._updated(ItemChangedType.HIGHLIGHTED_COLOR)
+
+ @deprecated(replacement='Curve.getHighlightedStyle().getColor()',
+ since_version='0.9.0')
def getHighlightedColor(self):
"""Returns the RGBA highlight color of the item
- :rtype: 4-tuple of int in [0, 255]
+ :rtype: 4-tuple of float in [0, 1]
"""
- return self._highlightColor
+ return self.getHighlightedStyle().getColor()
+ @deprecated(replacement='Curve.setHighlightedStyle()',
+ since_version='0.9.0')
def setHighlightedColor(self, color):
"""Set the color to use when highlighted
@@ -172,20 +318,45 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
:type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or
one of the predefined color names defined in colors.py
"""
- color = colors.rgba(color)
- if color != self._highlightColor:
- self._highlightColor = color
- self._updated(ItemChangedType.HIGHLIGHTED_COLOR)
+ self.setHighlightedStyle(CurveStyle(color))
+
+ def getCurrentStyle(self):
+ """Returns the current curve style.
+
+ Curve style depends on curve highlighting
+
+ :rtype: CurveStyle
+ """
+ if self.isHighlighted():
+ style = self.getHighlightedStyle()
+ color = style.getColor()
+ linestyle = style.getLineStyle()
+ linewidth = style.getLineWidth()
+ symbol = style.getSymbol()
+ symbolsize = style.getSymbolSize()
+
+ return CurveStyle(
+ color=self.getColor() if color is None else color,
+ linestyle=self.getLineStyle() if linestyle is None else linestyle,
+ linewidth=self.getLineWidth() if linewidth is None else linewidth,
+ symbol=self.getSymbol() if symbol is None else symbol,
+ symbolsize=self.getSymbolSize() if symbolsize is None else symbolsize)
+ else:
+ return CurveStyle(color=self.getColor(),
+ linestyle=self.getLineStyle(),
+ linewidth=self.getLineWidth(),
+ symbol=self.getSymbol(),
+ symbolsize=self.getSymbolSize())
+
+ @deprecated(replacement='Curve.getCurrentStyle()',
+ since_version='0.9.0')
def getCurrentColor(self):
"""Returns the current color of the curve.
This color is either the color of the curve or the highlighted color,
depending on the highlight state.
- :rtype: 4-tuple of int in [0, 255]
+ :rtype: 4-tuple of float in [0, 1]
"""
- if self.isHighlighted():
- return self.getHighlightedColor()
- else:
- return self.getColor()
+ return self.getCurrentStyle().getColor()
diff --git a/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py
index 3545345..389e8a6 100644
--- a/silx/gui/plot/items/histogram.py
+++ b/silx/gui/plot/items/histogram.py
@@ -27,7 +27,7 @@
__authors__ = ["H. Payno", "T. Vincent"]
__license__ = "MIT"
-__date__ = "27/06/2017"
+__date__ = "28/08/2018"
import logging
@@ -290,6 +290,11 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
self._edges = edges
self._alignement = align
+ if self.isVisible():
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
+
self._updated(ItemChangedType.DATA)
def getAlignment(self):
diff --git a/silx/gui/plot/items/marker.py b/silx/gui/plot/items/marker.py
index 8f79033..09767a5 100644
--- a/silx/gui/plot/items/marker.py
+++ b/silx/gui/plot/items/marker.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# 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
@@ -32,7 +32,7 @@ __date__ = "06/03/2017"
import logging
-from .core import (Item, DraggableMixIn, ColorMixIn, SymbolMixIn,
+from .core import (Item, DraggableMixIn, ColorMixIn, LineMixIn, SymbolMixIn,
ItemChangedType)
@@ -55,11 +55,9 @@ class _BaseMarker(Item, DraggableMixIn, ColorMixIn):
self._y = None
self._constraint = self._defaultConstraint
- def _addBackendRenderer(self, backend):
- """Update backend renderer"""
- # TODO not very nice way to do it, but simple
- symbol = self.getSymbol() if isinstance(self, Marker) else None
-
+ def _addRendererCall(self, backend,
+ symbol=None, linestyle='-', linewidth=1):
+ """Perform the update of the backend renderer"""
return backend.addMarker(
x=self.getXPosition(),
y=self.getYPosition(),
@@ -69,8 +67,14 @@ class _BaseMarker(Item, DraggableMixIn, ColorMixIn):
selectable=self.isSelectable(),
draggable=self.isDraggable(),
symbol=symbol,
+ linestyle=linestyle,
+ linewidth=linewidth,
constraint=self.getConstraint())
+ def _addBackendRenderer(self, backend):
+ """Update backend renderer"""
+ raise NotImplementedError()
+
def isOverlay(self):
"""Return true if marker is drawn as an overlay.
@@ -175,6 +179,9 @@ class Marker(_BaseMarker, SymbolMixIn):
self._x = 0.
self._y = 0.
+ def _addBackendRenderer(self, backend):
+ return self._addRendererCall(backend, symbol=self.getSymbol())
+
def _setConstraint(self, constraint):
"""Set the constraint function of the marker drag.
@@ -197,11 +204,24 @@ class Marker(_BaseMarker, SymbolMixIn):
return x, self.getYPosition()
-class XMarker(_BaseMarker):
- """Description of a marker"""
+class _LineMarker(_BaseMarker, LineMixIn):
+ """Base class for line markers"""
def __init__(self):
_BaseMarker.__init__(self)
+ LineMixIn.__init__(self)
+
+ def _addBackendRenderer(self, backend):
+ return self._addRendererCall(backend,
+ linestyle=self.getLineStyle(),
+ linewidth=self.getLineWidth())
+
+
+class XMarker(_LineMarker):
+ """Description of a marker"""
+
+ def __init__(self):
+ _LineMarker.__init__(self)
self._x = 0.
def setPosition(self, x, y):
@@ -219,11 +239,11 @@ class XMarker(_BaseMarker):
self._updated(ItemChangedType.POSITION)
-class YMarker(_BaseMarker):
+class YMarker(_LineMarker):
"""Description of a marker"""
def __init__(self):
- _BaseMarker.__init__(self)
+ _LineMarker.__init__(self)
self._y = 0.
def setPosition(self, x, y):
diff --git a/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py
index 72b8496..acc74b4 100644
--- a/silx/gui/plot/items/scatter.py
+++ b/silx/gui/plot/items/scatter.py
@@ -53,7 +53,8 @@ class Scatter(Points, ColormapMixIn):
Points.__init__(self)
ColormapMixIn.__init__(self)
self._value = ()
-
+ self.__alpha = None
+
def _addBackendRenderer(self, backend):
"""Update backend renderer"""
# Filter-out values <= 0
@@ -66,6 +67,9 @@ class Scatter(Points, ColormapMixIn):
cmap = self.getColormap()
rgbacolors = cmap.applyToData(self._value)
+ if self.__alpha is not None:
+ rgbacolors[:, -1] = (rgbacolors[:, -1] * self.__alpha).astype(numpy.uint8)
+
return backend.addCurve(xFiltered, yFiltered, self.getLegend(),
color=rgbacolors,
symbol=self.getSymbol(),
@@ -112,6 +116,15 @@ class Scatter(Points, ColormapMixIn):
"""
return numpy.array(self._value, copy=copy)
+ def getAlphaData(self, copy=True):
+ """Returns the alpha (transparency) assigned to the scatter data points.
+
+ :param copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(self.__alpha, copy=copy)
+
def getData(self, copy=True, displayed=False):
"""Returns the x, y coordinates and the value of the data points
@@ -137,7 +150,7 @@ class Scatter(Points, ColormapMixIn):
self.getYErrorData(copy))
# reimplemented from Points to handle `value`
- def setData(self, x, y, value, xerror=None, yerror=None, copy=True):
+ def setData(self, x, y, value, xerror=None, yerror=None, alpha=None, copy=True):
"""Set the data of the scatter.
:param numpy.ndarray x: The data corresponding to the x coordinates.
@@ -152,6 +165,8 @@ class Scatter(Points, ColormapMixIn):
row 1 for negative errors.
:param yerror: Values with the uncertainties on the y values
:type yerror: A float, or a numpy.ndarray of float32. See xerror.
+ :param alpha: Values with the transparency (between 0 and 1)
+ :type alpha: A float, or a numpy.ndarray of float32
:param bool copy: True make a copy of the data (default),
False to use provided arrays.
"""
@@ -161,6 +176,17 @@ class Scatter(Points, ColormapMixIn):
self._value = value
+ if alpha is not None:
+ # Make sure alpha is an array of float in [0, 1]
+ alpha = numpy.array(alpha, copy=copy)
+ assert alpha.ndim == 1
+ assert len(x) == len(alpha)
+ if alpha.dtype.kind != 'f':
+ alpha = alpha.astype(numpy.float32)
+ if numpy.any(numpy.logical_or(alpha < 0., alpha > 1.)):
+ alpha = numpy.clip(alpha, 0., 1.)
+ self.__alpha = alpha
+
# set x, y, xerror, yerror
# call self._updated + plot._invalidateDataRange()
diff --git a/silx/gui/plot/matplotlib/ModestImage.py b/silx/gui/plot/matplotlib/ModestImage.py
deleted file mode 100644
index e4a72d5..0000000
--- a/silx/gui/plot/matplotlib/ModestImage.py
+++ /dev/null
@@ -1,174 +0,0 @@
-# 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.
-#
-# ############################################################################*/
-"""Matplotlib computationally modest image class."""
-
-__authors__ = ["V.A. Sole", "T. Vincent"]
-__license__ = "MIT"
-__date__ = "03/05/2017"
-
-
-import numpy
-
-from matplotlib import cbook
-from matplotlib.image import AxesImage
-
-
-class ModestImage(AxesImage):
- """Computationally modest image class.
-
-Customization of https://github.com/ChrisBeaumont/ModestImage to allow
-extent support.
-
-ModestImage is an extension of the Matplotlib AxesImage class
-better suited for the interactive display of larger images. Before
-drawing, ModestImage resamples the data array based on the screen
-resolution and view window. This has very little affect on the
-appearance of the image, but can substantially cut down on
-computation since calculations of unresolved or clipped pixels
-are skipped.
-
-The interface of ModestImage is the same as AxesImage. However, it
-does not currently support setting the 'extent' property. There
-may also be weird coordinate warping operations for images that
-I'm not aware of. Don't expect those to work either.
-"""
- def __init__(self, *args, **kwargs):
- self._full_res = None
- self._sx, self._sy = None, None
- self._bounds = (None, None, None, None)
- self._origExtent = None
- super(ModestImage, self).__init__(*args, **kwargs)
- if 'extent' in kwargs and kwargs['extent'] is not None:
- self.set_extent(kwargs['extent'])
-
- def set_extent(self, extent):
- super(ModestImage, self).set_extent(extent)
- if self._origExtent is None:
- self._origExtent = self.get_extent()
-
- def get_image_extent(self):
- """Returns the extent of the whole image.
-
- get_extent returns the extent of the drawn area and not of the full
- image.
-
- :return: Bounds of the image (x0, x1, y0, y1).
- :rtype: Tuple of 4 floats.
- """
- if self._origExtent is not None:
- return self._origExtent
- else:
- return self.get_extent()
-
- def set_data(self, A):
- """
- Set the image array
-
- ACCEPTS: numpy/PIL Image A
- """
-
- self._full_res = A
- self._A = A
-
- if (self._A.dtype != numpy.uint8 and
- not numpy.can_cast(self._A.dtype, numpy.float)):
- raise TypeError("Image data can not convert to float")
-
- if (self._A.ndim not in (2, 3) or
- (self._A.ndim == 3 and self._A.shape[-1] not in (3, 4))):
- raise TypeError("Invalid dimensions for image data")
-
- self._imcache = None
- self._rgbacache = None
- self._oldxslice = None
- self._oldyslice = None
- self._sx, self._sy = None, None
-
- def get_array(self):
- """Override to return the full-resolution array"""
- return self._full_res
-
- def _scale_to_res(self):
- """ Change self._A and _extent to render an image whose
-resolution is matched to the eventual rendering."""
- # extent has to be set BEFORE set_data
- if self._origExtent is None:
- if self.origin == "upper":
- self._origExtent = (0, self._full_res.shape[1],
- self._full_res.shape[0], 0)
- else:
- self._origExtent = (0, self._full_res.shape[1],
- 0, self._full_res.shape[0])
-
- if self.origin == "upper":
- origXMin, origXMax, origYMax, origYMin = self._origExtent[0:4]
- else:
- origXMin, origXMax, origYMin, origYMax = self._origExtent[0:4]
- ax = self.axes
- ext = ax.transAxes.transform([1, 1]) - ax.transAxes.transform([0, 0])
- xlim, ylim = ax.get_xlim(), ax.get_ylim()
- xlim = max(xlim[0], origXMin), min(xlim[1], origXMax)
- if ylim[0] > ylim[1]:
- ylim = max(ylim[1], origYMin), min(ylim[0], origYMax)
- else:
- ylim = max(ylim[0], origYMin), min(ylim[1], origYMax)
- # print("THOSE LIMITS ARE TO BE COMPARED WITH THE EXTENT")
- # print("IN ORDER TO KNOW WHAT IT IS LIMITING THE DISPLAY")
- # print("IF THE AXES OR THE EXTENT")
- dx, dy = xlim[1] - xlim[0], ylim[1] - ylim[0]
-
- y0 = max(0, ylim[0] - 5)
- y1 = min(self._full_res.shape[0], ylim[1] + 5)
- x0 = max(0, xlim[0] - 5)
- x1 = min(self._full_res.shape[1], xlim[1] + 5)
- y0, y1, x0, x1 = [int(a) for a in [y0, y1, x0, x1]]
-
- sy = int(max(1, min((y1 - y0) / 5., numpy.ceil(dy / ext[1]))))
- sx = int(max(1, min((x1 - x0) / 5., numpy.ceil(dx / ext[0]))))
-
- # have we already calculated what we need?
- if (self._sx is not None) and (self._sy is not None):
- if (sx >= self._sx and sy >= self._sy and
- x0 >= self._bounds[0] and x1 <= self._bounds[1] and
- y0 >= self._bounds[2] and y1 <= self._bounds[3]):
- return
-
- self._A = self._full_res[y0:y1:sy, x0:x1:sx]
- self._A = cbook.safe_masked_invalid(self._A)
- x1 = x0 + self._A.shape[1] * sx
- y1 = y0 + self._A.shape[0] * sy
-
- if self.origin == "upper":
- self.set_extent([x0, x1, y1, y0])
- else:
- self.set_extent([x0, x1, y0, y1])
- self._sx = sx
- self._sy = sy
- self._bounds = (x0, x1, y0, y1)
- self.changed()
-
- def draw(self, renderer, *args, **kwargs):
- self._scale_to_res()
- super(ModestImage, self).draw(renderer, *args, **kwargs)
diff --git a/silx/gui/plot/test/__init__.py b/silx/gui/plot/test/__init__.py
index 1428bad..89c10c6 100644
--- a/silx/gui/plot/test/__init__.py
+++ b/silx/gui/plot/test/__init__.py
@@ -24,7 +24,7 @@
# ###########################################################################*/
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "24/04/2018"
+__date__ = "23/07/2018"
import unittest
@@ -52,6 +52,7 @@ from . import testImageView
from . import testSaveAction
from . import testScatterView
from . import testPixelIntensityHistoAction
+from . import testCompareImages
def suite():
@@ -83,6 +84,7 @@ def suite():
testImageView.suite(),
testSaveAction.suite(),
testScatterView.suite(),
- testPixelIntensityHistoAction.suite()
+ testPixelIntensityHistoAction.suite(),
+ testCompareImages.suite()
])
return test_suite
diff --git a/silx/gui/plot/test/testAlphaSlider.py b/silx/gui/plot/test/testAlphaSlider.py
index 304a562..63de441 100644
--- a/silx/gui/plot/test/testAlphaSlider.py
+++ b/silx/gui/plot/test/testAlphaSlider.py
@@ -33,7 +33,7 @@ import numpy
import unittest
from silx.gui import qt
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot import PlotWidget
from silx.gui.plot import AlphaSlider
diff --git a/silx/gui/plot/test/testColorBar.py b/silx/gui/plot/test/testColorBar.py
index 0d1c952..9a02e04 100644
--- a/silx/gui/plot/test/testColorBar.py
+++ b/silx/gui/plot/test/testColorBar.py
@@ -29,7 +29,7 @@ __license__ = "MIT"
__date__ = "24/04/2018"
import unittest
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot.ColorBar import _ColorScale
from silx.gui.plot.ColorBar import ColorBarWidget
from silx.gui.colors import Colormap
diff --git a/silx/gui/plot/test/testCompareImages.py b/silx/gui/plot/test/testCompareImages.py
new file mode 100644
index 0000000..ed6942a
--- /dev/null
+++ b/silx/gui/plot/test/testCompareImages.py
@@ -0,0 +1,117 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-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.
+#
+# ###########################################################################*/
+"""Tests for CompareImages widget"""
+
+__authors__ = ["H. Payno"]
+__license__ = "MIT"
+__date__ = "23/07/2018"
+
+import unittest
+import numpy
+import weakref
+
+from silx.gui.utils.testutils import TestCaseQt
+from silx.gui.plot.CompareImages import CompareImages
+
+
+class TestCompareImages(TestCaseQt):
+ """Test that CompareImages widget is working in some cases"""
+
+ def setUp(self):
+ super(TestCompareImages, self).setUp()
+ self.widget = CompareImages()
+
+ def tearDown(self):
+ ref = weakref.ref(self.widget)
+ self.widget = None
+ self.qWaitForDestroy(ref)
+ super(TestCompareImages, self).tearDown()
+
+ def testIntensityImage(self):
+ image1 = numpy.random.rand(10, 10)
+ image2 = numpy.random.rand(10, 10)
+ self.widget.setData(image1, image2)
+
+ def testRgbImage(self):
+ image1 = numpy.random.randint(0, 255, size=(10, 10, 3))
+ image2 = numpy.random.randint(0, 255, size=(10, 10, 3))
+ self.widget.setData(image1, image2)
+
+ def testRgbaImage(self):
+ image1 = numpy.random.randint(0, 255, size=(10, 10, 4))
+ image2 = numpy.random.randint(0, 255, size=(10, 10, 4))
+ self.widget.setData(image1, image2)
+
+ def testVizualisations(self):
+ image1 = numpy.random.rand(10, 10)
+ image2 = numpy.random.rand(10, 10)
+ self.widget.setData(image1, image2)
+ for mode in CompareImages.VisualizationMode:
+ self.widget.setVisualizationMode(mode)
+
+ def testAlignemnt(self):
+ image1 = numpy.random.rand(10, 10)
+ image2 = numpy.random.rand(5, 5)
+ self.widget.setData(image1, image2)
+ for mode in CompareImages.AlignmentMode:
+ self.widget.setAlignmentMode(mode)
+
+ def testGetPixel(self):
+ image1 = numpy.random.rand(11, 11)
+ image2 = numpy.random.rand(5, 5)
+ image1[5, 5] = 111.111
+ image2[2, 2] = 222.222
+ self.widget.setData(image1, image2)
+ expectedValue = {}
+ expectedValue[CompareImages.AlignmentMode.CENTER] = 222.222
+ expectedValue[CompareImages.AlignmentMode.STRETCH] = 222.222
+ expectedValue[CompareImages.AlignmentMode.ORIGIN] = None
+ for mode in expectedValue.keys():
+ self.widget.setAlignmentMode(mode)
+ data = self.widget.getRawPixelData(11 / 2.0, 11 / 2.0)
+ data1, data2 = data
+ self.assertEqual(data1, 111.111)
+ self.assertEqual(data2, expectedValue[mode])
+
+ def testImageEmpty(self):
+ self.widget.setData(image1=None, image2=None)
+ self.assertTrue(self.widget.getRawPixelData(11 / 2.0, 11 / 2.0) == (None, None))
+
+ def testSetImageSeparately(self):
+ self.widget.setImage1(numpy.random.rand(10, 10))
+ self.widget.setImage2(numpy.random.rand(10, 10))
+ for mode in CompareImages.VisualizationMode:
+ self.widget.setVisualizationMode(mode)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loadTests(TestCompareImages))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testCurvesROIWidget.py b/silx/gui/plot/test/testCurvesROIWidget.py
index 7a2e3d1..0704779 100644
--- a/silx/gui/plot/test/testCurvesROIWidget.py
+++ b/silx/gui/plot/test/testCurvesROIWidget.py
@@ -36,7 +36,7 @@ from collections import OrderedDict
import numpy
from silx.gui import qt
from silx.test.utils import temp_dir
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot import PlotWindow, CurvesROIWidget
diff --git a/silx/gui/plot/test/testImageView.py b/silx/gui/plot/test/testImageView.py
index 5059a0b..3c8d84c 100644
--- a/silx/gui/plot/test/testImageView.py
+++ b/silx/gui/plot/test/testImageView.py
@@ -33,7 +33,7 @@ import unittest
import numpy
from silx.gui import qt
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot import ImageView
from silx.gui.colors import Colormap
diff --git a/silx/gui/plot/test/testItem.py b/silx/gui/plot/test/testItem.py
index 1ba09c6..993cce7 100644
--- a/silx/gui/plot/test/testItem.py
+++ b/silx/gui/plot/test/testItem.py
@@ -33,7 +33,7 @@ import unittest
import numpy
-from silx.gui.test.utils import SignalListener
+from silx.gui.utils.testutils import SignalListener
from silx.gui.plot.items import ItemChangedType
from .utils import PlotWidgetTestCase
diff --git a/silx/gui/plot/test/testLegendSelector.py b/silx/gui/plot/test/testLegendSelector.py
index 9d4ada7..de5ffde 100644
--- a/silx/gui/plot/test/testLegendSelector.py
+++ b/silx/gui/plot/test/testLegendSelector.py
@@ -33,7 +33,7 @@ import logging
import unittest
from silx.gui import qt
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot import LegendSelector
diff --git a/silx/gui/plot/test/testMaskToolsWidget.py b/silx/gui/plot/test/testMaskToolsWidget.py
index 40c1db3..6912ea3 100644
--- a/silx/gui/plot/test/testMaskToolsWidget.py
+++ b/silx/gui/plot/test/testMaskToolsWidget.py
@@ -38,7 +38,7 @@ import numpy
from silx.gui import qt
from silx.test.utils import temp_dir
from silx.utils.testutils import ParametricTestCase
-from silx.gui.test.utils import getQToolButtonFromAction
+from silx.gui.utils.testutils import getQToolButtonFromAction
from silx.gui.plot import PlotWindow, MaskToolsWidget
from .utils import PlotWidgetTestCase
@@ -87,10 +87,10 @@ class TestMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=pos0)
- self.mousePress(plot, qt.Qt.LeftButton, pos=pos0)
+ self.mouseClick(plot, qt.Qt.LeftButton, pos=pos0)
self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=pos1)
- self.mouseRelease(plot, qt.Qt.LeftButton, pos=pos1)
+ self.mouseClick(plot, qt.Qt.LeftButton, pos=pos1)
def _drawPolygon(self):
"""Draw a star polygon in the plot"""
@@ -108,7 +108,9 @@ class TestMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
self.mouseMove(plot, pos=(0, 0))
for pos in star:
self.mouseMove(plot, pos=pos)
+ self.qapp.processEvents()
self.mouseClick(plot, qt.Qt.LeftButton, pos=pos)
+ self.qapp.processEvents()
def _drawPencil(self):
"""Draw a star polygon in the plot"""
diff --git a/silx/gui/plot/test/testPixelIntensityHistoAction.py b/silx/gui/plot/test/testPixelIntensityHistoAction.py
index 987e5b2..20d1ea2 100644
--- a/silx/gui/plot/test/testPixelIntensityHistoAction.py
+++ b/silx/gui/plot/test/testPixelIntensityHistoAction.py
@@ -33,7 +33,7 @@ import numpy
import unittest
from silx.utils.testutils import ParametricTestCase
-from silx.gui.test.utils import TestCaseQt, getQToolButtonFromAction
+from silx.gui.utils.testutils import TestCaseQt, getQToolButtonFromAction
from silx.gui import qt
from silx.gui.plot import Plot2D
diff --git a/silx/gui/plot/test/testPlotWidget.py b/silx/gui/plot/test/testPlotWidget.py
index dac6580..857b9bc 100644
--- a/silx/gui/plot/test/testPlotWidget.py
+++ b/silx/gui/plot/test/testPlotWidget.py
@@ -26,7 +26,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "24/04/2018"
+__date__ = "21/09/2018"
import unittest
@@ -34,8 +34,8 @@ import logging
import numpy
from silx.utils.testutils import ParametricTestCase, parameterize
-from silx.gui.test.utils import SignalListener
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import SignalListener
+from silx.gui.utils.testutils import TestCaseQt
from silx.utils import testutils
from silx.utils import deprecation
@@ -43,6 +43,7 @@ from silx.test.utils import test_options
from silx.gui import qt
from silx.gui.plot import PlotWidget
+from silx.gui.plot.items.curve import CurveStyle
from silx.gui.colors import Colormap
from .utils import PlotWidgetTestCase
@@ -118,6 +119,7 @@ class TestPlotWidget(PlotWidgetTestCase, ParametricTestCase):
"""Test resizing the widget and receiving limitsChanged events"""
self.plot.resize(200, 200)
self.qapp.processEvents()
+ self.qWait(100)
xlim = self.plot.getXAxis().getLimits()
ylim = self.plot.getYAxis().getLimits()
@@ -129,18 +131,58 @@ class TestPlotWidget(PlotWidgetTestCase, ParametricTestCase):
# Resize without aspect ratio
self.plot.resize(200, 300)
self.qapp.processEvents()
+ self.qWait(100)
self._checkLimits(expectedXLim=xlim, expectedYLim=ylim)
self.assertEqual(listener.callCount(), 0)
# Resize with aspect ratio
self.plot.setKeepDataAspectRatio(True)
self.qapp.processEvents()
+ self.qWait(1000)
listener.clear() # Clean-up received signal
self.plot.resize(200, 200)
self.qapp.processEvents()
+ self.qWait(100)
self.assertNotEqual(listener.callCount(), 0)
+ def testAddRemoveItemSignals(self):
+ """Test sigItemAdded and sigItemAboutToBeRemoved"""
+ listener = SignalListener()
+ self.plot.sigItemAdded.connect(listener.partial('add'))
+ self.plot.sigItemAboutToBeRemoved.connect(listener.partial('remove'))
+
+ self.plot.addCurve((1, 2, 3), (3, 2, 1), legend='curve')
+ self.assertEqual(listener.callCount(), 1)
+
+ curve = self.plot.getCurve('curve')
+ self.plot.remove('curve')
+ self.assertEqual(listener.callCount(), 2)
+ self.assertEqual(listener.arguments(callIndex=0), ('add', curve))
+ self.assertEqual(listener.arguments(callIndex=1), ('remove', curve))
+
+ def testGetItems(self):
+ """Test getItems method"""
+ curve_x = 1, 2
+ self.plot.addCurve(curve_x, (3, 4))
+ image = (0, 1), (2, 3)
+ self.plot.addImage(image)
+ scatter_x = 10, 11
+ self.plot.addScatter(scatter_x, (12, 13), (0, 1))
+ marker_pos = 5, 5
+ self.plot.addMarker(*marker_pos)
+ marker_x = 6
+ self.plot.addXMarker(marker_x)
+ self.plot.addItem((0, 5), (2, 10), shape='rectangle')
+
+ items = self.plot.getItems()
+ self.assertEqual(len(items), 6)
+ self.assertTrue(numpy.all(numpy.equal(items[0].getXData(), curve_x)))
+ self.assertTrue(numpy.all(numpy.equal(items[1].getData(), image)))
+ self.assertTrue(numpy.all(numpy.equal(items[2].getXData(), scatter_x)))
+ self.assertTrue(numpy.all(numpy.equal(items[3].getPosition(), marker_pos)))
+ self.assertTrue(numpy.all(numpy.equal(items[4].getPosition()[0], marker_x)))
+ self.assertEqual(items[5].getType(), 'rectangle')
class TestPlotImage(PlotWidgetTestCase, ParametricTestCase):
"""Basic tests for addImage"""
@@ -270,10 +312,10 @@ class TestPlotImage(PlotWidgetTestCase, ParametricTestCase):
self.plot.setKeepDataAspectRatio(True)
xmin, xmax = self.plot.getXAxis().getLimits()
ymin, ymax = self.plot.getYAxis().getLimits()
- self.assertTrue(xmin <= min(xbounds))
- self.assertTrue(xmax >= max(xbounds))
- self.assertTrue(ymin <= min(ybounds))
- self.assertTrue(ymax >= max(ybounds))
+ self.assertTrue(round(xmin, 7) <= min(xbounds))
+ self.assertTrue(round(xmax, 7) >= max(xbounds))
+ self.assertTrue(round(ymin, 7) <= min(ybounds))
+ self.assertTrue(round(ymax, 7) >= max(ybounds))
self.plot.setKeepDataAspectRatio(False) # Reset aspect ratio
self.plot.clear()
@@ -390,8 +432,7 @@ class TestPlotCurve(PlotWidgetTestCase):
self.plot.addCurve(self.xData, self.yData,
legend="curve 2",
replace=False, resetzoom=False,
- color=color, symbol='o')
-
+ color=color, symbol='o')
class TestPlotMarker(PlotWidgetTestCase):
"""Basic tests for add*Marker"""
@@ -562,7 +603,15 @@ class TestPlotItem(PlotWidgetTestCase):
class TestPlotActiveCurveImage(PlotWidgetTestCase):
- """Basic tests for active image handling"""
+ """Basic tests for active curve and image handling"""
+ xData = numpy.arange(1000)
+ yData = -500 + 100 * numpy.sin(xData)
+ xData2 = xData + 1000
+ yData2 = xData - 1000 + 200 * numpy.random.random(1000)
+
+ def tearDown(self):
+ self.plot.setActiveCurveHandling(False)
+ super(TestPlotActiveCurveImage, self).tearDown()
def testActiveCurveAndLabels(self):
# Active curve handling off, no label change
@@ -589,6 +638,7 @@ class TestPlotActiveCurveImage(PlotWidgetTestCase):
# labels changed as active curve
self.plot.addCurve((1, 2), (1, 2), legend='1',
xlabel='x1', ylabel='y1')
+ self.plot.setActiveCurve('1')
self.assertEqual(self.plot.getXAxis().getLabel(), 'x1')
self.assertEqual(self.plot.getYAxis().getLabel(), 'y1')
@@ -610,6 +660,110 @@ class TestPlotActiveCurveImage(PlotWidgetTestCase):
self.assertEqual(self.plot.getXAxis().getLabel(), 'XLabel')
self.assertEqual(self.plot.getYAxis().getLabel(), 'YLabel')
+ def testPlotActiveCurveSelectionMode(self):
+ self.plot.clear()
+ self.plot.setActiveCurveHandling(True)
+ legend = "curve 1"
+ self.plot.addCurve(self.xData, self.yData,
+ legend=legend,
+ color="green")
+
+ # active curve should be None
+ self.assertEqual(self.plot.getActiveCurve(just_legend=True), None)
+
+ # active curve should be None when None is set as active curve
+ self.plot.setActiveCurve(legend)
+ current = self.plot.getActiveCurve(just_legend=True)
+ self.assertEqual(current, legend)
+ self.plot.setActiveCurve(None)
+ current = self.plot.getActiveCurve(just_legend=True)
+ self.assertEqual(current, None)
+
+ # testing it automatically toggles if there is only one
+ self.plot.setActiveCurveSelectionMode("legacy")
+ current = self.plot.getActiveCurve(just_legend=True)
+ self.assertEqual(current, legend)
+
+ # active curve should not change when None set as active curve
+ self.assertEqual(self.plot.getActiveCurveSelectionMode(), "legacy")
+ self.plot.setActiveCurve(None)
+ current = self.plot.getActiveCurve(just_legend=True)
+ self.assertEqual(current, legend)
+
+ # situation where no curve is active
+ self.plot.clear()
+ self.plot.setActiveCurveHandling(True)
+ self.assertEqual(self.plot.getActiveCurveSelectionMode(), "atmostone")
+ self.plot.addCurve(self.xData, self.yData,
+ legend=legend,
+ color="green")
+ self.assertEqual(self.plot.getActiveCurve(just_legend=True), None)
+ self.plot.addCurve(self.xData2, self.yData2,
+ legend="curve 2",
+ color="red")
+ self.assertEqual(self.plot.getActiveCurve(just_legend=True), None)
+ self.plot.setActiveCurveSelectionMode("legacy")
+ self.assertEqual(self.plot.getActiveCurve(just_legend=True), None)
+
+ # the first curve added should be active
+ self.plot.clear()
+ self.plot.addCurve(self.xData, self.yData,
+ legend=legend,
+ color="green")
+ self.assertEqual(self.plot.getActiveCurve(just_legend=True), legend)
+ self.plot.addCurve(self.xData2, self.yData2,
+ legend="curve 2",
+ color="red")
+ self.assertEqual(self.plot.getActiveCurve(just_legend=True), legend)
+
+ def testActiveCurveStyle(self):
+ """Test change of active curve style"""
+ self.plot.setActiveCurveHandling(True)
+ self.plot.setActiveCurveStyle(color='black')
+ style = self.plot.getActiveCurveStyle()
+ self.assertEqual(style.getColor(), (0., 0., 0., 1.))
+ self.assertIsNone(style.getLineStyle())
+ self.assertIsNone(style.getLineWidth())
+ self.assertIsNone(style.getSymbol())
+ self.assertIsNone(style.getSymbolSize())
+
+ self.plot.addCurve(x=self.xData, y=self.yData, legend="curve1")
+ curve = self.plot.getCurve("curve1")
+ curve.setColor('blue')
+ curve.setLineStyle('-')
+ curve.setLineWidth(1)
+ curve.setSymbol('o')
+ curve.setSymbolSize(5)
+
+ # Check default current style
+ defaultStyle = curve.getCurrentStyle()
+ self.assertEqual(defaultStyle, CurveStyle(color='blue',
+ linestyle='-',
+ linewidth=1,
+ symbol='o',
+ symbolsize=5))
+
+ # Activate curve with highlight color=black
+ self.plot.setActiveCurve("curve1")
+ style = curve.getCurrentStyle()
+ self.assertEqual(style.getColor(), (0., 0., 0., 1.))
+ self.assertEqual(style.getLineStyle(), '-')
+ self.assertEqual(style.getLineWidth(), 1)
+ self.assertEqual(style.getSymbol(), 'o')
+ self.assertEqual(style.getSymbolSize(), 5)
+
+ # Change highlight to linewidth=2
+ self.plot.setActiveCurveStyle(linewidth=2)
+ style = curve.getCurrentStyle()
+ self.assertEqual(style.getColor(), (0., 0., 1., 1.))
+ self.assertEqual(style.getLineStyle(), '-')
+ self.assertEqual(style.getLineWidth(), 2)
+ self.assertEqual(style.getSymbol(), 'o')
+ self.assertEqual(style.getSymbolSize(), 5)
+
+ self.plot.setActiveCurve(None)
+ self.assertEqual(curve.getCurrentStyle(), defaultStyle)
+
def testActiveImageAndLabels(self):
# Active image handling always on, no API for toggling it
self.plot.getXAxis().setLabel('XLabel')
@@ -881,7 +1035,7 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
self.plot.getYAxis(axis="right").sigLimitsChanged.connect(listener.partial(axis="y2"))
self.plot.setLimits(0, 1, 0, 1, 0, 1)
# at least one event per axis
- self.assertEquals(len(set(listener.karguments(argumentName="axis"))), 3)
+ self.assertEqual(len(set(listener.karguments(argumentName="axis"))), 3)
def testLimitsChanged_resetZoom(self):
self.plot.addCurve(self.xData, self.yData,
@@ -894,7 +1048,7 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
self.plot.getYAxis(axis="right").sigLimitsChanged.connect(listener.partial(axis="y2"))
self.plot.resetZoom()
# at least one event per axis
- self.assertEquals(len(set(listener.karguments(argumentName="axis"))), 3)
+ self.assertEqual(len(set(listener.karguments(argumentName="axis"))), 3)
def testLimitsChanged_setXLimit(self):
self.plot.addCurve(self.xData, self.yData,
@@ -906,8 +1060,8 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
axis.sigLimitsChanged.connect(listener)
axis.setLimits(20, 30)
# at least one event per axis
- self.assertEquals(listener.arguments(callIndex=-1), (20.0, 30.0))
- self.assertEquals(axis.getLimits(), (20.0, 30.0))
+ self.assertEqual(listener.arguments(callIndex=-1), (20.0, 30.0))
+ self.assertEqual(axis.getLimits(), (20.0, 30.0))
def testLimitsChanged_setYLimit(self):
self.plot.addCurve(self.xData, self.yData,
@@ -919,8 +1073,8 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
axis.sigLimitsChanged.connect(listener)
axis.setLimits(20, 30)
# at least one event per axis
- self.assertEquals(listener.arguments(callIndex=-1), (20.0, 30.0))
- self.assertEquals(axis.getLimits(), (20.0, 30.0))
+ self.assertEqual(listener.arguments(callIndex=-1), (20.0, 30.0))
+ self.assertEqual(axis.getLimits(), (20.0, 30.0))
def testLimitsChanged_setYRightLimit(self):
self.plot.addCurve(self.xData, self.yData,
@@ -932,8 +1086,8 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
axis.sigLimitsChanged.connect(listener)
axis.setLimits(20, 30)
# at least one event per axis
- self.assertEquals(listener.arguments(callIndex=-1), (20.0, 30.0))
- self.assertEquals(axis.getLimits(), (20.0, 30.0))
+ self.assertEqual(listener.arguments(callIndex=-1), (20.0, 30.0))
+ self.assertEqual(axis.getLimits(), (20.0, 30.0))
def testScaleProxy(self):
listener = SignalListener()
@@ -943,9 +1097,9 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
yright.sigScaleChanged.connect(listener.partial("right"))
yright.setScale(yright.LOGARITHMIC)
- self.assertEquals(y.getScale(), y.LOGARITHMIC)
+ self.assertEqual(y.getScale(), y.LOGARITHMIC)
events = listener.arguments()
- self.assertEquals(len(events), 2)
+ self.assertEqual(len(events), 2)
self.assertIn(("left", y.LOGARITHMIC), events)
self.assertIn(("right", y.LOGARITHMIC), events)
@@ -957,9 +1111,9 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
yright.sigAutoScaleChanged.connect(listener.partial("right"))
yright.setAutoScale(False)
- self.assertEquals(y.isAutoScale(), False)
+ self.assertEqual(y.isAutoScale(), False)
events = listener.arguments()
- self.assertEquals(len(events), 2)
+ self.assertEqual(len(events), 2)
self.assertIn(("left", False), events)
self.assertIn(("right", False), events)
@@ -971,9 +1125,9 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
yright.sigInvertedChanged.connect(listener.partial("right"))
yright.setInverted(True)
- self.assertEquals(y.isInverted(), True)
+ self.assertEqual(y.isInverted(), True)
events = listener.arguments()
- self.assertEquals(len(events), 2)
+ self.assertEqual(len(events), 2)
self.assertIn(("left", True), events)
self.assertIn(("right", True), events)
@@ -1363,6 +1517,7 @@ class TestPlotItemLog(PlotWidgetTestCase):
def suite():
testClasses = (TestPlotWidget, TestPlotImage, TestPlotCurve,
TestPlotMarker, TestPlotItem, TestPlotAxes,
+ TestPlotActiveCurveImage,
TestPlotEmptyLog, TestPlotCurveLog, TestPlotImageLog,
TestPlotMarkerLog, TestPlotItemLog)
diff --git a/silx/gui/plot/test/testPlotWindow.py b/silx/gui/plot/test/testPlotWindow.py
index 24d840b..6d3eb8f 100644
--- a/silx/gui/plot/test/testPlotWindow.py
+++ b/silx/gui/plot/test/testPlotWindow.py
@@ -32,7 +32,7 @@ __date__ = "27/06/2017"
import doctest
import unittest
-from silx.gui.test.utils import TestCaseQt, getQToolButtonFromAction
+from silx.gui.utils.testutils import TestCaseQt, getQToolButtonFromAction
from silx.gui import qt
from silx.gui.plot import PlotWindow
diff --git a/silx/gui/plot/test/testProfile.py b/silx/gui/plot/test/testProfile.py
index 28d9669..847f404 100644
--- a/silx/gui/plot/test/testProfile.py
+++ b/silx/gui/plot/test/testProfile.py
@@ -32,7 +32,7 @@ import numpy
import unittest
from silx.utils.testutils import ParametricTestCase
-from silx.gui.test.utils import (
+from silx.gui.utils.testutils import (
TestCaseQt, getQToolButtonFromAction)
from silx.gui import qt
from silx.gui.plot import PlotWindow, Plot1D, Plot2D, Profile
@@ -75,58 +75,168 @@ class TestProfileToolBar(TestCaseQt, ParametricTestCase):
"""Test horizontal and vertical profile, without and with image"""
# Use Plot backend widget to submit mouse events
widget = self.plot.getWidgetHandle()
+ for method in ('sum', 'mean'):
+ with self.subTest(method=method):
+ # 2 positions to use for mouse events
+ pos1 = widget.width() * 0.4, widget.height() * 0.4
+ pos2 = widget.width() * 0.6, widget.height() * 0.6
+
+ for action in (self.toolBar.hLineAction, self.toolBar.vLineAction):
+ with self.subTest(mode=action.text()):
+ # Trigger tool button for mode
+ toolButton = getQToolButtonFromAction(action)
+ self.assertIsNot(toolButton, None)
+ self.mouseMove(toolButton)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ # Without image
+ self.mouseMove(widget, pos=pos1)
+ self.mouseClick(widget, qt.Qt.LeftButton, pos=pos1)
+
+ # with image
+ self.plot.addImage(
+ numpy.arange(100 * 100).reshape(100, -1))
+ self.mousePress(widget, qt.Qt.LeftButton, pos=pos1)
+ self.mouseMove(widget, pos=pos2)
+ self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2)
+
+ self.mouseMove(widget)
+ self.mouseClick(widget, qt.Qt.LeftButton)
- # 2 positions to use for mouse events
- pos1 = widget.width() * 0.4, widget.height() * 0.4
- pos2 = widget.width() * 0.6, widget.height() * 0.6
+ def testDiagonalProfile(self):
+ """Test diagonal profile, without and with image"""
+ # Use Plot backend widget to submit mouse events
+ widget = self.plot.getWidgetHandle()
- for action in (self.toolBar.hLineAction, self.toolBar.vLineAction):
- with self.subTest(mode=action.text()):
- # Trigger tool button for mode
- toolButton = getQToolButtonFromAction(action)
- self.assertIsNot(toolButton, None)
- self.mouseMove(toolButton)
- self.mouseClick(toolButton, qt.Qt.LeftButton)
+ for method in ('sum', 'mean'):
+ with self.subTest(method=method):
+ self.toolBar.setProfileMethod(method)
+
+ # 2 positions to use for mouse events
+ pos1 = widget.width() * 0.4, widget.height() * 0.4
+ pos2 = widget.width() * 0.6, widget.height() * 0.6
+
+ for image in (False, True):
+ with self.subTest(image=image):
+ if image:
+ self.plot.addImage(
+ numpy.arange(100 * 100).reshape(100, -1))
+
+ # Trigger tool button for diagonal profile mode
+ toolButton = getQToolButtonFromAction(
+ self.toolBar.lineAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseMove(toolButton)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+ self.toolBar.lineWidthSpinBox.setValue(3)
+
+ # draw profile line
+ self.mouseMove(widget, pos=pos1)
+ self.mousePress(widget, qt.Qt.LeftButton, pos=pos1)
+ self.mouseMove(widget, pos=pos2)
+ self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2)
+
+ if image is True:
+ profileCurve = self.toolBar.getProfilePlot().getAllCurves()[0]
+ if method == 'sum':
+ self.assertTrue(profileCurve.getData()[1].max() > 10000)
+ elif method == 'mean':
+ self.assertTrue(profileCurve.getData()[1].max() < 10000)
+ self.plot.clear()
+
+
+class TestProfile3DToolBar(TestCaseQt):
+ """Tests for Profile3DToolBar widget.
+ """
+ def setUp(self):
+ super(TestProfile3DToolBar, self).setUp()
+ self.plot = StackView()
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
- # Without image
- self.mouseMove(widget, pos=pos1)
- self.mouseClick(widget, qt.Qt.LeftButton, pos=pos1)
+ self.plot.setStack(numpy.array([
+ [[0, 1, 2], [3, 4, 5]],
+ [[6, 7, 8], [9, 10, 11]],
+ [[12, 13, 14], [15, 16, 17]]
+ ]))
- # with image
- self.plot.addImage(numpy.arange(100 * 100).reshape(100, -1))
- self.mousePress(widget, qt.Qt.LeftButton, pos=pos1)
- self.mouseMove(widget, pos=pos2)
- self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2)
+ def tearDown(self):
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ self.plot = None
- self.mouseMove(widget)
- self.mouseClick(widget, qt.Qt.LeftButton)
+ super(TestProfile3DToolBar, self).tearDown()
- def testDiagonalProfile(self):
- """Test diagonal profile, without and with image"""
- # Use Plot backend widget to submit mouse events
- widget = self.plot.getWidgetHandle()
+ def testMethodProfile1DAnd2D(self):
+ """Test that the profile can have a different method if we want to
+ compute then in 1D or in 2D"""
- # 2 positions to use for mouse events
- pos1 = widget.width() * 0.4, widget.height() * 0.4
- pos2 = widget.width() * 0.6, widget.height() * 0.6
+ _3DProfileToolbar = self.plot.getProfileToolbar()
+ _2DProfilePlot = _3DProfileToolbar.getProfilePlot()
+ self.plot.getProfileToolbar().setProfileMethod('mean')
+ self.plot.getProfileToolbar().lineWidthSpinBox.setValue(3)
+ self.assertTrue(_3DProfileToolbar.getProfileMethod() == 'mean')
- # Trigger tool button for diagonal profile mode
- toolButton = getQToolButtonFromAction(self.toolBar.lineAction)
+ # check 2D 'mean' profile
+ _3DProfileToolbar.profile3dAction.computeProfileIn2D()
+ toolButton = getQToolButtonFromAction(_3DProfileToolbar.vLineAction)
self.assertIsNot(toolButton, None)
self.mouseMove(toolButton)
self.mouseClick(toolButton, qt.Qt.LeftButton)
+ plot2D = self.plot.getPlot().getWidgetHandle()
+ pos1 = plot2D.width() * 0.5, plot2D.height() * 0.5
+ self.mouseClick(plot2D, qt.Qt.LeftButton, pos=pos1)
+ self.assertTrue(numpy.array_equal(
+ _2DProfilePlot.getActiveImage().getData(),
+ numpy.array([[1, 4], [7, 10], [13, 16]])
+ ))
+
+ # check 1D 'sum' profile
+ _2DProfileToolbar = _2DProfilePlot.getProfileToolbar()
+ _2DProfileToolbar.setProfileMethod('sum')
+ self.assertTrue(_2DProfileToolbar.getProfileMethod() == 'sum')
+ _1DProfilePlot = _2DProfileToolbar.getProfilePlot()
+
+ _2DProfileToolbar.lineWidthSpinBox.setValue(3)
+ toolButton = getQToolButtonFromAction(_2DProfileToolbar.vLineAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseMove(toolButton)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+ plot1D = _2DProfilePlot.getWidgetHandle()
+ pos1 = plot1D.width() * 0.5, plot1D.height() * 0.5
+ self.mouseClick(plot1D, qt.Qt.LeftButton, pos=pos1)
+ self.assertTrue(numpy.array_equal(
+ _1DProfilePlot.getAllCurves()[0].getData()[1],
+ numpy.array([5, 17, 29])
+ ))
+
+ def testMethodSumLine(self):
+ """Simple interaction test to make sure the sum is correctly computed
+ """
+ _3DProfileToolbar = self.plot.getProfileToolbar()
+ _2DProfilePlot = _3DProfileToolbar.getProfilePlot()
+ self.plot.getProfileToolbar().setProfileMethod('sum')
+ self.plot.getProfileToolbar().lineWidthSpinBox.setValue(3)
+ self.assertTrue(_3DProfileToolbar.getProfileMethod() == 'sum')
+
+ # check 2D 'mean' profile
+ _3DProfileToolbar.profile3dAction.computeProfileIn2D()
+ toolButton = getQToolButtonFromAction(_3DProfileToolbar.lineAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseMove(toolButton)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+ plot2D = self.plot.getPlot().getWidgetHandle()
+ pos1 = plot2D.width() * 0.5, plot2D.height() * 0.2
+ pos2 = plot2D.width() * 0.5, plot2D.height() * 0.8
- for image in (False, True):
- with self.subTest(image=image):
- if image:
- self.plot.addImage(numpy.arange(100 * 100).reshape(100, -1))
-
- self.mouseMove(widget, pos=pos1)
- self.mousePress(widget, qt.Qt.LeftButton, pos=pos1)
- self.mouseMove(widget, pos=pos2)
- self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2)
-
- self.plot.clear()
+ self.mouseMove(plot2D, pos=pos1)
+ self.mousePress(plot2D, qt.Qt.LeftButton, pos=pos1)
+ self.mouseMove(plot2D, pos=pos2)
+ self.mouseRelease(plot2D, qt.Qt.LeftButton, pos=pos2)
+ self.assertTrue(numpy.array_equal(
+ _2DProfilePlot.getActiveImage().getData(),
+ numpy.array([[3, 12], [21, 30], [39, 48]])
+ ))
class TestGetProfilePlot(TestCaseQt):
@@ -157,8 +267,6 @@ class TestGetProfilePlot(TestCaseQt):
self.assertIsInstance(plot.getProfileToolbar().getProfileMainWindow(),
qt.QMainWindow)
- # plot.getProfileToolbar().profile3dAction.computeProfileIn2D() # default
-
self.assertIsInstance(plot.getProfileToolbar().getProfilePlot(),
Plot2D)
plot.getProfileToolbar().profile3dAction.computeProfileIn1D()
@@ -172,8 +280,8 @@ class TestGetProfilePlot(TestCaseQt):
def suite():
test_suite = unittest.TestSuite()
- # test_suite.addTest(positionInfoTestSuite)
- for testClass in (TestProfileToolBar, TestGetProfilePlot):
+ for testClass in (TestProfileToolBar, TestGetProfilePlot,
+ TestProfile3DToolBar):
test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(
testClass))
return test_suite
diff --git a/silx/gui/plot/test/testScatterMaskToolsWidget.py b/silx/gui/plot/test/testScatterMaskToolsWidget.py
index 0342c8f..a446911 100644
--- a/silx/gui/plot/test/testScatterMaskToolsWidget.py
+++ b/silx/gui/plot/test/testScatterMaskToolsWidget.py
@@ -38,7 +38,7 @@ import numpy
from silx.gui import qt
from silx.test.utils import temp_dir
from silx.utils.testutils import ParametricTestCase
-from silx.gui.test.utils import getQToolButtonFromAction
+from silx.gui.utils.testutils import getQToolButtonFromAction
from silx.gui.plot import PlotWindow, ScatterMaskToolsWidget
from .utils import PlotWidgetTestCase
@@ -89,10 +89,10 @@ class TestScatterMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=pos0)
- self.mousePress(plot, qt.Qt.LeftButton, pos=pos0)
+ self.mouseClick(plot, qt.Qt.LeftButton, pos=pos0)
self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=pos1)
- self.mouseRelease(plot, qt.Qt.LeftButton, pos=pos1)
+ self.mouseClick(plot, qt.Qt.LeftButton, pos=pos1)
def _drawPolygon(self):
"""Draw a star polygon in the plot"""
@@ -110,7 +110,9 @@ class TestScatterMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
self.mouseMove(plot, pos=[0, 0])
for pos in star:
self.mouseMove(plot, pos=pos)
+ self.qapp.processEvents()
self.mouseClick(plot, qt.Qt.LeftButton, pos=pos)
+ self.qapp.processEvents()
def _drawPencil(self):
"""Draw a star polygon in the plot"""
diff --git a/silx/gui/plot/test/testScatterView.py b/silx/gui/plot/test/testScatterView.py
index 40fdac6..583e3ed 100644
--- a/silx/gui/plot/test/testScatterView.py
+++ b/silx/gui/plot/test/testScatterView.py
@@ -103,6 +103,25 @@ class TestScatterView(PlotWidgetTestCase):
self.assertIsNone(data[3]) # xerror
self.assertIsNone(data[4]) # yerror
+ def testAlpha(self):
+ """Test alpha transparency in setData"""
+ _pts = 100
+ _levels = 100
+ _fwhm = 50
+ x = numpy.random.rand(_pts)*_levels
+ y = numpy.random.rand(_pts)*_levels
+ value = numpy.random.rand(_pts)*_levels
+ x0 = x[int(_pts/2)]
+ y0 = x[int(_pts/2)]
+ #2D Gaussian kernel
+ alpha = numpy.exp(-4*numpy.log(2) * ((x-x0)**2 + (y-y0)**2) / _fwhm**2)
+
+ self.plot.setData(x, y, value, alpha=alpha)
+ self.qapp.processEvents()
+
+ alphaData = self.plot.getScatterItem().getAlphaData()
+ self.assertTrue(numpy.all(numpy.equal(alpha, alphaData)))
+
def suite():
test_suite = unittest.TestSuite()
diff --git a/silx/gui/plot/test/testStackView.py b/silx/gui/plot/test/testStackView.py
index 3dcea36..a5f649c 100644
--- a/silx/gui/plot/test/testStackView.py
+++ b/silx/gui/plot/test/testStackView.py
@@ -32,7 +32,7 @@ __date__ = "20/03/2017"
import unittest
import numpy
-from silx.gui.test.utils import TestCaseQt, SignalListener
+from silx.gui.utils.testutils import TestCaseQt, SignalListener
from silx.gui import qt
from silx.gui.plot import StackView
@@ -123,8 +123,9 @@ class TestStackView(TestCaseQt):
"Plane selection combobox not updating perspective")
self.stackview.setStack(numpy.arange(6).reshape((1, 2, 3)))
- self.assertEqual(self.stackview._perspective, 0,
- "Default perspective not restored in setStack.")
+ self.assertEqual(self.stackview._perspective, 1,
+ "Perspective not preserved when calling setStack "
+ "without specifying the perspective parameter.")
self.stackview.setStack(numpy.arange(24).reshape((2, 3, 4)), perspective=2)
self.assertEqual(self.stackview._perspective, 2,
diff --git a/silx/gui/plot/test/testStats.py b/silx/gui/plot/test/testStats.py
index 123eb89..faedcff 100644
--- a/silx/gui/plot/test/testStats.py
+++ b/silx/gui/plot/test/testStats.py
@@ -33,7 +33,7 @@ from silx.gui import qt
from silx.gui.plot.stats import stats
from silx.gui.plot import StatsWidget
from silx.gui.plot.stats import statshandler
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot import Plot1D, Plot2D
import unittest
import logging
@@ -361,6 +361,7 @@ class TestStatsWidgetWithCurves(TestCaseQt):
def setUp(self):
TestCaseQt.setUp(self)
self.plot = Plot1D()
+ self.plot.show()
x = range(20)
y = range(20)
self.plot.addCurve(x, y, legend='curve0')
diff --git a/silx/gui/plot/test/testUtilsAxis.py b/silx/gui/plot/test/testUtilsAxis.py
index 3f19dcd..016fafe 100644
--- a/silx/gui/plot/test/testUtilsAxis.py
+++ b/silx/gui/plot/test/testUtilsAxis.py
@@ -31,7 +31,7 @@ __date__ = "14/02/2018"
import unittest
from silx.gui.plot import PlotWidget
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot.utils.axis import SyncAxes
diff --git a/silx/gui/plot/test/utils.py b/silx/gui/plot/test/utils.py
index efba39c..ed1917a 100644
--- a/silx/gui/plot/test/utils.py
+++ b/silx/gui/plot/test/utils.py
@@ -31,7 +31,7 @@ __date__ = "26/01/2018"
import logging
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui import qt
from silx.gui.plot import PlotWidget
diff --git a/silx/gui/plot/tools/CurveLegendsWidget.py b/silx/gui/plot/tools/CurveLegendsWidget.py
new file mode 100644
index 0000000..7b63b29
--- /dev/null
+++ b/silx/gui/plot/tools/CurveLegendsWidget.py
@@ -0,0 +1,247 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides a widget to display :class:`PlotWidget` curve legends.
+"""
+
+from __future__ import division
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "20/07/2018"
+
+
+import logging
+import weakref
+
+
+from ... import qt
+from ...widgets.FlowLayout import FlowLayout as _FlowLayout
+from ..LegendSelector import LegendIcon as _LegendIcon
+from .. import items
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _LegendWidget(qt.QWidget):
+ """Widget displaying curve style and its legend
+
+ :param QWidget parent: See :class:`QWidget`
+ :param ~silx.gui.plot.items.Curve curve: Associated curve
+ """
+
+ def __init__(self, parent, curve):
+ super(_LegendWidget, self).__init__(parent)
+ layout = qt.QHBoxLayout(self)
+ layout.setContentsMargins(10, 0, 10, 0)
+
+ curve.sigItemChanged.connect(self._curveChanged)
+
+ icon = _LegendIcon(curve=curve)
+ layout.addWidget(icon)
+
+ label = qt.QLabel(curve.getLegend())
+ label.setAlignment(qt.Qt.AlignLeft | qt.Qt.AlignVCenter)
+ layout.addWidget(label)
+
+ self._update()
+
+ def getCurve(self):
+ """Returns curve associated to this widget
+
+ :rtype: Union[~silx.gui.plot.items.Curve,None]
+ """
+ icon = self.findChild(_LegendIcon)
+ return icon.getCurve()
+
+ def _update(self):
+ """Update widget according to current curve state.
+ """
+ curve = self.getCurve()
+ if curve is None:
+ _logger.error('Curve no more exists')
+ self.setVisible(False)
+ return
+
+ self.setEnabled(curve.isVisible())
+
+ label = self.findChild(qt.QLabel)
+ if curve.isHighlighted():
+ label.setStyleSheet("border: 1px solid black")
+ else:
+ label.setStyleSheet("")
+
+ def _curveChanged(self, event):
+ """Handle update of curve item
+
+ :param event: Kind of change
+ """
+ if event in (items.ItemChangedType.VISIBLE,
+ items.ItemChangedType.HIGHLIGHTED,
+ items.ItemChangedType.HIGHLIGHTED_STYLE):
+ self._update()
+
+
+class CurveLegendsWidget(qt.QWidget):
+ """Widget displaying curves legends in a plot
+
+ :param QWidget parent: See :class:`QWidget`
+ """
+
+ sigCurveClicked = qt.Signal(object)
+ """Signal emitted when the legend of a curve is clicked
+
+ It provides the corresponding curve.
+ """
+
+ def __init__(self, parent=None):
+ super(CurveLegendsWidget, self).__init__(parent)
+ self._clicked = None
+ self._legends = {}
+ self._plotRef = None
+
+ def layout(self):
+ layout = super(CurveLegendsWidget, self).layout()
+ if layout is None:
+ # Lazy layout initialization to allow overloading
+ layout = _FlowLayout()
+ layout.setHorizontalSpacing(0)
+ self.setLayout(layout)
+ return layout
+
+ def getPlotWidget(self):
+ """Returns the associated :class:`PlotWidget`
+
+ :rtype: Union[~silx.gui.plot.PlotWidget,None]
+ """
+ return None if self._plotRef is None else self._plotRef()
+
+ def setPlotWidget(self, plot):
+ """Set the associated :class:`PlotWidget`
+
+ :param ~silx.gui.plot.PlotWidget plot: Plot widget to attach
+ """
+ previousPlot = self.getPlotWidget()
+ if previousPlot is not None:
+ previousPlot.sigItemAdded.disconnect( self._itemAdded)
+ previousPlot.sigItemAboutToBeRemoved.disconnect(self._itemRemoved)
+ for legend in list(self._legends.keys()):
+ self._removeLegend(legend)
+
+ self._plotRef = None if plot is None else weakref.ref(plot)
+
+ if plot is not None:
+ plot.sigItemAdded.connect(self._itemAdded)
+ plot.sigItemAboutToBeRemoved.connect(self._itemRemoved)
+
+ for legend in plot.getAllCurves(just_legend=True):
+ self._addLegend(legend)
+
+ def curveAt(self, *args):
+ """Returns the curve object represented at the given position
+
+ Either takes a QPoint or x and y as input in widget coordinates.
+
+ :rtype: Union[~silx.gui.plot.items.Curve,None]
+ """
+ if len(args) == 1:
+ point = args[0]
+ elif len(args) == 2:
+ point = qt.QPoint(*args)
+ else:
+ raise ValueError('Unsupported arguments')
+ assert isinstance(point, qt.QPoint)
+
+ widget = self.childAt(point)
+ while widget not in (self, None):
+ if isinstance(widget, _LegendWidget):
+ return widget.getCurve()
+ widget = widget.parent()
+ return None # No widget or not in _LegendWidget
+
+ def _itemAdded(self, item):
+ """Handle item added to the plot content"""
+ if isinstance(item, items.Curve):
+ self._addLegend(item.getLegend())
+
+ def _itemRemoved(self, item):
+ """Handle item removed from the plot content"""
+ if isinstance(item, items.Curve):
+ self._removeLegend(item.getLegend())
+
+ def _addLegend(self, legend):
+ """Add a curve to the legends
+
+ :param str legend: Curve's legend
+ """
+ if legend in self._legends:
+ return # Can happen when changing curve's y axis
+
+ plot = self.getPlotWidget()
+ if plot is None:
+ return None
+
+ curve = plot.getCurve(legend)
+ if curve is None:
+ _logger.error('Curve not found: %s' % legend)
+ return
+
+ widget = _LegendWidget(parent=self, curve=curve)
+ self.layout().addWidget(widget)
+ self._legends[legend] = widget
+
+ def _removeLegend(self, legend):
+ """Remove a curve from the legends if it exists
+
+ :param str legend: The curve's legend
+ """
+ widget = self._legends.pop(legend, None)
+ if widget is None:
+ _logger.warning('Unknown legend: %s' % legend)
+ else:
+ self.layout().removeWidget(widget)
+ widget.setParent(None)
+
+ def mousePressEvent(self, event):
+ if event.button() == qt.Qt.LeftButton:
+ self._clicked = event.pos()
+
+ _CLICK_THRESHOLD = 5
+ """Threshold for clicks"""
+
+ def mouseMoveEvent(self, event):
+ if self._clicked is not None:
+ dx = abs(self._clicked.x() - event.pos().x())
+ dy = abs(self._clicked.y() - event.pos().y())
+ if dx > self._CLICK_THRESHOLD or dy > self._CLICK_THRESHOLD:
+ self._clicked = None # Click is cancelled
+
+ def mouseReleaseEvent(self, event):
+ if event.button() == qt.Qt.LeftButton and self._clicked is not None:
+ curve = self.curveAt(event.pos())
+ if curve is not None:
+ self.sigCurveClicked.emit(curve)
+
+ self._clicked = None
diff --git a/silx/gui/plot/tools/profile/ImageProfileToolBar.py b/silx/gui/plot/tools/profile/ImageProfileToolBar.py
deleted file mode 100644
index 207a2e2..0000000
--- a/silx/gui/plot/tools/profile/ImageProfileToolBar.py
+++ /dev/null
@@ -1,271 +0,0 @@
-# TODO quick & dirty proof of concept
-
-import numpy
-
-from silx.gui.plot.tools.profile.ScatterProfileToolBar import _BaseProfileToolBar
-from .. import items
-from ...colors import cursorColorForColormap
-from ....image.bilinear import BilinearImage
-
-
-def _alignedPartialProfile(data, rowRange, colRange, axis):
- """Mean of a rectangular region (ROI) of a stack of images
- along a given axis.
-
- Returned values and all parameters are in image coordinates.
-
- :param numpy.ndarray data: 3D volume (stack of 2D images)
- The first dimension is the image index.
- :param rowRange: [min, max[ of ROI rows (upper bound excluded).
- :type rowRange: 2-tuple of int (min, max) with min < max
- :param colRange: [min, max[ of ROI columns (upper bound excluded).
- :type colRange: 2-tuple of int (min, max) with min < max
- :param int axis: The axis along which to take the profile of the ROI.
- 0: Sum rows along columns.
- 1: Sum columns along rows.
- :return: Profile image along the ROI as the mean of the intersection
- of the ROI and the image.
- """
- assert axis in (0, 1)
- assert len(data.shape) == 3
- assert rowRange[0] < rowRange[1]
- assert colRange[0] < colRange[1]
-
- nimages, height, width = data.shape
-
- # Range aligned with the integration direction
- profileRange = colRange if axis == 0 else rowRange
-
- profileLength = abs(profileRange[1] - profileRange[0])
-
- # Subset of the image to use as intersection of ROI and image
- rowStart = min(max(0, rowRange[0]), height)
- rowEnd = min(max(0, rowRange[1]), height)
- colStart = min(max(0, colRange[0]), width)
- colEnd = min(max(0, colRange[1]), width)
-
- imgProfile = numpy.mean(data[:, rowStart:rowEnd, colStart:colEnd],
- axis=axis + 1, dtype=numpy.float32)
-
- # Profile including out of bound area
- profile = numpy.zeros((nimages, profileLength), dtype=numpy.float32)
-
- # Place imgProfile in full profile
- offset = - min(0, profileRange[0])
- profile[:, offset:offset + imgProfile.shape[1]] = imgProfile
-
- return profile
-
-
-def createProfile(points, data, origin, scale, lineWidth):
- """Create the profile line for the the given image.
-
- :param points: Coords of profile end points: (x0, y0, x1, y1)
- :param numpy.ndarray data: the 2D image or the 3D stack of images
- on which we compute the profile.
- :param origin: (ox, oy) the offset from origin
- :type origin: 2-tuple of float
- :param scale: (sx, sy) the scale to use
- :type scale: 2-tuple of float
- :param int lineWidth: width of the profile line
- :return: `profile, area`, where:
- - profile is a 2D array of the profiles of the stack of images.
- For a single image, the profile is a curve, so this parameter
- has a shape *(1, len(curve))*
- - area is a tuple of two 1D arrays with 4 values each. They represent
- the effective ROI area corners in plot coords.
-
- :rtype: tuple(ndarray, (ndarray, ndarray), str, str)
- """
- if data is None or points is None or lineWidth is None:
- raise ValueError("createProfile called with invalid arguments")
-
- # force 3D data (stack of images)
- if len(data.shape) == 2:
- data3D = data.reshape((1,) + data.shape)
- elif len(data.shape) == 3:
- data3D = data
-
- roiWidth = max(1, lineWidth)
- x0, y0, x1, y1 = points
-
- # Convert start and end points in image coords as (row, col)
- startPt = ((y0 - origin[1]) / scale[1],
- (x0 - origin[0]) / scale[0])
- endPt = ((y1 - origin[1]) / scale[1],
- (x1 - origin[0]) / scale[0])
-
- if (int(startPt[0]) == int(endPt[0]) or
- int(startPt[1]) == int(endPt[1])):
- # Profile is aligned with one of the axes
-
- # Convert to int
- startPt = int(startPt[0]), int(startPt[1])
- endPt = int(endPt[0]), int(endPt[1])
-
- # Ensure startPt <= endPt
- if startPt[0] > endPt[0] or startPt[1] > endPt[1]:
- startPt, endPt = endPt, startPt
-
- if startPt[0] == endPt[0]: # Row aligned
- rowRange = (int(startPt[0] + 0.5 - 0.5 * roiWidth),
- int(startPt[0] + 0.5 + 0.5 * roiWidth))
- colRange = startPt[1], endPt[1] + 1
- profile = _alignedPartialProfile(data3D,
- rowRange, colRange,
- axis=0)
-
- else: # Column aligned
- rowRange = startPt[0], endPt[0] + 1
- colRange = (int(startPt[1] + 0.5 - 0.5 * roiWidth),
- int(startPt[1] + 0.5 + 0.5 * roiWidth))
- profile = _alignedPartialProfile(data3D,
- rowRange, colRange,
- axis=1)
-
- # Convert ranges to plot coords to draw ROI area
- area = (
- numpy.array(
- (colRange[0], colRange[1], colRange[1], colRange[0]),
- dtype=numpy.float32) * scale[0] + origin[0],
- numpy.array(
- (rowRange[0], rowRange[0], rowRange[1], rowRange[1]),
- dtype=numpy.float32) * scale[1] + origin[1])
-
- else: # General case: use bilinear interpolation
-
- # Ensure startPt <= endPt
- if (startPt[1] > endPt[1] or (
- startPt[1] == endPt[1] and startPt[0] > endPt[0])):
- startPt, endPt = endPt, startPt
-
- profile = []
- for slice_idx in range(data3D.shape[0]):
- bilinear = BilinearImage(data3D[slice_idx, :, :])
-
- profile.append(bilinear.profile_line(
- (startPt[0] - 0.5, startPt[1] - 0.5),
- (endPt[0] - 0.5, endPt[1] - 0.5),
- roiWidth))
- profile = numpy.array(profile)
-
- # Extend ROI with half a pixel on each end, and
- # Convert back to plot coords (x, y)
- length = numpy.sqrt((endPt[0] - startPt[0]) ** 2 +
- (endPt[1] - startPt[1]) ** 2)
- dRow = (endPt[0] - startPt[0]) / length
- dCol = (endPt[1] - startPt[1]) / length
-
- # Extend ROI with half a pixel on each end
- startPt = startPt[0] - 0.5 * dRow, startPt[1] - 0.5 * dCol
- endPt = endPt[0] + 0.5 * dRow, endPt[1] + 0.5 * dCol
-
- # Rotate deltas by 90 degrees to apply line width
- dRow, dCol = dCol, -dRow
-
- area = (
- numpy.array((startPt[1] - 0.5 * roiWidth * dCol,
- startPt[1] + 0.5 * roiWidth * dCol,
- endPt[1] + 0.5 * roiWidth * dCol,
- endPt[1] - 0.5 * roiWidth * dCol),
- dtype=numpy.float32) * scale[0] + origin[0],
- numpy.array((startPt[0] - 0.5 * roiWidth * dRow,
- startPt[0] + 0.5 * roiWidth * dRow,
- endPt[0] + 0.5 * roiWidth * dRow,
- endPt[0] - 0.5 * roiWidth * dRow),
- dtype=numpy.float32) * scale[1] + origin[1])
-
- xProfile = numpy.arange(len(profile[0]), dtype=numpy.float64)
-
- return (xProfile, profile[0]), area
-
-
-class ImageProfileToolBar(_BaseProfileToolBar):
-
- def __init__(self, parent=None, plot=None, title='Image Profile'):
- super(ImageProfileToolBar, self).__init__(parent, plot, title)
- plot.sigActiveImageChanged.connect(self.__activeImageChanged)
-
- roiManager = self._getRoiManager()
- if roiManager is None:
- _logger.error(
- "Error during scatter profile toolbar initialisation")
- else:
- roiManager.sigInteractiveModeStarted.connect(
- self.__interactionStarted)
- roiManager.sigInteractiveModeFinished.connect(
- self.__interactionFinished)
- if roiManager.isStarted():
- self.__interactionStarted(roiManager.getRegionOfInterestKind())
-
- def __interactionStarted(self, kind):
- """Handle start of ROI interaction"""
- plot = self.getPlotWidget()
- if plot is None:
- return
-
- plot.sigActiveImageChanged.connect(self.__activeImageChanged)
-
- image = plot.getActiveImage()
- legend = None if image is None else image.getLegend()
- self.__activeImageChanged(None, legend)
-
- def __interactionFinished(self, rois):
- """Handle end of ROI interaction"""
- plot = self.getPlotWidget()
- if plot is None:
- return
-
- plot.sigActiveImageChanged.disconnect(self.__activeImageChanged)
-
- image = plot.getActiveImage()
- legend = None if image is None else image.getLegend()
- self.__activeImageChanged(legend, None)
-
- def __activeImageChanged(self, previous, legend):
- """Handle active image change: toggle enabled toolbar, update curve"""
- plot = self.getPlotWidget()
- if plot is None:
- return
-
- activeImage = plot.getActiveImage()
- if activeImage is None:
- self.setEnabled(False)
- else:
- # Disable for empty image
- self.setEnabled(activeImage.getData(copy=False).size > 0)
-
- # Update default profile color
- if isinstance(activeImage, items.ColormapMixIn):
- self.setColor(cursorColorForColormap(
- activeImage.getColormap()['name'])) # TODO change thsi
- else:
- self.setColor('black')
-
- self.updateProfile()
-
- def computeProfile(self, x0, y0, x1, y1):
- """Compute corresponding profile
-
- :param float x0: Profile start point X coord
- :param float y0: Profile start point Y coord
- :param float x1: Profile end point X coord
- :param float y1: Profile end point Y coord
- :return: (x, y) profile data or None
- """
- plot = self.getPlotWidget()
- if plot is None:
- return None
-
- image = plot.getActiveImage()
- if image is None:
- return None
-
- profile, area = createProfile(
- points=(x0, y0, x1, y1),
- data=image.getData(copy=False),
- origin=image.getOrigin(),
- scale=image.getScale(),
- lineWidth=1) # TODO
-
- return profile \ No newline at end of file
diff --git a/silx/gui/plot/tools/test/__init__.py b/silx/gui/plot/tools/test/__init__.py
index 79301ab..9cede27 100644
--- a/silx/gui/plot/tools/test/__init__.py
+++ b/silx/gui/plot/tools/test/__init__.py
@@ -32,6 +32,7 @@ import unittest
from . import testROI
from . import testTools
from . import testScatterProfileToolBar
+from . import testCurveLegendsWidget
def suite():
@@ -40,6 +41,7 @@ def suite():
[testROI.suite(),
testTools.suite(),
testScatterProfileToolBar.suite(),
+ testCurveLegendsWidget.suite(),
])
return test_suite
diff --git a/silx/gui/plot/tools/test/testCurveLegendsWidget.py b/silx/gui/plot/tools/test/testCurveLegendsWidget.py
new file mode 100644
index 0000000..4824dd7
--- /dev/null
+++ b/silx/gui/plot/tools/test/testCurveLegendsWidget.py
@@ -0,0 +1,125 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "02/08/2018"
+
+
+import unittest
+
+from silx.gui import qt
+from silx.utils.testutils import ParametricTestCase
+from silx.gui.utils.testutils import TestCaseQt
+from silx.gui.plot import PlotWindow
+from silx.gui.plot.tools import CurveLegendsWidget
+
+
+class TestCurveLegendsWidget(TestCaseQt, ParametricTestCase):
+ """Tests for CurveLegendsWidget class"""
+
+ def setUp(self):
+ super(TestCurveLegendsWidget, self).setUp()
+ self.plot = PlotWindow()
+
+ self.legends = CurveLegendsWidget.CurveLegendsWidget()
+ self.legends.setPlotWidget(self.plot)
+
+ dock = qt.QDockWidget()
+ dock.setWindowTitle('Curve Legends')
+ dock.setWidget(self.legends)
+ self.plot.addTabbedDockWidget(dock)
+
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ def tearDown(self):
+ del self.legends
+ self.qapp.processEvents()
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+ super(TestCurveLegendsWidget, self).tearDown()
+
+ def _assertNbLegends(self, count):
+ """Check the number of legends in the CurveLegendsWidget"""
+ children = self.legends.findChildren(CurveLegendsWidget._LegendWidget)
+ self.assertEqual(len(children), count)
+
+ def testAddRemoveCurves(self):
+ """Test CurveLegendsWidget while adding/removing curves"""
+ self.plot.addCurve((0, 1), (1, 2), legend='a')
+ self._assertNbLegends(1)
+ self.plot.addCurve((0, 1), (2, 3), legend='b')
+ self._assertNbLegends(2)
+
+ # Detached/attach
+ self.legends.setPlotWidget(None)
+ self._assertNbLegends(0)
+
+ self.legends.setPlotWidget(self.plot)
+ self._assertNbLegends(2)
+
+ self.plot.clear()
+ self._assertNbLegends(0)
+
+ def testUpdateCurves(self):
+ """Test CurveLegendsWidget while updating curves """
+ self.plot.addCurve((0, 1), (1, 2), legend='a')
+ self._assertNbLegends(1)
+ self.plot.addCurve((0, 1), (2, 3), legend='b')
+ self._assertNbLegends(2)
+
+ # Activate curve
+ self.plot.setActiveCurve('a')
+ self.qapp.processEvents()
+ self.plot.setActiveCurve('b')
+ self.qapp.processEvents()
+
+ # Change curve style
+ curve = self.plot.getCurve('a')
+ curve.setLineWidth(2)
+ for linestyle in (':', '', '--', '-'):
+ with self.subTest(linestyle=linestyle):
+ curve.setLineStyle(linestyle)
+ self.qapp.processEvents()
+ self.qWait(1000)
+
+ for symbol in ('o', 'd', '', 's'):
+ with self.subTest(symbol=symbol):
+ curve.setSymbol(symbol)
+ self.qapp.processEvents()
+ self.qWait(1000)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(
+ TestCurveLegendsWidget))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/tools/test/testROI.py b/silx/gui/plot/tools/test/testROI.py
index 5032036..8aec1d9 100644
--- a/silx/gui/plot/tools/test/testROI.py
+++ b/silx/gui/plot/tools/test/testROI.py
@@ -32,7 +32,7 @@ import numpy.testing
from silx.gui import qt
from silx.utils.testutils import ParametricTestCase
-from silx.gui.test.utils import TestCaseQt, SignalListener
+from silx.gui.utils.testutils import TestCaseQt, SignalListener
from silx.gui.plot import PlotWindow
import silx.gui.plot.items.roi as roi_items
from silx.gui.plot.tools import roi
diff --git a/silx/gui/plot/tools/test/testScatterProfileToolBar.py b/silx/gui/plot/tools/test/testScatterProfileToolBar.py
index 16972f9..b99cac7 100644
--- a/silx/gui/plot/tools/test/testScatterProfileToolBar.py
+++ b/silx/gui/plot/tools/test/testScatterProfileToolBar.py
@@ -32,7 +32,7 @@ import numpy
from silx.gui import qt
from silx.utils.testutils import ParametricTestCase
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot import PlotWindow
from silx.gui.plot.tools import profile
import silx.gui.plot.items.roi as roi_items
diff --git a/silx/gui/plot/tools/test/testTools.py b/silx/gui/plot/tools/test/testTools.py
index 810b933..f4adda0 100644
--- a/silx/gui/plot/tools/test/testTools.py
+++ b/silx/gui/plot/tools/test/testTools.py
@@ -34,7 +34,7 @@ import unittest
import numpy
from silx.utils.testutils import TestLogging
-from silx.gui.test.utils import qWaitForWindowExposedAndActivate
+from silx.gui.utils.testutils import qWaitForWindowExposedAndActivate
from silx.gui import qt
from silx.gui.plot import PlotWindow
from silx.gui.plot import tools
diff --git a/silx/gui/plot/utils/axis.py b/silx/gui/plot/utils/axis.py
index fae50b4..bd19996 100644
--- a/silx/gui/plot/utils/axis.py
+++ b/silx/gui/plot/utils/axis.py
@@ -35,6 +35,13 @@ from contextlib import contextmanager
import weakref
import silx.utils.weakref as silxWeakref
+try:
+ from ...qt.inspect import isValid as _isQObjectValid
+except ImportError: # PySide(1) fallback
+ def _isQObjectValid(obj):
+ return True
+
+
_logger = logging.getLogger(__name__)
@@ -135,7 +142,7 @@ class SyncAxes(object):
raise RuntimeError("Axes not synchronized")
for ref, callbacks in self.__callbacks.items():
axis = ref()
- if axis is not None:
+ if axis is not None and _isQObjectValid(axis):
for sigName, callback in callbacks:
sig = getattr(axis, sigName)
sig.disconnect(callback)