diff options
Diffstat (limited to 'silx/gui/plot')
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) |