From cebdc9244c019224846cb8d2668080fe386a6adc Mon Sep 17 00:00:00 2001 From: Alexandre Marie Date: Mon, 17 Dec 2018 12:28:24 +0100 Subject: New upstream version 0.9.0+dfsg --- silx/gui/_glutils/font.py | 2 +- silx/gui/colors.py | 9 +- silx/gui/data/Hdf5TableView.py | 78 +- silx/gui/data/NXdataWidgets.py | 3 +- silx/gui/data/RecordTableView.py | 24 +- silx/gui/data/TextFormatter.py | 10 +- silx/gui/data/test/test_arraywidget.py | 2 +- silx/gui/data/test/test_dataviewer.py | 16 +- silx/gui/data/test/test_numpyaxesselector.py | 4 +- silx/gui/data/test/test_textformatter.py | 52 +- silx/gui/dialog/AbstractDataFileDialog.py | 2 +- silx/gui/dialog/ColormapDialog.py | 5 +- silx/gui/dialog/DatasetDialog.py | 122 ++ silx/gui/dialog/GroupDialog.py | 161 ++- silx/gui/dialog/SafeFileIconProvider.py | 4 + silx/gui/dialog/SafeFileSystemModel.py | 2 +- silx/gui/dialog/test/test_colormapdialog.py | 4 +- silx/gui/dialog/test/test_datafiledialog.py | 120 +- silx/gui/dialog/test/test_imagefiledialog.py | 86 +- silx/gui/fit/FitConfig.py | 9 +- silx/gui/fit/FitWidget.py | 10 +- silx/gui/fit/test/testBackgroundWidget.py | 2 +- silx/gui/fit/test/testFitConfig.py | 2 +- silx/gui/fit/test/testFitWidget.py | 2 +- silx/gui/hdf5/Hdf5Item.py | 41 +- silx/gui/hdf5/Hdf5LoadingItem.py | 11 +- silx/gui/hdf5/Hdf5Node.py | 9 +- silx/gui/hdf5/Hdf5TreeModel.py | 15 +- silx/gui/hdf5/NexusSortFilterProxyModel.py | 20 +- silx/gui/hdf5/_utils.py | 11 +- silx/gui/hdf5/test/test_hdf5.py | 122 +- silx/gui/icons.py | 38 +- silx/gui/plot/ColorBar.py | 12 +- silx/gui/plot/CompareImages.py | 1190 ++++++++++++++++++++ silx/gui/plot/ImageView.py | 9 +- silx/gui/plot/LegendSelector.py | 134 ++- silx/gui/plot/MaskToolsWidget.py | 129 ++- silx/gui/plot/PlotToolButtons.py | 56 + silx/gui/plot/PlotWidget.py | 183 ++- silx/gui/plot/PlotWindow.py | 5 +- silx/gui/plot/PrintPreviewToolButton.py | 5 +- silx/gui/plot/Profile.py | 89 +- silx/gui/plot/ProfileMainWindow.py | 16 +- silx/gui/plot/ScatterMaskToolsWidget.py | 61 +- silx/gui/plot/ScatterView.py | 10 +- silx/gui/plot/StackView.py | 100 +- silx/gui/plot/StatsWidget.py | 18 +- silx/gui/plot/_BaseMaskToolsWidget.py | 6 +- silx/gui/plot/_utils/test/testColormap.py | 648 ----------- silx/gui/plot/actions/PlotToolAction.py | 150 +++ silx/gui/plot/actions/control.py | 1 + silx/gui/plot/actions/fit.py | 63 +- silx/gui/plot/actions/histogram.py | 88 +- silx/gui/plot/actions/io.py | 12 +- silx/gui/plot/actions/medfilt.py | 38 +- silx/gui/plot/backends/BackendBase.py | 17 +- silx/gui/plot/backends/BackendMatplotlib.py | 28 +- silx/gui/plot/backends/BackendOpenGL.py | 17 +- silx/gui/plot/backends/glutils/GLText.py | 46 +- silx/gui/plot/items/core.py | 23 +- silx/gui/plot/items/curve.py | 213 +++- silx/gui/plot/items/histogram.py | 7 +- silx/gui/plot/items/marker.py | 42 +- silx/gui/plot/items/scatter.py | 30 +- silx/gui/plot/matplotlib/ModestImage.py | 174 --- silx/gui/plot/test/__init__.py | 6 +- silx/gui/plot/test/testAlphaSlider.py | 2 +- silx/gui/plot/test/testColorBar.py | 2 +- silx/gui/plot/test/testCompareImages.py | 117 ++ silx/gui/plot/test/testCurvesROIWidget.py | 2 +- silx/gui/plot/test/testImageView.py | 2 +- silx/gui/plot/test/testItem.py | 2 +- silx/gui/plot/test/testLegendSelector.py | 2 +- silx/gui/plot/test/testMaskToolsWidget.py | 8 +- .../gui/plot/test/testPixelIntensityHistoAction.py | 2 +- silx/gui/plot/test/testPlotWidget.py | 203 +++- silx/gui/plot/test/testPlotWindow.py | 2 +- silx/gui/plot/test/testProfile.py | 198 +++- silx/gui/plot/test/testScatterMaskToolsWidget.py | 8 +- silx/gui/plot/test/testScatterView.py | 19 + silx/gui/plot/test/testStackView.py | 7 +- silx/gui/plot/test/testStats.py | 3 +- silx/gui/plot/test/testUtilsAxis.py | 2 +- silx/gui/plot/test/utils.py | 2 +- silx/gui/plot/tools/CurveLegendsWidget.py | 247 ++++ silx/gui/plot/tools/profile/ImageProfileToolBar.py | 271 ----- silx/gui/plot/tools/test/__init__.py | 2 + silx/gui/plot/tools/test/testCurveLegendsWidget.py | 125 ++ silx/gui/plot/tools/test/testROI.py | 2 +- .../plot/tools/test/testScatterProfileToolBar.py | 2 +- silx/gui/plot/tools/test/testTools.py | 2 +- silx/gui/plot/utils/axis.py | 9 +- silx/gui/plot3d/ParamTreeView.py | 11 +- silx/gui/plot3d/Plot3DWidget.py | 2 +- silx/gui/plot3d/SFViewParamTree.py | 64 +- silx/gui/plot3d/SceneWidget.py | 28 +- silx/gui/plot3d/_model/items.py | 2 +- silx/gui/plot3d/actions/io.py | 2 +- silx/gui/plot3d/items/_pick.py | 292 +++++ silx/gui/plot3d/items/clipplane.py | 90 +- silx/gui/plot3d/items/core.py | 201 +++- silx/gui/plot3d/items/image.py | 68 +- silx/gui/plot3d/items/mesh.py | 176 ++- silx/gui/plot3d/items/scatter.py | 182 ++- silx/gui/plot3d/items/volume.py | 173 ++- silx/gui/plot3d/scene/event.py | 4 +- silx/gui/plot3d/scene/function.py | 4 +- silx/gui/plot3d/scene/primitives.py | 2 +- silx/gui/plot3d/scene/setup.py | 41 - silx/gui/plot3d/scene/transform.py | 42 +- silx/gui/plot3d/scene/utils.py | 180 +++ silx/gui/plot3d/setup.py | 4 +- silx/gui/plot3d/test/__init__.py | 25 +- silx/gui/plot3d/test/testGL.py | 2 +- silx/gui/plot3d/test/testScalarFieldView.py | 29 +- silx/gui/plot3d/test/testSceneWidgetPicking.py | 267 +++++ silx/gui/plot3d/tools/PositionInfoWidget.py | 209 ++++ silx/gui/plot3d/tools/test/__init__.py | 41 + .../plot3d/tools/test/testPositionInfoWidget.py | 101 ++ silx/gui/qt/__init__.py | 7 +- silx/gui/qt/_pyside_dynamic.py | 55 +- silx/gui/qt/_qt.py | 43 +- silx/gui/qt/_utils.py | 5 +- silx/gui/qt/inspect.py | 82 ++ silx/gui/test/test_colors.py | 18 +- silx/gui/test/test_console.py | 2 +- silx/gui/test/test_icons.py | 2 +- silx/gui/test/test_qt.py | 63 +- silx/gui/test/utils.py | 507 +-------- silx/gui/utils/_image.py | 104 -- silx/gui/utils/image.py | 143 +++ silx/gui/utils/test/test_async.py | 2 +- silx/gui/utils/test/test_image.py | 50 +- silx/gui/utils/testutils.py | 520 +++++++++ silx/gui/widgets/FloatEdit.py | 4 +- silx/gui/widgets/FlowLayout.py | 177 +++ silx/gui/widgets/PrintPreview.py | 4 + silx/gui/widgets/RangeSlider.py | 627 +++++++++++ silx/gui/widgets/__init__.py | 8 +- silx/gui/widgets/test/__init__.py | 4 + silx/gui/widgets/test/test_boxlayoutdockwidget.py | 2 +- silx/gui/widgets/test/test_flowlayout.py | 77 ++ silx/gui/widgets/test/test_framebrowser.py | 2 +- .../gui/widgets/test/test_hierarchicaltableview.py | 2 +- silx/gui/widgets/test/test_periodictable.py | 2 +- silx/gui/widgets/test/test_printpreview.py | 2 +- silx/gui/widgets/test/test_rangeslider.py | 114 ++ silx/gui/widgets/test/test_tablewidget.py | 2 +- silx/gui/widgets/test/test_threadpoolpushbutton.py | 4 +- 149 files changed, 8018 insertions(+), 2725 deletions(-) create mode 100644 silx/gui/dialog/DatasetDialog.py create mode 100644 silx/gui/plot/CompareImages.py delete mode 100644 silx/gui/plot/_utils/test/testColormap.py create mode 100644 silx/gui/plot/actions/PlotToolAction.py delete mode 100644 silx/gui/plot/matplotlib/ModestImage.py create mode 100644 silx/gui/plot/test/testCompareImages.py create mode 100644 silx/gui/plot/tools/CurveLegendsWidget.py delete mode 100644 silx/gui/plot/tools/profile/ImageProfileToolBar.py create mode 100644 silx/gui/plot/tools/test/testCurveLegendsWidget.py create mode 100644 silx/gui/plot3d/items/_pick.py delete mode 100644 silx/gui/plot3d/scene/setup.py create mode 100644 silx/gui/plot3d/test/testSceneWidgetPicking.py create mode 100644 silx/gui/plot3d/tools/PositionInfoWidget.py create mode 100644 silx/gui/plot3d/tools/test/__init__.py create mode 100644 silx/gui/plot3d/tools/test/testPositionInfoWidget.py create mode 100644 silx/gui/qt/inspect.py delete mode 100644 silx/gui/utils/_image.py create mode 100644 silx/gui/utils/image.py create mode 100644 silx/gui/utils/testutils.py create mode 100644 silx/gui/widgets/FlowLayout.py create mode 100644 silx/gui/widgets/RangeSlider.py create mode 100644 silx/gui/widgets/test/test_flowlayout.py create mode 100644 silx/gui/widgets/test/test_rangeslider.py (limited to 'silx/gui') diff --git a/silx/gui/_glutils/font.py b/silx/gui/_glutils/font.py index b5bd6b5..8403c5a 100644 --- a/silx/gui/_glutils/font.py +++ b/silx/gui/_glutils/font.py @@ -32,7 +32,7 @@ __date__ = "13/10/2016" import logging import numpy -from ..utils._image import convertQImageToArray +from ..utils.image import convertQImageToArray from .. import qt _logger = logging.getLogger(__name__) diff --git a/silx/gui/colors.py b/silx/gui/colors.py index 028609b..a51bcdc 100644 --- a/silx/gui/colors.py +++ b/silx/gui/colors.py @@ -29,7 +29,7 @@ from __future__ import absolute_import __authors__ = ["T. Vincent", "H.Payno"] __license__ = "MIT" -__date__ = "14/06/2018" +__date__ = "05/10/2018" from silx.gui import qt import copy as copy_mdl @@ -234,6 +234,8 @@ class Colormap(qt.QObject): self._colors = None else: colors = numpy.array(colors, copy=False) + if colors.shape == (): + raise TypeError("An array is expected for 'colors' argument. '%s' was found." % type(colors)) colors.shape = -1, colors.shape[-1] if colors.dtype.kind == 'f': colors = self._convertColorsFromFloatToUint8(colors) @@ -518,6 +520,11 @@ class Colormap(qt.QObject): raise NotEditableError('Colormap is not editable') name = dic['name'] if 'name' in dic else None colors = dic['colors'] if 'colors' in dic else None + if name is not None and colors is not None: + if isinstance(colors, int): + # Filter out argument which was supported but never used + _logger.info("Unused 'colors' from colormap dictionary filterer.") + colors = None vmin = dic['vmin'] if 'vmin' in dic else None vmax = dic['vmax'] if 'vmax' in dic else None if 'normalization' in dic: diff --git a/silx/gui/data/Hdf5TableView.py b/silx/gui/data/Hdf5TableView.py index 04199b2..9e28fbf 100644 --- a/silx/gui/data/Hdf5TableView.py +++ b/silx/gui/data/Hdf5TableView.py @@ -30,7 +30,7 @@ from __future__ import division __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "23/05/2018" +__date__ = "05/07/2018" import collections import functools @@ -101,6 +101,9 @@ class _CellData(object): def invalidateToolTip(self): self.__tooltip = None + def data(self, role): + return None + class _TableData(object): """Modelize a table with header, row and column span. @@ -183,6 +186,46 @@ class _TableData(object): self.__data.append(row) +class _CellFilterAvailableData(_CellData): + """Cell rendering for availability of a filter""" + + _states = { + True: ("Available", qt.QColor(0x000000), None, None), + False: ("Not available", qt.QColor(0xFFFFFF), qt.QColor(0xFF0000), + "You have to install this filter on your system to be able to read this dataset"), + "na": ("n.a.", qt.QColor(0x000000), None, + "This version of h5py/hdf5 is not able to display the information"), + } + + def __init__(self, filterId): + import h5py.version + if h5py.version.hdf5_version_tuple >= (1, 10, 2): + # Previous versions only returns True if the filter was first used + # to decode a dataset + import h5py.h5z + self.__availability = h5py.h5z.filter_avail(filterId) + else: + self.__availability = "na" + _CellData.__init__(self) + + def value(self): + state = self._states[self.__availability] + return state[0] + + def tooltip(self): + state = self._states[self.__availability] + return state[3] + + def data(self, role=qt.Qt.DisplayRole): + state = self._states[self.__availability] + if role == qt.Qt.TextColorRole: + return state[1] + elif role == qt.Qt.BackgroundColorRole: + return state[2] + else: + return None + + class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): """This data model provides access to HDF5 node content (File, Group, Dataset). Main info, like name, file, attributes... are displayed @@ -198,7 +241,7 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): super(Hdf5TableModel, self).__init__(parent) self.__obj = None - self.__data = _TableData(columnCount=4) + self.__data = _TableData(columnCount=5) self.__formatter = None self.__hdf5Formatter = Hdf5Formatter(self) formatter = TextFormatter(self) @@ -245,6 +288,8 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): cell.invalidateToolTip() raise return value + else: + return cell.data(role) return None def flags(self, index): @@ -394,14 +439,16 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): hdf5id = _CellData(value="HDF5 ID", isHeader=True) name = _CellData(value="Name", isHeader=True) options = _CellData(value="Options", isHeader=True) - self.__data.addRow(pos, hdf5id, name, options) + availability = _CellData(value="", isHeader=True) + self.__data.addRow(pos, hdf5id, name, options, availability) for index in range(dcpl.get_nfilters()): - callback = lambda index, dataIndex, x: self.__get_filter_info(x, index)[dataIndex] - pos = _CellData(value=functools.partial(callback, index, 0)) - hdf5id = _CellData(value=functools.partial(callback, index, 1)) - name = _CellData(value=functools.partial(callback, index, 2)) - options = _CellData(value=functools.partial(callback, index, 3)) - self.__data.addRow(pos, hdf5id, name, options) + filterId, name, options = self.__getFilterInfo(obj, index) + pos = _CellData(value=index) + hdf5id = _CellData(value=filterId) + name = _CellData(value=name) + options = _CellData(value=options) + availability = _CellFilterAvailableData(filterId=filterId) + self.__data.addRow(pos, hdf5id, name, options, availability) if hasattr(obj, "attrs"): if len(obj.attrs) > 0: @@ -413,7 +460,7 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): value=functools.partial(callback, key), tooltip=functools.partial(callbackTooltip, key)) - def __get_filter_info(self, dataset, filterIndex): + def __getFilterInfo(self, dataset, filterIndex): """Get a tuple of readable info from dataset filters :param h5py.Dataset dataset: A h5py dataset @@ -425,10 +472,10 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): filterId, _flags, cdValues, name = info name = self.__formatter.toString(name) options = " ".join([self.__formatter.toString(i) for i in cdValues]) - return (filterIndex, filterId, name, options) + return (filterId, name, options) except Exception: _logger.debug("Backtrace", exc_info=True) - return [filterIndex, None, None, None] + return (None, None, None) def object(self): """Returns the internal object modelized. @@ -503,5 +550,8 @@ class Hdf5TableView(HierarchicalTableView.HierarchicalTableView): else: setResizeMode = header.setSectionResizeMode setResizeMode(0, qt.QHeaderView.Fixed) - setResizeMode(1, qt.QHeaderView.Stretch) - header.setStretchLastSection(True) + setResizeMode(1, qt.QHeaderView.ResizeToContents) + setResizeMode(2, qt.QHeaderView.Stretch) + setResizeMode(3, qt.QHeaderView.ResizeToContents) + setResizeMode(4, qt.QHeaderView.ResizeToContents) + header.setStretchLastSection(False) diff --git a/silx/gui/data/NXdataWidgets.py b/silx/gui/data/NXdataWidgets.py index 1bf5425..f7c479d 100644 --- a/silx/gui/data/NXdataWidgets.py +++ b/silx/gui/data/NXdataWidgets.py @@ -26,7 +26,7 @@ """ __authors__ = ["P. Knobel"] __license__ = "MIT" -__date__ = "24/04/2018" +__date__ = "10/10/2018" import numpy @@ -337,6 +337,7 @@ class ArrayImagePlot(qt.QWidget): self._plot.setDefaultColormap(Colormap(name="viridis", vmin=None, vmax=None, normalization=Colormap.LINEAR)) + self._plot.getIntensityHistogramAction().setVisible(True) self.selectorDock = qt.QDockWidget("Data selector", self._plot) # not closable diff --git a/silx/gui/data/RecordTableView.py b/silx/gui/data/RecordTableView.py index 54881b7..b1b7dcd 100644 --- a/silx/gui/data/RecordTableView.py +++ b/silx/gui/data/RecordTableView.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 @@ -37,7 +37,7 @@ from silx.gui.widgets.TableWidget import CopySelectedCellsAction __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "02/10/2017" +__date__ = "29/08/2018" class _MultiLineItem(qt.QItemDelegate): @@ -205,10 +205,13 @@ class RecordTableModel(qt.QAbstractTableModel): if len(key) > 1: data = data[key[1]] + # no dtype in case of 1D array of unicode objects (#2093) + dtype = getattr(data, "dtype", None) + if role == qt.Qt.DisplayRole: - return self.__formatter.toString(data, dtype=self.__data.dtype) + return self.__formatter.toString(data, dtype=dtype) elif role == qt.Qt.EditRole: - return self.__editFormatter.toString(data, dtype=self.__data.dtype) + return self.__editFormatter.toString(data, dtype=dtype) return None def headerData(self, section, orientation, role=qt.Qt.DisplayRole): @@ -382,8 +385,10 @@ class RecordTableView(qt.QTableView): qt.QTableView.__init__(self, parent) model = _ShowEditorProxyModel(self) - model.setSourceModel(RecordTableModel()) + self._model = RecordTableModel() + model.setSourceModel(self._model) self.setModel(model) + self.__multilineView = _MultiLineItem(self) self.setEditTriggers(qt.QAbstractItemView.AllEditTriggers) self._copyAction = CopySelectedCellsAction(self) @@ -393,13 +398,16 @@ class RecordTableView(qt.QTableView): self._copyAction.trigger() def setArrayData(self, data): - self.model().sourceModel().setArrayData(data) + model = self.model() + sourceModel = model.sourceModel() + sourceModel.setArrayData(data) + if data is not None: if issubclass(data.dtype.type, (numpy.string_, numpy.unicode_)): # TODO it would be nice to also fix fields # but using it only for string array is already very useful self.setItemDelegateForColumn(0, self.__multilineView) - self.model().forceCellEditor(True) + model.forceCellEditor(True) else: self.setItemDelegateForColumn(0, None) - self.model().forceCellEditor(False) + model.forceCellEditor(False) diff --git a/silx/gui/data/TextFormatter.py b/silx/gui/data/TextFormatter.py index 8440509..1401634 100644 --- a/silx/gui/data/TextFormatter.py +++ b/silx/gui/data/TextFormatter.py @@ -27,7 +27,7 @@ data module to format data as text in the same way.""" __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "25/06/2018" +__date__ = "24/07/2018" import numpy import numbers @@ -206,7 +206,13 @@ class TextFormatter(qt.QObject): if six.PY2: data = [ord(d) for d in data.data] else: - data = data.item().astype(numpy.uint8) + data = data.item() + if isinstance(data, numpy.ndarray): + # Before numpy 1.15.0 the item API was returning a numpy array + data = data.astype(numpy.uint8) + else: + # Now it is supposed to be a bytes type + pass elif six.PY2: data = [ord(d) for d in data] # In python3 data is already a bytes array diff --git a/silx/gui/data/test/test_arraywidget.py b/silx/gui/data/test/test_arraywidget.py index bbd7ee5..50ffc84 100644 --- a/silx/gui/data/test/test_arraywidget.py +++ b/silx/gui/data/test/test_arraywidget.py @@ -34,7 +34,7 @@ import numpy from silx.gui import qt from silx.gui.data import ArrayTableWidget -from silx.gui.test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt try: import h5py diff --git a/silx/gui/data/test/test_dataviewer.py b/silx/gui/data/test/test_dataviewer.py index f3c2808..a681f33 100644 --- a/silx/gui/data/test/test_dataviewer.py +++ b/silx/gui/data/test/test_dataviewer.py @@ -39,8 +39,8 @@ from .. import DataViews from silx.gui import qt from silx.gui.data.DataViewerFrame import DataViewerFrame -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 try: import h5py @@ -183,7 +183,7 @@ class AbstractDataViewerTests(TestCaseQt): widget.dataChanged.connect(listener) widget.setData(10) widget.setData(None) - self.assertEquals(listener.callCount(), 2) + self.assertEqual(listener.callCount(), 2) def test_display_mode_event(self): listener = SignalListener() @@ -192,7 +192,7 @@ class AbstractDataViewerTests(TestCaseQt): widget.setData(10) widget.setData(None) modes = [v.modeId() for v in listener.arguments(argumentIndex=0)] - self.assertEquals(modes, [DataViews.RAW_MODE, DataViews.EMPTY_MODE]) + self.assertEqual(modes, [DataViews.RAW_MODE, DataViews.EMPTY_MODE]) listener.clear() def test_change_display_mode(self): @@ -201,13 +201,13 @@ class AbstractDataViewerTests(TestCaseQt): widget = self.create_widget() widget.setData(data) widget.setDisplayMode(DataViews.PLOT1D_MODE) - self.assertEquals(widget.displayedView().modeId(), DataViews.PLOT1D_MODE) + self.assertEqual(widget.displayedView().modeId(), DataViews.PLOT1D_MODE) widget.setDisplayMode(DataViews.IMAGE_MODE) - self.assertEquals(widget.displayedView().modeId(), DataViews.IMAGE_MODE) + self.assertEqual(widget.displayedView().modeId(), DataViews.IMAGE_MODE) widget.setDisplayMode(DataViews.RAW_MODE) - self.assertEquals(widget.displayedView().modeId(), DataViews.RAW_MODE) + self.assertEqual(widget.displayedView().modeId(), DataViews.RAW_MODE) widget.setDisplayMode(DataViews.EMPTY_MODE) - self.assertEquals(widget.displayedView().modeId(), DataViews.EMPTY_MODE) + self.assertEqual(widget.displayedView().modeId(), DataViews.EMPTY_MODE) def test_create_default_views(self): widget = self.create_widget() diff --git a/silx/gui/data/test/test_numpyaxesselector.py b/silx/gui/data/test/test_numpyaxesselector.py index 6ce5119..6b7b58c 100644 --- a/silx/gui/data/test/test_numpyaxesselector.py +++ b/silx/gui/data/test/test_numpyaxesselector.py @@ -34,8 +34,8 @@ from contextlib import contextmanager import numpy from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector -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 try: import h5py diff --git a/silx/gui/data/test/test_textformatter.py b/silx/gui/data/test/test_textformatter.py index 06a29ba..850aa00 100644 --- a/silx/gui/data/test/test_textformatter.py +++ b/silx/gui/data/test/test_textformatter.py @@ -31,8 +31,8 @@ import shutil import tempfile import numpy -from silx.gui.test.utils import TestCaseQt -from silx.gui.test.utils import SignalListener +from silx.gui.utils.testutils import TestCaseQt +from silx.gui.utils.testutils import SignalListener from ..TextFormatter import TextFormatter from silx.third_party import six @@ -49,10 +49,10 @@ class TestTextFormatter(TestCaseQt): copy = TextFormatter(formatter=formatter) self.assertIsNot(formatter, copy) copy.setFloatFormat("%.3f") - self.assertEquals(formatter.integerFormat(), copy.integerFormat()) + self.assertEqual(formatter.integerFormat(), copy.integerFormat()) self.assertNotEquals(formatter.floatFormat(), copy.floatFormat()) - self.assertEquals(formatter.useQuoteForText(), copy.useQuoteForText()) - self.assertEquals(formatter.imaginaryUnit(), copy.imaginaryUnit()) + self.assertEqual(formatter.useQuoteForText(), copy.useQuoteForText()) + self.assertEqual(formatter.imaginaryUnit(), copy.imaginaryUnit()) def test_event(self): listener = SignalListener() @@ -62,19 +62,19 @@ class TestTextFormatter(TestCaseQt): formatter.setIntegerFormat("%03i") formatter.setUseQuoteForText(False) formatter.setImaginaryUnit("z") - self.assertEquals(listener.callCount(), 4) + self.assertEqual(listener.callCount(), 4) def test_int(self): formatter = TextFormatter() formatter.setIntegerFormat("%05i") result = formatter.toString(512) - self.assertEquals(result, "00512") + self.assertEqual(result, "00512") def test_float(self): formatter = TextFormatter() formatter.setFloatFormat("%.3f") result = formatter.toString(1.3) - self.assertEquals(result, "1.300") + self.assertEqual(result, "1.300") def test_complex(self): formatter = TextFormatter() @@ -82,25 +82,25 @@ class TestTextFormatter(TestCaseQt): formatter.setImaginaryUnit("i") result = formatter.toString(1.0 + 5j) result = result.replace(" ", "") - self.assertEquals(result, "1.0+5.0i") + self.assertEqual(result, "1.0+5.0i") def test_string(self): formatter = TextFormatter() formatter.setIntegerFormat("%.1f") formatter.setImaginaryUnit("z") result = formatter.toString("toto") - self.assertEquals(result, '"toto"') + self.assertEqual(result, '"toto"') def test_numpy_void(self): formatter = TextFormatter() result = formatter.toString(numpy.void(b"\xFF")) - self.assertEquals(result, 'b"\\xFF"') + self.assertEqual(result, 'b"\\xFF"') def test_char_cp1252(self): # degree character in cp1252 formatter = TextFormatter() result = formatter.toString(numpy.bytes_(b"\xB0")) - self.assertEquals(result, u'"\u00B0"') + self.assertEqual(result, u'"\u00B0"') class TestTextFormatterWithH5py(TestCaseQt): @@ -130,74 +130,74 @@ class TestTextFormatterWithH5py(TestCaseQt): def testAscii(self): d = self.create_dataset(data=b"abc") result = self.formatter.toString(d[()], dtype=d.dtype) - self.assertEquals(result, '"abc"') + self.assertEqual(result, '"abc"') def testUnicode(self): d = self.create_dataset(data=u"i\u2661cookies") result = self.formatter.toString(d[()], dtype=d.dtype) - self.assertEquals(len(result), 11) - self.assertEquals(result, u'"i\u2661cookies"') + self.assertEqual(len(result), 11) + self.assertEqual(result, u'"i\u2661cookies"') def testBadAscii(self): d = self.create_dataset(data=b"\xF0\x9F\x92\x94") result = self.formatter.toString(d[()], dtype=d.dtype) - self.assertEquals(result, 'b"\\xF0\\x9F\\x92\\x94"') + self.assertEqual(result, 'b"\\xF0\\x9F\\x92\\x94"') def testVoid(self): d = self.create_dataset(data=numpy.void(b"abc\xF0")) result = self.formatter.toString(d[()], dtype=d.dtype) - self.assertEquals(result, 'b"\\x61\\x62\\x63\\xF0"') + self.assertEqual(result, 'b"\\x61\\x62\\x63\\xF0"') def testEnum(self): dtype = h5py.special_dtype(enum=('i', {"RED": 0, "GREEN": 1, "BLUE": 42})) d = numpy.array(42, dtype=dtype) d = self.create_dataset(data=d) result = self.formatter.toString(d[()], dtype=d.dtype) - self.assertEquals(result, 'BLUE(42)') + self.assertEqual(result, 'BLUE(42)') def testRef(self): dtype = h5py.special_dtype(ref=h5py.Reference) d = numpy.array(self.h5File.ref, dtype=dtype) d = self.create_dataset(data=d) result = self.formatter.toString(d[()], dtype=d.dtype) - self.assertEquals(result, 'REF') + self.assertEqual(result, 'REF') def testArrayAscii(self): d = self.create_dataset(data=[b"abc"]) result = self.formatter.toString(d[()], dtype=d.dtype) - self.assertEquals(result, '["abc"]') + self.assertEqual(result, '["abc"]') def testArrayUnicode(self): dtype = h5py.special_dtype(vlen=six.text_type) d = numpy.array([u"i\u2661cookies"], dtype=dtype) d = self.create_dataset(data=d) result = self.formatter.toString(d[()], dtype=d.dtype) - self.assertEquals(len(result), 13) - self.assertEquals(result, u'["i\u2661cookies"]') + self.assertEqual(len(result), 13) + self.assertEqual(result, u'["i\u2661cookies"]') def testArrayBadAscii(self): d = self.create_dataset(data=[b"\xF0\x9F\x92\x94"]) result = self.formatter.toString(d[()], dtype=d.dtype) - self.assertEquals(result, '[b"\\xF0\\x9F\\x92\\x94"]') + self.assertEqual(result, '[b"\\xF0\\x9F\\x92\\x94"]') def testArrayVoid(self): d = self.create_dataset(data=numpy.void([b"abc\xF0"])) result = self.formatter.toString(d[()], dtype=d.dtype) - self.assertEquals(result, '[b"\\x61\\x62\\x63\\xF0"]') + self.assertEqual(result, '[b"\\x61\\x62\\x63\\xF0"]') def testArrayEnum(self): dtype = h5py.special_dtype(enum=('i', {"RED": 0, "GREEN": 1, "BLUE": 42})) d = numpy.array([42, 1, 100], dtype=dtype) d = self.create_dataset(data=d) result = self.formatter.toString(d[()], dtype=d.dtype) - self.assertEquals(result, '[BLUE(42) GREEN(1) 100]') + self.assertEqual(result, '[BLUE(42) GREEN(1) 100]') def testArrayRef(self): dtype = h5py.special_dtype(ref=h5py.Reference) d = numpy.array([self.h5File.ref, None], dtype=dtype) d = self.create_dataset(data=d) result = self.formatter.toString(d[()], dtype=d.dtype) - self.assertEquals(result, '[REF NULL_REF]') + self.assertEqual(result, '[REF NULL_REF]') def suite(): diff --git a/silx/gui/dialog/AbstractDataFileDialog.py b/silx/gui/dialog/AbstractDataFileDialog.py index cb6711c..40045fe 100644 --- a/silx/gui/dialog/AbstractDataFileDialog.py +++ b/silx/gui/dialog/AbstractDataFileDialog.py @@ -1170,7 +1170,7 @@ class AbstractDataFileDialog(qt.QDialog): def __filterSelected(self, index): filters = self.__fileTypeCombo.itemExtensions(index) - self.__fileModel.setNameFilters(filters) + self.__fileModel.setNameFilters(list(filters)) def __setData(self, data): self.__data = data diff --git a/silx/gui/dialog/ColormapDialog.py b/silx/gui/dialog/ColormapDialog.py index ed10728..cbbfa5a 100644 --- a/silx/gui/dialog/ColormapDialog.py +++ b/silx/gui/dialog/ColormapDialog.py @@ -710,8 +710,9 @@ class ColormapDialog(qt.QDialog): self._updateMinMaxData() def getColormap(self): - """Return the colormap description as a :class:`.Colormap`. + """Return the colormap description. + :rtype: ~silx.gui.colors.Colormap """ if self._colormap is None: return None @@ -811,7 +812,7 @@ class ColormapDialog(qt.QDialog): def setColormap(self, colormap): """Set the colormap description - :param :class:`Colormap` colormap: the colormap to edit + :param ~silx.gui.colors.Colormap colormap: the colormap to edit """ assert colormap is None or isinstance(colormap, Colormap) if self._ignoreColormapChange is True: diff --git a/silx/gui/dialog/DatasetDialog.py b/silx/gui/dialog/DatasetDialog.py new file mode 100644 index 0000000..87fc89d --- /dev/null +++ b/silx/gui/dialog/DatasetDialog.py @@ -0,0 +1,122 @@ +# 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 dialog widget to select a HDF5 dataset in a +tree. + +.. autoclass:: DatasetDialog + :members: addFile, addGroup, getSelectedDataUrl, setMode + +""" +from .GroupDialog import _Hdf5ItemSelectionDialog +import silx.io +from silx.io.url import DataUrl + + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "05/09/2018" + + +class DatasetDialog(_Hdf5ItemSelectionDialog): + """This :class:`QDialog` uses a :class:`silx.gui.hdf5.Hdf5TreeView` to + provide a HDF5 dataset selection dialog. + + The information identifying the selected node is provided as a + :class:`silx.io.url.DataUrl`. + + Example: + + .. code-block:: python + + dialog = DatasetDialog() + dialog.addFile(filepath1) + dialog.addFile(filepath2) + + if dialog.exec_(): + print("File path: %s" % dialog.getSelectedDataUrl().file_path()) + print("HDF5 dataset path : %s " % dialog.getSelectedDataUrl().data_path()) + else: + print("Operation cancelled :(") + + """ + def __init__(self, parent=None): + _Hdf5ItemSelectionDialog.__init__(self, parent) + + # customization for groups + self.setWindowTitle("HDF5 dataset selection") + + self._header.setSections([self._model.NAME_COLUMN, + self._model.NODE_COLUMN, + self._model.LINK_COLUMN, + self._model.TYPE_COLUMN, + self._model.SHAPE_COLUMN]) + self._selectDatasetStatusText = "Select a dataset or type a new dataset name" + + def setMode(self, mode): + """Set dialog mode DatasetDialog.SaveMode or DatasetDialog.LoadMode + + :param mode: DatasetDialog.SaveMode or DatasetDialog.LoadMode + """ + _Hdf5ItemSelectionDialog.setMode(self, mode) + if mode == DatasetDialog.SaveMode: + self._selectDatasetStatusText = "Select a dataset or type a new dataset name" + elif mode == DatasetDialog.LoadMode: + self._selectDatasetStatusText = "Select a dataset" + + def _onActivation(self, idx): + # double-click or enter press: filter for datasets + nodes = list(self._tree.selectedH5Nodes()) + node = nodes[0] + if silx.io.is_dataset(node.h5py_object): + self.accept() + + def _updateUrl(self): + # overloaded to filter for datasets + nodes = list(self._tree.selectedH5Nodes()) + newDatasetName = self._lineEditNewItem.text() + isDatasetSelected = False + if nodes: + node = nodes[0] + if silx.io.is_dataset(node.h5py_object): + data_path = node.local_name + isDatasetSelected = True + elif silx.io.is_group(node.h5py_object): + data_path = node.local_name + if newDatasetName.lstrip("/"): + if not data_path.endswith("/"): + data_path += "/" + data_path += newDatasetName.lstrip("/") + isDatasetSelected = True + + if isDatasetSelected: + self._selectedUrl = DataUrl(file_path=node.local_filename, + data_path=data_path) + self._okButton.setEnabled(True) + self._labelSelection.setText( + self._selectedUrl.path()) + else: + self._selectedUrl = None + self._okButton.setEnabled(False) + self._labelSelection.setText(self._selectDatasetStatusText) diff --git a/silx/gui/dialog/GroupDialog.py b/silx/gui/dialog/GroupDialog.py index 71235d2..217a03c 100644 --- a/silx/gui/dialog/GroupDialog.py +++ b/silx/gui/dialog/GroupDialog.py @@ -26,9 +26,7 @@ tree. .. autoclass:: GroupDialog - :show-inheritance: - :members: - + :members: addFile, addGroup, getSelectedDataUrl, setMode """ from silx.gui import qt @@ -41,31 +39,18 @@ __license__ = "MIT" __date__ = "22/03/2018" -class GroupDialog(qt.QDialog): - """This :class:`QDialog` uses a :class:`silx.gui.hdf5.Hdf5TreeView` to - provide a HDF5 group selection dialog. - - The information identifying the selected node is provided as a - :class:`silx.io.url.DataUrl`. - - Example: - - .. code-block:: python - - dialog = GroupDialog() - dialog.addFile(filepath1) - dialog.addFile(filepath2) +class _Hdf5ItemSelectionDialog(qt.QDialog): + SaveMode = 1 + """Mode used to set the HDF5 item selection dialog to *save* mode. + This adds a text field to type in a new item name.""" - if dialog.exec_(): - print("File path: %s" % dialog.getSelectedDataUrl().file_path()) - print("HDF5 group path : %s " % dialog.getSelectedDataUrl().data_path()) - else: - print("Operation cancelled :(") + LoadMode = 2 + """Mode used to set the HDF5 item selection dialog to *load* mode. + Only existing items of the HDF5 file can be selected in this mode.""" - """ def __init__(self, parent=None): qt.QDialog.__init__(self, parent) - self.setWindowTitle("HDF5 group selection") + self.setWindowTitle("HDF5 item selection") self._tree = Hdf5TreeView(self) self._tree.setSelectionMode(qt.QAbstractItemView.SingleSelection) @@ -76,25 +61,26 @@ class GroupDialog(qt.QDialog): self._model = self._tree.findHdf5TreeModel() self._header = self._tree.header() - self._header.setSections([self._model.NAME_COLUMN, - self._model.NODE_COLUMN, - self._model.LINK_COLUMN]) - _labelSubgroup = qt.QLabel(self) - _labelSubgroup.setText("Subgroup name (optional)") - self._lineEditSubgroup = qt.QLineEdit(self) - self._lineEditSubgroup.setToolTip( - "Specify the name of a new subgroup " + self._newItemWidget = qt.QWidget(self) + newItemLayout = qt.QVBoxLayout(self._newItemWidget) + self._labelNewItem = qt.QLabel(self._newItemWidget) + self._labelNewItem.setText("Create new item in selected group (optional):") + self._lineEditNewItem = qt.QLineEdit(self._newItemWidget) + self._lineEditNewItem.setToolTip( + "Specify the name of a new item " "to be created in the selected group.") - self._lineEditSubgroup.textChanged.connect( - self._onSubgroupNameChange) + self._lineEditNewItem.textChanged.connect( + self._onNewItemNameChange) + newItemLayout.addWidget(self._labelNewItem) + newItemLayout.addWidget(self._lineEditNewItem) _labelSelectionTitle = qt.QLabel(self) _labelSelectionTitle.setText("Current selection") self._labelSelection = qt.QLabel(self) self._labelSelection.setStyleSheet("color: gray") self._labelSelection.setWordWrap(True) - self._labelSelection.setText("Select a group") + self._labelSelection.setText("Select an item") buttonBox = qt.QDialogButtonBox() self._okButton = buttonBox.addButton(qt.QDialogButtonBox.Ok) @@ -106,8 +92,7 @@ class GroupDialog(qt.QDialog): vlayout = qt.QVBoxLayout(self) vlayout.addWidget(self._tree) - vlayout.addWidget(_labelSubgroup) - vlayout.addWidget(self._lineEditSubgroup) + vlayout.addWidget(self._newItemWidget) vlayout.addWidget(_labelSelectionTitle) vlayout.addWidget(self._labelSelection) vlayout.addWidget(buttonBox) @@ -117,6 +102,30 @@ class GroupDialog(qt.QDialog): self._selectedUrl = None + def _onSelectionChange(self, old, new): + self._updateUrl() + + def _onNewItemNameChange(self, text): + self._updateUrl() + + def _onActivation(self, idx): + # double-click or enter press + self.accept() + + def setMode(self, mode): + """Set dialog mode DatasetDialog.SaveMode or DatasetDialog.LoadMode + + :param mode: DatasetDialog.SaveMode or DatasetDialog.LoadMode + """ + if mode == self.LoadMode: + # hide "Create new item" field + self._lineEditNewItem.clear() + self._newItemWidget.hide() + elif mode == self.SaveMode: + self._newItemWidget.show() + else: + raise ValueError("Invalid DatasetDialog mode %s" % mode) + def addFile(self, path): """Add a HDF5 file to the tree. All groups it contains will be selectable in the dialog. @@ -133,22 +142,75 @@ class GroupDialog(qt.QDialog): """ self._model.insertH5pyObject(group) + def _updateUrl(self): + nodes = list(self._tree.selectedH5Nodes()) + subgroupName = self._lineEditNewItem.text() + if nodes: + node = nodes[0] + data_path = node.local_name + if subgroupName.lstrip("/"): + if not data_path.endswith("/"): + data_path += "/" + data_path += subgroupName.lstrip("/") + self._selectedUrl = DataUrl(file_path=node.local_filename, + data_path=data_path) + self._okButton.setEnabled(True) + self._labelSelection.setText( + self._selectedUrl.path()) + + def getSelectedDataUrl(self): + """Return a :class:`DataUrl` with a file path and a data path. + Return None if the dialog was cancelled. + + :return: :class:`silx.io.url.DataUrl` object pointing to the + selected HDF5 item. + """ + return self._selectedUrl + + +class GroupDialog(_Hdf5ItemSelectionDialog): + """This :class:`QDialog` uses a :class:`silx.gui.hdf5.Hdf5TreeView` to + provide a HDF5 group selection dialog. + + The information identifying the selected node is provided as a + :class:`silx.io.url.DataUrl`. + + Example: + + .. code-block:: python + + dialog = GroupDialog() + dialog.addFile(filepath1) + dialog.addFile(filepath2) + + if dialog.exec_(): + print("File path: %s" % dialog.getSelectedDataUrl().file_path()) + print("HDF5 group path : %s " % dialog.getSelectedDataUrl().data_path()) + else: + print("Operation cancelled :(") + + """ + def __init__(self, parent=None): + _Hdf5ItemSelectionDialog.__init__(self, parent) + + # customization for groups + self.setWindowTitle("HDF5 group selection") + + self._header.setSections([self._model.NAME_COLUMN, + self._model.NODE_COLUMN, + self._model.LINK_COLUMN]) + def _onActivation(self, idx): - # double-click or enter press + # double-click or enter press: filter for groups nodes = list(self._tree.selectedH5Nodes()) node = nodes[0] if silx.io.is_group(node.h5py_object): self.accept() - def _onSelectionChange(self, old, new): - self._updateUrl() - - def _onSubgroupNameChange(self, text): - self._updateUrl() - def _updateUrl(self): + # overloaded to filter for groups nodes = list(self._tree.selectedH5Nodes()) - subgroupName = self._lineEditSubgroup.text() + subgroupName = self._lineEditNewItem.text() if nodes: node = nodes[0] if silx.io.is_group(node.h5py_object): @@ -166,12 +228,3 @@ class GroupDialog(qt.QDialog): self._selectedUrl = None self._okButton.setEnabled(False) self._labelSelection.setText("Select a group") - - def getSelectedDataUrl(self): - """Return a :class:`DataUrl` with a file path and a data path. - Return None if the dialog was cancelled. - - :return: :class:`silx.io.url.DataUrl` object pointing to the - selected group. - """ - return self._selectedUrl diff --git a/silx/gui/dialog/SafeFileIconProvider.py b/silx/gui/dialog/SafeFileIconProvider.py index 7fac7c0..1e06b64 100644 --- a/silx/gui/dialog/SafeFileIconProvider.py +++ b/silx/gui/dialog/SafeFileIconProvider.py @@ -115,6 +115,10 @@ class SafeFileIconProvider(qt.QFileIconProvider): return driveInfo[0] def icon(self, info): + if isinstance(info, qt.QFileIconProvider.IconType): + # It's another C++ method signature: + # QIcon QFileIconProvider::icon(QFileIconProvider::IconType type) + return super(SafeFileIconProvider, self).icon(info) style = qt.QApplication.instance().style() path = info.filePath() if path in ["", "/"]: diff --git a/silx/gui/dialog/SafeFileSystemModel.py b/silx/gui/dialog/SafeFileSystemModel.py index 8a97974..198e089 100644 --- a/silx/gui/dialog/SafeFileSystemModel.py +++ b/silx/gui/dialog/SafeFileSystemModel.py @@ -749,7 +749,7 @@ class SafeFileSystemModel(qt.QSortFilterProxyModel): index = self.mapToSource(index) filters = sourceModel.flags(index) - if self.__nameFilterDisables: + if self.__nameFilterDisables and not sourceModel.isDir(index): item = sourceModel._item(index) if not self.__nameFiltersAccepted(item): filters &= ~qt.Qt.ItemIsEnabled diff --git a/silx/gui/dialog/test/test_colormapdialog.py b/silx/gui/dialog/test/test_colormapdialog.py index 6f0ceea..6e50193 100644 --- a/silx/gui/dialog/test/test_colormapdialog.py +++ b/silx/gui/dialog/test/test_colormapdialog.py @@ -32,10 +32,10 @@ __date__ = "23/05/2018" import doctest import unittest -from silx.gui.test.utils import qWaitForWindowExposedAndActivate +from silx.gui.utils.testutils import qWaitForWindowExposedAndActivate from silx.gui import qt from silx.gui.dialog import ColormapDialog -from silx.gui.test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from silx.gui.colors import Colormap, preferredColormaps from silx.utils.testutils import ParametricTestCase from silx.gui.plot.PlotWindow import PlotWindow diff --git a/silx/gui/dialog/test/test_datafiledialog.py b/silx/gui/dialog/test/test_datafiledialog.py index 38fa03b..aff6bc4 100644 --- a/silx/gui/dialog/test/test_datafiledialog.py +++ b/silx/gui/dialog/test/test_datafiledialog.py @@ -26,7 +26,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "03/07/2018" +__date__ = "05/10/2018" import unittest @@ -48,7 +48,7 @@ except ImportError: import silx.io.url from silx.gui import qt -from silx.gui.test import utils +from silx.gui.utils import testutils from ..DataFileDialog import DataFileDialog from silx.gui.hdf5 import Hdf5TreeModel @@ -134,7 +134,7 @@ class _UtilsMixin(object): path2_ = os.path.normcase(path2) if path1_ != path2_: # Use the unittest API to log and display error - self.assertEquals(path1, path2) + self.assertEqual(path1, path2) def assertNotSamePath(self, path1, path2): path1_ = os.path.normcase(path1) @@ -144,11 +144,11 @@ class _UtilsMixin(object): self.assertNotEquals(path1, path2) -class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): +class TestDataFileDialogInteraction(testutils.TestCaseQt, _UtilsMixin): def tearDown(self): self._deleteDialog() - utils.TestCaseQt.tearDown(self) + testutils.TestCaseQt.tearDown(self) def testDisplayAndKeyEscape(self): dialog = self.createDialog() @@ -158,7 +158,7 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): self.keyClick(dialog, qt.Qt.Key_Escape) self.assertFalse(dialog.isVisible()) - self.assertEquals(dialog.result(), qt.QDialog.Rejected) + self.assertEqual(dialog.result(), qt.QDialog.Rejected) def testDisplayAndClickCancel(self): dialog = self.createDialog() @@ -166,11 +166,11 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): self.qWaitForWindowExposed(dialog) self.assertTrue(dialog.isVisible()) - button = utils.findChildren(dialog, qt.QPushButton, name="cancel")[0] + button = testutils.findChildren(dialog, qt.QPushButton, name="cancel")[0] self.mouseClick(button, qt.Qt.LeftButton) self.assertFalse(dialog.isVisible()) self.assertFalse(dialog.isVisible()) - self.assertEquals(dialog.result(), qt.QDialog.Rejected) + self.assertEqual(dialog.result(), qt.QDialog.Rejected) def testDisplayAndClickLockedOpen(self): dialog = self.createDialog() @@ -178,17 +178,17 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): self.qWaitForWindowExposed(dialog) self.assertTrue(dialog.isVisible()) - button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0] self.mouseClick(button, qt.Qt.LeftButton) # open button locked, dialog is not closed self.assertTrue(dialog.isVisible()) - self.assertEquals(dialog.result(), qt.QDialog.Rejected) + self.assertEqual(dialog.result(), qt.QDialog.Rejected) def testSelectRoot_Activate(self): if fabio is None: self.skipTest("fabio is missing") dialog = self.createDialog() - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] dialog.show() self.qWaitForWindowExposed(dialog) self.assertTrue(dialog.isVisible()) @@ -202,19 +202,19 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): browser.activated.emit(index) self.qWaitForPendingActions(dialog) - button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0] self.assertTrue(button.isEnabled()) self.mouseClick(button, qt.Qt.LeftButton) url = silx.io.url.DataUrl(dialog.selectedUrl()) self.assertTrue(url.data_path() is not None) self.assertFalse(dialog.isVisible()) - self.assertEquals(dialog.result(), qt.QDialog.Accepted) + self.assertEqual(dialog.result(), qt.QDialog.Accepted) def testSelectGroup_Activate(self): if fabio is None: self.skipTest("fabio is missing") dialog = self.createDialog() - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] dialog.show() self.qWaitForWindowExposed(dialog) self.assertTrue(dialog.isVisible()) @@ -234,19 +234,19 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): browser.activated.emit(index) self.qWaitForPendingActions(dialog) - button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0] self.assertTrue(button.isEnabled()) self.mouseClick(button, qt.Qt.LeftButton) url = silx.io.url.DataUrl(dialog.selectedUrl()) self.assertEqual(url.data_path(), "/group") self.assertFalse(dialog.isVisible()) - self.assertEquals(dialog.result(), qt.QDialog.Accepted) + self.assertEqual(dialog.result(), qt.QDialog.Accepted) def testSelectDataset_Activate(self): if fabio is None: self.skipTest("fabio is missing") dialog = self.createDialog() - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] dialog.show() self.qWaitForWindowExposed(dialog) self.assertTrue(dialog.isVisible()) @@ -266,13 +266,13 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): browser.activated.emit(index) self.qWaitForPendingActions(dialog) - button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0] self.assertTrue(button.isEnabled()) self.mouseClick(button, qt.Qt.LeftButton) url = silx.io.url.DataUrl(dialog.selectedUrl()) self.assertEqual(url.data_path(), "/scalar") self.assertFalse(dialog.isVisible()) - self.assertEquals(dialog.result(), qt.QDialog.Accepted) + self.assertEqual(dialog.result(), qt.QDialog.Accepted) def testClickOnBackToParentTool(self): if h5py is None: @@ -281,9 +281,9 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): dialog.show() self.qWaitForWindowExposed(dialog) - url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] - action = utils.findChildren(dialog, qt.QAction, name="toParentAction")[0] - toParentButton = utils.getQToolButtonFromAction(action) + url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = testutils.findChildren(dialog, qt.QAction, name="toParentAction")[0] + toParentButton = testutils.getQToolButtonFromAction(action) filename = _tmpDirectory + "/data/data.h5" # init state @@ -313,9 +313,9 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): dialog.show() self.qWaitForWindowExposed(dialog) - url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] - action = utils.findChildren(dialog, qt.QAction, name="toRootFileAction")[0] - button = utils.getQToolButtonFromAction(action) + url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = testutils.findChildren(dialog, qt.QAction, name="toRootFileAction")[0] + button = testutils.getQToolButtonFromAction(action) filename = _tmpDirectory + "/data.h5" # init state @@ -338,9 +338,9 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): dialog.show() self.qWaitForWindowExposed(dialog) - url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] - action = utils.findChildren(dialog, qt.QAction, name="toDirectoryAction")[0] - button = utils.getQToolButtonFromAction(action) + url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = testutils.findChildren(dialog, qt.QAction, name="toDirectoryAction")[0] + button = testutils.getQToolButtonFromAction(action) filename = _tmpDirectory + "/data.h5" # init state @@ -367,9 +367,9 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): dialog.show() self.qWaitForWindowExposed(dialog) - url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] - forwardAction = utils.findChildren(dialog, qt.QAction, name="forwardAction")[0] - backwardAction = utils.findChildren(dialog, qt.QAction, name="backwardAction")[0] + url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0] + forwardAction = testutils.findChildren(dialog, qt.QAction, name="forwardAction")[0] + backwardAction = testutils.findChildren(dialog, qt.QAction, name="backwardAction")[0] filename = _tmpDirectory + "/data.h5" dialog.setDirectory(_tmpDirectory) @@ -387,14 +387,14 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): self.assertFalse(forwardAction.isEnabled()) self.assertTrue(backwardAction.isEnabled()) - button = utils.getQToolButtonFromAction(backwardAction) + button = testutils.getQToolButtonFromAction(backwardAction) self.mouseClick(button, qt.Qt.LeftButton) self.qWaitForPendingActions(dialog) self.assertTrue(forwardAction.isEnabled()) self.assertTrue(backwardAction.isEnabled()) self.assertSamePath(url.text(), path2) - button = utils.getQToolButtonFromAction(forwardAction) + button = testutils.getQToolButtonFromAction(forwardAction) self.mouseClick(button, qt.Qt.LeftButton) self.qWaitForPendingActions(dialog) self.assertFalse(forwardAction.isEnabled()) @@ -494,7 +494,7 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): # init state dialog.selectUrl(_tmpDirectory) self.qWaitForPendingActions(dialog) - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] filename = _tmpDirectory + "/data.h5" path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() index = browser.rootIndex().model().index(filename) @@ -514,7 +514,7 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): # init state dialog.selectUrl(_tmpDirectory) self.qWaitForPendingActions(dialog) - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] filename = _tmpDirectory + "/badformat.h5" index = browser.rootIndex().model().index(filename) browser.activated.emit(index) @@ -538,7 +538,7 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): if fabio is None: self.skipTest("fabio is missing") dialog = self.createDialog() - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] dialog.show() self.qWaitForWindowExposed(dialog) dialog.selectUrl(_tmpDirectory) @@ -546,11 +546,11 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 4) -class TestDataFileDialog_FilterDataset(utils.TestCaseQt, _UtilsMixin): +class TestDataFileDialog_FilterDataset(testutils.TestCaseQt, _UtilsMixin): def tearDown(self): self._deleteDialog() - utils.TestCaseQt.tearDown(self) + testutils.TestCaseQt.tearDown(self) def _createDialog(self): dialog = DataFileDialog() @@ -561,7 +561,7 @@ class TestDataFileDialog_FilterDataset(utils.TestCaseQt, _UtilsMixin): if fabio is None: self.skipTest("fabio is missing") dialog = self.createDialog() - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] dialog.show() self.qWaitForWindowExposed(dialog) self.assertTrue(dialog.isVisible()) @@ -581,14 +581,14 @@ class TestDataFileDialog_FilterDataset(utils.TestCaseQt, _UtilsMixin): browser.activated.emit(index) self.qWaitForPendingActions(dialog) - button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0] self.assertFalse(button.isEnabled()) def testSelectDataset_Activate(self): if fabio is None: self.skipTest("fabio is missing") dialog = self.createDialog() - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] dialog.show() self.qWaitForWindowExposed(dialog) self.assertTrue(dialog.isVisible()) @@ -608,23 +608,23 @@ class TestDataFileDialog_FilterDataset(utils.TestCaseQt, _UtilsMixin): browser.activated.emit(index) self.qWaitForPendingActions(dialog) - button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0] self.assertTrue(button.isEnabled()) self.mouseClick(button, qt.Qt.LeftButton) url = silx.io.url.DataUrl(dialog.selectedUrl()) self.assertEqual(url.data_path(), "/scalar") self.assertFalse(dialog.isVisible()) - self.assertEquals(dialog.result(), qt.QDialog.Accepted) + self.assertEqual(dialog.result(), qt.QDialog.Accepted) data = dialog.selectedData() self.assertEqual(data, 10) -class TestDataFileDialog_FilterGroup(utils.TestCaseQt, _UtilsMixin): +class TestDataFileDialog_FilterGroup(testutils.TestCaseQt, _UtilsMixin): def tearDown(self): self._deleteDialog() - utils.TestCaseQt.tearDown(self) + testutils.TestCaseQt.tearDown(self) def _createDialog(self): dialog = DataFileDialog() @@ -635,7 +635,7 @@ class TestDataFileDialog_FilterGroup(utils.TestCaseQt, _UtilsMixin): if fabio is None: self.skipTest("fabio is missing") dialog = self.createDialog() - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] dialog.show() self.qWaitForWindowExposed(dialog) self.assertTrue(dialog.isVisible()) @@ -655,13 +655,13 @@ class TestDataFileDialog_FilterGroup(utils.TestCaseQt, _UtilsMixin): browser.activated.emit(index) self.qWaitForPendingActions(dialog) - button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0] self.assertTrue(button.isEnabled()) self.mouseClick(button, qt.Qt.LeftButton) url = silx.io.url.DataUrl(dialog.selectedUrl()) self.assertEqual(url.data_path(), "/group") self.assertFalse(dialog.isVisible()) - self.assertEquals(dialog.result(), qt.QDialog.Accepted) + self.assertEqual(dialog.result(), qt.QDialog.Accepted) self.assertRaises(Exception, dialog.selectedData) @@ -669,7 +669,7 @@ class TestDataFileDialog_FilterGroup(utils.TestCaseQt, _UtilsMixin): if fabio is None: self.skipTest("fabio is missing") dialog = self.createDialog() - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] dialog.show() self.qWaitForWindowExposed(dialog) self.assertTrue(dialog.isVisible()) @@ -689,15 +689,15 @@ class TestDataFileDialog_FilterGroup(utils.TestCaseQt, _UtilsMixin): browser.activated.emit(index) self.qWaitForPendingActions(dialog) - button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0] self.assertFalse(button.isEnabled()) -class TestDataFileDialog_FilterNXdata(utils.TestCaseQt, _UtilsMixin): +class TestDataFileDialog_FilterNXdata(testutils.TestCaseQt, _UtilsMixin): def tearDown(self): self._deleteDialog() - utils.TestCaseQt.tearDown(self) + testutils.TestCaseQt.tearDown(self) def _createDialog(self): def customFilter(obj): @@ -714,7 +714,7 @@ class TestDataFileDialog_FilterNXdata(utils.TestCaseQt, _UtilsMixin): if fabio is None: self.skipTest("fabio is missing") dialog = self.createDialog() - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] dialog.show() self.qWaitForWindowExposed(dialog) self.assertTrue(dialog.isVisible()) @@ -734,7 +734,7 @@ class TestDataFileDialog_FilterNXdata(utils.TestCaseQt, _UtilsMixin): browser.activated.emit(index) self.qWaitForPendingActions(dialog) - button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0] self.assertFalse(button.isEnabled()) self.assertRaises(Exception, dialog.selectedData) @@ -743,7 +743,7 @@ class TestDataFileDialog_FilterNXdata(utils.TestCaseQt, _UtilsMixin): if fabio is None: self.skipTest("fabio is missing") dialog = self.createDialog() - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] dialog.show() self.qWaitForWindowExposed(dialog) self.assertTrue(dialog.isVisible()) @@ -763,20 +763,20 @@ class TestDataFileDialog_FilterNXdata(utils.TestCaseQt, _UtilsMixin): browser.activated.emit(index) self.qWaitForPendingActions(dialog) - button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0] self.assertTrue(button.isEnabled()) self.mouseClick(button, qt.Qt.LeftButton) url = silx.io.url.DataUrl(dialog.selectedUrl()) self.assertEqual(url.data_path(), "/nxdata") self.assertFalse(dialog.isVisible()) - self.assertEquals(dialog.result(), qt.QDialog.Accepted) + self.assertEqual(dialog.result(), qt.QDialog.Accepted) -class TestDataFileDialogApi(utils.TestCaseQt, _UtilsMixin): +class TestDataFileDialogApi(testutils.TestCaseQt, _UtilsMixin): def tearDown(self): self._deleteDialog() - utils.TestCaseQt.tearDown(self) + testutils.TestCaseQt.tearDown(self) def _createDialog(self): dialog = DataFileDialog() @@ -949,7 +949,7 @@ class TestDataFileDialogApi(utils.TestCaseQt, _UtilsMixin): dialog = self.createDialog() self.qWaitForPendingActions(dialog) - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] filename = _tmpDirectory + "/data.h5" url = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/foobar") diff --git a/silx/gui/dialog/test/test_imagefiledialog.py b/silx/gui/dialog/test/test_imagefiledialog.py index 8fef3c5..66469f3 100644 --- a/silx/gui/dialog/test/test_imagefiledialog.py +++ b/silx/gui/dialog/test/test_imagefiledialog.py @@ -26,7 +26,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "03/07/2018" +__date__ = "05/10/2018" import unittest @@ -48,7 +48,7 @@ except ImportError: import silx.io.url from silx.gui import qt -from silx.gui.test import utils +from silx.gui.utils import testutils from ..ImageFileDialog import ImageFileDialog from silx.gui.colors import Colormap from silx.gui.hdf5 import Hdf5TreeModel @@ -141,7 +141,7 @@ class _UtilsMixin(object): path2_ = os.path.normcase(path2) if path1_ != path2_: # Use the unittest API to log and display error - self.assertEquals(path1, path2) + self.assertEqual(path1, path2) def assertNotSamePath(self, path1, path2): path1_ = os.path.normcase(path1) @@ -151,11 +151,11 @@ class _UtilsMixin(object): self.assertNotEquals(path1, path2) -class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): +class TestImageFileDialogInteraction(testutils.TestCaseQt, _UtilsMixin): def tearDown(self): self._deleteDialog() - utils.TestCaseQt.tearDown(self) + testutils.TestCaseQt.tearDown(self) def testDisplayAndKeyEscape(self): dialog = self.createDialog() @@ -165,7 +165,7 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): self.keyClick(dialog, qt.Qt.Key_Escape) self.assertFalse(dialog.isVisible()) - self.assertEquals(dialog.result(), qt.QDialog.Rejected) + self.assertEqual(dialog.result(), qt.QDialog.Rejected) def testDisplayAndClickCancel(self): dialog = self.createDialog() @@ -173,11 +173,11 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): self.qWaitForWindowExposed(dialog) self.assertTrue(dialog.isVisible()) - button = utils.findChildren(dialog, qt.QPushButton, name="cancel")[0] + button = testutils.findChildren(dialog, qt.QPushButton, name="cancel")[0] self.mouseClick(button, qt.Qt.LeftButton) self.assertFalse(dialog.isVisible()) self.assertFalse(dialog.isVisible()) - self.assertEquals(dialog.result(), qt.QDialog.Rejected) + self.assertEqual(dialog.result(), qt.QDialog.Rejected) def testDisplayAndClickLockedOpen(self): dialog = self.createDialog() @@ -185,11 +185,11 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): self.qWaitForWindowExposed(dialog) self.assertTrue(dialog.isVisible()) - button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0] self.mouseClick(button, qt.Qt.LeftButton) # open button locked, dialog is not closed self.assertTrue(dialog.isVisible()) - self.assertEquals(dialog.result(), qt.QDialog.Rejected) + self.assertEqual(dialog.result(), qt.QDialog.Rejected) def testDisplayAndClickOpen(self): if fabio is None: @@ -202,20 +202,20 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): dialog.selectFile(filename) self.qWaitForPendingActions(dialog) - button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0] self.assertTrue(button.isEnabled()) self.mouseClick(button, qt.Qt.LeftButton) self.assertFalse(dialog.isVisible()) - self.assertEquals(dialog.result(), qt.QDialog.Accepted) + self.assertEqual(dialog.result(), qt.QDialog.Accepted) def testClickOnShortcut(self): dialog = self.createDialog() dialog.show() self.qWaitForWindowExposed(dialog) - sidebar = utils.findChildren(dialog, qt.QListView, name="sidebar")[0] - url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + sidebar = testutils.findChildren(dialog, qt.QListView, name="sidebar")[0] + url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] dialog.setDirectory(_tmpDirectory) self.qWaitForPendingActions(dialog) @@ -248,13 +248,13 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): dialog.show() self.qWaitForWindowExposed(dialog) - action = utils.findChildren(dialog, qt.QAction, name="detailModeAction")[0] - detailModeButton = utils.getQToolButtonFromAction(action) + action = testutils.findChildren(dialog, qt.QAction, name="detailModeAction")[0] + detailModeButton = testutils.getQToolButtonFromAction(action) self.mouseClick(detailModeButton, qt.Qt.LeftButton) self.assertEqual(dialog.viewMode(), qt.QFileDialog.Detail) - action = utils.findChildren(dialog, qt.QAction, name="listModeAction")[0] - listModeButton = utils.getQToolButtonFromAction(action) + action = testutils.findChildren(dialog, qt.QAction, name="listModeAction")[0] + listModeButton = testutils.getQToolButtonFromAction(action) self.mouseClick(listModeButton, qt.Qt.LeftButton) self.assertEqual(dialog.viewMode(), qt.QFileDialog.List) @@ -265,9 +265,9 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): dialog.show() self.qWaitForWindowExposed(dialog) - url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] - action = utils.findChildren(dialog, qt.QAction, name="toParentAction")[0] - toParentButton = utils.getQToolButtonFromAction(action) + url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = testutils.findChildren(dialog, qt.QAction, name="toParentAction")[0] + toParentButton = testutils.getQToolButtonFromAction(action) filename = _tmpDirectory + "/data/data.h5" # init state @@ -275,23 +275,19 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): dialog.selectUrl(path) self.qWaitForPendingActions(dialog) path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path() - print(url.text()) self.assertSamePath(url.text(), path) # test self.mouseClick(toParentButton, qt.Qt.LeftButton) self.qWaitForPendingActions(dialog) path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() - print(url.text()) self.assertSamePath(url.text(), path) self.mouseClick(toParentButton, qt.Qt.LeftButton) self.qWaitForPendingActions(dialog) - print(url.text()) self.assertSamePath(url.text(), _tmpDirectory + "/data") self.mouseClick(toParentButton, qt.Qt.LeftButton) self.qWaitForPendingActions(dialog) - print(url.text()) self.assertSamePath(url.text(), _tmpDirectory) def testClickOnBackToRootTool(self): @@ -301,9 +297,9 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): dialog.show() self.qWaitForWindowExposed(dialog) - url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] - action = utils.findChildren(dialog, qt.QAction, name="toRootFileAction")[0] - button = utils.getQToolButtonFromAction(action) + url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = testutils.findChildren(dialog, qt.QAction, name="toRootFileAction")[0] + button = testutils.getQToolButtonFromAction(action) filename = _tmpDirectory + "/data.h5" # init state @@ -326,9 +322,9 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): dialog.show() self.qWaitForWindowExposed(dialog) - url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] - action = utils.findChildren(dialog, qt.QAction, name="toDirectoryAction")[0] - button = utils.getQToolButtonFromAction(action) + url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = testutils.findChildren(dialog, qt.QAction, name="toDirectoryAction")[0] + button = testutils.getQToolButtonFromAction(action) filename = _tmpDirectory + "/data.h5" # init state @@ -355,9 +351,9 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): dialog.show() self.qWaitForWindowExposed(dialog) - url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] - forwardAction = utils.findChildren(dialog, qt.QAction, name="forwardAction")[0] - backwardAction = utils.findChildren(dialog, qt.QAction, name="backwardAction")[0] + url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0] + forwardAction = testutils.findChildren(dialog, qt.QAction, name="forwardAction")[0] + backwardAction = testutils.findChildren(dialog, qt.QAction, name="backwardAction")[0] filename = _tmpDirectory + "/data.h5" dialog.setDirectory(_tmpDirectory) @@ -375,14 +371,14 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): self.assertFalse(forwardAction.isEnabled()) self.assertTrue(backwardAction.isEnabled()) - button = utils.getQToolButtonFromAction(backwardAction) + button = testutils.getQToolButtonFromAction(backwardAction) self.mouseClick(button, qt.Qt.LeftButton) self.qWaitForPendingActions(dialog) self.assertTrue(forwardAction.isEnabled()) self.assertTrue(backwardAction.isEnabled()) self.assertSamePath(url.text(), path2) - button = utils.getQToolButtonFromAction(forwardAction) + button = testutils.getQToolButtonFromAction(forwardAction) self.mouseClick(button, qt.Qt.LeftButton) self.qWaitForPendingActions(dialog) self.assertFalse(forwardAction.isEnabled()) @@ -415,7 +411,7 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): # init state dialog.selectUrl(_tmpDirectory) self.qWaitForPendingActions(dialog) - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] filename = _tmpDirectory + "/singleimage.edf" path = silx.io.url.DataUrl(scheme="fabio", file_path=filename).path() index = browser.rootIndex().model().index(filename) @@ -489,7 +485,7 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): # init state dialog.selectUrl(_tmpDirectory) self.qWaitForPendingActions(dialog) - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] filename = _tmpDirectory + "/data.h5" path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() index = browser.rootIndex().model().index(filename) @@ -526,7 +522,7 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): # init state dialog.selectUrl(_tmpDirectory) self.qWaitForPendingActions(dialog) - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] filename = _tmpDirectory + "/badformat.edf" index = browser.rootIndex().model().index(filename) browser.activated.emit(index) @@ -550,8 +546,8 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): if fabio is None: self.skipTest("fabio is missing") dialog = self.createDialog() - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] - filters = utils.findChildren(dialog, qt.QWidget, name="fileTypeCombo")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] + filters = testutils.findChildren(dialog, qt.QWidget, name="fileTypeCombo")[0] dialog.show() self.qWaitForWindowExposed(dialog) dialog.selectUrl(_tmpDirectory) @@ -573,11 +569,11 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 2) -class TestImageFileDialogApi(utils.TestCaseQt, _UtilsMixin): +class TestImageFileDialogApi(testutils.TestCaseQt, _UtilsMixin): def tearDown(self): self._deleteDialog() - utils.TestCaseQt.tearDown(self) + testutils.TestCaseQt.tearDown(self) def testSaveRestoreState(self): dialog = self.createDialog() @@ -782,7 +778,7 @@ class TestImageFileDialogApi(utils.TestCaseQt, _UtilsMixin): dialog = self.createDialog() self.qWaitForPendingActions(dialog) - browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0] filename = _tmpDirectory + "/data.h5" url = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/foobar") diff --git a/silx/gui/fit/FitConfig.py b/silx/gui/fit/FitConfig.py index 04e411b..479e469 100644 --- a/silx/gui/fit/FitConfig.py +++ b/silx/gui/fit/FitConfig.py @@ -1,6 +1,6 @@ # coding: utf-8 # /*########################################################################## -# Copyright (C) 2004-2016 V.A. Sole, European Synchrotron Radiation Facility +# Copyright (C) 2004-2018 V.A. Sole, European Synchrotron Radiation Facility # # This file is part of the PyMca X-ray Fluorescence Toolkit developed at # the ESRF by the Software group. @@ -60,7 +60,7 @@ class TabsDialog(qt.QDialog): # layout2.addWidget(self.buttonHelp) self.buttonDefault = qt.QPushButton(self) - self.buttonDefault.setText("Default") + self.buttonDefault.setText("Undo changes") layout2.addWidget(self.buttonDefault) spacer = qt.QSpacerItem(20, 20, @@ -138,7 +138,7 @@ class TabsDialogData(TabsDialog): self.default = {} if default is None else default - self.buttonDefault.clicked.connect(self.setDefault) + self.buttonDefault.clicked.connect(self._resetDefault) # self.keyPressEvent(qt.Qt.Key_Enter). def keyPressEvent(self, event): @@ -174,6 +174,9 @@ class TabsDialogData(TabsDialog): self.setDefault() super(TabsDialogData, self).reject() + def _resetDefault(self, checked): + self.setDefault() + def setDefault(self, newdefault=None): """Reinitialize :attr:`output` with :attr:`default` or with new dictionary ``newdefault`` if provided. diff --git a/silx/gui/fit/FitWidget.py b/silx/gui/fit/FitWidget.py index 7012b63..78230b1 100644 --- a/silx/gui/fit/FitWidget.py +++ b/silx/gui/fit/FitWidget.py @@ -38,7 +38,7 @@ be user defined, or by default are loaded from __authors__ = ["V.A. Sole", "P. Knobel"] __license__ = "MIT" -__date__ = "15/02/2017" +__date__ = "17/07/2018" import logging import sys @@ -315,8 +315,8 @@ class FitWidget(qt.QWidget): configuration.update(self.configure()) def setdata(self, x, y, sigmay=None, xmin=None, xmax=None): - warnings.warn("Method renamed to setData", - DeprecationWarning) + warnings.warning("Method renamed to setData", + DeprecationWarning) self.setData(x, y, sigmay, xmin, xmax) def setData(self, x, y, sigmay=None, xmin=None, xmax=None): @@ -525,8 +525,8 @@ class FitWidget(qt.QWidget): self._emitSignal(ddict) def startfit(self): - warnings.warn("Method renamed to startFit", - DeprecationWarning) + warnings.warning("Method renamed to startFit", + DeprecationWarning) self.startFit() def startFit(self): diff --git a/silx/gui/fit/test/testBackgroundWidget.py b/silx/gui/fit/test/testBackgroundWidget.py index 2e366e4..03b17b9 100644 --- a/silx/gui/fit/test/testBackgroundWidget.py +++ b/silx/gui/fit/test/testBackgroundWidget.py @@ -24,7 +24,7 @@ # ###########################################################################*/ import unittest -from ...test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from .. import BackgroundWidget diff --git a/silx/gui/fit/test/testFitConfig.py b/silx/gui/fit/test/testFitConfig.py index eea35cc..f89c099 100644 --- a/silx/gui/fit/test/testFitConfig.py +++ b/silx/gui/fit/test/testFitConfig.py @@ -30,7 +30,7 @@ __date__ = "05/12/2016" import unittest -from ...test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from .. import FitConfig diff --git a/silx/gui/fit/test/testFitWidget.py b/silx/gui/fit/test/testFitWidget.py index d542fd0..cfd2bc9 100644 --- a/silx/gui/fit/test/testFitWidget.py +++ b/silx/gui/fit/test/testFitWidget.py @@ -26,7 +26,7 @@ import unittest -from ...test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from ... import qt from .. import FitWidget diff --git a/silx/gui/hdf5/Hdf5Item.py b/silx/gui/hdf5/Hdf5Item.py index 9804907..b3c313e 100644 --- a/silx/gui/hdf5/Hdf5Item.py +++ b/silx/gui/hdf5/Hdf5Item.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# Copyright (c) 2016-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 @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "10/10/2017" +__date__ = "03/09/2018" import logging @@ -37,9 +37,8 @@ from .Hdf5Node import Hdf5Node import silx.io.utils from silx.gui.data.TextFormatter import TextFormatter from ..hdf5.Hdf5Formatter import Hdf5Formatter - +from ...third_party import six _logger = logging.getLogger(__name__) - _formatter = TextFormatter() _hdf5Formatter = Hdf5Formatter(textFormatter=_formatter) # FIXME: The formatter should be an attribute of the Hdf5Model @@ -63,8 +62,16 @@ class Hdf5Item(Hdf5Node): self.__error = None self.__text = text self.__linkClass = linkClass + self.__nx_class = None Hdf5Node.__init__(self, parent, populateAll=populateAll) + def _getCanonicalName(self): + parent = self.parent + if parent is None: + return self.__text + else: + return "%s/%s" % (parent._getCanonicalName(), self.__text) + @property def obj(self): if self.__key: @@ -152,8 +159,7 @@ class Hdf5Item(Hdf5Node): try: obj = parent_obj.get(self.__key) except Exception as e: - lib_name = self.obj.__class__.__module__.split(".")[0] - _logger.debug("Internal %s error", lib_name, exc_info=True) + _logger.error("Internal error while reaching HDF5 object: %s", str(e)) _logger.debug("Backtrace", exc_info=True) try: self.__obj = parent_obj.get(self.__key, getlink=True) @@ -184,7 +190,7 @@ class Hdf5Item(Hdf5Node): elif class_ == silx.io.utils.H5Type.SOFT_LINK: message = "Soft link broken. Path %s does not exist" % (self.__obj.path) else: - name = self.obj.__class__.__name__.split(".")[-1].capitalize() + name = self.__obj.__class__.__name__.split(".")[-1].capitalize() message = "%s broken" % (name) self.__error = message self.__isBroken = True @@ -293,6 +299,8 @@ class Hdf5Item(Hdf5Node): attributeDict["Data type"] = self._getFormatter().humanReadableType(self.obj, full=True) elif self.h5Class == silx.io.utils.H5Type.GROUP: attributeDict["#Title"] = "HDF5 Group" + if self.nexusClassName: + attributeDict["NX_class"] = self.nexusClassName attributeDict["Name"] = self.basename attributeDict["Path"] = self.obj.name elif self.h5Class == silx.io.utils.H5Type.FILE: @@ -332,6 +340,20 @@ class Hdf5Item(Hdf5Node): return tooltip + @property + def nexusClassName(self): + """Returns the Nexus class name""" + if self.__nx_class is None: + self.__nx_class = self.obj.attrs.get("NX_class", None) + if self.__nx_class is None: + self.__nx_class = "" + else: + if six.PY2: + self.__nx_class = self.__nx_class.decode() + elif not isinstance(self.__nx_class, str): + self.__nx_class = str(self.__nx_class, "UTF-8") + return self.__nx_class + def dataName(self, role): """Data for the name column""" if role == qt.Qt.TextAlignmentRole: @@ -354,12 +376,13 @@ class Hdf5Item(Hdf5Node): if self.__error is not None: return "" class_ = self.h5Class - if class_ == silx.io.utils.H5Type.DATASET: + if self.isGroupObj(): + text = self.nexusClassName + elif class_ == silx.io.utils.H5Type.DATASET: text = self._getFormatter().humanReadableType(self.obj) else: text = "" return text - return None def dataShape(self, role): diff --git a/silx/gui/hdf5/Hdf5LoadingItem.py b/silx/gui/hdf5/Hdf5LoadingItem.py index 4467366..f11d252 100644 --- a/silx/gui/hdf5/Hdf5LoadingItem.py +++ b/silx/gui/hdf5/Hdf5LoadingItem.py @@ -25,11 +25,12 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "23/09/2016" +__date__ = "06/07/2018" from .. import qt from .Hdf5Node import Hdf5Node +import silx.io.utils class Hdf5LoadingItem(Hdf5Node): @@ -49,6 +50,14 @@ class Hdf5LoadingItem(Hdf5Node): def obj(self): return None + @property + def h5Class(self): + """Returns the class of the stored object. + + :rtype: silx.io.utils.H5Type + """ + return silx.io.utils.H5Type.FILE + def dataName(self, role): if role == qt.Qt.DecorationRole: return self.__animatedIcon.currentIcon() diff --git a/silx/gui/hdf5/Hdf5Node.py b/silx/gui/hdf5/Hdf5Node.py index 0fcb407..be16535 100644 --- a/silx/gui/hdf5/Hdf5Node.py +++ b/silx/gui/hdf5/Hdf5Node.py @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "16/06/2017" +__date__ = "24/07/2018" import weakref @@ -52,6 +52,13 @@ class Hdf5Node(object): self.__child = [] self._populateChild(populateAll=True) + def _getCanonicalName(self): + parent = self.parent + if parent is None: + return "root" + else: + return "%s/?" % (parent._getCanonicalName()) + @property def parent(self): """Parent of the node, or None if the node is a root diff --git a/silx/gui/hdf5/Hdf5TreeModel.py b/silx/gui/hdf5/Hdf5TreeModel.py index 835708a..438200b 100644 --- a/silx/gui/hdf5/Hdf5TreeModel.py +++ b/silx/gui/hdf5/Hdf5TreeModel.py @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "11/06/2018" +__date__ = "08/10/2018" import os @@ -591,6 +591,13 @@ class Hdf5TreeModel(qt.QAbstractItemModel): filename = node.obj.filename self.insertFileAsync(filename, index.row(), synchronizingNode=node) + def h5pyObjectRow(self, h5pyObject): + for row in range(self.__root.childCount()): + item = self.__root.child(row) + if item.obj == h5pyObject: + return row + return -1 + def synchronizeH5pyObject(self, h5pyObject): """ Synchronize a h5py object in all the tree. @@ -602,7 +609,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): index = 0 while index < self.__root.childCount(): item = self.__root.child(index) - if item.obj is h5pyObject: + if item.obj == h5pyObject: qindex = self.index(index, 0, qt.QModelIndex()) self.synchronizeIndex(qindex) index += 1 @@ -614,7 +621,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): :param qt.QModelIndex index: Index of the item to remove """ node = self.nodeFromIndex(index) - if node.parent is not self.__root: + if node.parent != self.__root: return self._closeFileIfOwned(node) self.beginRemoveRows(qt.QModelIndex(), index.row(), index.row()) @@ -632,7 +639,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): index = 0 while index < self.__root.childCount(): item = self.__root.child(index) - if item.obj is h5pyObject: + if item.obj == h5pyObject: qindex = self.index(index, 0, qt.QModelIndex()) self.removeIndex(qindex) else: diff --git a/silx/gui/hdf5/NexusSortFilterProxyModel.py b/silx/gui/hdf5/NexusSortFilterProxyModel.py index 3f2cf8d..216e992 100644 --- a/silx/gui/hdf5/NexusSortFilterProxyModel.py +++ b/silx/gui/hdf5/NexusSortFilterProxyModel.py @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "25/06/2018" +__date__ = "24/07/2018" import logging @@ -48,6 +48,24 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel): self.__split = re.compile("(\\d+|\\D+)") self.__iconCache = {} + def hasChildren(self, parent): + """Returns true if parent has any children; otherwise returns false. + + :param qt.QModelIndex parent: Index of the item to check + :rtype: bool + """ + parent = self.mapToSource(parent) + return self.sourceModel().hasChildren(parent) + + def rowCount(self, parent): + """Returns the number of rows under the given parent. + + :param qt.QModelIndex parent: Index of the item to check + :rtype: int + """ + parent = self.mapToSource(parent) + return self.sourceModel().rowCount(parent) + def lessThan(self, sourceLeft, sourceRight): """Returns True if the value of the item referred to by the given index `sourceLeft` is less than the value of the item referred to by diff --git a/silx/gui/hdf5/_utils.py b/silx/gui/hdf5/_utils.py index 8385129..6a34933 100644 --- a/silx/gui/hdf5/_utils.py +++ b/silx/gui/hdf5/_utils.py @@ -178,10 +178,11 @@ class H5Node(object): elif obj.name.startswith("/"): elements.pop(0) path = "" + subpath = "" while len(elements) > 0: e = elements.pop(0) - path = path + "/" + e - link = obj.parent.get(path, getlink=True) + subpath = path + "/" + e + link = obj.parent.get(subpath, getlink=True) classlink = silx.io.utils.get_h5_class(link) if classlink == silx.io.utils.H5Type.EXTERNAL_LINK: @@ -190,14 +191,18 @@ class H5Node(object): return self.__get_target(external_obj) elif classlink == silx.io.utils.H5Type.SOFT_LINK: # Restart from this stat - path = "" root_elements = link.path.split("/") if link.path == "/": + path = "" root_elements = [] elif link.path.startswith("/"): + path = "" root_elements.pop(0) + for name in reversed(root_elements): elements.insert(0, name) + else: + path = subpath return obj.file[path] diff --git a/silx/gui/hdf5/test/test_hdf5.py b/silx/gui/hdf5/test/test_hdf5.py index fc27f6b..1751a21 100644 --- a/silx/gui/hdf5/test/test_hdf5.py +++ b/silx/gui/hdf5/test/test_hdf5.py @@ -37,9 +37,9 @@ import numpy import shutil from contextlib import contextmanager from silx.gui import qt -from silx.gui.test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from silx.gui import hdf5 -from silx.gui.test.utils import SignalListener +from silx.gui.utils.testutils import SignalListener from silx.io import commonh5 import weakref @@ -123,9 +123,9 @@ class TestHdf5TreeModel(TestCaseQt): def testAppendFilename(self): filename = _tmpDirectory + "/data.h5" model = hdf5.Hdf5TreeModel() - self.assertEquals(model.rowCount(qt.QModelIndex()), 0) + self.assertEqual(model.rowCount(qt.QModelIndex()), 0) model.appendFile(filename) - self.assertEquals(model.rowCount(qt.QModelIndex()), 1) + self.assertEqual(model.rowCount(qt.QModelIndex()), 1) # clean up index = model.index(0, 0, qt.QModelIndex()) h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) @@ -141,9 +141,9 @@ class TestHdf5TreeModel(TestCaseQt): filename = _tmpDirectory + "/data.h5" try: model = hdf5.Hdf5TreeModel() - self.assertEquals(model.rowCount(qt.QModelIndex()), 0) + self.assertEqual(model.rowCount(qt.QModelIndex()), 0) model.insertFile(filename) - self.assertEquals(model.rowCount(qt.QModelIndex()), 1) + self.assertEqual(model.rowCount(qt.QModelIndex()), 1) # clean up index = model.index(0, 0, qt.QModelIndex()) h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) @@ -157,7 +157,7 @@ class TestHdf5TreeModel(TestCaseQt): filename = _tmpDirectory + "/data.h5" try: model = hdf5.Hdf5TreeModel() - self.assertEquals(model.rowCount(qt.QModelIndex()), 0) + self.assertEqual(model.rowCount(qt.QModelIndex()), 0) model.insertFileAsync(filename) index = model.index(0, 0, qt.QModelIndex()) self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5LoadingItem.Hdf5LoadingItem) @@ -172,25 +172,25 @@ class TestHdf5TreeModel(TestCaseQt): def testInsertObject(self): h5 = commonh5.File("/foo/bar/1.mock", "w") model = hdf5.Hdf5TreeModel() - self.assertEquals(model.rowCount(qt.QModelIndex()), 0) + self.assertEqual(model.rowCount(qt.QModelIndex()), 0) model.insertH5pyObject(h5) - self.assertEquals(model.rowCount(qt.QModelIndex()), 1) + self.assertEqual(model.rowCount(qt.QModelIndex()), 1) def testRemoveObject(self): h5 = commonh5.File("/foo/bar/1.mock", "w") model = hdf5.Hdf5TreeModel() - self.assertEquals(model.rowCount(qt.QModelIndex()), 0) + self.assertEqual(model.rowCount(qt.QModelIndex()), 0) model.insertH5pyObject(h5) - self.assertEquals(model.rowCount(qt.QModelIndex()), 1) + self.assertEqual(model.rowCount(qt.QModelIndex()), 1) model.removeH5pyObject(h5) - self.assertEquals(model.rowCount(qt.QModelIndex()), 0) + self.assertEqual(model.rowCount(qt.QModelIndex()), 0) def testSynchronizeObject(self): filename = _tmpDirectory + "/data.h5" h5 = h5py.File(filename) model = hdf5.Hdf5TreeModel() model.insertH5pyObject(h5) - self.assertEquals(model.rowCount(qt.QModelIndex()), 1) + self.assertEqual(model.rowCount(qt.QModelIndex()), 1) index = model.index(0, 0, qt.QModelIndex()) node1 = model.nodeFromIndex(index) model.synchronizeH5pyObject(h5) @@ -220,15 +220,15 @@ class TestHdf5TreeModel(TestCaseQt): def testFileMoveState(self): model = hdf5.Hdf5TreeModel() - self.assertEquals(model.isFileMoveEnabled(), True) + self.assertEqual(model.isFileMoveEnabled(), True) model.setFileMoveEnabled(False) - self.assertEquals(model.isFileMoveEnabled(), False) + self.assertEqual(model.isFileMoveEnabled(), False) def testFileDropState(self): model = hdf5.Hdf5TreeModel() - self.assertEquals(model.isFileDropEnabled(), True) + self.assertEqual(model.isFileDropEnabled(), True) model.setFileDropEnabled(False) - self.assertEquals(model.isFileDropEnabled(), False) + self.assertEqual(model.isFileDropEnabled(), False) def testSupportedDrop(self): model = hdf5.Hdf5TreeModel() @@ -236,7 +236,7 @@ class TestHdf5TreeModel(TestCaseQt): model.setFileMoveEnabled(False) model.setFileDropEnabled(False) - self.assertEquals(model.supportedDropActions(), 0) + self.assertEqual(model.supportedDropActions(), 0) model.setFileMoveEnabled(False) model.setFileDropEnabled(True) @@ -252,7 +252,7 @@ class TestHdf5TreeModel(TestCaseQt): mimeData = qt.QMimeData() mimeData.setUrls([qt.QUrl.fromLocalFile(filename)]) model.dropMimeData(mimeData, qt.Qt.CopyAction, 0, 0, qt.QModelIndex()) - self.assertEquals(model.rowCount(qt.QModelIndex()), 1) + self.assertEqual(model.rowCount(qt.QModelIndex()), 1) # after sync self.waitForPendingOperations(model) index = model.index(0, 0, qt.QModelIndex()) @@ -285,13 +285,13 @@ class TestHdf5TreeModel(TestCaseQt): model = hdf5.Hdf5TreeModel() model.insertH5pyObject(h5) displayed = self.getRowDataAsDict(model, row=0) - self.assertEquals(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DisplayRole], "1.mock") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DisplayRole], "1.mock") self.assertIsInstance(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DecorationRole], qt.QIcon) - self.assertEquals(displayed[hdf5.Hdf5TreeModel.TYPE_COLUMN, qt.Qt.DisplayRole], "") - self.assertEquals(displayed[hdf5.Hdf5TreeModel.SHAPE_COLUMN, qt.Qt.DisplayRole], "") - self.assertEquals(displayed[hdf5.Hdf5TreeModel.VALUE_COLUMN, qt.Qt.DisplayRole], "") - self.assertEquals(displayed[hdf5.Hdf5TreeModel.DESCRIPTION_COLUMN, qt.Qt.DisplayRole], "") - self.assertEquals(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "File") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.TYPE_COLUMN, qt.Qt.DisplayRole], "") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.SHAPE_COLUMN, qt.Qt.DisplayRole], "") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.VALUE_COLUMN, qt.Qt.DisplayRole], "") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.DESCRIPTION_COLUMN, qt.Qt.DisplayRole], "") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "File") def testGroupData(self): h5 = commonh5.File("/foo/bar/1.mock", "w") @@ -301,13 +301,13 @@ class TestHdf5TreeModel(TestCaseQt): model = hdf5.Hdf5TreeModel() model.insertH5pyObject(d) displayed = self.getRowDataAsDict(model, row=0) - self.assertEquals(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DisplayRole], "1.mock::foo") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DisplayRole], "1.mock::foo") self.assertIsInstance(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DecorationRole], qt.QIcon) - self.assertEquals(displayed[hdf5.Hdf5TreeModel.TYPE_COLUMN, qt.Qt.DisplayRole], "") - self.assertEquals(displayed[hdf5.Hdf5TreeModel.SHAPE_COLUMN, qt.Qt.DisplayRole], "") - self.assertEquals(displayed[hdf5.Hdf5TreeModel.VALUE_COLUMN, qt.Qt.DisplayRole], "") - self.assertEquals(displayed[hdf5.Hdf5TreeModel.DESCRIPTION_COLUMN, qt.Qt.DisplayRole], "fooo") - self.assertEquals(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "Group") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.TYPE_COLUMN, qt.Qt.DisplayRole], "") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.SHAPE_COLUMN, qt.Qt.DisplayRole], "") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.VALUE_COLUMN, qt.Qt.DisplayRole], "") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.DESCRIPTION_COLUMN, qt.Qt.DisplayRole], "fooo") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "Group") def testDatasetData(self): h5 = commonh5.File("/foo/bar/1.mock", "w") @@ -317,13 +317,13 @@ class TestHdf5TreeModel(TestCaseQt): model = hdf5.Hdf5TreeModel() model.insertH5pyObject(d) displayed = self.getRowDataAsDict(model, row=0) - self.assertEquals(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DisplayRole], "1.mock::foo") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DisplayRole], "1.mock::foo") self.assertIsInstance(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DecorationRole], qt.QIcon) - self.assertEquals(displayed[hdf5.Hdf5TreeModel.TYPE_COLUMN, qt.Qt.DisplayRole], value.dtype.name) - self.assertEquals(displayed[hdf5.Hdf5TreeModel.SHAPE_COLUMN, qt.Qt.DisplayRole], "3") - self.assertEquals(displayed[hdf5.Hdf5TreeModel.VALUE_COLUMN, qt.Qt.DisplayRole], "[1 2 3]") - self.assertEquals(displayed[hdf5.Hdf5TreeModel.DESCRIPTION_COLUMN, qt.Qt.DisplayRole], "") - self.assertEquals(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "Dataset") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.TYPE_COLUMN, qt.Qt.DisplayRole], value.dtype.name) + self.assertEqual(displayed[hdf5.Hdf5TreeModel.SHAPE_COLUMN, qt.Qt.DisplayRole], "3") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.VALUE_COLUMN, qt.Qt.DisplayRole], "[1 2 3]") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.DESCRIPTION_COLUMN, qt.Qt.DisplayRole], "") + self.assertEqual(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "Dataset") def testDropLastAsFirst(self): model = hdf5.Hdf5TreeModel() @@ -331,13 +331,13 @@ class TestHdf5TreeModel(TestCaseQt): h5_2 = commonh5.File("/foo/bar/2.mock", "w") model.insertH5pyObject(h5_1) model.insertH5pyObject(h5_2) - self.assertEquals(self.getItemName(model, 0), "1.mock") - self.assertEquals(self.getItemName(model, 1), "2.mock") + self.assertEqual(self.getItemName(model, 0), "1.mock") + self.assertEqual(self.getItemName(model, 1), "2.mock") index = model.index(1, 0, qt.QModelIndex()) mimeData = model.mimeData([index]) model.dropMimeData(mimeData, qt.Qt.MoveAction, 0, 0, qt.QModelIndex()) - self.assertEquals(self.getItemName(model, 0), "2.mock") - self.assertEquals(self.getItemName(model, 1), "1.mock") + self.assertEqual(self.getItemName(model, 0), "2.mock") + self.assertEqual(self.getItemName(model, 1), "1.mock") def testDropFirstAsLast(self): model = hdf5.Hdf5TreeModel() @@ -345,13 +345,13 @@ class TestHdf5TreeModel(TestCaseQt): h5_2 = commonh5.File("/foo/bar/2.mock", "w") model.insertH5pyObject(h5_1) model.insertH5pyObject(h5_2) - self.assertEquals(self.getItemName(model, 0), "1.mock") - self.assertEquals(self.getItemName(model, 1), "2.mock") + self.assertEqual(self.getItemName(model, 0), "1.mock") + self.assertEqual(self.getItemName(model, 1), "2.mock") index = model.index(0, 0, qt.QModelIndex()) mimeData = model.mimeData([index]) model.dropMimeData(mimeData, qt.Qt.MoveAction, 2, 0, qt.QModelIndex()) - self.assertEquals(self.getItemName(model, 0), "2.mock") - self.assertEquals(self.getItemName(model, 1), "1.mock") + self.assertEqual(self.getItemName(model, 0), "2.mock") + self.assertEqual(self.getItemName(model, 1), "1.mock") def testRootParent(self): model = hdf5.Hdf5TreeModel() @@ -359,7 +359,7 @@ class TestHdf5TreeModel(TestCaseQt): model.insertH5pyObject(h5_1) index = model.index(0, 0, qt.QModelIndex()) index = model.parent(index) - self.assertEquals(index, qt.QModelIndex()) + self.assertEqual(index, qt.QModelIndex()) class TestHdf5TreeModelSignals(TestCaseQt): @@ -397,27 +397,27 @@ class TestHdf5TreeModelSignals(TestCaseQt): filename = _tmpDirectory + "/data.h5" h5 = h5py.File(filename) self.model.insertH5pyObject(h5) - self.assertEquals(self.listener.callCount(), 0) + self.assertEqual(self.listener.callCount(), 0) def testLoaded(self): filename = _tmpDirectory + "/data.h5" self.model.insertFile(filename) - self.assertEquals(self.listener.callCount(), 1) - self.assertEquals(self.listener.karguments(argumentName="signal")[0], "loaded") + self.assertEqual(self.listener.callCount(), 1) + self.assertEqual(self.listener.karguments(argumentName="signal")[0], "loaded") self.assertIsNot(self.listener.arguments(callIndex=0)[0], self.h5) - self.assertEquals(self.listener.arguments(callIndex=0)[0].filename, filename) + self.assertEqual(self.listener.arguments(callIndex=0)[0].filename, filename) def testRemoved(self): self.model.removeH5pyObject(self.h5) - self.assertEquals(self.listener.callCount(), 1) - self.assertEquals(self.listener.karguments(argumentName="signal")[0], "removed") + self.assertEqual(self.listener.callCount(), 1) + self.assertEqual(self.listener.karguments(argumentName="signal")[0], "removed") self.assertIs(self.listener.arguments(callIndex=0)[0], self.h5) def testSynchonized(self): self.model.synchronizeH5pyObject(self.h5) self.waitForPendingOperations(self.model) - self.assertEquals(self.listener.callCount(), 1) - self.assertEquals(self.listener.karguments(argumentName="signal")[0], "synchronized") + self.assertEqual(self.listener.callCount(), 1) + self.assertEqual(self.listener.karguments(argumentName="signal")[0], "synchronized") self.assertIs(self.listener.arguments(callIndex=0)[0], self.h5) self.assertIsNot(self.listener.arguments(callIndex=0)[1], self.h5) @@ -595,6 +595,7 @@ class TestH5Node(TestCaseQt): h5["link/soft_link_to_group"] = h5py.SoftLink("/group") h5["link/soft_link_to_link"] = h5py.SoftLink("/link/soft_link") h5["link/soft_link_to_file"] = h5py.SoftLink("/") + h5["group/soft_link_relative"] = h5py.SoftLink("dataset") h5["link/external_link"] = h5py.ExternalLink(externalFilename, "/target/dataset") h5["link/external_link_to_link"] = h5py.ExternalLink(externalFilename, "/target/link") h5["broken_link/external_broken_file"] = h5py.ExternalLink(externalFilename + "_not_exists", "/target/link") @@ -697,6 +698,17 @@ class TestH5Node(TestCaseQt): self.assertEqual(h5node.local_basename, "soft_link_to_link") self.assertEqual(h5node.local_name, "/link/soft_link_to_link") + def testSoftLinkRelative(self): + path = ["base.h5", "group", "soft_link_relative"] + h5node = self.getH5NodeFromPath(self.model, path) + + self.assertEqual(h5node.physical_filename, h5node.local_filename) + self.assertIn("base.h5", h5node.physical_filename) + self.assertEqual(h5node.physical_basename, "dataset") + self.assertEqual(h5node.physical_name, "/group/dataset") + self.assertEqual(h5node.local_basename, "soft_link_relative") + self.assertEqual(h5node.local_name, "/group/soft_link_relative") + def testExternalLink(self): path = ["base.h5", "link", "external_link"] h5node = self.getH5NodeFromPath(self.model, path) @@ -895,7 +907,7 @@ class TestHdf5TreeView(TestCaseQt): view.setSelectedH5Node(tree) selection = list(view.selectedH5Nodes()) - self.assertEquals(len(selection), 0) + self.assertEqual(len(selection), 0) def testSelection_Tree(self): tree1 = commonh5.File("/foo/bar/1.mock", "w") diff --git a/silx/gui/icons.py b/silx/gui/icons.py index bd10300..ef99591 100644 --- a/silx/gui/icons.py +++ b/silx/gui/icons.py @@ -29,7 +29,7 @@ Use :func:`getQIcon` to create Qt QIcon from the name identifying an icon. __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "19/06/2018" +__date__ = "05/10/2018" import os @@ -45,10 +45,30 @@ _logger = logging.getLogger(__name__) """Module logger""" -_cached_icons = weakref.WeakValueDictionary() +_cached_icons = None """Cache loaded icons in a weak structure""" +def getIconCache(): + """Get access to all cached icons + + :rtype: dict + """ + global _cached_icons + if _cached_icons is None: + _cached_icons = weakref.WeakValueDictionary() + # Clean up the cache before leaving the application + # See https://github.com/silx-kit/silx/issues/1771 + qt.QApplication.instance().aboutToQuit.connect(cleanIconCache) + return _cached_icons + + +def cleanIconCache(): + """Clean up the icon cache""" + _logger.debug("Clean up icon cache") + _cached_icons.clear() + + _supported_formats = None """Order of file format extension to check""" @@ -285,7 +305,8 @@ def getAnimatedIcon(name): :raises: ValueError when name is not known """ key = name + "__anim" - if key not in _cached_icons: + cached_icons = getIconCache() + if key not in cached_icons: qtMajorVersion = int(qt.qVersion().split(".")[0]) icon = None @@ -306,9 +327,9 @@ def getAnimatedIcon(name): if icon is None: raise ValueError("Not an animated icon name: %s", name) - _cached_icons[key] = icon + cached_icons[key] = icon else: - icon = _cached_icons[key] + icon = cached_icons[key] return icon @@ -329,12 +350,13 @@ def getQIcon(name): :return: Corresponding QIcon :raises: ValueError when name is not known """ - if name not in _cached_icons: + cached_icons = getIconCache() + if name not in cached_icons: qfile = getQFile(name) icon = qt.QIcon(qfile.fileName()) - _cached_icons[name] = icon + cached_icons[name] = icon else: - icon = _cached_icons[name] + icon = cached_icons[name] return icon 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) diff --git a/silx/gui/plot3d/ParamTreeView.py b/silx/gui/plot3d/ParamTreeView.py index a352627..ee0c876 100644 --- a/silx/gui/plot3d/ParamTreeView.py +++ b/silx/gui/plot3d/ParamTreeView.py @@ -40,6 +40,7 @@ __license__ = "MIT" __date__ = "05/12/2017" +import numbers import sys from silx.third_party import six @@ -362,7 +363,7 @@ class ParameterTreeDelegate(qt.QStyledItemDelegate): assert isinstance(editor, qt.QWidget) editor.setParent(parent) - elif isinstance(data, (int, float)) and editorHint is not None: + elif isinstance(data, numbers.Number) and editorHint is not None: # Use a slider editor = IntSliderEditor(parent) range_ = editorHint @@ -394,7 +395,11 @@ class ParameterTreeDelegate(qt.QStyledItemDelegate): if hasattr(notifySignal, 'signature'): # Qt4 signature = notifySignal.signature() else: - signature = bytes(notifySignal.methodSignature()) + signature = notifySignal.methodSignature() + if qt.BINDING == 'PySide2': + signature = signature.data() + else: + signature = bytes(signature) if hasattr(signature, 'decode'): # For PySide with python3 signature = signature.decode('ascii') @@ -472,7 +477,7 @@ class ParamTreeView(qt.QTreeView): editorHint = index.data(qt.Qt.UserRole) if (isinstance(data, bool) or callable(editorHint) or - (isinstance(data, (float, int)) and editorHint)): + (isinstance(data, numbers.Number) and editorHint)): self.openPersistentEditor(index) self.__persistentEditors.add(index) diff --git a/silx/gui/plot3d/Plot3DWidget.py b/silx/gui/plot3d/Plot3DWidget.py index 53ff895..eed4438 100644 --- a/silx/gui/plot3d/Plot3DWidget.py +++ b/silx/gui/plot3d/Plot3DWidget.py @@ -36,7 +36,7 @@ import logging from silx.gui import qt from silx.gui.colors import rgba from . import actions -from ..utils._image import convertArrayToQImage +from ..utils.image import convertArrayToQImage from .. import _glutils as glu from .scene import interaction, primitives, transform diff --git a/silx/gui/plot3d/SFViewParamTree.py b/silx/gui/plot3d/SFViewParamTree.py index bb81465..a2b771c 100644 --- a/silx/gui/plot3d/SFViewParamTree.py +++ b/silx/gui/plot3d/SFViewParamTree.py @@ -694,6 +694,10 @@ class IsoSurfaceRootItem(SubjectItem): Root (i.e : column index 0) Isosurface item. """ + def __init__(self, subject, normalization, *args): + self._isoLevelSliderNormalization = normalization + super(IsoSurfaceRootItem, self).__init__(subject, *args) + def getSignals(self): subject = self.subject return [subject.sigColorChanged, @@ -717,7 +721,8 @@ class IsoSurfaceRootItem(SubjectItem): self.setCheckState((visible and qt.Qt.Checked) or qt.Qt.Unchecked) nameItem = qt.QStandardItem('Level') - sliderItem = IsoSurfaceLevelSlider(self.subject) + sliderItem = IsoSurfaceLevelSlider(self.subject, + self._isoLevelSliderNormalization) self.appendRow([nameItem, sliderItem]) nameItem = qt.QStandardItem('Color') @@ -788,12 +793,22 @@ class IsoSurfaceLevelItem(SubjectItem): class _IsoLevelSlider(qt.QSlider): - """QSlider used for iso-surface level""" + """QSlider used for iso-surface level with linear scale""" - def __init__(self, parent, subject): + def __init__(self, parent, subject, normalization): super(_IsoLevelSlider, self).__init__(parent=parent) self.subject = subject + if normalization == 'arcsinh': + self.__norm = numpy.arcsinh + self.__invNorm = numpy.sinh + elif normalization == 'linear': + self.__norm = lambda x: x + self.__invNorm = lambda x: x + else: + raise ValueError( + "Unsupported normalization %s", normalization) + self.sliderReleased.connect(self.__sliderReleased) self.subject.sigLevelChanged.connect(self.setLevel) @@ -804,10 +819,13 @@ class _IsoLevelSlider(qt.QSlider): dataRange = self.subject.parent().getDataRange() if dataRange is not None: - width = dataRange[-1] - dataRange[0] + min_ = self.__norm(dataRange[0]) + max_ = self.__norm(dataRange[-1]) + + width = max_ - min_ if width > 0: sliderWidth = self.maximum() - self.minimum() - sliderPosition = sliderWidth * (level - dataRange[0]) / width + sliderPosition = sliderWidth * (self.__norm(level) - min_) / width self.setValue(sliderPosition) def __dataChanged(self): @@ -818,11 +836,12 @@ class _IsoLevelSlider(qt.QSlider): value = self.value() dataRange = self.subject.parent().getDataRange() if dataRange is not None: - min_, _, max_ = dataRange + min_ = self.__norm(dataRange[0]) + max_ = self.__norm(dataRange[-1]) width = max_ - min_ sliderWidth = self.maximum() - self.minimum() level = min_ + width * value / sliderWidth - self.subject.setLevel(level) + self.subject.setLevel(self.__invNorm(level)) class IsoSurfaceLevelSlider(IsoSurfaceLevelItem): @@ -832,8 +851,12 @@ class IsoSurfaceLevelSlider(IsoSurfaceLevelItem): nTicks = 1000 persistent = True + def __init__(self, subject, normalization): + self.normalization = normalization + super(IsoSurfaceLevelSlider, self).__init__(subject) + def getEditor(self, parent, option, index): - editor = _IsoLevelSlider(parent, self.subject) + editor = _IsoLevelSlider(parent, self.subject, self.normalization) editor.setOrientation(qt.Qt.Horizontal) editor.setMinimum(0) editor.setMaximum(self.nTicks) @@ -1067,6 +1090,11 @@ class IsoSurfaceGroup(SubjectItem): """ Root item for the list of isosurface items. """ + + def __init__(self, subject, normalization, *args): + self._isoLevelSliderNormalization = normalization + super(IsoSurfaceGroup, self).__init__(subject, *args) + def getSignals(self): subject = self.subject return [subject.sigIsosurfaceAdded, subject.sigIsosurfaceRemoved] @@ -1090,7 +1118,9 @@ class IsoSurfaceGroup(SubjectItem): raise ValueError('Expected an isosurface instance.') def __addIsosurface(self, isosurface): - valueItem = IsoSurfaceRootItem(subject=isosurface) + valueItem = IsoSurfaceRootItem( + subject=isosurface, + normalization=self._isoLevelSliderNormalization) nameItem = IsoSurfaceLevelItem(subject=isosurface) self.insertRow(max(0, self.rowCount() - 1), [valueItem, nameItem]) @@ -1570,6 +1600,7 @@ class TreeView(qt.QTreeView): def __init__(self, parent=None): super(TreeView, self).__init__(parent) self.__openedIndex = None + self._isoLevelSliderNormalization = 'linear' self.setIconSize(qt.QSize(16, 16)) @@ -1607,7 +1638,10 @@ class TreeView(qt.QTreeView): item = IsoSurfaceCount(sfView) item.setEditable(False) - model.appendRow([IsoSurfaceGroup(sfView, 'Isosurfaces'), item]) + model.appendRow([IsoSurfaceGroup(sfView, + self._isoLevelSliderNormalization, + 'Isosurfaces'), + item]) item = qt.QStandardItem() item.setEditable(False) @@ -1771,3 +1805,13 @@ class TreeView(qt.QTreeView): def __delegateEvent(self, task): if task == 'remove_iso': self.__removeIsosurfaces() + + def setIsoLevelSliderNormalization(self, normalization): + """Set the normalization for iso level slider + + This MUST be called *before* :meth:`setSfView` to have an effect. + + :param str normalization: Either 'linear' or 'arcsinh' + """ + assert normalization in ('linear', 'arcsinh') + self._isoLevelSliderNormalization = normalization diff --git a/silx/gui/plot3d/SceneWidget.py b/silx/gui/plot3d/SceneWidget.py index f005dec..4a824d7 100644 --- a/silx/gui/plot3d/SceneWidget.py +++ b/silx/gui/plot3d/SceneWidget.py @@ -39,6 +39,7 @@ from ..colors import rgba from .Plot3DWidget import Plot3DWidget from . import items +from .items.core import RootGroupWithAxesItem from .scene import interaction from ._model import SceneModel, visitQAbstractItemModel from ._model.items import Item3DRow @@ -363,10 +364,11 @@ class SceneWidget(Plot3DWidget): self._foregroundColor = 1., 1., 1., 1. self._highlightColor = 0.7, 0.7, 0., 1. - self._sceneGroup = items.GroupWithAxesItem(parent=self) + self._sceneGroup = RootGroupWithAxesItem(parent=self) self._sceneGroup.setLabel('Data') - self.viewport.scene.children.append(self._sceneGroup._getScenePrimitive()) + self.viewport.scene.children.append( + self._sceneGroup._getScenePrimitive()) def model(self): """Returns the model corresponding the scene of this widget @@ -395,6 +397,28 @@ class SceneWidget(Plot3DWidget): """ return self._sceneGroup + def pickItems(self, x, y, condition=None): + """Iterator over picked items in the scene at given position. + + Each picked item yield a + :class:`~silx.gui.plot3d.items._pick.PickingResult` object + holding the picking information. + + It traverses the scene tree in a left-to-right top-down way. + + :param int x: X widget coordinate + :param int y: Y widget coordinate + :param callable condition: Optional test called for each item + checking whether to process it or not. + """ + if not self.isValid() or not self.isVisible(): + return # Empty iterator + + devicePixelRatio = self.getDevicePixelRatio() + for result in self.getSceneGroup().pickItems( + x * devicePixelRatio, y * devicePixelRatio, condition): + yield result + # Interactive modes def _handleSelectionChanged(self, current, previous): diff --git a/silx/gui/plot3d/_model/items.py b/silx/gui/plot3d/_model/items.py index 02485fe..b09f29a 100644 --- a/silx/gui/plot3d/_model/items.py +++ b/silx/gui/plot3d/_model/items.py @@ -41,7 +41,7 @@ import numpy from silx.third_party import six -from ...utils._image import convertArrayToQImage +from ...utils.image import convertArrayToQImage from ...colors import preferredColormaps from ... import qt, icons from .. import items diff --git a/silx/gui/plot3d/actions/io.py b/silx/gui/plot3d/actions/io.py index f30abeb..4020d6f 100644 --- a/silx/gui/plot3d/actions/io.py +++ b/silx/gui/plot3d/actions/io.py @@ -43,7 +43,7 @@ from silx.gui import qt, printer from silx.gui.icons import getQIcon from .Plot3DAction import Plot3DAction from ..utils import mng -from ...utils._image import convertQImageToArray +from ...utils.image import convertQImageToArray _logger = logging.getLogger(__name__) diff --git a/silx/gui/plot3d/items/_pick.py b/silx/gui/plot3d/items/_pick.py new file mode 100644 index 0000000..b35ef0d --- /dev/null +++ b/silx/gui/plot3d/items/_pick.py @@ -0,0 +1,292 @@ +# 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 classes supporting item picking. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "24/09/2018" + +import logging +import numpy + +from ..scene import Viewport, Base + + +_logger = logging.getLogger(__name__) + + +class PickContext(object): + """Store information related to current picking + + :param int x: Widget coordinate + :param int y: Widget coordinate + :param ~silx.gui.plot3d.scene.Viewport viewport: + Viewport where picking occurs + :param Union[None,callable] condition: + Test whether each item needs to be picked or not. + """ + + def __init__(self, x, y, viewport, condition): + self._widgetPosition = x, y + assert isinstance(viewport, Viewport) + self._viewport = viewport + self._ndcZRange = -1., 1. + self._enabled = True + self._condition = condition + + def copy(self): + """Returns a copy + + :rtype: PickContent + """ + x, y = self.getWidgetPosition() + context = PickContext(x, y, self.getViewport(), self._condition) + context.setNDCZRange(*self._ndcZRange) + context.setEnabled(self.isEnabled()) + return context + + def isItemPickable(self, item): + """Check condition for the given item. + + :param Item3D item: + :return: Whether to process the item (True) or to skip it (False) + :rtype: bool + """ + return self._condition is None or self._condition(item) + + def getViewport(self): + """Returns viewport where picking occurs + + :rtype: ~silx.gui.plot3d.scene.Viewport + """ + return self._viewport + + def getWidgetPosition(self): + """Returns (x, y) position in pixel in the widget + + Origin is at the top-left corner of the widget, + X from left to right, Y goes downward. + + :rtype: List[int] + """ + return self._widgetPosition + + def setEnabled(self, enabled): + """Set whether picking is enabled or not + + :param bool enabled: True to enable picking, False otherwise + """ + self._enabled = bool(enabled) + + def isEnabled(self): + """Returns True if picking is currently enabled, False otherwise. + + :rtype: bool + """ + return self._enabled + + def setNDCZRange(self, near=-1., far=1.): + """Set near and far Z value in normalized device coordinates + + This allows to clip the ray to a subset of the NDC range + + :param float near: Near segment end point Z coordinate + :param float far: Far segment end point Z coordinate + """ + self._ndcZRange = near, far + + def getNDCPosition(self): + """Return Normalized device coordinates of picked point. + + :return: (x, y) in NDC coordinates or None if outside viewport. + :rtype: Union[None,List[float]] + """ + if not self.isEnabled(): + return None + + # Convert x, y from window to NDC + x, y = self.getWidgetPosition() + return self.getViewport().windowToNdc(x, y, checkInside=True) + + def getPickingSegment(self, frame): + """Returns picking segment in requested coordinate frame. + + :param Union[str,Base] frame: + The frame in which to get the picking segment, + either a keyword: 'ndc', 'camera', 'scene' or a scene + :class:`~silx.gui.plot3d.scene.Base` object. + :return: Near and far points of the segment as (x, y, z, w) + or None if picked point is outside viewport + :rtype: Union[None,numpy.ndarray] + """ + assert frame in ('ndc', 'camera', 'scene') or isinstance(frame, Base) + + positionNdc = self.getNDCPosition() + if positionNdc is None: + return None + + near, far = self._ndcZRange + rayNdc = numpy.array((positionNdc + (near, 1.), + positionNdc + (far, 1.)), + dtype=numpy.float64) + if frame == 'ndc': + return rayNdc + + viewport = self.getViewport() + + rayCamera = viewport.camera.intrinsic.transformPoints( + rayNdc, + direct=False, + perspectiveDivide=True) + if frame == 'camera': + return rayCamera + + rayScene = viewport.camera.extrinsic.transformPoints( + rayCamera, direct=False) + if frame == 'scene': + return rayScene + + # frame is a scene Base object + rayObject = frame.objectToSceneTransform.transformPoints( + rayScene, direct=False) + return rayObject + + +class PickingResult(object): + """Class to access picking information in a 3D scene. + """ + + def __init__(self, item, positions, indices=None, fetchdata=None): + """Init + + :param ~silx.gui.plot3d.items.Item3D item: The picked item + :param numpy.ndarray positions: + Nx3 array-like of picked positions (x, y, z) in item coordinates. + :param numpy.ndarray indices: Array-like of indices of picked data. + Either 1D or 2D with dim0: data dimension and dim1: indices. + No copy is made. + :param callable fetchdata: Optional function with a bool copy argument + to provide an alternative function to access item data. + Default is to use `item.getData`. + """ + self._item = item + self._objectPositions = numpy.array( + positions, copy=False, dtype=numpy.float) + + # Store matrices to generate positions on demand + primitive = item._getScenePrimitive() + self._objectToSceneTransform = primitive.objectToSceneTransform + self._objectToNDCTransform = primitive.objectToNDCTransform + self._scenePositions = None + self._ndcPositions = None + + if indices is None: + self._indices = None + else: + self._indices = numpy.array(indices, copy=False, dtype=numpy.int) + + self._fetchdata = fetchdata + + def getItem(self): + """Returns the item this results corresponds to. + + :rtype: ~silx.gui.plot3d.items.Item3D + """ + return self._item + + def getIndices(self, copy=True): + """Returns indices of picked data. + + If data is 1D, it returns a numpy.ndarray, otherwise + it returns a tuple with as many numpy.ndarray as there are + dimensions in the data. + + :param bool copy: True (default) to get a copy, + False to return internal arrays + :rtype: Union[None,numpy.ndarray,List[numpy.ndarray]] + """ + if self._indices is None: + return None + indices = numpy.array(self._indices, copy=copy) + return indices if indices.ndim == 1 else tuple(indices) + + def getData(self, copy=True): + """Returns picked data values + + :param bool copy: True (default) to get a copy, + False to return internal arrays + :rtype: Union[None,numpy.ndarray] + """ + + indices = self.getIndices(copy=False) + if indices is None or len(indices) == 0: + return None + + item = self.getItem() + if self._fetchdata is None: + if hasattr(item, 'getData'): + data = item.getData(copy=False) + else: + return None + else: + data = self._fetchdata(copy=False) + + return numpy.array(data[indices], copy=copy) + + def getPositions(self, frame='scene', copy=True): + """Returns picking positions in item coordinates. + + :param str frame: The frame in which the positions are returned + Either 'scene' for world space, + 'ndc' for normalized device coordinates or 'object' for item frame. + :param bool copy: True (default) to get a copy, + False to return internal arrays + :return: Nx3 array of (x, y, z) coordinates + :rtype: numpy.ndarray + """ + if frame == 'ndc': + if self._ndcPositions is None: # Lazy-loading + self._ndcPositions = self._objectToNDCTransform.transformPoints( + self._objectPositions, perspectiveDivide=True) + + positions = self._ndcPositions + + elif frame == 'scene': + if self._scenePositions is None: # Lazy-loading + self._scenePositions = self._objectToSceneTransform.transformPoints( + self._objectPositions) + + positions = self._scenePositions + + elif frame == 'object': + positions = self._objectPositions + + else: + raise ValueError('Unsupported frame argument: %s' % str(frame)) + + return numpy.array(positions, copy=copy) diff --git a/silx/gui/plot3d/items/clipplane.py b/silx/gui/plot3d/items/clipplane.py index a5ba0e6..3e819d0 100644 --- a/silx/gui/plot3d/items/clipplane.py +++ b/silx/gui/plot3d/items/clipplane.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,8 +32,11 @@ __license__ = "MIT" __date__ = "15/11/2017" -from ..scene import primitives +import numpy +from ..scene import primitives, utils + +from ._pick import PickingResult from .core import Item3D from .mixins import PlaneMixIn @@ -48,3 +51,86 @@ class ClipPlane(Item3D, PlaneMixIn): plane = primitives.ClipPlane() Item3D.__init__(self, parent=parent, primitive=plane) PlaneMixIn.__init__(self, plane=plane) + + def __pickPreProcessing(self, context): + """Common processing for :meth:`_pickPostProcess` and :meth:`_pickFull` + + :param PickContext context: Current picking context + :return None or (bounds, intersection points, rayObject) + """ + plane = self._getPlane() + planeParent = plane.parent + if planeParent is None: + return None + + rayObject = context.getPickingSegment(frame=plane) + if rayObject is None: + return None + + bounds = planeParent.bounds(dataBounds=True) + rayClip = utils.clipSegmentToBounds(rayObject[:, :3], bounds) + if rayClip is None: + return None # Ray is outside parent's bounding box + + points = utils.segmentPlaneIntersect( + rayObject[0, :3], + rayObject[1, :3], + planeNorm=self.getNormal(), + planePt=self.getPoint()) + + # A single intersection inside bounding box + picked = (len(points) == 1 and + numpy.all(bounds[0] <= points[0]) and + numpy.all(points[0] <= bounds[1])) + + return picked, points, rayObject + + def _pick(self, context): + # Perform picking before modifying context + result = super(ClipPlane, self)._pick(context) + + # Modify context if needed + if self.isVisible() and context.isEnabled(): + info = self.__pickPreProcessing(context) + if info is not None: + picked, points, rayObject = info + plane = self._getPlane() + + if picked: # A single intersection inside bounding box + # Clip NDC z range for following brother items + ndcIntersect = plane.objectToNDCTransform.transformPoint( + points[0], perspectiveDivide=True) + ndcNormal = plane.objectToNDCTransform.transformNormal( + self.getNormal()) + if ndcNormal[2] < 0: + context.setNDCZRange(-1., ndcIntersect[2]) + else: + context.setNDCZRange(ndcIntersect[2], 1.) + + else: + # TODO check this might not be correct + rayObject[:, 3] = 1. # Make sure 4h coordinate is one + if numpy.sum(rayObject[0] * self.getParameters()) < 0.: + # Disable picking for remaining brothers + context.setEnabled(False) + + return result + + def _pickFastCheck(self, context): + return True + + def _pickFull(self, context): + """Perform picking in this item at given widget position. + + :param PickContext context: Current picking context + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + info = self.__pickPreProcessing(context) + if info is not None: + picked, points, _ = info + + if picked: + return PickingResult(self, positions=[points[0]]) + + return None diff --git a/silx/gui/plot3d/items/core.py b/silx/gui/plot3d/items/core.py index e549e59..0aefced 100644 --- a/silx/gui/plot3d/items/core.py +++ b/silx/gui/plot3d/items/core.py @@ -41,6 +41,7 @@ from ... import qt from ...plot.items import ItemChangedType from .. import scene from ..scene import axes, primitives, transform +from ._pick import PickContext @enum.unique @@ -219,6 +220,53 @@ class Item3D(qt.QObject): self._setForegroundColor( widget.getForegroundColor().getRgbF()) + # picking + + def _pick(self, context): + """Implement picking on this item. + + :param PickContext context: Current picking context + :return: Data indices at picked position or None + :rtype: Union[None,PickingResult] + """ + if (self.isVisible() and + context.isEnabled() and + context.isItemPickable(self) and + self._pickFastCheck(context)): + return self._pickFull(context) + return None + + def _pickFastCheck(self, context): + """Approximate item pick test (e.g., bounding box-based picking). + + :param PickContext context: Current picking context + :return: True if item might be picked + :rtype: bool + """ + primitive = self._getScenePrimitive() + + positionNdc = context.getNDCPosition() + if positionNdc is None: # No picking outside viewport + return False + + bounds = primitive.bounds(transformed=False, dataBounds=False) + if bounds is None: # primitive has no bounds + return False + + bounds = primitive.objectToNDCTransform.transformBounds(bounds) + + return (bounds[0, 0] <= positionNdc[0] <= bounds[1, 0] and + bounds[0, 1] <= positionNdc[1] <= bounds[1, 1]) + + def _pickFull(self, context): + """Perform precise picking in this item at given widget position. + + :param PickContext context: Current picking context + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + return None + class DataItem3D(Item3D): """Base class representing a data item with transform in the scene. @@ -256,12 +304,14 @@ class DataItem3D(Item3D): self._rotationCenter = 0., 0., 0. - self._getScenePrimitive().transforms = [ + self.__transforms = transform.TransformList([ self._translate, self._rotateForwardTranslation, self._rotate, self._rotateBackwardTranslation, - self._transformObjectToRotate] + self._transformObjectToRotate]) + + self._getScenePrimitive().transforms = self.__transforms def _updated(self, event=None): """Handle MixIn class updates. @@ -274,6 +324,13 @@ class DataItem3D(Item3D): # Transformations + def _getSceneTransforms(self): + """Return TransformList corresponding to current transforms + + :rtype: TransformList + """ + return self.__transforms + def setScale(self, sx=1., sy=1., sz=1.): """Set the scale of the item in the scene. @@ -452,7 +509,92 @@ class DataItem3D(Item3D): self._updated(Item3DChangedType.BOUNDING_BOX_VISIBLE) -class _BaseGroupItem(DataItem3D): +class BaseNodeItem(DataItem3D): + """Base class for data item having children (e.g., group, 3d volume).""" + + def __init__(self, parent=None, group=None): + """Base class representing a group of items in the scene. + + :param parent: The View widget this item belongs to. + :param Union[GroupBBox, None] group: + The scene group to use for rendering + """ + DataItem3D.__init__(self, parent=parent, group=group) + + def getItems(self): + """Returns the list of items currently present in the group. + + :rtype: tuple + """ + raise NotImplementedError('getItems must be implemented in subclass') + + def visit(self, included=True): + """Generator visiting the group content. + + It traverses the group sub-tree in a top-down left-to-right way. + + :param bool included: True (default) to include self in visit + """ + if included: + yield self + for child in self.getItems(): + yield child + if hasattr(child, 'visit'): + for item in child.visit(included=False): + yield item + + def pickItems(self, x, y, condition=None): + """Iterator over picked items in the group at given position. + + Each picked item yield a :class:`PickingResult` object + holding the picking information. + + It traverses the group sub-tree in a left-to-right top-down way. + + :param int x: X widget device pixel coordinate + :param int y: Y widget device pixel coordinate + :param callable condition: Optional test called for each item + checking whether to process it or not. + """ + viewport = self._getScenePrimitive().viewport + if viewport is None: + raise RuntimeError( + 'Cannot perform picking: Item not attached to a widget') + + context = PickContext(x, y, viewport, condition) + for result in self._pickItems(context): + yield result + + def _pickItems(self, context): + """Implement :meth:`pickItems` + + :param PickContext context: Current picking context + """ + if not self.isVisible() or not context.isEnabled(): + return # empty iterator + + # Use a copy to discard context changes once this returns + context = context.copy() + + if not self._pickFastCheck(context): + return # empty iterator + + result = self._pick(context) + if result is not None: + yield result + + for child in self.getItems(): + if isinstance(child, BaseNodeItem): + for result in child._pickItems(context): + yield result # Flatten result + + else: + result = child._pick(context) + if result is not None: + yield result + + +class _BaseGroupItem(BaseNodeItem): """Base class for group of items sharing a common transform.""" sigItemAdded = qt.Signal(object) @@ -474,9 +616,16 @@ class _BaseGroupItem(DataItem3D): :param Union[GroupBBox, None] group: The scene group to use for rendering """ - DataItem3D.__init__(self, parent=parent, group=group) + BaseNodeItem.__init__(self, parent=parent, group=group) self._items = [] + def _getGroupPrimitive(self): + """Returns the group for which to handle children. + + This allows this group to be different from the primitive. + """ + return self._getScenePrimitive() + def addItem(self, item, index=None): """Add an item to the group @@ -493,11 +642,11 @@ class _BaseGroupItem(DataItem3D): item.setParent(self) if index is None: - self._getScenePrimitive().children.append( + self._getGroupPrimitive().children.append( item._getScenePrimitive()) self._items.append(item) else: - self._getScenePrimitive().children.insert( + self._getGroupPrimitive().children.insert( index, item._getScenePrimitive()) self._items.insert(index, item) self.sigItemAdded.emit(item) @@ -518,7 +667,7 @@ class _BaseGroupItem(DataItem3D): if item not in self.getItems(): raise ValueError("Item3D not in group: %s" % str(item)) - self._getScenePrimitive().children.remove(item._getScenePrimitive()) + self._getGroupPrimitive().children.remove(item._getScenePrimitive()) self._items.remove(item) item.setParent(None) self.sigItemRemoved.emit(item) @@ -528,21 +677,6 @@ class _BaseGroupItem(DataItem3D): for item in self.getItems(): self.removeItem(item) - def visit(self, included=True): - """Generator visiting the group content. - - It traverses the group sub-tree in a top-down left-to-right way. - - :param bool included: True (default) to include self in visit - """ - if included: - yield self - for child in self.getItems(): - yield child - if hasattr(child, 'visit'): - for item in child.visit(included=False): - yield item - class GroupItem(_BaseGroupItem): """Group of items sharing a common transform.""" @@ -620,3 +754,26 @@ class GroupWithAxesItem(_BaseGroupItem): return self._Labels((labelledAxes.xlabel, labelledAxes.ylabel, labelledAxes.zlabel)) + + +class RootGroupWithAxesItem(GroupWithAxesItem): + """Special group with axes item for root of the scene. + + Uses 2 groups so that axes take transforms into account. + """ + + def __init__(self, parent=None): + super(RootGroupWithAxesItem, self).__init__(parent) + self.__group = scene.Group() + self.__group.transforms = self._getSceneTransforms() + + groupWithAxes = self._getScenePrimitive() + groupWithAxes.transforms = [] # Do not apply transforms here + groupWithAxes.children.append(self.__group) + + def _getGroupPrimitive(self): + """Returns the group for which to handle children. + + This allows this group to be different from the primitive. + """ + return self.__group diff --git a/silx/gui/plot3d/items/image.py b/silx/gui/plot3d/items/image.py index 9e8bf1e..210f2f3 100644 --- a/silx/gui/plot3d/items/image.py +++ b/silx/gui/plot3d/items/image.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,22 +33,72 @@ __date__ = "15/11/2017" import numpy -from ..scene import primitives +from ..scene import primitives, utils from .core import DataItem3D, ItemChangedType from .mixins import ColormapMixIn, InterpolationMixIn +from ._pick import PickingResult -class ImageData(DataItem3D, ColormapMixIn, InterpolationMixIn): - """Description of a 2D image data. +class _Image(DataItem3D, InterpolationMixIn): + """Base class for images :param parent: The View widget this item belongs to. """ def __init__(self, parent=None): DataItem3D.__init__(self, parent=parent) - ColormapMixIn.__init__(self) InterpolationMixIn.__init__(self) + def _setPrimitive(self, primitive): + InterpolationMixIn._setPrimitive(self, primitive) + + def getData(self, copy=True): + raise NotImplementedError() + + def _pickFull(self, context): + """Perform picking in this item at given widget position. + + :param PickContext context: Current picking context + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: + return None + + points = utils.segmentPlaneIntersect( + rayObject[0, :3], + rayObject[1, :3], + planeNorm=numpy.array((0., 0., 1.), dtype=numpy.float64), + planePt=numpy.array((0., 0., 0.), dtype=numpy.float64)) + + if len(points) == 1: # Single intersection + if points[0][0] < 0. or points[0][1] < 0.: + return None # Outside image + row, column = int(points[0][1]), int(points[0][0]) + data = self.getData(copy=False) + height, width = data.shape[:2] + if row < height and column < width: + return PickingResult( + self, + positions=[(points[0][0], points[0][1], 0.)], + indices=([row], [column])) + else: + return None # Outside image + else: # Either no intersection or segment and image are coplanar + return None + + +class ImageData(_Image, ColormapMixIn): + """Description of a 2D image data. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + _Image.__init__(self, parent=parent) + ColormapMixIn.__init__(self) + self._data = numpy.zeros((0, 0), dtype=numpy.float32) self._image = primitives.ImageData(self._data) @@ -56,7 +106,7 @@ class ImageData(DataItem3D, ColormapMixIn, InterpolationMixIn): # Connect scene primitive to mix-in class ColormapMixIn._setSceneColormap(self, self._image.colormap) - InterpolationMixIn._setPrimitive(self, self._image) + _Image._setPrimitive(self, self._image) def setData(self, data, copy=True): """Set the image data to display. @@ -83,14 +133,14 @@ class ImageData(DataItem3D, ColormapMixIn, InterpolationMixIn): return self._image.getData(copy=copy) -class ImageRgba(DataItem3D, InterpolationMixIn): +class ImageRgba(_Image, InterpolationMixIn): """Description of a 2D data RGB(A) image. :param parent: The View widget this item belongs to. """ def __init__(self, parent=None): - DataItem3D.__init__(self, parent=parent) + _Image.__init__(self, parent=parent) InterpolationMixIn.__init__(self) self._data = numpy.zeros((0, 0, 3), dtype=numpy.float32) @@ -99,7 +149,7 @@ class ImageRgba(DataItem3D, InterpolationMixIn): self._getScenePrimitive().children.append(self._image) # Connect scene primitive to mix-in class - InterpolationMixIn._setPrimitive(self, self._image) + _Image._setPrimitive(self, self._image) def setData(self, data, copy=True): """Set the RGB(A) image data to display. diff --git a/silx/gui/plot3d/items/mesh.py b/silx/gui/plot3d/items/mesh.py index 12a3941..21936ea 100644 --- a/silx/gui/plot3d/items/mesh.py +++ b/silx/gui/plot3d/items/mesh.py @@ -29,13 +29,19 @@ from __future__ import absolute_import __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "15/11/2017" +__date__ = "17/07/2018" + +import logging import numpy -from ..scene import primitives -from .core import DataItem3D, ItemChangedType +from ..scene import primitives, utils from ..scene.transform import Rotate +from .core import DataItem3D, ItemChangedType +from ._pick import PickingResult + + +_logger = logging.getLogger(__name__) class Mesh(DataItem3D): @@ -56,11 +62,7 @@ class Mesh(DataItem3D): copy=True): """Set mesh geometry data. - Supported drawing modes are: - - - For points: 'points' - - For lines: 'lines', 'line_strip', 'loop' - - For triangles: 'triangles', 'triangle_strip', 'fan' + Supported drawing modes are: 'triangles', 'triangle_strip', 'fan' :param numpy.ndarray position: Position (x, y, z) of each vertex as a (N, 3) array @@ -73,7 +75,7 @@ class Mesh(DataItem3D): self._getScenePrimitive().children = [] # Remove any previous mesh if position is None or len(position) == 0: - self._mesh = 0 + self._mesh = None else: self._mesh = primitives.Mesh3D( position, color, normal, mode=mode, copy=copy) @@ -145,6 +147,72 @@ class Mesh(DataItem3D): """ return self._mesh.drawMode + def _pickFull(self, context): + """Perform precise picking in this item at given widget position. + + :param PickContext context: Current picking context + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: # No picking outside viewport + return None + rayObject = rayObject[:, :3] + + positions = self.getPositionData(copy=False) + if positions.size == 0: + return None + + mode = self.getDrawMode() + if mode == 'triangles': + triangles = positions.reshape(-1, 3, 3) + + elif mode == 'triangle_strip': + # Expand strip + triangles = numpy.empty((len(positions) - 2, 3, 3), + dtype=positions.dtype) + triangles[:, 0] = positions[:-2] + triangles[:, 1] = positions[1:-1] + triangles[:, 2] = positions[2:] + + elif mode == 'fan': + # Expand fan + triangles = numpy.empty((len(positions) - 2, 3, 3), + dtype=positions.dtype) + triangles[:, 0] = positions[0] + triangles[:, 1] = positions[1:-1] + triangles[:, 2] = positions[2:] + + else: + _logger.warning("Unsupported draw mode: %s" % mode) + return None + + trianglesIndices, t, barycentric = utils.segmentTrianglesIntersection( + rayObject, triangles) + + if len(trianglesIndices) == 0: + return None + + points = t.reshape(-1, 1) * (rayObject[1] - rayObject[0]) + rayObject[0] + + # Get vertex index from triangle index and closest point in triangle + closest = numpy.argmax(barycentric, axis=1) + + if mode == 'triangles': + indices = trianglesIndices * 3 + closest + + elif mode == 'triangle_strip': + indices = trianglesIndices + closest + + elif mode == 'fan': + indices = trianglesIndices + closest # For corners 1 and 2 + indices[closest == 0] = 0 # For first corner (common) + + return PickingResult(self, + positions=points, + indices=indices, + fetchdata=self.getPositionData) + class _CylindricalVolume(DataItem3D): """Class that represents a volume with a rotational symmetry along z @@ -155,6 +223,18 @@ class _CylindricalVolume(DataItem3D): def __init__(self, parent=None): DataItem3D.__init__(self, parent=parent) self._mesh = None + self._nbFaces = 0 + + def getPosition(self, copy=True): + """Get primitive positions. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + :return: Position of the primitives as a (N, 3) array. + :rtype: numpy.ndarray + """ + raise NotImplementedError("Must be implemented in subclass") def _setData(self, position, radius, height, angles, color, flatFaces, rotation): @@ -173,30 +253,31 @@ class _CylindricalVolume(DataItem3D): self._getScenePrimitive().children = [] # Remove any previous mesh if position is None or len(position) == 0: - self._mesh = 0 + self._mesh = None + self._nbFaces = 0 else: + self._nbFaces = len(angles) - 1 + volume = numpy.empty(shape=(len(angles) - 1, 12, 3), dtype=numpy.float32) normal = numpy.empty(shape=(len(angles) - 1, 12, 3), dtype=numpy.float32) for i in range(0, len(angles) - 1): - """ - c6 - /\ - / \ - / \ - c4|------|c5 - | \ | - | \ | - | \ | - | \ | - c2|------|c3 - \ / - \ / - \/ - c1 - """ + # c6 + # /\ + # / \ + # / \ + # c4|------|c5 + # | \ | + # | \ | + # | \ | + # | \ | + # c2|------|c3 + # \ / + # \ / + # \/ + # c1 c1 = numpy.array([0, 0, -height/2]) c1 = rotation.transformPoint(c1) c2 = numpy.array([radius * numpy.cos(angles[i]), @@ -266,6 +347,49 @@ class _CylindricalVolume(DataItem3D): self.sigItemChanged.emit(ItemChangedType.DATA) + def _pickFull(self, context): + """Perform precise picking in this item at given widget position. + + :param PickContext context: Current picking context + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + if self._mesh is None or self._nbFaces == 0: + return None + + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: # No picking outside viewport + return None + rayObject = rayObject[:, :3] + + positions = self._mesh.getAttribute('position', copy=False) + triangles = positions.reshape(-1, 3, 3) # 'triangle' draw mode + + trianglesIndices, t = utils.segmentTrianglesIntersection( + rayObject, triangles)[:2] + + if len(trianglesIndices) == 0: + return None + + # Get object index from triangle index + indices = trianglesIndices // (4 * self._nbFaces) + + # Select closest intersection point for each primitive + indices, firstIndices = numpy.unique(indices, return_index=True) + t = t[firstIndices] + + # Resort along t as result of numpy.unique is not sorted by t + sortedIndices = numpy.argsort(t) + t = t[sortedIndices] + indices = indices[sortedIndices] + + points = t.reshape(-1, 1) * (rayObject[1] - rayObject[0]) + rayObject[0] + + return PickingResult(self, + positions=points, + indices=indices, + fetchdata=self.getPosition) + class Box(_CylindricalVolume): """Description of a box. diff --git a/silx/gui/plot3d/items/scatter.py b/silx/gui/plot3d/items/scatter.py index 5eea455..a13c3db 100644 --- a/silx/gui/plot3d/items/scatter.py +++ b/silx/gui/plot3d/items/scatter.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 @@ -40,6 +40,7 @@ from ..scene import function, primitives, utils from .core import DataItem3D, Item3DChangedType, ItemChangedType from .mixins import ColormapMixIn, SymbolMixIn +from ._pick import PickingResult _logger = logging.getLevelName(__name__) @@ -116,7 +117,7 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn): :return: X coordinates :rtype: numpy.ndarray """ - return self._scatter.getAttribute('x', copy=copy) + return self._scatter.getAttribute('x', copy=copy).reshape(-1) def getYData(self, copy=True): """Returns Y data coordinates. @@ -126,7 +127,7 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn): :return: Y coordinates :rtype: numpy.ndarray """ - return self._scatter.getAttribute('y', copy=copy) + return self._scatter.getAttribute('y', copy=copy).reshape(-1) def getZData(self, copy=True): """Returns Z data coordinates. @@ -136,7 +137,7 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn): :return: Z coordinates :rtype: numpy.ndarray """ - return self._scatter.getAttribute('z', copy=copy) + return self._scatter.getAttribute('z', copy=copy).reshape(-1) def getValues(self, copy=True): """Returns data values. @@ -146,7 +147,64 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn): :return: data values :rtype: numpy.ndarray """ - return self._scatter.getAttribute('value', copy=copy) + return self._scatter.getAttribute('value', copy=copy).reshape(-1) + + def _pickFull(self, context, threshold=0., sort='depth'): + """Perform picking in this item at given widget position. + + :param PickContext context: Current picking context + :param float threshold: Picking threshold in pixel. + Perform picking in a square of size threshold x threshold. + :param str sort: How returned indices are sorted: + + - 'index' (default): sort by the value of the indices + - 'depth': Sort by the depth of the points from the current + camera point of view. + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + assert sort in ('index', 'depth') + + rayNdc = context.getPickingSegment(frame='ndc') + if rayNdc is None: # No picking outside viewport + return None + + # Project data to NDC + xData = self.getXData(copy=False) + if len(xData) == 0: # No data in the scatter + return None + + primitive = self._getScenePrimitive() + + dataPoints = numpy.transpose((xData, + self.getYData(copy=False), + self.getZData(copy=False), + numpy.ones_like(xData))) + + pointsNdc = primitive.objectToNDCTransform.transformPoints( + dataPoints, perspectiveDivide=True) + + # Perform picking + distancesNdc = numpy.abs(pointsNdc[:, :2] - rayNdc[0, :2]) + # TODO issue with symbol size: using pixel instead of points + threshold += self.getSymbolSize() + thresholdNdc = 2. * threshold / numpy.array(primitive.viewport.size) + picked = numpy.where(numpy.logical_and( + numpy.all(distancesNdc < thresholdNdc, axis=1), + numpy.logical_and(rayNdc[0, 2] <= pointsNdc[:, 2], + pointsNdc[:, 2] <= rayNdc[1, 2])))[0] + + if sort == 'depth': + # Sort picked points from front to back + picked = picked[numpy.argsort(pointsNdc[picked, 2])] + + if picked.size > 0: + return PickingResult(self, + positions=dataPoints[picked, :3], + indices=picked, + fetchdata=self.getValues) + else: + return None class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn): @@ -373,6 +431,120 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn): """ return numpy.array(self._value, copy=copy) + def _pickPoints(self, context, points, threshold=1., sort='depth'): + """Perform picking while in 'points' visualization mode + + :param PickContext context: Current picking context + :param float threshold: Picking threshold in pixel. + Perform picking in a square of size threshold x threshold. + :param str sort: How returned indices are sorted: + + - 'index' (default): sort by the value of the indices + - 'depth': Sort by the depth of the points from the current + camera point of view. + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + assert sort in ('index', 'depth') + + rayNdc = context.getPickingSegment(frame='ndc') + if rayNdc is None: # No picking outside viewport + return None + + # Project data to NDC + primitive = self._getScenePrimitive() + pointsNdc = primitive.objectToNDCTransform.transformPoints( + points, perspectiveDivide=True) + + # Perform picking + distancesNdc = numpy.abs(pointsNdc[:, :2] - rayNdc[0, :2]) + thresholdNdc = threshold / numpy.array(primitive.viewport.size) + picked = numpy.where(numpy.logical_and( + numpy.all(distancesNdc < thresholdNdc, axis=1), + numpy.logical_and(rayNdc[0, 2] <= pointsNdc[:, 2], + pointsNdc[:, 2] <= rayNdc[1, 2])))[0] + + if sort == 'depth': + # Sort picked points from front to back + picked = picked[numpy.argsort(pointsNdc[picked, 2])] + + if picked.size > 0: + return PickingResult(self, + positions=points[picked, :3], + indices=picked, + fetchdata=self.getValues) + else: + return None + + def _pickSolid(self, context, points): + """Perform picking while in 'solid' visualization mode + + :param PickContext context: Current picking context + """ + if self._cachedTrianglesIndices is None: + _logger.info("Picking on Scatter2D before rendering") + return None + + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: # No picking outside viewport + return None + rayObject = rayObject[:, :3] + + trianglesIndices = self._cachedTrianglesIndices.reshape(-1, 3) + triangles = points[trianglesIndices, :3] + selectedIndices, t, barycentric = utils.segmentTrianglesIntersection( + rayObject, triangles) + closest = numpy.argmax(barycentric, axis=1) + + indices = trianglesIndices.reshape(-1, 3)[selectedIndices, closest] + + if len(indices) == 0: # No point is picked + return None + + # Compute intersection points and get closest data point + positions = t.reshape(-1, 1) * (rayObject[1] - rayObject[0]) + rayObject[0] + + return PickingResult(self, + positions=positions, + indices=indices, + fetchdata=self.getValues) + + def _pickFull(self, context): + """Perform picking in this item at given widget position. + + :param PickContext context: Current picking context + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + xData = self.getXData(copy=False) + if len(xData) == 0: # No data in the scatter + return None + + if self.isHeightMap(): + zData = self.getValues(copy=False) + else: + zData = numpy.zeros_like(xData) + + points = numpy.transpose((xData, + self.getYData(copy=False), + zData, + numpy.ones_like(xData))) + + mode = self.getVisualization() + if mode == 'points': + # TODO issue with symbol size: using pixel instead of points + # Get "corrected" symbol size + _, threshold = self._getSceneSymbol() + return self._pickPoints( + context, points, threshold=max(3., threshold)) + + elif mode == 'lines': + # Picking only at point + return self._pickPoints(context, points, threshold=5.) + + else: # mode == 'solid' + return self._pickSolid(context, points) + def _updateScene(self): self._getScenePrimitive().children = [] # Remove previous primitives diff --git a/silx/gui/plot3d/items/volume.py b/silx/gui/plot3d/items/volume.py index a7b5923..ca22f1f 100644 --- a/silx/gui/plot3d/items/volume.py +++ b/silx/gui/plot3d/items/volume.py @@ -41,10 +41,11 @@ from silx.math.marchingcubes import MarchingCubes from ... import qt from ...colors import rgba -from ..scene import cutplane, primitives, transform +from ..scene import cutplane, primitives, transform, utils -from .core import DataItem3D, Item3D, ItemChangedType, Item3DChangedType +from .core import BaseNodeItem, Item3D, ItemChangedType, Item3DChangedType from .mixins import ColormapMixIn, InterpolationMixIn, PlaneMixIn +from ._pick import PickingResult _logger = logging.getLogger(__name__) @@ -77,7 +78,8 @@ class CutPlane(Item3D, ColormapMixIn, InterpolationMixIn, PlaneMixIn): def _parentChanged(self, event): """Handle data change in the parent this plane belongs to""" if event == ItemChangedType.DATA: - self._getPlane().setData(self.sender().getData(), copy=False) + self._getPlane().setData(self.sender().getData(copy=False), + copy=False) # Store data range info as 3-tuple of values self._dataRange = self.sender().getDataRange() @@ -113,6 +115,53 @@ class CutPlane(Item3D, ColormapMixIn, InterpolationMixIn, PlaneMixIn): """ return self._dataRange + def getData(self, copy=True): + """Return 3D dataset. + + :param bool copy: + True (default) to get a copy, + False to get the internal data (DO NOT modify!) + :return: The data set (or None if not set) + """ + parent = self.parent() + return None if parent is None else parent.getData(copy=copy) + + def _pickFull(self, context): + """Perform picking in this item at given widget position. + + :param PickContext context: Current picking context + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: + return None + + points = utils.segmentPlaneIntersect( + rayObject[0, :3], + rayObject[1, :3], + planeNorm=self.getNormal(), + planePt=self.getPoint()) + + if len(points) == 1: # Single intersection + if numpy.any(points[0] < 0.): + return None # Outside volume + z, y, x = int(points[0][2]), int(points[0][1]), int(points[0][0]) + + data = self.getData(copy=False) + if data is None: + return None # No dataset + + depth, height, width = data.shape + if z < depth and y < height and x < width: + return PickingResult(self, + positions=[points[0]], + indices=([z], [y], [x])) + else: + return None # Outside image + else: # Either no intersection or segment and image are coplanar + return None + class Isosurface(Item3D): """Class representing an iso-surface in a :class:`ScalarField3D` item. @@ -122,24 +171,28 @@ class Isosurface(Item3D): def __init__(self, parent): Item3D.__init__(self, parent=parent) + assert isinstance(parent, ScalarField3D) + parent.sigItemChanged.connect(self._scalarField3DChanged) self._level = float('nan') self._autoLevelFunction = None self._color = rgba('#FFD700FF') - self._data = None + self._updateScenePrimitive() - # TODO register to ScalarField3D signal instead? - def _setData(self, data, copy=True): - """Set the data set from which to build the iso-surface. + def _scalarField3DChanged(self, event): + """Handle parent's ScalarField3D sigItemChanged""" + if event == ItemChangedType.DATA: + self._updateScenePrimitive() - :param numpy.ndarray data: The 3D data set or None - :param bool copy: True to make a copy, False to use as is if possible - """ - if data is None: - self._data = None - else: - self._data = numpy.array(data, copy=copy, order='C') + def getData(self, copy=True): + """Return 3D dataset. - self._updateScenePrimitive() + :param bool copy: + True (default) to get a copy, + False to get the internal data (DO NOT modify!) + :return: The data set (or None if not set) + """ + parent = self.parent() + return None if parent is None else parent.getData(copy=copy) def getLevel(self): """Return the level of this iso-surface (float)""" @@ -203,7 +256,9 @@ class Isosurface(Item3D): """Update underlying mesh""" self._getScenePrimitive().children = [] - if self._data is None: + data = self.getData(copy=False) + + if data is None: if self.isAutoLevel(): self._level = float('nan') @@ -211,7 +266,7 @@ class Isosurface(Item3D): if self.isAutoLevel(): st = time.time() try: - level = float(self.getAutoLevelFunction()(self._data)) + level = float(self.getAutoLevelFunction()(data)) except Exception: module_ = self.getAutoLevelFunction().__module__ @@ -236,7 +291,7 @@ class Isosurface(Item3D): st = time.time() vertices, normals, indices = MarchingCubes( - self._data, + data, isolevel=self._level) _logger.info('Computed iso-surface in %f s.', time.time() - st) @@ -250,15 +305,73 @@ class Isosurface(Item3D): indices=indices) self._getScenePrimitive().children = [mesh] + def _pickFull(self, context): + """Perform picking in this item at given widget position. + + :param PickContext context: Current picking context + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + rayObject = context.getPickingSegment(frame=self._getScenePrimitive()) + if rayObject is None: + return None + rayObject = rayObject[:, :3] + + data = self.getData(copy=False) + bins = utils.segmentVolumeIntersect( + rayObject, numpy.array(data.shape) - 1) + if bins is None: + return None -class ScalarField3D(DataItem3D): + # gather bin data + offsets = [(i, j, k) for i in (0, 1) for j in (0, 1) for k in (0, 1)] + indices = bins[:, numpy.newaxis, :] + offsets + binsData = data[indices[:, :, 0], indices[:, :, 1], indices[:, :, 2]] + # binsData.shape = nbins, 8 + # TODO up-to this point everything can be done once for all isosurfaces + + # check bin candidates + level = self.getLevel() + mask = numpy.logical_and(numpy.nanmin(binsData, axis=1) <= level, + level <= numpy.nanmax(binsData, axis=1)) + bins = bins[mask] + binsData = binsData[mask] + + if len(bins) == 0: + return None # No bin candidate + + # do picking on candidates + intersections = [] + depths = [] + for currentBin, data in zip(bins, binsData): + mc = MarchingCubes(data.reshape(2, 2, 2), isolevel=level) + points = mc.get_vertices() + currentBin + triangles = points[mc.get_indices()] + t = utils.segmentTrianglesIntersection(rayObject, triangles)[1] + t = numpy.unique(t) # Duplicates happen on triangle edges + if len(t) != 0: + # Compute intersection points and get closest data point + points = t.reshape(-1, 1) * (rayObject[1] - rayObject[0]) + rayObject[0] + # Get closest data points by rounding to int + intersections.extend(points) + depths.extend(t) + + if len(intersections) == 0: + return None # No intersected triangles + + intersections = numpy.array(intersections)[numpy.argsort(depths)] + indices = numpy.transpose(numpy.round(intersections).astype(numpy.int)) + return PickingResult(self, positions=intersections, indices=indices) + + +class ScalarField3D(BaseNodeItem): """3D scalar field on a regular grid. :param parent: The View widget this item belongs to. """ def __init__(self, parent=None): - DataItem3D.__init__(self, parent=parent) + BaseNodeItem.__init__(self, parent=parent) # Gives this item the shape of the data, no matter # of the isosurface/cut plane size @@ -327,10 +440,6 @@ class ScalarField3D(DataItem3D): self._boundedGroup.shape = self._data.shape - # Update iso-surfaces - for isosurface in self.getIsosurfaces(): - isosurface._setData(self._data, copy=False) - self._updated(ItemChangedType.DATA) def getData(self, copy=True): @@ -401,7 +510,6 @@ class ScalarField3D(DataItem3D): isosurface.setAutoLevelFunction(level) else: isosurface.setLevel(level) - isosurface._setData(self._data, copy=False) isosurface.sigItemChanged.connect(self._isosurfaceItemChanged) self._isosurfaces.append(isosurface) @@ -448,16 +556,11 @@ class ScalarField3D(DataItem3D): key=lambda isosurface: - isosurface.getLevel()) self._isogroup.children = [iso._getScenePrimitive() for iso in sortedIso] - def visit(self, included=True): - """Generator visiting the ScalarField3D content. + # BaseNodeItem - It first access cut planes and then isosurface + def getItems(self): + """Returns the list of items currently present in the ScalarField3D. - :param bool included: True (default) to include self in visit + :rtype: tuple """ - if included: - yield self - for cutPlane in self.getCutPlanes(): - yield cutPlane - for isosurface in self.getIsosurfaces(): - yield isosurface + return self.getCutPlanes() + self.getIsosurfaces() diff --git a/silx/gui/plot3d/scene/event.py b/silx/gui/plot3d/scene/event.py index 7b85434..98f8f8b 100644 --- a/silx/gui/plot3d/scene/event.py +++ b/silx/gui/plot3d/scene/event.py @@ -28,7 +28,7 @@ from __future__ import absolute_import, division, unicode_literals __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "25/07/2016" +__date__ = "17/07/2018" import logging @@ -66,7 +66,7 @@ class Notifier(object): try: self._listeners.remove(listener) except ValueError: - _logger.warn('Trying to remove a listener that is not registered') + _logger.warning('Trying to remove a listener that is not registered') def notify(self, *args, **kwargs): """Notify all registered listeners with the given parameters. diff --git a/silx/gui/plot3d/scene/function.py b/silx/gui/plot3d/scene/function.py index ba4c4ca..2921d48 100644 --- a/silx/gui/plot3d/scene/function.py +++ b/silx/gui/plot3d/scene/function.py @@ -28,7 +28,7 @@ from __future__ import absolute_import, division, unicode_literals __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "08/11/2016" +__date__ = "17/07/2018" import contextlib @@ -428,7 +428,7 @@ class Colormap(event.Notifier, ProgramFunction): range_ = float(range_[0]), float(range_[1]) if self.norm == 'log' and (range_[0] <= 0. or range_[1] <= 0.): - _logger.warn( + _logger.warning( "Log normalization and negative range: updating range.") minPos = numpy.finfo(numpy.float32).tiny range_ = max(range_[0], minPos), max(range_[1], minPos) diff --git a/silx/gui/plot3d/scene/primitives.py b/silx/gui/plot3d/scene/primitives.py index af00b6d..474581a 100644 --- a/silx/gui/plot3d/scene/primitives.py +++ b/silx/gui/plot3d/scene/primitives.py @@ -201,7 +201,7 @@ class Geometry(core.Elem): array = self._glReadyArray(array, copy=copy) if name not in self._ATTR_INFO: - _logger.info('Not checking attribute %s dimensions', name) + _logger.debug('Not checking attribute %s dimensions', name) else: checks = self._ATTR_INFO[name] diff --git a/silx/gui/plot3d/scene/setup.py b/silx/gui/plot3d/scene/setup.py deleted file mode 100644 index ff4c0a6..0000000 --- a/silx/gui/plot3d/scene/setup.py +++ /dev/null @@ -1,41 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2015-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. -# -# ###########################################################################*/ -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "25/07/2016" - -from numpy.distutils.misc_util import Configuration - - -def configuration(parent_package='', top_path=None): - config = Configuration('scene', parent_package, top_path) - config.add_subpackage('test') - return config - - -if __name__ == "__main__": - from numpy.distutils.core import setup - - setup(configuration=configuration) diff --git a/silx/gui/plot3d/scene/transform.py b/silx/gui/plot3d/scene/transform.py index 4061e81..1b82397 100644 --- a/silx/gui/plot3d/scene/transform.py +++ b/silx/gui/plot3d/scene/transform.py @@ -305,6 +305,44 @@ class Transform(event.Notifier): # Multiplication with vectors + def transformPoints(self, points, direct=True, perspectiveDivide=False): + """Apply the transform to an array of points. + + :param points: 2D array of N vectors of 3 or 4 coordinates + :param bool direct: Whether to apply the direct (True, the default) + or inverse (False) transform. + :param bool perspectiveDivide: Whether to apply the perspective divide + (True) or not (False, the default). + :return: The transformed points. + :rtype: numpy.ndarray of same shape as points. + """ + if direct: + matrix = self.getMatrix(copy=False) + else: + matrix = self.getInverseMatrix(copy=False) + + points = numpy.array(points, copy=False) + assert points.ndim == 2 + + points = numpy.transpose(points) + + dimension = points.shape[0] + assert dimension in (3, 4) + + if dimension == 3: # Add 4th coordinate + points = numpy.append( + points, + numpy.ones((1, points.shape[1]), dtype=points.dtype), + axis=0) + + result = numpy.transpose(numpy.dot(matrix, points)) + + if perspectiveDivide: + mask = result[:, 3] != 0. + result[mask] /= result[mask, 3][:, numpy.newaxis] + + return result[:, :3] if dimension == 3 else result + @staticmethod def _prepareVector(vector, w): """Add 4th coordinate (w) to vector if missing.""" @@ -317,8 +355,6 @@ class Transform(event.Notifier): def transformPoint(self, point, direct=True, perspectiveDivide=False): """Apply the transform to a point. - If len(point) == 3, apply perspective divide if possible. - :param point: Array-like vector of 3 or 4 coordinates. :param bool direct: Whether to apply the direct (True, the default) or inverse (False) transform. @@ -373,7 +409,7 @@ class Transform(event.Notifier): _CUBE_CORNERS = numpy.array(list(itertools.product((0., 1.), repeat=3)), dtype=numpy.float32) - """Unit cube corners used by :meth:`transformRectangularBox`""" + """Unit cube corners used by :meth:`transformBounds`""" def transformBounds(self, bounds, direct=True): """Apply the transform to an axes-aligned rectangular box. diff --git a/silx/gui/plot3d/scene/utils.py b/silx/gui/plot3d/scene/utils.py index 3752289..1224f5e 100644 --- a/silx/gui/plot3d/scene/utils.py +++ b/silx/gui/plot3d/scene/utils.py @@ -435,6 +435,186 @@ def boxPlaneIntersect(boxVertices, boxLineIndices, planeNorm, planePt): return points +def clipSegmentToBounds(segment, bounds): + """Clip segment to volume aligned with axes. + + :param numpy.ndarray segment: (p0, p1) + :param numpy.ndarray bounds: (lower corner, upper corner) + :return: Either clipped (p0, p1) or None if outside volume + :rtype: Union[None,List[numpy.ndarray]] + """ + segment = numpy.array(segment, copy=False) + bounds = numpy.array(bounds, copy=False) + + p0, p1 = segment + # Get intersection points of ray with volume boundary planes + # Line equation: P = offset * delta + p0 + delta = p1 - p0 + deltaNotZero = numpy.array(delta, copy=True) + deltaNotZero[deltaNotZero == 0] = numpy.nan # Invalidated to avoid division by zero + offsets = ((bounds - p0) / deltaNotZero).reshape(-1) + points = offsets.reshape(-1, 1) * delta + p0 + + # Avoid precision errors by using bounds value + points.shape = 2, 3, 3 # Reshape 1 point per bound value + for dim in range(3): + points[:, dim, dim] = bounds[:, dim] + points.shape = -1, 3 # Set back to 2D array + + # Find intersection points that are included in the volume + mask = numpy.logical_and(numpy.all(bounds[0] <= points, axis=1), + numpy.all(points <= bounds[1], axis=1)) + intersections = numpy.unique(offsets[mask]) + if len(intersections) != 2: + return None + + intersections.sort() + # Do p1 first as p0 is need to compute it + if intersections[1] < 1: # clip p1 + segment[1] = intersections[1] * delta + p0 + if intersections[0] > 0: # clip p0 + segment[0] = intersections[0] * delta + p0 + return segment + + +def segmentVolumeIntersect(segment, nbins): + """Get bin indices intersecting with segment + + It should work with N dimensions. + Coordinate convention (z, y, x) or (x, y, z) should not matter + as long as segment and nbins are consistent. + + :param numpy.ndarray segment: + Segment end points as a 2xN array of coordinates + :param numpy.ndarray nbins: + Shape of the volume with same coordinates order as segment + :return: List of bins indices as a 2D array or None if no bins + :rtype: Union[None,numpy.ndarray] + """ + segment = numpy.asarray(segment) + nbins = numpy.asarray(nbins) + + assert segment.ndim == 2 + assert segment.shape[0] == 2 + assert nbins.ndim == 1 + assert segment.shape[1] == nbins.size + + dim = len(nbins) + + bounds = numpy.array((numpy.zeros_like(nbins), nbins)) + segment = clipSegmentToBounds(segment, bounds) + if segment is None: + return None # Segment outside volume + p0, p1 = segment + + # Get intersections + + # Get coordinates of bin edges crossing the segment + clipped = numpy.ceil(numpy.clip(segment, 0, nbins)) + start = numpy.min(clipped, axis=0) + stop = numpy.max(clipped, axis=0) # stop is NOT included + edgesByDim = [numpy.arange(start[i], stop[i]) for i in range(dim)] + + # Line equation: P = t * delta + p0 + delta = p1 - p0 + + # Get bin edge/line intersections as sorted points along the line + # Get corresponding line parameters + t = [] + if numpy.all(0 <= p0) and numpy.all(p0 <= nbins): + t.append([0.]) # p0 within volume, add it + t += [(edgesByDim[i] - p0[i]) / delta[i] for i in range(dim) if delta[i] != 0] + if numpy.all(0 <= p1) and numpy.all(p1 <= nbins): + t.append([1.]) # p1 within volume, add it + t = numpy.concatenate(t) + t.sort(kind='mergesort') + + # Remove duplicates + unique = numpy.ones((len(t),), dtype=bool) + numpy.not_equal(t[1:], t[:-1], out=unique[1:]) + t = t[unique] + + if len(t) < 2: + return None # Not enough intersection points + + # bin edges/line intersection points + points = t.reshape(-1, 1) * delta + p0 + centers = (points[:-1] + points[1:]) / 2. + bins = numpy.floor(centers).astype(numpy.int) + return bins + + +def segmentTrianglesIntersection(segment, triangles): + """Check for segment/triangles intersection. + + This is based on signed tetrahedron volume comparison. + + See A. Kensler, A., Shirley, P. + Optimizing Ray-Triangle Intersection via Automated Search. + Symposium on Interactive Ray Tracing, vol. 0, p33-38 (2006) + + :param numpy.ndarray segment: + Segment end points as a 2x3 array of coordinates + :param numpy.ndarray triangles: + Nx3x3 array of triangles + :return: (triangle indices, segment parameter, barycentric coord) + Indices of intersected triangles, "depth" along the segment + of the intersection point and barycentric coordinates of intersection + point in the triangle. + :rtype: List[numpy.ndarray] + """ + # TODO triangles from vertices + indices + # TODO early rejection? e.g., check segment bbox vs triangle bbox + segment = numpy.asarray(segment) + assert segment.ndim == 2 + assert segment.shape == (2, 3) + + triangles = numpy.asarray(triangles) + assert triangles.ndim == 3 + assert triangles.shape[1] == 3 + + # Test line/triangles intersection + d = segment[1] - segment[0] + t0s0 = segment[0] - triangles[:, 0, :] + edge01 = triangles[:, 1, :] - triangles[:, 0, :] + edge02 = triangles[:, 2, :] - triangles[:, 0, :] + + dCrossEdge02 = numpy.cross(d, edge02) + t0s0CrossEdge01 = numpy.cross(t0s0, edge01) + volume = numpy.sum(dCrossEdge02 * edge01, axis=1) + del edge01 + subVolumes = numpy.empty((len(triangles), 3), dtype=triangles.dtype) + subVolumes[:, 1] = numpy.sum(dCrossEdge02 * t0s0, axis=1) + del dCrossEdge02 + subVolumes[:, 2] = numpy.sum(t0s0CrossEdge01 * d, axis=1) + subVolumes[:, 0] = volume - subVolumes[:, 1] - subVolumes[:, 2] + intersect = numpy.logical_or( + numpy.all(subVolumes >= 0., axis=1), # All positive + numpy.all(subVolumes <= 0., axis=1)) # All negative + intersect = numpy.where(intersect)[0] # Indices of intersected triangles + + # Get barycentric coordinates + barycentric = subVolumes[intersect] / volume[intersect].reshape(-1, 1) + del subVolumes + + # Test segment/triangles intersection + volAlpha = numpy.sum(t0s0CrossEdge01[intersect] * edge02[intersect], axis=1) + t = volAlpha / volume[intersect] # segment parameter of intersected triangles + del t0s0CrossEdge01 + del edge02 + del volAlpha + del volume + + inSegmentMask = numpy.logical_and(t >= 0., t <= 1.) + intersect = intersect[inSegmentMask] + t = t[inSegmentMask] + barycentric = barycentric[inSegmentMask] + + # Sort intersecting triangles by t + indices = numpy.argsort(t) + return intersect[indices], t[indices], barycentric[indices] + + # Plane ####################################################################### class Plane(event.Notifier): diff --git a/silx/gui/plot3d/setup.py b/silx/gui/plot3d/setup.py index c477919..59c0230 100644 --- a/silx/gui/plot3d/setup.py +++ b/silx/gui/plot3d/setup.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# Copyright (c) 2015-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 @@ -36,7 +36,9 @@ def configuration(parent_package='', top_path=None): config.add_subpackage('actions') config.add_subpackage('items') config.add_subpackage('scene') + config.add_subpackage('scene.test') config.add_subpackage('tools') + config.add_subpackage('tools.test') config.add_subpackage('test') config.add_subpackage('utils') return config diff --git a/silx/gui/plot3d/test/__init__.py b/silx/gui/plot3d/test/__init__.py index bd2f7c3..c58f307 100644 --- a/silx/gui/plot3d/test/__init__.py +++ b/silx/gui/plot3d/test/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# Copyright (c) 2015-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 @@ -30,7 +30,6 @@ __date__ = "09/11/2017" import logging -import os import unittest from silx.test.utils import test_options @@ -39,7 +38,7 @@ _logger = logging.getLogger(__name__) def suite(): - test_suite = unittest.TestSuite() + testsuite = unittest.TestSuite() if not test_options.WITH_GL_TEST: # Explicitly disabled tests @@ -50,17 +49,21 @@ def suite(): def runTest(self): self.skipTest(test_options.WITH_GL_TEST_REASON) - test_suite.addTest(SkipPlot3DTest()) - return test_suite + testsuite.addTest(SkipPlot3DTest()) + return testsuite # Import here to avoid loading modules if tests are disabled - from ..scene import test as test_scene + from ..scene.test import suite as sceneTestSuite + from ..tools.test import suite as toolsTestSuite from .testGL import suite as testGLSuite from .testScalarFieldView import suite as testScalarFieldViewSuite + from .testSceneWidgetPicking import suite as testSceneWidgetPickingSuite - test_suite = unittest.TestSuite() - test_suite.addTest(testGLSuite()) - test_suite.addTest(test_scene.suite()) - test_suite.addTest(testScalarFieldViewSuite()) - return test_suite + testsuite = unittest.TestSuite() + testsuite.addTest(testGLSuite()) + testsuite.addTest(sceneTestSuite()) + testsuite.addTest(testScalarFieldViewSuite()) + testsuite.addTest(testSceneWidgetPickingSuite()) + testsuite.addTest(toolsTestSuite()) + return testsuite diff --git a/silx/gui/plot3d/test/testGL.py b/silx/gui/plot3d/test/testGL.py index 70f197f..ae167ab 100644 --- a/silx/gui/plot3d/test/testGL.py +++ b/silx/gui/plot3d/test/testGL.py @@ -32,7 +32,7 @@ import logging import unittest from silx.gui._glutils import gl, OpenGLWidget -from silx.gui.test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from silx.gui import qt diff --git a/silx/gui/plot3d/test/testScalarFieldView.py b/silx/gui/plot3d/test/testScalarFieldView.py index 43d401f..d9c743b 100644 --- a/silx/gui/plot3d/test/testScalarFieldView.py +++ b/silx/gui/plot3d/test/testScalarFieldView.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 @@ -34,7 +34,7 @@ import unittest import numpy from silx.utils.testutils import ParametricTestCase -from silx.gui.test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from silx.gui import qt from silx.gui.plot3d.ScalarFieldView import ScalarFieldView @@ -52,6 +52,13 @@ class TestScalarFieldView(TestCaseQt, ParametricTestCase): self.widget = ScalarFieldView() self.widget.show() + paramTreeWidget = TreeView() + paramTreeWidget.setSfView(self.widget) + + dock = qt.QDockWidget() + dock.setWidget(paramTreeWidget) + self.widget.addDockWidget(qt.Qt.BottomDockWidgetArea, dock) + # Commented as it slows down the tests # self.qWaitForWindowExposed(self.widget) @@ -102,6 +109,24 @@ class TestScalarFieldView(TestCaseQt, ParametricTestCase): self.widget.setData(data, copy=True) self.qapp.processEvents() + def testIsoSliderNormalization(self): + """Test set TreeView with a different isoslider normalization""" + data = self._buildData(size=32) + + self.widget.setData(data) + self.widget.addIsosurface(0.5, (1., 0., 0., 0.5)) + self.widget.addIsosurface(0.7, qt.QColor('green')) + self.qapp.processEvents() + + # Add a second TreeView + paramTreeWidget = TreeView(self.widget) + paramTreeWidget.setIsoLevelSliderNormalization('arcsinh') + paramTreeWidget.setSfView(self.widget) + + dock = qt.QDockWidget() + dock.setWidget(paramTreeWidget) + self.widget.addDockWidget(qt.Qt.BottomDockWidgetArea, dock) + def suite(): test_suite = unittest.TestSuite() diff --git a/silx/gui/plot3d/test/testSceneWidgetPicking.py b/silx/gui/plot3d/test/testSceneWidgetPicking.py new file mode 100644 index 0000000..d0c6467 --- /dev/null +++ b/silx/gui/plot3d/test/testSceneWidgetPicking.py @@ -0,0 +1,267 @@ +# 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. +# ###########################################################################*/ +"""Test SceneWidget picking feature""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "03/10/2018" + + +import unittest + +import numpy + +from silx.utils.testutils import ParametricTestCase +from silx.gui.utils.testutils import TestCaseQt +from silx.gui import qt + +from silx.gui.plot3d.SceneWidget import SceneWidget, items + + +class TestSceneWidgetPicking(TestCaseQt, ParametricTestCase): + """Tests SceneWidget picking feature""" + + def setUp(self): + super(TestSceneWidgetPicking, self).setUp() + self.widget = SceneWidget() + self.widget.resize(300, 300) + self.widget.show() + # self.qWaitForWindowExposed(self.widget) + + def tearDown(self): + self.qapp.processEvents() + self.widget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.widget.close() + del self.widget + super(TestSceneWidgetPicking, self).tearDown() + + def _widgetCenter(self): + """Returns widget center""" + size = self.widget.size() + return size.width() // 2, size.height() // 2 + + def testPickImage(self): + """Test picking of ImageData and ImageRgba items""" + imageData = items.ImageData() + imageData.setData(numpy.arange(100).reshape(10, 10)) + + imageRgba = items.ImageRgba() + imageRgba.setData( + numpy.arange(300, dtype=numpy.uint8).reshape(10, 10, 3)) + + for item in (imageData, imageRgba): + with self.subTest(item=item.__class__.__name__): + # Add item + self.widget.clearItems() + self.widget.addItem(item) + self.widget.resetZoom('front') + self.qapp.processEvents() + + # Picking on data (at widget center) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), item) + self.assertEqual(picking[0].getPositions('ndc').shape, (1, 3)) + data = picking[0].getData() + self.assertEqual(len(data), 1) + self.assertTrue(numpy.array_equal( + data, + item.getData()[picking[0].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) + + def testPickScatter(self): + """Test picking of Scatter2D and Scatter3D items""" + data = numpy.arange(100) + + scatter2d = items.Scatter2D() + scatter2d.setData(x=data, y=data, value=data) + + scatter3d = items.Scatter3D() + scatter3d.setData(x=data, y=data, z=data, value=data) + + for item in (scatter2d, scatter3d): + with self.subTest(item=item.__class__.__name__): + # Add item + self.widget.clearItems() + self.widget.addItem(item) + self.widget.resetZoom('front') + self.qapp.processEvents() + + # Picking on data (at widget center) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), item) + nbPos = len(picking[0].getPositions('ndc')) + data = picking[0].getData() + self.assertEqual(nbPos, len(data)) + self.assertTrue(numpy.array_equal( + data, + item.getValues()[picking[0].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) + + def testPickScalarField3D(self): + """Test picking of volume CutPlane and Isosurface items""" + volume = self.widget.add3DScalarField( + numpy.arange(10**3, dtype=numpy.float32).reshape(10, 10, 10)) + self.widget.resetZoom('front') + + cutplane = volume.getCutPlanes()[0] + cutplane.getColormap().setVRange(0, 100) + cutplane.setNormal((0, 0, 1)) + + # Picking on data without anything displayed + cutplane.setVisible(False) + picking = list(self.widget.pickItems(*self._widgetCenter())) + self.assertEqual(len(picking), 0) + + # Picking on data with the cut plane + cutplane.setVisible(True) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), cutplane) + data = picking[0].getData() + self.assertEqual(len(data), 1) + self.assertEqual(picking[0].getPositions().shape, (1, 3)) + self.assertTrue(numpy.array_equal( + data, + volume.getData(copy=False)[picking[0].getIndices()])) + + # Picking on data with an isosurface + isosurface = volume.addIsosurface(level=500, color=(1., 0., 0., .5)) + picking = list(self.widget.pickItems(*self._widgetCenter())) + self.assertEqual(len(picking), 2) + self.assertIs(picking[0].getItem(), cutplane) + self.assertIs(picking[1].getItem(), isosurface) + self.assertEqual(picking[1].getPositions().shape, (1, 3)) + data = picking[1].getData() + self.assertEqual(len(data), 1) + self.assertTrue(numpy.array_equal( + data, + volume.getData(copy=False)[picking[1].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) + + def testPickMesh(self): + """Test picking of Mesh items""" + + triangles = items.Mesh() + triangles.setData( + position=((0, 0, 0), (1, 0, 0), (1, 1, 0), + (0, 0, 0), (1, 1, 0), (0, 1, 0)), + color=(1, 0, 0, 1), + mode='triangles') + triangleStrip = items.Mesh() + triangleStrip.setData( + position=(((1, 0, 0), (0, 0, 0), (1, 1, 0), (0, 1, 0))), + color=(0, 1, 0, 1), + mode='triangle_strip') + triangleFan = items.Mesh() + triangleFan.setData( + position=((0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)), + color=(0, 0, 1, 1), + mode='fan') + + for item in (triangles, triangleStrip, triangleFan): + with self.subTest(mode=item.getDrawMode()): + # Add item + self.widget.clearItems() + self.widget.addItem(item) + self.widget.resetZoom('front') + self.qapp.processEvents() + + # Picking on data (at widget center) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), item) + nbPos = len(picking[0].getPositions()) + data = picking[0].getData() + self.assertEqual(nbPos, len(data)) + self.assertTrue(numpy.array_equal( + data, + item.getPositionData()[picking[0].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) + + def testPickCylindricalMesh(self): + """Test picking of Box, Cylinder and Hexagon items""" + + positions = numpy.array(((0., 0., 0.), (1., 1., 0.), (2., 2., 0.))) + box = items.Box() + box.setData(position=positions) + cylinder = items.Cylinder() + cylinder.setData(position=positions) + hexagon = items.Hexagon() + hexagon.setData(position=positions) + + for item in (box, cylinder, hexagon): + with self.subTest(item=item.__class__.__name__): + # Add item + self.widget.clearItems() + self.widget.addItem(item) + self.widget.resetZoom('front') + self.qapp.processEvents() + + # Picking on data (at widget center) + picking = list(self.widget.pickItems(*self._widgetCenter())) + + self.assertEqual(len(picking), 1) + self.assertIs(picking[0].getItem(), item) + nbPos = len(picking[0].getPositions()) + data = picking[0].getData() + print(item.__class__.__name__, [positions[1]], data) + self.assertTrue(numpy.all(numpy.equal(positions[1], data))) + self.assertEqual(nbPos, len(data)) + self.assertTrue(numpy.array_equal( + data, + item.getPosition()[picking[0].getIndices()])) + + # Picking outside data + picking = list(self.widget.pickItems(1, 1)) + self.assertEqual(len(picking), 0) + + +def suite(): + testsuite = unittest.TestSuite() + testsuite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase( + TestSceneWidgetPicking)) + return testsuite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/plot3d/tools/PositionInfoWidget.py b/silx/gui/plot3d/tools/PositionInfoWidget.py new file mode 100644 index 0000000..b4d2c05 --- /dev/null +++ b/silx/gui/plot3d/tools/PositionInfoWidget.py @@ -0,0 +1,209 @@ +# 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 that displays data values of a SceneWidget. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "01/10/2018" + + +import logging +import weakref + +from ... import qt +from .. import items +from ..items import volume +from ..SceneWidget import SceneWidget + + +_logger = logging.getLogger(__name__) + + +class PositionInfoWidget(qt.QWidget): + """Widget displaying information about picked position + + :param QWidget parent: See :class:`QWidget` + """ + + def __init__(self, parent=None): + super(PositionInfoWidget, self).__init__(parent) + self._sceneWidgetRef = None + + self.setToolTip("Double-click on a data point to show its value") + layout = qt.QBoxLayout(qt.QBoxLayout.LeftToRight, self) + + self._xLabel = self._addInfoField('X') + self._yLabel = self._addInfoField('Y') + self._zLabel = self._addInfoField('Z') + self._dataLabel = self._addInfoField('Data') + self._itemLabel = self._addInfoField('Item') + + layout.addStretch(1) + + def _addInfoField(self, label): + """Add a description: info widget to this widget + + :param str label: Description label + :return: The QLabel used to display the info + :rtype: QLabel + """ + subLayout = qt.QHBoxLayout() + subLayout.setContentsMargins(0, 0, 0, 0) + + subLayout.addWidget(qt.QLabel(label + ':')) + + widget = qt.QLabel('-') + widget.setAlignment(qt.Qt.AlignLeft | qt.Qt.AlignVCenter) + widget.setTextInteractionFlags(qt.Qt.TextSelectableByMouse) + widget.setMinimumWidth(widget.fontMetrics().width('#######')) + subLayout.addWidget(widget) + + subLayout.addStretch(1) + + layout = self.layout() + layout.addLayout(subLayout) + return widget + + def getSceneWidget(self): + """Returns the associated :class:`SceneWidget` or None. + + :rtype: Union[None,~silx.gui.plot3d.SceneWidget.SceneWidget] + """ + if self._sceneWidgetRef is None: + return None + else: + return self._sceneWidgetRef() + + def setSceneWidget(self, widget): + """Set the associated :class:`SceneWidget` + + :param ~silx.gui.plot3d.SceneWidget.SceneWidget widget: + 3D scene for which to display information + """ + if widget is not None and not isinstance(widget, SceneWidget): + raise ValueError("widget must be a SceneWidget or None") + + previous = self.getSceneWidget() + if previous is not None: + previous.removeEventFilter(self) + + if widget is None: + self._sceneWidgetRef = None + else: + widget.installEventFilter(self) + self._sceneWidgetRef = weakref.ref(widget) + + def eventFilter(self, watched, event): + # Filter events of SceneWidget to react on mouse events. + if (event.type() == qt.QEvent.MouseButtonDblClick and + event.button() == qt.Qt.LeftButton): + self.pick(event.x(), event.y()) + + return super(PositionInfoWidget, self).eventFilter(watched, event) + + def clear(self): + """Clean-up displayed values""" + for widget in (self._xLabel, self._yLabel, self._zLabel, + self._dataLabel, self._itemLabel): + widget.setText('-') + + _SUPPORTED_ITEMS = (items.Scatter3D, + items.Scatter2D, + items.ImageData, + items.ImageRgba, + items.Mesh, + items.Box, + items.Cylinder, + items.Hexagon, + volume.CutPlane, + volume.Isosurface) + """Type of items that are picked""" + + def _isSupportedItem(self, item): + """Returns True if item is of supported type + + :param Item3D item: The Item3D to check + :rtype: bool + """ + return isinstance(item, self._SUPPORTED_ITEMS) + + def pick(self, x, y): + """Pick items in the associated SceneWidget and display result + + Only the closest point is displayed. + + :param int x: X coordinate in pixel in the SceneWidget + :param int y: Y coordinate in pixel in the SceneWidget + """ + self.clear() + + sceneWidget = self.getSceneWidget() + if sceneWidget is None: # No associated widget + _logger.info('Picking without associated SceneWidget') + return + + # Find closest (and latest in the tree) supported item + closestNdcZ = float('inf') + picking = None + for result in sceneWidget.pickItems(x, y, + condition=self._isSupportedItem): + ndcZ = result.getPositions('ndc', copy=False)[0, 2] + if ndcZ <= closestNdcZ: + closestNdcZ = ndcZ + picking = result + + if picking is None: + return # No picked item + + item = picking.getItem() + self._itemLabel.setText(item.getLabel()) + positions = picking.getPositions('scene', copy=False) + x, y, z = positions[0] + self._xLabel.setText("%g" % x) + self._yLabel.setText("%g" % y) + self._zLabel.setText("%g" % z) + + data = picking.getData(copy=False) + if data is not None: + data = data[0] + if hasattr(data, '__len__'): + text = ' '.join(["%.3g"] * len(data)) % tuple(data) + else: + text = "%g" % data + self._dataLabel.setText(text) + + def updateInfo(self): + """Update information according to cursor position""" + widget = self.getSceneWidget() + if widget is None: + _logger.info('Update without associated SceneWidget') + self.clear() + return + + position = widget.mapFromGlobal(qt.QCursor.pos()) + self.pick(position.x(), position.y()) diff --git a/silx/gui/plot3d/tools/test/__init__.py b/silx/gui/plot3d/tools/test/__init__.py new file mode 100644 index 0000000..2dbc0ab --- /dev/null +++ b/silx/gui/plot3d/tools/test/__init__.py @@ -0,0 +1,41 @@ +# 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. +# +# ###########################################################################*/ +"""plot3d tools test suite.""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "03/10/2018" + + +import unittest +from .testPositionInfoWidget import suite as testPositionInfoWidgetSuite + + +def suite(): + testsuite = unittest.TestSuite() + testsuite.addTest(testPositionInfoWidgetSuite()) + return testsuite diff --git a/silx/gui/plot3d/tools/test/testPositionInfoWidget.py b/silx/gui/plot3d/tools/test/testPositionInfoWidget.py new file mode 100644 index 0000000..4520a2a --- /dev/null +++ b/silx/gui/plot3d/tools/test/testPositionInfoWidget.py @@ -0,0 +1,101 @@ +# 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. +# ###########################################################################*/ +"""Test PositionInfoWidget""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "03/10/2018" + + +import unittest + +import numpy + +from silx.gui.utils.testutils import TestCaseQt +from silx.gui import qt + +from silx.gui.plot3d.SceneWidget import SceneWidget +from silx.gui.plot3d.tools.PositionInfoWidget import PositionInfoWidget + + +class TestPositionInfoWidget(TestCaseQt): + """Tests PositionInfoWidget""" + + def setUp(self): + super(TestPositionInfoWidget, self).setUp() + self.sceneWidget = SceneWidget() + self.sceneWidget.resize(300, 300) + self.sceneWidget.show() + + self.positionInfoWidget = PositionInfoWidget() + self.positionInfoWidget.setSceneWidget(self.sceneWidget) + self.positionInfoWidget.show() + self.qWaitForWindowExposed(self.positionInfoWidget) + + # self.qWaitForWindowExposed(self.widget) + + def tearDown(self): + self.qapp.processEvents() + + self.sceneWidget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.sceneWidget.close() + del self.sceneWidget + + self.positionInfoWidget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.positionInfoWidget.close() + del self.positionInfoWidget + super(TestPositionInfoWidget, self).tearDown() + + def test(self): + """Test PositionInfoWidget""" + self.assertIs(self.positionInfoWidget.getSceneWidget(), + self.sceneWidget) + + data = numpy.arange(100) + self.sceneWidget.add2DScatter(x=data, y=data, value=data) + self.sceneWidget.resetZoom('front') + + # Double click at the center + self.mouseDClick(self.sceneWidget, button=qt.Qt.LeftButton) + + # Clear displayed value + self.positionInfoWidget.clear() + + # Update info from API + self.positionInfoWidget.pick(x=10, y=10) + + # Remove SceneWidget + self.positionInfoWidget.setSceneWidget(None) + + +def suite(): + testsuite = unittest.TestSuite() + testsuite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase( + TestPositionInfoWidget)) + return testsuite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/qt/__init__.py b/silx/gui/qt/__init__.py index f7bc916..b8c6cdd 100644 --- a/silx/gui/qt/__init__.py +++ b/silx/gui/qt/__init__.py @@ -25,12 +25,11 @@ """Common wrapper over Python Qt bindings: - `PyQt5 `_ +- `PySide2 `_ - `PyQt4 `_ -- `PySide `_ -- `PySide2 `_ If a Qt binding is already loaded, it will use it, otherwise the different -Qt bindings are tried in this order: PyQt5, PyQt4, PySide, PySide2. +Qt bindings are tried in this order: PyQt5, PyQt4, PySide2. The name of the loaded Qt binding is stored in the BINDING variable. @@ -48,7 +47,7 @@ Example of using :mod:`silx.gui.qt` module: For an alternative solution providing a structured namespace, see `qtpy `_ which -provides the namespace of PyQt5 over PyQt4 and PySide. +provides the namespace of PyQt5 over PyQt4, PySide and PySide2. """ from ._qt import * # noqa diff --git a/silx/gui/qt/_pyside_dynamic.py b/silx/gui/qt/_pyside_dynamic.py index a9246b9..13d1a9d 100644 --- a/silx/gui/qt/_pyside_dynamic.py +++ b/silx/gui/qt/_pyside_dynamic.py @@ -39,11 +39,15 @@ from __future__ import (print_function, division, unicode_literals, absolute_import) import logging +import sys -from PySide.QtCore import QMetaObject -from PySide.QtUiTools import QUiLoader -from PySide.QtGui import QMainWindow - +if "PySide.QtCore" in sys.modules: + from PySide.QtCore import QMetaObject + from PySide.QtUiTools import QUiLoader +else: # PySide2 + from PySide2.QtCore import QMetaObject, Property, Qt + from PySide2.QtWidgets import QFrame + from PySide2.QtUiTools import QUiLoader _logger = logging.getLogger(__name__) @@ -57,7 +61,7 @@ class UiLoader(QUiLoader): create a new instance of the top-level widget, but creates the user interface in an existing instance of the top-level class. - This mimics the behaviour of :func:`PyQt4.uic.loadUi`. + This mimics the behaviour of :func:`PyQt*.uic.loadUi`. """ def __init__(self, baseinstance, customWidgets=None): @@ -113,7 +117,7 @@ class UiLoader(QUiLoader): if self.baseinstance: # set an attribute for the new child widget on the base - # instance, just like PyQt4.uic.loadUi does. + # instance, just like PyQt*.uic.loadUi does. setattr(self.baseinstance, name, widget) # this outputs the various widget names, e.g. @@ -123,6 +127,43 @@ class UiLoader(QUiLoader): return widget +if "PySide2.QtCore" in sys.modules: + + class _Line(QFrame): + """Widget to use as 'Line' Qt designer""" + def __init__(self, parent=None): + super(_Line, self).__init__(parent) + self.setFrameShape(QFrame.HLine) + self.setFrameShadow(QFrame.Sunken) + + def getOrientation(self): + shape = self.frameShape() + if shape == QFrame.HLine: + return Qt.Horizontal + elif shape == QFrame.VLine: + return Qt.Vertical + else: + raise RuntimeError("Wrong shape: %d", shape) + + def setOrientation(self, orientation): + if orientation == Qt.Horizontal: + self.setFrameShape(QFrame.HLine) + elif orientation == Qt.Vertical: + self.setFrameShape(QFrame.VLine) + else: + raise ValueError("Unsupported orientation %s" % str(orientation)) + + orientation = Property("Qt::Orientation", getOrientation, setOrientation) + + + CUSTOM_WIDGETS = {"Line": _Line} + """Default custom widgets for `loadUi`""" + +else: # PySide support + CUSTOM_WIDGETS = {} + """Default custom widgets for `loadUi`""" + + def loadUi(uifile, baseinstance=None, package=None, resource_suffix=None): """ Dynamically load a user interface from the given ``uifile``. @@ -152,7 +193,7 @@ def loadUi(uifile, baseinstance=None, package=None, resource_suffix=None): _logger.warning( "loadUi resource_suffix parameter not implemented with PySide") - loader = UiLoader(baseinstance) + loader = UiLoader(baseinstance, customWidgets=CUSTOM_WIDGETS) widget = loader.load(uifile) QMetaObject.connectSlotsByName(widget) return widget diff --git a/silx/gui/qt/_qt.py b/silx/gui/qt/_qt.py index 6bf7d93..a4b9007 100644 --- a/silx/gui/qt/_qt.py +++ b/silx/gui/qt/_qt.py @@ -22,21 +22,7 @@ # THE SOFTWARE. # # ###########################################################################*/ -"""Common wrapper over Python Qt bindings: - -- `PyQt5 `_, -- `PyQt4 `_ or -- `PySide `_. - -If a Qt binding is already loaded, it will use it, otherwise the different -Qt bindings are tried in this order: PySide2, PyQt4, PySide, PyQt5. - -The name of the loaded Qt binding is stored in the BINDING variable. - -For an alternative solution providing a structured namespace, -see `qtpy `_ which -provides the namespace of PyQt5 over PyQt4, PySide and PySide2. -""" +"""Load Qt binding""" __authors__ = ["V.A. Sole"] __license__ = "MIT" @@ -47,15 +33,17 @@ import logging import sys import traceback +from ...utils.deprecation import deprecated_warning + _logger = logging.getLogger(__name__) BINDING = None -"""The name of the Qt binding in use: PyQt5, 'PyQt4, PySide2 or PySide.""" +"""The name of the Qt binding in use: PyQt5, PyQt4 or PySide2.""" QtBinding = None # noqa -"""The Qt binding module in use: PyQt5, PyQt4, PySide2 or PySide.""" +"""The Qt binding module in use: PyQt5, PyQt4 or PySide2.""" HAS_SVG = False """True if Qt provides support for Scalable Vector Graphics (QtSVG).""" @@ -84,17 +72,17 @@ else: # Then try Qt bindings import PyQt4 # noqa except ImportError: try: - import PySide # noqa + import PySide2 # noqa except ImportError: try: - import PySide2 # noqa + import PySide # noqa except ImportError: raise ImportError( 'No Qt wrapper found. Install PyQt5, PyQt4 or PySide2.') else: - BINDING = 'PySide2' + BINDING = 'PySide' else: - BINDING = 'PySide' + BINDING = 'PySide2' else: BINDING = 'PyQt4' else: @@ -103,6 +91,9 @@ else: # Then try Qt bindings if BINDING == 'PyQt4': _logger.debug('Using PyQt4 bindings') + deprecated_warning("Qt Binding", "PyQt4", + replacement='PyQt5', + since_version='0.9.0') if sys.version_info < (3, ): try: @@ -110,6 +101,11 @@ if BINDING == 'PyQt4': sip.setapi("QString", 2) sip.setapi("QVariant", 2) + sip.setapi('QDate', 2) + sip.setapi('QDateTime', 2) + sip.setapi('QTextStream', 2) + sip.setapi('QTime', 2) + sip.setapi('QUrl', 2) except: _logger.warning("Cannot set sip API") @@ -144,6 +140,9 @@ if BINDING == 'PyQt4': elif BINDING == 'PySide': _logger.debug('Using PySide bindings') + deprecated_warning("Qt Binding", "PySide", + replacement='PySide2', + since_version='0.9.0') import PySide as QtBinding # noqa @@ -238,7 +237,7 @@ elif BINDING == 'PySide2': HAS_SVG = True # Import loadUi wrapper for PySide2 - # TODO from ._pyside_dynamic import loadUi # noqa + from ._pyside_dynamic import loadUi # noqa pyqtSignal = Signal diff --git a/silx/gui/qt/_utils.py b/silx/gui/qt/_utils.py index be55465..912f08c 100644 --- a/silx/gui/qt/_utils.py +++ b/silx/gui/qt/_utils.py @@ -36,8 +36,11 @@ from . import _qt as qt def supportedImageFormats(): """Return a set of string of file format extensions supported by the Qt runtime.""" - if sys.version_info[0] < 3 or qt.BINDING in ('PySide', 'PySide2'): + if sys.version_info[0] < 3 or qt.BINDING == 'PySide': convert = str + elif qt.BINDING == 'PySide2': + def convert(data): + return str(data.data(), 'ascii') else: convert = lambda data: str(data, 'ascii') formats = qt.QImageReader.supportedImageFormats() diff --git a/silx/gui/qt/inspect.py b/silx/gui/qt/inspect.py new file mode 100644 index 0000000..c6c2cbe --- /dev/null +++ b/silx/gui/qt/inspect.py @@ -0,0 +1,82 @@ +# 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 functions to access Qt C++ object state: + +- :func:`isValid` to check whether a QObject C++ pointer is valid. +- :func:`createdByPython` to check if a QObject was created from Python. +- :func:`ownedByPython` to check if a QObject is currently owned by Python. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "08/10/2018" + + +from . import _qt as qt + + +if qt.BINDING in ('PyQt4', 'PyQt5'): + if qt.BINDING == 'PyQt5': + try: + from PyQt5.sip import isdeleted as _isdeleted # noqa + from PyQt5.sip import ispycreated as createdByPython # noqa + from PyQt5.sip import ispyowned as ownedByPython # noqa + except ImportError: + from sip import isdeleted as _isdeleted # noqa + from sip import ispycreated as createdByPython # noqa + from sip import ispyowned as ownedByPython # noqa + + else: # PyQt4 + from sip import isdeleted as _isdeleted # noqa + from sip import ispycreated as createdByPython # noqa + from sip import ispyowned as ownedByPython # noqa + + def isValid(obj): + """Returns True if underlying C++ object is valid. + + :param QObject obj: + :rtype: bool + """ + return not _isdeleted(obj) + +elif qt.BINDING == 'PySide2': + from PySide2.shiboken2 import isValid # noqa + from PySide2.shiboken2 import createdByPython # noqa + from PySide2.shiboken2 import ownedByPython # noqa + +elif qt.BINDING == 'PySide': + try: # Available through PySide + from PySide.shiboken import isValid # noqa + from PySide.shiboken import createdByPython # noqa + from PySide.shiboken import ownedByPython # noqa + except ImportError: # Available through standalone shiboken package + from Shiboken.shiboken import isValid # noqa + from Shiboken.shiboken import createdByPython # noqa + from Shiboken.shiboken import ownedByPython # noqa + +else: + raise ImportError("Unsupported Qt binding %s" % qt.BINDING) + +__all__ = ['isValid', 'createdByPython', 'ownedByPython'] diff --git a/silx/gui/test/test_colors.py b/silx/gui/test/test_colors.py index d7c205e..e980068 100644 --- a/silx/gui/test/test_colors.py +++ b/silx/gui/test/test_colors.py @@ -29,7 +29,7 @@ from __future__ import absolute_import __authors__ = ["H.Payno"] __license__ = "MIT" -__date__ = "24/04/2018" +__date__ = "05/10/2018" import unittest import numpy @@ -184,6 +184,17 @@ class TestDictAPI(unittest.TestCase): with self.assertRaises(ValueError): Colormap._fromDict(clm_dict) + def testNumericalColors(self): + """Make sure the old API using colors=int was supported""" + clm_dict = { + 'name': 'temperature', + 'vmin': 1.0, + 'vmax': 2.0, + 'colors': 256, + 'autoscale': False + } + Colormap._fromDict(clm_dict) + class TestObjectAPI(ParametricTestCase): """Test the new Object API of the colormap""" @@ -357,6 +368,11 @@ class TestObjectAPI(ParametricTestCase): with self.assertRaises(NotEditableError): colormap.restoreState(state) + def testBadColorsType(self): + """Make sure colors can't be something else than an array""" + with self.assertRaises(TypeError): + Colormap(name='temperature', colors=256) + class TestPreferredColormaps(unittest.TestCase): """Test get|setPreferredColormaps functions""" diff --git a/silx/gui/test/test_console.py b/silx/gui/test/test_console.py index 7c25372..7db5f12 100644 --- a/silx/gui/test/test_console.py +++ b/silx/gui/test/test_console.py @@ -33,7 +33,7 @@ __date__ = "05/12/2016" import unittest -from silx.gui.test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from silx.gui import qt try: diff --git a/silx/gui/test/test_icons.py b/silx/gui/test/test_icons.py index d747761..1757f30 100644 --- a/silx/gui/test/test_icons.py +++ b/silx/gui/test/test_icons.py @@ -38,7 +38,7 @@ import os import silx.resources from silx.gui import qt -from silx.gui.test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from silx.gui import icons diff --git a/silx/gui/test/test_qt.py b/silx/gui/test/test_qt.py index 3a89a33..0d10620 100644 --- a/silx/gui/test/test_qt.py +++ b/silx/gui/test/test_qt.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016 European Synchrotron Radiation Facility +# Copyright (c) 2016-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,9 +33,13 @@ import os.path import unittest from silx.test.utils import temp_dir -from silx.gui.test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from silx.gui import qt +try: + from silx.gui.qt import inspect as qt_inspect +except ImportError: + qt_inspect = None class TestQtWrapper(unittest.TestCase): @@ -92,6 +96,32 @@ class TestLoadUi(TestCaseQt): Button 2 + + + + 10 + 90 + 118 + 3 + + + + Qt::Horizontal + + + + + + 150 + 20 + 3 + 61 + + + + Qt::Vertical + + @@ -110,6 +140,7 @@ class TestLoadUi(TestCaseQt): """ + @unittest.skipIf(qt.BINDING == "PySide", "Not fully working with PySide") def testLoadUi(self): """Create a QMainWindow from an ui file""" with temp_dir() as tmp: @@ -132,9 +163,35 @@ class TestLoadUi(TestCaseQt): testMainWindow.close() +class TestQtInspect(unittest.TestCase): + """Test functions of silx.gui.qt.inspect module""" + + # shiboken module is not always available + @unittest.skipIf(qt.BINDING == 'PySide' and qt_inspect is None, + reason="shiboken module not available") + def test(self): + """Test functions of silx.gui.qt.inspect module""" + self.assertIsNotNone(qt_inspect) + + parent = qt.QObject() + + self.assertTrue(qt_inspect.isValid(parent)) + self.assertTrue(qt_inspect.createdByPython(parent)) + self.assertTrue(qt_inspect.ownedByPython(parent)) + + obj = qt.QObject(parent) + + self.assertTrue(qt_inspect.isValid(obj)) + self.assertTrue(qt_inspect.createdByPython(obj)) + self.assertFalse(qt_inspect.ownedByPython(obj)) + + del parent + self.assertFalse(qt_inspect.isValid(obj)) + + def suite(): test_suite = unittest.TestSuite() - for TestCaseCls in (TestQtWrapper, TestLoadUi): + for TestCaseCls in (TestQtWrapper, TestLoadUi, TestQtInspect): test_suite.addTest( unittest.defaultTestLoader.loadTestsFromTestCase(TestCaseCls)) return test_suite diff --git a/silx/gui/test/utils.py b/silx/gui/test/utils.py index 3eee474..db4c0ee 100644 --- a/silx/gui/test/utils.py +++ b/silx/gui/test/utils.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# Copyright (c) 2016-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 @@ -22,499 +22,22 @@ # THE SOFTWARE. # # ###########################################################################*/ -"""Helper class to write Qt widget unittests.""" +"""Color conversion function, color dictionary and colormap tools.""" -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "27/02/2018" - - -import gc -import logging -import unittest -import time -import functools -import sys -import os - -_logger = logging.getLogger(__name__) - -from silx.gui import qt - -if qt.BINDING == 'PySide': - from PySide.QtTest import QTest -elif qt.BINDING == 'PySide2': - from PySide2.QtTest import QTest -elif qt.BINDING == 'PyQt5': - from PyQt5.QtTest import QTest -elif qt.BINDING == 'PyQt4': - from PyQt4.QtTest import QTest -else: - raise ImportError('Unsupported Qt bindings') - -# Qt4/Qt5 compatibility wrapper -if qt.BINDING in ('PySide', 'PyQt4'): - _logger.info("QTest.qWaitForWindowExposed not available," + - "using QTest.qWaitForWindowShown instead.") - - def qWaitForWindowExposed(window, timeout=None): - """Mimic QTest.qWaitForWindowExposed for Qt4.""" - QTest.qWaitForWindowShown(window) - return True -else: - qWaitForWindowExposed = QTest.qWaitForWindowExposed - - -def qWaitForWindowExposedAndActivate(window, timeout=None): - """Waits until the window is shown in the screen. - - It also activates the window and raises it. - - See QTest.qWaitForWindowExposed for details. - """ - if timeout is None: - result = qWaitForWindowExposed(window) - else: - result = qWaitForWindowExposed(window, timeout) - - if result: - # Makes sure window is active and on top - window.activateWindow() - window.raise_() - - return result - - -# Placeholder for QApplication -_qapp = None - - -class TestCaseQt(unittest.TestCase): - """Base class to write test for Qt stuff. - - It creates a QApplication before running the tests. - WARNING: The QApplication is shared by all tests, which might have side - effects. - - After each test, this class is checking for widgets remaining alive. - To allow some widgets to remain alive at the end of a test, set the - allowedLeakingWidgets attribute to the number of widgets that can remain - alive at the end of the test. - With PySide, this test is not run for now as it seems PySide - is leaking widgets internally. - - All keyboard and mouse event simulation methods call qWait(20) after - simulating the event (as QTest does on Mac OSX). - This was introduced to fix issues with continuous integration tests - running with Xvfb on Linux. - """ - - DEFAULT_TIMEOUT_WAIT = 100 - """Default timeout for qWait""" - - TIMEOUT_WAIT = 0 - """Extra timeout in millisecond to add to qSleep, qWait and - qWaitForWindowExposed. - - Intended purpose is for debugging, to add extra time to waits in order to - allow to view the tested widgets. - """ - - @classmethod - def exceptionHandler(cls, exceptionClass, exception, stack): - import traceback - message = (''.join(traceback.format_tb(stack))) - template = 'Traceback (most recent call last):\n{2}{0}: {1}' - message = template.format(exceptionClass.__name__, exception, message) - cls._exceptions.append(message) - - @classmethod - def setUpClass(cls): - """Makes sure Qt is inited""" - cls._oldExceptionHook = sys.excepthook - sys.excepthook = cls.exceptionHandler - - global _qapp - if _qapp is None: - # Makes sure a QApplication exists and do it once for all - _qapp = qt.QApplication.instance() or qt.QApplication([]) - - # Makes sure QDesktopWidget is init - # Otherwise it happens randomly during the tests - cls._desktopWidget = _qapp.desktop() - _qapp.processEvents() - - @classmethod - def tearDownClass(cls): - sys.excepthook = cls._oldExceptionHook - - def setUp(self): - """Get the list of existing widgets.""" - self.allowedLeakingWidgets = 0 - self.__previousWidgets = self.qapp.allWidgets() - self.__class__._exceptions = [] - - def _currentTestSucceeded(self): - if hasattr(self, '_outcome'): - # For Python >= 3.4 - result = self.defaultTestResult() # these 2 methods have no side effects - self._feedErrorsToResult(result, self._outcome.errors) - else: - # For Python < 3.4 - result = getattr(self, '_outcomeForDoCleanups', self._resultForDoCleanups) - - skipped = self.id() in [case.id() for case, _ in result.skipped] - error = self.id() in [case.id() for case, _ in result.errors] - failure = self.id() in [case.id() for case, _ in result.failures] - return not error and not failure and not skipped - - def _checkForUnreleasedWidgets(self): - """Test fixture checking that no more widgets exists.""" - gc.collect() - - widgets = [widget for widget in self.qapp.allWidgets() - if widget not in self.__previousWidgets] - del self.__previousWidgets - - if qt.BINDING in ('PySide', 'PySide2'): - return # Do not test for leaking widgets with PySide - - allowedLeakingWidgets = self.allowedLeakingWidgets - self.allowedLeakingWidgets = 0 - - if widgets and len(widgets) <= allowedLeakingWidgets: - _logger.info( - '%s: %d remaining widgets after test' % (self.id(), - len(widgets))) - - if len(widgets) > allowedLeakingWidgets: - raise RuntimeError( - "Test ended with widgets alive: %s" % str(widgets)) - - def tearDown(self): - if len(self.__class__._exceptions) > 0: - messages = "\n".join(self.__class__._exceptions) - raise AssertionError("Exception occured in Qt thread:\n" + messages) - - if self._currentTestSucceeded(): - self._checkForUnreleasedWidgets() - - @property - def qapp(self): - """The QApplication currently running.""" - return qt.QApplication.instance() - - # Proxy to QTest - - Press = QTest.Press - """Key press action code""" - - Release = QTest.Release - """Key release action code""" - - Click = QTest.Click - """Key click action code""" - - QTest = property(lambda self: QTest, - doc="""The Qt QTest class from the used Qt binding.""") - - def keyClick(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1): - """Simulate clicking a key. - - See QTest.keyClick for details. - """ - QTest.keyClick(widget, key, modifier, delay) - self.qWait(20) - - def keyClicks(self, widget, sequence, modifier=qt.Qt.NoModifier, delay=-1): - """Simulate clicking a sequence of keys. - - See QTest.keyClick for details. - """ - QTest.keyClicks(widget, sequence, modifier, delay) - self.qWait(20) - - def keyEvent(self, action, widget, key, - modifier=qt.Qt.NoModifier, delay=-1): - """Sends a Qt key event. +from __future__ import absolute_import - See QTest.keyEvent for details. - """ - QTest.keyEvent(action, widget, key, modifier, delay) - self.qWait(20) - - def keyPress(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1): - """Sends a Qt key press event. - - See QTest.keyPress for details. - """ - QTest.keyPress(widget, key, modifier, delay) - self.qWait(20) - - def keyRelease(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1): - """Sends a Qt key release event. - - See QTest.keyRelease for details. - """ - QTest.keyRelease(widget, key, modifier, delay) - self.qWait(20) - - def mouseClick(self, widget, button, modifier=None, pos=None, delay=-1): - """Simulate clicking a mouse button. - - See QTest.mouseClick for details. - """ - if modifier is None: - modifier = qt.Qt.KeyboardModifiers() - pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint() - QTest.mouseClick(widget, button, modifier, pos, delay) - self.qWait(20) - - def mouseDClick(self, widget, button, modifier=None, pos=None, delay=-1): - """Simulate double clicking a mouse button. - - See QTest.mouseDClick for details. - """ - if modifier is None: - modifier = qt.Qt.KeyboardModifiers() - pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint() - QTest.mouseDClick(widget, button, modifier, pos, delay) - self.qWait(20) - - def mouseMove(self, widget, pos=None, delay=-1): - """Simulate moving the mouse. - - See QTest.mouseMove for details. - """ - pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint() - QTest.mouseMove(widget, pos, delay) - self.qWait(20) - - def mousePress(self, widget, button, modifier=None, pos=None, delay=-1): - """Simulate pressing a mouse button. - - See QTest.mousePress for details. - """ - if modifier is None: - modifier = qt.Qt.KeyboardModifiers() - pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint() - QTest.mousePress(widget, button, modifier, pos, delay) - self.qWait(20) - - def mouseRelease(self, widget, button, modifier=None, pos=None, delay=-1): - """Simulate releasing a mouse button. - - See QTest.mouseRelease for details. - """ - if modifier is None: - modifier = qt.Qt.KeyboardModifiers() - pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint() - QTest.mouseRelease(widget, button, modifier, pos, delay) - self.qWait(20) - - def qSleep(self, ms): - """Sleep for ms milliseconds, blocking the execution of the test. - - See QTest.qSleep for details. - """ - QTest.qSleep(ms + self.TIMEOUT_WAIT) - - @classmethod - def qWait(cls, ms=None): - """Waits for ms milliseconds, events will be processed. - - See QTest.qWait for details. - """ - if ms is None: - ms = cls.DEFAULT_TIMEOUT_WAIT - - if qt.BINDING == 'PySide': - # PySide has no qWait, provide a replacement - timeout = int(ms) - endTimeMS = int(time.time() * 1000) + timeout - while timeout > 0: - _qapp.processEvents(qt.QEventLoop.AllEvents, - maxtime=timeout) - timeout = endTimeMS - int(time.time() * 1000) - else: - QTest.qWait(ms + cls.TIMEOUT_WAIT) - - def qWaitForWindowExposed(self, window, timeout=None): - """Waits until the window is shown in the screen. - - See QTest.qWaitForWindowExposed for details. - """ - result = qWaitForWindowExposedAndActivate(window, timeout) - - if self.TIMEOUT_WAIT: - QTest.qWait(self.TIMEOUT_WAIT) - - return result - - _qobject_destroyed = False - - @classmethod - def _aboutToDestroy(cls): - cls._qobject_destroyed = True - - @classmethod - def qWaitForDestroy(cls, ref): - """ - Wait for Qt object destruction. - - Use a weakref as parameter to avoid any strong references to the - object. - - It have to be used as following. Removing the reference to the object - before calling the function looks to be expected, else - :meth:`deleteLater` will not work. - - .. code-block:: python - - ref = weakref.ref(self.obj) - self.obj = None - self.qWaitForDestroy(ref) - - :param weakref ref: A weakref to an object to avoid any reference - :return: True if the object was destroyed - :rtype: bool - """ - cls._qobject_destroyed = False - if qt.BINDING == 'PyQt4': - # Without this, QWidget will be still alive on PyQt4 - # (at least on Windows Python 2.7) - # If it is not skipped on PySide, silx.gui.dialog tests will - # segfault (at least on Windows Python 2.7) - import gc - gc.collect() - qobject = ref() - if qobject is None: - return True - qobject.destroyed.connect(cls._aboutToDestroy) - qobject.deleteLater() - qobject = None - for _ in range(10): - if cls._qobject_destroyed: - break - cls.qWait(10) - else: - _logger.debug("Object was not destroyed") - - return ref() is None - - def logScreenShot(self, level=logging.ERROR): - """Take a screenshot and log it into the logging system if the - logger is enabled for the expected level. - - The screenshot is stored in the directory "./build/test-debug", and - the logging system only log the path to this file. - - :param level: Logging level - """ - if not _logger.isEnabledFor(level): - return - basedir = os.path.abspath(os.path.join("build", "test-debug")) - if not os.path.exists(basedir): - os.makedirs(basedir) - filename = "Screenshot_%s.png" % self.id() - filename = os.path.join(basedir, filename) - - if not hasattr(self.qapp, "primaryScreen"): - # Qt4 - winId = qt.QApplication.desktop().winId() - pixmap = qt.QPixmap.grabWindow(winId) - else: - # Qt5 - screen = self.qapp.primaryScreen() - pixmap = screen.grabWindow(0) - pixmap.save(filename) - _logger.log(level, "Screenshot saved at %s", filename) - - -class SignalListener(object): - """Util to listen a Qt event and store parameters - """ - - def __init__(self): - self.__calls = [] - - def __call__(self, *args, **kargs): - self.__calls.append((args, kargs)) - - def clear(self): - """Clear stored data""" - self.__calls = [] - - def callCount(self): - """ - Returns how many times the listener was called. - - :rtype: int - """ - return len(self.__calls) - - def arguments(self, callIndex=None, argumentIndex=None): - """Returns positional arguments optionally filtered by call count id - or argument index. - - :param int callIndex: Index of the called data - :param int argumentIndex: Index of the positional argument. - """ - if callIndex is not None: - result = self.__calls[callIndex][0] - if argumentIndex is not None: - result = result[argumentIndex] - else: - result = [x[0] for x in self.__calls] - if argumentIndex is not None: - result = [x[argumentIndex] for x in result] - return result - - def karguments(self, callIndex=None, argumentName=None): - """Returns positional arguments optionally filtered by call count id - or name of the keyword argument. - - :param int callIndex: Index of the called data - :param int argumentName: Name of the keyword argument. - """ - if callIndex is not None: - result = self.__calls[callIndex][1] - if argumentName is not None: - result = result[argumentName] - else: - result = [x[1] for x in self.__calls] - if argumentName is not None: - result = [x[argumentName] for x in result] - return result - - def partial(self, *args, **kargs): - """Returns a new partial object which when called will behave like this - listener called with the positional arguments args and keyword - arguments keywords. If more arguments are supplied to the call, they - are appended to args. If additional keyword arguments are supplied, - they extend and override keywords. - """ - return functools.partial(self, *args, **kargs) - - -def getQToolButtonFromAction(action): - """Return a QToolButton corresponding to a QAction. +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "05/10/2018" - :param QAction action: The QAction from which to get QToolButton. - :return: A QToolButton associated to action or None. - """ - for widget in action.associatedWidgets(): - if isinstance(widget, qt.QToolButton): - return widget - return None +import silx.utils.deprecation +silx.utils.deprecation.deprecated_warning("Module", + name="silx.gui.test.utils", + reason="moved", + replacement="silx.gui.utils.testutils", + since_version="0.9.0", + only_once=True, + skip_backtrace_count=1) -def findChildren(parent, kind, name=None): - if qt.BINDING == "PySide" and name is not None: - result = [] - for obj in parent.findChildren(kind): - if obj.objectName() == name: - result.append(obj) - return result - else: - return parent.findChildren(kind, name=name) +from ..utils.testutils import * # noqa diff --git a/silx/gui/utils/_image.py b/silx/gui/utils/_image.py deleted file mode 100644 index 260aac3..0000000 --- a/silx/gui/utils/_image.py +++ /dev/null @@ -1,104 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""This module provides convenient functions to use with Qt objects. - -It provides: -- conversion between numpy and QImage: - :func:`convertArrayToQImage`, :func:`convertQImageToArray` -""" - -from __future__ import division - - -__authors__ = ["T. Vincent"] -__license__ = "MIT" -__date__ = "16/01/2017" - - -import sys -import numpy - -from .. import qt - - -def convertArrayToQImage(image): - """Convert an array-like RGB888 image to a QImage. - - The created QImage is using a copy of the array data. - - Limitation: Only supports RGB888 format. - - :param image: Array-like image data - :type image: numpy.ndarray of uint8 of dimension HxWx3 - :return: Corresponding Qt image - :rtype: QImage - """ - # Possible extension: add a format argument to support more formats - - image = numpy.array(image, copy=False, order='C', dtype=numpy.uint8) - - height, width, depth = image.shape - assert depth == 3 - - qimage = qt.QImage( - image.data, - width, - height, - image.strides[0], # bytesPerLine - qt.QImage.Format_RGB888) - - return qimage.copy() # Making a copy of the image and its data - - -def convertQImageToArray(image): - """Convert a RGB888 QImage to a numpy array. - - Limitation: Only supports RGB888 format. - If QImage is not RGB888 it gets converted to this format. - - :param QImage: The QImage to convert. - :return: The image array - :rtype: numpy.ndarray of uint8 of shape HxWx3 - """ - # Possible extension: avoid conversion to support more formats - - if image.format() != qt.QImage.Format_RGB888: - # Convert to RGB888 if needed - image = image.convertToFormat(qt.QImage.Format_RGB888) - - ptr = image.bits() - if qt.BINDING not in ('PySide', 'PySide2'): - ptr.setsize(image.byteCount()) - if qt.BINDING == 'PyQt4' and sys.version_info[0] == 2: - ptr = ptr.asstring() - elif sys.version_info[0] == 3: # PySide with Python3 - ptr = ptr.tobytes() - - array = numpy.fromstring(ptr, dtype=numpy.uint8) - - # Lines are 32 bits aligned: remove padding bytes - array = array.reshape(image.height(), -1)[:, :image.width() * 3] - array.shape = image.height(), image.width(), 3 - return array diff --git a/silx/gui/utils/image.py b/silx/gui/utils/image.py new file mode 100644 index 0000000..3ac737f --- /dev/null +++ b/silx/gui/utils/image.py @@ -0,0 +1,143 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides conversions between numpy.ndarray and QImage + +- :func:`convertArrayToQImage` +- :func:`convertQImageToArray` +""" + +from __future__ import division + + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "04/09/2018" + + +import sys +import numpy +from numpy.lib.stride_tricks import as_strided as _as_strided + +from .. import qt + + +def convertArrayToQImage(array): + """Convert an array-like image to a QImage. + + The created QImage is using a copy of the array data. + + Limitation: Only RGB or RGBA images with 8 bits per channel are supported. + + :param array: Array-like image data of shape (height, width, channels) + Channels are expected to be either RGB or RGBA. + :type array: numpy.ndarray of uint8 + :return: Corresponding Qt image with RGB888 or ARGB32 format. + :rtype: QImage + """ + array = numpy.array(array, copy=False, order='C', dtype=numpy.uint8) + + if array.ndim != 3 or array.shape[2] not in (3, 4): + raise ValueError( + 'Image must be a 3D array with 3 or 4 channels per pixel') + + if array.shape[2] == 4: + format_ = qt.QImage.Format_ARGB32 + # RGBA -> ARGB + take care of endianness + if sys.byteorder == 'little': # RGBA -> BGRA + array = array[:, :, (2, 1, 0, 3)] + else: # big endian: RGBA -> ARGB + array = array[:, :, (3, 0, 1, 2)] + + array = numpy.array(array, order='C') # Make a contiguous array + + else: # array.shape[2] == 3 + format_ = qt.QImage.Format_RGB888 + + height, width, depth = array.shape + qimage = qt.QImage( + array.data, + width, + height, + array.strides[0], # bytesPerLine + format_) + + return qimage.copy() # Making a copy of the image and its data + + +def convertQImageToArray(image): + """Convert a QImage to a numpy array. + + If QImage format is not Format_RGB888, Format_RGBA8888 or Format_ARGB32, + it is first converted to one of this format depending on + the presence of an alpha channel. + + The created numpy array is using a copy of the QImage data. + + :param QImage image: The QImage to convert. + :return: The image array of RGB or RGBA channels of shape + (height, width, channels (3 or 4)) + :rtype: numpy.ndarray of uint8 + """ + rgba8888 = getattr(qt.QImage, 'Format_RGBA8888', None) # Only in Qt5 + + # Convert to supported format if needed + if image.format() not in (qt.QImage.Format_ARGB32, + qt.QImage.Format_RGB888, + rgba8888): + if image.hasAlphaChannel(): + image = image.convertToFormat( + rgba8888 if rgba8888 is not None else qt.QImage.Format_ARGB32) + else: + image = image.convertToFormat(qt.QImage.Format_RGB888) + + format_ = image.format() + channels = 3 if format_ == qt.QImage.Format_RGB888 else 4 + + ptr = image.bits() + if qt.BINDING not in ('PySide', 'PySide2'): + ptr.setsize(image.byteCount()) + if qt.BINDING == 'PyQt4' and sys.version_info[0] == 2: + ptr = ptr.asstring() + elif sys.version_info[0] == 3: # PySide with Python3 + ptr = ptr.tobytes() + + # Create an array view on QImage internal data + view = _as_strided( + numpy.frombuffer(ptr, dtype=numpy.uint8), + shape=(image.height(), image.width(), channels), + strides=(image.bytesPerLine(), channels, 1)) + + if format_ == qt.QImage.Format_ARGB32: + # Convert from ARGB to RGBA + # Not a byte-ordered format: do care about endianness + if sys.byteorder == 'little': # BGRA -> RGBA + view = view[:, :, (2, 1, 0, 3)] + else: # big endian: ARGB -> RGBA + view = view[:, :, (1, 2, 3, 0)] + + # Format_RGB888 and Format_RGBA8888 do not need reshuffling channels: + # They are byte-ordered and already in the right order + + return numpy.array(view, copy=True, order='C') diff --git a/silx/gui/utils/test/test_async.py b/silx/gui/utils/test/test_async.py index fd32a3f..dabfb3c 100644 --- a/silx/gui/utils/test/test_async.py +++ b/silx/gui/utils/test/test_async.py @@ -35,7 +35,7 @@ import unittest from silx.third_party.concurrent_futures import wait from silx.gui import qt -from silx.gui.test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from silx.gui.utils import concurrent diff --git a/silx/gui/utils/test/test_image.py b/silx/gui/utils/test/test_image.py index 7cba1b0..cda7d95 100644 --- a/silx/gui/utils/test/test_image.py +++ b/silx/gui/utils/test/test_image.py @@ -32,35 +32,51 @@ import numpy import unittest from silx.gui import qt -from silx.gui.test.utils import TestCaseQt -from silx.gui.utils import _image +from silx.utils.testutils import ParametricTestCase +from silx.gui.utils.testutils import TestCaseQt +from silx.gui.utils.image import convertArrayToQImage, convertQImageToArray -class TestQImageConversion(TestCaseQt): +class TestQImageConversion(TestCaseQt, ParametricTestCase): """Tests conversion of QImage to/from numpy array.""" def testConvertArrayToQImage(self): """Test conversion of numpy array to QImage""" - image = numpy.ones((3, 3, 3), dtype=numpy.uint8) - qimage = _image.convertArrayToQImage(image) + for format_, channels in [('Format_RGB888', 3), + ('Format_ARGB32', 4)]: + with self.subTest(format_): + image = numpy.arange( + 3*3*channels, dtype=numpy.uint8).reshape(3, 3, channels) + qimage = convertArrayToQImage(image) - self.assertEqual(qimage.height(), image.shape[0]) - self.assertEqual(qimage.width(), image.shape[1]) - self.assertEqual(qimage.format(), qt.QImage.Format_RGB888) + self.assertEqual(qimage.height(), image.shape[0]) + self.assertEqual(qimage.width(), image.shape[1]) + self.assertEqual(qimage.format(), getattr(qt.QImage, format_)) + + for row in range(3): + for col in range(3): + # Qrgb has no alpha channel, not compared + # Qt uses x,y while array is row,col... + self.assertEqual(qt.QColor(qimage.pixel(col, row)), + qt.QColor(*image[row, col, :3])) - color = qt.QColor(1, 1, 1).rgb() - self.assertEqual(qimage.pixel(1, 1), color) def testConvertQImageToArray(self): """Test conversion of QImage to numpy array""" - qimage = qt.QImage(3, 3, qt.QImage.Format_RGB888) - qimage.fill(0x010101) - image = _image.convertQImageToArray(qimage) + for format_, channels in [ + ('Format_RGB888', 3), # Native support + ('Format_ARGB32', 4), # Native support + ('Format_RGB32', 3)]: # Conversion to RGB + with self.subTest(format_): + color = numpy.arange(channels) # RGB(A) values + qimage = qt.QImage(3, 3, getattr(qt.QImage, format_)) + qimage.fill(qt.QColor(*color)) + image = convertQImageToArray(qimage) - self.assertEqual(qimage.height(), image.shape[0]) - self.assertEqual(qimage.width(), image.shape[1]) - self.assertEqual(image.shape[2], 3) - self.assertTrue(numpy.all(numpy.equal(image, 1))) + self.assertEqual(qimage.height(), image.shape[0]) + self.assertEqual(qimage.width(), image.shape[1]) + self.assertEqual(image.shape[2], len(color)) + self.assertTrue(numpy.all(numpy.equal(image, color))) def suite(): diff --git a/silx/gui/utils/testutils.py b/silx/gui/utils/testutils.py new file mode 100644 index 0000000..35085fc --- /dev/null +++ b/silx/gui/utils/testutils.py @@ -0,0 +1,520 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-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. +# +# ###########################################################################*/ +"""Helper class to write Qt widget unittests.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "05/10/2018" + + +import gc +import logging +import unittest +import time +import functools +import sys +import os + +_logger = logging.getLogger(__name__) + +from silx.gui import qt + +if qt.BINDING == 'PySide': + from PySide.QtTest import QTest +elif qt.BINDING == 'PySide2': + from PySide2.QtTest import QTest +elif qt.BINDING == 'PyQt5': + from PyQt5.QtTest import QTest +elif qt.BINDING == 'PyQt4': + from PyQt4.QtTest import QTest +else: + raise ImportError('Unsupported Qt bindings') + +# Qt4/Qt5 compatibility wrapper +if qt.BINDING in ('PySide', 'PyQt4'): + _logger.info("QTest.qWaitForWindowExposed not available," + + "using QTest.qWaitForWindowShown instead.") + + def qWaitForWindowExposed(window, timeout=None): + """Mimic QTest.qWaitForWindowExposed for Qt4.""" + QTest.qWaitForWindowShown(window) + return True +else: + qWaitForWindowExposed = QTest.qWaitForWindowExposed + + +def qWaitForWindowExposedAndActivate(window, timeout=None): + """Waits until the window is shown in the screen. + + It also activates the window and raises it. + + See QTest.qWaitForWindowExposed for details. + """ + if timeout is None: + result = qWaitForWindowExposed(window) + else: + result = qWaitForWindowExposed(window, timeout) + + if result: + # Makes sure window is active and on top + window.activateWindow() + window.raise_() + + return result + + +# Placeholder for QApplication +_qapp = None + + +class TestCaseQt(unittest.TestCase): + """Base class to write test for Qt stuff. + + It creates a QApplication before running the tests. + WARNING: The QApplication is shared by all tests, which might have side + effects. + + After each test, this class is checking for widgets remaining alive. + To allow some widgets to remain alive at the end of a test, set the + allowedLeakingWidgets attribute to the number of widgets that can remain + alive at the end of the test. + With PySide, this test is not run for now as it seems PySide + is leaking widgets internally. + + All keyboard and mouse event simulation methods call qWait(20) after + simulating the event (as QTest does on Mac OSX). + This was introduced to fix issues with continuous integration tests + running with Xvfb on Linux. + """ + + DEFAULT_TIMEOUT_WAIT = 100 + """Default timeout for qWait""" + + TIMEOUT_WAIT = 0 + """Extra timeout in millisecond to add to qSleep, qWait and + qWaitForWindowExposed. + + Intended purpose is for debugging, to add extra time to waits in order to + allow to view the tested widgets. + """ + + @classmethod + def exceptionHandler(cls, exceptionClass, exception, stack): + import traceback + message = (''.join(traceback.format_tb(stack))) + template = 'Traceback (most recent call last):\n{2}{0}: {1}' + message = template.format(exceptionClass.__name__, exception, message) + cls._exceptions.append(message) + + @classmethod + def setUpClass(cls): + """Makes sure Qt is inited""" + cls._oldExceptionHook = sys.excepthook + sys.excepthook = cls.exceptionHandler + + global _qapp + if _qapp is None: + # Makes sure a QApplication exists and do it once for all + _qapp = qt.QApplication.instance() or qt.QApplication([]) + + # Makes sure QDesktopWidget is init + # Otherwise it happens randomly during the tests + cls._desktopWidget = _qapp.desktop() + _qapp.processEvents() + + @classmethod + def tearDownClass(cls): + sys.excepthook = cls._oldExceptionHook + + def setUp(self): + """Get the list of existing widgets.""" + self.allowedLeakingWidgets = 0 + self.__previousWidgets = self.qapp.allWidgets() + self.__class__._exceptions = [] + + def _currentTestSucceeded(self): + if hasattr(self, '_outcome'): + # For Python >= 3.4 + result = self.defaultTestResult() # these 2 methods have no side effects + self._feedErrorsToResult(result, self._outcome.errors) + else: + # For Python < 3.4 + result = getattr(self, '_outcomeForDoCleanups', self._resultForDoCleanups) + + skipped = self.id() in [case.id() for case, _ in result.skipped] + error = self.id() in [case.id() for case, _ in result.errors] + failure = self.id() in [case.id() for case, _ in result.failures] + return not error and not failure and not skipped + + def _checkForUnreleasedWidgets(self): + """Test fixture checking that no more widgets exists.""" + gc.collect() + + widgets = [widget for widget in self.qapp.allWidgets() + if widget not in self.__previousWidgets] + del self.__previousWidgets + + if qt.BINDING in ('PySide', 'PySide2'): + return # Do not test for leaking widgets with PySide + + allowedLeakingWidgets = self.allowedLeakingWidgets + self.allowedLeakingWidgets = 0 + + if widgets and len(widgets) <= allowedLeakingWidgets: + _logger.info( + '%s: %d remaining widgets after test' % (self.id(), + len(widgets))) + + if len(widgets) > allowedLeakingWidgets: + raise RuntimeError( + "Test ended with widgets alive: %s" % str(widgets)) + + def tearDown(self): + if len(self.__class__._exceptions) > 0: + messages = "\n".join(self.__class__._exceptions) + raise AssertionError("Exception occured in Qt thread:\n" + messages) + + if self._currentTestSucceeded(): + self._checkForUnreleasedWidgets() + + @property + def qapp(self): + """The QApplication currently running.""" + return qt.QApplication.instance() + + # Proxy to QTest + + Press = QTest.Press + """Key press action code""" + + Release = QTest.Release + """Key release action code""" + + Click = QTest.Click + """Key click action code""" + + QTest = property(lambda self: QTest, + doc="""The Qt QTest class from the used Qt binding.""") + + def keyClick(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1): + """Simulate clicking a key. + + See QTest.keyClick for details. + """ + QTest.keyClick(widget, key, modifier, delay) + self.qWait(20) + + def keyClicks(self, widget, sequence, modifier=qt.Qt.NoModifier, delay=-1): + """Simulate clicking a sequence of keys. + + See QTest.keyClick for details. + """ + QTest.keyClicks(widget, sequence, modifier, delay) + self.qWait(20) + + def keyEvent(self, action, widget, key, + modifier=qt.Qt.NoModifier, delay=-1): + """Sends a Qt key event. + + See QTest.keyEvent for details. + """ + QTest.keyEvent(action, widget, key, modifier, delay) + self.qWait(20) + + def keyPress(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1): + """Sends a Qt key press event. + + See QTest.keyPress for details. + """ + QTest.keyPress(widget, key, modifier, delay) + self.qWait(20) + + def keyRelease(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1): + """Sends a Qt key release event. + + See QTest.keyRelease for details. + """ + QTest.keyRelease(widget, key, modifier, delay) + self.qWait(20) + + def mouseClick(self, widget, button, modifier=None, pos=None, delay=-1): + """Simulate clicking a mouse button. + + See QTest.mouseClick for details. + """ + if modifier is None: + modifier = qt.Qt.KeyboardModifiers() + pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint() + QTest.mouseClick(widget, button, modifier, pos, delay) + self.qWait(20) + + def mouseDClick(self, widget, button, modifier=None, pos=None, delay=-1): + """Simulate double clicking a mouse button. + + See QTest.mouseDClick for details. + """ + if modifier is None: + modifier = qt.Qt.KeyboardModifiers() + pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint() + QTest.mouseDClick(widget, button, modifier, pos, delay) + self.qWait(20) + + def mouseMove(self, widget, pos=None, delay=-1): + """Simulate moving the mouse. + + See QTest.mouseMove for details. + """ + pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint() + QTest.mouseMove(widget, pos, delay) + self.qWait(20) + + def mousePress(self, widget, button, modifier=None, pos=None, delay=-1): + """Simulate pressing a mouse button. + + See QTest.mousePress for details. + """ + if modifier is None: + modifier = qt.Qt.KeyboardModifiers() + pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint() + QTest.mousePress(widget, button, modifier, pos, delay) + self.qWait(20) + + def mouseRelease(self, widget, button, modifier=None, pos=None, delay=-1): + """Simulate releasing a mouse button. + + See QTest.mouseRelease for details. + """ + if modifier is None: + modifier = qt.Qt.KeyboardModifiers() + pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint() + QTest.mouseRelease(widget, button, modifier, pos, delay) + self.qWait(20) + + def qSleep(self, ms): + """Sleep for ms milliseconds, blocking the execution of the test. + + See QTest.qSleep for details. + """ + QTest.qSleep(ms + self.TIMEOUT_WAIT) + + @classmethod + def qWait(cls, ms=None): + """Waits for ms milliseconds, events will be processed. + + See QTest.qWait for details. + """ + if ms is None: + ms = cls.DEFAULT_TIMEOUT_WAIT + + if qt.BINDING in ('PySide', 'PySide2'): + # PySide has no qWait, provide a replacement + timeout = int(ms) + endTimeMS = int(time.time() * 1000) + timeout + while timeout > 0: + _qapp.processEvents(qt.QEventLoop.AllEvents, + maxtime=timeout) + timeout = endTimeMS - int(time.time() * 1000) + else: + QTest.qWait(ms + cls.TIMEOUT_WAIT) + + def qWaitForWindowExposed(self, window, timeout=None): + """Waits until the window is shown in the screen. + + See QTest.qWaitForWindowExposed for details. + """ + result = qWaitForWindowExposedAndActivate(window, timeout) + + if self.TIMEOUT_WAIT: + QTest.qWait(self.TIMEOUT_WAIT) + + return result + + _qobject_destroyed = False + + @classmethod + def _aboutToDestroy(cls): + cls._qobject_destroyed = True + + @classmethod + def qWaitForDestroy(cls, ref): + """ + Wait for Qt object destruction. + + Use a weakref as parameter to avoid any strong references to the + object. + + It have to be used as following. Removing the reference to the object + before calling the function looks to be expected, else + :meth:`deleteLater` will not work. + + .. code-block:: python + + ref = weakref.ref(self.obj) + self.obj = None + self.qWaitForDestroy(ref) + + :param weakref ref: A weakref to an object to avoid any reference + :return: True if the object was destroyed + :rtype: bool + """ + cls._qobject_destroyed = False + if qt.BINDING == 'PyQt4': + # Without this, QWidget will be still alive on PyQt4 + # (at least on Windows Python 2.7) + # If it is not skipped on PySide, silx.gui.dialog tests will + # segfault (at least on Windows Python 2.7) + import gc + gc.collect() + qobject = ref() + if qobject is None: + return True + qobject.destroyed.connect(cls._aboutToDestroy) + qobject.deleteLater() + qobject = None + for _ in range(10): + if cls._qobject_destroyed: + break + cls.qWait(10) + else: + _logger.debug("Object was not destroyed") + + return ref() is None + + def logScreenShot(self, level=logging.ERROR): + """Take a screenshot and log it into the logging system if the + logger is enabled for the expected level. + + The screenshot is stored in the directory "./build/test-debug", and + the logging system only log the path to this file. + + :param level: Logging level + """ + if not _logger.isEnabledFor(level): + return + basedir = os.path.abspath(os.path.join("build", "test-debug")) + if not os.path.exists(basedir): + os.makedirs(basedir) + filename = "Screenshot_%s.png" % self.id() + filename = os.path.join(basedir, filename) + + if not hasattr(self.qapp, "primaryScreen"): + # Qt4 + winId = qt.QApplication.desktop().winId() + pixmap = qt.QPixmap.grabWindow(winId) + else: + # Qt5 + screen = self.qapp.primaryScreen() + pixmap = screen.grabWindow(0) + pixmap.save(filename) + _logger.log(level, "Screenshot saved at %s", filename) + + +class SignalListener(object): + """Util to listen a Qt event and store parameters + """ + + def __init__(self): + self.__calls = [] + + def __call__(self, *args, **kargs): + self.__calls.append((args, kargs)) + + def clear(self): + """Clear stored data""" + self.__calls = [] + + def callCount(self): + """ + Returns how many times the listener was called. + + :rtype: int + """ + return len(self.__calls) + + def arguments(self, callIndex=None, argumentIndex=None): + """Returns positional arguments optionally filtered by call count id + or argument index. + + :param int callIndex: Index of the called data + :param int argumentIndex: Index of the positional argument. + """ + if callIndex is not None: + result = self.__calls[callIndex][0] + if argumentIndex is not None: + result = result[argumentIndex] + else: + result = [x[0] for x in self.__calls] + if argumentIndex is not None: + result = [x[argumentIndex] for x in result] + return result + + def karguments(self, callIndex=None, argumentName=None): + """Returns positional arguments optionally filtered by call count id + or name of the keyword argument. + + :param int callIndex: Index of the called data + :param int argumentName: Name of the keyword argument. + """ + if callIndex is not None: + result = self.__calls[callIndex][1] + if argumentName is not None: + result = result[argumentName] + else: + result = [x[1] for x in self.__calls] + if argumentName is not None: + result = [x[argumentName] for x in result] + return result + + def partial(self, *args, **kargs): + """Returns a new partial object which when called will behave like this + listener called with the positional arguments args and keyword + arguments keywords. If more arguments are supplied to the call, they + are appended to args. If additional keyword arguments are supplied, + they extend and override keywords. + """ + return functools.partial(self, *args, **kargs) + + +def getQToolButtonFromAction(action): + """Return a QToolButton corresponding to a QAction. + + :param QAction action: The QAction from which to get QToolButton. + :return: A QToolButton associated to action or None. + """ + for widget in action.associatedWidgets(): + if isinstance(widget, qt.QToolButton): + return widget + return None + + +def findChildren(parent, kind, name=None): + if qt.BINDING in ("PySide", "PySide2") and name is not None: + result = [] + for obj in parent.findChildren(kind): + if obj.objectName() == name: + result.append(obj) + return result + else: + return parent.findChildren(kind, name=name) diff --git a/silx/gui/widgets/FloatEdit.py b/silx/gui/widgets/FloatEdit.py index fd6d8a7..36a39a7 100644 --- a/silx/gui/widgets/FloatEdit.py +++ b/silx/gui/widgets/FloatEdit.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 @@ -61,5 +61,5 @@ class FloatEdit(qt.QLineEdit): :param float value: The value to set the QLineEdit to. """ - text = self.validator().locale().toString(value) + text = self.validator().locale().toString(float(value)) self.setText(text) diff --git a/silx/gui/widgets/FlowLayout.py b/silx/gui/widgets/FlowLayout.py new file mode 100644 index 0000000..14c8ab2 --- /dev/null +++ b/silx/gui/widgets/FlowLayout.py @@ -0,0 +1,177 @@ +# 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 flow layout for QWidget: :class:`FlowLayout`. +""" + +from __future__ import division + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "20/07/2018" + + +from .. import qt + + +class FlowLayout(qt.QLayout): + """Layout widgets on (possibly) multiple lines in the available width. + + See Qt :class:`QLayout` for API documentation. + + Adapted from C++ `Qt FlowLayout example + `_ + + :param QWidget parent: See :class:`QLayout` + """ + + def __init__(self, parent=None): + super(FlowLayout, self).__init__(parent) + self._items = [] + self._horizontalSpacing = -1 + self._verticalSpacing = -1 + + def addItem(self, item): + self._items.append(item) + + def count(self): + return len(self._items) + + def itemAt(self, index): + if 0 <= index < len(self._items): + return self._items[index] + else: + return None + + def takeAt(self, index): + if 0 <= index < len(self._items): + return self._items.pop(index) + else: + return None + + def expandingDirections(self): + return 0 + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + return self._layout(qt.QRect(0, 0, width, 0), test=True) + + def setGeometry(self, rect): + super(FlowLayout, self).setGeometry(rect) + self._layout(rect) + + def sizeHint(self): + return self.minimumSize() + + def minimumSize(self): + size = qt.QSize() + for item in self._items: + size = size.expandedTo(item.minimumSize()) + + left, top, right, bottom = self.getContentsMargins() + size += qt.QSize(left + right, top + bottom) + return size + + def _layout(self, rect, test=False): + left, top, right, bottom = self.getContentsMargins() + effectiveRect = rect.adjusted(left, top, -right, -bottom) + x, y = effectiveRect.x(), effectiveRect.y() + lineHeight = 0 + + for item in self._items: + widget = item.widget() + spaceX = self.horizontalSpacing() + if spaceX == -1: + spaceX = widget.style().layoutSpacing( + qt.QSizePolicy.PushButton, + qt.QSizePolicy.PushButton, + qt.Qt.Horizontal) + spaceY = self.verticalSpacing() + if spaceY == -1: + spaceY = widget.style().layoutSpacing( + qt.QSizePolicy.PushButton, + qt.QSizePolicy.PushButton, + qt.Qt.Vertical) + + nextX = x + item.sizeHint().width() + spaceX + if (nextX - spaceX) > effectiveRect.right() and lineHeight > 0: + x = effectiveRect.x() + y += lineHeight + spaceY + nextX = x + item.sizeHint().width() + spaceX + lineHeight = 0 + + if not test: + item.setGeometry(qt.QRect(qt.QPoint(x, y), item.sizeHint())) + + x = nextX + lineHeight = max(lineHeight, item.sizeHint().height()) + + return y + lineHeight - rect.y() + bottom + + def setHorizontalSpacing(self, spacing): + """Set the horizontal spacing between widgets laid out side by side + + :param int spacing: + """ + self._horizontalSpacing = spacing + self.update() + + def horizontalSpacing(self): + """Returns the horizontal spacing between widgets laid out side by side + + :rtype: int + """ + if self._horizontalSpacing >= 0: + return self._horizontalSpacing + else: + return self._smartSpacing(qt.QStyle.PM_LayoutHorizontalSpacing) + + def setVerticalSpacing(self, spacing): + """Set the vertical spacing between lines + + :param int spacing: + """ + self._verticalSpacing = spacing + self.update() + + def verticalSpacing(self): + """Returns the vertical spacing between lines + + :rtype: int + """ + if self._verticalSpacing >= 0: + return self._verticalSpacing + else: + return self._smartSpacing(qt.QStyle.PM_LayoutVerticalSpacing) + + def _smartSpacing(self, pm): + parent = self.parent() + if parent is None: + return -1 + if parent.isWidgetType(): + return parent.style().pixelMetric(pm, None, parent) + else: + return parent.spacing() diff --git a/silx/gui/widgets/PrintPreview.py b/silx/gui/widgets/PrintPreview.py index 78d1bd7..94a8ed4 100644 --- a/silx/gui/widgets/PrintPreview.py +++ b/silx/gui/widgets/PrintPreview.py @@ -411,6 +411,9 @@ class PrintPreviewDialog(qt.QDialog): """If the printer is not already set, try to interactively setup the printer using a QPrintDialog. In case of failure, hide widget and log a warning. + + :return: True if printer was set. False if it failed or if the + selection dialog was canceled. """ if self.printer is None: self.setup() @@ -418,6 +421,7 @@ class PrintPreviewDialog(qt.QDialog): self.hide() _logger.warning("Printer setup failed or was cancelled, " + "but printer is required.") + return self.printer is not None def setOutputFileName(self, name): """Set output filename. diff --git a/silx/gui/widgets/RangeSlider.py b/silx/gui/widgets/RangeSlider.py new file mode 100644 index 0000000..0b72e71 --- /dev/null +++ b/silx/gui/widgets/RangeSlider.py @@ -0,0 +1,627 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-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 :class:`RangeSlider` widget. + +.. image:: img/RangeSlider.png + :align: center +""" +from __future__ import absolute_import, division + +__authors__ = ["D. Naudet", "T. Vincent"] +__license__ = "MIT" +__date__ = "02/08/2018" + + +import numpy as numpy + +from silx.gui import qt, icons, colors +from silx.gui.utils.image import convertArrayToQImage + + +class RangeSlider(qt.QWidget): + """Range slider with 2 thumbs and an optional colored groove. + + The position of the slider thumbs can be retrieved either as values + in the slider range or as a number of steps or pixels. + + :param QWidget parent: See QWidget + """ + + _SLIDER_WIDTH = 10 + """Width of the slider rectangle""" + + _PIXMAP_VOFFSET = 7 + """Vertical groove pixmap offset""" + + sigRangeChanged = qt.Signal(float, float) + """Signal emitted when the value range has changed. + + It provides the new range (min, max). + """ + + sigValueChanged = qt.Signal(float, float) + """Signal emitted when the value of the sliders has changed. + + It provides the slider values (first, second). + """ + + sigPositionCountChanged = qt.Signal(object) + """This signal is emitted when the number of steps has changed. + + It provides the new position count. + """ + + sigPositionChanged = qt.Signal(int, int) + """Signal emitted when the position of the sliders has changed. + + It provides the slider positions in steps or pixels (first, second). + """ + + def __init__(self, parent=None): + self.__pixmap = None + self.__positionCount = None + self.__firstValue = 0. + self.__secondValue = 1. + self.__minValue = 0. + self.__maxValue = 1. + + self.__focus = None + self.__moving = None + + self.__icons = { + 'first': icons.getQIcon('previous'), + 'second': icons.getQIcon('next') + } + + # call the super constructor AFTER defining all members that + # are used in the "paint" method + super(RangeSlider, self).__init__(parent) + + self.setFocusPolicy(qt.Qt.ClickFocus) + + self.setMinimumSize(qt.QSize(50, 20)) + self.setMaximumHeight(20) + + # Broadcast value changed signal + self.sigValueChanged.connect(self.__emitPositionChanged) + + # Position <-> Value conversion + + def __positionToValue(self, position): + """Returns value corresponding to position + + :param int position: + :rtype: float + """ + min_, max_ = self.getMinimum(), self.getMaximum() + maxPos = self.__getCurrentPositionCount() - 1 + return min_ + (max_ - min_) * int(position) / maxPos + + def __valueToPosition(self, value): + """Returns closest position corresponding to value + + :param float value: + :rtype: int + """ + min_, max_ = self.getMinimum(), self.getMaximum() + maxPos = self.__getCurrentPositionCount() - 1 + return int(0.5 + maxPos * (float(value) - min_) / (max_ - min_)) + + # Position (int) API + + def __getCurrentPositionCount(self): + """Return current count (either position count or widget width + + :rtype: int + """ + count = self.getPositionCount() + if count is not None: + return count + else: + return max(2, self.width() - self._SLIDER_WIDTH) + + def getPositionCount(self): + """Returns the number of positions. + + :rtype: Union[int,None]""" + return self.__positionCount + + def setPositionCount(self, count): + """Set the number of positions. + + Slider values are eventually adjusted. + + :param Union[int,None] count: + Either the number of possible positions or + None to allow any values. + :raise ValueError: If count <= 1 + """ + count = None if count is None else int(count) + if count != self.getPositionCount(): + if count is not None and count <= 1: + raise ValueError("Position count must be higher than 1") + self.__positionCount = count + emit = self.__setValues(*self.getValues()) + self.sigPositionCountChanged.emit(count) + if emit: + self.sigValueChanged.emit(*self.getValues()) + + def getFirstPosition(self): + """Returns first slider position + + :rtype: int + """ + return self.__valueToPosition(self.getFirstValue()) + + def setFirstPosition(self, position): + """Set the position of the first slider + + The position is adjusted to valid values + + :param int position: + """ + self.setFirstValue(self.__positionToValue(position)) + + def getSecondPosition(self): + """Returns second slider position + + :rtype: int + """ + return self.__valueToPosition(self.getSecondValue()) + + def setSecondPosition(self, position): + """Set the position of the second slider + + The position is adjusted to valid values + + :param int position: + """ + self.setSecondValue(self.__positionToValue(position)) + + def getPositions(self): + """Returns slider positions (first, second) + + :rtype: List[int] + """ + return self.getFirstPosition(), self.getSecondPosition() + + def setPositions(self, first, second): + """Set the position of both sliders at once + + First is clipped to the slider range: [0, max]. + Second is clipped to valid values: [first, max] + + :param int first: + :param int second: + """ + self.setValues(self.__positionToValue(first), + self.__positionToValue(second)) + + # Value (float) API + + def __emitPositionChanged(self, *args, **kwargs): + self.sigPositionChanged.emit(*self.getPositions()) + + def __rangeChanged(self): + """Handle change of value range""" + emit = self.__setValues(*self.getValues()) + self.sigRangeChanged.emit(*self.getRange()) + if emit: + self.sigValueChanged.emit(*self.getValues()) + + def getMinimum(self): + """Returns the minimum value of the slider range + + :rtype: float + """ + return self.__minValue + + def setMinimum(self, minimum): + """Set the minimum value of the slider range. + + It eventually adjusts maximum. + Slider positions remains unchanged and slider values are modified. + + :param float minimum: + """ + minimum = float(minimum) + if minimum != self.getMinimum(): + if minimum > self.getMaximum(): + self.__maxValue = minimum + self.__minValue = minimum + self.__rangeChanged() + + def getMaximum(self): + """Returns the maximum value of the slider range + + :rtype: float + """ + return self.__maxValue + + def setMaximum(self, maximum): + """Set the maximum value of the slider range + + It eventually adjusts minimum. + Slider positions remains unchanged and slider values are modified. + + :param float maximum: + """ + maximum = float(maximum) + if maximum != self.getMaximum(): + if maximum < self.getMinimum(): + self.__minValue = maximum + self.__maxValue = maximum + self.__rangeChanged() + + def getRange(self): + """Returns the range of values (min, max) + + :rtype: List[float] + """ + return self.getMinimum(), self.getMaximum() + + def setRange(self, minimum, maximum): + """Set the range of values. + + If maximum is lower than minimum, minimum is the only valid value. + Slider positions remains unchanged and slider values are modified. + + :param float minimum: + :param float maximum: + """ + minimum, maximum = float(minimum), float(maximum) + if minimum != self.getMinimum() or maximum != self.getMaximum(): + self.__minValue = minimum + self.__maxValue = max(maximum, minimum) + self.__rangeChanged() + + def getFirstValue(self): + """Returns the value of the first slider + + :rtype: float + """ + return self.__firstValue + + def __clipFirstValue(self, value, max_=None): + """Clip first value to range and steps + + :param float value: + :param float max_: Alternative maximum to use + """ + if max_ is None: + max_ = self.getSecondValue() + value = min(max(self.getMinimum(), float(value)), max_) + if self.getPositionCount() is not None: # Clip to steps + value = self.__positionToValue(self.__valueToPosition(value)) + return value + + def setFirstValue(self, value): + """Set the value of the first slider + + Value is clipped to valid values. + + :param float value: + """ + value = self.__clipFirstValue(value) + if value != self.getFirstValue(): + self.__firstValue = value + self.update() + self.sigValueChanged.emit(*self.getValues()) + + def getSecondValue(self): + """Returns the value of the second slider + + :rtype: float + """ + return self.__secondValue + + def __clipSecondValue(self, value): + """Clip second value to range and steps + + :param float value: + """ + value = min(max(self.getFirstValue(), float(value)), self.getMaximum()) + if self.getPositionCount() is not None: # Clip to steps + value = self.__positionToValue(self.__valueToPosition(value)) + return value + + def setSecondValue(self, value): + """Set the value of the second slider + + Value is clipped to valid values. + + :param float value: + """ + value = self.__clipSecondValue(value) + if value != self.getSecondValue(): + self.__secondValue = value + self.update() + self.sigValueChanged.emit(*self.getValues()) + + def getValues(self): + """Returns value of both sliders at once + + :return: (first value, second value) + :rtype: List[float] + """ + return self.getFirstValue(), self.getSecondValue() + + def setValues(self, first, second): + """Set values for both sliders at once + + First is clipped to the slider range: [minimum, maximum]. + Second is clipped to valid values: [first, maximum] + + :param float first: + :param float second: + """ + if self.__setValues(first, second): + self.sigValueChanged.emit(*self.getValues()) + + def __setValues(self, first, second): + """Set values for both sliders at once + + First is clipped to the slider range: [minimum, maximum]. + Second is clipped to valid values: [first, maximum] + + :param float first: + :param float second: + :return: True if values has changed, False otherwise + :rtype: bool + """ + first = self.__clipFirstValue(first, self.getMaximum()) + second = self.__clipSecondValue(second) + values = first, second + + if self.getValues() != values: + self.__firstValue, self.__secondValue = values + self.update() + return True + return False + + # Groove API + + def getGroovePixmap(self): + """Returns the pixmap displayed in the slider groove if any. + + :rtype: Union[QPixmap,None] + """ + return self.__pixmap + + def setGroovePixmap(self, pixmap): + """Set the pixmap displayed in the slider groove. + + :param Union[QPixmap,None] pixmap: The QPixmap to use or None to unset. + """ + assert pixmap is None or isinstance(pixmap, qt.QPixmap) + self.__pixmap = pixmap + self.update() + + def setGroovePixmapFromProfile(self, profile, colormap=None): + """Set the pixmap displayed in the slider groove from histogram values. + + :param Union[numpy.ndarray,None] profile: + 1D array of values to display + :param Union[Colormap,str] colormap: + The colormap name or object to convert profile values to colors + """ + if profile is None: + self.setSliderPixmap(None) + return + + profile = numpy.array(profile, copy=False) + + if profile.size == 0: + self.setSliderPixmap(None) + return + + if colormap is None: + colormap = colors.Colormap() + elif isinstance(colormap, str): + colormap = colors.Colormap(name=colormap) + assert isinstance(colormap, colors.Colormap) + + rgbImage = colormap.applyToData(profile.reshape(1, -1))[:, :, :3] + qimage = convertArrayToQImage(rgbImage) + qpixmap = qt.QPixmap.fromImage(qimage) + self.setGroovePixmap(qpixmap) + + # Handle interaction + + def mousePressEvent(self, event): + super(RangeSlider, self).mousePressEvent(event) + + if event.buttons() == qt.Qt.LeftButton: + picked = None + for name in ('first', 'second'): + area = self.__sliderRect(name) + if area.contains(event.pos()): + picked = name + break + + self.__moving = picked + self.__focus = picked + self.update() + + def mouseMoveEvent(self, event): + super(RangeSlider, self).mouseMoveEvent(event) + + if self.__moving is not None: + position = self.__xPixelToPosition(event.pos().x()) + if self.__moving == 'first': + self.setFirstPosition(position) + else: + self.setSecondPosition(position) + + def mouseReleaseEvent(self, event): + super(RangeSlider, self).mouseReleaseEvent(event) + + if event.button() == qt.Qt.LeftButton and self.__moving is not None: + self.__moving = None + self.update() + + def focusOutEvent(self, event): + if self.__focus is not None: + self.__focus = None + self.update() + super(RangeSlider, self).focusOutEvent(event) + + def keyPressEvent(self, event): + key = event.key() + if event.modifiers() == qt.Qt.NoModifier and self.__focus is not None: + if key in (qt.Qt.Key_Left, qt.Qt.Key_Down): + if self.__focus == 'first': + self.setFirstPosition(self.getFirstPosition() - 1) + else: + self.setSecondPosition(self.getSecondPosition() - 1) + return # accept event + elif key in (qt.Qt.Key_Right, qt.Qt.Key_Up): + if self.__focus == 'first': + self.setFirstPosition(self.getFirstPosition() + 1) + else: + self.setSecondPosition(self.getSecondPosition() + 1) + return # accept event + + super(RangeSlider, self).keyPressEvent(event) + + # Handle resize + + def resizeEvent(self, event): + super(RangeSlider, self).resizeEvent(event) + + # If no step, signal position update when width change + if (self.getPositionCount() is None and + event.size().width() != event.oldSize().width()): + self.sigPositionChanged.emit(*self.getPositions()) + + # Handle repaint + + def __xPixelToPosition(self, x): + """Convert position in pixel to slider position + + :param int x: X in pixel coordinates + :rtype: int + """ + sliderArea = self.__sliderAreaRect() + maxPos = self.__getCurrentPositionCount() - 1 + position = maxPos * (x - sliderArea.left()) / (sliderArea.width() - 1) + return int(position + 0.5) + + def __sliderRect(self, name): + """Returns rectangle corresponding to slider in pixels + + :param str name: 'first' or 'second' + :rtype: QRect + :raise ValueError: If wrong name + """ + assert name in ('first', 'second') + if name == 'first': + offset = - self._SLIDER_WIDTH + position = self.getFirstPosition() + elif name == 'second': + offset = 0 + position = self.getSecondPosition() + else: + raise ValueError('Unknown name') + + sliderArea = self.__sliderAreaRect() + + maxPos = self.__getCurrentPositionCount() - 1 + xOffset = int((sliderArea.width() - 1) * position / maxPos) + xPos = sliderArea.left() + xOffset + offset + + return qt.QRect(xPos, + sliderArea.top(), + self._SLIDER_WIDTH, + sliderArea.height()) + + def __drawArea(self): + return self.rect().adjusted(self._SLIDER_WIDTH, 0, + -self._SLIDER_WIDTH, 0) + + def __sliderAreaRect(self): + return self.__drawArea().adjusted(self._SLIDER_WIDTH / 2., + 0, + -self._SLIDER_WIDTH / 2., + 0) + + def __pixMapRect(self): + return self.__sliderAreaRect().adjusted(0, + self._PIXMAP_VOFFSET, + 0, + -self._PIXMAP_VOFFSET) + + def paintEvent(self, event): + painter = qt.QPainter(self) + + style = qt.QApplication.style() + + area = self.__drawArea() + pixmapRect = self.__pixMapRect() + + option = qt.QStyleOptionProgressBar() + option.initFrom(self) + option.rect = area + option.state = ((self.isEnabled() and qt.QStyle.State_Enabled) + or qt.QStyle.State_None) + style.drawControl(qt.QStyle.CE_ProgressBarGroove, + option, + painter, + self) + + painter.save() + pen = painter.pen() + pen.setWidth(1) + pen.setColor(qt.Qt.black if self.isEnabled() else qt.Qt.gray) + painter.setPen(pen) + painter.drawRect(pixmapRect.adjusted(-1, -1, 1, 1)) + painter.restore() + + if self.isEnabled() and self.__pixmap is not None: + painter.drawPixmap(area.adjusted(self._SLIDER_WIDTH / 2, + self._PIXMAP_VOFFSET, + -self._SLIDER_WIDTH / 2 + 1, + -self._PIXMAP_VOFFSET + 1), + self.__pixmap, + self.__pixmap.rect()) + + for name in ('first', 'second'): + rect = self.__sliderRect(name) + option = qt.QStyleOptionButton() + option.initFrom(self) + option.icon = self.__icons[name] + option.iconSize = rect.size() * 0.7 + if option.state & qt.QStyle.State_MouseOver: + option.state ^= qt.QStyle.State_MouseOver + if self.__focus == name: + option.state |= qt.QStyle.State_HasFocus + elif option.state & qt.QStyle.State_HasFocus: + option.state ^= qt.QStyle.State_HasFocus + option.rect = rect + style.drawControl( + qt.QStyle.CE_PushButton, option, painter, self) + + def sizeHint(self): + return qt.QSize(200, self.minimumHeight()) diff --git a/silx/gui/widgets/__init__.py b/silx/gui/widgets/__init__.py index 034f4d3..9d0299d 100644 --- a/silx/gui/widgets/__init__.py +++ b/silx/gui/widgets/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# Copyright (c) 2016-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 @@ -22,6 +22,6 @@ # THE SOFTWARE. # # ###########################################################################*/ -"""This package provides a few simple Qt widgets that rely only on a Qt wrapper -for Python (PyQt5, PyQt4 or PySide). No other optional dependencies of *silx* -should be required.""" +"""This package provides a few simple Qt widgets that rely only on a Qt binding for Python. + +No other optional dependencies of *silx* should be required.""" diff --git a/silx/gui/widgets/test/__init__.py b/silx/gui/widgets/test/__init__.py index 5e62393..8d179bc 100644 --- a/silx/gui/widgets/test/__init__.py +++ b/silx/gui/widgets/test/__init__.py @@ -31,6 +31,8 @@ from . import test_hierarchicaltableview from . import test_printpreview from . import test_framebrowser from . import test_boxlayoutdockwidget +from . import test_rangeslider +from . import test_flowlayout __authors__ = ["V. Valls", "P. Knobel"] __license__ = "MIT" @@ -47,5 +49,7 @@ def suite(): test_hierarchicaltableview.suite(), test_framebrowser.suite(), test_boxlayoutdockwidget.suite(), + test_rangeslider.suite(), + test_flowlayout.suite(), ]) return test_suite diff --git a/silx/gui/widgets/test/test_boxlayoutdockwidget.py b/silx/gui/widgets/test/test_boxlayoutdockwidget.py index 0df262b..9a93ca1 100644 --- a/silx/gui/widgets/test/test_boxlayoutdockwidget.py +++ b/silx/gui/widgets/test/test_boxlayoutdockwidget.py @@ -32,7 +32,7 @@ import unittest from silx.gui.widgets.BoxLayoutDockWidget import BoxLayoutDockWidget from silx.gui import qt -from silx.gui.test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt class TestBoxLayoutDockWidget(TestCaseQt): diff --git a/silx/gui/widgets/test/test_flowlayout.py b/silx/gui/widgets/test/test_flowlayout.py new file mode 100644 index 0000000..1497945 --- /dev/null +++ b/silx/gui/widgets/test/test_flowlayout.py @@ -0,0 +1,77 @@ +# 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. +# +# ###########################################################################*/ +"""Tests for FlowLayout""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "02/08/2018" + +import unittest + +from silx.gui.widgets.FlowLayout import FlowLayout +from silx.gui import qt +from silx.gui.utils.testutils import TestCaseQt + + +class TestFlowLayout(TestCaseQt): + """Tests for FlowLayout""" + + def setUp(self): + """Create and show a widget""" + self.widget = qt.QWidget() + self.widget.show() + self.qWaitForWindowExposed(self.widget) + + def tearDown(self): + """Delete widget""" + self.widget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.widget.close() + del self.widget + self.qapp.processEvents() + + def test(self): + """Basic tests""" + layout = FlowLayout() + self.widget.setLayout(layout) + + layout.addWidget(qt.QLabel('first')) + layout.addWidget(qt.QLabel('second')) + self.assertEqual(layout.count(), 2) + + layout.setHorizontalSpacing(10) + self.assertEqual(layout.horizontalSpacing(), 10) + layout.setVerticalSpacing(5) + self.assertEqual(layout.verticalSpacing(), 5) + + +def suite(): + loader = unittest.defaultTestLoader.loadTestsFromTestCase + test_suite = unittest.TestSuite() + test_suite.addTest(loader(TestFlowLayout)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/widgets/test/test_framebrowser.py b/silx/gui/widgets/test/test_framebrowser.py index 9988d16..2dfd302 100644 --- a/silx/gui/widgets/test/test_framebrowser.py +++ b/silx/gui/widgets/test/test_framebrowser.py @@ -29,7 +29,7 @@ __date__ = "23/03/2018" import unittest -from silx.gui.test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from silx.gui.widgets.FrameBrowser import FrameBrowser diff --git a/silx/gui/widgets/test/test_hierarchicaltableview.py b/silx/gui/widgets/test/test_hierarchicaltableview.py index b3d37ed..9fad54d 100644 --- a/silx/gui/widgets/test/test_hierarchicaltableview.py +++ b/silx/gui/widgets/test/test_hierarchicaltableview.py @@ -29,7 +29,7 @@ __date__ = "07/04/2017" import unittest from .. import HierarchicalTableView -from ...test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from silx.gui import qt diff --git a/silx/gui/widgets/test/test_periodictable.py b/silx/gui/widgets/test/test_periodictable.py index c6bed81..3e7eb16 100644 --- a/silx/gui/widgets/test/test_periodictable.py +++ b/silx/gui/widgets/test/test_periodictable.py @@ -29,7 +29,7 @@ __date__ = "05/12/2016" import unittest from .. import PeriodicTable -from ...test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from silx.gui import qt diff --git a/silx/gui/widgets/test/test_printpreview.py b/silx/gui/widgets/test/test_printpreview.py index ecb165a..3c29171 100644 --- a/silx/gui/widgets/test/test_printpreview.py +++ b/silx/gui/widgets/test/test_printpreview.py @@ -30,7 +30,7 @@ __date__ = "19/07/2017" import unittest -from silx.gui.test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from silx.gui.widgets.PrintPreview import PrintPreviewDialog from silx.gui import qt diff --git a/silx/gui/widgets/test/test_rangeslider.py b/silx/gui/widgets/test/test_rangeslider.py new file mode 100644 index 0000000..2829050 --- /dev/null +++ b/silx/gui/widgets/test/test_rangeslider.py @@ -0,0 +1,114 @@ +# 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. +# +# ###########################################################################*/ +"""Tests for RangeSlider""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "01/08/2018" + +import unittest + +from silx.gui import qt, colors +from silx.gui.widgets.RangeSlider import RangeSlider +from silx.gui.utils.testutils import TestCaseQt +from silx.utils.testutils import ParametricTestCase + + +class TestRangeSlider(TestCaseQt, ParametricTestCase): + """Tests for TestRangeSlider""" + + def setUp(self): + self.slider = RangeSlider() + self.slider.show() + self.qWaitForWindowExposed(self.slider) + + def tearDown(self): + self.slider.setAttribute(qt.Qt.WA_DeleteOnClose) + self.slider.close() + del self.slider + self.qapp.processEvents() + + def testRangeValue(self): + """Test slider range and values""" + + # Play with range + self.slider.setRange(1, 2) + self.assertEqual(self.slider.getRange(), (1., 2.)) + self.assertEqual(self.slider.getValues(), (1., 1.)) + + self.slider.setMinimum(-1) + self.assertEqual(self.slider.getRange(), (-1., 2.)) + self.assertEqual(self.slider.getValues(), (1., 1.)) + + self.slider.setMaximum(0) + self.assertEqual(self.slider.getRange(), (-1., 0.)) + self.assertEqual(self.slider.getValues(), (0., 0.)) + + # Play with values + self.slider.setFirstValue(-2.) + self.assertEqual(self.slider.getValues(), (-1., 0.)) + + self.slider.setFirstValue(-0.5) + self.assertEqual(self.slider.getValues(), (-0.5, 0.)) + + self.slider.setSecondValue(2.) + self.assertEqual(self.slider.getValues(), (-0.5, 0.)) + + self.slider.setSecondValue(-0.1) + self.assertEqual(self.slider.getValues(), (-0.5, -0.1)) + + def testStepCount(self): + """Test related to step count""" + self.slider.setPositionCount(11) + self.assertEqual(self.slider.getPositionCount(), 11) + self.slider.setFirstValue(0.32) + self.assertEqual(self.slider.getFirstValue(), 0.3) + self.assertEqual(self.slider.getFirstPosition(), 3) + + self.slider.setPositionCount(3) # Value is adjusted + self.assertEqual(self.slider.getValues(), (0.5, 1.)) + self.assertEqual(self.slider.getPositions(), (1, 2)) + + def testGroove(self): + """Test Groove pixmap""" + profile = list(range(100)) + + for cmap in ('jet', colors.Colormap('viridis')): + with self.subTest(str(cmap)): + self.slider.setGroovePixmapFromProfile(profile, cmap) + pixmap = self.slider.getGroovePixmap() + self.assertIsInstance(pixmap, qt.QPixmap) + self.assertEqual(pixmap.width(), len(profile)) + + +def suite(): + loader = unittest.defaultTestLoader.loadTestsFromTestCase + test_suite = unittest.TestSuite() + test_suite.addTest(loader(TestRangeSlider)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/widgets/test/test_tablewidget.py b/silx/gui/widgets/test/test_tablewidget.py index 5ad0a06..6822aef 100644 --- a/silx/gui/widgets/test/test_tablewidget.py +++ b/silx/gui/widgets/test/test_tablewidget.py @@ -30,7 +30,7 @@ __date__ = "05/12/2016" import unittest -from silx.gui.test.utils import TestCaseQt +from silx.gui.utils.testutils import TestCaseQt from silx.gui.widgets.TableWidget import TableWidget diff --git a/silx/gui/widgets/test/test_threadpoolpushbutton.py b/silx/gui/widgets/test/test_threadpoolpushbutton.py index a8618a4..e92eb02 100644 --- a/silx/gui/widgets/test/test_threadpoolpushbutton.py +++ b/silx/gui/widgets/test/test_threadpoolpushbutton.py @@ -32,8 +32,8 @@ __date__ = "17/01/2018" import unittest import time from silx.gui import qt -from silx.gui.test.utils import TestCaseQt -from silx.gui.test.utils import SignalListener +from silx.gui.utils.testutils import TestCaseQt +from silx.gui.utils.testutils import SignalListener from silx.gui.widgets.ThreadPoolPushButton import ThreadPoolPushButton from silx.utils.testutils import TestLogging -- cgit v1.2.3