diff options
author | Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> | 2018-07-31 16:22:25 +0200 |
---|---|---|
committer | Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> | 2018-07-31 16:22:25 +0200 |
commit | 159ef14fb9e198bb0066ea14e6b980f065de63dd (patch) | |
tree | bc37c7d4ba09ee59deb708897fa0571709aec293 /silx | |
parent | 270d5ddc31c26b62379e3caa9044dd75ccc71847 (diff) |
New upstream version 0.8.0+dfsg
Diffstat (limited to 'silx')
279 files changed, 210406 insertions, 61958 deletions
diff --git a/silx/__init__.py b/silx/__init__.py index 8dab7e1..2892572 100644 --- a/silx/__init__.py +++ b/silx/__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 @@ -22,16 +22,31 @@ # THE SOFTWARE. # # ###########################################################################*/ +"""The silx package contains the following main sub-packages: + +- silx.gui: Qt widgets for data visualization and data file browsing +- silx.image: Some processing functions for 2D images +- silx.io: Reading and writing data files (HDF5/NeXus, SPEC, ...) +- silx.math: Some processing functions for 1D, 2D, 3D, nD arrays +- silx.opencl: OpenCL-based data processing +- silx.sx: High-level silx functions suited for (I)Python console. +- silx.utils: Miscellaneous convenient functions + +See silx documentation: http://www.silx.org/doc/silx/latest/ +""" from __future__ import absolute_import, print_function, division __authors__ = ["Jérôme Kieffer"] __license__ = "MIT" -__date__ = "23/05/2016" +__date__ = "26/04/2018" import os as _os import logging as _logging +from ._config import Config as _Config +config = _Config() +"""Global configuration shared with the whole library""" # Attach a do nothing logging handler for silx _logging.getLogger(__name__).addHandler(_logging.NullHandler()) diff --git a/silx/__main__.py b/silx/__main__.py index 8323b03..a971390 100644 --- a/silx/__main__.py +++ b/silx/__main__.py @@ -32,7 +32,7 @@ Your environment should provide a command `silx`. You can reach help with __authors__ = ["V. Valls", "P. Knobel"] __license__ = "MIT" -__date__ = "29/06/2017" +__date__ = "07/06/2018" import logging @@ -54,7 +54,7 @@ def main(): """ launcher = Launcher(prog="silx", version=silx._version.version) launcher.add_command("view", - module_name="silx.app.view", + module_name="silx.app.view.main", description="Browse a data file with a GUI") launcher.add_command("convert", module_name="silx.app.convert", diff --git a/silx/_config.py b/silx/_config.py new file mode 100644 index 0000000..932aec1 --- /dev/null +++ b/silx/_config.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python +# 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. +# +# ###########################################################################*/ +"""This module contains library wide configuration. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "26/04/2018" + + +class Config(object): + """ + Class containing shared global configuration for the silx library. + + .. versionadded:: 0.8 + """ + + DEFAULT_PLOT_BACKEND = "matplotlib" + """Default plot backend. + + It will be used as default backend for all the next created PlotWidget. + + This attribute can be set with: + + - 'matplotlib' (default) or 'mpl' + - 'opengl', 'gl' + - 'none' + - A :class:`silx.gui.plot.backend.BackendBase.BackendBase` class + - A callable returning backend class or binding name + + .. versionadded:: 0.8 + """ + + DEFAULT_COLORMAP_NAME = 'gray' + """Default LUT for the plot widgets. + + The available list of names are availaible in the module + :module:`silx.gui.colors`. + + .. versionadded:: 0.8 + """ + + DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = 'upward' + """Default Y-axis orientation for plot widget displaying images. + + This attribute can be set with: + + - 'upward' (default), which set the origin to the bottom with an upward + orientation. + - 'downward', which set the origin to the top with a backward orientation. + + It will have an influence on: + + - :class:`silx.gui.plot.StackWidget` + - :class:`silx.gui.plot.ComplexImageView` + - :class:`silx.gui.plot.Plot2D` + - :class:`silx.gui.plot.ImageView` + + .. versionadded:: 0.8 + """ diff --git a/silx/app/__init__.py b/silx/app/__init__.py index 9cbb8bb..3af680c 100644 --- a/silx/app/__init__.py +++ b/silx/app/__init__.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 @@ -22,7 +22,7 @@ # THE SOFTWARE. # # ###########################################################################*/ -"""Application provided by the launcher""" +"""This package contains the application provided by the launcher""" __authors__ = ["V. Valls"] __license__ = "MIT" diff --git a/silx/app/setup.py b/silx/app/setup.py index bf6f3af..85c3662 100644 --- a/silx/app/setup.py +++ b/silx/app/setup.py @@ -24,7 +24,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "30/03/2017" +__date__ = "23/04/2018" from numpy.distutils.misc_util import Configuration @@ -32,6 +32,7 @@ from numpy.distutils.misc_util import Configuration def configuration(parent_package='', top_path=None): config = Configuration('app', parent_package, top_path) config.add_subpackage('test') + config.add_subpackage('view') return config diff --git a/silx/app/test/__init__.py b/silx/app/test/__init__.py index 0c22386..7c91134 100644 --- a/silx/app/test/__init__.py +++ b/silx/app/test/__init__.py @@ -24,11 +24,11 @@ # ###########################################################################*/ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "30/03/2017" +__date__ = "06/06/2018" import unittest -from . import test_view +from ..view import test as test_view from . import test_convert diff --git a/silx/app/test/test_view.py b/silx/app/test/test_view.py deleted file mode 100644 index aeba0cc..0000000 --- a/silx/app/test/test_view.py +++ /dev/null @@ -1,146 +0,0 @@ -# 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. -# -# ###########################################################################*/ -"""Module testing silx.app.view""" - -__authors__ = ["V. Valls"] -__license__ = "MIT" -__date__ = "09/11/2017" - - -import unittest -import sys -from silx.test.utils import test_options - - -if not test_options.WITH_QT_TEST: - view = None - TestCaseQt = unittest.TestCase -else: - from silx.gui.test.utils import TestCaseQt - from .. import view - - -class QApplicationMock(object): - - def __init__(self, args): - pass - - def exec_(self): - return 0 - - def deleteLater(self): - pass - - -class ViewerMock(object): - - def __init__(self): - super(ViewerMock, self).__init__() - self.__class__._instance = self - self.appendFileCalls = [] - - def appendFile(self, filename): - self.appendFileCalls.append(filename) - - def setAttribute(self, attr, value): - pass - - def resize(self, size): - pass - - def show(self): - pass - - -@unittest.skipUnless(test_options.WITH_QT_TEST, test_options.WITH_QT_TEST_REASON) -class TestLauncher(unittest.TestCase): - """Test command line parsing""" - - @classmethod - def setUpClass(cls): - super(TestLauncher, cls).setUpClass() - cls._Viewer = view.Viewer - view.Viewer = ViewerMock - cls._QApplication = view.qt.QApplication - view.qt.QApplication = QApplicationMock - - @classmethod - def tearDownClass(cls): - view.Viewer = cls._Viewer - view.qt.QApplication = cls._QApplication - cls._Viewer = None - super(TestLauncher, cls).tearDownClass() - - def testHelp(self): - # option -h must cause a raise SystemExit or a return 0 - try: - result = view.main(["view", "--help"]) - except SystemExit as e: - result = e.args[0] - self.assertEqual(result, 0) - - def testWrongOption(self): - try: - result = view.main(["view", "--foo"]) - except SystemExit as e: - result = e.args[0] - self.assertNotEqual(result, 0) - - def testWrongFile(self): - try: - result = view.main(["view", "__file.not.found__"]) - except SystemExit as e: - result = e.args[0] - self.assertEqual(result, 0) - - def testFile(self): - # sys.executable is an existing readable file - result = view.main(["view", sys.executable]) - self.assertEqual(result, 0) - viewer = ViewerMock._instance - self.assertEqual(viewer.appendFileCalls, [sys.executable]) - ViewerMock._instance = None - - -class TestViewer(TestCaseQt): - """Test for Viewer class""" - - @unittest.skipUnless(test_options.WITH_QT_TEST, test_options.WITH_QT_TEST_REASON) - def testConstruct(self): - if view is not None: - widget = view.Viewer() - self.qWaitForWindowExposed(widget) - - -def suite(): - test_suite = unittest.TestSuite() - loader = unittest.defaultTestLoader.loadTestsFromTestCase - test_suite.addTest(loader(TestViewer)) - test_suite.addTest(loader(TestLauncher)) - return test_suite - - -if __name__ == '__main__': - unittest.main(defaultTest='suite') diff --git a/silx/app/view.py b/silx/app/view.py deleted file mode 100644 index bc4e30c..0000000 --- a/silx/app/view.py +++ /dev/null @@ -1,314 +0,0 @@ -# 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. -# -# ############################################################################*/ -"""Browse a data file with a GUI""" - -__authors__ = ["V. Valls"] -__license__ = "MIT" -__date__ = "28/02/2018" - -import sys -import os -import argparse -import logging -import collections - -_logger = logging.getLogger(__name__) -"""Module logger""" - -if "silx.gui.qt" not in sys.modules: - # Try first PyQt5 and not the priority imposed by silx.gui.qt. - # To avoid problem with unittests we only do it if silx.gui.qt is not - # yet loaded. - # TODO: Can be removed for silx 0.8, as it should be the default binding - # of the silx library. - try: - import PyQt5.QtCore - except ImportError: - pass - -from silx.gui import qt - - -class Viewer(qt.QMainWindow): - """ - This window allows to browse a data file like images or HDF5 and it's - content. - """ - - def __init__(self): - """ - :param files_: List of HDF5 or Spec files (pathes or - :class:`silx.io.spech5.SpecH5` or :class:`h5py.File` - instances) - """ - # Import it here to be sure to use the right logging level - import silx.gui.hdf5 - from silx.gui.data.DataViewerFrame import DataViewerFrame - - qt.QMainWindow.__init__(self) - self.setWindowTitle("Silx viewer") - - self.__asyncload = False - self.__dialogState = None - self.__treeview = silx.gui.hdf5.Hdf5TreeView(self) - """Silx HDF5 TreeView""" - - self.__dataViewer = DataViewerFrame(self) - vSpliter = qt.QSplitter(qt.Qt.Vertical) - vSpliter.addWidget(self.__dataViewer) - vSpliter.setSizes([10, 0]) - - spliter = qt.QSplitter(self) - spliter.addWidget(self.__treeview) - spliter.addWidget(vSpliter) - spliter.setStretchFactor(1, 1) - - main_panel = qt.QWidget(self) - layout = qt.QVBoxLayout() - layout.addWidget(spliter) - layout.setStretchFactor(spliter, 1) - main_panel.setLayout(layout) - - self.setCentralWidget(main_panel) - - model = self.__treeview.selectionModel() - model.selectionChanged.connect(self.displayData) - self.__treeview.addContextMenuCallback(self.closeAndSyncCustomContextMenu) - - treeModel = self.__treeview.findHdf5TreeModel() - columns = list(treeModel.COLUMN_IDS) - columns.remove(treeModel.DESCRIPTION_COLUMN) - columns.remove(treeModel.NODE_COLUMN) - self.__treeview.header().setSections(columns) - - self.createActions() - self.createMenus() - - def createActions(self): - action = qt.QAction("E&xit", self) - action.setShortcuts(qt.QKeySequence.Quit) - action.setStatusTip("Exit the application") - action.triggered.connect(self.close) - self._exitAction = action - - action = qt.QAction("&Open", self) - action.setStatusTip("Open a file") - action.triggered.connect(self.open) - self._openAction = action - - action = qt.QAction("&About", self) - action.setStatusTip("Show the application's About box") - action.triggered.connect(self.about) - self._aboutAction = action - - def createMenus(self): - fileMenu = self.menuBar().addMenu("&File") - fileMenu.addAction(self._openAction) - fileMenu.addSeparator() - fileMenu.addAction(self._exitAction) - helpMenu = self.menuBar().addMenu("&Help") - helpMenu.addAction(self._aboutAction) - - def open(self): - dialog = self.createFileDialog() - if self.__dialogState is None: - currentDirectory = os.getcwd() - dialog.setDirectory(currentDirectory) - else: - dialog.restoreState(self.__dialogState) - - result = dialog.exec_() - if not result: - return - - self.__dialogState = dialog.saveState() - - filenames = dialog.selectedFiles() - for filename in filenames: - self.appendFile(filename) - - def createFileDialog(self): - dialog = qt.QFileDialog(self) - dialog.setWindowTitle("Open") - dialog.setModal(True) - - # NOTE: hdf5plugin have to be loaded before - import silx.io - extensions = collections.OrderedDict() - for description, ext in silx.io.supported_extensions().items(): - extensions[description] = " ".join(sorted(list(ext))) - - # NOTE: hdf5plugin have to be loaded before - import fabio - if fabio is not None: - extensions["NeXus layout from EDF files"] = "*.edf" - extensions["NeXus layout from TIFF image files"] = "*.tif *.tiff" - extensions["NeXus layout from CBF files"] = "*.cbf" - extensions["NeXus layout from MarCCD image files"] = "*.mccd" - - all_supported_extensions = set() - for name, exts in extensions.items(): - exts = exts.split(" ") - all_supported_extensions.update(exts) - all_supported_extensions = sorted(list(all_supported_extensions)) - - filters = [] - filters.append("All supported files (%s)" % " ".join(all_supported_extensions)) - for name, extension in extensions.items(): - filters.append("%s (%s)" % (name, extension)) - filters.append("All files (*)") - - dialog.setNameFilters(filters) - dialog.setFileMode(qt.QFileDialog.ExistingFiles) - return dialog - - def about(self): - from . import qtutils - qtutils.About.about(self, "Silx viewer") - - def appendFile(self, filename): - self.__treeview.findHdf5TreeModel().appendFile(filename) - - def displayData(self): - """Called to update the dataviewer with the selected data. - """ - selected = list(self.__treeview.selectedH5Nodes(ignoreBrokenLinks=False)) - if len(selected) == 1: - # Update the viewer for a single selection - data = selected[0] - self.__dataViewer.setData(data) - - def useAsyncLoad(self, useAsync): - self.__asyncload = useAsync - - def closeAndSyncCustomContextMenu(self, event): - """Called to populate the context menu - - :param silx.gui.hdf5.Hdf5ContextMenuEvent event: Event - containing expected information to populate the context menu - """ - selectedObjects = event.source().selectedH5Nodes(ignoreBrokenLinks=False) - menu = event.menu() - - if not menu.isEmpty(): - menu.addSeparator() - - # Import it here to be sure to use the right logging level - import h5py - for obj in selectedObjects: - if obj.ntype is h5py.File: - action = qt.QAction("Remove %s" % obj.local_filename, event.source()) - action.triggered.connect(lambda: self.__treeview.findHdf5TreeModel().removeH5pyObject(obj.h5py_object)) - menu.addAction(action) - action = qt.QAction("Synchronize %s" % obj.local_filename, event.source()) - action.triggered.connect(lambda: self.__treeview.findHdf5TreeModel().synchronizeH5pyObject(obj.h5py_object)) - menu.addAction(action) - - -def main(argv): - """ - Main function to launch the viewer as an application - - :param argv: Command line arguments - :returns: exit status - """ - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - 'files', - nargs=argparse.ZERO_OR_MORE, - help='Data file to show (h5 file, edf files, spec files)') - parser.add_argument( - '--debug', - dest="debug", - action="store_true", - default=False, - help='Set logging system in debug mode') - parser.add_argument( - '--use-opengl-plot', - dest="use_opengl_plot", - action="store_true", - default=False, - help='Use OpenGL for plots (instead of matplotlib)') - - options = parser.parse_args(argv[1:]) - - if options.debug: - logging.root.setLevel(logging.DEBUG) - - # - # Import most of the things here to be sure to use the right logging level - # - - try: - # it should be loaded before h5py - import hdf5plugin # noqa - except ImportError: - _logger.debug("Backtrace", exc_info=True) - hdf5plugin = None - - try: - import h5py - except ImportError: - _logger.debug("Backtrace", exc_info=True) - h5py = None - - if h5py is None: - message = "Module 'h5py' is not installed but is mandatory."\ - + " You can install it using \"pip install h5py\"." - _logger.error(message) - return -1 - - if hdf5plugin is None: - message = "Module 'hdf5plugin' is not installed. It supports some hdf5"\ - + " compressions. You can install it using \"pip install hdf5plugin\"." - _logger.warning(message) - - # - # Run the application - # - - if options.use_opengl_plot: - from silx.gui.plot import PlotWidget - PlotWidget.setDefaultBackend("opengl") - - app = qt.QApplication([]) - qt.QLocale.setDefault(qt.QLocale.c()) - - sys.excepthook = qt.exceptionHandler - window = Viewer() - window.setAttribute(qt.Qt.WA_DeleteOnClose, True) - window.resize(qt.QSize(640, 480)) - - for filename in options.files: - try: - window.appendFile(filename) - except IOError as e: - _logger.error(e.args[0]) - _logger.debug("Backtrace", exc_info=True) - - window.show() - result = app.exec_() - # remove ending warnings relative to QTimer - app.deleteLater() - return result diff --git a/silx/app/qtutils.py b/silx/app/view/About.py index 4c29c84..07306ef 100644 --- a/silx/app/qtutils.py +++ b/silx/app/view/About.py @@ -21,30 +21,14 @@ # THE SOFTWARE. # # ############################################################################*/ -"""Qt utils for Silx applications""" +"""About box for Silx viewer""" __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "22/09/2017" +__date__ = "05/06/2018" import sys -try: - # it should be loaded before h5py - import hdf5plugin # noqa -except ImportError: - hdf5plugin = None - -try: - import h5py -except ImportError: - h5py = None - -try: - import fabio -except ImportError: - fabio = None - from silx.gui import qt from silx.gui import icons @@ -158,9 +142,9 @@ class About(qt.QDialog): def __formatOptionalLibraries(name, isAvailable): """Utils to format availability of features""" if isAvailable: - template = '<b>%s</b> is <font color="green">installed</font>' + template = '<b>%s</b> is <font color="green">loaded</font>' else: - template = '<b>%s</b> is <font color="red">not installed</font>' + template = '<b>%s</b> is <font color="red">not loaded</font>' return template % name def __updateText(self): @@ -189,10 +173,15 @@ class About(qt.QDialog): Copyright (C) <a href="{esrf_url}">European Synchrotron Radiation Facility</a> </p> """ + + hdf5pluginLoaded = "hdf5plugin" in sys.modules + fabioLoaded = "fabio" in sys.modules + h5pyLoaded = "h5py" in sys.modules + optional_lib = [] - optional_lib.append(self.__formatOptionalLibraries("FabIO", fabio is not None)) - optional_lib.append(self.__formatOptionalLibraries("H5py", h5py is not None)) - optional_lib.append(self.__formatOptionalLibraries("hdf5plugin", hdf5plugin is not None)) + optional_lib.append(self.__formatOptionalLibraries("FabIO", fabioLoaded)) + optional_lib.append(self.__formatOptionalLibraries("H5py", h5pyLoaded)) + optional_lib.append(self.__formatOptionalLibraries("hdf5plugin", hdf5pluginLoaded)) # Access to the logo in SVG or PNG logo = icons.getQFile("../logo/silx") diff --git a/silx/app/view/ApplicationContext.py b/silx/app/view/ApplicationContext.py new file mode 100644 index 0000000..8693848 --- /dev/null +++ b/silx/app/view/ApplicationContext.py @@ -0,0 +1,194 @@ +# 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. +# +# ############################################################################*/ +"""Browse a data file with a GUI""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "23/05/2018" + +import weakref +import logging + +import silx +from silx.gui.data.DataViews import DataViewHooks +from silx.gui.colors import Colormap +from silx.gui.dialog.ColormapDialog import ColormapDialog + + +_logger = logging.getLogger(__name__) + + +class ApplicationContext(DataViewHooks): + """ + Store the conmtext of the application + + It overwrites the DataViewHooks to custom the use of the DataViewer for + the silx view application. + + - Create a single colormap shared with all the views + - Create a single colormap dialog shared with all the views + """ + + def __init__(self, parent, settings=None): + self.__parent = weakref.ref(parent) + self.__defaultColormap = None + self.__defaultColormapDialog = None + self.__settings = settings + self.__recentFiles = [] + + def getSettings(self): + """Returns actual application settings. + + :rtype: qt.QSettings + """ + return self.__settings + + def restoreLibrarySettings(self): + """Restore the library settings, which must be done early""" + settings = self.__settings + if settings is None: + return + settings.beginGroup("library") + plotBackend = settings.value("plot.backend", "") + plotImageYAxisOrientation = settings.value("plot-image.y-axis-orientation", "") + settings.endGroup() + + if plotBackend != "": + silx.config.DEFAULT_PLOT_BACKEND = plotBackend + if plotImageYAxisOrientation != "": + silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = plotImageYAxisOrientation + + def restoreSettings(self): + """Restore the settings of all the application""" + settings = self.__settings + if settings is None: + return + parent = self.__parent() + parent.restoreSettings(settings) + + settings.beginGroup("colormap") + byteArray = settings.value("default", None) + if byteArray is not None: + try: + colormap = Colormap() + colormap.restoreState(byteArray) + self.__defaultColormap = colormap + except Exception: + _logger.debug("Backtrace", exc_info=True) + settings.endGroup() + + self.__recentFiles = [] + settings.beginGroup("recent-files") + for index in range(1, 10 + 1): + if not settings.contains("path%d" % index): + break + filePath = settings.value("path%d" % index) + self.__recentFiles.append(filePath) + settings.endGroup() + + def saveSettings(self): + """Save the settings of all the application""" + settings = self.__settings + if settings is None: + return + parent = self.__parent() + parent.saveSettings(settings) + + if self.__defaultColormap is not None: + settings.beginGroup("colormap") + settings.setValue("default", self.__defaultColormap.saveState()) + settings.endGroup() + + settings.beginGroup("library") + settings.setValue("plot.backend", silx.config.DEFAULT_PLOT_BACKEND) + settings.setValue("plot-image.y-axis-orientation", silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION) + settings.endGroup() + + settings.beginGroup("recent-files") + for index in range(0, 11): + key = "path%d" % (index + 1) + if index < len(self.__recentFiles): + filePath = self.__recentFiles[index] + settings.setValue(key, filePath) + else: + settings.remove(key) + settings.endGroup() + + def getRecentFiles(self): + """Returns the list of recently opened files. + + The list is limited to the last 10 entries. The newest file path is + in first. + + :rtype: List[str] + """ + return self.__recentFiles + + def pushRecentFile(self, filePath): + """Push a new recent file to the list. + + If the file is duplicated in the list, all duplications are removed + before inserting the new filePath. + + If the list becan bigger than 10 items, oldest paths are removed. + + :param filePath: File path to push + """ + # Remove old occurencies + self.__recentFiles[:] = (f for f in self.__recentFiles if f != filePath) + self.__recentFiles.insert(0, filePath) + while len(self.__recentFiles) > 10: + self.__recentFiles.pop() + + def clearRencentFiles(self): + """Clear the history of the rencent files. + """ + self.__recentFiles[:] = [] + + def getColormap(self, view): + """Returns a default colormap. + + Override from DataViewHooks + + :rtype: Colormap + """ + if self.__defaultColormap is None: + self.__defaultColormap = Colormap(name="viridis") + return self.__defaultColormap + + def getColormapDialog(self, view): + """Returns a shared color dialog as default for all the views. + + Override from DataViewHooks + + :rtype: ColorDialog + """ + if self.__defaultColormapDialog is None: + parent = self.__parent() + if parent is None: + return None + dialog = ColormapDialog(parent=parent) + dialog.setModal(False) + self.__defaultColormapDialog = dialog + return self.__defaultColormapDialog diff --git a/silx/app/view/CustomNxdataWidget.py b/silx/app/view/CustomNxdataWidget.py new file mode 100644 index 0000000..02ae6c0 --- /dev/null +++ b/silx/app/view/CustomNxdataWidget.py @@ -0,0 +1,1008 @@ +# 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. +# +# ############################################################################*/ + +"""Widget to custom NXdata groups""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "15/06/2018" + +import logging +import numpy +import weakref + +from silx.gui import qt +from silx.io import commonh5 +import silx.io.nxdata +from silx.gui.hdf5._utils import Hdf5DatasetMimeData +from silx.gui.data.TextFormatter import TextFormatter +from silx.gui.hdf5.Hdf5Formatter import Hdf5Formatter +from silx.gui import icons + + +_logger = logging.getLogger(__name__) +_formatter = TextFormatter() +_hdf5Formatter = Hdf5Formatter(textFormatter=_formatter) + + +class _RowItems(qt.QStandardItem): + """Define the list of items used for a specific row.""" + + def type(self): + return qt.QStandardItem.UserType + 1 + + def getRowItems(self): + """Returns the list of items used for a specific row. + + The first item should be this class. + + :rtype: List[qt.QStandardItem] + """ + raise NotImplementedError() + + +class _DatasetItemRow(_RowItems): + """Define a row which can contain a dataset.""" + + def __init__(self, label="", dataset=None): + """Constructor""" + super(_DatasetItemRow, self).__init__(label) + self.setEditable(False) + self.setDropEnabled(False) + self.setDragEnabled(False) + + self.__name = qt.QStandardItem() + self.__name.setEditable(False) + self.__name.setDropEnabled(True) + + self.__type = qt.QStandardItem() + self.__type.setEditable(False) + self.__type.setDropEnabled(False) + self.__type.setDragEnabled(False) + + self.__shape = qt.QStandardItem() + self.__shape.setEditable(False) + self.__shape.setDropEnabled(False) + self.__shape.setDragEnabled(False) + + self.setDataset(dataset) + + def getDefaultFormatter(self): + """Get the formatter used to display dataset informations. + + :rtype: Hdf5Formatter + """ + return _hdf5Formatter + + def setDataset(self, dataset): + """Set the dataset stored in this item. + + :param Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset] dataset: + The dataset to store. + """ + self.__dataset = dataset + if self.__dataset is not None: + name = self.__dataset.name + + if silx.io.is_dataset(dataset): + type_ = self.getDefaultFormatter().humanReadableType(dataset) + shape = self.getDefaultFormatter().humanReadableShape(dataset) + + if dataset.shape is None: + icon_name = "item-none" + elif len(dataset.shape) < 4: + icon_name = "item-%ddim" % len(dataset.shape) + else: + icon_name = "item-ndim" + icon = icons.getQIcon(icon_name) + else: + type_ = "" + shape = "" + icon = qt.QIcon() + else: + name = "" + type_ = "" + shape = "" + icon = qt.QIcon() + + self.__icon = icon + self.__name.setText(name) + self.__name.setDragEnabled(self.__dataset is not None) + self.__name.setIcon(self.__icon) + self.__type.setText(type_) + self.__shape.setText(shape) + + parent = self.parent() + if parent is not None: + self.parent()._datasetUpdated() + + def getDataset(self): + """Returns the dataset stored within the item.""" + return self.__dataset + + def getRowItems(self): + """Returns the list of items used for a specific row. + + The first item should be this class. + + :rtype: List[qt.QStandardItem] + """ + return [self, self.__name, self.__type, self.__shape] + + +class _DatasetAxisItemRow(_DatasetItemRow): + """Define a row describing an axis.""" + + def __init__(self): + """Constructor""" + super(_DatasetAxisItemRow, self).__init__() + + def setAxisId(self, axisId): + """Set the id of the axis (the first axis is 0) + + :param int axisId: Identifier of this axis. + """ + self.__axisId = axisId + label = "Axis %d" % (axisId + 1) + self.setText(label) + + def getAxisId(self): + """Returns the identifier of this axis. + + :rtype: int + """ + return self.__axisId + + +class _NxDataItem(qt.QStandardItem): + """ + Define a custom NXdata. + """ + + def __init__(self): + """Constructor""" + qt.QStandardItem.__init__(self) + self.__error = None + self.__title = None + self.__axes = [] + self.__virtual = None + + item = _DatasetItemRow("Signal", None) + self.appendRow(item.getRowItems()) + self.__signal = item + + self.setEditable(False) + self.setDragEnabled(False) + self.setDropEnabled(False) + self.__setError(None) + + def getRowItems(self): + """Returns the list of items used for a specific row. + + The first item should be this class. + + :rtype: List[qt.QStandardItem] + """ + row = [self] + for _ in range(3): + item = qt.QStandardItem("") + item.setEditable(False) + item.setDragEnabled(False) + item.setDropEnabled(False) + row.append(item) + return row + + def _datasetUpdated(self): + """Called when the NXdata contained of the item have changed. + + It invalidates the NXdata stored and send an event `sigNxdataUpdated`. + """ + self.__virtual = None + self.__setError(None) + model = self.model() + if model is not None: + model.sigNxdataUpdated.emit(self.index()) + + def createVirtualGroup(self): + """Returns a new virtual Group using a NeXus NXdata structure to store + data + + :rtype: silx.io.commonh5.Group + """ + name = "" + if self.__title is not None: + name = self.__title + virtual = commonh5.Group(name) + virtual.attrs["NX_class"] = "NXdata" + + if self.__title is not None: + virtual.attrs["title"] = self.__title + + if self.__signal is not None: + signal = self.__signal.getDataset() + if signal is not None: + # Could be done using a link instead of a copy + node = commonh5.DatasetProxy("signal", target=signal) + virtual.attrs["signal"] = "signal" + virtual.add_node(node) + + axesAttr = [] + for i, axis in enumerate(self.__axes): + if axis is None: + name = "." + else: + axis = axis.getDataset() + if axis is None: + name = "." + else: + name = "axis%d" % i + node = commonh5.DatasetProxy(name, target=axis) + virtual.add_node(node) + axesAttr.append(name) + + if axesAttr != []: + virtual.attrs["axes"] = numpy.array(axesAttr) + + validator = silx.io.nxdata.NXdata(virtual) + if not validator.is_valid: + message = "<html>" + message += "This NXdata is not consistant" + message += "<ul>" + for issue in validator.issues: + message += "<li>%s</li>" % issue + message += "</ul>" + message += "</html>" + self.__setError(message) + else: + self.__setError(None) + return virtual + + def isValid(self): + """Returns true if the stored NXdata is valid + + :rtype: bool + """ + return self.__error is None + + def getVirtualGroup(self): + """Returns a cached virtual Group using a NeXus NXdata structure to + store data. + + If the stored NXdata was invalidated, :meth:`createVirtualGroup` is + internally called to update the cache. + + :rtype: silx.io.commonh5.Group + """ + if self.__virtual is None: + self.__virtual = self.createVirtualGroup() + return self.__virtual + + def getTitle(self): + """Returns the title of the NXdata + + :rtype: str + """ + return self.text() + + def setTitle(self, title): + """Set the title of the NXdata + + :param str title: The title of this NXdata + """ + self.setText(title) + + def __setError(self, error): + """Set the error message in case of the current state of the stored + NXdata is not valid. + + :param str error: Message to display + """ + self.__error = error + style = qt.QApplication.style() + if error is None: + message = "" + icon = style.standardIcon(qt.QStyle.SP_DirLinkIcon) + else: + message = error + icon = style.standardIcon(qt.QStyle.SP_MessageBoxCritical) + self.setIcon(icon) + self.setToolTip(message) + + def getError(self): + """Returns the error message in case the NXdata is not valid. + + :rtype: str""" + return self.__error + + def setSignalDataset(self, dataset): + """Set the dataset to use as signal with this NXdata. + + :param Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset] dataset: + The dataset to use as signal. + """ + + self.__signal.setDataset(dataset) + self._datasetUpdated() + + def getSignalDataset(self): + """Returns the dataset used as signal. + + :rtype: Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset] + """ + return self.__signal.getDataset() + + def setAxesDatasets(self, datasets): + """Set all the available dataset used as axes. + + Axes will be created or removed from the GUI in order to provide the + same amount of requested axes. + + A `None` element is an axes with no dataset. + + :param List[Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset,None]] datasets: + List of dataset to use as axes. + """ + for i, dataset in enumerate(datasets): + if i < len(self.__axes): + mustAppend = False + item = self.__axes[i] + else: + mustAppend = True + item = _DatasetAxisItemRow() + item.setAxisId(i) + item.setDataset(dataset) + if mustAppend: + self.__axes.append(item) + self.appendRow(item.getRowItems()) + + # Clean up extra axis + for i in range(len(datasets), len(self.__axes)): + item = self.__axes.pop(len(datasets)) + self.removeRow(item.row()) + + self._datasetUpdated() + + def getAxesDatasets(self): + """Returns available axes as dataset. + + A `None` element is an axes with no dataset. + + :rtype: List[Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset,None]] + """ + datasets = [] + for axis in self.__axes: + datasets.append(axis.getDataset()) + return datasets + + +class _Model(qt.QStandardItemModel): + """Model storing a list of custom NXdata items. + + Supports drag and drop of datasets. + """ + + sigNxdataUpdated = qt.Signal(qt.QModelIndex) + """Emitted when stored NXdata was edited""" + + def __init__(self, parent=None): + """Constructor""" + qt.QStandardItemModel.__init__(self, parent) + root = self.invisibleRootItem() + root.setDropEnabled(True) + root.setDragEnabled(False) + + def supportedDropActions(self): + """Inherited method to redefine supported drop actions.""" + return qt.Qt.CopyAction | qt.Qt.MoveAction + + def mimeTypes(self): + """Inherited method to redefine draggable mime types.""" + return [Hdf5DatasetMimeData.MIME_TYPE] + + def mimeData(self, indexes): + """ + Returns an object that contains serialized items of data corresponding + to the list of indexes specified. + + :param List[qt.QModelIndex] indexes: List of indexes + :rtype: qt.QMimeData + """ + if len(indexes) > 1: + return None + if len(indexes) == 0: + return None + + qindex = indexes[0] + qindex = self.index(qindex.row(), 0, parent=qindex.parent()) + item = self.itemFromIndex(qindex) + if isinstance(item, _DatasetItemRow): + dataset = item.getDataset() + if dataset is None: + return None + else: + mimeData = Hdf5DatasetMimeData(dataset=item.getDataset()) + else: + mimeData = None + return mimeData + + def dropMimeData(self, mimedata, action, row, column, parentIndex): + """Inherited method to handle a drop operation to this model.""" + if action == qt.Qt.IgnoreAction: + return True + + if mimedata.hasFormat(Hdf5DatasetMimeData.MIME_TYPE): + if row != -1 or column != -1: + # It is not a drop on a specific item + return False + item = self.itemFromIndex(parentIndex) + if item is None or item is self.invisibleRootItem(): + # Drop at the end + dataset = mimedata.dataset() + if silx.io.is_dataset(dataset): + self.createFromSignal(dataset) + elif silx.io.is_group(dataset): + nxdata = dataset + try: + self.createFromNxdata(nxdata) + except ValueError: + _logger.error("Error while dropping a group as an NXdata") + _logger.debug("Backtrace", exc_info=True) + return False + else: + _logger.error("Dropping a wrong object") + return False + else: + item = item.parent().child(item.row(), 0) + if not isinstance(item, _DatasetItemRow): + # Dropped at a bad place + return False + dataset = mimedata.dataset() + if silx.io.is_dataset(dataset): + item.setDataset(dataset) + else: + _logger.error("Dropping a wrong object") + return False + return True + + return False + + def __getNxdataByTitle(self, title): + """Returns an NXdata item by its title, else None. + + :rtype: Union[_NxDataItem,None] + """ + for row in range(self.rowCount()): + qindex = self.index(row, 0) + item = self.itemFromIndex(qindex) + if item.getTitle() == title: + return item + return None + + def findFreeNxdataTitle(self): + """Returns an NXdata title which is not yet used. + + :rtype: str + """ + for i in range(self.rowCount() + 1): + name = "NXData #%d" % (i + 1) + group = self.__getNxdataByTitle(name) + if group is None: + break + return name + + def createNewNxdata(self, name=None): + """Create a new NXdata item. + + :param Union[str,None] name: A title for the new NXdata + """ + item = _NxDataItem() + if name is None: + name = self.findFreeNxdataTitle() + item.setTitle(name) + self.appendRow(item.getRowItems()) + + def createFromSignal(self, dataset): + """Create a new NXdata item from a signal dataset. + + This signal will also define an amount of axes according to its number + of dimensions. + + :param Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset] dataset: + A dataset uses as signal. + """ + + item = _NxDataItem() + name = self.findFreeNxdataTitle() + item.setTitle(name) + item.setSignalDataset(dataset) + item.setAxesDatasets([None] * len(dataset.shape)) + self.appendRow(item.getRowItems()) + + def createFromNxdata(self, nxdata): + """Create a new custom NXdata item from an existing NXdata group. + + If the NXdata is not valid, nothing is created, and an exception is + returned. + + :param Union[h5py.Group,silx.io.commonh5.Group] nxdata: An h5py group + following the NXData specification. + :raise ValueError:If `nxdata` is not valid. + """ + validator = silx.io.nxdata.NXdata(nxdata) + if validator.is_valid: + item = _NxDataItem() + title = validator.title + if title in [None or ""]: + title = self.findFreeNxdataTitle() + item.setTitle(title) + item.setSignalDataset(validator.signal) + item.setAxesDatasets(validator.axes) + self.appendRow(item.getRowItems()) + else: + raise ValueError("Not a valid NXdata") + + def removeNxdataItem(self, item): + """Remove an NXdata item from this model. + + :param _NxDataItem item: An item + """ + if isinstance(item, _NxDataItem): + parent = item.parent() + assert(parent is None) + model = item.model() + model.removeRow(item.row()) + else: + _logger.error("Unexpected item") + + def appendAxisToNxdataItem(self, item): + """Append a new axes to this item (or the NXdata item own by this item). + + :param Union[_NxDataItem,qt.QStandardItem] item: An item + """ + if item is not None and not isinstance(item, _NxDataItem): + item = item.parent() + nxdataItem = item + if isinstance(item, _NxDataItem): + datasets = nxdataItem.getAxesDatasets() + datasets.append(None) + nxdataItem.setAxesDatasets(datasets) + else: + _logger.error("Unexpected item") + + def removeAxisItem(self, item): + """Remove an axis item from this model. + + :param _DatasetAxisItemRow item: An axis item + """ + if isinstance(item, _DatasetAxisItemRow): + axisId = item.getAxisId() + nxdataItem = item.parent() + datasets = nxdataItem.getAxesDatasets() + del datasets[axisId] + nxdataItem.setAxesDatasets(datasets) + else: + _logger.error("Unexpected item") + + +class CustomNxDataToolBar(qt.QToolBar): + """A specialised toolbar to manage custom NXdata model and items.""" + + def __init__(self, parent=None): + """Constructor""" + super(CustomNxDataToolBar, self).__init__(parent=parent) + self.__nxdataWidget = None + self.__initContent() + # Initialize action state + self.__currentSelectionChanged(qt.QModelIndex(), qt.QModelIndex()) + + def __initContent(self): + """Create all expected actions and set the content of this toolbar.""" + action = qt.QAction("Create a new custom NXdata", self) + action.setIcon(icons.getQIcon("nxdata-create")) + action.triggered.connect(self.__createNewNxdata) + self.addAction(action) + self.__addNxDataAction = action + + action = qt.QAction("Remove the selected NXdata", self) + action.setIcon(icons.getQIcon("nxdata-remove")) + action.triggered.connect(self.__removeSelectedNxdata) + self.addAction(action) + self.__removeNxDataAction = action + + self.addSeparator() + + action = qt.QAction("Create a new axis to the selected NXdata", self) + action.setIcon(icons.getQIcon("nxdata-axis-add")) + action.triggered.connect(self.__appendNewAxisToSelectedNxdata) + self.addAction(action) + self.__addNxDataAxisAction = action + + action = qt.QAction("Remove the selected NXdata axis", self) + action.setIcon(icons.getQIcon("nxdata-axis-remove")) + action.triggered.connect(self.__removeSelectedAxis) + self.addAction(action) + self.__removeNxDataAxisAction = action + + def __getSelectedItem(self): + """Get the selected item from the linked CustomNxdataWidget. + + :rtype: qt.QStandardItem + """ + selectionModel = self.__nxdataWidget.selectionModel() + index = selectionModel.currentIndex() + if not index.isValid(): + return + model = self.__nxdataWidget.model() + index = model.index(index.row(), 0, index.parent()) + item = model.itemFromIndex(index) + return item + + def __createNewNxdata(self): + """Create a new NXdata item to the linked CustomNxdataWidget.""" + if self.__nxdataWidget is None: + return + model = self.__nxdataWidget.model() + model.createNewNxdata() + + def __removeSelectedNxdata(self): + """Remove the NXdata item currently selected in the linked + CustomNxdataWidget.""" + if self.__nxdataWidget is None: + return + model = self.__nxdataWidget.model() + item = self.__getSelectedItem() + model.removeNxdataItem(item) + + def __appendNewAxisToSelectedNxdata(self): + """Append a new axis to the NXdata item currently selected in the + linked CustomNxdataWidget.""" + if self.__nxdataWidget is None: + return + model = self.__nxdataWidget.model() + item = self.__getSelectedItem() + model.appendAxisToNxdataItem(item) + + def __removeSelectedAxis(self): + """Remove the axis item currently selected in the linked + CustomNxdataWidget.""" + if self.__nxdataWidget is None: + return + model = self.__nxdataWidget.model() + item = self.__getSelectedItem() + model.removeAxisItem(item) + + def setCustomNxDataWidget(self, widget): + """Set the linked CustomNxdataWidget to this toolbar.""" + assert(isinstance(widget, CustomNxdataWidget)) + if self.__nxdataWidget is not None: + selectionModel = self.__nxdataWidget.selectionModel() + selectionModel.currentChanged.disconnect(self.__currentSelectionChanged) + self.__nxdataWidget = widget + if self.__nxdataWidget is not None: + selectionModel = self.__nxdataWidget.selectionModel() + selectionModel.currentChanged.connect(self.__currentSelectionChanged) + + def __currentSelectionChanged(self, current, previous): + """Update the actions according to the linked CustomNxdataWidget + item selection""" + if not current.isValid(): + item = None + else: + model = self.__nxdataWidget.model() + index = model.index(current.row(), 0, current.parent()) + item = model.itemFromIndex(index) + self.__removeNxDataAction.setEnabled(isinstance(item, _NxDataItem)) + self.__removeNxDataAxisAction.setEnabled(isinstance(item, _DatasetAxisItemRow)) + self.__addNxDataAxisAction.setEnabled(isinstance(item, _NxDataItem) or isinstance(item, _DatasetItemRow)) + + +class _HashDropZones(qt.QStyledItemDelegate): + """Delegate item displaying a drop zone when the item do not contains + dataset.""" + + def __init__(self, parent=None): + """Constructor""" + super(_HashDropZones, self).__init__(parent) + pen = qt.QPen() + pen.setColor(qt.QColor("#D0D0D0")) + pen.setStyle(qt.Qt.DotLine) + pen.setWidth(2) + self.__dropPen = pen + + def paint(self, painter, option, index): + """ + Paint the item + + :param qt.QPainter painter: A painter + :param qt.QStyleOptionViewItem option: Options of the item to paint + :param qt.QModelIndex index: Index of the item to paint + """ + displayDropZone = False + if index.isValid(): + model = index.model() + rowIndex = model.index(index.row(), 0, index.parent()) + rowItem = model.itemFromIndex(rowIndex) + if isinstance(rowItem, _DatasetItemRow): + displayDropZone = rowItem.getDataset() is None + + if displayDropZone: + painter.save() + + # Draw background if selected + if option.state & qt.QStyle.State_Selected: + colorGroup = qt.QPalette.Inactive + if option.state & qt.QStyle.State_Active: + colorGroup = qt.QPalette.Active + if not option.state & qt.QStyle.State_Enabled: + colorGroup = qt.QPalette.Disabled + brush = option.palette.brush(colorGroup, qt.QPalette.Highlight) + painter.fillRect(option.rect, brush) + + painter.setPen(self.__dropPen) + painter.drawRect(option.rect.adjusted(3, 3, -3, -3)) + painter.restore() + else: + qt.QStyledItemDelegate.paint(self, painter, option, index) + + +class CustomNxdataWidget(qt.QTreeView): + """Widget providing a table displaying and allowing to custom virtual + NXdata.""" + + sigNxdataItemUpdated = qt.Signal(qt.QStandardItem) + """Emitted when the NXdata from an NXdata item was edited""" + + sigNxdataItemRemoved = qt.Signal(qt.QStandardItem) + """Emitted when an NXdata item was removed""" + + def __init__(self, parent=None): + """Constructor""" + qt.QTreeView.__init__(self, parent=None) + self.__model = _Model(self) + self.__model.setColumnCount(4) + self.__model.setHorizontalHeaderLabels(["Name", "Dataset", "Type", "Shape"]) + self.setModel(self.__model) + + self.setItemDelegateForColumn(1, _HashDropZones(self)) + + self.__model.sigNxdataUpdated.connect(self.__nxdataUpdate) + self.__model.rowsAboutToBeRemoved.connect(self.__rowsAboutToBeRemoved) + self.__model.rowsAboutToBeInserted.connect(self.__rowsAboutToBeInserted) + + header = self.header() + if qt.qVersion() < "5.0": + setResizeMode = header.setResizeMode + else: + setResizeMode = header.setSectionResizeMode + setResizeMode(0, qt.QHeaderView.ResizeToContents) + setResizeMode(1, qt.QHeaderView.Stretch) + setResizeMode(2, qt.QHeaderView.ResizeToContents) + setResizeMode(3, qt.QHeaderView.ResizeToContents) + + self.setSelectionMode(qt.QAbstractItemView.SingleSelection) + self.setDropIndicatorShown(True) + self.setDragDropOverwriteMode(True) + self.setDragEnabled(True) + self.viewport().setAcceptDrops(True) + + self.setContextMenuPolicy(qt.Qt.CustomContextMenu) + self.customContextMenuRequested[qt.QPoint].connect(self.__executeContextMenu) + + def __rowsAboutToBeInserted(self, parentIndex, start, end): + if qt.qVersion()[0:2] == "5.": + # FIXME: workaround for https://github.com/silx-kit/silx/issues/1919 + # Uses of ResizeToContents looks to break nice update of cells with Qt5 + # This patch make the view blinking + self.repaint() + + def __rowsAboutToBeRemoved(self, parentIndex, start, end): + """Called when an item was removed from the model.""" + items = [] + model = self.model() + for index in range(start, end): + qindex = model.index(index, 0, parent=parentIndex) + item = self.__model.itemFromIndex(qindex) + if isinstance(item, _NxDataItem): + items.append(item) + for item in items: + self.sigNxdataItemRemoved.emit(item) + + if qt.qVersion()[0:2] == "5.": + # FIXME: workaround for https://github.com/silx-kit/silx/issues/1919 + # Uses of ResizeToContents looks to break nice update of cells with Qt5 + # This patch make the view blinking + self.repaint() + + def __nxdataUpdate(self, index): + """Called when a virtual NXdata was updated from the model.""" + model = self.model() + item = model.itemFromIndex(index) + self.sigNxdataItemUpdated.emit(item) + + def createDefaultContextMenu(self, index): + """Create a default context menu at this position. + + :param qt.QModelIndex index: Index of the item + """ + index = self.__model.index(index.row(), 0, parent=index.parent()) + item = self.__model.itemFromIndex(index) + + menu = qt.QMenu() + + weakself = weakref.proxy(self) + + if isinstance(item, _NxDataItem): + action = qt.QAction("Add a new axis", menu) + action.triggered.connect(lambda: weakself.model().appendAxisToNxdataItem(item)) + action.setIcon(icons.getQIcon("nxdata-axis-add")) + action.setIconVisibleInMenu(True) + menu.addAction(action) + menu.addSeparator() + action = qt.QAction("Remove this NXdata", menu) + action.triggered.connect(lambda: weakself.model().removeNxdataItem(item)) + action.setIcon(icons.getQIcon("remove")) + action.setIconVisibleInMenu(True) + menu.addAction(action) + else: + if isinstance(item, _DatasetItemRow): + if item.getDataset() is not None: + action = qt.QAction("Remove this dataset", menu) + action.triggered.connect(lambda: item.setDataset(None)) + menu.addAction(action) + + if isinstance(item, _DatasetAxisItemRow): + menu.addSeparator() + action = qt.QAction("Remove this axis", menu) + action.triggered.connect(lambda: weakself.model().removeAxisItem(item)) + action.setIcon(icons.getQIcon("remove")) + action.setIconVisibleInMenu(True) + menu.addAction(action) + + return menu + + def __executeContextMenu(self, point): + """Execute the context menu at this position.""" + index = self.indexAt(point) + menu = self.createDefaultContextMenu(index) + if menu is None or menu.isEmpty(): + return + menu.exec_(qt.QCursor.pos()) + + def removeDatasetsFrom(self, root): + """ + Remove all datasets provided by this root + + :param root: The root file of datasets to remove + """ + for row in range(self.__model.rowCount()): + qindex = self.__model.index(row, 0) + item = self.model().itemFromIndex(qindex) + + edited = False + datasets = item.getAxesDatasets() + for i, dataset in enumerate(datasets): + if dataset is not None: + # That's an approximation, IS can't be used as h5py generates + # To objects for each requests to a node + if dataset.file.filename == root.file.filename: + datasets[i] = None + edited = True + if edited: + item.setAxesDatasets(datasets) + + dataset = item.getSignalDataset() + if dataset is not None: + # That's an approximation, IS can't be used as h5py generates + # To objects for each requests to a node + if dataset.file.filename == root.file.filename: + item.setSignalDataset(None) + + def replaceDatasetsFrom(self, removedRoot, loadedRoot): + """ + Replace any dataset from any NXdata items using the same dataset name + from another root. + + Usually used when a file was synchronized. + + :param removedRoot: The h5py root file which is replaced + (which have to be removed) + :param loadedRoot: The new h5py root file which have to be used + instread. + """ + for row in range(self.__model.rowCount()): + qindex = self.__model.index(row, 0) + item = self.model().itemFromIndex(qindex) + + edited = False + datasets = item.getAxesDatasets() + for i, dataset in enumerate(datasets): + newDataset = self.__replaceDatasetRoot(dataset, removedRoot, loadedRoot) + if dataset is not newDataset: + datasets[i] = newDataset + edited = True + if edited: + item.setAxesDatasets(datasets) + + dataset = item.getSignalDataset() + newDataset = self.__replaceDatasetRoot(dataset, removedRoot, loadedRoot) + if dataset is not newDataset: + item.setSignalDataset(newDataset) + + def __replaceDatasetRoot(self, dataset, fromRoot, toRoot): + """ + Replace the dataset by the same dataset name from another root. + """ + if dataset is None: + return None + + if dataset.file is None: + # Not from the expected root + return dataset + + # That's an approximation, IS can't be used as h5py generates + # To objects for each requests to a node + if dataset.file.filename == fromRoot.file.filename: + # Try to find the same dataset name + try: + return toRoot[dataset.name] + except Exception: + _logger.debug("Backtrace", exc_info=True) + return None + else: + # Not from the expected root + return dataset + + def selectedItems(self): + """Returns the list of selected items containing NXdata + + :rtype: List[qt.QStandardItem] + """ + result = [] + for qindex in self.selectedIndexes(): + if qindex.column() != 0: + continue + if not qindex.isValid(): + continue + item = self.__model.itemFromIndex(qindex) + if not isinstance(item, _NxDataItem): + continue + result.append(item) + return result + + def selectedNxdata(self): + """Returns the list of selected NXdata + + :rtype: List[silx.io.commonh5.Group] + """ + result = [] + for qindex in self.selectedIndexes(): + if qindex.column() != 0: + continue + if not qindex.isValid(): + continue + item = self.__model.itemFromIndex(qindex) + if not isinstance(item, _NxDataItem): + continue + result.append(item.getVirtualGroup()) + return result diff --git a/silx/app/view/DataPanel.py b/silx/app/view/DataPanel.py new file mode 100644 index 0000000..0653f74 --- /dev/null +++ b/silx/app/view/DataPanel.py @@ -0,0 +1,171 @@ +# 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. +# +# ############################################################################*/ +"""Browse a data file with a GUI""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "06/06/2018" + +import logging + +from silx.gui import qt +from silx.gui.data.DataViewerFrame import DataViewerFrame + + +_logger = logging.getLogger(__name__) + + +class _HeaderLabel(qt.QLabel): + + def __init__(self, parent=None): + qt.QLabel.__init__(self, parent=parent) + self.setFrameShape(qt.QFrame.StyledPanel) + + def sizeHint(self): + return qt.QSize(10, 30) + + def paintEvent(self, event): + painter = qt.QPainter(self) + + opt = qt.QStyleOptionHeader() + opt.orientation = qt.Qt.Horizontal + opt.text = self.text() + opt.textAlignment = self.alignment() + opt.direction = self.layoutDirection() + opt.fontMetrics = self.fontMetrics() + opt.palette = self.palette() + opt.state = qt.QStyle.State_Active + opt.position = qt.QStyleOptionHeader.Beginning + style = self.style() + + # Background + margin = -1 + opt.rect = self.rect().adjusted(margin, margin, -margin, -margin) + style.drawControl(qt.QStyle.CE_HeaderSection, opt, painter, None) + + # Frame border and text + super(_HeaderLabel, self).paintEvent(event) + + +class DataPanel(qt.QWidget): + + def __init__(self, parent=None, context=None): + qt.QWidget.__init__(self, parent=parent) + + self.__customNxdataItem = None + + self.__dataTitle = _HeaderLabel(self) + self.__dataTitle.setVisible(False) + + self.__dataViewer = DataViewerFrame(self) + self.__dataViewer.setGlobalHooks(context) + + layout = qt.QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.__dataTitle) + layout.addWidget(self.__dataViewer) + + def getData(self): + return self.__dataViewer.data() + + def getCustomNxdataItem(self): + return self.__customNxdataItem + + def setData(self, data): + self.__customNxdataItem = None + self.__dataViewer.setData(data) + self.__dataTitle.setVisible(data is not None) + if data is not None: + self.__dataTitle.setVisible(True) + if hasattr(data, "name"): + if hasattr(data, "file"): + label = str(data.file.filename) + label += "::" + else: + label = "" + label += data.name + else: + label = "" + self.__dataTitle.setText(label) + + def setCustomDataItem(self, item): + self.__customNxdataItem = item + if item is not None: + data = item.getVirtualGroup() + else: + data = None + self.__dataViewer.setData(data) + self.__dataTitle.setVisible(item is not None) + if item is not None: + text = item.text() + self.__dataTitle.setText(text) + + def removeDatasetsFrom(self, root): + """ + Remove all datasets provided by this root + + .. note:: This function do not update data stored inside + customNxdataItem cause in the silx-view context this item is + already updated on his own. + + :param root: The root file of datasets to remove + """ + data = self.__dataViewer.data() + if data is not None: + if data.file is not None: + # That's an approximation, IS can't be used as h5py generates + # To objects for each requests to a node + if data.file.filename == root.file.filename: + self.__dataViewer.setData(None) + + def replaceDatasetsFrom(self, removedH5, loadedH5): + """ + Replace any dataset from any NXdata items using the same dataset name + from another root. + + Usually used when a file was synchronized. + + .. note:: This function do not update data stored inside + customNxdataItem cause in the silx-view context this item is + already updated on his own. + + :param removedRoot: The h5py root file which is replaced + (which have to be removed) + :param loadedRoot: The new h5py root file which have to be used + instread. + """ + + data = self.__dataViewer.data() + if data is not None: + if data.file is not None: + if data.file.filename == removedH5.file.filename: + # Try to synchonize the viewed data + try: + # TODO: It have to update the data without changing the + # view which is not so easy + newData = loadedH5[data.name] + self.__dataViewer.setData(newData) + except Exception: + _logger.debug("Backtrace", exc_info=True) diff --git a/silx/app/view/Viewer.py b/silx/app/view/Viewer.py new file mode 100644 index 0000000..8f5db60 --- /dev/null +++ b/silx/app/view/Viewer.py @@ -0,0 +1,686 @@ +# 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. +# +# ############################################################################*/ +"""Browse a data file with a GUI""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "25/06/2018" + + +import os +import collections +import logging +import functools + +import silx.io.nxdata +from silx.gui import qt +from silx.gui import icons +import silx.gui.hdf5 +from .ApplicationContext import ApplicationContext +from .CustomNxdataWidget import CustomNxdataWidget +from .CustomNxdataWidget import CustomNxDataToolBar +from . import utils +from .DataPanel import DataPanel + + +_logger = logging.getLogger(__name__) + + +class Viewer(qt.QMainWindow): + """ + This window allows to browse a data file like images or HDF5 and it's + content. + """ + + def __init__(self, parent=None, settings=None): + """ + Constructor + """ + + qt.QMainWindow.__init__(self, parent) + self.setWindowTitle("Silx viewer") + + self.__context = ApplicationContext(self, settings) + self.__context.restoreLibrarySettings() + + self.__dialogState = None + self.__customNxDataItem = None + self.__treeview = silx.gui.hdf5.Hdf5TreeView(self) + self.__treeview.setExpandsOnDoubleClick(False) + """Silx HDF5 TreeView""" + + rightPanel = qt.QSplitter(self) + rightPanel.setOrientation(qt.Qt.Vertical) + self.__splitter2 = rightPanel + + self.__treeWindow = self.__createTreeWindow(self.__treeview) + + # Custom the model to be able to manage the life cycle of the files + treeModel = silx.gui.hdf5.Hdf5TreeModel(self.__treeview, ownFiles=False) + treeModel.sigH5pyObjectLoaded.connect(self.__h5FileLoaded) + treeModel.sigH5pyObjectRemoved.connect(self.__h5FileRemoved) + treeModel.sigH5pyObjectSynchronized.connect(self.__h5FileSynchonized) + treeModel.setDatasetDragEnabled(True) + treeModel2 = silx.gui.hdf5.NexusSortFilterProxyModel(self.__treeview) + treeModel2.setSourceModel(treeModel) + treeModel2.sort(0, qt.Qt.AscendingOrder) + treeModel2.setSortCaseSensitivity(qt.Qt.CaseInsensitive) + + self.__treeview.setModel(treeModel2) + rightPanel.addWidget(self.__treeWindow) + + self.__customNxdata = CustomNxdataWidget(self) + self.__customNxdata.setSelectionBehavior(qt.QAbstractItemView.SelectRows) + # optimise the rendering + self.__customNxdata.setUniformRowHeights(True) + self.__customNxdata.setIconSize(qt.QSize(16, 16)) + self.__customNxdata.setExpandsOnDoubleClick(False) + + self.__customNxdataWindow = self.__createCustomNxdataWindow(self.__customNxdata) + self.__customNxdataWindow.setVisible(False) + rightPanel.addWidget(self.__customNxdataWindow) + + rightPanel.setStretchFactor(1, 1) + rightPanel.setCollapsible(0, False) + rightPanel.setCollapsible(1, False) + + self.__dataPanel = DataPanel(self, self.__context) + + spliter = qt.QSplitter(self) + spliter.addWidget(rightPanel) + spliter.addWidget(self.__dataPanel) + spliter.setStretchFactor(1, 1) + self.__splitter = spliter + + main_panel = qt.QWidget(self) + layout = qt.QVBoxLayout() + layout.addWidget(spliter) + layout.setStretchFactor(spliter, 1) + main_panel.setLayout(layout) + + self.setCentralWidget(main_panel) + + self.__treeview.activated.connect(self.displaySelectedData) + self.__customNxdata.activated.connect(self.displaySelectedCustomData) + self.__customNxdata.sigNxdataItemRemoved.connect(self.__customNxdataRemoved) + self.__customNxdata.sigNxdataItemUpdated.connect(self.__customNxdataUpdated) + self.__treeview.addContextMenuCallback(self.customContextMenu) + + treeModel = self.__treeview.findHdf5TreeModel() + columns = list(treeModel.COLUMN_IDS) + columns.remove(treeModel.DESCRIPTION_COLUMN) + columns.remove(treeModel.NODE_COLUMN) + self.__treeview.header().setSections(columns) + + self._iconUpward = icons.getQIcon('plot-yup') + self._iconDownward = icons.getQIcon('plot-ydown') + + self.createActions() + self.createMenus() + self.__context.restoreSettings() + + def __createTreeWindow(self, treeView): + toolbar = qt.QToolBar(self) + toolbar.setIconSize(qt.QSize(16, 16)) + toolbar.setStyleSheet("QToolBar { border: 0px }") + + action = qt.QAction(toolbar) + action.setIcon(icons.getQIcon("tree-expand-all")) + action.setText("Expand all") + action.setToolTip("Expand all selected items") + action.triggered.connect(self.__expandAllSelected) + action.setShortcut(qt.QKeySequence(qt.Qt.ControlModifier + qt.Qt.Key_Plus)) + toolbar.addAction(action) + treeView.addAction(action) + self.__expandAllAction = action + + action = qt.QAction(toolbar) + action.setIcon(icons.getQIcon("tree-collapse-all")) + action.setText("Collapse all") + action.setToolTip("Collapse all selected items") + action.triggered.connect(self.__collapseAllSelected) + action.setShortcut(qt.QKeySequence(qt.Qt.ControlModifier + qt.Qt.Key_Minus)) + toolbar.addAction(action) + treeView.addAction(action) + self.__collapseAllAction = action + + widget = qt.QWidget(self) + layout = qt.QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(toolbar) + layout.addWidget(treeView) + return widget + + def __expandAllSelected(self): + """Expand all selected items of the tree. + + The depth is fixed to avoid infinite loop with recurssive links. + """ + qt.QApplication.setOverrideCursor(qt.Qt.WaitCursor) + + selection = self.__treeview.selectionModel() + indexes = selection.selectedIndexes() + model = self.__treeview.model() + while len(indexes) > 0: + index = indexes.pop(0) + if isinstance(index, tuple): + index, depth = index + else: + depth = 0 + + if depth > 10: + # Avoid infinite loop with recursive links + break + + if model.hasChildren(index): + self.__treeview.setExpanded(index, True) + for row in range(model.rowCount(index)): + childIndex = model.index(row, 0, index) + indexes.append((childIndex, depth + 1)) + qt.QApplication.restoreOverrideCursor() + + def __collapseAllSelected(self): + """Collapse all selected items of the tree. + + The depth is fixed to avoid infinite loop with recurssive links. + """ + selection = self.__treeview.selectionModel() + indexes = selection.selectedIndexes() + model = self.__treeview.model() + while len(indexes) > 0: + index = indexes.pop(0) + if isinstance(index, tuple): + index, depth = index + else: + depth = 0 + + if depth > 10: + # Avoid infinite loop with recursive links + break + + if model.hasChildren(index): + self.__treeview.setExpanded(index, False) + for row in range(model.rowCount(index)): + childIndex = model.index(row, 0, index) + indexes.append((childIndex, depth + 1)) + + def __createCustomNxdataWindow(self, customNxdataWidget): + toolbar = CustomNxDataToolBar(self) + toolbar.setCustomNxDataWidget(customNxdataWidget) + toolbar.setIconSize(qt.QSize(16, 16)) + toolbar.setStyleSheet("QToolBar { border: 0px }") + + widget = qt.QWidget(self) + layout = qt.QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(toolbar) + layout.addWidget(customNxdataWidget) + return widget + + def __h5FileLoaded(self, loadedH5): + self.__context.pushRecentFile(loadedH5.file.filename) + + def __h5FileRemoved(self, removedH5): + self.__dataPanel.removeDatasetsFrom(removedH5) + self.__customNxdata.removeDatasetsFrom(removedH5) + removedH5.close() + + def __h5FileSynchonized(self, removedH5, loadedH5): + self.__dataPanel.replaceDatasetsFrom(removedH5, loadedH5) + self.__customNxdata.replaceDatasetsFrom(removedH5, loadedH5) + removedH5.close() + + def closeEvent(self, event): + self.__context.saveSettings() + + # Clean up as much as possible Python objects + model = self.__customNxdata.model() + model.clear() + model = self.__treeview.findHdf5TreeModel() + model.clear() + + def saveSettings(self, settings): + """Save the window settings to this settings object + + :param qt.QSettings settings: Initialized settings + """ + isFullScreen = bool(self.windowState() & qt.Qt.WindowFullScreen) + if isFullScreen: + # show in normal to catch the normal geometry + self.showNormal() + + settings.beginGroup("mainwindow") + settings.setValue("size", self.size()) + settings.setValue("pos", self.pos()) + settings.setValue("full-screen", isFullScreen) + settings.endGroup() + + settings.beginGroup("mainlayout") + settings.setValue("spliter", self.__splitter.sizes()) + settings.setValue("spliter2", self.__splitter2.sizes()) + isVisible = self.__customNxdataWindow.isVisible() + settings.setValue("custom-nxdata-window-visible", isVisible) + settings.endGroup() + + if isFullScreen: + self.showFullScreen() + + def restoreSettings(self, settings): + """Restore the window settings using this settings object + + :param qt.QSettings settings: Initialized settings + """ + settings.beginGroup("mainwindow") + size = settings.value("size", qt.QSize(640, 480)) + pos = settings.value("pos", qt.QPoint()) + isFullScreen = settings.value("full-screen", False) + try: + if not isinstance(isFullScreen, bool): + isFullScreen = utils.stringToBool(isFullScreen) + except ValueError: + isFullScreen = False + settings.endGroup() + + settings.beginGroup("mainlayout") + try: + data = settings.value("spliter") + data = [int(d) for d in data] + self.__splitter.setSizes(data) + except Exception: + _logger.debug("Backtrace", exc_info=True) + try: + data = settings.value("spliter2") + data = [int(d) for d in data] + self.__splitter2.setSizes(data) + except Exception: + _logger.debug("Backtrace", exc_info=True) + isVisible = settings.value("custom-nxdata-window-visible", False) + try: + if not isinstance(isVisible, bool): + isVisible = utils.stringToBool(isVisible) + except ValueError: + isVisible = False + self.__customNxdataWindow.setVisible(isVisible) + self._displayCustomNxdataWindow.setChecked(isVisible) + + settings.endGroup() + + if not pos.isNull(): + self.move(pos) + if not size.isNull(): + self.resize(size) + if isFullScreen: + self.showFullScreen() + + def createActions(self): + action = qt.QAction("E&xit", self) + action.setShortcuts(qt.QKeySequence.Quit) + action.setStatusTip("Exit the application") + action.triggered.connect(self.close) + self._exitAction = action + + action = qt.QAction("&Open...", self) + action.setStatusTip("Open a file") + action.triggered.connect(self.open) + self._openAction = action + + action = qt.QAction("Open Recent", self) + action.setStatusTip("Open a recently openned file") + action.triggered.connect(self.open) + self._openRecentAction = action + + action = qt.QAction("&About", self) + action.setStatusTip("Show the application's About box") + action.triggered.connect(self.about) + self._aboutAction = action + + # Plot backend + + action = qt.QAction("Plot rendering backend", self) + action.setStatusTip("Select plot rendering backend") + self._plotBackendSelection = action + + menu = qt.QMenu() + action.setMenu(menu) + group = qt.QActionGroup(self) + group.setExclusive(True) + + action = qt.QAction("matplotlib", self) + action.setStatusTip("Plot will be rendered using matplotlib") + action.setCheckable(True) + action.triggered.connect(self.__forceMatplotlibBackend) + group.addAction(action) + menu.addAction(action) + self._usePlotWithMatplotlib = action + + action = qt.QAction("OpenGL", self) + action.setStatusTip("Plot will be rendered using OpenGL") + action.setCheckable(True) + action.triggered.connect(self.__forceOpenglBackend) + group.addAction(action) + menu.addAction(action) + self._usePlotWithOpengl = action + + # Plot image orientation + + action = qt.QAction("Default plot image y-axis orientation", self) + action.setStatusTip("Select the default y-axis orientation used by plot displaying images") + self._plotImageOrientation = action + + menu = qt.QMenu() + action.setMenu(menu) + group = qt.QActionGroup(self) + group.setExclusive(True) + + action = qt.QAction("Downward, origin on top", self) + action.setIcon(self._iconDownward) + action.setStatusTip("Plot images will use a downward Y-axis orientation") + action.setCheckable(True) + action.triggered.connect(self.__forcePlotImageDownward) + group.addAction(action) + menu.addAction(action) + self._useYAxisOrientationDownward = action + + action = qt.QAction("Upward, origin on bottom", self) + action.setIcon(self._iconUpward) + action.setStatusTip("Plot images will use a upward Y-axis orientation") + action.setCheckable(True) + action.triggered.connect(self.__forcePlotImageUpward) + group.addAction(action) + menu.addAction(action) + self._useYAxisOrientationUpward = action + + # Windows + + action = qt.QAction("Show custom NXdata selector", self) + action.setStatusTip("Show a widget which allow to create plot by selecting data and axes") + action.setCheckable(True) + action.setShortcut(qt.QKeySequence(qt.Qt.Key_F5)) + action.toggled.connect(self.__toggleCustomNxdataWindow) + self._displayCustomNxdataWindow = action + + def __toggleCustomNxdataWindow(self): + isVisible = self._displayCustomNxdataWindow.isChecked() + self.__customNxdataWindow.setVisible(isVisible) + + def __updateFileMenu(self): + files = self.__context.getRecentFiles() + self._openRecentAction.setEnabled(len(files) != 0) + menu = None + if len(files) != 0: + menu = qt.QMenu() + for filePath in files: + baseName = os.path.basename(filePath) + action = qt.QAction(baseName, self) + action.setToolTip(filePath) + action.triggered.connect(functools.partial(self.__openRecentFile, filePath)) + menu.addAction(action) + menu.addSeparator() + baseName = os.path.basename(filePath) + action = qt.QAction("Clear history", self) + action.setToolTip("Clear the history of the recent files") + action.triggered.connect(self.__clearRecentFile) + menu.addAction(action) + self._openRecentAction.setMenu(menu) + + def __clearRecentFile(self): + self.__context.clearRencentFiles() + + def __openRecentFile(self, filePath): + self.appendFile(filePath) + + def __updateOptionMenu(self): + """Update the state of the checked options as it is based on global + environment values.""" + + # plot backend + + action = self._plotBackendSelection + title = action.text().split(": ", 1)[0] + action.setText("%s: %s" % (title, silx.config.DEFAULT_PLOT_BACKEND)) + + action = self._usePlotWithMatplotlib + action.setChecked(silx.config.DEFAULT_PLOT_BACKEND in ["matplotlib", "mpl"]) + title = action.text().split(" (", 1)[0] + if not action.isChecked(): + title += " (applied after application restart)" + action.setText(title) + + action = self._usePlotWithOpengl + action.setChecked(silx.config.DEFAULT_PLOT_BACKEND in ["opengl", "gl"]) + title = action.text().split(" (", 1)[0] + if not action.isChecked(): + title += " (applied after application restart)" + action.setText(title) + + # plot orientation + + action = self._plotImageOrientation + if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == "downward": + action.setIcon(self._iconDownward) + else: + action.setIcon(self._iconUpward) + action.setIconVisibleInMenu(True) + + action = self._useYAxisOrientationDownward + action.setChecked(silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == "downward") + title = action.text().split(" (", 1)[0] + if not action.isChecked(): + title += " (applied after application restart)" + action.setText(title) + + action = self._useYAxisOrientationUpward + action.setChecked(silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION != "downward") + title = action.text().split(" (", 1)[0] + if not action.isChecked(): + title += " (applied after application restart)" + action.setText(title) + + def createMenus(self): + fileMenu = self.menuBar().addMenu("&File") + fileMenu.addAction(self._openAction) + fileMenu.addAction(self._openRecentAction) + fileMenu.addSeparator() + fileMenu.addAction(self._exitAction) + fileMenu.aboutToShow.connect(self.__updateFileMenu) + + optionMenu = self.menuBar().addMenu("&Options") + optionMenu.addAction(self._plotImageOrientation) + optionMenu.addAction(self._plotBackendSelection) + optionMenu.aboutToShow.connect(self.__updateOptionMenu) + + viewMenu = self.menuBar().addMenu("&Views") + viewMenu.addAction(self._displayCustomNxdataWindow) + + helpMenu = self.menuBar().addMenu("&Help") + helpMenu.addAction(self._aboutAction) + + def open(self): + dialog = self.createFileDialog() + if self.__dialogState is None: + currentDirectory = os.getcwd() + dialog.setDirectory(currentDirectory) + else: + dialog.restoreState(self.__dialogState) + + result = dialog.exec_() + if not result: + return + + self.__dialogState = dialog.saveState() + + filenames = dialog.selectedFiles() + for filename in filenames: + self.appendFile(filename) + + def createFileDialog(self): + dialog = qt.QFileDialog(self) + dialog.setWindowTitle("Open") + dialog.setModal(True) + + # NOTE: hdf5plugin have to be loaded before + extensions = collections.OrderedDict() + for description, ext in silx.io.supported_extensions().items(): + extensions[description] = " ".join(sorted(list(ext))) + + try: + # NOTE: hdf5plugin have to be loaded before + import fabio + except Exception: + _logger.debug("Backtrace while loading fabio", exc_info=True) + fabio = None + + if fabio is not None: + extensions["NeXus layout from EDF files"] = "*.edf" + extensions["NeXus layout from TIFF image files"] = "*.tif *.tiff" + extensions["NeXus layout from CBF files"] = "*.cbf" + extensions["NeXus layout from MarCCD image files"] = "*.mccd" + + all_supported_extensions = set() + for name, exts in extensions.items(): + exts = exts.split(" ") + all_supported_extensions.update(exts) + all_supported_extensions = sorted(list(all_supported_extensions)) + + filters = [] + filters.append("All supported files (%s)" % " ".join(all_supported_extensions)) + for name, extension in extensions.items(): + filters.append("%s (%s)" % (name, extension)) + filters.append("All files (*)") + + dialog.setNameFilters(filters) + dialog.setFileMode(qt.QFileDialog.ExistingFiles) + return dialog + + def about(self): + from .About import About + About.about(self, "Silx viewer") + + def __forcePlotImageDownward(self): + silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = "downward" + + def __forcePlotImageUpward(self): + silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = "upward" + + def __forceMatplotlibBackend(self): + silx.config.DEFAULT_PLOT_BACKEND = "matplotlib" + + def __forceOpenglBackend(self): + silx.config.DEFAULT_PLOT_BACKEND = "opengl" + + def appendFile(self, filename): + self.__treeview.findHdf5TreeModel().appendFile(filename) + + def displaySelectedData(self): + """Called to update the dataviewer with the selected data. + """ + selected = list(self.__treeview.selectedH5Nodes(ignoreBrokenLinks=False)) + if len(selected) == 1: + # Update the viewer for a single selection + data = selected[0] + self.__dataPanel.setData(data) + else: + _logger.debug("Too many data selected") + + def displayData(self, data): + """Called to update the dataviewer with a secific data. + """ + self.__dataPanel.setData(data) + + def displaySelectedCustomData(self): + selected = list(self.__customNxdata.selectedItems()) + if len(selected) == 1: + # Update the viewer for a single selection + item = selected[0] + self.__dataPanel.setCustomDataItem(item) + else: + _logger.debug("Too many items selected") + + def __customNxdataRemoved(self, item): + if self.__dataPanel.getCustomNxdataItem() is item: + self.__dataPanel.setCustomDataItem(None) + + def __customNxdataUpdated(self, item): + if self.__dataPanel.getCustomNxdataItem() is item: + self.__dataPanel.setCustomDataItem(item) + + def __makeSureCustomNxDataWindowIsVisible(self): + if not self.__customNxdataWindow.isVisible(): + self.__customNxdataWindow.setVisible(True) + self._displayCustomNxdataWindow.setChecked(True) + + def useAsNewCustomSignal(self, h5dataset): + self.__makeSureCustomNxDataWindowIsVisible() + model = self.__customNxdata.model() + model.createFromSignal(h5dataset) + + def useAsNewCustomNxdata(self, h5nxdata): + self.__makeSureCustomNxDataWindowIsVisible() + model = self.__customNxdata.model() + model.createFromNxdata(h5nxdata) + + def customContextMenu(self, event): + """Called to populate the context menu + + :param silx.gui.hdf5.Hdf5ContextMenuEvent event: Event + containing expected information to populate the context menu + """ + selectedObjects = event.source().selectedH5Nodes(ignoreBrokenLinks=False) + menu = event.menu() + + if not menu.isEmpty(): + menu.addSeparator() + + for obj in selectedObjects: + h5 = obj.h5py_object + + name = obj.name + if name.startswith("/"): + name = name[1:] + if name == "": + name = "the root" + + action = qt.QAction("Show %s" % name, event.source()) + action.triggered.connect(lambda: self.displayData(h5)) + menu.addAction(action) + + if silx.io.is_dataset(h5): + action = qt.QAction("Use as a new custom signal", event.source()) + action.triggered.connect(lambda: self.useAsNewCustomSignal(h5)) + menu.addAction(action) + + if silx.io.is_group(h5) and silx.io.nxdata.is_valid_nxdata(h5): + action = qt.QAction("Use as a new custom NXdata", event.source()) + action.triggered.connect(lambda: self.useAsNewCustomNxdata(h5)) + menu.addAction(action) + + if silx.io.is_file(h5): + action = qt.QAction("Remove %s" % obj.local_filename, event.source()) + action.triggered.connect(lambda: self.__treeview.findHdf5TreeModel().removeH5pyObject(h5)) + menu.addAction(action) + action = qt.QAction("Synchronize %s" % obj.local_filename, event.source()) + action.triggered.connect(lambda: self.__treeview.findHdf5TreeModel().synchronizeH5pyObject(h5)) + menu.addAction(action) diff --git a/silx/app/view/__init__.py b/silx/app/view/__init__.py new file mode 100644 index 0000000..229c44e --- /dev/null +++ b/silx/app/view/__init__.py @@ -0,0 +1,28 @@ +# 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. +# +# ############################################################################*/ +"""Package containing source code of the `silx view` application""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/06/2018" diff --git a/silx/app/view/main.py b/silx/app/view/main.py new file mode 100644 index 0000000..fc89a22 --- /dev/null +++ b/silx/app/view/main.py @@ -0,0 +1,168 @@ +# 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. +# +# ############################################################################*/ +"""Module containing launcher of the `silx view` application""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/06/2018" + +import sys +import argparse +import logging +import signal + + +_logger = logging.getLogger(__name__) +"""Module logger""" + +if "silx.gui.qt" not in sys.modules: + # Try first PyQt5 and not the priority imposed by silx.gui.qt. + # To avoid problem with unittests we only do it if silx.gui.qt is not + # yet loaded. + # TODO: Can be removed for silx 0.8, as it should be the default binding + # of the silx library. + try: + import PyQt5.QtCore + except ImportError: + pass + +import silx +from silx.gui import qt + + +def sigintHandler(*args): + """Handler for the SIGINT signal.""" + qt.QApplication.quit() + + +def createParser(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + 'files', + nargs=argparse.ZERO_OR_MORE, + help='Data file to show (h5 file, edf files, spec files)') + parser.add_argument( + '--debug', + dest="debug", + action="store_true", + default=False, + help='Set logging system in debug mode') + parser.add_argument( + '--use-opengl-plot', + dest="use_opengl_plot", + action="store_true", + default=False, + help='Use OpenGL for plots (instead of matplotlib)') + parser.add_argument( + '--fresh', + dest="fresh_preferences", + action="store_true", + default=False, + help='Start the application using new fresh user preferences') + return parser + + +def main(argv): + """ + Main function to launch the viewer as an application + + :param argv: Command line arguments + :returns: exit status + """ + parser = createParser() + options = parser.parse_args(argv[1:]) + + if options.debug: + logging.root.setLevel(logging.DEBUG) + + # + # Import most of the things here to be sure to use the right logging level + # + + try: + # it should be loaded before h5py + import hdf5plugin # noqa + except ImportError: + _logger.debug("Backtrace", exc_info=True) + + try: + import h5py + except ImportError: + _logger.debug("Backtrace", exc_info=True) + h5py = None + + if h5py is None: + message = "Module 'h5py' is not installed but is mandatory."\ + + " You can install it using \"pip install h5py\"." + _logger.error(message) + return -1 + + # + # Run the application + # + + app = qt.QApplication([]) + qt.QLocale.setDefault(qt.QLocale.c()) + + signal.signal(signal.SIGINT, sigintHandler) + sys.excepthook = qt.exceptionHandler + + timer = qt.QTimer() + timer.start(500) + # Application have to wake up Python interpreter, else SIGINT is not + # catched + timer.timeout.connect(lambda: None) + + settings = qt.QSettings(qt.QSettings.IniFormat, + qt.QSettings.UserScope, + "silx", + "silx-view", + None) + if options.fresh_preferences: + settings.clear() + + from .Viewer import Viewer + window = Viewer(parent=None, settings=settings) + window.setAttribute(qt.Qt.WA_DeleteOnClose, True) + + if options.use_opengl_plot: + # It have to be done after the settings (after the Viewer creation) + silx.config.DEFAULT_PLOT_BACKEND = "opengl" + + for filename in options.files: + try: + window.appendFile(filename) + except IOError as e: + _logger.error(e.args[0]) + _logger.debug("Backtrace", exc_info=True) + + window.show() + result = app.exec_() + # remove ending warnings relative to QTimer + app.deleteLater() + return result + + +if __name__ == '__main__': + main(sys.argv) diff --git a/silx/app/view/setup.py b/silx/app/view/setup.py new file mode 100644 index 0000000..fa076cb --- /dev/null +++ b/silx/app/view/setup.py @@ -0,0 +1,40 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 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__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "06/06/2018" + +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('view', 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/app/view/test/__init__.py b/silx/app/view/test/__init__.py new file mode 100644 index 0000000..8e64948 --- /dev/null +++ b/silx/app/view/test/__init__.py @@ -0,0 +1,41 @@ +# 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. +# +# ###########################################################################*/ +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/06/2018" + +import unittest + +from silx.test.utils import test_options + + +def suite(): + test_suite = unittest.TestSuite() + if test_options.WITH_QT_TEST: + from . import test_launcher + from . import test_view + test_suite.addTest(test_view.suite()) + test_suite.addTest(test_launcher.suite()) + return test_suite diff --git a/silx/app/view/test/test_launcher.py b/silx/app/view/test/test_launcher.py new file mode 100644 index 0000000..aabccf0 --- /dev/null +++ b/silx/app/view/test/test_launcher.py @@ -0,0 +1,145 @@ +# 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. +# +# ###########################################################################*/ +"""Module testing silx.app.view""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/06/2018" + + +import os +import sys +import unittest +import logging +import subprocess + +from silx.test.utils import test_options +from .. import main +from silx import __main__ as silx_main + +_logger = logging.getLogger(__name__) + + +@unittest.skipUnless(test_options.WITH_QT_TEST, test_options.WITH_QT_TEST_REASON) +class TestLauncher(unittest.TestCase): + """Test command line parsing""" + + def testHelp(self): + # option -h must cause a raise SystemExit or a return 0 + try: + parser = main.createParser() + parser.parse_args(["view", "--help"]) + result = 0 + except SystemExit as e: + result = e.args[0] + self.assertEqual(result, 0) + + def testWrongOption(self): + try: + parser = main.createParser() + parser.parse_args(["view", "--foo"]) + self.fail() + except SystemExit as e: + result = e.args[0] + self.assertNotEqual(result, 0) + + def testWrongFile(self): + try: + parser = main.createParser() + result = parser.parse_args(["view", "__file.not.found__"]) + result = 0 + except SystemExit as e: + result = e.args[0] + self.assertEqual(result, 0) + + def executeCommandLine(self, command_line, env): + """Execute a command line. + + Log output as debug in case of bad return code. + """ + _logger.info("Execute: %s", " ".join(command_line)) + p = subprocess.Popen(command_line, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env) + out, err = p.communicate() + _logger.info("Return code: %d", p.returncode) + try: + out = out.decode('utf-8') + except UnicodeError: + pass + try: + err = err.decode('utf-8') + except UnicodeError: + pass + + if p.returncode != 0: + _logger.info("stdout:") + _logger.info("%s", out) + _logger.info("stderr:") + _logger.info("%s", err) + else: + _logger.debug("stdout:") + _logger.debug("%s", out) + _logger.debug("stderr:") + _logger.debug("%s", err) + self.assertEqual(p.returncode, 0) + + def createTestEnv(self): + """ + Returns an associated environment with a working project. + """ + env = dict((str(k), str(v)) for k, v in os.environ.items()) + env["PYTHONPATH"] = os.pathsep.join(sys.path) + return env + + def testExecuteViewHelp(self): + """Test if the main module is well connected. + + Uses subprocess to avoid to parasite the current environment. + """ + env = self.createTestEnv() + commandLine = [sys.executable, main.__file__, "--help"] + self.executeCommandLine(commandLine, env) + + def testExecuteSilxViewHelp(self): + """Test if the main module is well connected. + + Uses subprocess to avoid to parasite the current environment. + """ + env = self.createTestEnv() + commandLine = [sys.executable, silx_main.__file__, "view", "--help"] + self.executeCommandLine(commandLine, env) + + +def suite(): + test_suite = unittest.TestSuite() + loader = unittest.defaultTestLoader.loadTestsFromTestCase + test_suite.addTest(loader(TestLauncher)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/app/view/test/test_view.py b/silx/app/view/test/test_view.py new file mode 100644 index 0000000..010cda5 --- /dev/null +++ b/silx/app/view/test/test_view.py @@ -0,0 +1,402 @@ +# 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. +# +# ###########################################################################*/ +"""Module testing silx.app.view""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/06/2018" + + +import unittest +import weakref +import numpy +import tempfile +import shutil +import os.path +try: + import h5py +except ImportError: + h5py = None + +from silx.gui import qt +from silx.app.view.Viewer import Viewer +from silx.app.view.About import About +from silx.app.view.DataPanel import DataPanel +from silx.app.view.CustomNxdataWidget import CustomNxdataWidget +from silx.gui.hdf5._utils import Hdf5DatasetMimeData +from silx.gui.test.utils import TestCaseQt +from silx.io import commonh5 + +_tmpDirectory = None + + +def setUpModule(): + global _tmpDirectory + _tmpDirectory = tempfile.mkdtemp(prefix=__name__) + + if h5py is not None: + # create h5 data + filename = _tmpDirectory + "/data.h5" + f = h5py.File(filename, "w") + g = f.create_group("arrays") + g.create_dataset("scalar", data=10) + g.create_dataset("integers", data=numpy.array([10, 20, 30])) + f.close() + + # create h5 data + filename = _tmpDirectory + "/data2.h5" + f = h5py.File(filename, "w") + g = f.create_group("arrays") + g.create_dataset("scalar", data=20) + g.create_dataset("integers", data=numpy.array([10, 20, 30])) + f.close() + + +def tearDownModule(): + global _tmpDirectory + shutil.rmtree(_tmpDirectory) + _tmpDirectory = None + + +class TestViewer(TestCaseQt): + """Test for Viewer class""" + + def testConstruct(self): + widget = Viewer() + self.qWaitForWindowExposed(widget) + + def testDestroy(self): + widget = Viewer() + ref = weakref.ref(widget) + widget = None + self.qWaitForDestroy(ref) + + +class TestAbout(TestCaseQt): + """Test for About box class""" + + def testConstruct(self): + widget = About() + self.qWaitForWindowExposed(widget) + + def testLicense(self): + widget = About() + widget.getHtmlLicense() + self.qWaitForWindowExposed(widget) + + def testDestroy(self): + widget = About() + ref = weakref.ref(widget) + widget = None + self.qWaitForDestroy(ref) + + +class TestDataPanel(TestCaseQt): + + def testConstruct(self): + widget = DataPanel() + self.qWaitForWindowExposed(widget) + + def testDestroy(self): + widget = DataPanel() + ref = weakref.ref(widget) + widget = None + self.qWaitForDestroy(ref) + + def testHeaderLabelPaintEvent(self): + widget = DataPanel() + data = numpy.array([1, 2, 3, 4, 5]) + widget.setData(data) + # Expected to execute HeaderLabel.paintEvent + widget.setVisible(True) + self.qWaitForWindowExposed(widget) + + def testData(self): + widget = DataPanel() + data = numpy.array([1, 2, 3, 4, 5]) + widget.setData(data) + self.assertIs(widget.getData(), data) + self.assertIs(widget.getCustomNxdataItem(), None) + + def testDataNone(self): + widget = DataPanel() + widget.setData(None) + self.assertIs(widget.getData(), None) + self.assertIs(widget.getCustomNxdataItem(), None) + + def testCustomDataItem(self): + class CustomDataItemMock(object): + def getVirtualGroup(self): + return None + + def text(self): + return "" + + data = CustomDataItemMock() + widget = DataPanel() + widget.setCustomDataItem(data) + self.assertIs(widget.getData(), None) + self.assertIs(widget.getCustomNxdataItem(), data) + + def testCustomDataItemNone(self): + data = None + widget = DataPanel() + widget.setCustomDataItem(data) + self.assertIs(widget.getData(), None) + self.assertIs(widget.getCustomNxdataItem(), data) + + @unittest.skipIf(h5py is None, "Could not import h5py") + def testRemoveDatasetsFrom(self): + f = h5py.File(os.path.join(_tmpDirectory, "data.h5")) + try: + widget = DataPanel() + widget.setData(f["arrays/scalar"]) + widget.removeDatasetsFrom(f) + self.assertIs(widget.getData(), None) + finally: + widget.setData(None) + f.close() + + @unittest.skipIf(h5py is None, "Could not import h5py") + def testReplaceDatasetsFrom(self): + f = h5py.File(os.path.join(_tmpDirectory, "data.h5")) + f2 = h5py.File(os.path.join(_tmpDirectory, "data2.h5")) + try: + widget = DataPanel() + widget.setData(f["arrays/scalar"]) + self.assertEqual(widget.getData()[()], 10) + widget.replaceDatasetsFrom(f, f2) + self.assertEqual(widget.getData()[()], 20) + finally: + widget.setData(None) + f.close() + f2.close() + + +class TestCustomNxdataWidget(TestCaseQt): + + def testConstruct(self): + widget = CustomNxdataWidget() + self.qWaitForWindowExposed(widget) + + def testDestroy(self): + widget = CustomNxdataWidget() + ref = weakref.ref(widget) + widget = None + self.qWaitForDestroy(ref) + + def testCreateNxdata(self): + widget = CustomNxdataWidget() + model = widget.model() + model.createNewNxdata() + model.createNewNxdata("Foo") + widget.setVisible(True) + self.qWaitForWindowExposed(widget) + + def testCreateNxdataFromDataset(self): + widget = CustomNxdataWidget() + model = widget.model() + signal = commonh5.Dataset("foo", data=numpy.array([[[5]]])) + model.createFromSignal(signal) + widget.setVisible(True) + self.qWaitForWindowExposed(widget) + + def testCreateNxdataFromNxdata(self): + widget = CustomNxdataWidget() + model = widget.model() + data = numpy.array([[[5]]]) + nxdata = commonh5.Group("foo") + nxdata.attrs["NX_class"] = "NXdata" + nxdata.attrs["signal"] = "signal" + nxdata.create_dataset("signal", data=data) + model.createFromNxdata(nxdata) + widget.setVisible(True) + self.qWaitForWindowExposed(widget) + + def testCreateBadNxdata(self): + widget = CustomNxdataWidget() + model = widget.model() + signal = commonh5.Dataset("foo", data=numpy.array([[[5]]])) + model.createFromSignal(signal) + axis = commonh5.Dataset("foo", data=numpy.array([[[5]]])) + nxdataIndex = model.index(0, 0) + item = model.itemFromIndex(nxdataIndex) + item.setAxesDatasets([axis]) + nxdata = item.getVirtualGroup() + self.assertIsNotNone(nxdata) + self.assertFalse(item.isValid()) + + @unittest.skipIf(h5py is None, "Could not import h5py") + def testRemoveDatasetsFrom(self): + f = h5py.File(os.path.join(_tmpDirectory, "data.h5")) + try: + widget = CustomNxdataWidget() + model = widget.model() + dataset = f["arrays/integers"] + model.createFromSignal(dataset) + widget.removeDatasetsFrom(f) + finally: + model.clear() + f.close() + + @unittest.skipIf(h5py is None, "Could not import h5py") + def testReplaceDatasetsFrom(self): + f = h5py.File(os.path.join(_tmpDirectory, "data.h5")) + f2 = h5py.File(os.path.join(_tmpDirectory, "data2.h5")) + try: + widget = CustomNxdataWidget() + model = widget.model() + dataset = f["arrays/integers"] + model.createFromSignal(dataset) + widget.replaceDatasetsFrom(f, f2) + finally: + model.clear() + f.close() + f2.close() + + +class TestCustomNxdataWidgetInteraction(TestCaseQt): + """Test CustomNxdataWidget with user interaction""" + + def setUp(self): + TestCaseQt.setUp(self) + + self.widget = CustomNxdataWidget() + self.model = self.widget.model() + data = numpy.array([[[5]]]) + dataset = commonh5.Dataset("foo", data=data) + self.model.createFromSignal(dataset) + self.selectionModel = self.widget.selectionModel() + + def tearDown(self): + self.selectionModel = None + self.model.clear() + self.model = None + self.widget = None + TestCaseQt.tearDown(self) + + def testSelectedNxdata(self): + index = self.model.index(0, 0) + self.selectionModel.setCurrentIndex(index, qt.QItemSelectionModel.ClearAndSelect) + nxdata = self.widget.selectedNxdata() + self.assertEqual(len(nxdata), 1) + self.assertIsNot(nxdata[0], None) + + def testSelectedItems(self): + index = self.model.index(0, 0) + self.selectionModel.setCurrentIndex(index, qt.QItemSelectionModel.ClearAndSelect) + items = self.widget.selectedItems() + self.assertEqual(len(items), 1) + self.assertIsNot(items[0], None) + self.assertIsInstance(items[0], qt.QStandardItem) + + def testRowsAboutToBeRemoved(self): + self.model.removeRow(0) + self.qWaitForWindowExposed(self.widget) + + def testPaintItems(self): + self.widget.expandAll() + self.widget.setVisible(True) + self.qWaitForWindowExposed(self.widget) + + def testCreateDefaultContextMenu(self): + nxDataIndex = self.model.index(0, 0) + menu = self.widget.createDefaultContextMenu(nxDataIndex) + self.assertIsNot(menu, None) + self.assertIsInstance(menu, qt.QMenu) + + signalIndex = self.model.index(0, 0, nxDataIndex) + menu = self.widget.createDefaultContextMenu(signalIndex) + self.assertIsNot(menu, None) + self.assertIsInstance(menu, qt.QMenu) + + axesIndex = self.model.index(1, 0, nxDataIndex) + menu = self.widget.createDefaultContextMenu(axesIndex) + self.assertIsNot(menu, None) + self.assertIsInstance(menu, qt.QMenu) + + def testDropNewDataset(self): + dataset = commonh5.Dataset("foo", numpy.array([1, 2, 3, 4])) + mimedata = Hdf5DatasetMimeData(dataset=dataset) + self.model.dropMimeData(mimedata, qt.Qt.CopyAction, -1, -1, qt.QModelIndex()) + self.assertEqual(self.model.rowCount(qt.QModelIndex()), 2) + + def testDropNewNxdata(self): + data = numpy.array([[[5]]]) + nxdata = commonh5.Group("foo") + nxdata.attrs["NX_class"] = "NXdata" + nxdata.attrs["signal"] = "signal" + nxdata.create_dataset("signal", data=data) + mimedata = Hdf5DatasetMimeData(dataset=nxdata) + self.model.dropMimeData(mimedata, qt.Qt.CopyAction, -1, -1, qt.QModelIndex()) + self.assertEqual(self.model.rowCount(qt.QModelIndex()), 2) + + def testDropAxisDataset(self): + dataset = commonh5.Dataset("foo", numpy.array([1, 2, 3, 4])) + mimedata = Hdf5DatasetMimeData(dataset=dataset) + nxDataIndex = self.model.index(0, 0) + axesIndex = self.model.index(1, 0, nxDataIndex) + self.model.dropMimeData(mimedata, qt.Qt.CopyAction, -1, -1, axesIndex) + self.assertEqual(self.model.rowCount(qt.QModelIndex()), 1) + item = self.model.itemFromIndex(axesIndex) + self.assertIsNot(item.getDataset(), None) + + def testMimeData(self): + nxDataIndex = self.model.index(0, 0) + signalIndex = self.model.index(0, 0, nxDataIndex) + mimeData = self.model.mimeData([signalIndex]) + self.assertIsNot(mimeData, None) + self.assertIsInstance(mimeData, qt.QMimeData) + + def testRemoveNxdataItem(self): + nxdataIndex = self.model.index(0, 0) + item = self.model.itemFromIndex(nxdataIndex) + self.model.removeNxdataItem(item) + + def testAppendAxisToNxdataItem(self): + nxdataIndex = self.model.index(0, 0) + item = self.model.itemFromIndex(nxdataIndex) + self.model.appendAxisToNxdataItem(item) + + def testRemoveAxisItem(self): + nxdataIndex = self.model.index(0, 0) + axesIndex = self.model.index(1, 0, nxdataIndex) + item = self.model.itemFromIndex(axesIndex) + self.model.removeAxisItem(item) + + +def suite(): + test_suite = unittest.TestSuite() + loader = unittest.defaultTestLoader.loadTestsFromTestCase + test_suite.addTest(loader(TestViewer)) + test_suite.addTest(loader(TestAbout)) + test_suite.addTest(loader(TestDataPanel)) + test_suite.addTest(loader(TestCustomNxdataWidget)) + test_suite.addTest(loader(TestCustomNxdataWidgetInteraction)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/app/view/utils.py b/silx/app/view/utils.py new file mode 100644 index 0000000..80167c8 --- /dev/null +++ b/silx/app/view/utils.py @@ -0,0 +1,45 @@ +# 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. +# +# ############################################################################*/ +"""Browse a data file with a GUI""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "28/05/2018" + + +_trueStrings = set(["yes", "true", "1"]) +_falseStrings = set(["no", "false", "0"]) + + +def stringToBool(string): + """Returns a boolean from a string. + + :raise ValueError: If the string do not contains a boolean information. + """ + lower = string.lower() + if lower in _trueStrings: + return True + if lower in _falseStrings: + return False + raise ValueError("'%s' is not a valid boolean" % string) diff --git a/silx/gui/__init__.py b/silx/gui/__init__.py index 6baf238..b796e20 100644 --- a/silx/gui/__init__.py +++ b/silx/gui/__init__.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 @@ -22,7 +22,27 @@ # THE SOFTWARE. # # ###########################################################################*/ -"""Set of Qt widgets""" +"""This package provides a set of Qt widgets. + +It contains the following sub-packages and modules: + +- silx.gui.colors: Functions to handle colors and colormap +- silx.gui.console: IPython console widget +- silx.gui.data: + Widgets for displaying data arrays using table views and plot widgets +- silx.gui.dialog: Specific dialog widgets +- silx.gui.fit: Widgets for controlling curve fitting +- silx.gui.hdf5: Widgets for displaying content relative to HDF5 format +- silx.gui.icons: Functions to access embedded icons +- silx.gui.plot: Widgets for 1D and 2D plotting and related tools +- silx.gui.plot3d: Widgets for visualizing data in 3D based on OpenGL +- silx.gui.printer: Shared printer used by the library +- silx.gui.qt: Common wrapper over different Python Qt binding +- silx.gui.utils: Miscellaneous helpers for Qt +- silx.gui.widgets: Miscellaneous standalone widgets + +See silx documentation: http://www.silx.org/doc/silx/latest/ +""" __authors__ = ["T. Vincent"] __license__ = "MIT" diff --git a/silx/gui/_glutils/font.py b/silx/gui/_glutils/font.py index 2be2c04..b5bd6b5 100644 --- a/silx/gui/_glutils/font.py +++ b/silx/gui/_glutils/font.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 @@ -30,11 +30,10 @@ __date__ = "13/10/2016" import logging -import sys import numpy -from .. import qt -from .._utils 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 new file mode 100644 index 0000000..028609b --- /dev/null +++ b/silx/gui/colors.py @@ -0,0 +1,732 @@ +# 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 API to manage colors. +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent", "H.Payno"] +__license__ = "MIT" +__date__ = "14/06/2018" + +from silx.gui import qt +import copy as copy_mdl +import numpy +import logging +from silx.math.combo import min_max +from silx.math.colormap import cmap as _cmap +from silx.utils.exceptions import NotEditableError + +_logger = logging.getLogger(__file__) + + +_COLORDICT = {} +"""Dictionary of common colors.""" + +_COLORDICT['b'] = _COLORDICT['blue'] = '#0000ff' +_COLORDICT['r'] = _COLORDICT['red'] = '#ff0000' +_COLORDICT['g'] = _COLORDICT['green'] = '#00ff00' +_COLORDICT['k'] = _COLORDICT['black'] = '#000000' +_COLORDICT['w'] = _COLORDICT['white'] = '#ffffff' +_COLORDICT['pink'] = '#ff66ff' +_COLORDICT['brown'] = '#a52a2a' +_COLORDICT['orange'] = '#ff9900' +_COLORDICT['violet'] = '#6600ff' +_COLORDICT['gray'] = _COLORDICT['grey'] = '#a0a0a4' +# _COLORDICT['darkGray'] = _COLORDICT['darkGrey'] = '#808080' +# _COLORDICT['lightGray'] = _COLORDICT['lightGrey'] = '#c0c0c0' +_COLORDICT['y'] = _COLORDICT['yellow'] = '#ffff00' +_COLORDICT['m'] = _COLORDICT['magenta'] = '#ff00ff' +_COLORDICT['c'] = _COLORDICT['cyan'] = '#00ffff' +_COLORDICT['darkBlue'] = '#000080' +_COLORDICT['darkRed'] = '#800000' +_COLORDICT['darkGreen'] = '#008000' +_COLORDICT['darkBrown'] = '#660000' +_COLORDICT['darkCyan'] = '#008080' +_COLORDICT['darkYellow'] = '#808000' +_COLORDICT['darkMagenta'] = '#800080' + + +# FIXME: It could be nice to expose a functional API instead of that attribute +COLORDICT = _COLORDICT + + +def rgba(color, colorDict=None): + """Convert color code '#RRGGBB' and '#RRGGBBAA' to (R, G, B, A) + + It also convert RGB(A) values from uint8 to float in [0, 1] and + accept a QColor as color argument. + + :param str color: The color to convert + :param dict colorDict: A dictionary of color name conversion to color code + :returns: RGBA colors as floats in [0., 1.] + :rtype: tuple + """ + if colorDict is None: + colorDict = _COLORDICT + + if hasattr(color, 'getRgbF'): # QColor support + color = color.getRgbF() + + values = numpy.asarray(color).ravel() + + if values.dtype.kind in 'iuf': # integer or float + # Color is an array + assert len(values) in (3, 4) + + # Convert from integers in [0, 255] to float in [0, 1] + if values.dtype.kind in 'iu': + values = values / 255. + + # Clip to [0, 1] + values[values < 0.] = 0. + values[values > 1.] = 1. + + if len(values) == 3: + return values[0], values[1], values[2], 1. + else: + return tuple(values) + + # We assume color is a string + if not color.startswith('#'): + color = colorDict[color] + + assert len(color) in (7, 9) and color[0] == '#' + r = int(color[1:3], 16) / 255. + g = int(color[3:5], 16) / 255. + b = int(color[5:7], 16) / 255. + a = int(color[7:9], 16) / 255. if len(color) == 9 else 1. + return r, g, b, a + + +_COLORMAP_CURSOR_COLORS = { + 'gray': 'pink', + 'reversed gray': 'pink', + 'temperature': 'pink', + 'red': 'green', + 'green': 'pink', + 'blue': 'yellow', + 'jet': 'pink', + 'viridis': 'pink', + 'magma': 'green', + 'inferno': 'green', + 'plasma': 'green', +} + + +def cursorColorForColormap(colormapName): + """Get a color suitable for overlay over a colormap. + + :param str colormapName: The name of the colormap. + :return: Name of the color. + :rtype: str + """ + return _COLORMAP_CURSOR_COLORS.get(colormapName, 'black') + + +DEFAULT_COLORMAPS = ( + 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue') +"""Tuple of supported colormap names.""" + +DEFAULT_MIN_LIN = 0 +"""Default min value if in linear normalization""" +DEFAULT_MAX_LIN = 1 +"""Default max value if in linear normalization""" +DEFAULT_MIN_LOG = 1 +"""Default min value if in log normalization""" +DEFAULT_MAX_LOG = 10 +"""Default max value if in log normalization""" + + +class Colormap(qt.QObject): + """Description of a colormap + + :param str name: Name of the colormap + :param tuple colors: optional, custom colormap. + Nx3 or Nx4 numpy array of RGB(A) colors, + either uint8 or float in [0, 1]. + If 'name' is None, then this array is used as the colormap. + :param str normalization: Normalization: 'linear' (default) or 'log' + :param float vmin: + Lower bound of the colormap or None for autoscale (default) + :param float vmax: + Upper bounds of the colormap or None for autoscale (default) + """ + + LINEAR = 'linear' + """constant for linear normalization""" + + LOGARITHM = 'log' + """constant for logarithmic normalization""" + + NORMALIZATIONS = (LINEAR, LOGARITHM) + """Tuple of managed normalizations""" + + sigChanged = qt.Signal() + """Signal emitted when the colormap has changed.""" + + def __init__(self, name='gray', colors=None, normalization=LINEAR, vmin=None, vmax=None): + qt.QObject.__init__(self) + assert normalization in Colormap.NORMALIZATIONS + assert not (name is None and colors is None) + if normalization is Colormap.LOGARITHM: + if (vmin is not None and vmin < 0) or (vmax is not None and vmax < 0): + m = "Unsuported vmin (%s) and/or vmax (%s) given for a log scale." + m += ' Autoscale will be performed.' + m = m % (vmin, vmax) + _logger.warning(m) + vmin = None + vmax = None + + self._name = str(name) if name is not None else None + self._setColors(colors) + self._normalization = str(normalization) + self._vmin = float(vmin) if vmin is not None else None + self._vmax = float(vmax) if vmax is not None else None + self._editable = True + + def isAutoscale(self): + """Return True if both min and max are in autoscale mode""" + return self._vmin is None and self._vmax is None + + def getName(self): + """Return the name of the colormap + :rtype: str + """ + return self._name + + @staticmethod + def _convertColorsFromFloatToUint8(colors): + """Convert colors from float in [0, 1] to uint8 + + :param numpy.ndarray colors: Array of float colors to convert + :return: colors as uint8 + :rtype: numpy.ndarray + """ + # Each bin is [N, N+1[ except the last one: [255, 256] + return numpy.clip( + colors.astype(numpy.float64) * 256, 0., 255.).astype(numpy.uint8) + + def _setColors(self, colors): + if colors is None: + self._colors = None + else: + colors = numpy.array(colors, copy=False) + colors.shape = -1, colors.shape[-1] + if colors.dtype.kind == 'f': + colors = self._convertColorsFromFloatToUint8(colors) + + # Makes sure it is RGBA8888 + self._colors = numpy.zeros((len(colors), 4), dtype=numpy.uint8) + self._colors[:, 3] = 255 # Alpha channel + self._colors[:, :colors.shape[1]] = colors # Copy colors + + def getNColors(self, nbColors=None): + """Returns N colors computed by sampling the colormap regularly. + + :param nbColors: + The number of colors in the returned array or None for the default value. + The default value is 256 for colormap with a name (see :meth:`setName`) and + it is the size of the LUT for colormap defined with :meth:`setColormapLUT`. + :type nbColors: int or None + :return: 2D array of uint8 of shape (nbColors, 4) + :rtype: numpy.ndarray + """ + # Handle default value for nbColors + if nbColors is None: + lut = self.getColormapLUT() + if lut is not None: # In this case uses LUT length + nbColors = len(lut) + else: # Default to 256 + nbColors = 256 + + nbColors = int(nbColors) + + colormap = self.copy() + colormap.setNormalization(Colormap.LINEAR) + colormap.setVRange(vmin=None, vmax=None) + colors = colormap.applyToData( + numpy.arange(nbColors, dtype=numpy.int)) + return colors + + def setName(self, name): + """Set the name of the colormap to use. + + :param str name: The name of the colormap. + At least the following names are supported: 'gray', + 'reversed gray', 'temperature', 'red', 'green', 'blue', 'jet', + 'viridis', 'magma', 'inferno', 'plasma'. + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + assert name in self.getSupportedColormaps() + self._name = str(name) + self._colors = None + self.sigChanged.emit() + + def getColormapLUT(self): + """Return the list of colors for the colormap or None if not set + + :return: the list of colors for the colormap or None if not set + :rtype: numpy.ndarray or None + """ + if self._colors is None: + return None + else: + return numpy.array(self._colors, copy=True) + + def setColormapLUT(self, colors): + """Set the colors of the colormap. + + :param numpy.ndarray colors: the colors of the LUT. + If float, it is converted from [0, 1] to uint8 range. + Otherwise it is casted to uint8. + + .. warning: this will set the value of name to None + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + self._setColors(colors) + if len(colors) is 0: + self._colors = None + + self._name = None + self.sigChanged.emit() + + def getNormalization(self): + """Return the normalization of the colormap ('log' or 'linear') + + :return: the normalization of the colormap + :rtype: str + """ + return self._normalization + + def setNormalization(self, norm): + """Set the norm ('log', 'linear') + + :param str norm: the norm to set + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + self._normalization = str(norm) + self.sigChanged.emit() + + def getVMin(self): + """Return the lower bound of the colormap + + :return: the lower bound of the colormap + :rtype: float or None + """ + return self._vmin + + def setVMin(self, vmin): + """Set the minimal value of the colormap + + :param float vmin: Lower bound of the colormap or None for autoscale + (default) + value) + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + if vmin is not None: + if self._vmax is not None and vmin > self._vmax: + err = "Can't set vmin because vmin >= vmax. " \ + "vmin = %s, vmax = %s" % (vmin, self._vmax) + raise ValueError(err) + + self._vmin = vmin + self.sigChanged.emit() + + def getVMax(self): + """Return the upper bounds of the colormap or None + + :return: the upper bounds of the colormap or None + :rtype: float or None + """ + return self._vmax + + def setVMax(self, vmax): + """Set the maximal value of the colormap + + :param float vmax: Upper bounds of the colormap or None for autoscale + (default) + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + if vmax is not None: + if self._vmin is not None and vmax < self._vmin: + err = "Can't set vmax because vmax <= vmin. " \ + "vmin = %s, vmax = %s" % (self._vmin, vmax) + raise ValueError(err) + + self._vmax = vmax + self.sigChanged.emit() + + def isEditable(self): + """ Return if the colormap is editable or not + + :return: editable state of the colormap + :rtype: bool + """ + return self._editable + + def setEditable(self, editable): + """ + Set the editable state of the colormap + + :param bool editable: is the colormap editable + """ + assert type(editable) is bool + self._editable = editable + self.sigChanged.emit() + + def getColormapRange(self, data=None): + """Return (vmin, vmax) + + :return: the tuple vmin, vmax fitting vmin, vmax, normalization and + data if any given + :rtype: tuple + """ + vmin = self._vmin + vmax = self._vmax + assert vmin is None or vmax is None or vmin <= vmax # TODO handle this in setters + + if self.getNormalization() == self.LOGARITHM: + # Handle negative bounds as autoscale + if vmin is not None and (vmin is not None and vmin <= 0.): + mess = 'negative vmin, moving to autoscale for lower bound' + _logger.warning(mess) + vmin = None + if vmax is not None and (vmax is not None and vmax <= 0.): + mess = 'negative vmax, moving to autoscale for upper bound' + _logger.warning(mess) + vmax = None + + if vmin is None or vmax is None: # Handle autoscale + # Get min/max from data + if data is not None: + data = numpy.array(data, copy=False) + if data.size == 0: # Fallback an array but no data + min_, max_ = self._getDefaultMin(), self._getDefaultMax() + else: + if self.getNormalization() == self.LOGARITHM: + result = min_max(data, min_positive=True, finite=True) + min_ = result.min_positive # >0 or None + max_ = result.maximum # can be <= 0 + else: + min_, max_ = min_max(data, min_positive=False, finite=True) + + # Handle fallback + if min_ is None or not numpy.isfinite(min_): + min_ = self._getDefaultMin() + if max_ is None or not numpy.isfinite(max_): + max_ = self._getDefaultMax() + else: # Fallback if no data is provided + min_, max_ = self._getDefaultMin(), self._getDefaultMax() + + if vmin is None: # Set vmin respecting provided vmax + vmin = min_ if vmax is None else min(min_, vmax) + + if vmax is None: + vmax = max(max_, vmin) # Handle max_ <= 0 for log scale + + return vmin, vmax + + def setVRange(self, vmin, vmax): + """Set the bounds of the colormap + + :param vmin: Lower bound of the colormap or None for autoscale + (default) + :param vmax: Upper bounds of the colormap or None for autoscale + (default) + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + if vmin is not None and vmax is not None: + if vmin > vmax: + err = "Can't set vmin and vmax because vmin >= vmax " \ + "vmin = %s, vmax = %s" % (vmin, vmax) + raise ValueError(err) + + if self._vmin == vmin and self._vmax == vmax: + return + + self._vmin = vmin + self._vmax = vmax + self.sigChanged.emit() + + def __getitem__(self, item): + if item == 'autoscale': + return self.isAutoscale() + elif item == 'name': + return self.getName() + elif item == 'normalization': + return self.getNormalization() + elif item == 'vmin': + return self.getVMin() + elif item == 'vmax': + return self.getVMax() + elif item == 'colors': + return self.getColormapLUT() + else: + raise KeyError(item) + + def _toDict(self): + """Return the equivalent colormap as a dictionary + (old colormap representation) + + :return: the representation of the Colormap as a dictionary + :rtype: dict + """ + return { + 'name': self._name, + 'colors': copy_mdl.copy(self._colors), + 'vmin': self._vmin, + 'vmax': self._vmax, + 'autoscale': self.isAutoscale(), + 'normalization': self._normalization + } + + def _setFromDict(self, dic): + """Set values to the colormap from a dictionary + + :param dict dic: the colormap as a dictionary + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + name = dic['name'] if 'name' in dic else None + colors = dic['colors'] if 'colors' in dic else None + vmin = dic['vmin'] if 'vmin' in dic else None + vmax = dic['vmax'] if 'vmax' in dic else None + if 'normalization' in dic: + normalization = dic['normalization'] + else: + warn = 'Normalization not given in the dictionary, ' + warn += 'set by default to ' + Colormap.LINEAR + _logger.warning(warn) + normalization = Colormap.LINEAR + + if name is None and colors is None: + err = 'The colormap should have a name defined or a tuple of colors' + raise ValueError(err) + if normalization not in Colormap.NORMALIZATIONS: + err = 'Given normalization is not recoginized (%s)' % normalization + raise ValueError(err) + + # If autoscale, then set boundaries to None + if dic.get('autoscale', False): + vmin, vmax = None, None + + self._name = name + self._colors = colors + self._vmin = vmin + self._vmax = vmax + self._autoscale = True if (vmin is None and vmax is None) else False + self._normalization = normalization + + self.sigChanged.emit() + + @staticmethod + def _fromDict(dic): + colormap = Colormap(name="") + colormap._setFromDict(dic) + return colormap + + def copy(self): + """Return a copy of the Colormap. + + :rtype: silx.gui.colors.Colormap + """ + return Colormap(name=self._name, + colors=copy_mdl.copy(self._colors), + vmin=self._vmin, + vmax=self._vmax, + normalization=self._normalization) + + def applyToData(self, data): + """Apply the colormap to the data + + :param numpy.ndarray data: The data to convert. + """ + name = self.getName() + if name is not None: # Get colormap definition from matplotlib + # FIXME: If possible remove dependency to the plot + from .plot.matplotlib import Colormap as MPLColormap + mplColormap = MPLColormap.getColormap(name) + colors = mplColormap(numpy.linspace(0, 1, 256, endpoint=True)) + colors = self._convertColorsFromFloatToUint8(colors) + + else: # Use user defined LUT + colors = self.getColormapLUT() + + vmin, vmax = self.getColormapRange(data) + normalization = self.getNormalization() + + return _cmap(data, colors, vmin, vmax, normalization) + + @staticmethod + def getSupportedColormaps(): + """Get the supported colormap names as a tuple of str. + + The list should at least contain and start by: + ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue') + :rtype: tuple + """ + # FIXME: If possible remove dependency to the plot + from .plot.matplotlib import Colormap as MPLColormap + maps = MPLColormap.getSupportedColormaps() + return DEFAULT_COLORMAPS + maps + + def __str__(self): + return str(self._toDict()) + + def _getDefaultMin(self): + return DEFAULT_MIN_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MIN_LOG + + def _getDefaultMax(self): + return DEFAULT_MAX_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MAX_LOG + + def __eq__(self, other): + """Compare colormap values and not pointers""" + return (self.getName() == other.getName() and + self.getNormalization() == other.getNormalization() and + self.getVMin() == other.getVMin() and + self.getVMax() == other.getVMax() and + numpy.array_equal(self.getColormapLUT(), other.getColormapLUT()) + ) + + _SERIAL_VERSION = 1 + + def restoreState(self, byteArray): + """ + Read the colormap state from a QByteArray. + + :param qt.QByteArray byteArray: Stream containing the state + :return: True if the restoration sussseed + :rtype: bool + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + stream = qt.QDataStream(byteArray, qt.QIODevice.ReadOnly) + + className = stream.readQString() + if className != self.__class__.__name__: + _logger.warning("Classname mismatch. Found %s." % className) + return False + + version = stream.readUInt32() + if version != self._SERIAL_VERSION: + _logger.warning("Serial version mismatch. Found %d." % version) + return False + + name = stream.readQString() + isNull = stream.readBool() + if not isNull: + vmin = stream.readQVariant() + else: + vmin = None + isNull = stream.readBool() + if not isNull: + vmax = stream.readQVariant() + else: + vmax = None + normalization = stream.readQString() + + # emit change event only once + old = self.blockSignals(True) + try: + self.setName(name) + self.setNormalization(normalization) + self.setVRange(vmin, vmax) + finally: + self.blockSignals(old) + self.sigChanged.emit() + return True + + def saveState(self): + """ + Save state of the colomap into a QDataStream. + + :rtype: qt.QByteArray + """ + data = qt.QByteArray() + stream = qt.QDataStream(data, qt.QIODevice.WriteOnly) + + stream.writeQString(self.__class__.__name__) + stream.writeUInt32(self._SERIAL_VERSION) + stream.writeQString(self.getName()) + stream.writeBool(self.getVMin() is None) + if self.getVMin() is not None: + stream.writeQVariant(self.getVMin()) + stream.writeBool(self.getVMax() is None) + if self.getVMax() is not None: + stream.writeQVariant(self.getVMax()) + stream.writeQString(self.getNormalization()) + return data + + +_PREFERRED_COLORMAPS = None +""" +Tuple of preferred colormap names accessed with :meth:`preferredColormaps`. +""" + + +def preferredColormaps(): + """Returns the name of the preferred colormaps. + + This list is used by widgets allowing to change the colormap + like the :class:`ColormapDialog` as a subset of colormap choices. + + :rtype: tuple of str + """ + global _PREFERRED_COLORMAPS + if _PREFERRED_COLORMAPS is None: + _PREFERRED_COLORMAPS = DEFAULT_COLORMAPS + # Initialize preferred colormaps + setPreferredColormaps(('gray', 'reversed gray', + 'temperature', 'red', 'green', 'blue', 'jet', + 'viridis', 'magma', 'inferno', 'plasma', + 'hsv')) + return _PREFERRED_COLORMAPS + + +def setPreferredColormaps(colormaps): + """Set the list of preferred colormap names. + + Warning: If a colormap name is not available + it will be removed from the list. + + :param colormaps: Not empty list of colormap names + :type colormaps: iterable of str + :raise ValueError: if the list of available preferred colormaps is empty. + """ + supportedColormaps = Colormap.getSupportedColormaps() + colormaps = tuple( + cmap for cmap in colormaps if cmap in supportedColormaps) + if len(colormaps) == 0: + raise ValueError("Cannot set preferred colormaps to an empty list") + + global _PREFERRED_COLORMAPS + _PREFERRED_COLORMAPS = colormaps diff --git a/silx/gui/console.py b/silx/gui/console.py index 3c69419..b6341ef 100644 --- a/silx/gui/console.py +++ b/silx/gui/console.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 @@ -34,9 +34,8 @@ the widgets' methods from the console. .. note:: This module has a dependency on - `IPython <https://pypi.python.org/pypi/ipython>`_ and - `qtconsole <https://pypi.python.org/pypi/qtconsole>`_ (or *ipython.qt* for - older versions of *IPython*). An ``ImportError`` will be raised if it is + `qtconsole <https://pypi.org/project/qtconsole/>`_. + An ``ImportError`` will be raised if it is imported while the dependencies are not satisfied. Basic usage example:: @@ -76,11 +75,7 @@ from . import qt _logger = logging.getLogger(__name__) -try: - import IPython -except ImportError as e: - raise ImportError("Failed to import IPython, required by " + __name__) - + # This widget cannot be used inside an interactive IPython shell. # It would raise MultipleInstanceError("Multiple incompatible subclass # instances of InProcessInteractiveShell are being created"). @@ -92,48 +87,14 @@ else: msg = "Module " + __name__ + " cannot be used within an IPython shell" raise ImportError(msg) -# qtconsole is a separate module in recent versions of IPython/Jupyter -# http://blog.jupyter.org/2015/04/15/the-big-split/ -if IPython.__version__.startswith("2"): - qtconsole = None -else: - try: - import qtconsole - except ImportError: - qtconsole = None - -if qtconsole is not None: - try: - from qtconsole.rich_ipython_widget import RichJupyterWidget as \ - RichIPythonWidget - except ImportError: - try: - from qtconsole.rich_ipython_widget import RichIPythonWidget - except ImportError as e: - qtconsole = None - else: - from qtconsole.inprocess import QtInProcessKernelManager - else: - from qtconsole.inprocess import QtInProcessKernelManager - - -if qtconsole is None: - # Import the console machinery from ipython - - # The `has_binding` test of IPython does not find the Qt bindings - # in case silx is used in a frozen binary - import IPython.external.qt_loaders - - def has_binding(*var, **kw): - return True - - IPython.external.qt_loaders.has_binding = has_binding - - try: - from IPython.qtconsole.rich_ipython_widget import RichIPythonWidget - except ImportError: - from IPython.qt.console.rich_ipython_widget import RichIPythonWidget - from IPython.qt.inprocess import QtInProcessKernelManager + +try: + from qtconsole.rich_ipython_widget import RichJupyterWidget as \ + RichIPythonWidget +except ImportError: + from qtconsole.rich_ipython_widget import RichIPythonWidget + +from qtconsole.inprocess import QtInProcessKernelManager class IPythonWidget(RichIPythonWidget): diff --git a/silx/gui/data/DataViewer.py b/silx/gui/data/DataViewer.py index 5e0b25e..4db2863 100644 --- a/silx/gui/data/DataViewer.py +++ b/silx/gui/data/DataViewer.py @@ -37,7 +37,7 @@ from silx.utils.property import classproperty __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "26/02/2018" +__date__ = "24/04/2018" _logger = logging.getLogger(__name__) @@ -167,8 +167,10 @@ class DataViewer(qt.QFrame): self.__currentAvailableViews = [] self.__currentView = None self.__data = None + self.__info = None self.__useAxisSelection = False self.__userSelectedView = None + self.__hooks = None self.__views = [] self.__index = {} @@ -182,6 +184,15 @@ class DataViewer(qt.QFrame): self.__views = list(views) self.setDisplayMode(DataViews.EMPTY_MODE) + def setGlobalHooks(self, hooks): + """Set a data view hooks for all the views + + :param DataViewHooks context: The hooks to use + """ + self.__hooks = hooks + for v in self.__views: + v.setHooks(hooks) + def createDefaultViews(self, parent=None): """Create and returns available views which can be displayed by default by the data viewer. It is called internally by the widget. It can be @@ -250,7 +261,7 @@ class DataViewer(qt.QFrame): """ previous = self.__numpySelection.blockSignals(True) self.__numpySelection.clear() - info = DataViews.DataInfo(self.__data) + info = self._getInfo() axisNames = self.__currentView.axesNames(self.__data, info) if info.isArray and info.size != 0 and self.__data is not None and axisNames is not None: self.__useAxisSelection = True @@ -359,6 +370,8 @@ class DataViewer(qt.QFrame): :param DataView view: A dataview """ + if self.__hooks is not None: + view.setHooks(self.__hooks) self.__views.append(view) # TODO It can be skipped if the view do not support the data self.__updateAvailableViews() @@ -390,8 +403,8 @@ class DataViewer(qt.QFrame): Update available views from the current data. """ data = self.__data + info = self._getInfo() # sort available views according to priority - info = DataViews.DataInfo(data) priorities = [v.getDataPriority(data, info) for v in self.__views] views = zip(priorities, self.__views) views = filter(lambda t: t[0] > DataViews.DataView.UNSUPPORTED, views) @@ -490,6 +503,7 @@ class DataViewer(qt.QFrame): :param numpy.ndarray data: The data. """ self.__data = data + self._invalidateInfo() self.__displayedData = None self.__updateView() self.__updateNumpySelectionAxis() @@ -512,6 +526,21 @@ class DataViewer(qt.QFrame): """Returns the data""" return self.__data + def _invalidateInfo(self): + """Invalidate DataInfo cache.""" + self.__info = None + + def _getInfo(self): + """Returns the DataInfo of the current selected data. + + This value is cached. + + :rtype: DataInfo + """ + if self.__info is None: + self.__info = DataViews.DataInfo(self.__data) + return self.__info + def displayMode(self): """Returns the current display mode""" return self.__currentView.modeId() @@ -552,6 +581,8 @@ class DataViewer(qt.QFrame): isReplaced = False for idx, view in enumerate(self.__views): if view.modeId() == modeId: + if self.__hooks is not None: + newView.setHooks(self.__hooks) self.__views[idx] = newView isReplaced = True break diff --git a/silx/gui/data/DataViewerFrame.py b/silx/gui/data/DataViewerFrame.py index 89a9992..4e6d2e8 100644 --- a/silx/gui/data/DataViewerFrame.py +++ b/silx/gui/data/DataViewerFrame.py @@ -27,7 +27,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "21/09/2017" +__date__ = "24/04/2018" from silx.gui import qt from .DataViewer import DataViewer @@ -113,6 +113,13 @@ class DataViewerFrame(qt.QWidget): """Called when the displayed view changes""" self.displayedViewChanged.emit(view) + def setGlobalHooks(self, hooks): + """Set a data view hooks for all the views + + :param DataViewHooks context: The hooks to use + """ + self.__dataViewer.setGlobalHooks(hooks) + def availableViews(self): """Returns the list of registered views diff --git a/silx/gui/data/DataViews.py b/silx/gui/data/DataViews.py index ef69441..2291e87 100644 --- a/silx/gui/data/DataViews.py +++ b/silx/gui/data/DataViews.py @@ -35,13 +35,13 @@ from silx.gui import qt, icons from silx.gui.data.TextFormatter import TextFormatter from silx.io import nxdata from silx.gui.hdf5 import H5Node -from silx.io.nxdata import get_attr_as_string -from silx.gui.plot.Colormap import Colormap -from silx.gui.plot.actions.control import ColormapAction +from silx.io.nxdata import get_attr_as_unicode +from silx.gui.colors import Colormap +from silx.gui.dialog.ColormapDialog import ColormapDialog __authors__ = ["V. Valls", "P. Knobel"] __license__ = "MIT" -__date__ = "23/01/2018" +__date__ = "23/05/2018" _logger = logging.getLogger(__name__) @@ -109,6 +109,7 @@ class DataInfo(object): self.isBoolean = False self.isRecord = False self.hasNXdata = False + self.isInvalidNXdata = False self.shape = tuple() self.dim = 0 self.size = 0 @@ -118,8 +119,28 @@ class DataInfo(object): if silx.io.is_group(data): nxd = nxdata.get_default(data) + nx_class = get_attr_as_unicode(data, "NX_class") if nxd is not None: self.hasNXdata = True + # can we plot it? + is_scalar = nxd.signal_is_0d or nxd.interpretation in ["scalar", "scaler"] + if not (is_scalar or nxd.is_curve or nxd.is_x_y_value_scatter or + nxd.is_image or nxd.is_stack): + # invalid: cannot be plotted by any widget + self.isInvalidNXdata = True + elif nx_class == "NXdata": + # group claiming to be NXdata could not be parsed + self.isInvalidNXdata = True + elif nx_class == "NXentry" and "default" in data.attrs: + # entry claiming to have a default NXdata could not be parsed + self.isInvalidNXdata = True + elif nx_class == "NXroot" or silx.io.is_file(data): + # root claiming to have a default entry + if "default" in data.attrs: + def_entry = data.attrs["default"] + if def_entry in data and "default" in data[def_entry].attrs: + # and entry claims to have default NXdata + self.isInvalidNXdata = True if isinstance(data, numpy.ndarray): self.isArray = True @@ -130,7 +151,7 @@ class DataInfo(object): if silx.io.is_dataset(data): if "interpretation" in data.attrs: - self.interpretation = get_attr_as_string(data, "interpretation") + self.interpretation = get_attr_as_unicode(data, "interpretation") else: self.interpretation = None elif self.hasNXdata: @@ -166,7 +187,11 @@ class DataInfo(object): if self.shape is not None: self.dim = len(self.shape) - if hasattr(data, "size"): + if hasattr(data, "shape") and data.shape is None: + # This test is expected to avoid to fall done on the h5py issue + # https://github.com/h5py/h5py/issues/1044 + self.size = 0 + elif hasattr(data, "size"): self.size = int(data.size) else: self.size = 1 @@ -177,6 +202,18 @@ class DataInfo(object): return _normalizeData(data) +class DataViewHooks(object): + """A set of hooks defined to custom the behaviour of the data views.""" + + def getColormap(self, view): + """Returns a colormap for this view.""" + return None + + def getColormapDialog(self, view): + """Returns a color dialog for this view.""" + return None + + class DataView(object): """Holder for the data view.""" @@ -184,12 +221,6 @@ class DataView(object): """Priority returned when the requested data can't be displayed by the view.""" - _defaultColormap = None - """Store a default colormap shared with all the views""" - - _defaultColorDialog = None - """Store a default color dialog shared with all the views""" - def __init__(self, parent, modeId=None, icon=None, label=None): """Constructor @@ -204,32 +235,46 @@ class DataView(object): if icon is None: icon = qt.QIcon() self.__icon = icon + self.__hooks = None - @staticmethod - def defaultColormap(): - """Returns a shared colormap as default for all the views. + def getHooks(self): + """Returns the data viewer hooks used by this view. - :rtype: Colormap + :rtype: DataViewHooks """ - if DataView._defaultColormap is None: - DataView._defaultColormap = Colormap(name="viridis") - return DataView._defaultColormap + return self.__hooks - @staticmethod - def defaultColorDialog(): - """Returns a shared color dialog as default for all the views. + def setHooks(self, hooks): + """Set the data view hooks to use with this view. - :rtype: ColorDialog + :param DataViewHooks hooks: The data view hooks to use """ - if DataView._defaultColorDialog is None: - DataView._defaultColorDialog = ColormapAction._createDialog(qt.QApplication.instance().activeWindow()) - return DataView._defaultColorDialog + self.__hooks = hooks - @staticmethod - def _cleanUpCache(): - """Clean up the cache. Needed for tests""" - DataView._defaultColormap = None - DataView._defaultColorDialog = None + def defaultColormap(self): + """Returns a default colormap. + + :rtype: Colormap + """ + colormap = None + if self.__hooks is not None: + colormap = self.__hooks.getColormap(self) + if colormap is None: + colormap = Colormap(name="viridis") + return colormap + + def defaultColorDialog(self): + """Returns a default color dialog. + + :rtype: ColormapDialog + """ + dialog = None + if self.__hooks is not None: + dialog = self.__hooks.getColormapDialog(self) + if dialog is None: + dialog = ColormapDialog() + dialog.setModal(False) + return dialog def icon(self): """Returns the default icon""" @@ -345,8 +390,21 @@ class CompositeDataView(DataView): self.__views = OrderedDict() self.__currentView = None + def setHooks(self, hooks): + """Set the data context to use with this view. + + :param DataViewHooks hooks: The data view hooks to use + """ + super(CompositeDataView, self).setHooks(hooks) + if hooks is not None: + for v in self.__views: + v.setHooks(hooks) + def addView(self, dataView): """Add a new dataview to the available list.""" + hooks = self.getHooks() + if hooks is not None: + dataView.setHooks(hooks) self.__views[dataView] = None def availableViews(self): @@ -446,6 +504,9 @@ class CompositeDataView(DataView): break elif isinstance(view, CompositeDataView): # recurse + hooks = self.getHooks() + if hooks is not None: + newView.setHooks(hooks) if view.replaceView(modeId, newView): return True if oldView is None: @@ -1022,70 +1083,46 @@ class _InvalidNXdataView(DataView): def getDataPriority(self, data, info): data = self.normalizeData(data) - if silx.io.is_group(data): - nxd = nxdata.get_default(data) - nx_class = get_attr_as_string(data, "NX_class") - - if nxd is None: - if nx_class == "NXdata": - # invalid: could not even be parsed by NXdata - self._msg = "Group has @NX_class = NXdata, but could not be interpreted" - self._msg += " as valid NXdata." - return 100 - elif nx_class == "NXentry": - if "default" not in data.attrs: - # no link to NXdata, no problem - return DataView.UNSUPPORTED - self._msg = "NXentry group provides a @default attribute," - default_nxdata_name = data.attrs["default"] - if default_nxdata_name not in data: - self._msg += " but no corresponding NXdata group exists." - elif get_attr_as_string(data[default_nxdata_name], "NX_class") != "NXdata": - self._msg += " but the corresponding item is not a " - self._msg += "NXdata group." - else: - self._msg += " but the corresponding NXdata seems to be" - self._msg += " malformed." - return 100 - elif nx_class == "NXroot" or silx.io.is_file(data): - if "default" not in data.attrs: - # no link to NXentry, no problem - return DataView.UNSUPPORTED - default_entry_name = data.attrs["default"] - if default_entry_name not in data: - # this is a problem, but not NXdata related - return DataView.UNSUPPORTED - default_entry = data[default_entry_name] - if "default" not in default_entry.attrs: - # no NXdata specified, no problemo - return DataView.UNSUPPORTED - default_nxdata_name = default_entry.attrs["default"] - self._msg = "NXroot group provides a @default attribute " - self._msg += "pointing to a NXentry which defines its own " - self._msg += "@default attribute, " - if default_nxdata_name not in default_entry: - self._msg += " but no corresponding NXdata group exists." - elif get_attr_as_string(default_entry[default_nxdata_name], - "NX_class") != "NXdata": - self._msg += " but the corresponding item is not a " - self._msg += "NXdata group." - else: - self._msg += " but the corresponding NXdata seems to be" - self._msg += " malformed." - return 100 - else: - # Not pretending to be NXdata, no problem - return DataView.UNSUPPORTED - - is_scalar = nxd.signal_is_0d or nxd.interpretation in ["scalar", "scaler"] - if not (is_scalar or nxd.is_curve or nxd.is_x_y_value_scatter or - nxd.is_image or nxd.is_stack): - # invalid: cannot be plotted by any widget (I cannot imagine a case) - self._msg = "NXdata seems valid, but cannot be displayed " - self._msg += "by any existing plot widget." - return 100 - return DataView.UNSUPPORTED + if not info.isInvalidNXdata: + return DataView.UNSUPPORTED + + if info.hasNXdata: + self._msg = "NXdata seems valid, but cannot be displayed " + self._msg += "by any existing plot widget." + else: + nx_class = get_attr_as_unicode(data, "NX_class") + if nx_class == "NXdata": + # invalid: could not even be parsed by NXdata + self._msg = "Group has @NX_class = NXdata, but could not be interpreted" + self._msg += " as valid NXdata." + elif nx_class == "NXentry": + self._msg = "NXentry group provides a @default attribute," + default_nxdata_name = data.attrs["default"] + if default_nxdata_name not in data: + self._msg += " but no corresponding NXdata group exists." + elif get_attr_as_unicode(data[default_nxdata_name], "NX_class") != "NXdata": + self._msg += " but the corresponding item is not a " + self._msg += "NXdata group." + else: + self._msg += " but the corresponding NXdata seems to be" + self._msg += " malformed." + elif nx_class == "NXroot" or silx.io.is_file(data): + default_entry = data[data.attrs["default"]] + default_nxdata_name = default_entry.attrs["default"] + self._msg = "NXroot group provides a @default attribute " + self._msg += "pointing to a NXentry which defines its own " + self._msg += "@default attribute, " + if default_nxdata_name not in default_entry: + self._msg += " but no corresponding NXdata group exists." + elif get_attr_as_unicode(default_entry[default_nxdata_name], + "NX_class") != "NXdata": + self._msg += " but the corresponding item is not a " + self._msg += "NXdata group." + else: + self._msg += " but the corresponding NXdata seems to be" + self._msg += " malformed." + return 100 class _NXdataScalarView(DataView): @@ -1111,7 +1148,7 @@ class _NXdataScalarView(DataView): def setData(self, data): data = self.normalizeData(data) # data could be a NXdata or an NXentry - nxd = nxdata.get_default(data) + nxd = nxdata.get_default(data, validate=False) signal = nxd.signal self.getWidget().setArrayData(signal, labels=True) @@ -1119,8 +1156,8 @@ class _NXdataScalarView(DataView): def getDataPriority(self, data, info): data = self.normalizeData(data) - if info.hasNXdata: - nxd = nxdata.get_default(data) + if info.hasNXdata and not info.isInvalidNXdata: + nxd = nxdata.get_default(data, validate=False) if nxd.signal_is_0d or nxd.interpretation in ["scalar", "scaler"]: return 100 return DataView.UNSUPPORTED @@ -1151,7 +1188,7 @@ class _NXdataCurveView(DataView): def setData(self, data): data = self.normalizeData(data) - nxd = nxdata.get_default(data) + nxd = nxdata.get_default(data, validate=False) signals_names = [nxd.signal_name] + nxd.auxiliary_signals_names if nxd.axes_dataset_names[-1] is not None: x_errors = nxd.get_axis_errors(nxd.axes_dataset_names[-1]) @@ -1177,8 +1214,8 @@ class _NXdataCurveView(DataView): def getDataPriority(self, data, info): data = self.normalizeData(data) - if info.hasNXdata: - if nxdata.get_default(data).is_curve: + if info.hasNXdata and not info.isInvalidNXdata: + if nxdata.get_default(data, validate=False).is_curve: return 100 return DataView.UNSUPPORTED @@ -1204,8 +1241,13 @@ class _NXdataXYVScatterView(DataView): def setData(self, data): data = self.normalizeData(data) - nxd = nxdata.get_default(data) + nxd = nxdata.get_default(data, validate=False) + x_axis, y_axis = nxd.axes[-2:] + if x_axis is None: + x_axis = numpy.arange(nxd.signal.size) + if y_axis is None: + y_axis = numpy.arange(nxd.signal.size) x_label, y_label = nxd.axes_names[-2:] if x_label is not None: @@ -1226,8 +1268,8 @@ class _NXdataXYVScatterView(DataView): def getDataPriority(self, data, info): data = self.normalizeData(data) - if info.hasNXdata: - if nxdata.get_default(data).is_x_y_value_scatter: + if info.hasNXdata and not info.isInvalidNXdata: + if nxdata.get_default(data, validate=False).is_x_y_value_scatter: return 100 return DataView.UNSUPPORTED @@ -1256,7 +1298,7 @@ class _NXdataImageView(DataView): def setData(self, data): data = self.normalizeData(data) - nxd = nxdata.get_default(data) + nxd = nxdata.get_default(data, validate=False) isRgba = nxd.interpretation == "rgba-image" # last two axes are Y & X @@ -1274,8 +1316,8 @@ class _NXdataImageView(DataView): def getDataPriority(self, data, info): data = self.normalizeData(data) - if info.hasNXdata: - if nxdata.get_default(data).is_image: + if info.hasNXdata and not info.isInvalidNXdata: + if nxdata.get_default(data, validate=False).is_image: return 100 return DataView.UNSUPPORTED @@ -1302,7 +1344,7 @@ class _NXdataStackView(DataView): def setData(self, data): data = self.normalizeData(data) - nxd = nxdata.get_default(data) + nxd = nxdata.get_default(data, validate=False) signal_name = nxd.signal_name z_axis, y_axis, x_axis = nxd.axes[-3:] z_label, y_label, x_label = nxd.axes_names[-3:] @@ -1319,8 +1361,8 @@ class _NXdataStackView(DataView): def getDataPriority(self, data, info): data = self.normalizeData(data) - if info.hasNXdata: - if nxdata.get_default(data).is_stack: + if info.hasNXdata and not info.isInvalidNXdata: + if nxdata.get_default(data, validate=False).is_stack: return 100 return DataView.UNSUPPORTED diff --git a/silx/gui/data/Hdf5TableView.py b/silx/gui/data/Hdf5TableView.py index e4a0747..04199b2 100644 --- a/silx/gui/data/Hdf5TableView.py +++ b/silx/gui/data/Hdf5TableView.py @@ -30,8 +30,9 @@ from __future__ import division __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "10/10/2017" +__date__ = "23/05/2018" +import collections import functools import os.path import logging @@ -41,6 +42,7 @@ from .TextFormatter import TextFormatter import silx.gui.hdf5 from silx.gui.widgets import HierarchicalTableView from ..hdf5.Hdf5Formatter import Hdf5Formatter +from ..hdf5._utils import htmlFromDict try: import h5py @@ -54,7 +56,7 @@ _logger = logging.getLogger(__name__) class _CellData(object): """Store a table item """ - def __init__(self, value=None, isHeader=False, span=None): + def __init__(self, value=None, isHeader=False, span=None, tooltip=None): """ Constructor @@ -65,6 +67,7 @@ class _CellData(object): self.__value = value self.__isHeader = isHeader self.__span = span + self.__tooltip = tooltip def isHeader(self): """Returns true if the property is a sub-header title. @@ -85,6 +88,19 @@ class _CellData(object): """ return self.__span + def tooltip(self): + """Returns the tooltip of the item. + + :rtype: tuple + """ + return self.__tooltip + + def invalidateValue(self): + self.__value = None + + def invalidateToolTip(self): + self.__tooltip = None + class _TableData(object): """Modelize a table with header, row and column span. @@ -143,7 +159,7 @@ class _TableData(object): item = _CellData(value=headerLabel, isHeader=True, span=(1, self.__colCount)) self.__data.append([item]) - def addHeaderValueRow(self, headerLabel, value): + def addHeaderValueRow(self, headerLabel, value, tooltip=None): """Append the table with a row using the first column as an header and other cells as a single cell for the value. @@ -151,7 +167,7 @@ class _TableData(object): :param object value: value to store. """ header = _CellData(value=headerLabel, isHeader=True) - value = _CellData(value=value, span=(1, self.__colCount)) + value = _CellData(value=value, span=(1, self.__colCount), tooltip=tooltip) self.__data.append([header, value]) def addRow(self, *args): @@ -214,7 +230,20 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): elif role == qt.Qt.DisplayRole: value = cell.value() if callable(value): - value = value(self.__obj) + try: + value = value(self.__obj) + except Exception: + cell.invalidateValue() + raise + return value + elif role == qt.Qt.ToolTipRole: + value = cell.tooltip() + if callable(value): + try: + value = value(self.__obj) + except Exception: + cell.invalidateToolTip() + raise return value return None @@ -260,6 +289,14 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): """Format the HDF5 type""" return self.__hdf5Formatter.humanReadableHdf5Type(dataset) + def __attributeTooltip(self, attribute): + attributeDict = collections.OrderedDict() + if hasattr(attribute, "shape"): + attributeDict["Shape"] = self.__hdf5Formatter.humanReadableShape(attribute) + attributeDict["Data type"] = self.__hdf5Formatter.humanReadableType(attribute, full=True) + html = htmlFromDict(attributeDict, title="HDF5 Attribute") + return html + def __formatDType(self, dataset): """Format the numpy dtype""" return self.__hdf5Formatter.humanReadableType(dataset, full=True) @@ -310,7 +347,8 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): # it's a real H5py object self.__data.addHeaderValueRow("Basename", lambda x: os.path.basename(x.name)) self.__data.addHeaderValueRow("Name", lambda x: x.name) - self.__data.addHeaderValueRow("File", lambda x: x.file.filename) + if obj.file is not None: + self.__data.addHeaderValueRow("File", lambda x: x.file.filename) if hasattr(obj, "path"): # That's a link @@ -322,8 +360,11 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): else: if silx.io.is_file(obj): physical = lambda x: x.filename + SEPARATOR + x.name + elif obj.file is not None: + physical = lambda x: x.file.filename + SEPARATOR + x.name else: - physical = lambda x: x.file.filename + SEPARATOR + x.name + # Guess it is a virtual node + physical = "No physical location" self.__data.addHeaderValueRow("Physical", physical) if hasattr(obj, "dtype"): @@ -367,7 +408,10 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): self.__data.addHeaderRow(headerLabel="Attributes") for key in sorted(obj.attrs.keys()): callback = lambda key, x: self.__formatter.toString(x.attrs[key]) - self.__data.addHeaderValueRow(headerLabel=key, value=functools.partial(callback, key)) + callbackTooltip = lambda key, x: self.__attributeTooltip(x.attrs[key]) + self.__data.addHeaderValueRow(headerLabel=key, + value=functools.partial(callback, key), + tooltip=functools.partial(callbackTooltip, key)) def __get_filter_info(self, dataset, filterIndex): """Get a tuple of readable info from dataset filters @@ -447,7 +491,7 @@ class Hdf5TableView(HierarchicalTableView.HierarchicalTableView): def setData(self, data): """Set the h5py-like object exposed by the model - :param h5pyObject: A h5py-like object. It can be a `h5py.Dataset`, + :param data: A h5py-like object. It can be a `h5py.Dataset`, a `h5py.File`, a `h5py.Group`. It also can be a, `silx.gui.hdf5.H5Node` which is needed to display some local path information. diff --git a/silx/gui/data/HexaTableView.py b/silx/gui/data/HexaTableView.py index 1b2a7e9..c86c0af 100644 --- a/silx/gui/data/HexaTableView.py +++ b/silx/gui/data/HexaTableView.py @@ -37,7 +37,7 @@ from silx.gui.widgets.TableWidget import CopySelectedCellsAction __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "27/09/2017" +__date__ = "23/05/2018" class _VoidConnector(object): @@ -54,7 +54,13 @@ class _VoidConnector(object): def __getBuffer(self, bufferId): if bufferId not in self.__cache: pos = bufferId << 10 - data = self.__data.tobytes()[pos:pos + 1024] + data = self.__data + if hasattr(data, "tobytes"): + data = data.tobytes()[pos:pos + 1024] + else: + # Old fashion + data = data.data[pos:pos + 1024] + self.__cache[bufferId] = data if len(self.__cache) > 32: self.__cache.popitem() diff --git a/silx/gui/data/NXdataWidgets.py b/silx/gui/data/NXdataWidgets.py index ae2911d..1bf5425 100644 --- a/silx/gui/data/NXdataWidgets.py +++ b/silx/gui/data/NXdataWidgets.py @@ -26,14 +26,14 @@ """ __authors__ = ["P. Knobel"] __license__ = "MIT" -__date__ = "20/12/2017" +__date__ = "24/04/2018" import numpy from silx.gui import qt from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector -from silx.gui.plot import Plot1D, Plot2D, StackView -from silx.gui.plot.Colormap import Colormap +from silx.gui.plot import Plot1D, Plot2D, StackView, ScatterView +from silx.gui.colors import Colormap from silx.gui.widgets.FrameBrowser import HorizontalSliderWithBrowser from silx.math.calibration import ArrayCalibration, NoCalibration, LinearCalibration @@ -211,10 +211,10 @@ class XYVScatterPlot(qt.QWidget): self.__y_axis_name = None self.__y_axis_errors = None - self._plot = Plot1D(self) - self._plot.setDefaultColormap(Colormap(name="viridis", - vmin=None, vmax=None, - normalization=Colormap.LINEAR)) + self._plot = ScatterView(self) + self._plot.setColormap(Colormap(name="viridis", + vmin=None, vmax=None, + normalization=Colormap.LINEAR)) self._slider = HorizontalSliderWithBrowser(parent=self) self._slider.setMinimum(0) @@ -235,9 +235,9 @@ class XYVScatterPlot(qt.QWidget): def getPlot(self): """Returns the plot used for the display - :rtype: Plot1D + :rtype: PlotWidget """ - return self._plot + return self._plot.getPlotWidget() def setScattersData(self, y, x, values, yerror=None, xerror=None, @@ -284,8 +284,6 @@ class XYVScatterPlot(qt.QWidget): x = self.__x_axis y = self.__y_axis - self._plot.remove(kind=("scatter", )) - idx = self._slider.value() title = "" @@ -294,16 +292,15 @@ class XYVScatterPlot(qt.QWidget): title += self.__scatter_titles[idx] # scatter dataset name self._plot.setGraphTitle(title) - self._plot.addScatter(x, y, self.__values[idx], - legend="scatter%d" % idx, - xerror=self.__x_axis_errors, - yerror=self.__y_axis_errors) + self._plot.setData(x, y, self.__values[idx], + xerror=self.__x_axis_errors, + yerror=self.__y_axis_errors) self._plot.resetZoom() self._plot.getXAxis().setLabel(self.__x_axis_name) self._plot.getYAxis().setLabel(self.__y_axis_name) def clear(self): - self._plot.clear() + self._plot.getPlotWidget().clear() class ArrayImagePlot(qt.QWidget): @@ -476,7 +473,8 @@ class ArrayImagePlot(qt.QWidget): scale = (xscale, yscale) self._plot.addImage(image, legend=legend, - origin=origin, scale=scale) + origin=origin, scale=scale, + replace=True) else: scatterx, scattery = numpy.meshgrid(x_axis, y_axis) # fixme: i don't think this can handle "irregular" RGBA images diff --git a/silx/gui/data/TextFormatter.py b/silx/gui/data/TextFormatter.py index 332625c..8440509 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__ = "13/12/2017" +__date__ = "25/06/2018" import numpy import numbers @@ -204,7 +204,7 @@ class TextFormatter(qt.QObject): def __formatBinary(self, data): if isinstance(data, numpy.void): if six.PY2: - data = [ord(d) for d in data.item()] + data = [ord(d) for d in data.data] else: data = data.item().astype(numpy.uint8) elif six.PY2: @@ -266,6 +266,8 @@ class TextFormatter(qt.QObject): elif vlen == six.binary_type: # HDF5 ASCII return self.__formatCharString(data) + elif isinstance(vlen, numpy.dtype): + return self.toString(data, vlen) return None def toString(self, data, dtype=None): @@ -291,11 +293,17 @@ class TextFormatter(qt.QObject): else: text = [self.toString(d, dtype) for d in data] return "[" + " ".join(text) + "]" + if dtype is not None and dtype.kind == 'O': + text = self.__formatH5pyObject(data, dtype) + if text is not None: + return text elif isinstance(data, numpy.void): if dtype is None: dtype = data.dtype - if data.dtype.fields is not None: - text = [self.toString(data[f], dtype) for f in dtype.fields] + if dtype.fields is not None: + text = [] + for index, field in enumerate(dtype.fields.items()): + text.append(field[0] + ":" + self.toString(data[index], field[1][0])) return "(" + " ".join(text) + ")" return self.__formatBinary(data) elif isinstance(data, (numpy.unicode_, six.text_type)): @@ -340,7 +348,7 @@ class TextFormatter(qt.QObject): elif isinstance(data, (numbers.Real, numpy.floating)): # It have to be done before complex checking return self.__floatFormat % data - elif isinstance(data, (numpy.complex_, numbers.Complex)): + elif isinstance(data, (numpy.complexfloating, numbers.Complex)): text = "" if data.real != 0: text += self.__floatFormat % data.real diff --git a/silx/gui/data/test/test_dataviewer.py b/silx/gui/data/test/test_dataviewer.py index 274df92..f3c2808 100644 --- a/silx/gui/data/test/test_dataviewer.py +++ b/silx/gui/data/test/test_dataviewer.py @@ -24,7 +24,7 @@ # ###########################################################################*/ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "22/02/2018" +__date__ = "23/04/2018" import os import tempfile @@ -208,7 +208,6 @@ class AbstractDataViewerTests(TestCaseQt): self.assertEquals(widget.displayedView().modeId(), DataViews.RAW_MODE) widget.setDisplayMode(DataViews.EMPTY_MODE) self.assertEquals(widget.displayedView().modeId(), DataViews.EMPTY_MODE) - DataView._cleanUpCache() def test_create_default_views(self): widget = self.create_widget() @@ -287,7 +286,6 @@ class TestDataView(TestCaseQt): dataViewClass = DataViews._Plot2dView widget = self.createDataViewWithData(dataViewClass, data[0]) self.qWaitForWindowExposed(widget) - DataView._cleanUpCache() def testCubeWithComplex(self): self.skipTest("OpenGL widget not yet tested") @@ -299,14 +297,12 @@ class TestDataView(TestCaseQt): dataViewClass = DataViews._Plot3dView widget = self.createDataViewWithData(dataViewClass, data) self.qWaitForWindowExposed(widget) - DataView._cleanUpCache() def testImageStackWithComplex(self): data = self.createComplexData() dataViewClass = DataViews._StackView widget = self.createDataViewWithData(dataViewClass, data) self.qWaitForWindowExposed(widget) - DataView._cleanUpCache() def suite(): diff --git a/silx/gui/dialog/AbstractDataFileDialog.py b/silx/gui/dialog/AbstractDataFileDialog.py index 1bd52bb..cb6711c 100644 --- a/silx/gui/dialog/AbstractDataFileDialog.py +++ b/silx/gui/dialog/AbstractDataFileDialog.py @@ -28,7 +28,7 @@ This module contains an :class:`AbstractDataFileDialog`. __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "12/02/2018" +__date__ = "05/03/2018" import sys @@ -494,7 +494,9 @@ class _CatchResizeEvent(qt.QObject): class AbstractDataFileDialog(qt.QDialog): """The `AbstractFileDialog` provides a generic GUI to create a custom dialog - allowing to access to file resources like HDF5 files or HDF5 datasets + allowing to access to file resources like HDF5 files or HDF5 datasets. + + .. image:: img/abstractdatafiledialog.png The dialog contains: diff --git a/silx/gui/dialog/ColormapDialog.py b/silx/gui/dialog/ColormapDialog.py new file mode 100644 index 0000000..ed10728 --- /dev/null +++ b/silx/gui/dialog/ColormapDialog.py @@ -0,0 +1,986 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""A QDialog widget to set-up the colormap. + +It uses a description of colormaps as dict compatible with :class:`Plot`. + +To run the following sample code, a QApplication must be initialized. + +Create the colormap dialog and set the colormap description and data range: + +>>> from silx.gui.dialog.ColormapDialog import ColormapDialog +>>> from silx.gui.colors import Colormap + +>>> dialog = ColormapDialog() +>>> colormap = Colormap(name='red', normalization='log', +... vmin=1., vmax=2.) + +>>> dialog.setColormap(colormap) +>>> colormap.setVRange(1., 100.) # This scale the width of the plot area +>>> dialog.show() + +Get the colormap description (compatible with :class:`Plot`) from the dialog: + +>>> cmap = dialog.getColormap() +>>> cmap.getName() +'red' + +It is also possible to display an histogram of the image in the dialog. +This updates the data range with the range of the bins. + +>>> import numpy +>>> image = numpy.random.normal(size=512 * 512).reshape(512, -1) +>>> hist, bin_edges = numpy.histogram(image, bins=10) +>>> dialog.setHistogram(hist, bin_edges) + +The updates of the colormap description are also available through the signal: +:attr:`ColormapDialog.sigColormapChanged`. +""" # noqa + +from __future__ import division + +__authors__ = ["V.A. Sole", "T. Vincent", "H. Payno"] +__license__ = "MIT" +__date__ = "23/05/2018" + + +import logging + +import numpy + +from .. import qt +from ..colors import Colormap, preferredColormaps +from ..plot import PlotWidget +from silx.gui.widgets.FloatEdit import FloatEdit +import weakref +from silx.math.combo import min_max +from silx.third_party import enum +from silx.gui import icons +from silx.math.histogram import Histogramnd + +_logger = logging.getLogger(__name__) + + +_colormapIconPreview = {} + + +class _BoundaryWidget(qt.QWidget): + """Widget to edit a boundary of the colormap (vmin, vmax)""" + sigValueChanged = qt.Signal(object) + """Signal emitted when value is changed""" + + def __init__(self, parent=None, value=0.0): + qt.QWidget.__init__(self, parent=None) + self.setLayout(qt.QHBoxLayout()) + self.layout().setContentsMargins(0, 0, 0, 0) + self._numVal = FloatEdit(parent=self, value=value) + self.layout().addWidget(self._numVal) + self._autoCB = qt.QCheckBox('auto', parent=self) + self.layout().addWidget(self._autoCB) + self._autoCB.setChecked(False) + + self._autoCB.toggled.connect(self._autoToggled) + self.sigValueChanged = self._autoCB.toggled + self.textEdited = self._numVal.textEdited + self.editingFinished = self._numVal.editingFinished + self._dataValue = None + + def isAutoChecked(self): + return self._autoCB.isChecked() + + def getValue(self): + return None if self._autoCB.isChecked() else self._numVal.value() + + def getFiniteValue(self): + if not self._autoCB.isChecked(): + return self._numVal.value() + elif self._dataValue is None: + return self._numVal.value() + else: + return self._dataValue + + def _autoToggled(self, enabled): + self._numVal.setEnabled(not enabled) + self._updateDisplayedText() + + def _updateDisplayedText(self): + # if dataValue is finite + if self._autoCB.isChecked() and self._dataValue is not None: + old = self._numVal.blockSignals(True) + self._numVal.setValue(self._dataValue) + self._numVal.blockSignals(old) + + def setDataValue(self, dataValue): + self._dataValue = dataValue + self._updateDisplayedText() + + def setFiniteValue(self, value): + assert(value is not None) + old = self._numVal.blockSignals(True) + self._numVal.setValue(value) + self._numVal.blockSignals(old) + + def setValue(self, value, isAuto=False): + self._autoCB.setChecked(isAuto or value is None) + if value is not None: + self._numVal.setValue(value) + self._updateDisplayedText() + + +class _ColormapNameCombox(qt.QComboBox): + def __init__(self, parent=None): + qt.QComboBox.__init__(self, parent) + self.__initItems() + + ORIGINAL_NAME = qt.Qt.UserRole + 1 + + def __initItems(self): + for colormapName in preferredColormaps(): + index = self.count() + self.addItem(str.title(colormapName)) + self.setItemIcon(index, self.getIconPreview(colormapName)) + self.setItemData(index, colormapName, role=self.ORIGINAL_NAME) + + def getIconPreview(self, colormapName): + """Return an icon preview from a LUT name. + + This icons are cached into a global structure. + + :param str colormapName: str + :rtype: qt.QIcon + """ + if colormapName not in _colormapIconPreview: + icon = self.createIconPreview(colormapName) + _colormapIconPreview[colormapName] = icon + return _colormapIconPreview[colormapName] + + def createIconPreview(self, colormapName): + """Create and return an icon preview from a LUT name. + + This icons are cached into a global structure. + + :param str colormapName: Name of the LUT + :rtype: qt.QIcon + """ + colormap = Colormap(colormapName) + size = 32 + lut = colormap.getNColors(size) + if lut is None or len(lut) == 0: + return qt.QIcon() + + pixmap = qt.QPixmap(size, size) + painter = qt.QPainter(pixmap) + for i in range(size): + rgb = lut[i] + r, g, b = rgb[0], rgb[1], rgb[2] + painter.setPen(qt.QColor(r, g, b)) + painter.drawPoint(qt.QPoint(i, 0)) + + painter.drawPixmap(0, 1, size, size - 1, pixmap, 0, 0, size, 1) + painter.end() + + return qt.QIcon(pixmap) + + def getCurrentName(self): + return self.itemData(self.currentIndex(), self.ORIGINAL_NAME) + + def findColormap(self, name): + return self.findData(name, role=self.ORIGINAL_NAME) + + def setCurrentName(self, name): + index = self.findColormap(name) + if index < 0: + index = self.count() + self.addItem(str.title(name)) + self.setItemIcon(index, self.getIconPreview(name)) + self.setItemData(index, name, role=self.ORIGINAL_NAME) + self.setCurrentIndex(index) + + +@enum.unique +class _DataInPlotMode(enum.Enum): + """Enum for each mode of display of the data in the plot.""" + NONE = 'none' + RANGE = 'range' + HISTOGRAM = 'histogram' + + +class ColormapDialog(qt.QDialog): + """A QDialog widget to set the colormap. + + :param parent: See :class:`QDialog` + :param str title: The QDialog title + """ + + visibleChanged = qt.Signal(bool) + """This event is sent when the dialog visibility change""" + + def __init__(self, parent=None, title="Colormap Dialog"): + qt.QDialog.__init__(self, parent) + self.setWindowTitle(title) + + self._colormap = None + self._data = None + self._dataInPlotMode = _DataInPlotMode.RANGE + + self._ignoreColormapChange = False + """Used as a semaphore to avoid editing the colormap object when we are + only attempt to display it. + Used instead of n connect and disconnect of the sigChanged. The + disconnection to sigChanged was also limiting when this colormapdialog + is used in the colormapaction and associated to the activeImageChanged. + (because the activeImageChanged is send when the colormap changed and + the self.setcolormap is a callback) + """ + + self._histogramData = None + self._minMaxWasEdited = False + self._initialRange = None + + self._dataRange = None + """If defined 3-tuple containing information from a data: + minimum, positive minimum, maximum""" + + self._colormapStoredState = None + + # Make the GUI + vLayout = qt.QVBoxLayout(self) + + formWidget = qt.QWidget(parent=self) + vLayout.addWidget(formWidget) + formLayout = qt.QFormLayout(formWidget) + formLayout.setContentsMargins(10, 10, 10, 10) + formLayout.setSpacing(0) + + # Colormap row + self._comboBoxColormap = _ColormapNameCombox(parent=formWidget) + self._comboBoxColormap.currentIndexChanged[int].connect(self._updateName) + formLayout.addRow('Colormap:', self._comboBoxColormap) + + # Normalization row + self._normButtonLinear = qt.QRadioButton('Linear') + self._normButtonLinear.setChecked(True) + self._normButtonLog = qt.QRadioButton('Log') + self._normButtonLog.toggled.connect(self._activeLogNorm) + + normButtonGroup = qt.QButtonGroup(self) + normButtonGroup.setExclusive(True) + normButtonGroup.addButton(self._normButtonLinear) + normButtonGroup.addButton(self._normButtonLog) + self._normButtonLinear.toggled[bool].connect(self._updateLinearNorm) + + normLayout = qt.QHBoxLayout() + normLayout.setContentsMargins(0, 0, 0, 0) + normLayout.setSpacing(10) + normLayout.addWidget(self._normButtonLinear) + normLayout.addWidget(self._normButtonLog) + + formLayout.addRow('Normalization:', normLayout) + + # Min row + self._minValue = _BoundaryWidget(parent=self, value=1.0) + self._minValue.textEdited.connect(self._minMaxTextEdited) + self._minValue.editingFinished.connect(self._minEditingFinished) + self._minValue.sigValueChanged.connect(self._updateMinMax) + formLayout.addRow('\tMin:', self._minValue) + + # Max row + self._maxValue = _BoundaryWidget(parent=self, value=10.0) + self._maxValue.textEdited.connect(self._minMaxTextEdited) + self._maxValue.sigValueChanged.connect(self._updateMinMax) + self._maxValue.editingFinished.connect(self._maxEditingFinished) + formLayout.addRow('\tMax:', self._maxValue) + + # Add plot for histogram + self._plotToolbar = qt.QToolBar(self) + self._plotToolbar.setFloatable(False) + self._plotToolbar.setMovable(False) + self._plotToolbar.setIconSize(qt.QSize(8, 8)) + self._plotToolbar.setStyleSheet("QToolBar { border: 0px }") + self._plotToolbar.setOrientation(qt.Qt.Vertical) + + group = qt.QActionGroup(self._plotToolbar) + group.setExclusive(True) + + action = qt.QAction("Nothing", self) + action.setToolTip("No range nor histogram are displayed. No extra computation have to be done.") + action.setIcon(icons.getQIcon('colormap-none')) + action.setCheckable(True) + action.setData(_DataInPlotMode.NONE) + action.setChecked(action.data() == self._dataInPlotMode) + self._plotToolbar.addAction(action) + group.addAction(action) + action = qt.QAction("Data range", self) + action.setToolTip("Display the data range within the colormap range. A fast data processing have to be done.") + action.setIcon(icons.getQIcon('colormap-range')) + action.setCheckable(True) + action.setData(_DataInPlotMode.RANGE) + action.setChecked(action.data() == self._dataInPlotMode) + self._plotToolbar.addAction(action) + group.addAction(action) + action = qt.QAction("Histogram", self) + action.setToolTip("Display the data histogram within the colormap range. A slow data processing have to be done. ") + action.setIcon(icons.getQIcon('colormap-histogram')) + action.setCheckable(True) + action.setData(_DataInPlotMode.HISTOGRAM) + action.setChecked(action.data() == self._dataInPlotMode) + self._plotToolbar.addAction(action) + group.addAction(action) + group.triggered.connect(self._displayDataInPlotModeChanged) + + self._plotBox = qt.QWidget(self) + self._plotInit() + + plotBoxLayout = qt.QHBoxLayout() + plotBoxLayout.setContentsMargins(0, 0, 0, 0) + plotBoxLayout.setSpacing(2) + plotBoxLayout.addWidget(self._plotToolbar) + plotBoxLayout.addWidget(self._plot) + plotBoxLayout.setSizeConstraint(qt.QLayout.SetMinimumSize) + self._plotBox.setLayout(plotBoxLayout) + vLayout.addWidget(self._plotBox) + + # define modal buttons + types = qt.QDialogButtonBox.Ok | qt.QDialogButtonBox.Cancel + self._buttonsModal = qt.QDialogButtonBox(parent=self) + self._buttonsModal.setStandardButtons(types) + self.layout().addWidget(self._buttonsModal) + self._buttonsModal.accepted.connect(self.accept) + self._buttonsModal.rejected.connect(self.reject) + + # define non modal buttons + types = qt.QDialogButtonBox.Close | qt.QDialogButtonBox.Reset + self._buttonsNonModal = qt.QDialogButtonBox(parent=self) + self._buttonsNonModal.setStandardButtons(types) + self.layout().addWidget(self._buttonsNonModal) + self._buttonsNonModal.button(qt.QDialogButtonBox.Close).clicked.connect(self.accept) + self._buttonsNonModal.button(qt.QDialogButtonBox.Reset).clicked.connect(self.resetColormap) + + # Set the colormap to default values + self.setColormap(Colormap(name='gray', normalization='linear', + vmin=None, vmax=None)) + + self.setModal(self.isModal()) + + vLayout.setSizeConstraint(qt.QLayout.SetMinimumSize) + self.setFixedSize(self.sizeHint()) + self._applyColormap() + + def showEvent(self, event): + self.visibleChanged.emit(True) + super(ColormapDialog, self).showEvent(event) + + def closeEvent(self, event): + if not self.isModal(): + self.accept() + super(ColormapDialog, self).closeEvent(event) + + def hideEvent(self, event): + self.visibleChanged.emit(False) + super(ColormapDialog, self).hideEvent(event) + + def close(self): + self.accept() + qt.QDialog.close(self) + + def setModal(self, modal): + assert type(modal) is bool + self._buttonsNonModal.setVisible(not modal) + self._buttonsModal.setVisible(modal) + qt.QDialog.setModal(self, modal) + + def exec_(self): + wasModal = self.isModal() + self.setModal(True) + result = super(ColormapDialog, self).exec_() + self.setModal(wasModal) + return result + + def _plotInit(self): + """Init the plot to display the range and the values""" + self._plot = PlotWidget() + self._plot.setDataMargins(yMinMargin=0.125, yMaxMargin=0.125) + self._plot.getXAxis().setLabel("Data Values") + self._plot.getYAxis().setLabel("") + self._plot.setInteractiveMode('select', zoomOnWheel=False) + self._plot.setActiveCurveHandling(False) + self._plot.setMinimumSize(qt.QSize(250, 200)) + self._plot.sigPlotSignal.connect(self._plotSlot) + + self._plotUpdate() + + def sizeHint(self): + return self.layout().minimumSize() + + def _plotUpdate(self, updateMarkers=True): + """Update the plot content + + :param bool updateMarkers: True to update markers, False otherwith + """ + colormap = self.getColormap() + if colormap is None: + if self._plotBox.isVisibleTo(self): + self._plotBox.setVisible(False) + self.setFixedSize(self.sizeHint()) + return + + if not self._plotBox.isVisibleTo(self): + self._plotBox.setVisible(True) + self.setFixedSize(self.sizeHint()) + + minData, maxData = self._minValue.getFiniteValue(), self._maxValue.getFiniteValue() + if minData > maxData: + # avoid a full collapse + minData, maxData = maxData, minData + minimum = minData + maximum = maxData + + if self._dataRange is not None: + minRange = self._dataRange[0] + maxRange = self._dataRange[2] + minimum = min(minimum, minRange) + maximum = max(maximum, maxRange) + + if self._histogramData is not None: + minHisto = self._histogramData[1][0] + maxHisto = self._histogramData[1][-1] + minimum = min(minimum, minHisto) + maximum = max(maximum, maxHisto) + + marge = abs(maximum - minimum) / 6.0 + if marge < 0.0001: + # Smaller that the QLineEdit precision + marge = 0.0001 + + minView, maxView = minimum - marge, maximum + marge + + if updateMarkers: + # Save the state in we are not moving the markers + self._initialRange = minView, maxView + elif self._initialRange is not None: + minView = min(minView, self._initialRange[0]) + maxView = max(maxView, self._initialRange[1]) + + x = [minView, minData, maxData, maxView] + y = [0, 0, 1, 1] + + self._plot.addCurve(x, y, + legend="ConstrainedCurve", + color='black', + symbol='o', + linestyle='-', + resetzoom=False) + + if updateMarkers: + minDraggable = (self._colormap().isEditable() and + not self._minValue.isAutoChecked()) + self._plot.addXMarker( + self._minValue.getFiniteValue(), + legend='Min', + text='Min', + draggable=minDraggable, + color='blue', + constraint=self._plotMinMarkerConstraint) + + maxDraggable = (self._colormap().isEditable() and + not self._maxValue.isAutoChecked()) + self._plot.addXMarker( + self._maxValue.getFiniteValue(), + legend='Max', + text='Max', + draggable=maxDraggable, + color='blue', + constraint=self._plotMaxMarkerConstraint) + + self._plot.resetZoom() + + def _plotMinMarkerConstraint(self, x, y): + """Constraint of the min marker""" + return min(x, self._maxValue.getFiniteValue()), y + + def _plotMaxMarkerConstraint(self, x, y): + """Constraint of the max marker""" + return max(x, self._minValue.getFiniteValue()), y + + def _plotSlot(self, event): + """Handle events from the plot""" + if event['event'] in ('markerMoving', 'markerMoved'): + value = float(str(event['xdata'])) + if event['label'] == 'Min': + self._minValue.setValue(value) + elif event['label'] == 'Max': + self._maxValue.setValue(value) + + # This will recreate the markers while interacting... + # It might break if marker interaction is changed + if event['event'] == 'markerMoved': + self._initialRange = None + self._updateMinMax() + else: + self._plotUpdate(updateMarkers=False) + + @staticmethod + def computeDataRange(data): + """Compute the data range as used by :meth:`setDataRange`. + + :param data: The data to process + :rtype: Tuple(float, float, float) + """ + if data is None or len(data) == 0: + return None, None, None + + dataRange = min_max(data, min_positive=True, finite=True) + if dataRange.minimum is None: + # Only non-finite data + dataRange = None + + if dataRange is not None: + min_positive = dataRange.min_positive + if min_positive is None: + min_positive = float('nan') + dataRange = dataRange.minimum, min_positive, dataRange.maximum + + if dataRange is None or len(dataRange) != 3: + qt.QMessageBox.warning( + None, "No Data", + "Image data does not contain any real value") + dataRange = 1., 1., 10. + + return dataRange + + @staticmethod + def computeHistogram(data): + """Compute the data histogram as used by :meth:`setHistogram`. + + :param data: The data to process + :rtype: Tuple(List(float),List(float) + """ + _data = data + if _data.ndim == 3: # RGB(A) images + _logger.info('Converting current image from RGB(A) to grayscale\ + in order to compute the intensity distribution') + _data = (_data[:, :, 0] * 0.299 + + _data[:, :, 1] * 0.587 + + _data[:, :, 2] * 0.114) + + if len(_data) == 0: + return None, None + + xmin, xmax = min_max(_data, min_positive=False, finite=True) + nbins = min(256, int(numpy.sqrt(_data.size))) + data_range = xmin, xmax + + # bad hack: get 256 bins in the case we have a B&W + if numpy.issubdtype(_data.dtype, numpy.integer): + if nbins > xmax - xmin: + nbins = xmax - xmin + + nbins = max(2, nbins) + _data = _data.ravel().astype(numpy.float32) + + histogram = Histogramnd(_data, n_bins=nbins, histo_range=data_range) + return histogram.histo, histogram.edges[0] + + def _getData(self): + if self._data is None: + return None + return self._data() + + def setData(self, data): + """Store the data as a weakref. + + According to the state of the dialog, the data will be used to display + the data range or the histogram of the data using :meth:`setDataRange` + and :meth:`setHistogram` + """ + oldData = self._getData() + if oldData is data: + return + + if data is None: + self._data = None + else: + self._data = weakref.ref(data, self._dataAboutToFinalize) + + self._updateDataInPlot() + + def _setDataInPlotMode(self, mode): + if self._dataInPlotMode == mode: + return + self._dataInPlotMode = mode + self._updateDataInPlot() + + def _displayDataInPlotModeChanged(self, action): + mode = action.data() + self._setDataInPlotMode(mode) + + def _updateDataInPlot(self): + data = self._getData() + if data is None: + self.setDataRange() + self.setHistogram() + return + + if data.size == 0: + # One or more dimensions are equal to 0 + self.setHistogram() + self.setDataRange() + return + + mode = self._dataInPlotMode + + if mode == _DataInPlotMode.NONE: + self.setHistogram() + self.setDataRange() + elif mode == _DataInPlotMode.RANGE: + result = self.computeDataRange(data) + self.setHistogram() + self.setDataRange(*result) + elif mode == _DataInPlotMode.HISTOGRAM: + # The histogram should be done in a worker thread + result = self.computeHistogram(data) + self.setHistogram(*result) + self.setDataRange() + + def _colormapAboutToFinalize(self, weakrefColormap): + """Callback when the data weakref is about to be finalized.""" + if self._colormap is weakrefColormap: + self.setColormap(None) + + def _dataAboutToFinalize(self, weakrefData): + """Callback when the data weakref is about to be finalized.""" + if self._data is weakrefData: + self.setData(None) + + def getHistogram(self): + """Returns the counts and bin edges of the displayed histogram. + + :return: (hist, bin_edges) + :rtype: 2-tuple of numpy arrays""" + if self._histogramData is None: + return None + else: + bins, counts = self._histogramData + return numpy.array(bins, copy=True), numpy.array(counts, copy=True) + + def setHistogram(self, hist=None, bin_edges=None): + """Set the histogram to display. + + This update the data range with the bounds of the bins. + + :param hist: array-like of counts or None to hide histogram + :param bin_edges: array-like of bins edges or None to hide histogram + """ + if hist is None or bin_edges is None: + self._histogramData = None + self._plot.remove(legend='Histogram', kind='histogram') + else: + hist = numpy.array(hist, copy=True) + bin_edges = numpy.array(bin_edges, copy=True) + self._histogramData = hist, bin_edges + norm_hist = hist / max(hist) + self._plot.addHistogram(norm_hist, + bin_edges, + legend="Histogram", + color='gray', + align='center', + fill=True) + self._updateMinMaxData() + + def getColormap(self): + """Return the colormap description as a :class:`.Colormap`. + + """ + if self._colormap is None: + return None + return self._colormap() + + def resetColormap(self): + """ + Reset the colormap state before modification. + + ..note :: the colormap reference state is the state when set or the + state when validated + """ + colormap = self.getColormap() + if colormap is not None and self._colormapStoredState is not None: + if self._colormap()._toDict() != self._colormapStoredState: + self._ignoreColormapChange = True + colormap._setFromDict(self._colormapStoredState) + self._ignoreColormapChange = False + self._applyColormap() + + def setDataRange(self, minimum=None, positiveMin=None, maximum=None): + """Set the range of data to use for the range of the histogram area. + + :param float minimum: The minimum of the data + :param float positiveMin: The positive minimum of the data + :param float maximum: The maximum of the data + """ + if minimum is None or positiveMin is None or maximum is None: + self._dataRange = None + self._plot.remove(legend='Range', kind='histogram') + else: + hist = numpy.array([1]) + bin_edges = numpy.array([minimum, maximum]) + self._plot.addHistogram(hist, + bin_edges, + legend="Range", + color='gray', + align='center', + fill=True) + self._dataRange = minimum, positiveMin, maximum + self._updateMinMaxData() + + def _updateMinMaxData(self): + """Update the min and max of the data according to the data range and + the histogram preset.""" + colormap = self.getColormap() + + minimum = float("+inf") + maximum = float("-inf") + + if colormap is not None and colormap.getNormalization() == colormap.LOGARITHM: + # find a range in the positive part of the data + if self._dataRange is not None: + minimum = min(minimum, self._dataRange[1]) + maximum = max(maximum, self._dataRange[2]) + if self._histogramData is not None: + positives = list(filter(lambda x: x > 0, self._histogramData[1])) + if len(positives) > 0: + minimum = min(minimum, positives[0]) + maximum = max(maximum, positives[-1]) + else: + if self._dataRange is not None: + minimum = min(minimum, self._dataRange[0]) + maximum = max(maximum, self._dataRange[2]) + if self._histogramData is not None: + minimum = min(minimum, self._histogramData[1][0]) + maximum = max(maximum, self._histogramData[1][-1]) + + if not numpy.isfinite(minimum): + minimum = None + if not numpy.isfinite(maximum): + maximum = None + + self._minValue.setDataValue(minimum) + self._maxValue.setDataValue(maximum) + self._plotUpdate() + + def accept(self): + self.storeCurrentState() + qt.QDialog.accept(self) + + def storeCurrentState(self): + """ + save the current value sof the colormap if the user want to undo is + modifications + """ + colormap = self.getColormap() + if colormap is not None: + self._colormapStoredState = colormap._toDict() + else: + self._colormapStoredState = None + + def reject(self): + self.resetColormap() + qt.QDialog.reject(self) + + def setColormap(self, colormap): + """Set the colormap description + + :param :class:`Colormap` colormap: the colormap to edit + """ + assert colormap is None or isinstance(colormap, Colormap) + if self._ignoreColormapChange is True: + return + + oldColormap = self.getColormap() + if oldColormap is colormap: + return + if oldColormap is not None: + oldColormap.sigChanged.disconnect(self._applyColormap) + + if colormap is not None: + colormap.sigChanged.connect(self._applyColormap) + colormap = weakref.ref(colormap, self._colormapAboutToFinalize) + + self._colormap = colormap + self.storeCurrentState() + self._updateResetButton() + self._applyColormap() + + def _updateResetButton(self): + resetButton = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset) + rStateEnabled = False + colormap = self.getColormap() + if colormap is not None and colormap.isEditable(): + # can reset only in the case the colormap changed + rStateEnabled = colormap._toDict() != self._colormapStoredState + resetButton.setEnabled(rStateEnabled) + + def _applyColormap(self): + self._updateResetButton() + if self._ignoreColormapChange is True: + return + + colormap = self.getColormap() + if colormap is None: + self._comboBoxColormap.setEnabled(False) + self._normButtonLinear.setEnabled(False) + self._normButtonLog.setEnabled(False) + self._minValue.setEnabled(False) + self._maxValue.setEnabled(False) + else: + self._ignoreColormapChange = True + + if colormap.getName() is not None: + name = colormap.getName() + self._comboBoxColormap.setCurrentName(name) + self._comboBoxColormap.setEnabled(self._colormap().isEditable()) + + assert colormap.getNormalization() in Colormap.NORMALIZATIONS + self._normButtonLinear.setChecked( + colormap.getNormalization() == Colormap.LINEAR) + self._normButtonLog.setChecked( + colormap.getNormalization() == Colormap.LOGARITHM) + vmin = colormap.getVMin() + vmax = colormap.getVMax() + dataRange = colormap.getColormapRange() + self._normButtonLinear.setEnabled(self._colormap().isEditable()) + self._normButtonLog.setEnabled(self._colormap().isEditable()) + self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None) + self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None) + self._minValue.setEnabled(self._colormap().isEditable()) + self._maxValue.setEnabled(self._colormap().isEditable()) + self._ignoreColormapChange = False + + self._plotUpdate() + + def _updateMinMax(self): + if self._ignoreColormapChange is True: + return + + vmin = self._minValue.getFiniteValue() + vmax = self._maxValue.getFiniteValue() + if vmax is not None and vmin is not None and vmax < vmin: + # If only one autoscale is checked constraints are too strong + # We have to edit a user value anyway it is not requested + # TODO: It would be better IMO to disable the auto checkbox before + # this case occur (valls) + cmin = self._minValue.isAutoChecked() + cmax = self._maxValue.isAutoChecked() + if cmin is False: + self._minValue.setFiniteValue(vmax) + if cmax is False: + self._maxValue.setFiniteValue(vmin) + + vmin = self._minValue.getValue() + vmax = self._maxValue.getValue() + self._ignoreColormapChange = True + colormap = self._colormap() + if colormap is not None: + colormap.setVRange(vmin, vmax) + self._ignoreColormapChange = False + self._plotUpdate() + self._updateResetButton() + + def _updateName(self): + if self._ignoreColormapChange is True: + return + + if self._colormap(): + self._ignoreColormapChange = True + self._colormap().setName( + self._comboBoxColormap.getCurrentName()) + self._ignoreColormapChange = False + + def _updateLinearNorm(self, isNormLinear): + if self._ignoreColormapChange is True: + return + + if self._colormap(): + self._ignoreColormapChange = True + norm = Colormap.LINEAR if isNormLinear else Colormap.LOGARITHM + self._colormap().setNormalization(norm) + self._ignoreColormapChange = False + + def _minMaxTextEdited(self, text): + """Handle _minValue and _maxValue textEdited signal""" + self._minMaxWasEdited = True + + def _minEditingFinished(self): + """Handle _minValue editingFinished signal + + Together with :meth:`_minMaxTextEdited`, this avoids to notify + colormap change when the min and max value where not edited. + """ + if self._minMaxWasEdited: + self._minMaxWasEdited = False + + # Fix start value + if (self._maxValue.getValue() is not None and + self._minValue.getValue() > self._maxValue.getValue()): + self._minValue.setValue(self._maxValue.getValue()) + self._updateMinMax() + + def _maxEditingFinished(self): + """Handle _maxValue editingFinished signal + + Together with :meth:`_minMaxTextEdited`, this avoids to notify + colormap change when the min and max value where not edited. + """ + if self._minMaxWasEdited: + self._minMaxWasEdited = False + + # Fix end value + if (self._minValue.getValue() is not None and + self._minValue.getValue() > self._maxValue.getValue()): + self._maxValue.setValue(self._minValue.getValue()) + self._updateMinMax() + + def keyPressEvent(self, event): + """Override key handling. + + It disables leaving the dialog when editing a text field. + """ + if event.key() == qt.Qt.Key_Enter and (self._minValue.hasFocus() or + self._maxValue.hasFocus()): + # Bypass QDialog keyPressEvent + # To avoid leaving the dialog when pressing enter on a text field + super(qt.QDialog, self).keyPressEvent(event) + else: + # Use QDialog keyPressEvent + super(ColormapDialog, self).keyPressEvent(event) + + def _activeLogNorm(self, isLog): + if self._ignoreColormapChange is True: + return + if self._colormap(): + self._ignoreColormapChange = True + norm = Colormap.LOGARITHM if isLog is True else Colormap.LINEAR + self._colormap().setNormalization(norm) + self._ignoreColormapChange = False + self._updateMinMaxData() diff --git a/silx/gui/dialog/GroupDialog.py b/silx/gui/dialog/GroupDialog.py new file mode 100644 index 0000000..71235d2 --- /dev/null +++ b/silx/gui/dialog/GroupDialog.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 dialog widget to select a HDF5 group in a +tree. + +.. autoclass:: GroupDialog + :show-inheritance: + :members: + + +""" +from silx.gui import qt +from silx.gui.hdf5.Hdf5TreeView import Hdf5TreeView +import silx.io +from silx.io.url import DataUrl + +__authors__ = ["P. Knobel"] +__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) + + 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): + qt.QDialog.__init__(self, parent) + self.setWindowTitle("HDF5 group selection") + + self._tree = Hdf5TreeView(self) + self._tree.setSelectionMode(qt.QAbstractItemView.SingleSelection) + self._tree.activated.connect(self._onActivation) + self._tree.selectionModel().selectionChanged.connect( + self._onSelectionChange) + + 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 " + "to be created in the selected group.") + self._lineEditSubgroup.textChanged.connect( + self._onSubgroupNameChange) + + _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") + + buttonBox = qt.QDialogButtonBox() + self._okButton = buttonBox.addButton(qt.QDialogButtonBox.Ok) + self._okButton.setEnabled(False) + buttonBox.addButton(qt.QDialogButtonBox.Cancel) + + buttonBox.accepted.connect(self.accept) + buttonBox.rejected.connect(self.reject) + + vlayout = qt.QVBoxLayout(self) + vlayout.addWidget(self._tree) + vlayout.addWidget(_labelSubgroup) + vlayout.addWidget(self._lineEditSubgroup) + vlayout.addWidget(_labelSelectionTitle) + vlayout.addWidget(self._labelSelection) + vlayout.addWidget(buttonBox) + self.setLayout(vlayout) + + self.setMinimumWidth(400) + + self._selectedUrl = None + + def addFile(self, path): + """Add a HDF5 file to the tree. + All groups it contains will be selectable in the dialog. + + :param str path: File path + """ + self._model.insertFile(path) + + def addGroup(self, group): + """Add a HDF5 group to the tree. This group and all its subgroups + will be selectable in the dialog. + + :param h5py.Group group: HDF5 group + """ + self._model.insertH5pyObject(group) + + def _onActivation(self, idx): + # double-click or enter press + 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): + nodes = list(self._tree.selectedH5Nodes()) + subgroupName = self._lineEditSubgroup.text() + if nodes: + node = nodes[0] + if silx.io.is_group(node.h5py_object): + 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()) + else: + 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/test/__init__.py b/silx/gui/dialog/test/__init__.py index eee8aea..f43a37a 100644 --- a/silx/gui/dialog/test/__init__.py +++ b/silx/gui/dialog/test/__init__.py @@ -26,7 +26,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "07/02/2018" +__date__ = "24/04/2018" import logging @@ -42,6 +42,8 @@ def suite(): test_suite = unittest.TestSuite() from . import test_imagefiledialog from . import test_datafiledialog + from . import test_colormapdialog test_suite.addTest(test_imagefiledialog.suite()) test_suite.addTest(test_datafiledialog.suite()) + test_suite.addTest(test_colormapdialog.suite()) return test_suite diff --git a/silx/gui/plot/test/testColormapDialog.py b/silx/gui/dialog/test/test_colormapdialog.py index 8087369..6f0ceea 100644 --- a/silx/gui/plot/test/testColormapDialog.py +++ b/silx/gui/dialog/test/test_colormapdialog.py @@ -26,7 +26,7 @@ __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "17/01/2018" +__date__ = "23/05/2018" import doctest @@ -34,9 +34,9 @@ import unittest from silx.gui.test.utils import qWaitForWindowExposedAndActivate from silx.gui import qt -from silx.gui.plot import ColormapDialog +from silx.gui.dialog import ColormapDialog from silx.gui.test.utils import TestCaseQt -from silx.gui.plot.Colormap import Colormap, preferredColormaps +from silx.gui.colors import Colormap, preferredColormaps from silx.utils.testutils import ParametricTestCase from silx.gui.plot.PlotWindow import PlotWindow @@ -119,7 +119,7 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase): self.assertTrue(self.colormap.getVMin() is None) self.assertTrue(self.colormap.getVMax() is None) self.assertTrue(self.colormap.isAutoscale() is True) - + def testGUIModalCancel(self): """Make sure the colormap is not modified if gone through reject""" assert self.colormap.isAutoscale() is False @@ -308,6 +308,19 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase): colormap.setEditable(False) self.assertFalse(resetButton.isEnabled()) + def testImageData(self): + data = numpy.random.rand(5, 5) + self.colormapDiag.setData(data) + + def testEmptyData(self): + data = numpy.empty((10, 0)) + self.colormapDiag.setData(data) + + def testNoneData(self): + data = numpy.random.rand(5, 5) + self.colormapDiag.setData(data) + self.colormapDiag.setData(None) + class TestColormapAction(TestCaseQt): def setUp(self): @@ -336,16 +349,16 @@ class TestColormapAction(TestCaseQt): self.assertTrue(self.colormapDialog.getColormap() is self.defaultColormap) self.plot.addImage(data=numpy.random.rand(10, 10), legend='img1', - replace=False, origin=(0, 0), + origin=(0, 0), colormap=self.colormap1) self.plot.setActiveImage('img1') self.assertTrue(self.colormapDialog.getColormap() is self.colormap1) self.plot.addImage(data=numpy.random.rand(10, 10), legend='img2', - replace=False, origin=(0, 0), + origin=(0, 0), colormap=self.colormap2) self.plot.addImage(data=numpy.random.rand(10, 10), legend='img3', - replace=False, origin=(0, 0)) + origin=(0, 0)) self.plot.setActiveImage('img3') self.assertTrue(self.colormapDialog.getColormap() is self.defaultColormap) @@ -363,7 +376,7 @@ class TestColormapAction(TestCaseQt): self.plot.getColormapAction()._actionTriggered(checked=True) self.assertTrue(self.plot.getColormapAction().isChecked()) self.plot.addImage(data=numpy.random.rand(10, 10), legend='img1', - replace=False, origin=(0, 0), + origin=(0, 0), colormap=self.colormap1) self.colormap1.setName('red') self.plot.getColormapAction()._actionTriggered() diff --git a/silx/gui/dialog/test/test_datafiledialog.py b/silx/gui/dialog/test/test_datafiledialog.py index bdda810..38fa03b 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__ = "14/02/2018" +__date__ = "03/07/2018" import unittest @@ -79,6 +79,20 @@ def setUpModule(): f["nxdata"].attrs["NX_class"] = u"NXdata" f.close() + if h5py is not None: + directory = os.path.join(_tmpDirectory, "data") + os.mkdir(directory) + filename = os.path.join(directory, "data.h5") + f = h5py.File(filename, "w") + f["scalar"] = 10 + f["image"] = data + f["cube"] = [data, data + 1, data + 2] + f["complex_image"] = data * 1j + f["group/image"] = data + f["nxdata/foo"] = 10 + f["nxdata"].attrs["NX_class"] = u"NXdata" + f.close() + filename = _tmpDirectory + "/badformat.h5" with io.open(filename, "wb") as f: f.write(b"{\nHello Nurse!") @@ -270,7 +284,7 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] action = utils.findChildren(dialog, qt.QAction, name="toParentAction")[0] toParentButton = utils.getQToolButtonFromAction(action) - filename = _tmpDirectory + "/data.h5" + filename = _tmpDirectory + "/data/data.h5" # init state path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path() @@ -286,11 +300,11 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): self.mouseClick(toParentButton, qt.Qt.LeftButton) self.qWaitForPendingActions(dialog) - self.assertSamePath(url.text(), _tmpDirectory) + self.assertSamePath(url.text(), _tmpDirectory + "/data") self.mouseClick(toParentButton, qt.Qt.LeftButton) self.qWaitForPendingActions(dialog) - self.assertSamePath(url.text(), os.path.dirname(_tmpDirectory)) + self.assertSamePath(url.text(), _tmpDirectory) def testClickOnBackToRootTool(self): if h5py is None: @@ -529,7 +543,7 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): self.qWaitForWindowExposed(dialog) dialog.selectUrl(_tmpDirectory) self.qWaitForPendingActions(dialog) - self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 3) + self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 4) class TestDataFileDialog_FilterDataset(utils.TestCaseQt, _UtilsMixin): diff --git a/silx/gui/dialog/test/test_imagefiledialog.py b/silx/gui/dialog/test/test_imagefiledialog.py index 7909f10..8fef3c5 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__ = "12/02/2018" +__date__ = "03/07/2018" import unittest @@ -50,7 +50,7 @@ import silx.io.url from silx.gui import qt from silx.gui.test import utils from ..ImageFileDialog import ImageFileDialog -from silx.gui.plot.Colormap import Colormap +from silx.gui.colors import Colormap from silx.gui.hdf5 import Hdf5TreeModel _tmpDirectory = None @@ -88,6 +88,18 @@ def setUpModule(): f["group/image"] = data f.close() + if h5py is not None: + directory = os.path.join(_tmpDirectory, "data") + os.mkdir(directory) + filename = os.path.join(directory, "data.h5") + f = h5py.File(filename, "w") + f["scalar"] = 10 + f["image"] = data + f["cube"] = [data, data + 1, data + 2] + f["complex_image"] = data * 1j + f["group/image"] = data + f.close() + filename = _tmpDirectory + "/badformat.edf" with io.open(filename, "wb") as f: f.write(b"{\nHello Nurse!") @@ -256,27 +268,31 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] action = utils.findChildren(dialog, qt.QAction, name="toParentAction")[0] toParentButton = utils.getQToolButtonFromAction(action) - filename = _tmpDirectory + "/data.h5" + filename = _tmpDirectory + "/data/data.h5" # init state path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path() 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) - self.assertSamePath(url.text(), _tmpDirectory) + print(url.text()) + self.assertSamePath(url.text(), _tmpDirectory + "/data") self.mouseClick(toParentButton, qt.Qt.LeftButton) self.qWaitForPendingActions(dialog) - self.assertSamePath(url.text(), os.path.dirname(_tmpDirectory)) + print(url.text()) + self.assertSamePath(url.text(), _tmpDirectory) def testClickOnBackToRootTool(self): if h5py is None: @@ -540,21 +556,21 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): self.qWaitForWindowExposed(dialog) dialog.selectUrl(_tmpDirectory) self.qWaitForPendingActions(dialog) - self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 5) + self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 6) codecName = fabio.edfimage.EdfImage.codec_name() index = filters.indexFromCodec(codecName) filters.setCurrentIndex(index) filters.activated[int].emit(index) self.qWait(50) - self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 3) + self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 4) codecName = fabio.fit2dmaskimage.Fit2dMaskImage.codec_name() index = filters.indexFromCodec(codecName) filters.setCurrentIndex(index) filters.activated[int].emit(index) self.qWait(50) - self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 1) + self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 2) class TestImageFileDialogApi(utils.TestCaseQt, _UtilsMixin): diff --git a/silx/gui/hdf5/Hdf5Formatter.py b/silx/gui/hdf5/Hdf5Formatter.py index 0e3697f..6802142 100644 --- a/silx/gui/hdf5/Hdf5Formatter.py +++ b/silx/gui/hdf5/Hdf5Formatter.py @@ -27,7 +27,7 @@ text.""" __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "23/01/2018" +__date__ = "06/06/2018" import numpy from silx.third_party import six @@ -119,7 +119,11 @@ class Hdf5Formatter(qt.QObject): return text def humanReadableType(self, dataset, full=False): - dtype = dataset.dtype + if hasattr(dataset, "dtype"): + dtype = dataset.dtype + else: + # Fallback... + dtype = type(dataset) return self.humanReadableDType(dtype, full) def humanReadableDType(self, dtype, full=False): @@ -164,6 +168,16 @@ class Hdf5Formatter(qt.QObject): return "enum" text = str(dtype.newbyteorder('N')) + if numpy.issubdtype(dtype, numpy.floating): + if hasattr(numpy, "float128") and dtype == numpy.float128: + text = "float80" + if full: + text += " (padding 128bits)" + elif hasattr(numpy, "float96") and dtype == numpy.float96: + text = "float80" + if full: + text += " (padding 96bits)" + if full: if dtype.byteorder == "<": text = "Little-endian " + text diff --git a/silx/gui/hdf5/Hdf5TreeModel.py b/silx/gui/hdf5/Hdf5TreeModel.py index 2d62429..835708a 100644 --- a/silx/gui/hdf5/Hdf5TreeModel.py +++ b/silx/gui/hdf5/Hdf5TreeModel.py @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "29/11/2017" +__date__ = "11/06/2018" import os @@ -205,7 +205,23 @@ class Hdf5TreeModel(qt.QAbstractItemModel): ] """List of logical columns available""" - def __init__(self, parent=None): + sigH5pyObjectLoaded = qt.Signal(object) + """Emitted when a new root item was loaded and inserted to the model.""" + + sigH5pyObjectRemoved = qt.Signal(object) + """Emitted when a root item is removed from the model.""" + + sigH5pyObjectSynchronized = qt.Signal(object, object) + """Emitted when an item was synchronized.""" + + def __init__(self, parent=None, ownFiles=True): + """ + Constructor + + :param qt.QWidget parent: Parent widget + :param bool ownFiles: If true (default) the model will manage the files + life cycle when they was added using path (like DnD). + """ super(Hdf5TreeModel, self).__init__(parent) self.header_labels = [None] * len(self.COLUMN_IDS) @@ -221,6 +237,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): self.__root = Hdf5Node() self.__fileDropEnabled = True self.__fileMoveEnabled = True + self.__datasetDragEnabled = False self.__animatedIcon = icons.getWaitIcon() self.__animatedIcon.iconChanged.connect(self.__updateLoadingItems) @@ -235,6 +252,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): self.__icons.append(icons.getQIcon("item-3dim")) self.__icons.append(icons.getQIcon("item-ndim")) + self.__ownFiles = ownFiles self.__openedFiles = [] """Store the list of files opened by the model itself.""" # FIXME: It should be managed one by one by Hdf5Item itself @@ -285,16 +303,25 @@ class Hdf5TreeModel(qt.QAbstractItemModel): newItem = _unwrapNone(newItem) error = _unwrapNone(error) row = self.__root.indexOfChild(oldItem) + rootIndex = qt.QModelIndex() self.beginRemoveRows(rootIndex, row, row) self.__root.removeChildAtIndex(row) self.endRemoveRows() + if newItem is not None: rootIndex = qt.QModelIndex() - self.__openedFiles.append(newItem.obj) + if self.__ownFiles: + self.__openedFiles.append(newItem.obj) self.beginInsertRows(rootIndex, row, row) self.__root.insertChild(row, newItem) self.endInsertRows() + + if isinstance(oldItem, Hdf5LoadingItem): + self.sigH5pyObjectLoaded.emit(newItem.obj) + else: + self.sigH5pyObjectSynchronized.emit(oldItem.obj, newItem.obj) + # FIXME the error must be displayed def isFileDropEnabled(self): @@ -306,6 +333,15 @@ class Hdf5TreeModel(qt.QAbstractItemModel): fileDropEnabled = qt.Property(bool, isFileDropEnabled, setFileDropEnabled) """Property to enable/disable file dropping in the model.""" + def isDatasetDragEnabled(self): + return self.__datasetDragEnabled + + def setDatasetDragEnabled(self, enabled): + self.__datasetDragEnabled = enabled + + datasetDragEnabled = qt.Property(bool, isDatasetDragEnabled, setDatasetDragEnabled) + """Property to enable/disable drag of datasets.""" + def isFileMoveEnabled(self): return self.__fileMoveEnabled @@ -323,10 +359,12 @@ class Hdf5TreeModel(qt.QAbstractItemModel): return 0 def mimeTypes(self): + types = [] if self.__fileMoveEnabled: - return [_utils.Hdf5NodeMimeData.MIME_TYPE] - else: - return [] + types.append(_utils.Hdf5NodeMimeData.MIME_TYPE) + if self.__datasetDragEnabled: + types.append(_utils.Hdf5DatasetMimeData.MIME_TYPE) + return types def mimeData(self, indexes): """ @@ -336,7 +374,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): :param List[qt.QModelIndex] indexes: List of indexes :rtype: qt.QMimeData """ - if not self.__fileMoveEnabled or len(indexes) == 0: + if len(indexes) == 0: return None indexes = [i for i in indexes if i.column() == 0] @@ -346,7 +384,13 @@ class Hdf5TreeModel(qt.QAbstractItemModel): raise NotImplementedError("Drag of cell is not implemented") node = self.nodeFromIndex(indexes[0]) - mimeData = _utils.Hdf5NodeMimeData(node) + + if self.__fileMoveEnabled and node.parent is self.__root: + mimeData = _utils.Hdf5NodeMimeData(node=node) + elif self.__datasetDragEnabled: + mimeData = _utils.Hdf5DatasetMimeData(node=node) + else: + mimeData = None return mimeData def flags(self, index): @@ -357,6 +401,8 @@ class Hdf5TreeModel(qt.QAbstractItemModel): if self.__fileMoveEnabled and node.parent is self.__root: # that's a root return qt.Qt.ItemIsDragEnabled | defaultFlags + elif self.__datasetDragEnabled: + return qt.Qt.ItemIsDragEnabled | defaultFlags return defaultFlags elif self.__fileDropEnabled or self.__fileMoveEnabled: return qt.Qt.ItemIsDropEnabled | defaultFlags @@ -543,8 +589,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): return filename = node.obj.filename - self.removeIndex(index) - self.insertFileAsync(filename, index.row()) + self.insertFileAsync(filename, index.row(), synchronizingNode=node) def synchronizeH5pyObject(self, h5pyObject): """ @@ -560,8 +605,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): if item.obj is h5pyObject: qindex = self.index(index, 0, qt.QModelIndex()) self.synchronizeIndex(qindex) - else: - index += 1 + index += 1 def removeIndex(self, index): """ @@ -576,6 +620,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): self.beginRemoveRows(qt.QModelIndex(), index.row(), index.row()) self.__root.removeChildAtIndex(index.row()) self.endRemoveRows() + self.sigH5pyObjectRemoved.emit(node.obj) def removeH5pyObject(self, h5pyObject): """ @@ -608,14 +653,17 @@ class Hdf5TreeModel(qt.QAbstractItemModel): def hasPendingOperations(self): return len(self.__runnerSet) > 0 - def insertFileAsync(self, filename, row=-1): + def insertFileAsync(self, filename, row=-1, synchronizingNode=None): if not os.path.isfile(filename): raise IOError("Filename '%s' must be a file path" % filename) # create temporary item - text = os.path.basename(filename) - item = Hdf5LoadingItem(text=text, parent=self.__root, animatedIcon=self.__animatedIcon) - self.insertNode(row, item) + if synchronizingNode is None: + text = os.path.basename(filename) + item = Hdf5LoadingItem(text=text, parent=self.__root, animatedIcon=self.__animatedIcon) + self.insertNode(row, item) + else: + item = synchronizingNode # start loading the real one runnable = LoadingItemRunnable(filename, item) @@ -634,12 +682,20 @@ class Hdf5TreeModel(qt.QAbstractItemModel): """ try: h5file = silx_io.open(filename) - self.__openedFiles.append(h5file) + if self.__ownFiles: + self.__openedFiles.append(h5file) + self.sigH5pyObjectLoaded.emit(h5file) self.insertH5pyObject(h5file, row=row) except IOError: _logger.debug("File '%s' can't be read.", filename, exc_info=True) raise + def clear(self): + """Remove all the content of the model""" + for _ in range(self.rowCount()): + qindex = self.index(0, 0, qt.QModelIndex()) + self.removeIndex(qindex) + def appendFile(self, filename): self.insertFile(filename, -1) diff --git a/silx/gui/hdf5/Hdf5TreeView.py b/silx/gui/hdf5/Hdf5TreeView.py index 78b5c19..a86140a 100644 --- a/silx/gui/hdf5/Hdf5TreeView.py +++ b/silx/gui/hdf5/Hdf5TreeView.py @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "20/02/2018" +__date__ = "30/04/2018" import logging @@ -66,10 +66,8 @@ class Hdf5TreeView(qt.QTreeView): """ qt.QTreeView.__init__(self, parent) - model = Hdf5TreeModel(self) - proxy_model = NexusSortFilterProxyModel(self) - proxy_model.setSourceModel(model) - self.setModel(proxy_model) + model = self.createDefaultModel() + self.setModel(model) self.setHeader(Hdf5HeaderView(qt.Qt.Horizontal, self)) self.setSelectionBehavior(qt.QAbstractItemView.SelectRows) @@ -87,6 +85,15 @@ class Hdf5TreeView(qt.QTreeView): self.setContextMenuPolicy(qt.Qt.CustomContextMenu) self.customContextMenuRequested.connect(self._createContextMenu) + def createDefaultModel(self): + """Creates and returns the default model. + + Inherite to custom the default model""" + model = Hdf5TreeModel(self) + proxy_model = NexusSortFilterProxyModel(self) + proxy_model.setSourceModel(model) + return proxy_model + def __removeContextMenuProxies(self, ref): """Callback to remove dead proxy from the list""" self.__context_menu_callbacks.remove(ref) diff --git a/silx/gui/hdf5/NexusSortFilterProxyModel.py b/silx/gui/hdf5/NexusSortFilterProxyModel.py index 9a27968..3f2cf8d 100644 --- a/silx/gui/hdf5/NexusSortFilterProxyModel.py +++ b/silx/gui/hdf5/NexusSortFilterProxyModel.py @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "10/10/2017" +__date__ = "25/06/2018" import logging @@ -34,6 +34,7 @@ import numpy from .. import qt from .Hdf5TreeModel import Hdf5TreeModel import silx.io.utils +from silx.gui import icons _logger = logging.getLogger(__name__) @@ -45,6 +46,7 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel): def __init__(self, parent=None): qt.QSortFilterProxyModel.__init__(self, parent) self.__split = re.compile("(\\d+|\\D+)") + self.__iconCache = {} def lessThan(self, sourceLeft, sourceRight): """Returns True if the value of the item referred to by the given @@ -86,6 +88,14 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel): nxClass = node.obj.attrs.get("NX_class", None) return nxClass == "NXentry" + def __isNXnode(self, node): + """Returns true if the node is an NX concept""" + class_ = node.h5Class + if class_ is None or class_ != silx.io.utils.H5Type.GROUP: + return False + nxClass = node.obj.attrs.get("NX_class", None) + return nxClass is not None + def getWordsAndNumbers(self, name): """ Returns a list of words and integers composing the name. @@ -96,11 +106,14 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel): :param str name: A name :rtype: List """ + nonSensitive = self.sortCaseSensitivity() == qt.Qt.CaseInsensitive words = self.__split.findall(name) result = [] for i in words: if i[0].isdigit(): i = int(i) + elif nonSensitive: + i = i.lower() result.append(i) return result @@ -145,3 +158,47 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel): except Exception: _logger.debug("Exception occurred", exc_info=True) return None + + def __createCompoundIcon(self, backgroundIcon, foregroundIcon): + icon = qt.QIcon() + + sizes = backgroundIcon.availableSizes() + sizes = sorted(sizes, key=lambda s: s.height()) + sizes = filter(lambda s: s.height() < 100, sizes) + sizes = list(sizes) + if len(sizes) > 0: + baseSize = sizes[-1] + else: + baseSize = qt.QSize(32, 32) + + modes = [qt.QIcon.Normal, qt.QIcon.Disabled] + for mode in modes: + pixmap = qt.QPixmap(baseSize) + pixmap.fill(qt.Qt.transparent) + painter = qt.QPainter(pixmap) + painter.drawPixmap(0, 0, backgroundIcon.pixmap(baseSize, mode=mode)) + painter.drawPixmap(0, 0, foregroundIcon.pixmap(baseSize, mode=mode)) + painter.end() + icon.addPixmap(pixmap, mode=mode) + + return icon + + def __getNxIcon(self, baseIcon): + iconHash = baseIcon.cacheKey() + icon = self.__iconCache.get(iconHash, None) + if icon is None: + nxIcon = icons.getQIcon("layer-nx") + icon = self.__createCompoundIcon(baseIcon, nxIcon) + self.__iconCache[iconHash] = icon + return icon + + def data(self, index, role=qt.Qt.DisplayRole): + result = super(NexusSortFilterProxyModel, self).data(index, role) + + if index.column() == Hdf5TreeModel.NAME_COLUMN: + if role == qt.Qt.DecorationRole: + sourceIndex = self.mapToSource(index) + item = self.sourceModel().data(sourceIndex, Hdf5TreeModel.H5PY_ITEM_ROLE) + if self.__isNXnode(item): + result = self.__getNxIcon(result) + return result diff --git a/silx/gui/hdf5/_utils.py b/silx/gui/hdf5/_utils.py index ddf4db5..8385129 100644 --- a/silx/gui/hdf5/_utils.py +++ b/silx/gui/hdf5/_utils.py @@ -28,7 +28,7 @@ package `silx.gui.hdf5` package. __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "20/12/2017" +__date__ = "04/05/2018" import logging @@ -102,6 +102,26 @@ def htmlFromDict(dictionary, title=None): return result +class Hdf5DatasetMimeData(qt.QMimeData): + """Mimedata class to identify an internal drag and drop of a Hdf5Node.""" + + MIME_TYPE = "application/x-internal-h5py-dataset" + + def __init__(self, node=None, dataset=None): + qt.QMimeData.__init__(self) + self.__dataset = dataset + self.__node = node + self.setData(self.MIME_TYPE, "".encode(encoding='utf-8')) + + def node(self): + return self.__node + + def dataset(self): + if self.__node is not None: + return self.__node.obj + return self.__dataset + + class Hdf5NodeMimeData(qt.QMimeData): """Mimedata class to identify an internal drag and drop of a Hdf5Node.""" diff --git a/silx/gui/hdf5/test/test_hdf5.py b/silx/gui/hdf5/test/test_hdf5.py index 44c4456..fc27f6b 100644 --- a/silx/gui/hdf5/test/test_hdf5.py +++ b/silx/gui/hdf5/test/test_hdf5.py @@ -26,7 +26,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "20/02/2018" +__date__ = "03/05/2018" import time @@ -39,6 +39,7 @@ from contextlib import contextmanager from silx.gui import qt from silx.gui.test.utils import TestCaseQt from silx.gui import hdf5 +from silx.gui.test.utils import SignalListener from silx.io import commonh5 import weakref @@ -48,6 +49,29 @@ except ImportError: h5py = None +_tmpDirectory = None + + +def setUpModule(): + global _tmpDirectory + _tmpDirectory = tempfile.mkdtemp(prefix=__name__) + + if h5py is not None: + filename = _tmpDirectory + "/data.h5" + + # create h5 data + f = h5py.File(filename, "w") + g = f.create_group("arrays") + g.create_dataset("scalar", data=10) + f.close() + + +def tearDownModule(): + global _tmpDirectory + shutil.rmtree(_tmpDirectory) + _tmpDirectory = None + + _called = 0 @@ -71,7 +95,7 @@ class TestHdf5TreeModel(TestCaseQt): self.skipTest("h5py is not available") def waitForPendingOperations(self, model): - for i in range(10): + for _ in range(10): if not model.hasPendingOperations(): break self.qWait(10) @@ -97,53 +121,53 @@ class TestHdf5TreeModel(TestCaseQt): self.assertIsNotNone(model) def testAppendFilename(self): - with self.h5TempFile() as filename: + filename = _tmpDirectory + "/data.h5" + model = hdf5.Hdf5TreeModel() + self.assertEquals(model.rowCount(qt.QModelIndex()), 0) + model.appendFile(filename) + self.assertEquals(model.rowCount(qt.QModelIndex()), 1) + # clean up + index = model.index(0, 0, qt.QModelIndex()) + h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) + ref = weakref.ref(model) + model = None + self.qWaitForDestroy(ref) + + def testAppendBadFilename(self): + model = hdf5.Hdf5TreeModel() + self.assertRaises(IOError, model.appendFile, "#%$") + + def testInsertFilename(self): + filename = _tmpDirectory + "/data.h5" + try: model = hdf5.Hdf5TreeModel() self.assertEquals(model.rowCount(qt.QModelIndex()), 0) - model.appendFile(filename) + model.insertFile(filename) self.assertEquals(model.rowCount(qt.QModelIndex()), 1) # clean up index = model.index(0, 0, qt.QModelIndex()) h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) + self.assertIsNotNone(h5File) + finally: ref = weakref.ref(model) model = None self.qWaitForDestroy(ref) - def testAppendBadFilename(self): - model = hdf5.Hdf5TreeModel() - self.assertRaises(IOError, model.appendFile, "#%$") - - def testInsertFilename(self): - with self.h5TempFile() as filename: - try: - model = hdf5.Hdf5TreeModel() - self.assertEquals(model.rowCount(qt.QModelIndex()), 0) - model.insertFile(filename) - self.assertEquals(model.rowCount(qt.QModelIndex()), 1) - # clean up - index = model.index(0, 0, qt.QModelIndex()) - h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) - self.assertIsNotNone(h5File) - finally: - ref = weakref.ref(model) - model = None - self.qWaitForDestroy(ref) - def testInsertFilenameAsync(self): - with self.h5TempFile() as filename: - try: - model = hdf5.Hdf5TreeModel() - self.assertEquals(model.rowCount(qt.QModelIndex()), 0) - model.insertFileAsync(filename) - index = model.index(0, 0, qt.QModelIndex()) - self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5LoadingItem.Hdf5LoadingItem) - self.waitForPendingOperations(model) - index = model.index(0, 0, qt.QModelIndex()) - self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item) - finally: - ref = weakref.ref(model) - model = None - self.qWaitForDestroy(ref) + filename = _tmpDirectory + "/data.h5" + try: + model = hdf5.Hdf5TreeModel() + self.assertEquals(model.rowCount(qt.QModelIndex()), 0) + model.insertFileAsync(filename) + index = model.index(0, 0, qt.QModelIndex()) + self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5LoadingItem.Hdf5LoadingItem) + self.waitForPendingOperations(model) + index = model.index(0, 0, qt.QModelIndex()) + self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item) + finally: + ref = weakref.ref(model) + model = None + self.qWaitForDestroy(ref) def testInsertObject(self): h5 = commonh5.File("/foo/bar/1.mock", "w") @@ -162,36 +186,37 @@ class TestHdf5TreeModel(TestCaseQt): self.assertEquals(model.rowCount(qt.QModelIndex()), 0) def testSynchronizeObject(self): - with self.h5TempFile() as filename: - h5 = h5py.File(filename) - model = hdf5.Hdf5TreeModel() - model.insertH5pyObject(h5) - self.assertEquals(model.rowCount(qt.QModelIndex()), 1) - index = model.index(0, 0, qt.QModelIndex()) - node1 = model.nodeFromIndex(index) - model.synchronizeH5pyObject(h5) - # Now h5 was loaded from it's filename - # Another ref is owned by the model - h5.close() + filename = _tmpDirectory + "/data.h5" + h5 = h5py.File(filename) + model = hdf5.Hdf5TreeModel() + model.insertH5pyObject(h5) + self.assertEquals(model.rowCount(qt.QModelIndex()), 1) + index = model.index(0, 0, qt.QModelIndex()) + node1 = model.nodeFromIndex(index) + model.synchronizeH5pyObject(h5) + self.waitForPendingOperations(model) + # Now h5 was loaded from it's filename + # Another ref is owned by the model + h5.close() - index = model.index(0, 0, qt.QModelIndex()) - node2 = model.nodeFromIndex(index) - self.assertIsNot(node1, node2) - # after sync - time.sleep(0.1) - self.qapp.processEvents() - time.sleep(0.1) - index = model.index(0, 0, qt.QModelIndex()) - self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item) - # clean up - index = model.index(0, 0, qt.QModelIndex()) - h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) - self.assertIsNotNone(h5File) - h5File = None - # delete the model - ref = weakref.ref(model) - model = None - self.qWaitForDestroy(ref) + index = model.index(0, 0, qt.QModelIndex()) + node2 = model.nodeFromIndex(index) + self.assertIsNot(node1, node2) + # after sync + time.sleep(0.1) + self.qapp.processEvents() + time.sleep(0.1) + index = model.index(0, 0, qt.QModelIndex()) + self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item) + # clean up + index = model.index(0, 0, qt.QModelIndex()) + h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) + self.assertIsNotNone(h5File) + h5File = None + # delete the model + ref = weakref.ref(model) + model = None + self.qWaitForDestroy(ref) def testFileMoveState(self): model = hdf5.Hdf5TreeModel() @@ -222,24 +247,24 @@ class TestHdf5TreeModel(TestCaseQt): self.assertNotEquals(model.supportedDropActions(), 0) def testDropExternalFile(self): - with self.h5TempFile() as filename: - model = hdf5.Hdf5TreeModel() - 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) - # after sync - self.waitForPendingOperations(model) - index = model.index(0, 0, qt.QModelIndex()) - self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item) - # clean up - index = model.index(0, 0, qt.QModelIndex()) - h5File = model.data(index, role=hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) - self.assertIsNotNone(h5File) - h5File = None - ref = weakref.ref(model) - model = None - self.qWaitForDestroy(ref) + filename = _tmpDirectory + "/data.h5" + model = hdf5.Hdf5TreeModel() + 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) + # after sync + self.waitForPendingOperations(model) + index = model.index(0, 0, qt.QModelIndex()) + self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item) + # clean up + index = model.index(0, 0, qt.QModelIndex()) + h5File = model.data(index, role=hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) + self.assertIsNotNone(h5File) + h5File = None + ref = weakref.ref(model) + model = None + self.qWaitForDestroy(ref) def getRowDataAsDict(self, model, row): displayed = {} @@ -337,6 +362,66 @@ class TestHdf5TreeModel(TestCaseQt): self.assertEquals(index, qt.QModelIndex()) +class TestHdf5TreeModelSignals(TestCaseQt): + + def setUp(self): + TestCaseQt.setUp(self) + self.model = hdf5.Hdf5TreeModel() + filename = _tmpDirectory + "/data.h5" + self.h5 = h5py.File(filename) + self.model.insertH5pyObject(self.h5) + + self.listener = SignalListener() + self.model.sigH5pyObjectLoaded.connect(self.listener.partial(signal="loaded")) + self.model.sigH5pyObjectRemoved.connect(self.listener.partial(signal="removed")) + self.model.sigH5pyObjectSynchronized.connect(self.listener.partial(signal="synchronized")) + + def tearDown(self): + self.signals = None + ref = weakref.ref(self.model) + self.model = None + self.qWaitForDestroy(ref) + self.h5.close() + self.h5 = None + TestCaseQt.tearDown(self) + + def waitForPendingOperations(self, model): + for _ in range(10): + if not model.hasPendingOperations(): + break + self.qWait(10) + else: + raise RuntimeError("Still waiting for a pending operation") + + def testInsert(self): + filename = _tmpDirectory + "/data.h5" + h5 = h5py.File(filename) + self.model.insertH5pyObject(h5) + self.assertEquals(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.assertIsNot(self.listener.arguments(callIndex=0)[0], self.h5) + self.assertEquals(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.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.assertIs(self.listener.arguments(callIndex=0)[0], self.h5) + self.assertIsNot(self.listener.arguments(callIndex=0)[1], self.h5) + + class TestNexusSortFilterProxyModel(TestCaseQt): def getChildNames(self, model, index): @@ -873,6 +958,7 @@ def suite(): test_suite = unittest.TestSuite() loadTests = unittest.defaultTestLoader.loadTestsFromTestCase test_suite.addTest(loadTests(TestHdf5TreeModel)) + test_suite.addTest(loadTests(TestHdf5TreeModelSignals)) test_suite.addTest(loadTests(TestNexusSortFilterProxyModel)) test_suite.addTest(loadTests(TestHdf5TreeView)) test_suite.addTest(loadTests(TestH5Node)) diff --git a/silx/gui/icons.py b/silx/gui/icons.py index 0108b3a..bd10300 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__ = "06/09/2017" +__date__ = "19/06/2018" import os @@ -193,10 +193,13 @@ class MultiImageAnimatedIcon(AbstractAnimatedIcon): self.__frames = [] for i in range(100): try: - pixmap = getQPixmap("%s/%02d" % (filename, i)) + filename = getQFile("%s/%02d" % (filename, i)) + except ValueError: + break + try: + icon = qt.QIcon(filename.fileName()) except ValueError: break - icon = qt.QIcon(pixmap) self.__frames.append(icon) if len(self.__frames) == 0: @@ -328,8 +331,7 @@ def getQIcon(name): """ if name not in _cached_icons: qfile = getQFile(name) - pixmap = qt.QPixmap(qfile.fileName()) - icon = qt.QIcon(pixmap) + icon = qt.QIcon(qfile.fileName()) _cached_icons[name] = icon else: icon = _cached_icons[name] @@ -392,7 +394,7 @@ def getQFile(name): for format_ in _supported_formats: format_ = str(format_) filename = silx.resources._resource_filename('%s.%s' % (name, format_), - default_directory=os.path.join('gui', 'icons')) + default_directory=os.path.join('gui', 'icons')) qfile = qt.QFile(filename) if qfile.exists(): return qfile diff --git a/silx/gui/plot/ColorBar.py b/silx/gui/plot/ColorBar.py index 2db7b79..0941e82 100644 --- a/silx/gui/plot/ColorBar.py +++ b/silx/gui/plot/ColorBar.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 @@ -27,14 +27,16 @@ __authors__ = ["H. Payno", "T. Vincent"] __license__ = "MIT" -__date__ = "15/02/2018" +__date__ = "24/04/2018" import logging +import weakref import numpy + from ._utils import ticklayout -from .. import qt, icons -from silx.gui.plot import Colormap +from .. import qt +from silx.gui import colors _logger = logging.getLogger(__name__) @@ -70,7 +72,7 @@ class ColorBarWidget(qt.QWidget): def __init__(self, parent=None, plot=None, legend=None): self._isConnected = False - self._plot = None + self._plotRef = None self._colormap = None self._data = None @@ -96,7 +98,7 @@ class ColorBarWidget(qt.QWidget): def getPlot(self): """Returns the :class:`Plot` associated to this widget or None""" - return self._plot + return None if self._plotRef is None else self._plotRef() def setPlot(self, plot): """Associate a plot to the ColorBar @@ -105,27 +107,38 @@ class ColorBarWidget(qt.QWidget): If None will remove any connection with a previous plot. """ self._disconnectPlot() - self._plot = plot + self._plotRef = None if plot is None else weakref.ref(plot) self._connectPlot() def _disconnectPlot(self): """Disconnect from Plot signals""" - if self._plot is not None and self._isConnected: + plot = self.getPlot() + if plot is not None and self._isConnected: self._isConnected = False - self._plot.sigActiveImageChanged.disconnect( + plot.sigActiveImageChanged.disconnect( self._activeImageChanged) - self._plot.sigPlotSignal.disconnect(self._defaultColormapChanged) + plot.sigActiveScatterChanged.disconnect( + self._activeScatterChanged) + plot.sigPlotSignal.disconnect(self._defaultColormapChanged) def _connectPlot(self): """Connect to Plot signals""" - if self._plot is not None and not self._isConnected: - activeImageLegend = self._plot.getActiveImage(just_legend=True) - if activeImageLegend is None: # Show plot default colormap + plot = self.getPlot() + if plot is not None and not self._isConnected: + activeImageLegend = plot.getActiveImage(just_legend=True) + activeScatterLegend = plot._getActiveItem( + kind='scatter', just_legend=True) + if activeImageLegend is None and activeScatterLegend is None: + # Show plot default colormap self._syncWithDefaultColormap() - else: # Show active image colormap + elif activeImageLegend is not None: # Show active image colormap self._activeImageChanged(None, activeImageLegend) - self._plot.sigActiveImageChanged.connect(self._activeImageChanged) - self._plot.sigPlotSignal.connect(self._defaultColormapChanged) + elif activeScatterLegend is not None: # Show active scatter colormap + self._activeScatterChanged(None, activeScatterLegend) + + plot.sigActiveImageChanged.connect(self._activeImageChanged) + plot.sigActiveScatterChanged.connect(self._activeScatterChanged) + plot.sigPlotSignal.connect(self._defaultColormapChanged) self._isConnected = True def setVisible(self, isVisible): @@ -196,36 +209,58 @@ class ColorBarWidget(qt.QWidget): """ return self.legend.getText() - def _activeImageChanged(self, previous, legend): - """Handle plot active curve changed""" - if legend is None: # No active image, display no colormap - self.setColormap(colormap=None) - return + def _activeScatterChanged(self, previous, legend): + """Handle plot active scatter changed""" + plot = self.getPlot() - # Sync with active image - image = self._plot.getActiveImage().getData(copy=False) + # Do not handle active scatter while there is an image + if plot.getActiveImage() is not None: + return - # RGB(A) image, display default colormap - if image.ndim != 2: + if legend is None: # No active scatter, display no colormap self.setColormap(colormap=None) return - # data image, sync with image colormap - # do we need the copy here : used in the case we are changing - # vmin and vmax but should have already be done by the plot - self.setColormap(colormap=self._plot.getActiveImage().getColormap(), - data=image) + # Sync with active scatter + activeScatter = plot._getActiveItem(kind='scatter') + + self.setColormap(colormap=activeScatter.getColormap(), + data=activeScatter.getValueData(copy=False)) + + def _activeImageChanged(self, previous, legend): + """Handle plot active image changed""" + plot = self.getPlot() + + if legend is None: # No active image, try with active scatter + activeScatterLegend = plot._getActiveItem( + kind='scatter', just_legend=True) + # No more active image, use active scatter if any + self._activeScatterChanged(None, activeScatterLegend) + else: + # Sync with active image + image = plot.getActiveImage().getData(copy=False) + + # RGB(A) image, display default colormap + if image.ndim != 2: + self.setColormap(colormap=None) + return + + # data image, sync with image colormap + # do we need the copy here : used in the case we are changing + # vmin and vmax but should have already be done by the plot + self.setColormap(colormap=plot.getActiveImage().getColormap(), + data=image) def _defaultColormapChanged(self, event): """Handle plot default colormap changed""" if (event['event'] == 'defaultColormapChanged' and - self._plot.getActiveImage() is None): + self.getPlot().getActiveImage() is None): # No active image, take default colormap update into account self._syncWithDefaultColormap() def _syncWithDefaultColormap(self, data=None): """Update colorbar according to plot default colormap""" - self.setColormap(self._plot.getDefaultColormap(), data) + self.setColormap(self.getPlot().getDefaultColormap(), data) def getColorScaleBar(self): """ @@ -316,9 +351,9 @@ class ColorScaleBar(qt.QWidget): if colormap: vmin, vmax = colormap.getColormapRange(data) else: - vmin, vmax = Colormap.DEFAULT_MIN_LIN, Colormap.DEFAULT_MAX_LIN + vmin, vmax = colors.DEFAULT_MIN_LIN, colors.DEFAULT_MAX_LIN - norm = colormap.getNormalization() if colormap else Colormap.Colormap.LINEAR + norm = colormap.getNormalization() if colormap else colors.Colormap.LINEAR self.tickbar = _TickBar(vmin=vmin, vmax=vmax, norm=norm, @@ -503,7 +538,7 @@ class _ColorScale(qt.QWidget): if colormap is None: self.vmin, self.vmax = None, None else: - assert colormap.getNormalization() in Colormap.Colormap.NORMALIZATIONS + assert colormap.getNormalization() in colors.Colormap.NORMALIZATIONS self.vmin, self.vmax = self._colormap.getColormapRange(data=data) self._updateColorGradient() self.update() @@ -575,9 +610,9 @@ class _ColorScale(qt.QWidget): vmin = self.vmin vmax = self.vmax - if colormap.getNormalization() == Colormap.Colormap.LINEAR: + if colormap.getNormalization() == colors.Colormap.LINEAR: return vmin + (vmax - vmin) * value - elif colormap.getNormalization() == Colormap.Colormap.LOGARITHM: + elif colormap.getNormalization() == colors.Colormap.LOGARITHM: rpos = (numpy.log10(vmax) - numpy.log10(vmin)) * value + numpy.log10(vmin) return numpy.power(10., rpos) else: @@ -706,9 +741,9 @@ class _TickBar(qt.QWidget): # No range: no ticks self.ticks = () self.subTicks = () - elif self._norm == Colormap.Colormap.LOGARITHM: + elif self._norm == colors.Colormap.LOGARITHM: self._computeTicksLog(nticks) - elif self._norm == Colormap.Colormap.LINEAR: + elif self._norm == colors.Colormap.LINEAR: self._computeTicksLin(nticks) else: err = 'TickBar - Wrong normalization %s' % self._norm @@ -765,9 +800,9 @@ class _TickBar(qt.QWidget): def _getRelativePosition(self, val): """Return the relative position of val according to min and max value """ - if self._norm == Colormap.Colormap.LINEAR: + if self._norm == colors.Colormap.LINEAR: return 1 - (val - self._vmin) / (self._vmax - self._vmin) - elif self._norm == Colormap.Colormap.LOGARITHM: + elif self._norm == colors.Colormap.LOGARITHM: return 1 - (numpy.log10(val) - numpy.log10(self._vmin)) / (numpy.log10(self._vmax) - numpy.log(self._vmin)) else: raise ValueError('Norm is not recognized') diff --git a/silx/gui/plot/Colormap.py b/silx/gui/plot/Colormap.py index 9adf0d4..e797d89 100644 --- a/silx/gui/plot/Colormap.py +++ b/silx/gui/plot/Colormap.py @@ -22,568 +22,23 @@ # THE SOFTWARE. # # ###########################################################################*/ -"""This module provides the Colormap object +"""Deprecated module providing the Colormap object """ from __future__ import absolute_import __authors__ = ["T. Vincent", "H.Payno"] __license__ = "MIT" -__date__ = "08/01/2018" +__date__ = "24/04/2018" -from silx.gui import qt -import copy as copy_mdl -import numpy -from .matplotlib import Colormap as MPLColormap -import logging -from silx.math.combo import min_max -from silx.utils.exceptions import NotEditableError +import silx.utils.deprecation -_logger = logging.getLogger(__file__) +silx.utils.deprecation.deprecated_warning("Module", + name="silx.gui.plot.Colormap", + reason="moved", + replacement="silx.gui.colors.Colormap", + since_version="0.8.0", + only_once=True, + skip_backtrace_count=1) -DEFAULT_COLORMAPS = ( - 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue') -"""Tuple of supported colormap names.""" - -DEFAULT_MIN_LIN = 0 -"""Default min value if in linear normalization""" -DEFAULT_MAX_LIN = 1 -"""Default max value if in linear normalization""" -DEFAULT_MIN_LOG = 1 -"""Default min value if in log normalization""" -DEFAULT_MAX_LOG = 10 -"""Default max value if in log normalization""" - - -class Colormap(qt.QObject): - """Description of a colormap - - :param str name: Name of the colormap - :param tuple colors: optional, custom colormap. - Nx3 or Nx4 numpy array of RGB(A) colors, - either uint8 or float in [0, 1]. - If 'name' is None, then this array is used as the colormap. - :param str normalization: Normalization: 'linear' (default) or 'log' - :param float vmin: - Lower bound of the colormap or None for autoscale (default) - :param float vmax: - Upper bounds of the colormap or None for autoscale (default) - """ - - LINEAR = 'linear' - """constant for linear normalization""" - - LOGARITHM = 'log' - """constant for logarithmic normalization""" - - NORMALIZATIONS = (LINEAR, LOGARITHM) - """Tuple of managed normalizations""" - - sigChanged = qt.Signal() - """Signal emitted when the colormap has changed.""" - - def __init__(self, name='gray', colors=None, normalization=LINEAR, vmin=None, vmax=None): - qt.QObject.__init__(self) - assert normalization in Colormap.NORMALIZATIONS - assert not (name is None and colors is None) - if normalization is Colormap.LOGARITHM: - if (vmin is not None and vmin < 0) or (vmax is not None and vmax < 0): - m = "Unsuported vmin (%s) and/or vmax (%s) given for a log scale." - m += ' Autoscale will be performed.' - m = m % (vmin, vmax) - _logger.warning(m) - vmin = None - vmax = None - - self._name = str(name) if name is not None else None - self._setColors(colors) - self._normalization = str(normalization) - self._vmin = float(vmin) if vmin is not None else None - self._vmax = float(vmax) if vmax is not None else None - self._editable = True - - def isAutoscale(self): - """Return True if both min and max are in autoscale mode""" - return self._vmin is None and self._vmax is None - - def getName(self): - """Return the name of the colormap - :rtype: str - """ - return self._name - - def _setColors(self, colors): - if colors is None: - self._colors = None - else: - self._colors = numpy.array(colors, copy=True) - - def getNColors(self, nbColors=None): - """Returns N colors computed by sampling the colormap regularly. - - :param nbColors: - The number of colors in the returned array or None for the default value. - The default value is 256 for colormap with a name (see :meth:`setName`) and - it is the size of the LUT for colormap defined with :meth:`setColormapLUT`. - :type nbColors: int or None - :return: 2D array of uint8 of shape (nbColors, 4) - :rtype: numpy.ndarray - """ - # Handle default value for nbColors - if nbColors is None: - lut = self.getColormapLUT() - if lut is not None: # In this case uses LUT length - nbColors = len(lut) - else: # Default to 256 - nbColors = 256 - - nbColors = int(nbColors) - - colormap = self.copy() - colormap.setNormalization(Colormap.LINEAR) - colormap.setVRange(vmin=None, vmax=None) - colors = colormap.applyToData( - numpy.arange(nbColors, dtype=numpy.int)) - return colors - - def setName(self, name): - """Set the name of the colormap to use. - - :param str name: The name of the colormap. - At least the following names are supported: 'gray', - 'reversed gray', 'temperature', 'red', 'green', 'blue', 'jet', - 'viridis', 'magma', 'inferno', 'plasma'. - """ - if self.isEditable() is False: - raise NotEditableError('Colormap is not editable') - assert name in self.getSupportedColormaps() - self._name = str(name) - self._colors = None - self.sigChanged.emit() - - def getColormapLUT(self): - """Return the list of colors for the colormap or None if not set - - :return: the list of colors for the colormap or None if not set - :rtype: numpy.ndarray or None - """ - if self._colors is None: - return None - else: - return numpy.array(self._colors, copy=True) - - def setColormapLUT(self, colors): - """Set the colors of the colormap. - - :param numpy.ndarray colors: the colors of the LUT - - .. warning: this will set the value of name to None - """ - if self.isEditable() is False: - raise NotEditableError('Colormap is not editable') - self._setColors(colors) - if len(colors) is 0: - self._colors = None - - self._name = None - self.sigChanged.emit() - - def getNormalization(self): - """Return the normalization of the colormap ('log' or 'linear') - - :return: the normalization of the colormap - :rtype: str - """ - return self._normalization - - def setNormalization(self, norm): - """Set the norm ('log', 'linear') - - :param str norm: the norm to set - """ - if self.isEditable() is False: - raise NotEditableError('Colormap is not editable') - self._normalization = str(norm) - self.sigChanged.emit() - - def getVMin(self): - """Return the lower bound of the colormap - - :return: the lower bound of the colormap - :rtype: float or None - """ - return self._vmin - - def setVMin(self, vmin): - """Set the minimal value of the colormap - - :param float vmin: Lower bound of the colormap or None for autoscale - (default) - value) - """ - if self.isEditable() is False: - raise NotEditableError('Colormap is not editable') - if vmin is not None: - if self._vmax is not None and vmin > self._vmax: - err = "Can't set vmin because vmin >= vmax. " \ - "vmin = %s, vmax = %s" % (vmin, self._vmax) - raise ValueError(err) - - self._vmin = vmin - self.sigChanged.emit() - - def getVMax(self): - """Return the upper bounds of the colormap or None - - :return: the upper bounds of the colormap or None - :rtype: float or None - """ - return self._vmax - - def setVMax(self, vmax): - """Set the maximal value of the colormap - - :param float vmax: Upper bounds of the colormap or None for autoscale - (default) - """ - if self.isEditable() is False: - raise NotEditableError('Colormap is not editable') - if vmax is not None: - if self._vmin is not None and vmax < self._vmin: - err = "Can't set vmax because vmax <= vmin. " \ - "vmin = %s, vmax = %s" % (self._vmin, vmax) - raise ValueError(err) - - self._vmax = vmax - self.sigChanged.emit() - - def isEditable(self): - """ Return if the colormap is editable or not - - :return: editable state of the colormap - :rtype: bool - """ - return self._editable - - def setEditable(self, editable): - """ - Set the editable state of the colormap - - :param bool editable: is the colormap editable - """ - assert type(editable) is bool - self._editable = editable - self.sigChanged.emit() - - def getColormapRange(self, data=None): - """Return (vmin, vmax) - - :return: the tuple vmin, vmax fitting vmin, vmax, normalization and - data if any given - :rtype: tuple - """ - vmin = self._vmin - vmax = self._vmax - assert vmin is None or vmax is None or vmin <= vmax # TODO handle this in setters - - if self.getNormalization() == self.LOGARITHM: - # Handle negative bounds as autoscale - if vmin is not None and (vmin is not None and vmin <= 0.): - mess = 'negative vmin, moving to autoscale for lower bound' - _logger.warning(mess) - vmin = None - if vmax is not None and (vmax is not None and vmax <= 0.): - mess = 'negative vmax, moving to autoscale for upper bound' - _logger.warning(mess) - vmax = None - - if vmin is None or vmax is None: # Handle autoscale - # Get min/max from data - if data is not None: - data = numpy.array(data, copy=False) - if data.size == 0: # Fallback an array but no data - min_, max_ = self._getDefaultMin(), self._getDefaultMax() - else: - if self.getNormalization() == self.LOGARITHM: - result = min_max(data, min_positive=True, finite=True) - min_ = result.min_positive # >0 or None - max_ = result.maximum # can be <= 0 - else: - min_, max_ = min_max(data, min_positive=False, finite=True) - - # Handle fallback - if min_ is None or not numpy.isfinite(min_): - min_ = self._getDefaultMin() - if max_ is None or not numpy.isfinite(max_): - max_ = self._getDefaultMax() - else: # Fallback if no data is provided - min_, max_ = self._getDefaultMin(), self._getDefaultMax() - - if vmin is None: # Set vmin respecting provided vmax - vmin = min_ if vmax is None else min(min_, vmax) - - if vmax is None: - vmax = max(max_, vmin) # Handle max_ <= 0 for log scale - - return vmin, vmax - - def setVRange(self, vmin, vmax): - """Set the bounds of the colormap - - :param vmin: Lower bound of the colormap or None for autoscale - (default) - :param vmax: Upper bounds of the colormap or None for autoscale - (default) - """ - if self.isEditable() is False: - raise NotEditableError('Colormap is not editable') - if vmin is not None and vmax is not None: - if vmin > vmax: - err = "Can't set vmin and vmax because vmin >= vmax " \ - "vmin = %s, vmax = %s" % (vmin, vmax) - raise ValueError(err) - - if self._vmin == vmin and self._vmax == vmax: - return - - self._vmin = vmin - self._vmax = vmax - self.sigChanged.emit() - - def __getitem__(self, item): - if item == 'autoscale': - return self.isAutoscale() - elif item == 'name': - return self.getName() - elif item == 'normalization': - return self.getNormalization() - elif item == 'vmin': - return self.getVMin() - elif item == 'vmax': - return self.getVMax() - elif item == 'colors': - return self.getColormapLUT() - else: - raise KeyError(item) - - def _toDict(self): - """Return the equivalent colormap as a dictionary - (old colormap representation) - - :return: the representation of the Colormap as a dictionary - :rtype: dict - """ - return { - 'name': self._name, - 'colors': copy_mdl.copy(self._colors), - 'vmin': self._vmin, - 'vmax': self._vmax, - 'autoscale': self.isAutoscale(), - 'normalization': self._normalization - } - - def _setFromDict(self, dic): - """Set values to the colormap from a dictionary - - :param dict dic: the colormap as a dictionary - """ - if self.isEditable() is False: - raise NotEditableError('Colormap is not editable') - name = dic['name'] if 'name' in dic else None - colors = dic['colors'] if 'colors' in dic else None - vmin = dic['vmin'] if 'vmin' in dic else None - vmax = dic['vmax'] if 'vmax' in dic else None - if 'normalization' in dic: - normalization = dic['normalization'] - else: - warn = 'Normalization not given in the dictionary, ' - warn += 'set by default to ' + Colormap.LINEAR - _logger.warning(warn) - normalization = Colormap.LINEAR - - if name is None and colors is None: - err = 'The colormap should have a name defined or a tuple of colors' - raise ValueError(err) - if normalization not in Colormap.NORMALIZATIONS: - err = 'Given normalization is not recoginized (%s)' % normalization - raise ValueError(err) - - # If autoscale, then set boundaries to None - if dic.get('autoscale', False): - vmin, vmax = None, None - - self._name = name - self._colors = colors - self._vmin = vmin - self._vmax = vmax - self._autoscale = True if (vmin is None and vmax is None) else False - self._normalization = normalization - - self.sigChanged.emit() - - @staticmethod - def _fromDict(dic): - colormap = Colormap(name="") - colormap._setFromDict(dic) - return colormap - - def copy(self): - """Return a copy of the Colormap. - - :rtype: silx.gui.plot.Colormap.Colormap - """ - return Colormap(name=self._name, - colors=copy_mdl.copy(self._colors), - vmin=self._vmin, - vmax=self._vmax, - normalization=self._normalization) - - def applyToData(self, data): - """Apply the colormap to the data - - :param numpy.ndarray data: The data to convert. - """ - rgbaImage = MPLColormap.applyColormapToData(colormap=self, data=data) - return rgbaImage - - @staticmethod - def getSupportedColormaps(): - """Get the supported colormap names as a tuple of str. - - The list should at least contain and start by: - ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue') - :rtype: tuple - """ - maps = MPLColormap.getSupportedColormaps() - return DEFAULT_COLORMAPS + maps - - def __str__(self): - return str(self._toDict()) - - def _getDefaultMin(self): - return DEFAULT_MIN_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MIN_LOG - - def _getDefaultMax(self): - return DEFAULT_MAX_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MAX_LOG - - def __eq__(self, other): - """Compare colormap values and not pointers""" - return (self.getName() == other.getName() and - self.getNormalization() == other.getNormalization() and - self.getVMin() == other.getVMin() and - self.getVMax() == other.getVMax() and - numpy.array_equal(self.getColormapLUT(), other.getColormapLUT()) - ) - - _SERIAL_VERSION = 1 - - def restoreState(self, byteArray): - """ - Read the colormap state from a QByteArray. - - :param qt.QByteArray byteArray: Stream containing the state - :return: True if the restoration sussseed - :rtype: bool - """ - if self.isEditable() is False: - raise NotEditableError('Colormap is not editable') - stream = qt.QDataStream(byteArray, qt.QIODevice.ReadOnly) - - className = stream.readQString() - if className != self.__class__.__name__: - _logger.warning("Classname mismatch. Found %s." % className) - return False - - version = stream.readUInt32() - if version != self._SERIAL_VERSION: - _logger.warning("Serial version mismatch. Found %d." % version) - return False - - name = stream.readQString() - isNull = stream.readBool() - if not isNull: - vmin = stream.readQVariant() - else: - vmin = None - isNull = stream.readBool() - if not isNull: - vmax = stream.readQVariant() - else: - vmax = None - normalization = stream.readQString() - - # emit change event only once - old = self.blockSignals(True) - try: - self.setName(name) - self.setNormalization(normalization) - self.setVRange(vmin, vmax) - finally: - self.blockSignals(old) - self.sigChanged.emit() - return True - - def saveState(self): - """ - Save state of the colomap into a QDataStream. - - :rtype: qt.QByteArray - """ - data = qt.QByteArray() - stream = qt.QDataStream(data, qt.QIODevice.WriteOnly) - - stream.writeQString(self.__class__.__name__) - stream.writeUInt32(self._SERIAL_VERSION) - stream.writeQString(self.getName()) - stream.writeBool(self.getVMin() is None) - if self.getVMin() is not None: - stream.writeQVariant(self.getVMin()) - stream.writeBool(self.getVMax() is None) - if self.getVMax() is not None: - stream.writeQVariant(self.getVMax()) - stream.writeQString(self.getNormalization()) - return data - - -_PREFERRED_COLORMAPS = DEFAULT_COLORMAPS -""" -Tuple of preferred colormap names accessed with :meth:`preferredColormaps`. -""" - - -def preferredColormaps(): - """Returns the name of the preferred colormaps. - - This list is used by widgets allowing to change the colormap - like the :class:`ColormapDialog` as a subset of colormap choices. - - :rtype: tuple of str - """ - return _PREFERRED_COLORMAPS - - -def setPreferredColormaps(colormaps): - """Set the list of preferred colormap names. - - Warning: If a colormap name is not available - it will be removed from the list. - - :param colormaps: Not empty list of colormap names - :type colormaps: iterable of str - :raise ValueError: if the list of available preferred colormaps is empty. - """ - supportedColormaps = Colormap.getSupportedColormaps() - colormaps = tuple( - cmap for cmap in colormaps if cmap in supportedColormaps) - if len(colormaps) == 0: - raise ValueError("Cannot set preferred colormaps to an empty list") - - global _PREFERRED_COLORMAPS - _PREFERRED_COLORMAPS = colormaps - - -# Initialize preferred colormaps -setPreferredColormaps(('gray', 'reversed gray', - 'temperature', 'red', 'green', 'blue', 'jet', - 'viridis', 'magma', 'inferno', 'plasma', - 'hsv')) +from ..colors import * # noqa diff --git a/silx/gui/plot/ColormapDialog.py b/silx/gui/plot/ColormapDialog.py index 4aefab6..7c66cb8 100644 --- a/silx/gui/plot/ColormapDialog.py +++ b/silx/gui/plot/ColormapDialog.py @@ -22,960 +22,22 @@ # THE SOFTWARE. # # ###########################################################################*/ -"""A QDialog widget to set-up the colormap. +"""Deprecated module providing ColormapDialog.""" -It uses a description of colormaps as dict compatible with :class:`Plot`. +from __future__ import absolute_import -To run the following sample code, a QApplication must be initialized. - -Create the colormap dialog and set the colormap description and data range: - ->>> from silx.gui.plot.ColormapDialog import ColormapDialog ->>> from silx.gui.plot.Colormap import Colormap - ->>> dialog = ColormapDialog() ->>> colormap = Colormap(name='red', normalization='log', -... vmin=1., vmax=2.) - ->>> dialog.setColormap(colormap) ->>> colormap.setVRange(1., 100.) # This scale the width of the plot area ->>> dialog.show() - -Get the colormap description (compatible with :class:`Plot`) from the dialog: - ->>> cmap = dialog.getColormap() ->>> cmap.getName() -'red' - -It is also possible to display an histogram of the image in the dialog. -This updates the data range with the range of the bins. - ->>> import numpy ->>> image = numpy.random.normal(size=512 * 512).reshape(512, -1) ->>> hist, bin_edges = numpy.histogram(image, bins=10) ->>> dialog.setHistogram(hist, bin_edges) - -The updates of the colormap description are also available through the signal: -:attr:`ColormapDialog.sigColormapChanged`. -""" # noqa - -from __future__ import division - -__authors__ = ["V.A. Sole", "T. Vincent", "H. Payno"] +__authors__ = ["T. Vincent", "H.Payno"] __license__ = "MIT" -__date__ = "09/02/2018" - - -import logging - -import numpy - -from .. import qt -from .Colormap import Colormap, preferredColormaps -from . import PlotWidget -from silx.gui.widgets.FloatEdit import FloatEdit -import weakref -from silx.math.combo import min_max -from silx.third_party import enum -from silx.gui import icons -from silx.math.histogram import Histogramnd - -_logger = logging.getLogger(__name__) - - -_colormapIconPreview = {} - - -class _BoundaryWidget(qt.QWidget): - """Widget to edit a boundary of the colormap (vmin, vmax)""" - sigValueChanged = qt.Signal(object) - """Signal emitted when value is changed""" - - def __init__(self, parent=None, value=0.0): - qt.QWidget.__init__(self, parent=None) - self.setLayout(qt.QHBoxLayout()) - self.layout().setContentsMargins(0, 0, 0, 0) - self._numVal = FloatEdit(parent=self, value=value) - self.layout().addWidget(self._numVal) - self._autoCB = qt.QCheckBox('auto', parent=self) - self.layout().addWidget(self._autoCB) - self._autoCB.setChecked(False) - - self._autoCB.toggled.connect(self._autoToggled) - self.sigValueChanged = self._autoCB.toggled - self.textEdited = self._numVal.textEdited - self.editingFinished = self._numVal.editingFinished - self._dataValue = None - - def isAutoChecked(self): - return self._autoCB.isChecked() - - def getValue(self): - return None if self._autoCB.isChecked() else self._numVal.value() - - def getFiniteValue(self): - if not self._autoCB.isChecked(): - return self._numVal.value() - elif self._dataValue is None: - return self._numVal.value() - else: - return self._dataValue - - def _autoToggled(self, enabled): - self._numVal.setEnabled(not enabled) - self._updateDisplayedText() - - def _updateDisplayedText(self): - # if dataValue is finite - if self._autoCB.isChecked() and self._dataValue is not None: - old = self._numVal.blockSignals(True) - self._numVal.setValue(self._dataValue) - self._numVal.blockSignals(old) - - def setDataValue(self, dataValue): - self._dataValue = dataValue - self._updateDisplayedText() - - def setFiniteValue(self, value): - assert(value is not None) - old = self._numVal.blockSignals(True) - self._numVal.setValue(value) - self._numVal.blockSignals(old) - - def setValue(self, value, isAuto=False): - self._autoCB.setChecked(isAuto or value is None) - if value is not None: - self._numVal.setValue(value) - self._updateDisplayedText() - - -class _ColormapNameCombox(qt.QComboBox): - def __init__(self, parent=None): - qt.QComboBox.__init__(self, parent) - self.__initItems() - - ORIGINAL_NAME = qt.Qt.UserRole + 1 - - def __initItems(self): - for colormapName in preferredColormaps(): - index = self.count() - self.addItem(str.title(colormapName)) - self.setItemIcon(index, self.getIconPreview(colormapName)) - self.setItemData(index, colormapName, role=self.ORIGINAL_NAME) - - def getIconPreview(self, colormapName): - """Return an icon preview from a LUT name. - - This icons are cached into a global structure. - - :param str colormapName: str - :rtype: qt.QIcon - """ - if colormapName not in _colormapIconPreview: - icon = self.createIconPreview(colormapName) - _colormapIconPreview[colormapName] = icon - return _colormapIconPreview[colormapName] - - def createIconPreview(self, colormapName): - """Create and return an icon preview from a LUT name. - - This icons are cached into a global structure. - - :param str colormapName: Name of the LUT - :rtype: qt.QIcon - """ - colormap = Colormap(colormapName) - size = 32 - lut = colormap.getNColors(size) - if lut is None or len(lut) == 0: - return qt.QIcon() - - pixmap = qt.QPixmap(size, size) - painter = qt.QPainter(pixmap) - for i in range(size): - rgb = lut[i] - r, g, b = rgb[0], rgb[1], rgb[2] - painter.setPen(qt.QColor(r, g, b)) - painter.drawPoint(qt.QPoint(i, 0)) - - painter.drawPixmap(0, 1, size, size - 1, pixmap, 0, 0, size, 1) - painter.end() - - return qt.QIcon(pixmap) - - def getCurrentName(self): - return self.itemData(self.currentIndex(), self.ORIGINAL_NAME) - - def findColormap(self, name): - return self.findData(name, role=self.ORIGINAL_NAME) - - def setCurrentName(self, name): - index = self.findColormap(name) - if index < 0: - index = self.count() - self.addItem(str.title(name)) - self.setItemIcon(index, self.getIconPreview(name)) - self.setItemData(index, name, role=self.ORIGINAL_NAME) - self.setCurrentIndex(index) - - -@enum.unique -class _DataInPlotMode(enum.Enum): - """Enum for each mode of display of the data in the plot.""" - NONE = 'none' - RANGE = 'range' - HISTOGRAM = 'histogram' - - -class ColormapDialog(qt.QDialog): - """A QDialog widget to set the colormap. - - :param parent: See :class:`QDialog` - :param str title: The QDialog title - """ - - visibleChanged = qt.Signal(bool) - """This event is sent when the dialog visibility change""" - - def __init__(self, parent=None, title="Colormap Dialog"): - qt.QDialog.__init__(self, parent) - self.setWindowTitle(title) - - self._colormap = None - self._data = None - self._dataInPlotMode = _DataInPlotMode.RANGE - - self._ignoreColormapChange = False - """Used as a semaphore to avoid editing the colormap object when we are - only attempt to display it. - Used instead of n connect and disconnect of the sigChanged. The - disconnection to sigChanged was also limiting when this colormapdialog - is used in the colormapaction and associated to the activeImageChanged. - (because the activeImageChanged is send when the colormap changed and - the self.setcolormap is a callback) - """ - - self._histogramData = None - self._minMaxWasEdited = False - self._initialRange = None - - self._dataRange = None - """If defined 3-tuple containing information from a data: - minimum, positive minimum, maximum""" - - self._colormapStoredState = None - - # Make the GUI - vLayout = qt.QVBoxLayout(self) - - formWidget = qt.QWidget(parent=self) - vLayout.addWidget(formWidget) - formLayout = qt.QFormLayout(formWidget) - formLayout.setContentsMargins(10, 10, 10, 10) - formLayout.setSpacing(0) - - # Colormap row - self._comboBoxColormap = _ColormapNameCombox(parent=formWidget) - self._comboBoxColormap.currentIndexChanged[int].connect(self._updateName) - formLayout.addRow('Colormap:', self._comboBoxColormap) - - # Normalization row - self._normButtonLinear = qt.QRadioButton('Linear') - self._normButtonLinear.setChecked(True) - self._normButtonLog = qt.QRadioButton('Log') - self._normButtonLog.toggled.connect(self._activeLogNorm) - - normButtonGroup = qt.QButtonGroup(self) - normButtonGroup.setExclusive(True) - normButtonGroup.addButton(self._normButtonLinear) - normButtonGroup.addButton(self._normButtonLog) - self._normButtonLinear.toggled[bool].connect(self._updateLinearNorm) - - normLayout = qt.QHBoxLayout() - normLayout.setContentsMargins(0, 0, 0, 0) - normLayout.setSpacing(10) - normLayout.addWidget(self._normButtonLinear) - normLayout.addWidget(self._normButtonLog) - - formLayout.addRow('Normalization:', normLayout) - - # Min row - self._minValue = _BoundaryWidget(parent=self, value=1.0) - self._minValue.textEdited.connect(self._minMaxTextEdited) - self._minValue.editingFinished.connect(self._minEditingFinished) - self._minValue.sigValueChanged.connect(self._updateMinMax) - formLayout.addRow('\tMin:', self._minValue) - - # Max row - self._maxValue = _BoundaryWidget(parent=self, value=10.0) - self._maxValue.textEdited.connect(self._minMaxTextEdited) - self._maxValue.sigValueChanged.connect(self._updateMinMax) - self._maxValue.editingFinished.connect(self._maxEditingFinished) - formLayout.addRow('\tMax:', self._maxValue) - - # Add plot for histogram - self._plotToolbar = qt.QToolBar(self) - self._plotToolbar.setFloatable(False) - self._plotToolbar.setMovable(False) - self._plotToolbar.setIconSize(qt.QSize(8, 8)) - self._plotToolbar.setStyleSheet("QToolBar { border: 0px }") - self._plotToolbar.setOrientation(qt.Qt.Vertical) - - group = qt.QActionGroup(self._plotToolbar) - group.setExclusive(True) - - action = qt.QAction("Nothing", self) - action.setToolTip("No range nor histogram are displayed. No extra computation have to be done.") - action.setIcon(icons.getQIcon('colormap-none')) - action.setCheckable(True) - action.setData(_DataInPlotMode.NONE) - action.setChecked(action.data() == self._dataInPlotMode) - self._plotToolbar.addAction(action) - group.addAction(action) - action = qt.QAction("Data range", self) - action.setToolTip("Display the data range within the colormap range. A fast data processing have to be done.") - action.setIcon(icons.getQIcon('colormap-range')) - action.setCheckable(True) - action.setData(_DataInPlotMode.RANGE) - action.setChecked(action.data() == self._dataInPlotMode) - self._plotToolbar.addAction(action) - group.addAction(action) - action = qt.QAction("Histogram", self) - action.setToolTip("Display the data histogram within the colormap range. A slow data processing have to be done. ") - action.setIcon(icons.getQIcon('colormap-histogram')) - action.setCheckable(True) - action.setData(_DataInPlotMode.HISTOGRAM) - action.setChecked(action.data() == self._dataInPlotMode) - self._plotToolbar.addAction(action) - group.addAction(action) - group.triggered.connect(self._displayDataInPlotModeChanged) - - self._plotBox = qt.QWidget(self) - self._plotInit() - - plotBoxLayout = qt.QHBoxLayout() - plotBoxLayout.setContentsMargins(0, 0, 0, 0) - plotBoxLayout.setSpacing(2) - plotBoxLayout.addWidget(self._plotToolbar) - plotBoxLayout.addWidget(self._plot) - plotBoxLayout.setSizeConstraint(qt.QLayout.SetMinimumSize) - self._plotBox.setLayout(plotBoxLayout) - vLayout.addWidget(self._plotBox) - - # define modal buttons - types = qt.QDialogButtonBox.Ok | qt.QDialogButtonBox.Cancel - self._buttonsModal = qt.QDialogButtonBox(parent=self) - self._buttonsModal.setStandardButtons(types) - self.layout().addWidget(self._buttonsModal) - self._buttonsModal.accepted.connect(self.accept) - self._buttonsModal.rejected.connect(self.reject) - - # define non modal buttons - types = qt.QDialogButtonBox.Close | qt.QDialogButtonBox.Reset - self._buttonsNonModal = qt.QDialogButtonBox(parent=self) - self._buttonsNonModal.setStandardButtons(types) - self.layout().addWidget(self._buttonsNonModal) - self._buttonsNonModal.button(qt.QDialogButtonBox.Close).clicked.connect(self.accept) - self._buttonsNonModal.button(qt.QDialogButtonBox.Reset).clicked.connect(self.resetColormap) - - # Set the colormap to default values - self.setColormap(Colormap(name='gray', normalization='linear', - vmin=None, vmax=None)) - - self.setModal(self.isModal()) - - vLayout.setSizeConstraint(qt.QLayout.SetMinimumSize) - self.setFixedSize(self.sizeHint()) - self._applyColormap() - - def showEvent(self, event): - self.visibleChanged.emit(True) - super(ColormapDialog, self).showEvent(event) - - def closeEvent(self, event): - if not self.isModal(): - self.accept() - super(ColormapDialog, self).closeEvent(event) - - def hideEvent(self, event): - self.visibleChanged.emit(False) - super(ColormapDialog, self).hideEvent(event) - - def close(self): - self.accept() - qt.QDialog.close(self) - - def setModal(self, modal): - assert type(modal) is bool - self._buttonsNonModal.setVisible(not modal) - self._buttonsModal.setVisible(modal) - qt.QDialog.setModal(self, modal) - - def exec_(self): - wasModal = self.isModal() - self.setModal(True) - result = super(ColormapDialog, self).exec_() - self.setModal(wasModal) - return result - - def _plotInit(self): - """Init the plot to display the range and the values""" - self._plot = PlotWidget() - self._plot.setDataMargins(yMinMargin=0.125, yMaxMargin=0.125) - self._plot.getXAxis().setLabel("Data Values") - self._plot.getYAxis().setLabel("") - self._plot.setInteractiveMode('select', zoomOnWheel=False) - self._plot.setActiveCurveHandling(False) - self._plot.setMinimumSize(qt.QSize(250, 200)) - self._plot.sigPlotSignal.connect(self._plotSlot) - - self._plotUpdate() - - def sizeHint(self): - return self.layout().minimumSize() - - def _plotUpdate(self, updateMarkers=True): - """Update the plot content - - :param bool updateMarkers: True to update markers, False otherwith - """ - colormap = self.getColormap() - if colormap is None: - if self._plotBox.isVisibleTo(self): - self._plotBox.setVisible(False) - self.setFixedSize(self.sizeHint()) - return - - if not self._plotBox.isVisibleTo(self): - self._plotBox.setVisible(True) - self.setFixedSize(self.sizeHint()) - - minData, maxData = self._minValue.getFiniteValue(), self._maxValue.getFiniteValue() - if minData > maxData: - # avoid a full collapse - minData, maxData = maxData, minData - minimum = minData - maximum = maxData - - if self._dataRange is not None: - minRange = self._dataRange[0] - maxRange = self._dataRange[2] - minimum = min(minimum, minRange) - maximum = max(maximum, maxRange) - - if self._histogramData is not None: - minHisto = self._histogramData[1][0] - maxHisto = self._histogramData[1][-1] - minimum = min(minimum, minHisto) - maximum = max(maximum, maxHisto) - - marge = abs(maximum - minimum) / 6.0 - if marge < 0.0001: - # Smaller that the QLineEdit precision - marge = 0.0001 - - minView, maxView = minimum - marge, maximum + marge - - if updateMarkers: - # Save the state in we are not moving the markers - self._initialRange = minView, maxView - elif self._initialRange is not None: - minView = min(minView, self._initialRange[0]) - maxView = max(maxView, self._initialRange[1]) - - x = [minView, minData, maxData, maxView] - y = [0, 0, 1, 1] - - self._plot.addCurve(x, y, - legend="ConstrainedCurve", - color='black', - symbol='o', - linestyle='-', - resetzoom=False) - - if updateMarkers: - minDraggable = (self._colormap().isEditable() and - not self._minValue.isAutoChecked()) - self._plot.addXMarker( - self._minValue.getFiniteValue(), - legend='Min', - text='Min', - draggable=minDraggable, - color='blue', - constraint=self._plotMinMarkerConstraint) - - maxDraggable = (self._colormap().isEditable() and - not self._maxValue.isAutoChecked()) - self._plot.addXMarker( - self._maxValue.getFiniteValue(), - legend='Max', - text='Max', - draggable=maxDraggable, - color='blue', - constraint=self._plotMaxMarkerConstraint) - - self._plot.resetZoom() - - def _plotMinMarkerConstraint(self, x, y): - """Constraint of the min marker""" - return min(x, self._maxValue.getFiniteValue()), y - - def _plotMaxMarkerConstraint(self, x, y): - """Constraint of the max marker""" - return max(x, self._minValue.getFiniteValue()), y - - def _plotSlot(self, event): - """Handle events from the plot""" - if event['event'] in ('markerMoving', 'markerMoved'): - value = float(str(event['xdata'])) - if event['label'] == 'Min': - self._minValue.setValue(value) - elif event['label'] == 'Max': - self._maxValue.setValue(value) - - # This will recreate the markers while interacting... - # It might break if marker interaction is changed - if event['event'] == 'markerMoved': - self._initialRange = None - self._updateMinMax() - else: - self._plotUpdate(updateMarkers=False) - - @staticmethod - def computeDataRange(data): - """Compute the data range as used by :meth:`setDataRange`. - - :param data: The data to process - :rtype: Tuple(float, float, float) - """ - if data is None or len(data) == 0: - return None, None, None - - dataRange = min_max(data, min_positive=True, finite=True) - if dataRange.minimum is None: - # Only non-finite data - dataRange = None - - if dataRange is not None: - min_positive = dataRange.min_positive - if min_positive is None: - min_positive = float('nan') - dataRange = dataRange.minimum, min_positive, dataRange.maximum - - if dataRange is None or len(dataRange) != 3: - qt.QMessageBox.warning( - None, "No Data", - "Image data does not contain any real value") - dataRange = 1., 1., 10. - - return dataRange - - @staticmethod - def computeHistogram(data): - """Compute the data histogram as used by :meth:`setHistogram`. - - :param data: The data to process - :rtype: Tuple(List(float),List(float) - """ - _data = data - if _data.ndim == 3: # RGB(A) images - _logger.info('Converting current image from RGB(A) to grayscale\ - in order to compute the intensity distribution') - _data = (_data[:, :, 0] * 0.299 + - _data[:, :, 1] * 0.587 + - _data[:, :, 2] * 0.114) - - if len(_data) == 0: - return None, None - - xmin, xmax = min_max(_data, min_positive=False, finite=True) - nbins = min(256, int(numpy.sqrt(_data.size))) - data_range = xmin, xmax - - # bad hack: get 256 bins in the case we have a B&W - if numpy.issubdtype(_data.dtype, numpy.integer): - if nbins > xmax - xmin: - nbins = xmax - xmin - - nbins = max(2, nbins) - _data = _data.ravel().astype(numpy.float32) - - histogram = Histogramnd(_data, n_bins=nbins, histo_range=data_range) - return histogram.histo, histogram.edges[0] - - def _getData(self): - if self._data is None: - return None - return self._data() - - def setData(self, data): - """Store the data as a weakref. - - According to the state of the dialog, the data will be used to display - the data range or the histogram of the data using :meth:`setDataRange` - and :meth:`setHistogram` - """ - oldData = self._getData() - if oldData is data: - return - - if data is None: - self.setDataRange() - self.setHistogram() - self._data = None - return - - self._data = weakref.ref(data, self._dataAboutToFinalize) - - self._updateDataInPlot() - - def _setDataInPlotMode(self, mode): - if self._dataInPlotMode == mode: - return - self._dataInPlotMode = mode - self._updateDataInPlot() - - def _displayDataInPlotModeChanged(self, action): - mode = action.data() - self._setDataInPlotMode(mode) - - def _updateDataInPlot(self): - data = self._getData() - if data is None: - return - - mode = self._dataInPlotMode - - if mode == _DataInPlotMode.NONE: - self.setHistogram() - self.setDataRange() - elif mode == _DataInPlotMode.RANGE: - result = self.computeDataRange(data) - self.setHistogram() - self.setDataRange(*result) - elif mode == _DataInPlotMode.HISTOGRAM: - # The histogram should be done in a worker thread - result = self.computeHistogram(data) - self.setHistogram(*result) - self.setDataRange() - - def _colormapAboutToFinalize(self, weakrefColormap): - """Callback when the data weakref is about to be finalized.""" - if self._colormap is weakrefColormap: - self.setColormap(None) - - def _dataAboutToFinalize(self, weakrefData): - """Callback when the data weakref is about to be finalized.""" - if self._data is weakrefData: - self.setData(None) - - def getHistogram(self): - """Returns the counts and bin edges of the displayed histogram. - - :return: (hist, bin_edges) - :rtype: 2-tuple of numpy arrays""" - if self._histogramData is None: - return None - else: - bins, counts = self._histogramData - return numpy.array(bins, copy=True), numpy.array(counts, copy=True) - - def setHistogram(self, hist=None, bin_edges=None): - """Set the histogram to display. - - This update the data range with the bounds of the bins. - - :param hist: array-like of counts or None to hide histogram - :param bin_edges: array-like of bins edges or None to hide histogram - """ - if hist is None or bin_edges is None: - self._histogramData = None - self._plot.remove(legend='Histogram', kind='histogram') - else: - hist = numpy.array(hist, copy=True) - bin_edges = numpy.array(bin_edges, copy=True) - self._histogramData = hist, bin_edges - norm_hist = hist / max(hist) - self._plot.addHistogram(norm_hist, - bin_edges, - legend="Histogram", - color='gray', - align='center', - fill=True) - self._updateMinMaxData() - - def getColormap(self): - """Return the colormap description as a :class:`.Colormap`. - - """ - if self._colormap is None: - return None - return self._colormap() - - def resetColormap(self): - """ - Reset the colormap state before modification. - - ..note :: the colormap reference state is the state when set or the - state when validated - """ - colormap = self.getColormap() - if colormap is not None and self._colormapStoredState is not None: - if self._colormap()._toDict() != self._colormapStoredState: - self._ignoreColormapChange = True - colormap._setFromDict(self._colormapStoredState) - self._ignoreColormapChange = False - self._applyColormap() - - def setDataRange(self, minimum=None, positiveMin=None, maximum=None): - """Set the range of data to use for the range of the histogram area. - - :param float minimum: The minimum of the data - :param float positiveMin: The positive minimum of the data - :param float maximum: The maximum of the data - """ - if minimum is None or positiveMin is None or maximum is None: - self._dataRange = None - self._plot.remove(legend='Range', kind='histogram') - else: - hist = numpy.array([1]) - bin_edges = numpy.array([minimum, maximum]) - self._plot.addHistogram(hist, - bin_edges, - legend="Range", - color='gray', - align='center', - fill=True) - self._dataRange = minimum, positiveMin, maximum - self._updateMinMaxData() - - def _updateMinMaxData(self): - """Update the min and max of the data according to the data range and - the histogram preset.""" - colormap = self.getColormap() - - minimum = float("+inf") - maximum = float("-inf") - - if colormap is not None and colormap.getNormalization() == colormap.LOGARITHM: - # find a range in the positive part of the data - if self._dataRange is not None: - minimum = min(minimum, self._dataRange[1]) - maximum = max(maximum, self._dataRange[2]) - if self._histogramData is not None: - positives = list(filter(lambda x: x > 0, self._histogramData[1])) - if len(positives) > 0: - minimum = min(minimum, positives[0]) - maximum = max(maximum, positives[-1]) - else: - if self._dataRange is not None: - minimum = min(minimum, self._dataRange[0]) - maximum = max(maximum, self._dataRange[2]) - if self._histogramData is not None: - minimum = min(minimum, self._histogramData[1][0]) - maximum = max(maximum, self._histogramData[1][-1]) - - if not numpy.isfinite(minimum): - minimum = None - if not numpy.isfinite(maximum): - maximum = None - - self._minValue.setDataValue(minimum) - self._maxValue.setDataValue(maximum) - self._plotUpdate() - - def accept(self): - self.storeCurrentState() - qt.QDialog.accept(self) - - def storeCurrentState(self): - """ - save the current value sof the colormap if the user want to undo is - modifications - """ - colormap = self.getColormap() - if colormap is not None: - self._colormapStoredState = colormap._toDict() - else: - self._colormapStoredState = None - - def reject(self): - self.resetColormap() - qt.QDialog.reject(self) - - def setColormap(self, colormap): - """Set the colormap description - - :param :class:`Colormap` colormap: the colormap to edit - """ - assert colormap is None or isinstance(colormap, Colormap) - if self._ignoreColormapChange is True: - return - - oldColormap = self.getColormap() - if oldColormap is colormap: - return - if oldColormap is not None: - oldColormap.sigChanged.disconnect(self._applyColormap) - - if colormap is not None: - colormap.sigChanged.connect(self._applyColormap) - colormap = weakref.ref(colormap, self._colormapAboutToFinalize) - - self._colormap = colormap - self.storeCurrentState() - self._updateResetButton() - self._applyColormap() - - def _updateResetButton(self): - resetButton = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset) - rStateEnabled = False - colormap = self.getColormap() - if colormap is not None and colormap.isEditable(): - # can reset only in the case the colormap changed - rStateEnabled = colormap._toDict() != self._colormapStoredState - resetButton.setEnabled(rStateEnabled) - - def _applyColormap(self): - self._updateResetButton() - if self._ignoreColormapChange is True: - return - - colormap = self.getColormap() - if colormap is None: - self._comboBoxColormap.setEnabled(False) - self._normButtonLinear.setEnabled(False) - self._normButtonLog.setEnabled(False) - self._minValue.setEnabled(False) - self._maxValue.setEnabled(False) - else: - self._ignoreColormapChange = True - - if colormap.getName() is not None: - name = colormap.getName() - self._comboBoxColormap.setCurrentName(name) - self._comboBoxColormap.setEnabled(self._colormap().isEditable()) - - assert colormap.getNormalization() in Colormap.NORMALIZATIONS - self._normButtonLinear.setChecked( - colormap.getNormalization() == Colormap.LINEAR) - self._normButtonLog.setChecked( - colormap.getNormalization() == Colormap.LOGARITHM) - vmin = colormap.getVMin() - vmax = colormap.getVMax() - dataRange = colormap.getColormapRange() - self._normButtonLinear.setEnabled(self._colormap().isEditable()) - self._normButtonLog.setEnabled(self._colormap().isEditable()) - self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None) - self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None) - self._minValue.setEnabled(self._colormap().isEditable()) - self._maxValue.setEnabled(self._colormap().isEditable()) - self._ignoreColormapChange = False - - self._plotUpdate() - - def _updateMinMax(self): - if self._ignoreColormapChange is True: - return - - vmin = self._minValue.getFiniteValue() - vmax = self._maxValue.getFiniteValue() - if vmax is not None and vmin is not None and vmax < vmin: - # If only one autoscale is checked constraints are too strong - # We have to edit a user value anyway it is not requested - # TODO: It would be better IMO to disable the auto checkbox before - # this case occur (valls) - cmin = self._minValue.isAutoChecked() - cmax = self._maxValue.isAutoChecked() - if cmin is False: - self._minValue.setFiniteValue(vmax) - if cmax is False: - self._maxValue.setFiniteValue(vmin) - - vmin = self._minValue.getValue() - vmax = self._maxValue.getValue() - self._ignoreColormapChange = True - colormap = self._colormap() - if colormap is not None: - colormap.setVRange(vmin, vmax) - self._ignoreColormapChange = False - self._plotUpdate() - self._updateResetButton() - - def _updateName(self): - if self._ignoreColormapChange is True: - return - - if self._colormap(): - self._ignoreColormapChange = True - self._colormap().setName( - self._comboBoxColormap.getCurrentName()) - self._ignoreColormapChange = False - - def _updateLinearNorm(self, isNormLinear): - if self._ignoreColormapChange is True: - return - - if self._colormap(): - self._ignoreColormapChange = True - norm = Colormap.LINEAR if isNormLinear else Colormap.LOGARITHM - self._colormap().setNormalization(norm) - self._ignoreColormapChange = False - - def _minMaxTextEdited(self, text): - """Handle _minValue and _maxValue textEdited signal""" - self._minMaxWasEdited = True - - def _minEditingFinished(self): - """Handle _minValue editingFinished signal - - Together with :meth:`_minMaxTextEdited`, this avoids to notify - colormap change when the min and max value where not edited. - """ - if self._minMaxWasEdited: - self._minMaxWasEdited = False - - # Fix start value - if (self._maxValue.getValue() is not None and - self._minValue.getValue() > self._maxValue.getValue()): - self._minValue.setValue(self._maxValue.getValue()) - self._updateMinMax() - - def _maxEditingFinished(self): - """Handle _maxValue editingFinished signal - - Together with :meth:`_minMaxTextEdited`, this avoids to notify - colormap change when the min and max value where not edited. - """ - if self._minMaxWasEdited: - self._minMaxWasEdited = False - - # Fix end value - if (self._minValue.getValue() is not None and - self._minValue.getValue() > self._maxValue.getValue()): - self._maxValue.setValue(self._minValue.getValue()) - self._updateMinMax() +__date__ = "24/04/2018" - def keyPressEvent(self, event): - """Override key handling. +import silx.utils.deprecation - It disables leaving the dialog when editing a text field. - """ - if event.key() == qt.Qt.Key_Enter and (self._minValue.hasFocus() or - self._maxValue.hasFocus()): - # Bypass QDialog keyPressEvent - # To avoid leaving the dialog when pressing enter on a text field - super(qt.QDialog, self).keyPressEvent(event) - else: - # Use QDialog keyPressEvent - super(ColormapDialog, self).keyPressEvent(event) +silx.utils.deprecation.deprecated_warning("Module", + name="silx.gui.plot.ColormapDialog", + reason="moved", + replacement="silx.gui.dialog.ColormapDialog", + since_version="0.8.0", + only_once=True, + skip_backtrace_count=1) - def _activeLogNorm(self, isLog): - if self._ignoreColormapChange is True: - return - if self._colormap(): - self._ignoreColormapChange = True - norm = Colormap.LOGARITHM if isLog is True else Colormap.LINEAR - self._colormap().setNormalization(norm) - self._ignoreColormapChange = False - self._updateMinMaxData() +from ..dialog.ColormapDialog import * # noqa diff --git a/silx/gui/plot/Colors.py b/silx/gui/plot/Colors.py index 2d44d4d..277e104 100644 --- a/silx/gui/plot/Colors.py +++ b/silx/gui/plot/Colors.py @@ -28,120 +28,22 @@ from __future__ import absolute_import __authors__ = ["V.A. Sole", "T. Vincent"] __license__ = "MIT" -__date__ = "15/05/2017" +__date__ = "14/06/2018" +import silx.utils.deprecation -from silx.utils.deprecation import deprecated -import logging -import numpy +silx.utils.deprecation.deprecated_warning("Module", + name="silx.gui.plot.Colors", + reason="moved", + replacement="silx.gui.colors", + since_version="0.8.0", + only_once=True, + skip_backtrace_count=1) -from .Colormap import Colormap +from ..colors import * # noqa -_logger = logging.getLogger(__name__) - - -COLORDICT = {} -"""Dictionary of common colors.""" - -COLORDICT['b'] = COLORDICT['blue'] = '#0000ff' -COLORDICT['r'] = COLORDICT['red'] = '#ff0000' -COLORDICT['g'] = COLORDICT['green'] = '#00ff00' -COLORDICT['k'] = COLORDICT['black'] = '#000000' -COLORDICT['w'] = COLORDICT['white'] = '#ffffff' -COLORDICT['pink'] = '#ff66ff' -COLORDICT['brown'] = '#a52a2a' -COLORDICT['orange'] = '#ff9900' -COLORDICT['violet'] = '#6600ff' -COLORDICT['gray'] = COLORDICT['grey'] = '#a0a0a4' -# COLORDICT['darkGray'] = COLORDICT['darkGrey'] = '#808080' -# COLORDICT['lightGray'] = COLORDICT['lightGrey'] = '#c0c0c0' -COLORDICT['y'] = COLORDICT['yellow'] = '#ffff00' -COLORDICT['m'] = COLORDICT['magenta'] = '#ff00ff' -COLORDICT['c'] = COLORDICT['cyan'] = '#00ffff' -COLORDICT['darkBlue'] = '#000080' -COLORDICT['darkRed'] = '#800000' -COLORDICT['darkGreen'] = '#008000' -COLORDICT['darkBrown'] = '#660000' -COLORDICT['darkCyan'] = '#008080' -COLORDICT['darkYellow'] = '#808000' -COLORDICT['darkMagenta'] = '#800080' - - -def rgba(color, colorDict=None): - """Convert color code '#RRGGBB' and '#RRGGBBAA' to (R, G, B, A) - - It also convert RGB(A) values from uint8 to float in [0, 1] and - accept a QColor as color argument. - - :param str color: The color to convert - :param dict colorDict: A dictionary of color name conversion to color code - :returns: RGBA colors as floats in [0., 1.] - :rtype: tuple - """ - if colorDict is None: - colorDict = COLORDICT - - if hasattr(color, 'getRgbF'): # QColor support - color = color.getRgbF() - - values = numpy.asarray(color).ravel() - - if values.dtype.kind in 'iuf': # integer or float - # Color is an array - assert len(values) in (3, 4) - - # Convert from integers in [0, 255] to float in [0, 1] - if values.dtype.kind in 'iu': - values = values / 255. - - # Clip to [0, 1] - values[values < 0.] = 0. - values[values > 1.] = 1. - - if len(values) == 3: - return values[0], values[1], values[2], 1. - else: - return tuple(values) - - # We assume color is a string - if not color.startswith('#'): - color = colorDict[color] - - assert len(color) in (7, 9) and color[0] == '#' - r = int(color[1:3], 16) / 255. - g = int(color[3:5], 16) / 255. - b = int(color[5:7], 16) / 255. - a = int(color[7:9], 16) / 255. if len(color) == 9 else 1. - return r, g, b, a - - -_COLORMAP_CURSOR_COLORS = { - 'gray': 'pink', - 'reversed gray': 'pink', - 'temperature': 'pink', - 'red': 'green', - 'green': 'pink', - 'blue': 'yellow', - 'jet': 'pink', - 'viridis': 'pink', - 'magma': 'green', - 'inferno': 'green', - 'plasma': 'green', -} - - -def cursorColorForColormap(colormapName): - """Get a color suitable for overlay over a colormap. - - :param str colormapName: The name of the colormap. - :return: Name of the color. - :rtype: str - """ - return _COLORMAP_CURSOR_COLORS.get(colormapName, 'black') - - -@deprecated(replacement='silx.gui.plot.Colormap.applyColormap') +@silx.utils.deprecation.deprecated(replacement='silx.gui.colors.Colormap.applyColormap') def applyColormapToData(data, name='gray', normalization='linear', @@ -178,7 +80,7 @@ def applyColormapToData(data, return colormap.applyToData(data) -@deprecated(replacement='silx.gui.plot.Colormap.getSupportedColormaps') +@silx.utils.deprecation.deprecated(replacement='silx.gui.colors.Colormap.getSupportedColormaps') def getSupportedColormaps(): """Get the supported colormap names as a tuple of str. diff --git a/silx/gui/plot/ComplexImageView.py b/silx/gui/plot/ComplexImageView.py index ebff175..bbcb0a5 100644 --- a/silx/gui/plot/ComplexImageView.py +++ b/silx/gui/plot/ComplexImageView.py @@ -32,7 +32,7 @@ from __future__ import absolute_import __authors__ = ["Vincent Favre-Nicolin", "T. Vincent"] __license__ = "MIT" -__date__ = "19/01/2018" +__date__ = "24/04/2018" import logging @@ -410,7 +410,7 @@ class ComplexImageView(qt.QWidget): WARNING: This colormap is not used when displaying both amplitude and phase. - :param ~silx.gui.plot.Colormap.Colormap colormap: The colormap + :param ~silx.gui.colors.Colormap colormap: The colormap :param Mode mode: If specified, set the colormap of this specific mode """ self._plotImage.setColormap(colormap, mode) @@ -419,7 +419,7 @@ class ComplexImageView(qt.QWidget): """Returns the colormap used to display the data. :param Mode mode: If specified, set the colormap of this specific mode - :rtype: ~silx.gui.plot.Colormap.Colormap + :rtype: ~silx.gui.colors.Colormap """ return self._plotImage.getColormap(mode=mode) diff --git a/silx/gui/plot/CurvesROIWidget.py b/silx/gui/plot/CurvesROIWidget.py index ccb6866..81e684e 100644 --- a/silx/gui/plot/CurvesROIWidget.py +++ b/silx/gui/plot/CurvesROIWidget.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 @@ -33,13 +33,11 @@ ROI are defined by : This can be used to apply or not some ROI to a curve and do some post processing. - The x coordinate of the left limit (`from` column) - The x coordinate of the right limit (`to` column) -- Raw counts: integral of the curve between the - min ROI point and the max ROI point to the y = 0 line +- Raw counts: Sum of the curve's values in the defined Region Of Intereset. .. image:: img/rawCounts.png -- Net counts: the integral of the curve between the - min ROI point and the max ROI point to [ROI min point, ROI max point] segment +- Net counts: Raw counts minus background .. image:: img/netCounts.png """ @@ -53,6 +51,7 @@ from collections import OrderedDict import logging import os import sys +import weakref import numpy @@ -93,7 +92,8 @@ class CurvesROIWidget(qt.QWidget): if name is not None: self.setWindowTitle(name) assert plot is not None - self.plot = plot + self._plotRef = weakref.ref(plot) + layout = qt.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) @@ -162,6 +162,13 @@ class CurvesROIWidget(qt.QWidget): self._isConnected = False # True if connected to plot signals self._isInit = False + def getPlotWidget(self): + """Returns the associated PlotWidget or None + + :rtype: Union[~silx.gui.plot.PlotWidget,None] + """ + return None if self._plotRef is None else self._plotRef() + def showEvent(self, event): self._visibilityChangedHandler(visible=True) qt.QWidget.showEvent(self, event) @@ -400,14 +407,18 @@ class CurvesROIWidget(qt.QWidget): def _roiSignal(self, ddict): """Handle ROI widget signal""" _logger.debug("CurvesROIWidget._roiSignal %s", str(ddict)) + plot = self.getPlotWidget() + if plot is None: + return + if ddict['event'] == "AddROI": - xmin, xmax = self.plot.getXAxis().getLimits() + xmin, xmax = plot.getXAxis().getLimits() fromdata = xmin + 0.25 * (xmax - xmin) todata = xmin + 0.75 * (xmax - xmin) - self.plot.remove('ROI min', kind='marker') - self.plot.remove('ROI max', kind='marker') + plot.remove('ROI min', kind='marker') + plot.remove('ROI max', kind='marker') if self._middleROIMarkerFlag: - self.plot.remove('ROI middle', kind='marker') + plot.remove('ROI middle', kind='marker') roiList, roiDict = self.roiTable.getROIListAndDict() nrois = len(roiList) if nrois == 0: @@ -416,6 +427,7 @@ class CurvesROIWidget(qt.QWidget): draggable = False color = 'black' else: + # find the next index free for newroi. for i in range(nrois): i += 1 newroi = "newroi %d" % i @@ -423,29 +435,29 @@ class CurvesROIWidget(qt.QWidget): break color = 'blue' draggable = True - self.plot.addXMarker(fromdata, - legend='ROI min', - text='ROI min', - color=color, - draggable=draggable) - self.plot.addXMarker(todata, - legend='ROI max', - text='ROI max', - color=color, - draggable=draggable) + plot.addXMarker(fromdata, + legend='ROI min', + text='ROI min', + color=color, + draggable=draggable) + plot.addXMarker(todata, + legend='ROI max', + text='ROI max', + color=color, + draggable=draggable) if draggable and self._middleROIMarkerFlag: pos = 0.5 * (fromdata + todata) - self.plot.addXMarker(pos, - legend='ROI middle', - text="", - color='yellow', - draggable=draggable) + plot.addXMarker(pos, + legend='ROI middle', + text="", + color='yellow', + draggable=draggable) roiList.append(newroi) roiDict[newroi] = {} if newroi == "ICR": roiDict[newroi]['type'] = "Default" else: - roiDict[newroi]['type'] = self.plot.getXAxis().getLabel() + roiDict[newroi]['type'] = plot.getXAxis().getLabel() roiDict[newroi]['from'] = fromdata roiDict[newroi]['to'] = todata self.roiTable.fillFromROIDict(roilist=roiList, @@ -454,10 +466,10 @@ class CurvesROIWidget(qt.QWidget): self.currentROI = newroi self.calculateRois() elif ddict['event'] in ['DelROI', "ResetROI"]: - self.plot.remove('ROI min', kind='marker') - self.plot.remove('ROI max', kind='marker') + plot.remove('ROI min', kind='marker') + plot.remove('ROI max', kind='marker') if self._middleROIMarkerFlag: - self.plot.remove('ROI middle', kind='marker') + plot.remove('ROI middle', kind='marker') roiList, roiDict = self.roiTable.getROIListAndDict() roiDictKeys = list(roiDict.keys()) if len(roiDictKeys): @@ -480,37 +492,37 @@ class CurvesROIWidget(qt.QWidget): self.roilist, self.roidict = self.roiTable.getROIListAndDict() fromdata = ddict['roi']['from'] todata = ddict['roi']['to'] - self.plot.remove('ROI min', kind='marker') - self.plot.remove('ROI max', kind='marker') + plot.remove('ROI min', kind='marker') + plot.remove('ROI max', kind='marker') if ddict['key'] == 'ICR': draggable = False color = 'black' else: draggable = True color = 'blue' - self.plot.addXMarker(fromdata, - legend='ROI min', - text='ROI min', - color=color, - draggable=draggable) - self.plot.addXMarker(todata, - legend='ROI max', - text='ROI max', - color=color, - draggable=draggable) + plot.addXMarker(fromdata, + legend='ROI min', + text='ROI min', + color=color, + draggable=draggable) + plot.addXMarker(todata, + legend='ROI max', + text='ROI max', + color=color, + draggable=draggable) if draggable and self._middleROIMarkerFlag: pos = 0.5 * (fromdata + todata) - self.plot.addXMarker(pos, - legend='ROI middle', - text="", - color='yellow', - draggable=True) + plot.addXMarker(pos, + legend='ROI middle', + text="", + color='yellow', + draggable=True) self.currentROI = ddict['key'] if ddict['colheader'] in ['From', 'To']: dict0 = {} dict0['event'] = "SetActiveCurveEvent" - dict0['legend'] = self.plot.getActiveCurve(just_legend=1) - self.plot.setActiveCurve(dict0['legend']) + dict0['legend'] = plot.getActiveCurve(just_legend=1) + plot.setActiveCurve(dict0['legend']) elif ddict['colheader'] == 'Raw Counts': pass elif ddict['colheader'] == 'Net Counts': @@ -523,7 +535,8 @@ class CurvesROIWidget(qt.QWidget): def _getAllLimits(self): """Retrieve the limits based on the curves.""" - curves = self.plot.getAllCurves() + plot = self.getPlotWidget() + curves = () if plot is None else plot.getAllCurves() if not curves: return 1.0, 1.0, 100., 100. @@ -562,7 +575,12 @@ class CurvesROIWidget(qt.QWidget): if roiList is None or roiDict is None: roiList, roiDict = self.roiTable.getROIListAndDict() - activeCurve = self.plot.getActiveCurve(just_legend=False) + plot = self.getPlotWidget() + if plot is None: + activeCurve = None + else: + activeCurve = plot.getActiveCurve(just_legend=False) + if activeCurve is None: xproc = None yproc = None @@ -640,6 +658,11 @@ class CurvesROIWidget(qt.QWidget): return if self.currentROI not in roiDict: return + + plot = self.getPlotWidget() + if plot is None: + return + x = ddict['x'] if label == 'ROI min': @@ -647,36 +670,36 @@ class CurvesROIWidget(qt.QWidget): if self._middleROIMarkerFlag: pos = 0.5 * (roiDict[self.currentROI]['to'] + roiDict[self.currentROI]['from']) - self.plot.addXMarker(pos, - legend='ROI middle', - text='', - color='yellow', - draggable=True) + plot.addXMarker(pos, + legend='ROI middle', + text='', + color='yellow', + draggable=True) elif label == 'ROI max': roiDict[self.currentROI]['to'] = x if self._middleROIMarkerFlag: pos = 0.5 * (roiDict[self.currentROI]['to'] + roiDict[self.currentROI]['from']) - self.plot.addXMarker(pos, - legend='ROI middle', - text='', - color='yellow', - draggable=True) + plot.addXMarker(pos, + legend='ROI middle', + text='', + color='yellow', + draggable=True) elif label == 'ROI middle': delta = x - 0.5 * (roiDict[self.currentROI]['from'] + roiDict[self.currentROI]['to']) roiDict[self.currentROI]['from'] += delta roiDict[self.currentROI]['to'] += delta - self.plot.addXMarker(roiDict[self.currentROI]['from'], - legend='ROI min', - text='ROI min', - color='blue', - draggable=True) - self.plot.addXMarker(roiDict[self.currentROI]['to'], - legend='ROI max', - text='ROI max', - color='blue', - draggable=True) + plot.addXMarker(roiDict[self.currentROI]['from'], + legend='ROI min', + text='ROI min', + color='blue', + draggable=True) + plot.addXMarker(roiDict[self.currentROI]['to'], + legend='ROI max', + text='ROI max', + color='blue', + draggable=True) else: return self.calculateRois(roiList, roiDict) @@ -687,32 +710,39 @@ class CurvesROIWidget(qt.QWidget): It is connected to plot signals only when visible. """ + plot = self.getPlotWidget() + if visible: if not self._isInit: # Deferred ROI widget init finalization - self._isInit = True - self.sigROIWidgetSignal.connect(self._roiSignal) - # initialize with the ICR - self._roiSignal({'event': "AddROI"}) - - if not self._isConnected: - self.plot.sigPlotSignal.connect(self._handleROIMarkerEvent) - self.plot.sigActiveCurveChanged.connect( + self._finalizeInit() + + if not self._isConnected and plot is not None: + plot.sigPlotSignal.connect(self._handleROIMarkerEvent) + plot.sigActiveCurveChanged.connect( self._activeCurveChanged) self._isConnected = True self.calculateRois() else: if self._isConnected: - self.plot.sigPlotSignal.disconnect(self._handleROIMarkerEvent) - self.plot.sigActiveCurveChanged.disconnect( - self._activeCurveChanged) + if plot is not None: + plot.sigPlotSignal.disconnect(self._handleROIMarkerEvent) + plot.sigActiveCurveChanged.disconnect( + self._activeCurveChanged) self._isConnected = False def _activeCurveChanged(self, *args): """Recompute ROIs when active curve changed.""" self.calculateRois() + def _finalizeInit(self): + self._isInit = True + self.sigROIWidgetSignal.connect(self._roiSignal) + # initialize with the ICR if no ROi existing yet + if len(self.getRois()) is 0: + self._roiSignal({'event': "AddROI"}) + class ROITable(qt.QTableWidget): """Table widget displaying ROI information. @@ -977,9 +1007,6 @@ class CurvesROIDockWidget(qt.QDockWidget): def __init__(self, parent=None, plot=None, name=None): super(CurvesROIDockWidget, self).__init__(name, parent) - assert plot is not None - self.plot = plot - self.roiWidget = CurvesROIWidget(self, name, plot=plot) """Main widget of type :class:`CurvesROIWidget`""" diff --git a/silx/gui/plot/ImageView.py b/silx/gui/plot/ImageView.py index 46e56e6..c28ffca 100644 --- a/silx/gui/plot/ImageView.py +++ b/silx/gui/plot/ImageView.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 @@ -42,18 +42,19 @@ from __future__ import division __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "17/08/2017" +__date__ = "26/04/2018" import logging import numpy +import silx from .. import qt from . import items, PlotWindow, PlotWidget, actions -from .Colormap import Colormap -from .Colors import cursorColorForColormap -from .PlotTools import LimitsToolBar +from ..colors import Colormap +from ..colors import cursorColorForColormap +from .tools import LimitsToolBar from .Profile import ProfileToolBar @@ -296,6 +297,9 @@ class ImageView(PlotWindow): if parent is None: self.setWindowTitle('ImageView') + if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward': + self.getYAxis().setInverted(True) + self._initWidgets(backend) self.profile = ProfileToolBar(plot=self) @@ -356,7 +360,7 @@ class ImageView(PlotWindow): layout.setSpacing(0) layout.setContentsMargins(0, 0, 0, 0) - centralWidget = qt.QWidget() + centralWidget = qt.QWidget(self) centralWidget.setLayout(layout) self.setCentralWidget(centralWidget) @@ -773,7 +777,7 @@ class ImageView(PlotWindow): legend=self._imageLegend, origin=origin, scale=scale, colormap=self.getColormap(), - replace=False, resetzoom=False) + resetzoom=False) self.setActiveImage(self._imageLegend) self._updateHistograms() @@ -810,17 +814,17 @@ class ImageViewMainWindow(ImageView): self.statusBar() menu = self.menuBar().addMenu('File') - menu.addAction(self.saveAction) - menu.addAction(self.printAction) + menu.addAction(self.getOutputToolBar().getSaveAction()) + menu.addAction(self.getOutputToolBar().getPrintAction()) menu.addSeparator() action = menu.addAction('Quit') action.triggered[bool].connect(qt.QApplication.instance().quit) menu = self.menuBar().addMenu('Edit') - menu.addAction(self.copyAction) + menu.addAction(self.getOutputToolBar().getCopyAction()) menu.addSeparator() - menu.addAction(self.resetZoomAction) - menu.addAction(self.colormapAction) + menu.addAction(self.getResetZoomAction()) + menu.addAction(self.getColormapAction()) menu.addAction(actions.control.KeepAspectRatioAction(self, self)) menu.addAction(actions.control.YAxisInvertedAction(self, self)) diff --git a/silx/gui/plot/MaskToolsWidget.py b/silx/gui/plot/MaskToolsWidget.py index 09c5ca5..797068e 100644 --- a/silx/gui/plot/MaskToolsWidget.py +++ b/silx/gui/plot/MaskToolsWidget.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 @@ -35,7 +35,7 @@ from __future__ import division __authors__ = ["T. Vincent", "P. Knobel"] __license__ = "MIT" -__date__ = "20/06/2017" +__date__ = "24/04/2018" import os @@ -48,7 +48,7 @@ from silx.image import shapes from ._BaseMaskToolsWidget import BaseMask, BaseMaskToolsWidget, BaseMaskToolsDockWidget from . import items -from .Colors import cursorColorForColormap, rgba +from ..colors import cursorColorForColormap, rgba from .. import qt from silx.third_party.EdfFile import EdfFile @@ -76,6 +76,7 @@ class ImageMask(BaseMask): :param image: :class:`silx.gui.plot.items.ImageBase` instance """ BaseMask.__init__(self, image) + self.reset(shape=(0, 0)) # Init the mask with a 2D shape def getDataValues(self): """Return image data as a 2D or 3D array (if it is a RGBA image). @@ -222,7 +223,8 @@ class MaskToolsWidget(BaseMaskToolsWidget): def setSelectionMask(self, mask, copy=True): """Set the mask to a new array. - :param numpy.ndarray mask: The array to use for the mask. + :param numpy.ndarray mask: + The array to use for the mask or None to reset the mask. :type mask: numpy.ndarray of uint8 of dimension 2, C-contiguous. Array of other types are converted. :param bool copy: True (the default) to copy the array, @@ -231,11 +233,19 @@ class MaskToolsWidget(BaseMaskToolsWidget): The mask can be cropped or padded to fit active image, the returned shape is that of the active image. """ + if mask is None: + self.resetSelectionMask() + return self._data.shape[:2] + mask = numpy.array(mask, copy=False, dtype=numpy.uint8) if len(mask.shape) != 2: _logger.error('Not an image, shape: %d', len(mask.shape)) return None + # if mask has not changed, do nothing + if numpy.array_equal(mask, self.getSelectionMask()): + return mask.shape + # ensure all mask attributes are synchronized with the active image # and connect listener activeImage = self.plot.getActiveImage() @@ -265,7 +275,7 @@ class MaskToolsWidget(BaseMaskToolsWidget): def _updatePlotMask(self): """Update mask image in plot""" mask = self.getSelectionMask(copy=False) - if len(mask): + if mask is not None: # get the mask from the plot maskItem = self.plot.getImage(self._maskName) mustBeAdded = maskItem is None @@ -303,7 +313,7 @@ class MaskToolsWidget(BaseMaskToolsWidget): if not self.browseAction.isChecked(): self.browseAction.trigger() # Disable drawing tool - if len(self.getSelectionMask(copy=False)): + if self.getSelectionMask(copy=False) is not None: self.plot.sigActiveImageChanged.connect( self._activeImageChangedAfterCare) @@ -328,6 +338,13 @@ class MaskToolsWidget(BaseMaskToolsWidget): activeImage = self.plot.getActiveImage() if activeImage is None or activeImage.getLegend() == self._maskName: # No active image or active image is the mask... + self._data = numpy.zeros((0, 0), dtype=numpy.uint8) + self._mask.setDataItem(None) + self._mask.reset() + + if self.plot.getImage(self._maskName): + self.plot.remove(self._maskName, kind='image') + self.plot.sigActiveImageChanged.disconnect( self._activeImageChangedAfterCare) else: @@ -340,7 +357,7 @@ class MaskToolsWidget(BaseMaskToolsWidget): self._scale = activeImage.getScale() self._z = activeImage.getZValue() + 1 self._data = activeImage.getData(copy=False) - if self._data.shape[:2] != self.getSelectionMask(copy=False).shape: + if self._data.shape[:2] != self._mask.getMask(copy=False).shape: # Image has not the same size, remove mask and stop listening if self.plot.getImage(self._maskName): self.plot.remove(self._maskName, kind='image') @@ -378,7 +395,7 @@ class MaskToolsWidget(BaseMaskToolsWidget): self._z = activeImage.getZValue() + 1 self._data = activeImage.getData(copy=False) self._mask.setDataItem(activeImage) - if self._data.shape[:2] != self.getSelectionMask(copy=False).shape: + if self._data.shape[:2] != self._mask.getMask(copy=False).shape: self._mask.reset(self._data.shape[:2]) self._mask.commit() else: @@ -597,7 +614,7 @@ class MaskToolsWidget(BaseMaskToolsWidget): # convert from plot to array coords col, row = (event['points'][-1] - self._origin) / self._scale col, row = int(col), int(row) - brushSize = self.pencilSpinBox.value() + brushSize = self._getPencilWidth() if self._lastPencilPos != (row, col): if self._lastPencilPos is not None: diff --git a/silx/gui/plot/PlotInteraction.py b/silx/gui/plot/PlotInteraction.py index 865073b..356bda6 100644 --- a/silx/gui/plot/PlotInteraction.py +++ b/silx/gui/plot/PlotInteraction.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# Copyright (c) 2014-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 @@ -26,7 +26,7 @@ __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "27/06/2017" +__date__ = "24/04/2018" import math @@ -34,7 +34,8 @@ import numpy import time import weakref -from . import Colors +from .. import colors +from .. import qt from . import items from .Interaction import (ClickOrDrag, LEFT_BTN, RIGHT_BTN, State, StateMachine) @@ -115,11 +116,52 @@ class _ZoomOnWheel(ClickOrDrag, _PlotInteraction): Base class for :class:`Pan` and :class:`Zoom` """ + + _DOUBLE_CLICK_TIMEOUT = 0.4 + class ZoomIdle(ClickOrDrag.Idle): def onWheel(self, x, y, angle): scaleF = 1.1 if angle > 0 else 1. / 1.1 applyZoomToPlot(self.machine.plot, scaleF, (x, y)) + def click(self, x, y, btn): + """Handle clicks by sending events + + :param int x: Mouse X position in pixels + :param int y: Mouse Y position in pixels + :param btn: Clicked mouse button + """ + if btn == LEFT_BTN: + lastClickTime, lastClickPos = self._lastClick + + # Signal mouse double clicked event first + if (time.time() - lastClickTime) <= self._DOUBLE_CLICK_TIMEOUT: + # Use position of first click + eventDict = prepareMouseSignal('mouseDoubleClicked', 'left', + *lastClickPos) + self.plot.notify(**eventDict) + + self._lastClick = 0., None + else: + # Signal mouse clicked event + dataPos = self.plot.pixelToData(x, y) + assert dataPos is not None + eventDict = prepareMouseSignal('mouseClicked', 'left', + dataPos[0], dataPos[1], + x, y) + self.plot.notify(**eventDict) + + self._lastClick = time.time(), (dataPos[0], dataPos[1], x, y) + + elif btn == RIGHT_BTN: + # Signal mouse clicked event + dataPos = self.plot.pixelToData(x, y) + assert dataPos is not None + eventDict = prepareMouseSignal('mouseClicked', 'right', + dataPos[0], dataPos[1], + x, y) + self.plot.notify(**eventDict) + def __init__(self, plot): """Init. @@ -135,6 +177,8 @@ class _ZoomOnWheel(ClickOrDrag, _PlotInteraction): } StateMachine.__init__(self, states, 'idle') + self._lastClick = 0., None + # Pan ######################################################################### @@ -229,11 +273,9 @@ class Zoom(_ZoomOnWheel): Zoom-in on selected area, zoom-out on right click, and zoom on mouse wheel. """ - _DOUBLE_CLICK_TIMEOUT = 0.4 def __init__(self, plot, color): self.color = color - self._lastClick = 0., None super(Zoom, self).__init__(plot) self.plot.getLimitsHistory().clear() @@ -263,38 +305,6 @@ class Zoom(_ZoomOnWheel): return areaX0, areaY0, areaX1, areaY1 - def click(self, x, y, btn): - if btn == LEFT_BTN: - lastClickTime, lastClickPos = self._lastClick - - # Signal mouse double clicked event first - if (time.time() - lastClickTime) <= self._DOUBLE_CLICK_TIMEOUT: - # Use position of first click - eventDict = prepareMouseSignal('mouseDoubleClicked', 'left', - *lastClickPos) - self.plot.notify(**eventDict) - - self._lastClick = 0., None - else: - # Signal mouse clicked event - dataPos = self.plot.pixelToData(x, y) - assert dataPos is not None - eventDict = prepareMouseSignal('mouseClicked', 'left', - dataPos[0], dataPos[1], - x, y) - self.plot.notify(**eventDict) - - self._lastClick = time.time(), (dataPos[0], dataPos[1], x, y) - - elif btn == RIGHT_BTN: - # Signal mouse clicked event - dataPos = self.plot.pixelToData(x, y) - assert dataPos is not None - eventDict = prepareMouseSignal('mouseClicked', 'right', - dataPos[0], dataPos[1], - x, y) - self.plot.notify(**eventDict) - def beginDrag(self, x, y): dataPos = self.plot.pixelToData(x, y) assert dataPos is not None @@ -424,7 +434,7 @@ class SelectPolygon(Select): """Update drawing first point, using self._firstPos""" x, y = self.machine.plot.dataToPixel(*self._firstPos, check=False) - offset = self.machine.DRAG_THRESHOLD_DIST + offset = self.machine.getDragThreshold() points = [(x - offset, y - offset), (x - offset, y + offset), (x + offset, y + offset), @@ -458,10 +468,10 @@ class SelectPolygon(Select): check=False) dx, dy = abs(firstPos[0] - x), abs(firstPos[1] - y) + threshold = self.machine.getDragThreshold() + # Only allow to close polygon after first point - if (len(self.points) > 2 and - dx < self.machine.DRAG_THRESHOLD_DIST and - dy < self.machine.DRAG_THRESHOLD_DIST): + if len(self.points) > 2 and dx <= threshold and dy <= threshold: self.machine.resetSelectionArea() self.points[-1] = self.points[0] @@ -489,8 +499,7 @@ class SelectPolygon(Select): previousPos = self.machine.plot.dataToPixel(*self.points[-2], check=False) dx, dy = abs(previousPos[0] - x), abs(previousPos[1] - y) - if(dx >= self.machine.DRAG_THRESHOLD_DIST or - dy >= self.machine.DRAG_THRESHOLD_DIST): + if dx >= threshold or dy >= threshold: self.points.append(dataPos) else: self.points[-1] = dataPos @@ -502,8 +511,9 @@ class SelectPolygon(Select): firstPos = self.machine.plot.dataToPixel(*self._firstPos, check=False) dx, dy = abs(firstPos[0] - x), abs(firstPos[1] - y) - if (dx < self.machine.DRAG_THRESHOLD_DIST and - dy < self.machine.DRAG_THRESHOLD_DIST): + threshold = self.machine.getDragThreshold() + + if dx <= threshold and dy <= threshold: x, y = firstPos # Snap to first point dataPos = self.machine.plot.pixelToData(x, y) @@ -523,6 +533,17 @@ class SelectPolygon(Select): if isinstance(self.state, self.states['select']): self.resetSelectionArea() + def getDragThreshold(self): + """Return dragging ratio with device to pixel ratio applied. + + :rtype: float + """ + ratio = 1. + if qt.BINDING in ('PyQt5', 'PySide2'): + ratio = self.plot.window().windowHandle().devicePixelRatio() + return self.DRAG_THRESHOLD_DIST * ratio + + class Select2Points(Select): """Base class for drawing selection based on 2 input points.""" @@ -1204,6 +1225,48 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction): self.plot.setGraphCursorShape() +class ItemsInteractionForCombo(ItemsInteraction): + """Interaction with items to combine through :class:`FocusManager`. + """ + + class Idle(ItemsInteraction.Idle): + def onPress(self, x, y, btn): + if btn == LEFT_BTN: + def test(item): + return (item.isSelectable() or + (isinstance(item, items.DraggableMixIn) and + item.isDraggable())) + + picked = self.machine.plot._pickMarker(x, y, test) + if picked is not None: + itemInteraction = True + + else: + picked = self.machine.plot._pickImageOrCurve(x, y, test) + itemInteraction = picked is not None + + if itemInteraction: # Request focus and handle interaction + self.goto('clickOrDrag', x, y) + return True + else: # Do not request focus + return False + + elif btn == RIGHT_BTN: + self.goto('rightClick', x, y) + return True + + def __init__(self, plot): + _PlotInteraction.__init__(self, plot) + + states = { + 'idle': ItemsInteractionForCombo.Idle, + 'rightClick': ClickOrDrag.RightClick, + 'clickOrDrag': ClickOrDrag.ClickOrDrag, + 'drag': ClickOrDrag.Drag + } + StateMachine.__init__(self, states, 'idle') + + # FocusManager ################################################################ class FocusManager(StateMachine): @@ -1344,6 +1407,74 @@ class ZoomAndSelect(ItemsInteraction): return super(ZoomAndSelect, self).endDrag(startPos, endPos) +class PanAndSelect(ItemsInteraction): + """Combine Pan and ItemInteraction state machine. + + :param plot: The Plot to which this interaction is attached + """ + + def __init__(self, plot): + super(PanAndSelect, self).__init__(plot) + self._pan = Pan(plot) + self._doPan = False + + def click(self, x, y, btn): + """Handle mouse click + + :param x: X position of the mouse in pixels + :param y: Y position of the mouse in pixels + :param btn: Pressed button id + :return: True if click is catched by an item, False otherwise + """ + eventDict = self._handleClick(x, y, btn) + + if eventDict is not None: + # Signal mouse clicked event + dataPos = self.plot.pixelToData(x, y) + assert dataPos is not None + clickedEventDict = prepareMouseSignal('mouseClicked', btn, + dataPos[0], dataPos[1], + x, y) + self.plot.notify(**clickedEventDict) + + self.plot.notify(**eventDict) + + else: + self._pan.click(x, y, btn) + + def beginDrag(self, x, y): + """Handle start drag and switching between zoom and item drag. + + :param x: X position in pixels + :param y: Y position in pixels + """ + self._doPan = not super(PanAndSelect, self).beginDrag(x, y) + if self._doPan: + self._pan.beginDrag(x, y) + + def drag(self, x, y): + """Handle drag, eventually forwarding to zoom. + + :param x: X position in pixels + :param y: Y position in pixels + """ + if self._doPan: + return self._pan.drag(x, y) + else: + return super(PanAndSelect, self).drag(x, y) + + def endDrag(self, startPos, endPos): + """Handle end of drag, eventually forwarding to zoom. + + :param startPos: (x, y) position at the beginning of the drag + :param endPos: (x, y) position at the end of the drag + """ + if self._doPan: + return self._pan.endDrag(startPos, endPos) + else: + return super(PanAndSelect, self).endDrag(startPos, endPos) + + # Interaction mode control #################################################### class PlotInteraction(object): @@ -1384,12 +1515,21 @@ class PlotInteraction(object): if isinstance(self._eventHandler, ZoomAndSelect): return {'mode': 'zoom', 'color': self._eventHandler.color} + elif isinstance(self._eventHandler, FocusManager): + drawHandler = self._eventHandler.eventHandlers[1] + if not isinstance(drawHandler, Select): + raise RuntimeError('Unknown interactive mode') + + result = drawHandler.parameters.copy() + result['mode'] = 'draw' + return result + elif isinstance(self._eventHandler, Select): result = self._eventHandler.parameters.copy() result['mode'] = 'draw' return result - elif isinstance(self._eventHandler, Pan): + elif isinstance(self._eventHandler, PanAndSelect): return {'mode': 'pan'} else: @@ -1400,7 +1540,7 @@ class PlotInteraction(object): """Switch the interactive mode. :param str mode: The name of the interactive mode. - In 'draw', 'pan', 'select', 'zoom'. + In 'draw', 'pan', 'select', 'select-draw', 'zoom'. :param color: Only for 'draw' and 'zoom' modes. Color to use for drawing selection area. Default black. If None, selection area is not drawn. @@ -1413,15 +1553,15 @@ class PlotInteraction(object): :param str label: Only for 'draw' mode. :param float width: Width of the pencil. Only for draw pencil mode. """ - assert mode in ('draw', 'pan', 'select', 'zoom') + assert mode in ('draw', 'pan', 'select', 'select-draw', 'zoom') plot = self._plot() assert plot is not None if color not in (None, 'video inverted'): - color = Colors.rgba(color) + color = colors.rgba(color) - if mode == 'draw': + if mode in ('draw', 'select-draw'): assert shape in self._DRAW_MODES eventHandlerClass = self._DRAW_MODES[shape] parameters = { @@ -1430,14 +1570,21 @@ class PlotInteraction(object): 'color': color, 'width': width, } + eventHandler = eventHandlerClass(plot, parameters) self._eventHandler.cancel() - self._eventHandler = eventHandlerClass(plot, parameters) + + if mode == 'draw': + self._eventHandler = eventHandler + + else: # mode == 'select-draw' + self._eventHandler = FocusManager( + (ItemsInteractionForCombo(plot), eventHandler)) elif mode == 'pan': # Ignores color, shape and label self._eventHandler.cancel() - self._eventHandler = Pan(plot) + self._eventHandler = PanAndSelect(plot) elif mode == 'zoom': # Ignores shape and label diff --git a/silx/gui/plot/PlotToolButtons.py b/silx/gui/plot/PlotToolButtons.py index fc5fcf4..e354877 100644 --- a/silx/gui/plot/PlotToolButtons.py +++ b/silx/gui/plot/PlotToolButtons.py @@ -30,6 +30,7 @@ The following QToolButton are available: - :class:`.AspectToolButton` - :class:`.YAxisOriginToolButton` - :class:`.ProfileToolButton` +- :class:`.SymbolToolButton` """ @@ -38,10 +39,15 @@ __license__ = "MIT" __date__ = "27/06/2017" +import functools import logging +import weakref + from .. import icons from .. import qt +from .items import SymbolMixIn + _logger = logging.getLogger(__name__) @@ -52,7 +58,7 @@ class PlotToolButton(qt.QToolButton): def __init__(self, parent=None, plot=None): super(PlotToolButton, self).__init__(parent) - self._plot = None + self._plotRef = None if plot is not None: self.setPlot(plot) @@ -60,7 +66,7 @@ class PlotToolButton(qt.QToolButton): """ Returns the plot connected to the widget. """ - return self._plot + return None if self._plotRef is None else self._plotRef() def setPlot(self, plot): """ @@ -68,13 +74,18 @@ class PlotToolButton(qt.QToolButton): :param plot: :class:`.PlotWidget` instance on which to operate. """ - if self._plot is plot: + previousPlot = self.plot() + + if previousPlot is plot: return - if self._plot is not None: - self._disconnectPlot(self._plot) - self._plot = plot - if self._plot is not None: - self._connectPlot(self._plot) + if previousPlot is not None: + self._disconnectPlot(previousPlot) + + if plot is None: + self._plotRef = None + else: + self._plotRef = weakref.ref(plot) + self._connectPlot(plot) def _connectPlot(self, plot): """ @@ -282,3 +293,71 @@ class ProfileToolButton(PlotToolButton): def computeProfileIn2D(self): self._profileDimensionChanged(2) + + +class SymbolToolButton(PlotToolButton): + """A tool button with a drop-down menu to control symbol size and marker. + + :param parent: See QWidget + :param plot: The `~silx.gui.plot.PlotWidget` to control + """ + + def __init__(self, parent=None, plot=None): + super(SymbolToolButton, self).__init__(parent=parent, plot=plot) + + self.setToolTip('Set symbol size and marker') + self.setIcon(icons.getQIcon('plot-symbols')) + + menu = qt.QMenu(self) + + # Size slider + + slider = qt.QSlider(qt.Qt.Horizontal) + slider.setRange(1, 20) + slider.setValue(SymbolMixIn._DEFAULT_SYMBOL_SIZE) + slider.setTracking(False) + slider.valueChanged.connect(self._sizeChanged) + widgetAction = qt.QWidgetAction(menu) + widgetAction.setDefaultWidget(slider) + menu.addAction(widgetAction) + + menu.addSeparator() + + # Marker actions + + for marker, name in zip(SymbolMixIn.getSupportedSymbols(), + SymbolMixIn.getSupportedSymbolNames()): + action = qt.QAction(name, menu) + action.setCheckable(False) + action.triggered.connect( + functools.partial(self._markerChanged, marker)) + menu.addAction(action) + + self.setMenu(menu) + self.setPopupMode(qt.QToolButton.InstantPopup) + + def _sizeChanged(self, value): + """Manage slider value changed + + :param int value: Marker size + """ + plot = self.plot() + if plot is None: + return + + for item in plot._getItems(withhidden=True): + if isinstance(item, SymbolMixIn): + item.setSymbolSize(value) + + def _markerChanged(self, marker): + """Manage change of marker. + + :param str marker: Letter describing the marker + """ + plot = self.plot() + if plot is None: + return + + for item in plot._getItems(withhidden=True): + if isinstance(item, SymbolMixIn): + item.setSymbol(marker) diff --git a/silx/gui/plot/PlotTools.py b/silx/gui/plot/PlotTools.py index 7fadfd2..5929473 100644 --- a/silx/gui/plot/PlotTools.py +++ b/silx/gui/plot/PlotTools.py @@ -25,288 +25,19 @@ """Set of widgets to associate with a :class:'PlotWidget'. """ -from __future__ import division +from __future__ import absolute_import -__authors__ = ["V.A. Sole", "T. Vincent"] +__authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "16/10/2017" +__date__ = "01/03/2018" -import logging -import numbers -import traceback -import weakref +from ...utils.deprecation import deprecated_warning -import numpy +deprecated_warning(type_='module', + name=__file__, + reason='Plot tools refactoring', + replacement='silx.gui.plot.tools', + since_version='0.8') -from .. import qt -from silx.gui.widgets.FloatEdit import FloatEdit - -_logger = logging.getLogger(__name__) - - -# PositionInfo ################################################################ - -class PositionInfo(qt.QWidget): - """QWidget displaying coords converted from data coords of the mouse. - - Provide this widget with a list of couple: - - - A name to display before the data - - A function that takes (x, y) as arguments and returns something that - gets converted to a string. - If the result is a float it is converted with '%.7g' format. - - To run the following sample code, a QApplication must be initialized. - First, create a PlotWindow and add a QToolBar where to place the - PositionInfo widget. - - >>> from silx.gui.plot import PlotWindow - >>> from silx.gui import qt - - >>> plot = PlotWindow() # Create a PlotWindow to add the widget to - >>> toolBar = qt.QToolBar() # Create a toolbar to place the widget in - >>> plot.addToolBar(qt.Qt.BottomToolBarArea, toolBar) # Add it to plot - - Then, create the PositionInfo widget and add it to the toolbar. - The PositionInfo widget is created with a list of converters, here - to display polar coordinates of the mouse position. - - >>> import numpy - >>> from silx.gui.plot.PlotTools import PositionInfo - - >>> position = PositionInfo(plot=plot, converters=[ - ... ('Radius', lambda x, y: numpy.sqrt(x*x + y*y)), - ... ('Angle', lambda x, y: numpy.degrees(numpy.arctan2(y, x)))]) - >>> toolBar.addWidget(position) # Add the widget to the toolbar - <...> - >>> plot.show() # To display the PlotWindow with the position widget - - :param plot: The PlotWidget this widget is displaying data coords from. - :param converters: - List of 2-tuple: name to display and conversion function from (x, y) - in data coords to displayed value. - If None, the default, it displays X and Y. - :param parent: Parent widget - """ - - def __init__(self, parent=None, plot=None, converters=None): - assert plot is not None - self._plotRef = weakref.ref(plot) - - super(PositionInfo, self).__init__(parent) - - if converters is None: - converters = (('X', lambda x, y: x), ('Y', lambda x, y: y)) - - self.autoSnapToActiveCurve = False - """Toggle snapping use position to active curve. - - - True to snap used coordinates to the active curve if the active curve - is displayed with symbols and mouse is close enough. - If the mouse is not close to a point of the curve, values are - displayed in red. - - False (the default) to always use mouse coordinates. - - """ - - self._fields = [] # To store (QLineEdit, name, function (x, y)->v) - - # Create a new layout with new widgets - layout = qt.QHBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - # layout.setSpacing(0) - - # Create all QLabel and store them with the corresponding converter - for name, func in converters: - layout.addWidget(qt.QLabel('<b>' + name + ':</b>')) - - contentWidget = qt.QLabel() - contentWidget.setText('------') - contentWidget.setTextInteractionFlags(qt.Qt.TextSelectableByMouse) - contentWidget.setFixedWidth( - contentWidget.fontMetrics().width('##############')) - layout.addWidget(contentWidget) - self._fields.append((contentWidget, name, func)) - - layout.addStretch(1) - self.setLayout(layout) - - # Connect to Plot events - plot.sigPlotSignal.connect(self._plotEvent) - - @property - def plot(self): - """The :class:`.PlotWindow` this widget is attached to.""" - return self._plotRef() - - def getConverters(self): - """Return the list of converters as 2-tuple (name, function).""" - return [(name, func) for _label, name, func in self._fields] - - def _plotEvent(self, event): - """Handle events from the Plot. - - :param dict event: Plot event - """ - if event['event'] == 'mouseMoved': - x, y = event['x'], event['y'] - xPixel, yPixel = event['xpixel'], event['ypixel'] - self._updateStatusBar(x, y, xPixel, yPixel) - - def _updateStatusBar(self, x, y, xPixel, yPixel): - """Update information from the status bar using the definitions. - - :param float x: Position-x in data - :param float y: Position-y in data - :param float xPixel: Position-x in pixels - :param float yPixel: Position-y in pixels - """ - styleSheet = "color: rgb(0, 0, 0);" # Default style - - if self.autoSnapToActiveCurve and self.plot.getGraphCursor(): - # Check if near active curve with symbols. - - styleSheet = "color: rgb(255, 0, 0);" # Style far from curve - - activeCurve = self.plot.getActiveCurve() - if activeCurve: - xData = activeCurve.getXData(copy=False) - yData = activeCurve.getYData(copy=False) - if activeCurve.getSymbol(): # Only handled if symbols on curve - closestIndex = numpy.argmin( - pow(xData - x, 2) + pow(yData - y, 2)) - - xClosest = xData[closestIndex] - yClosest = yData[closestIndex] - - closestInPixels = self.plot.dataToPixel( - xClosest, yClosest, axis=activeCurve.getYAxis()) - if closestInPixels is not None: - if (abs(closestInPixels[0] - xPixel) < 5 and - abs(closestInPixels[1] - yPixel) < 5): - # Update label style sheet - styleSheet = "color: rgb(0, 0, 0);" - - # if close enough, wrap to data point coords - x, y = xClosest, yClosest - - for label, name, func in self._fields: - label.setStyleSheet(styleSheet) - - try: - value = func(x, y) - text = self.valueToString(value) - label.setText(text) - except: - label.setText('Error') - _logger.error( - "Error while converting coordinates (%f, %f)" - "with converter '%s'" % (x, y, name)) - _logger.error(traceback.format_exc()) - - def valueToString(self, value): - if isinstance(value, (tuple, list)): - value = [self.valueToString(v) for v in value] - return ", ".join(value) - elif isinstance(value, numbers.Real): - # Use this for floats and int - return '%.7g' % value - else: - # Fallback for other types - return str(value) - -# LimitsToolBar ############################################################## - -class LimitsToolBar(qt.QToolBar): - """QToolBar displaying and controlling the limits of a :class:`PlotWidget`. - - To run the following sample code, a QApplication must be initialized. - First, create a PlotWindow: - - >>> from silx.gui.plot import PlotWindow - >>> plot = PlotWindow() # Create a PlotWindow to add the toolbar to - - Then, create the LimitsToolBar and add it to the PlotWindow. - - >>> from silx.gui import qt - >>> from silx.gui.plot.PlotTools import LimitsToolBar - - >>> toolbar = LimitsToolBar(plot=plot) # Create the toolbar - >>> plot.addToolBar(qt.Qt.BottomToolBarArea, toolbar) # Add it to the plot - >>> plot.show() # To display the PlotWindow with the limits toolbar - - :param parent: See :class:`QToolBar`. - :param plot: :class:`PlotWidget` instance on which to operate. - :param str title: See :class:`QToolBar`. - """ - - def __init__(self, parent=None, plot=None, title='Limits'): - super(LimitsToolBar, self).__init__(title, parent) - assert plot is not None - self._plot = plot - self._plot.sigPlotSignal.connect(self._plotWidgetSlot) - - self._initWidgets() - - @property - def plot(self): - """The :class:`PlotWidget` the toolbar is attached to.""" - return self._plot - - def _initWidgets(self): - """Create and init Toolbar widgets.""" - xMin, xMax = self.plot.getXAxis().getLimits() - yMin, yMax = self.plot.getYAxis().getLimits() - - self.addWidget(qt.QLabel('Limits: ')) - self.addWidget(qt.QLabel(' X: ')) - self._xMinFloatEdit = FloatEdit(self, xMin) - self._xMinFloatEdit.editingFinished[()].connect( - self._xFloatEditChanged) - self.addWidget(self._xMinFloatEdit) - - self._xMaxFloatEdit = FloatEdit(self, xMax) - self._xMaxFloatEdit.editingFinished[()].connect( - self._xFloatEditChanged) - self.addWidget(self._xMaxFloatEdit) - - self.addWidget(qt.QLabel(' Y: ')) - self._yMinFloatEdit = FloatEdit(self, yMin) - self._yMinFloatEdit.editingFinished[()].connect( - self._yFloatEditChanged) - self.addWidget(self._yMinFloatEdit) - - self._yMaxFloatEdit = FloatEdit(self, yMax) - self._yMaxFloatEdit.editingFinished[()].connect( - self._yFloatEditChanged) - self.addWidget(self._yMaxFloatEdit) - - def _plotWidgetSlot(self, event): - """Listen to :class:`PlotWidget` events.""" - if event['event'] not in ('limitsChanged',): - return - - xMin, xMax = self.plot.getXAxis().getLimits() - yMin, yMax = self.plot.getYAxis().getLimits() - - self._xMinFloatEdit.setValue(xMin) - self._xMaxFloatEdit.setValue(xMax) - self._yMinFloatEdit.setValue(yMin) - self._yMaxFloatEdit.setValue(yMax) - - def _xFloatEditChanged(self): - """Handle X limits changed from the GUI.""" - xMin, xMax = self._xMinFloatEdit.value(), self._xMaxFloatEdit.value() - if xMax < xMin: - xMin, xMax = xMax, xMin - - self.plot.getXAxis().setLimits(xMin, xMax) - - def _yFloatEditChanged(self): - """Handle Y limits changed from the GUI.""" - yMin, yMax = self._yMinFloatEdit.value(), self._yMaxFloatEdit.value() - if yMax < yMin: - yMin, yMax = yMax, yMin - - self.plot.getYAxis().setLimits(yMin, yMax) +from .tools import PositionInfo, LimitsToolBar # noqa diff --git a/silx/gui/plot/PlotWidget.py b/silx/gui/plot/PlotWidget.py index 3641b8c..2f7132c 100644 --- a/silx/gui/plot/PlotWidget.py +++ b/silx/gui/plot/PlotWidget.py @@ -31,37 +31,43 @@ from __future__ import division __authors__ = ["V.A. Sole", "T. Vincent"] __license__ = "MIT" -__date__ = "18/10/2017" +__date__ = "14/06/2018" from collections import OrderedDict, namedtuple from contextlib import contextmanager +import datetime as dt import itertools import logging import numpy +import silx +from silx.utils.weakref import WeakMethodProxy +from silx.utils import deprecation +from silx.utils.property import classproperty from silx.utils.deprecation import deprecated # Import matplotlib backend here to init matplotlib our way from .backends.BackendMatplotlib import BackendMatplotlibQt -from .Colormap import Colormap -from . import Colors +from ..colors import Colormap +from .. import colors from . import PlotInteraction from . import PlotEvents from .LimitsHistory import LimitsHistory from . import _utils from . import items +from .items.axis import TickMode from .. import qt from ._utils.panzoom import ViewConstraints - +from ...gui.plot._utils.dtime_ticklayout import timestamp _logger = logging.getLogger(__name__) -_COLORDICT = Colors.COLORDICT +_COLORDICT = colors.COLORDICT _COLORLIST = [_COLORDICT['black'], _COLORDICT['blue'], _COLORDICT['red'], @@ -110,8 +116,12 @@ class PlotWidget(qt.QMainWindow): :type backend: str or :class:`BackendBase.BackendBase` """ - DEFAULT_BACKEND = 'matplotlib' - """Class attribute setting the default backend for all instances.""" + # TODO: Can be removed for silx 0.10 + @classproperty + @deprecation.deprecated(replacement="silx.config.DEFAULT_PLOT_BACKEND", since_version="0.8", skip_backtrace_count=2) + def DEFAULT_BACKEND(self): + """Class attribute setting the default backend for all instances.""" + return silx.config.DEFAULT_PLOT_BACKEND colorList = _COLORLIST colorDict = _COLORDICT @@ -209,7 +219,7 @@ class PlotWidget(qt.QMainWindow): self.setWindowTitle('PlotWidget') if backend is None: - backend = self.DEFAULT_BACKEND + backend = silx.config.DEFAULT_PLOT_BACKEND if hasattr(backend, "__call__"): self._backend = backend(self, parent) @@ -296,7 +306,9 @@ class PlotWidget(qt.QMainWindow): self.setGraphYLimits(0., 100., axis='right') self.setGraphYLimits(0., 100., axis='left') + # TODO: Can be removed for silx 0.10 @staticmethod + @deprecation.deprecated(replacement="silx.config.DEFAULT_PLOT_BACKEND", since_version="0.8", skip_backtrace_count=2) def setDefaultBackend(backend): """Set system wide default plot backend. @@ -306,7 +318,7 @@ class PlotWidget(qt.QMainWindow): 'matplotlib' (default), 'mpl', 'opengl', 'gl', 'none' or a :class:`BackendBase.BackendBase` class """ - PlotWidget.DEFAULT_BACKEND = backend + silx.config.DEFAULT_PLOT_BACKEND = backend def _getDirtyPlot(self): """Return the plot dirty flag. @@ -525,7 +537,9 @@ class PlotWidget(qt.QMainWindow): :param numpy.ndarray x: The data corresponding to the x coordinates. If you attempt to plot an histogram you can set edges values in x. - In this case len(x) = len(y) + 1 + In this case len(x) = len(y) + 1. + If x contains datetime objects the XAxis tickMode is set to + TickMode.TIME_SERIES. :param numpy.ndarray y: The data corresponding to the y coordinates :param str legend: The legend to be associated to the curve (or None) :param info: User-defined information associated to the curve @@ -533,7 +547,7 @@ class PlotWidget(qt.QMainWindow): curves :param color: color(s) to be used :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or - one of the predefined color names defined in Colors.py + one of the predefined color names defined in colors.py :param str symbol: Symbol to be drawn at each (x, y) position:: - 'o' circle @@ -686,6 +700,13 @@ class PlotWidget(qt.QMainWindow): if yerror is None: yerror = curve.getYErrorData(copy=False) + # Convert x to timestamps so that the internal representation + # remains floating points. The user is expected to set the axis' + # tickMode to TickMode.TIME_SERIES and, if necessary, set the axis + # to the correct time zone. + if len(x) > 0 and isinstance(x[0], dt.datetime): + x = [timestamp(d) for d in x] + curve.setData(x, y, xerror, yerror, copy=copy) if replace: # Then remove all other curves @@ -739,7 +760,7 @@ class PlotWidget(qt.QMainWindow): The legend to be associated to the histogram (or None) :param color: color to be used :type color: str ("#RRGGBB") or RGB unsigned byte array or - one of the predefined color names defined in Colors.py + one of the predefined color names defined in colors.py :param bool fill: True to fill the curve, False otherwise (default). :param str align: In case histogram values and edges have the same length N, @@ -785,7 +806,7 @@ class PlotWidget(qt.QMainWindow): return legend def addImage(self, data, legend=None, info=None, - replace=True, replot=None, + replace=False, replot=None, xScale=None, yScale=None, z=None, selectable=None, draggable=None, colormap=None, pixmap=None, @@ -811,7 +832,8 @@ class PlotWidget(qt.QMainWindow): Note: boolean values are converted to int8. :param str legend: The legend to be associated to the image (or None) :param info: User-defined information associated to the image - :param bool replace: True (default) to delete already existing images + :param bool replace: + True to delete already existing images (Default: False). :param int z: Layer on which to draw the image (default: 0) This allows to control the overlay. :param bool selectable: Indicate if the image can be selected. @@ -821,7 +843,7 @@ class PlotWidget(qt.QMainWindow): :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.plot.Colormap.Colormap, dict] + :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, @@ -964,7 +986,7 @@ 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.plot.Colormap.Colormap colormap: + :param silx.gui.colors.Colormap colormap: The :class:`.Colormap`. 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:: @@ -1477,7 +1499,7 @@ class PlotWidget(qt.QMainWindow): :param bool flag: Toggle the display of a crosshair cursor. The crosshair cursor is hidden by default. :param color: The color to use for the crosshair. - :type color: A string (either a predefined color name in Colors.py + :type color: A string (either a predefined color name in colors.py or "#RRGGBB")) or a 4 columns unsigned byte array (Default: black). :param int linewidth: The width of the lines of the crosshair @@ -2264,13 +2286,13 @@ class PlotWidget(qt.QMainWindow): It only affects future calls to :meth:`addImage` without the colormap parameter. - :param silx.gui.plot.Colormap.Colormap colormap: + :param silx.gui.colors.Colormap colormap: The description of the default colormap, or None to set the :class:`.Colormap` to a linear autoscale gray colormap. """ if colormap is None: - colormap = Colormap(name='gray', + colormap = Colormap(name=silx.config.DEFAULT_COLORMAP_NAME, normalization='linear', vmin=None, vmax=None) @@ -2370,10 +2392,10 @@ class PlotWidget(qt.QMainWindow): to handle the graph events If None (default), use a default listener. """ - # TODO allow multiple listeners, keep a weakref on it + # TODO allow multiple listeners # allow register listener by event type if callbackFunction is None: - callbackFunction = self.graphCallback + callbackFunction = WeakMethodProxy(self.graphCallback) self._callback = callbackFunction def graphCallback(self, ddict=None): @@ -2392,6 +2414,8 @@ class PlotWidget(qt.QMainWindow): if ddict['button'] == "left": self.setActiveCurve(ddict['label']) qt.QToolTip.showText(self.cursor().pos(), ddict['label']) + elif ddict['event'] == 'mouseClicked' and ddict['button'] == 'left': + self.setActiveCurve(None) def saveGraph(self, filename, fileFormat=None, dpi=None, **kw): """Save a snapshot of the plot. @@ -2519,9 +2543,8 @@ class PlotWidget(qt.QMainWindow): # Compute bbox wth figure aspect ratio plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] - plotRatio = plotHeight / plotWidth - - if plotRatio > 0.: + if plotWidth > 0 and plotHeight > 0: + plotRatio = plotHeight / plotWidth dataRatio = (ymax - ymin) / (xmax - xmin) if dataRatio < plotRatio: # Increase y range @@ -2741,6 +2764,39 @@ class PlotWidget(qt.QMainWindow): return None + def _pick(self, x, y): + """Pick items in the plot at given position. + + :param float x: X position in pixels + :param float y: Y position in pixels + :return: Iterable of (plot item, indices) at picked position. + Items are ordered from back to front. + """ + items = [] + + # Convert backend result to plot items + for itemInfo in self._backend.pickItems( + x, y, kinds=('marker', 'curve', 'image')): + kind, legend = itemInfo['kind'], itemInfo['legend'] + + if kind in ('marker', 'image'): + item = self._getItem(kind=kind, legend=legend) + indices = None # TODO compute indices for images + + else: # backend kind == 'curve' + for kind in ('curve', 'histogram', 'scatter'): + item = self._getItem(kind=kind, legend=legend) + if item is not None: + indices = itemInfo['indices'] + break + else: + _logger.error( + 'Cannot find corresponding picked item') + continue + items.append((item, indices)) + + return tuple(items) + # User event handling # def _isPositionInPlotArea(self, x, y): @@ -2846,7 +2902,7 @@ class PlotWidget(qt.QMainWindow): """Switch the interactive mode. :param str mode: The name of the interactive mode. - In 'draw', 'pan', 'select', 'zoom'. + In 'draw', 'pan', 'select', 'select-draw', 'zoom'. :param color: Only for 'draw' and 'zoom' modes. Color to use for drawing selection area. Default black. :type color: Color description: The name as a str or @@ -2959,7 +3015,7 @@ class PlotWidget(qt.QMainWindow): :param str label: Associated text for identifying draw signals :param color: The color to use to draw the selection area :type color: string ("#RRGGBB") or 4 column unsigned byte array or - one of the predefined color names defined in Colors.py + one of the predefined color names defined in colors.py """ _logger.warning( 'setDrawModeEnabled deprecated, use setInteractiveMode instead') @@ -3011,7 +3067,7 @@ class PlotWidget(qt.QMainWindow): (Default: 'black') :param color: The color to use to draw the selection area :type color: string ("#RRGGBB") or 4 column unsigned byte array or - one of the predefined color names defined in Colors.py + one of the predefined color names defined in colors.py """ _logger.warning( 'setZoomModeEnabled deprecated, use setInteractiveMode instead') diff --git a/silx/gui/plot/PlotWindow.py b/silx/gui/plot/PlotWindow.py index 5c7e661..459ffdc 100644 --- a/silx/gui/plot/PlotWindow.py +++ b/silx/gui/plot/PlotWindow.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 @@ -29,11 +29,14 @@ The :class:`PlotWindow` is a subclass of :class:`.PlotWidget`. __authors__ = ["V.A. Sole", "T. Vincent"] __license__ = "MIT" -__date__ = "15/02/2018" +__date__ = "05/06/2018" import collections import logging +import weakref +import silx +from silx.utils.weakref import WeakMethodProxy from silx.utils.deprecation import deprecated from . import PlotWidget @@ -44,11 +47,12 @@ from .actions import fit as actions_fit from .actions import control as actions_control from .actions import histogram as actions_histogram from . import PlotToolButtons -from .PlotTools import PositionInfo +from . import tools from .Profile import ProfileToolBar from .LegendSelector import LegendsDockWidget from .CurvesROIWidget import CurvesROIDockWidget from .MaskToolsWidget import MaskToolsDockWidget +from .StatsWidget import BasicStatsWidget from .ColorBar import ColorBarWidget try: from ..console import IPythonDockWidget @@ -90,7 +94,7 @@ class PlotWindow(PlotWidget): (Default: False). It also supports a list of (name, funct(x, y)->value) to customize the displayed values. - See :class:`silx.gui.plot.PlotTools.PositionInfo`. + See :class:`~silx.gui.plot.tools.PositionInfo`. :param bool roi: Toggle visibilty of ROI action. :param bool mask: Toggle visibilty of mask action. :param bool fit: Toggle visibilty of fit action. @@ -114,6 +118,7 @@ class PlotWindow(PlotWidget): self._curvesROIDockWidget = None self._maskToolsDockWidget = None self._consoleDockWidget = None + self._statsWidget = None # Create color bar, hidden by default for backward compatibility self._colorbar = ColorBarWidget(parent=self, plot=self) @@ -122,11 +127,6 @@ class PlotWindow(PlotWidget): self.group = qt.QActionGroup(self) self.group.setExclusive(False) - self.zoomModeAction = self.group.addAction( - actions.mode.ZoomModeAction(self)) - self.panModeAction = self.group.addAction( - actions.mode.PanModeAction(self)) - self.resetZoomAction = self.group.addAction( actions.control.ResetZoomAction(self)) self.resetZoomAction.setVisible(resetzoom) @@ -205,28 +205,13 @@ class PlotWindow(PlotWidget): actions_medfilt.MedianFilter1DAction(self)) self._medianFilter1DAction.setVisible(False) - self._separator = qt.QAction('separator', self) - self._separator.setSeparator(True) - self.group.addAction(self._separator) - - self.copyAction = self.group.addAction(actions.io.CopyAction(self)) - self.copyAction.setVisible(copy) - self.addAction(self.copyAction) - - self.saveAction = self.group.addAction(actions.io.SaveAction(self)) - self.saveAction.setVisible(save) - self.addAction(self.saveAction) - - self.printAction = self.group.addAction(actions.io.PrintAction(self)) - self.printAction.setVisible(print_) - self.addAction(self.printAction) - self.fitAction = self.group.addAction(actions_fit.FitAction(self)) self.fitAction.setVisible(fit) self.addAction(self.fitAction) # lazy loaded actions needed by the controlButton menu self._consoleAction = None + self._statsAction = None self._panWithArrowKeysAction = None self._crosshairAction = None @@ -244,10 +229,12 @@ class PlotWindow(PlotWidget): gridLayout.addWidget(self._colorbar, 0, 1) gridLayout.setRowStretch(0, 1) gridLayout.setColumnStretch(0, 1) - centralWidget = qt.QWidget() + centralWidget = qt.QWidget(self) centralWidget.setLayout(gridLayout) self.setCentralWidget(centralWidget) + self._positionWidget = None + if control or position: hbox = qt.QHBoxLayout() hbox.setContentsMargins(0, 0, 0, 0) @@ -270,22 +257,69 @@ class PlotWindow(PlotWidget): converters = position else: converters = None - self.positionWidget = PositionInfo( + self._positionWidget = tools.PositionInfo( plot=self, converters=converters) - self.positionWidget.autoSnapToActiveCurve = True + # Set a snapping mode that is consistent with legacy one + self._positionWidget.setSnappingMode( + tools.PositionInfo.SNAPPING_CROSSHAIR | + tools.PositionInfo.SNAPPING_ACTIVE_ONLY | + tools.PositionInfo.SNAPPING_SYMBOLS_ONLY | + tools.PositionInfo.SNAPPING_CURVE | + tools.PositionInfo.SNAPPING_SCATTER) - hbox.addWidget(self.positionWidget) + hbox.addWidget(self._positionWidget) hbox.addStretch(1) - bottomBar = qt.QWidget() + bottomBar = qt.QWidget(centralWidget) bottomBar.setLayout(hbox) gridLayout.addWidget(bottomBar, 1, 0, 1, -1) # Creating the toolbar also create actions for toolbuttons + self._interactiveModeToolBar = tools.InteractiveModeToolBar( + parent=self, plot=self) + self.addToolBar(self._interactiveModeToolBar) + self._toolbar = self._createToolBar(title='Plot', parent=None) self.addToolBar(self._toolbar) + self._outputToolBar = tools.OutputToolBar(parent=self, plot=self) + self._outputToolBar.getCopyAction().setVisible(copy) + self._outputToolBar.getSaveAction().setVisible(save) + self._outputToolBar.getPrintAction().setVisible(print_) + self.addToolBar(self._outputToolBar) + + # Activate shortcuts in PlotWindow widget: + for toolbar in (self._interactiveModeToolBar, self._outputToolBar): + for action in toolbar.actions(): + self.addAction(action) + + def getInteractiveModeToolBar(self): + """Returns QToolBar controlling interactive mode. + + :rtype: QToolBar + """ + return self._interactiveModeToolBar + + def getOutputToolBar(self): + """Returns QToolBar containing save, copy and print actions + + :rtype: QToolBar + """ + return self._outputToolBar + + @property + @deprecated(replacement="getPositionInfoWidget()", since_version="0.8.0") + def positionWidget(self): + return self.getPositionInfoWidget() + + def getPositionInfoWidget(self): + """Returns the widget displaying current cursor position information + + :rtype: ~silx.gui.plot.tools.PositionInfo + """ + return self._positionWidget + def getSelectionMask(self): """Return the current mask handled by :attr:`maskToolsDockWidget`. @@ -313,7 +347,7 @@ class PlotWindow(PlotWidget): show it or hide it.""" # create widget if needed (first call) if self._consoleDockWidget is None: - available_vars = {"plt": self} + available_vars = {"plt": weakref.proxy(self)} banner = "The variable 'plt' is available. Use the 'whos' " banner += "and 'help(plt)' commands for more information.\n\n" self._consoleDockWidget = IPythonDockWidget( @@ -327,6 +361,9 @@ class PlotWindow(PlotWidget): self._consoleDockWidget.setVisible(isChecked) + def _toggleStatsVisibility(self, isChecked=False): + self.getStatsWidget().parent().setVisible(isChecked) + def _createToolBar(self, title, parent): """Create a QToolBar from the QAction of the PlotWindow. @@ -355,8 +392,6 @@ class PlotWindow(PlotWidget): self.yAxisInvertedAction = toolbar.addWidget(obj) else: raise RuntimeError() - if obj is self.panModeAction: - toolbar.addSeparator() return toolbar def toolBar(self): @@ -381,6 +416,7 @@ class PlotWindow(PlotWidget): controlMenu.clear() controlMenu.addAction(self.getLegendsDockWidget().toggleViewAction()) controlMenu.addAction(self.getRoiAction()) + controlMenu.addAction(self.getStatsAction()) controlMenu.addAction(self.getMaskAction()) controlMenu.addAction(self.getConsoleAction()) @@ -474,8 +510,35 @@ class PlotWindow(PlotWidget): self.addTabbedDockWidget(self._maskToolsDockWidget) return self._maskToolsDockWidget + def getStatsWidget(self): + """Returns a BasicStatsWidget connected to this plot + + :rtype: BasicStatsWidget + """ + if self._statsWidget is None: + dockWidget = qt.QDockWidget(parent=self) + dockWidget.setWindowTitle("Curves stats") + dockWidget.layout().setContentsMargins(0, 0, 0, 0) + self._statsWidget = BasicStatsWidget(parent=self, plot=self) + dockWidget.setWidget(self._statsWidget) + dockWidget.hide() + self.addTabbedDockWidget(dockWidget) + return self._statsWidget + # getters for actions @property + @deprecated(replacement="getInteractiveModeToolBar().getZoomModeAction()", + since_version="0.8.0") + def zoomModeAction(self): + return self.getInteractiveModeToolBar().getZoomModeAction() + + @property + @deprecated(replacement="getInteractiveModeToolBar().getPanModeAction()", + since_version="0.8.0") + def panModeAction(self): + return self.getInteractiveModeToolBar().getPanModeAction() + + @property @deprecated(replacement="getConsoleAction()", since_version="0.4.0") def consoleAction(self): return self.getConsoleAction() @@ -545,6 +608,14 @@ class PlotWindow(PlotWidget): def roiAction(self): return self.getRoiAction() + def getStatsAction(self): + if self._statsAction is None: + self._statsAction = qt.QAction('Curves stats', self) + self._statsAction.setCheckable(True) + self._statsAction.setChecked(self.getStatsWidget().parent().isVisible()) + self._statsAction.toggled.connect(self._toggleStatsVisibility) + return self._statsAction + def getRoiAction(self): """QAction toggling curve ROI dock widget @@ -667,21 +738,21 @@ class PlotWindow(PlotWidget): :rtype: actions.PlotAction """ - return self.copyAction + return self.getOutputToolBar().getCopyAction() def getSaveAction(self): """Action to save plot :rtype: actions.PlotAction """ - return self.saveAction + return self.getOutputToolBar().getSaveAction() def getPrintAction(self): """Action to print plot :rtype: actions.PlotAction """ - return self.printAction + return self.getOutputToolBar().getPrintAction() def getFitAction(self): """Action to fit selected curve @@ -757,7 +828,7 @@ class Plot2D(PlotWindow): posInfo = [ ('X', lambda x, y: x), ('Y', lambda x, y: y), - ('Data', self._getImageValue)] + ('Data', WeakMethodProxy(self._getImageValue))] super(Plot2D, self).__init__(parent=parent, backend=backend, resetzoom=True, autoScale=False, @@ -772,6 +843,9 @@ class Plot2D(PlotWindow): self.getXAxis().setLabel('Columns') self.getYAxis().setLabel('Rows') + if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward': + self.getYAxis().setInverted(True) + self.profile = ProfileToolBar(plot=self) self.addToolBar(self.profile) @@ -780,10 +854,41 @@ class Plot2D(PlotWindow): # Put colorbar action after colormap action actions = self.toolBar().actions() - for index, action in enumerate(actions): + for action in actions: if action is self.getColormapAction(): break + self.sigActiveImageChanged.connect(self.__activeImageChanged) + + def __activeImageChanged(self, previous, legend): + """Handle change of active image + + :param Union[str,None] previous: Legend of previous active image + :param Union[str,None] legend: Legend of current active image + """ + if previous is not None: + item = self.getImage(previous) + if item is not None: + item.sigItemChanged.disconnect(self.__imageChanged) + + if legend is not None: + item = self.getImage(legend) + item.sigItemChanged.connect(self.__imageChanged) + + positionInfo = self.getPositionInfoWidget() + if positionInfo is not None: + positionInfo.updateInfo() + + def __imageChanged(self, event): + """Handle update of active image item + + :param event: Type of changed event + """ + if event == items.ItemChangedType.DATA: + positionInfo = self.getPositionInfoWidget() + if positionInfo is not None: + positionInfo.updateInfo() + def _getImageValue(self, x, y): """Get status bar value of top most image at position (x, y) diff --git a/silx/gui/plot/Profile.py b/silx/gui/plot/Profile.py index f61412d..5a733fe 100644 --- a/silx/gui/plot/Profile.py +++ b/silx/gui/plot/Profile.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 @@ -28,7 +28,7 @@ and stacks of images""" __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel", "H. Payno"] __license__ = "MIT" -__date__ = "17/08/2017" +__date__ = "24/04/2018" import weakref @@ -40,7 +40,7 @@ from silx.image.bilinear import BilinearImage from .. import icons from .. import qt from . import items -from .Colors import cursorColorForColormap +from ..colors import cursorColorForColormap from . import actions from .PlotToolButtons import ProfileToolButton from .ProfileMainWindow import ProfileMainWindow @@ -637,6 +637,12 @@ class ProfileToolBar(qt.QToolBar): colormap=colormap) else: coords = numpy.arange(len(profile[0]), dtype=numpy.float32) + # Scale horizontal and vertical profile coordinates + if self._roiInfo[2] == 'X': + coords = coords * scale[0] + origin[0] + elif self._roiInfo[2] == 'Y': + coords = coords * scale[1] + origin[1] + self.getProfilePlot().addCurve(coords, profile[0], legend=profileName, diff --git a/silx/gui/plot/ProfileMainWindow.py b/silx/gui/plot/ProfileMainWindow.py index 835de2c..3738511 100644 --- a/silx/gui/plot/ProfileMainWindow.py +++ b/silx/gui/plot/ProfileMainWindow.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 @@ -73,6 +73,8 @@ class ProfileMainWindow(qt.QMainWindow): self._plot2D.setParent(None) # necessary to avoid widget destruction if self._plot1D is None: self._plot1D = Plot1D() + self._plot1D.setGraphYLabel('Profile') + self._plot1D.setGraphXLabel('') self.setCentralWidget(self._plot1D) elif self._profileType == "2D": if self._plot1D is not None: diff --git a/silx/gui/plot/ScatterMaskToolsWidget.py b/silx/gui/plot/ScatterMaskToolsWidget.py index a9c1073..2a10f6d 100644 --- a/silx/gui/plot/ScatterMaskToolsWidget.py +++ b/silx/gui/plot/ScatterMaskToolsWidget.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# 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 @@ -35,7 +35,7 @@ from __future__ import division __authors__ = ["P. Knobel"] __license__ = "MIT" -__date__ = "07/04/2017" +__date__ = "24/04/2018" import math @@ -45,10 +45,11 @@ import numpy import sys from .. import qt +from ...math.combo import min_max from ...image import shapes from ._BaseMaskToolsWidget import BaseMask, BaseMaskToolsWidget, BaseMaskToolsDockWidget -from .Colors import cursorColorForColormap, rgba +from ..colors import cursorColorForColormap, rgba _logger = logging.getLogger(__name__) @@ -186,13 +187,18 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): self._z = 2 # Mask layer in plot self._data_scatter = None """plot Scatter item for data""" + + self._data_extent = None + """Maximum extent of the data i.e., max(xMax-xMin, yMax-yMin)""" + self._mask_scatter = None """plot Scatter item for representing the mask""" def setSelectionMask(self, mask, copy=True): """Set the mask to a new array. - :param numpy.ndarray mask: The array to use for the mask. + :param numpy.ndarray mask: + The array to use for the mask or None to reset the mask. :type mask: numpy.ndarray of uint8, C-contiguous. Array of other types are converted. :param bool copy: True (the default) to copy the array, @@ -201,6 +207,10 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): The mask can be cropped or padded to fit active scatter, the returned shape is that of the scatter data. """ + if mask is None: + self.resetSelectionMask() + return self._data_scatter.getXData(copy=False).shape + mask = numpy.array(mask, copy=False, dtype=numpy.uint8) if self._data_scatter.getXData(copy=False).shape == (0,) \ @@ -216,7 +226,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): def _updatePlotMask(self): """Update mask image in plot""" mask = self.getSelectionMask(copy=False) - if len(mask): + if mask is not None: self.plot.addScatter(self._data_scatter.getXData(), self._data_scatter.getYData(), mask, @@ -226,8 +236,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): self._mask_scatter = self.plot._getItem(kind="scatter", legend=self._maskName) self._mask_scatter.setSymbolSize( - self._data_scatter.getSymbolSize() * 4.0 - ) + self._data_scatter.getSymbolSize() + 2.0) elif self.plot._getItem(kind="scatter", legend=self._maskName) is not None: self.plot.remove(self._maskName, kind='scatter') @@ -248,7 +257,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): if not self.browseAction.isChecked(): self.browseAction.trigger() # Disable drawing tool - if len(self.getSelectionMask(copy=False)): + if self.getSelectionMask(copy=False) is not None: self.plot.sigActiveScatterChanged.connect( self._activeScatterChangedAfterCare) @@ -265,6 +274,9 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): # No active scatter or active scatter is the mask... self.plot.sigActiveScatterChanged.disconnect( self._activeScatterChangedAfterCare) + self._data_extent = None + self._data_scatter = None + else: colormap = activeScatter.getColormap() self._defaultOverlayColor = rgba(cursorColorForColormap(colormap['name'])) @@ -274,13 +286,22 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): self._z = activeScatter.getZValue() + 1 self._data_scatter = activeScatter - if self._data_scatter.getXData(copy=False).shape != self.getSelectionMask(copy=False).shape: + + # 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) + + 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 if self.plot._getItem(kind="scatter", legend=self._maskName): self.plot.remove(self._maskName, kind='scatter') self.plot.sigActiveScatterChanged.disconnect( self._activeScatterChangedAfterCare) + self._data_extent = None + self._data_scatter = None + else: # Refresh in case z changed self._mask.setDataItem(self._data_scatter) @@ -295,6 +316,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): self.setEnabled(False) self._data_scatter = None + self._data_extent = None self._mask.reset() self._mask.commit() @@ -309,8 +331,19 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): 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._mask.setDataItem(self._data_scatter) - if self._data_scatter.getXData(copy=False).shape != self.getSelectionMask(copy=False).shape: + if self._data_scatter.getXData(copy=False).shape != self._mask.getMask(copy=False).shape: self._mask.reset(self._data_scatter.getXData(copy=False).shape) self._mask.commit() else: @@ -439,6 +472,16 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): shape=self._data_scatter.getXData(copy=False).shape) self._mask.commit() + def _getPencilWidth(self): + """Returns the width of the pencil to use in data coordinates` + + :rtype: float + """ + width = super(ScatterMaskToolsWidget, self)._getPencilWidth() + if self._data_extent is not None: + width *= 0.01 * self._data_extent + return width + def _plotDrawEvent(self, event): """Handle draw events from the plot""" if (self._drawingMode is None or @@ -467,7 +510,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): event['event'] == 'drawingFinished'): doMask = self._isMasking() vertices = event['points'] - vertices = vertices.astype(numpy.int)[:, (1, 0)] # (y, x) + vertices = vertices[:, (1, 0)] # (y, x) self._mask.updatePolygon(level, vertices, doMask) self._mask.commit() @@ -475,7 +518,8 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget): doMask = self._isMasking() # convert from plot to array coords x, y = event['points'][-1] - brushSize = self.pencilSpinBox.value() + + brushSize = self._getPencilWidth() if self._lastPencilPos != (y, x): if self._lastPencilPos is not None: diff --git a/silx/gui/plot/ScatterView.py b/silx/gui/plot/ScatterView.py new file mode 100644 index 0000000..f830cb3 --- /dev/null +++ b/silx/gui/plot/ScatterView.py @@ -0,0 +1,353 @@ +# 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 display scatter plots + +It is based on a :class:`~silx.gui.plot.PlotWidget` with additional tools +for scatter plots. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "14/06/2018" + + +import logging +import weakref + +import numpy + +from . import items +from . import PlotWidget +from . import tools +from .tools.profile import ScatterProfileToolBar +from .ColorBar import ColorBarWidget +from .ScatterMaskToolsWidget import ScatterMaskToolsWidget + +from ..widgets.BoxLayoutDockWidget import BoxLayoutDockWidget +from .. import qt, icons + + +_logger = logging.getLogger(__name__) + + +class ScatterView(qt.QMainWindow): + """Main window with a PlotWidget and tools specific for scatter plots. + + :param parent: The parent of this widget + :param backend: The backend to use for the plot (default: matplotlib). + See :class:`~silx.gui.plot.PlotWidget` for the list of supported backend. + :type backend: Union[str,~silx.gui.plot.backends.BackendBase.BackendBase] + """ + + _SCATTER_LEGEND = ' ' + """Legend used for the scatter item""" + + def __init__(self, parent=None, backend=None): + super(ScatterView, self).__init__(parent=parent) + if parent is not None: + # behave as a widget + self.setWindowFlags(qt.Qt.Widget) + else: + self.setWindowTitle('ScatterView') + + # Create plot widget + plot = PlotWidget(parent=self, backend=backend) + self._plot = weakref.ref(plot) + + # Add an empty scatter + plot.addScatter(x=(), y=(), value=(), legend=self._SCATTER_LEGEND) + + # Create colorbar widget with white background + self._colorbar = ColorBarWidget(parent=self, plot=plot) + self._colorbar.setAutoFillBackground(True) + palette = self._colorbar.palette() + palette.setColor(qt.QPalette.Background, qt.Qt.white) + palette.setColor(qt.QPalette.Window, qt.Qt.white) + self._colorbar.setPalette(palette) + + # Create PositionInfo widget + self.__lastPickingPos = None + self.__pickingCache = None + self._positionInfo = tools.PositionInfo( + plot=plot, + converters=(('X', lambda x, y: x), + ('Y', lambda x, y: y), + ('Data', lambda x, y: self._getScatterValue(x, y)), + ('Index', lambda x, y: self._getScatterIndex(x, y)))) + + # Combine plot, position info and colorbar into central widget + gridLayout = qt.QGridLayout() + gridLayout.setSpacing(0) + gridLayout.setContentsMargins(0, 0, 0, 0) + gridLayout.addWidget(plot, 0, 0) + gridLayout.addWidget(self._colorbar, 0, 1) + gridLayout.addWidget(self._positionInfo, 1, 0, 1, -1) + gridLayout.setRowStretch(0, 1) + gridLayout.setColumnStretch(0, 1) + centralWidget = qt.QWidget(self) + centralWidget.setLayout(gridLayout) + self.setCentralWidget(centralWidget) + + # Create mask tool dock widget + self._maskToolsWidget = ScatterMaskToolsWidget(parent=self, plot=plot) + self._maskDock = BoxLayoutDockWidget() + self._maskDock.setWindowTitle('Scatter Mask') + self._maskDock.setWidget(self._maskToolsWidget) + self._maskDock.setVisible(False) + self.addDockWidget(qt.Qt.BottomDockWidgetArea, self._maskDock) + + self._maskAction = self._maskDock.toggleViewAction() + self._maskAction.setIcon(icons.getQIcon('image-mask')) + self._maskAction.setToolTip("Display/hide mask tools") + + # Create toolbars + self._interactiveModeToolBar = tools.InteractiveModeToolBar( + parent=self, plot=plot) + + self._scatterToolBar = tools.ScatterToolBar( + parent=self, plot=plot) + self._scatterToolBar.addAction(self._maskAction) + + self._profileToolBar = ScatterProfileToolBar(parent=self, plot=plot) + + self._outputToolBar = tools.OutputToolBar(parent=self, plot=plot) + + # Activate shortcuts in PlotWindow widget: + for toolbar in (self._interactiveModeToolBar, + self._scatterToolBar, + self._profileToolBar, + self._outputToolBar): + self.addToolBar(toolbar) + for action in toolbar.actions(): + self.addAction(action) + + def _pickScatterData(self, x, y): + """Get data and index and value of top most scatter plot at position (x, y) + + :param float x: X position in plot coordinates + :param float y: Y position in plot coordinates + :return: The data index and value at that point or None + """ + pickingPos = x, y + if self.__lastPickingPos != pickingPos: + self.__pickingCache = None + self.__lastPickingPos = pickingPos + + plot = self.getPlotWidget() + if plot is not None: + pixelPos = plot.dataToPixel(x, y) + if pixelPos is not None: + # Start from top-most item + for item, indices in reversed(plot._pick(*pixelPos)): + if isinstance(item, items.Scatter): + # Get last index + # with matplotlib it should be the top-most point + dataIndex = indices[-1] + self.__pickingCache = ( + dataIndex, + item.getValueData(copy=False)[dataIndex]) + break + + return self.__pickingCache + + def _getScatterValue(self, x, y): + """Get data value of top most scatter plot at position (x, y) + + :param float x: X position in plot coordinates + :param float y: Y position in plot coordinates + :return: The data value at that point or '-' + """ + picking = self._pickScatterData(x, y) + return '-' if picking is None else picking[1] + + def _getScatterIndex(self, x, y): + """Get data index of top most scatter plot at position (x, y) + + :param float x: X position in plot coordinates + :param float y: Y position in plot coordinates + :return: The data index at that point or '-' + """ + picking = self._pickScatterData(x, y) + return '-' if picking is None else picking[0] + + _PICK_OFFSET = 3 # Offset in pixel used for picking + + def _mouseInPlotArea(self, x, y): + """Clip mouse coordinates to plot area coordinates + + :param float x: X position in pixels + :param float y: Y position in pixels + :return: (x, y) in data coordinates + """ + plot = self.getPlotWidget() + left, top, width, height = plot.getPlotBoundsInPixels() + xPlot = numpy.clip(x, left, left + width - 1) + yPlot = numpy.clip(y, top, top + height - 1) + return xPlot, yPlot + + def getPlotWidget(self): + """Returns the :class:`~silx.gui.plot.PlotWidget` this window is based on. + + :rtype: ~silx.gui.plot.PlotWidget + """ + return self._plot() + + def getPositionInfoWidget(self): + """Returns the widget display mouse coordinates information. + + :rtype: ~silx.gui.plot.tools.PositionInfo + """ + return self._positionInfo + + def getMaskToolsWidget(self): + """Returns the widget controlling mask drawing + + :rtype: ~silx.gui.plot.ScatterMaskToolsWidget + """ + return self._maskToolsWidget + + def getInteractiveModeToolBar(self): + """Returns QToolBar controlling interactive mode. + + :rtype: ~silx.gui.plot.tools.InteractiveModeToolBar + """ + return self._interactiveModeToolBar + + def getScatterToolBar(self): + """Returns QToolBar providing scatter plot tools. + + :rtype: ~silx.gui.plot.tools.ScatterToolBar + """ + return self._scatterToolBar + + def getScatterProfileToolBar(self): + """Returns QToolBar providing scatter profile tools. + + :rtype: ~silx.gui.plot.tools.profile.ScatterProfileToolBar + """ + return self._profileToolBar + + def getOutputToolBar(self): + """Returns QToolBar containing save, copy and print actions + + :rtype: ~silx.gui.plot.tools.OutputToolBar + """ + return self._outputToolBar + + def setColormap(self, colormap=None): + """Set the colormap for the displayed scatter and the + default plot colormap. + + :param ~silx.gui.colors.Colormap colormap: + The description of the colormap. + """ + self.getScatterItem().setColormap(colormap) + # Resilient to call to PlotWidget API (e.g., clear) + self.getPlotWidget().setDefaultColormap(colormap) + + def getColormap(self): + """Return the :class:`.Colormap` in use. + + :return: Colormap currently in use + :rtype: ~silx.gui.colors.Colormap + """ + self.getScatterItem().getColormap() + + # Control displayed scatter plot + + def setData(self, x, y, value, xerror=None, yerror=None, copy=True): + """Set the data of the scatter plot. + + To reset the scatter plot, set x, y and value to None. + + :param Union[numpy.ndarray,None] x: X coordinates. + :param Union[numpy.ndarray,None] y: Y coordinates. + :param Union[numpy.ndarray,None] value: + The data corresponding to the value of the data points. + :param xerror: Values with the uncertainties on the x values. + If it is an array, it can either be a 1D array of + same length as the data or a 2D array with 2 rows + of same length as the data: row 0 for positive errors, + row 1 for negative errors. + :type xerror: A float, or a numpy.ndarray of float32. + + :param yerror: Values with the uncertainties on the y values + :type yerror: A float, or a numpy.ndarray of float32. See xerror. + :param bool copy: True make a copy of the data (default), + False to use provided arrays. + """ + x = () if x is None else x + y = () if y is None else y + value = () if value is None else value + + self.getScatterItem().setData( + x=x, y=y, value=value, xerror=xerror, yerror=yerror, copy=copy) + + def getData(self, *args, **kwargs): + return self.getScatterItem().getData(*args, **kwargs) + + getData.__doc__ = items.Scatter.getData.__doc__ + + def getScatterItem(self): + """Returns the plot item displaying the scatter data. + + This allows to set the style of the displayed scatter. + + :rtype: ~silx.gui.plot.items.Scatter + """ + plot = self.getPlotWidget() + scatter = plot._getItem(kind='scatter', legend=self._SCATTER_LEGEND) + if scatter is None: # Resilient to call to PlotWidget API (e.g., clear) + plot.addScatter(x=(), y=(), value=(), legend=self._SCATTER_LEGEND) + scatter = plot._getItem( + kind='scatter', legend=self._SCATTER_LEGEND) + return scatter + + # Convenient proxies + + def getXAxis(self, *args, **kwargs): + return self.getPlotWidget().getXAxis(*args, **kwargs) + + getXAxis.__doc__ = PlotWidget.getXAxis.__doc__ + + def getYAxis(self, *args, **kwargs): + return self.getPlotWidget().getYAxis(*args, **kwargs) + + getYAxis.__doc__ = PlotWidget.getYAxis.__doc__ + + def setGraphTitle(self, *args, **kwargs): + return self.getPlotWidget().setGraphTitle(*args, **kwargs) + + setGraphTitle.__doc__ = PlotWidget.setGraphTitle.__doc__ + + def getGraphTitle(self, *args, **kwargs): + return self.getPlotWidget().getGraphTitle(*args, **kwargs) + + getGraphTitle.__doc__ = PlotWidget.getGraphTitle.__doc__ + + def resetZoom(self, *args, **kwargs): + return self.getPlotWidget().resetZoom(*args, **kwargs) + + resetZoom.__doc__ = PlotWidget.resetZoom.__doc__ diff --git a/silx/gui/plot/StackView.py b/silx/gui/plot/StackView.py index 1fb188c..d1e8e3c 100644 --- a/silx/gui/plot/StackView.py +++ b/silx/gui/plot/StackView.py @@ -69,16 +69,18 @@ Example:: __authors__ = ["P. Knobel", "H. Payno"] __license__ = "MIT" -__date__ = "15/02/2018" +__date__ = "26/04/2018" import numpy +import logging +import silx from silx.gui import qt from .. import icons from . import items, PlotWindow, actions -from .Colormap import Colormap -from .Colors import cursorColorForColormap -from .PlotTools import LimitsToolBar +from ..colors import Colormap +from ..colors import cursorColorForColormap +from .tools import LimitsToolBar from .Profile import Profile3DToolBar from ..widgets.FrameBrowser import HorizontalSliderWithBrowser @@ -96,6 +98,8 @@ except ImportError: else: from silx.io.utils import is_dataset +_logger = logging.getLogger(__name__) + class StackView(qt.QMainWindow): """Stack view widget, to display and browse through stack of @@ -156,6 +160,12 @@ class StackView(qt.QMainWindow): integer. """ + sigFrameChanged = qt.Signal(int) + """Signal emitter when the frame number has changed. + + This signal provides the current frame number. + """ + def __init__(self, parent=None, resetzoom=True, backend=None, autoScale=False, logScale=False, grid=False, colormap=True, aspectRatio=True, yinverted=True, @@ -206,6 +216,9 @@ class StackView(qt.QMainWindow): self.sigActiveImageChanged = self._plot.sigActiveImageChanged self.sigPlotSignal = self._plot.sigPlotSignal + if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward': + self._plot.getYAxis().setInverted(True) + self._addColorBarAction() self._plot.profile = Profile3DToolBar(parent=self._plot, @@ -221,6 +234,7 @@ class StackView(qt.QMainWindow): self._browser_label = qt.QLabel("Image index (Dim0):") self._browser = HorizontalSliderWithBrowser(central_widget) + self._browser.setRange(0, 0) self._browser.valueChanged[int].connect(self.__updateFrameNumber) self._browser.setEnabled(False) @@ -313,7 +327,7 @@ class StackView(qt.QMainWindow): assert self._stack is not None assert 0 <= self._perspective < 3 - # ensure we have the stack encapsulated in an array like object + # ensure we have the stack encapsulated in an array-like object # having a transpose() method if isinstance(self._stack, numpy.ndarray): self.__transposed_view = self._stack @@ -324,7 +338,7 @@ class StackView(qt.QMainWindow): elif isinstance(self._stack, ListOfImages): self.__transposed_view = ListOfImages(self._stack) - # transpose the array like object if necessary + # transpose the array-like object if necessary if self._perspective == 1: self.__transposed_view = self.__transposed_view.transpose((1, 0, 2)) elif self._perspective == 2: @@ -338,13 +352,16 @@ class StackView(qt.QMainWindow): :param index: index of the frame to be displayed """ - assert self.__transposed_view is not None + if self.__transposed_view is None: + # no data set + return self._plot.addImage(self.__transposed_view[index, :, :], origin=self._getImageOrigin(), scale=self._getImageScale(), legend=self.__imageLegend, - resetzoom=False, replace=False) + resetzoom=False) self._updateTitle() + self.sigFrameChanged.emit(index) def _set3DScaleAndOrigin(self, calibrations): """Set scale and origin for all 3 axes, to be used when plotting @@ -358,7 +375,7 @@ class StackView(qt.QMainWindow): calibration.NoCalibration()) else: self.calibrations3D = [] - for calib in calibrations: + for i, calib in enumerate(calibrations): if hasattr(calib, "__len__") and len(calib) == 2: calib = calibration.LinearCalibration(calib[0], calib[1]) elif calib is None: @@ -367,9 +384,19 @@ class StackView(qt.QMainWindow): raise TypeError("calibration must be a 2-tuple, None or" + " an instance of an AbstractCalibration " + "subclass") + elif not calib.is_affine(): + _logger.warning( + "Calibration for dimension %d is not linear, " + "it will be ignored for scaling the graph axes.", + i) self.calibrations3D.append(calib) def _getXYZCalibs(self): + """Return calibrations sorted in the XYZ graph order. + + 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) @@ -377,6 +404,12 @@ class StackView(qt.QMainWindow): ycalib = self.calibrations3D[min(xy_dims)] zcalib = self.calibrations3D[self._perspective] + # filter out non-linear calibration for graph axes + if not xcalib.is_affine(): + xcalib = calibration.NoCalibration() + if not ycalib.is_affine(): + ycalib = calibration.NoCalibration() + return xcalib, ycalib, zcalib def _getImageScale(self): @@ -469,6 +502,7 @@ class StackView(qt.QMainWindow): colormap=self.getColormap(), origin=self._getImageOrigin(), scale=self._getImageScale(), + replace=True, resetzoom=False) self._plot.setActiveImage(self.__imageLegend) self._plot.setGraphTitle("Image z=%g" % self._getImageZ(0)) @@ -586,6 +620,14 @@ class StackView(qt.QMainWindow): """ self._browser.setValue(number) + def getFrameNumber(self): + """Set the frame selection to a specific value + + :return: Index of currently displayed frame + :rtype: int + """ + return self._browser.value() + def setFirstStackDimension(self, first_stack_dimension): """When viewing the last 3 dimensions of an n-D array (n>3), you can use this method to change the text in the combobox. @@ -641,6 +683,8 @@ class StackView(qt.QMainWindow): self.__transposed_view = None self._perspective = 0 self._browser.setEnabled(False) + # reset browser range + self._browser.setRange(0, 0) self._plot.clear() def setLabels(self, labels=None): @@ -1101,17 +1145,17 @@ class StackViewMainWindow(StackView): self.statusBar() menu = self.menuBar().addMenu('File') - menu.addAction(self._plot.saveAction) - menu.addAction(self._plot.printAction) + menu.addAction(self._plot.getOutputToolBar().getSaveAction()) + menu.addAction(self._plot.getOutputToolBar().getPrintAction()) menu.addSeparator() action = menu.addAction('Quit') action.triggered[bool].connect(qt.QApplication.instance().quit) menu = self.menuBar().addMenu('Edit') - menu.addAction(self._plot.copyAction) + menu.addAction(self._plot.getOutputToolBar().getCopyAction()) menu.addSeparator() - menu.addAction(self._plot.resetZoomAction) - menu.addAction(self._plot.colormapAction) + menu.addAction(self._plot.getResetZoomAction()) + menu.addAction(self._plot.getColormapAction()) menu.addAction(self.getColorBarAction()) menu.addAction(actions.control.KeepAspectRatioAction(self._plot, self)) diff --git a/silx/gui/plot/StatsWidget.py b/silx/gui/plot/StatsWidget.py new file mode 100644 index 0000000..a36dd9f --- /dev/null +++ b/silx/gui/plot/StatsWidget.py @@ -0,0 +1,572 @@ +# 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. +# +# ###########################################################################*/ +""" +Module containing widgets displaying stats from items of a plot. +""" + +__authors__ = ["H. Payno"] +__license__ = "MIT" +__date__ = "12/06/2018" + + +import functools +import logging +import numpy +from collections import OrderedDict + +import silx.utils.weakref +from silx.gui import qt +from silx.gui import icons +from silx.gui.plot.items.curve import Curve as CurveItem +from silx.gui.plot.items.histogram import Histogram as HistogramItem +from silx.gui.plot.items.image import ImageBase as ImageItem +from silx.gui.plot.items.scatter import Scatter as ScatterItem +from silx.gui.plot import stats as statsmdl +from silx.gui.widgets.TableWidget import TableWidget +from silx.gui.plot.stats.statshandler import StatsHandler, StatFormatter + +logger = logging.getLogger(__name__) + + +class StatsWidget(qt.QWidget): + """ + Widget displaying a set of :class:`Stat` to be displayed on a + :class:`StatsTable` and to be apply on items contained in the :class:`Plot` + Also contains options to: + + * compute statistics on all the data or on visible data only + * show statistics of all items or only the active one + + :param parent: Qt parent + :param plot: the plot containing items on which we want statistics. + """ + + NUMBER_FORMAT = '{0:.3f}' + + class OptionsWidget(qt.QToolBar): + + def __init__(self, parent=None): + qt.QToolBar.__init__(self, parent) + self.setIconSize(qt.QSize(16, 16)) + + action = qt.QAction(self) + action.setIcon(icons.getQIcon("stats-active-items")) + action.setText("Active items only") + action.setToolTip("Display stats for active items only.") + action.setCheckable(True) + action.setChecked(True) + self.__displayActiveItems = action + + action = qt.QAction(self) + action.setIcon(icons.getQIcon("stats-whole-items")) + action.setText("All items") + action.setToolTip("Display stats for all available items.") + action.setCheckable(True) + self.__displayWholeItems = action + + action = qt.QAction(self) + action.setIcon(icons.getQIcon("stats-visible-data")) + action.setText("Use the visible data range") + action.setToolTip("Use the visible data range.<br/>" + "If activated the data is filtered to only use" + "visible data of the plot." + "The filtering is a data sub-sampling." + "No interpolation is made to fit data to" + "boundaries.") + action.setCheckable(True) + self.__useVisibleData = action + + action = qt.QAction(self) + action.setIcon(icons.getQIcon("stats-whole-data")) + action.setText("Use the full data range") + action.setToolTip("Use the full data range.") + action.setCheckable(True) + action.setChecked(True) + self.__useWholeData = action + + self.addAction(self.__displayWholeItems) + self.addAction(self.__displayActiveItems) + self.addSeparator() + self.addAction(self.__useVisibleData) + self.addAction(self.__useWholeData) + + self.itemSelection = qt.QActionGroup(self) + self.itemSelection.setExclusive(True) + self.itemSelection.addAction(self.__displayActiveItems) + self.itemSelection.addAction(self.__displayWholeItems) + + self.dataRangeSelection = qt.QActionGroup(self) + self.dataRangeSelection.setExclusive(True) + self.dataRangeSelection.addAction(self.__useWholeData) + self.dataRangeSelection.addAction(self.__useVisibleData) + + def isActiveItemMode(self): + return self.itemSelection.checkedAction() is self.__displayActiveItems + + def isVisibleDataRangeMode(self): + return self.dataRangeSelection.checkedAction() is self.__useVisibleData + + def __init__(self, parent=None, plot=None, stats=None): + qt.QWidget.__init__(self, parent) + self.setLayout(qt.QVBoxLayout()) + self.layout().setContentsMargins(0, 0, 0, 0) + self._options = self.OptionsWidget(parent=self) + self.layout().addWidget(self._options) + self._statsTable = StatsTable(parent=self, plot=plot) + self.setStats = self._statsTable.setStats + self.setStats(stats) + + self.layout().addWidget(self._statsTable) + self.setPlot = self._statsTable.setPlot + + self._options.itemSelection.triggered.connect( + self._optSelectionChanged) + self._options.dataRangeSelection.triggered.connect( + self._optDataRangeChanged) + self._optSelectionChanged() + self._optDataRangeChanged() + + self.setDisplayOnlyActiveItem = self._statsTable.setDisplayOnlyActiveItem + self.setStatsOnVisibleData = self._statsTable.setStatsOnVisibleData + + def _optSelectionChanged(self, action=None): + self._statsTable.setDisplayOnlyActiveItem(self._options.isActiveItemMode()) + + def _optDataRangeChanged(self, action=None): + self._statsTable.setStatsOnVisibleData(self._options.isVisibleDataRangeMode()) + + +class BasicStatsWidget(StatsWidget): + """ + Widget defining a simple set of :class:`Stat` to be displayed on a + :class:`StatsWidget`. + + :param parent: Qt parent + :param plot: the plot containing items on which we want statistics. + """ + + STATS = StatsHandler(( + (statsmdl.StatMin(), StatFormatter()), + statsmdl.StatCoordMin(), + (statsmdl.StatMax(), StatFormatter()), + statsmdl.StatCoordMax(), + (('std', numpy.std), StatFormatter()), + (('mean', numpy.mean), StatFormatter()), + statsmdl.StatCOM() + )) + + def __init__(self, parent=None, plot=None): + StatsWidget.__init__(self, parent=parent, plot=plot, stats=self.STATS) + + +class StatsTable(TableWidget): + """ + TableWidget displaying for each curves contained by the Plot some + information: + + * legend + * minimal value + * maximal value + * standard deviation (std) + + :param parent: The widget's parent. + :param plot: :class:`.PlotWidget` instance on which to operate + """ + + COMPATIBLE_KINDS = { + 'curve': CurveItem, + 'image': ImageItem, + 'scatter': ScatterItem, + 'histogram': HistogramItem + } + + COMPATIBLE_ITEMS = tuple(COMPATIBLE_KINDS.values()) + + def __init__(self, parent=None, plot=None): + TableWidget.__init__(self, parent) + """Next freeID for the curve""" + self.plot = None + self._displayOnlyActItem = False + self._statsOnVisibleData = False + self._lgdAndKindToItems = {} + """Associate to a tuple(legend, kind) the items legend""" + self.callbackImage = None + self.callbackScatter = None + self.callbackCurve = None + """Associate the curve legend to his first item""" + self._statsHandler = None + self._legendsSet = [] + """list of legends actually displayed""" + self._resetColumns() + + self.setColumnCount(len(self._columns)) + self.setSelectionBehavior(qt.QAbstractItemView.SelectRows) + self.setPlot(plot) + self.setSortingEnabled(True) + + def _resetColumns(self): + self._columns_index = OrderedDict([('legend', 0), ('kind', 1)]) + self._columns = self._columns_index.keys() + self.setColumnCount(len(self._columns)) + + def setStats(self, statsHandler): + """ + + :param statsHandler: Set the statistics to be displayed and how to + format them using + :rtype: :class:`StatsHandler` + """ + _statsHandler = statsHandler + if statsHandler is None: + _statsHandler = StatsHandler(statFormatters=()) + if isinstance(_statsHandler, (list, tuple)): + _statsHandler = StatsHandler(_statsHandler) + assert isinstance(_statsHandler, StatsHandler) + self._resetColumns() + self.clear() + + for statName, stat in list(_statsHandler.stats.items()): + assert isinstance(stat, statsmdl.StatBase) + self._columns_index[statName] = len(self._columns_index) + self._statsHandler = _statsHandler + self._columns = self._columns_index.keys() + self.setColumnCount(len(self._columns)) + + self._updateItemObserve() + self._updateAllStats() + + def getStatsHandler(self): + return self._statsHandler + + def _updateAllStats(self): + for (legend, kind) in self._lgdAndKindToItems: + self._updateStats(legend, kind) + + @staticmethod + def _getKind(myItem): + if isinstance(myItem, CurveItem): + return 'curve' + elif isinstance(myItem, ImageItem): + return 'image' + elif isinstance(myItem, ScatterItem): + return 'scatter' + elif isinstance(myItem, HistogramItem): + return 'histogram' + else: + return None + + def setPlot(self, plot): + """ + Define the plot to interact with + + :param plot: the plot containing the items on which statistics are + applied + :rtype: :class:`.PlotWidget` + """ + if self.plot: + self._dealWithPlotConnection(create=False) + self.plot = plot + self.clear() + if self.plot: + self._dealWithPlotConnection(create=True) + self._updateItemObserve() + + def _updateItemObserve(self): + if self.plot: + self.clear() + if self._displayOnlyActItem is True: + activeCurve = self.plot.getActiveCurve(just_legend=False) + activeScatter = self.plot._getActiveItem(kind='scatter', + just_legend=False) + activeImage = self.plot.getActiveImage(just_legend=False) + if activeCurve: + self._addItem(activeCurve) + if activeImage: + self._addItem(activeImage) + if activeScatter: + self._addItem(activeScatter) + else: + [self._addItem(curve) for curve in self.plot.getAllCurves()] + [self._addItem(image) for image in self.plot.getAllImages()] + scatters = self.plot._getItems(kind='scatter', + just_legend=False, + withhidden=True) + [self._addItem(scatter) for scatter in scatters] + histograms = self.plot._getItems(kind='histogram', + just_legend=False, + withhidden=True) + [self._addItem(histogram) for histogram in histograms] + + def _dealWithPlotConnection(self, create=True): + """ + Manage connection to plot signals + + Note: connection on Item are managed by the _removeItem function + """ + if self.plot is None: + return + if self._displayOnlyActItem: + if create is True: + if self.callbackImage is None: + self.callbackImage = functools.partial(self._activeItemChanged, 'image') + self.callbackScatter = functools.partial(self._activeItemChanged, 'scatter') + self.callbackCurve = functools.partial(self._activeItemChanged, 'curve') + self.plot.sigActiveImageChanged.connect(self.callbackImage) + self.plot.sigActiveScatterChanged.connect(self.callbackScatter) + self.plot.sigActiveCurveChanged.connect(self.callbackCurve) + else: + if self.callbackImage is not None: + self.plot.sigActiveImageChanged.disconnect(self.callbackImage) + self.plot.sigActiveScatterChanged.disconnect(self.callbackScatter) + self.plot.sigActiveCurveChanged.disconnect(self.callbackCurve) + self.callbackImage = None + self.callbackScatter = None + self.callbackCurve = None + else: + if create is True: + self.plot.sigContentChanged.connect(self._plotContentChanged) + else: + self.plot.sigContentChanged.disconnect(self._plotContentChanged) + if create is True: + self.plot.sigPlotSignal.connect(self._zoomPlotChanged) + else: + self.plot.sigPlotSignal.disconnect(self._zoomPlotChanged) + + def clear(self): + """ + Clear all existing items + """ + lgdsAndKinds = list(self._lgdAndKindToItems.keys()) + for lgdAndKind in lgdsAndKinds: + self._removeItem(legend=lgdAndKind[0], kind=lgdAndKind[1]) + self._lgdAndKindToItems = {} + qt.QTableWidget.clear(self) + self.setRowCount(0) + + # It have to called befor3e accessing to the header items + self.setHorizontalHeaderLabels(self._columns) + + if self._statsHandler is not None: + for columnId, name in enumerate(self._columns): + item = self.horizontalHeaderItem(columnId) + if name in self._statsHandler.stats: + stat = self._statsHandler.stats[name] + text = stat.name[0].upper() + stat.name[1:] + if stat.description is not None: + tooltip = stat.description + else: + tooltip = "" + else: + text = name[0].upper() + name[1:] + tooltip = "" + item.setToolTip(tooltip) + item.setText(text) + + if hasattr(self.horizontalHeader(), 'setSectionResizeMode'): # Qt5 + self.horizontalHeader().setSectionResizeMode(qt.QHeaderView.ResizeToContents) + else: # Qt4 + self.horizontalHeader().setResizeMode(qt.QHeaderView.ResizeToContents) + self.setColumnHidden(self._columns_index['kind'], True) + + def _addItem(self, item): + assert isinstance(item, self.COMPATIBLE_ITEMS) + if (item.getLegend(), self._getKind(item)) in self._lgdAndKindToItems: + self._updateStats(item.getLegend(), self._getKind(item)) + return + + self.setRowCount(self.rowCount() + 1) + indexTable = self.rowCount() - 1 + kind = self._getKind(item) + + self._lgdAndKindToItems[(item.getLegend(), kind)] = {} + + # the get item will manage the item creation of not existing + _createItem = self._getItem + for itemName in self._columns: + _createItem(name=itemName, legend=item.getLegend(), kind=kind, + indexTable=indexTable) + + self._updateStats(legend=item.getLegend(), kind=kind) + + callback = functools.partial( + silx.utils.weakref.WeakMethodProxy(self._updateStats), + item.getLegend(), kind) + item.sigItemChanged.connect(callback) + self.setColumnHidden(self._columns_index['kind'], + item.getLegend() not in self._legendsSet) + self._legendsSet.append(item.getLegend()) + + def _getItem(self, name, legend, kind, indexTable): + if (legend, kind) not in self._lgdAndKindToItems: + self._lgdAndKindToItems[(legend, kind)] = {} + if not (name in self._lgdAndKindToItems[(legend, kind)] and + self._lgdAndKindToItems[(legend, kind)]): + if name in ('legend', 'kind'): + _item = qt.QTableWidgetItem(type=qt.QTableWidgetItem.Type) + if name == 'legend': + _item.setText(legend) + else: + assert name == 'kind' + _item.setText(kind) + else: + if self._statsHandler.formatters[name]: + _item = self._statsHandler.formatters[name].tabWidgetItemClass() + else: + _item = qt.QTableWidgetItem() + tooltip = self._statsHandler.stats[name].getToolTip(kind=kind) + if tooltip is not None: + _item.setToolTip(tooltip) + + _item.setFlags(qt.Qt.ItemIsEnabled | qt.Qt.ItemIsSelectable) + self.setItem(indexTable, self._columns_index[name], _item) + self._lgdAndKindToItems[(legend, kind)][name] = _item + + return self._lgdAndKindToItems[(legend, kind)][name] + + def _removeItem(self, legend, kind): + if (legend, kind) not in self._lgdAndKindToItems or not self.plot: + return + + self.firstItem = self._lgdAndKindToItems[(legend, kind)]['legend'] + del self._lgdAndKindToItems[(legend, kind)] + self.removeRow(self.firstItem.row()) + self._legendsSet.remove(legend) + self.setColumnHidden(self._columns_index['kind'], + legend not in self._legendsSet) + + def _updateCurrentStats(self): + for lgdAndKind in self._lgdAndKindToItems: + self._updateStats(lgdAndKind[0], lgdAndKind[1]) + + def _updateStats(self, legend, kind, event=None): + if self._statsHandler is None: + return + + assert kind in ('curve', 'image', 'scatter', 'histogram') + if kind == 'curve': + item = self.plot.getCurve(legend) + elif kind == 'image': + item = self.plot.getImage(legend) + elif kind == 'scatter': + item = self.plot.getScatter(legend) + elif kind == 'histogram': + item = self.plot.getHistogram(legend) + else: + raise ValueError('kind not managed') + + if not item or (item.getLegend(), kind) not in self._lgdAndKindToItems: + return + + assert isinstance(item, self.COMPATIBLE_ITEMS) + + statsValDict = self._statsHandler.calculate(item, self.plot, + self._statsOnVisibleData) + + lgdItem = self._lgdAndKindToItems[(item.getLegend(), kind)]['legend'] + assert lgdItem + rowStat = lgdItem.row() + + for statName, statVal in list(statsValDict.items()): + assert statName in self._lgdAndKindToItems[(item.getLegend(), kind)] + tableItem = self._getItem(name=statName, legend=item.getLegend(), + kind=kind, indexTable=rowStat) + tableItem.setText(str(statVal)) + + def currentChanged(self, current, previous): + if current.row() >= 0: + legendItem = self.item(current.row(), self._columns_index['legend']) + assert legendItem + kindItem = self.item(current.row(), self._columns_index['kind']) + kind = kindItem.text() + if kind == 'curve': + self.plot.setActiveCurve(legendItem.text()) + elif kind == 'image': + self.plot.setActiveImage(legendItem.text()) + elif kind == 'scatter': + self.plot._setActiveItem('scatter', legendItem.text()) + elif kind == 'histogram': + # active histogram not managed by the plot actually + pass + else: + raise ValueError('kind not managed') + qt.QTableWidget.currentChanged(self, current, previous) + + def setDisplayOnlyActiveItem(self, displayOnlyActItem): + """ + + :param bool displayOnlyActItem: True if we want to only show active + item + """ + if self._displayOnlyActItem == displayOnlyActItem: + return + self._displayOnlyActItem = displayOnlyActItem + self._dealWithPlotConnection(create=False) + self._updateItemObserve() + self._dealWithPlotConnection(create=True) + + def setStatsOnVisibleData(self, b): + """ + .. warning:: When visible data is activated we will process to a simple + filtering of visible data by the user. The filtering is a + simple data sub-sampling. No interpolation is made to fit + data to boundaries. + + :param bool b: True if we want to apply statistics only on visible data + """ + if self._statsOnVisibleData != b: + self._statsOnVisibleData = b + self._updateCurrentStats() + + def _activeItemChanged(self, kind): + """Callback used when plotting only the active item""" + assert kind in ('curve', 'image', 'scatter', 'histogram') + self._updateItemObserve() + + def _plotContentChanged(self, action, kind, legend): + """Callback used when plotting all the plot items""" + if kind not in ('curve', 'image', 'scatter', 'histogram'): + return + if kind == 'curve': + item = self.plot.getCurve(legend) + elif kind == 'image': + item = self.plot.getImage(legend) + elif kind == 'scatter': + item = self.plot.getScatter(legend) + elif kind == 'histogram': + item = self.plot.getHistogram(legend) + else: + raise ValueError('kind not managed') + + if action == 'add': + if item is None: + raise ValueError('Item from legend "%s" do not exists' % legend) + self._addItem(item) + elif action == 'remove': + self._removeItem(legend, kind) + + def _zoomPlotChanged(self, event): + if self._statsOnVisibleData is True: + if 'event' in event and event['event'] == 'limitsChanged': + self._updateCurrentStats() diff --git a/silx/gui/plot/_BaseMaskToolsWidget.py b/silx/gui/plot/_BaseMaskToolsWidget.py index 35a48ae..da0dbf5 100644 --- a/silx/gui/plot/_BaseMaskToolsWidget.py +++ b/silx/gui/plot/_BaseMaskToolsWidget.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 @@ -29,16 +29,17 @@ from __future__ import division __authors__ = ["T. Vincent", "P. Knobel"] __license__ = "MIT" -__date__ = "02/10/2017" +__date__ = "24/04/2018" import os +import weakref import numpy from silx.gui import qt, icons from silx.gui.widgets.FloatEdit import FloatEdit -from silx.gui.plot.Colormap import Colormap -from silx.gui.plot.Colors import rgba +from silx.gui.colors import Colormap +from silx.gui.colors import rgba from .actions.mode import PanModeAction @@ -372,7 +373,7 @@ class BaseMaskToolsWidget(qt.QWidget): # as parent have to be the first argument of the widget to fit # QtDesigner need but here plot can't be None by default. assert plot is not None - self._plot = plot + self._plotRef = weakref.ref(plot) self._maskName = '__MASK_TOOLS_%d' % id(self) # Legend of the mask self._colormap = Colormap(name="", @@ -409,12 +410,21 @@ class BaseMaskToolsWidget(qt.QWidget): :param bool copy: True (default) to get a copy of the mask. If False, the returned array MUST not be modified. - :return: The array of the mask with dimension of the 'active' plot item. - If there is no active image or scatter, an empty array is - returned. - :rtype: numpy.ndarray of uint8 + :return: The mask (as an array of uint8) with dimension of + the 'active' plot item. + If there is no active image or scatter, it returns None. + :rtype: Union[numpy.ndarray,None] """ - return self._mask.getMask(copy=copy) + mask = self._mask.getMask(copy=copy) + return None if mask.size == 0 else mask + + def setSelectionMask(self, mask): + """Set the mask: Must be implemented in subclass""" + raise NotImplementedError() + + def resetSelectionMask(self): + """Reset the mask: Must be implemented in subclass""" + raise NotImplementedError() def multipleMasks(self): """Return the current mode of multiple masks support. @@ -453,7 +463,11 @@ class BaseMaskToolsWidget(qt.QWidget): @property def plot(self): """The :class:`.PlotWindow` this widget is attached to.""" - return self._plot + plot = self._plotRef() + if plot is None: + raise RuntimeError( + 'Mask widget attached to a PlotWidget that no longer exists') + return plot def setDirection(self, direction=qt.QBoxLayout.LeftToRight): """Set the direction of the layout of the widget @@ -604,8 +618,8 @@ class BaseMaskToolsWidget(qt.QWidget): self.polygonAction.setShortcut(qt.QKeySequence(qt.Qt.Key_S)) self.polygonAction.setToolTip( 'Polygon selection tool: (Un)Mask a polygonal region <b>S</b><br>' - 'Left-click to place polygon corners<br>' - 'Right-click to place the last corner') + 'Left-click to place new polygon corners<br>' + 'Left-click on first corner to close the polygon') self.polygonAction.setCheckable(True) self.polygonAction.triggered.connect(self._activePolygonMode) self.addAction(self.polygonAction) @@ -962,13 +976,20 @@ class BaseMaskToolsWidget(qt.QWidget): self.plot.setInteractiveMode('draw', shape='polygon', source=self, color=color) self._updateDrawingModeWidgets() + def _getPencilWidth(self): + """Returns the width of the pencil to use in data coordinates` + + :rtype: float + """ + return self.pencilSpinBox.value() + def _activePencilMode(self): """Handle pencil action mode triggering""" self._releaseDrawingMode() self._drawingMode = 'pencil' self.plot.sigPlotSignal.connect(self._plotDrawEvent) color = self.getCurrentMaskColor() - width = self.pencilSpinBox.value() + width = self._getPencilWidth() self.plot.setInteractiveMode( 'draw', shape='pencil', source=self, color=color, width=width) self._updateDrawingModeWidgets() diff --git a/silx/gui/plot/__init__.py b/silx/gui/plot/__init__.py index b03392d..3a141b3 100644 --- a/silx/gui/plot/__init__.py +++ b/silx/gui/plot/__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 @@ -37,6 +37,7 @@ List of Qt widgets: - :mod:`.PlotWindow`: A :mod:`.PlotWidget` with a configurable set of tools. - :class:`.Plot1D`: A widget with tools for curves. - :class:`.Plot2D`: A widget with tools for images. +- :class:`.ScatterView`: A widget with tools for scatter plot. - :class:`.ImageView`: A widget with tools for images and a side histogram. - :class:`.StackView`: A widget with tools for a stack of images. @@ -61,8 +62,10 @@ __date__ = "03/05/2017" from .PlotWidget import PlotWidget # noqa from .PlotWindow import PlotWindow, Plot1D, Plot2D # noqa +from .items.axis import TickMode from .ImageView import ImageView # noqa from .StackView import StackView # noqa +from .ScatterView import ScatterView # noqa __all__ = ['ImageView', 'PlotWidget', 'PlotWindow', 'Plot1D', 'Plot2D', - 'StackView'] + 'StackView', 'ScatterView', 'TickMode'] diff --git a/silx/gui/plot/_utils/dtime_ticklayout.py b/silx/gui/plot/_utils/dtime_ticklayout.py new file mode 100644 index 0000000..95fc235 --- /dev/null +++ b/silx/gui/plot/_utils/dtime_ticklayout.py @@ -0,0 +1,438 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2014-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. +# +# ###########################################################################*/ +"""This module implements date-time labels layout on graph axes.""" + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["P. Kenter"] +__license__ = "MIT" +__date__ = "04/04/2018" + + +import datetime as dt +import logging +import math +import time + +import dateutil.tz + +from dateutil.relativedelta import relativedelta + +from silx.third_party import enum +from .ticklayout import niceNumGeneric + +_logger = logging.getLogger(__name__) + + +MICROSECONDS_PER_SECOND = 1000000 +SECONDS_PER_MINUTE = 60 +SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE +SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR +SECONDS_PER_YEAR = 365.25 * SECONDS_PER_DAY +SECONDS_PER_MONTH_AVERAGE = SECONDS_PER_YEAR / 12 # Seconds per average month + + +# No dt.timezone in Python 2.7 so we use dateutil.tz.tzutc +_EPOCH = dt.datetime(1970, 1, 1, tzinfo=dateutil.tz.tzutc()) + +def timestamp(dtObj): + """ Returns POSIX timestamp of a datetime objects. + + If the dtObj object has a timestamp() method (python 3.3), this is + used. Otherwise (e.g. python 2.7) it is calculated here. + + The POSIX timestamp is a floating point value of the number of seconds + since the start of an epoch (typically 1970-01-01). For details see: + https://docs.python.org/3/library/datetime.html#datetime.datetime.timestamp + + :param datetime.datetime dtObj: date-time representation. + :return: POSIX timestamp + :rtype: float + """ + if hasattr(dtObj, "timestamp"): + return dtObj.timestamp() + else: + # Back ported from Python 3.5 + if dtObj.tzinfo is None: + return time.mktime((dtObj.year, dtObj.month, dtObj.day, + dtObj.hour, dtObj.minute, dtObj.second, + -1, -1, -1)) + dtObj.microsecond / 1e6 + else: + return (dtObj - _EPOCH).total_seconds() + + +@enum.unique +class DtUnit(enum.Enum): + YEARS = 0 + MONTHS = 1 + DAYS = 2 + HOURS = 3 + MINUTES = 4 + SECONDS = 5 + MICRO_SECONDS = 6 # a fraction of a second + + +def getDateElement(dateTime, unit): + """ Picks the date element with the unit from the dateTime + + E.g. getDateElement(datetime(1970, 5, 6), DtUnit.Day) will return 6 + + :param datetime dateTime: date/time to pick from + :param DtUnit unit: The unit describing the date element. + """ + if unit == DtUnit.YEARS: + return dateTime.year + elif unit == DtUnit.MONTHS: + return dateTime.month + elif unit == DtUnit.DAYS: + return dateTime.day + elif unit == DtUnit.HOURS: + return dateTime.hour + elif unit == DtUnit.MINUTES: + return dateTime.minute + elif unit == DtUnit.SECONDS: + return dateTime.second + elif unit == DtUnit.MICRO_SECONDS: + return dateTime.microsecond + else: + raise ValueError("Unexpected DtUnit: {}".format(unit)) + + +def setDateElement(dateTime, value, unit): + """ Returns a copy of dateTime with the tickStep unit set to value + + :param datetime.datetime: date time object + :param int value: value to set + :param DtUnit unit: unit + :return: datetime.datetime + """ + intValue = int(value) + _logger.debug("setDateElement({}, {} (int={}), {})" + .format(dateTime, value, intValue, unit)) + + year = dateTime.year + month = dateTime.month + day = dateTime.day + hour = dateTime.hour + minute = dateTime.minute + second = dateTime.second + microsecond = dateTime.microsecond + + if unit == DtUnit.YEARS: + year = intValue + elif unit == DtUnit.MONTHS: + month = intValue + elif unit == DtUnit.DAYS: + day = intValue + elif unit == DtUnit.HOURS: + hour = intValue + elif unit == DtUnit.MINUTES: + minute = intValue + elif unit == DtUnit.SECONDS: + second = intValue + elif unit == DtUnit.MICRO_SECONDS: + microsecond = intValue + else: + raise ValueError("Unexpected DtUnit: {}".format(unit)) + + _logger.debug("creating date time {}" + .format((year, month, day, hour, minute, second, microsecond))) + + return dt.datetime(year, month, day, hour, minute, second, microsecond, + tzinfo=dateTime.tzinfo) + + + +def roundToElement(dateTime, unit): + """ Returns a copy of dateTime with the + + :param datetime.datetime: date time object + :param DtUnit unit: unit + :return: datetime.datetime + """ + year = dateTime.year + month = dateTime.month + day = dateTime.day + hour = dateTime.hour + minute = dateTime.minute + second = dateTime.second + microsecond = dateTime.microsecond + + if unit.value < DtUnit.YEARS.value: + pass # Never round years + if unit.value < DtUnit.MONTHS.value: + month = 1 + if unit.value < DtUnit.DAYS.value: + day = 1 + if unit.value < DtUnit.HOURS.value: + hour = 0 + if unit.value < DtUnit.MINUTES.value: + minute = 0 + if unit.value < DtUnit.SECONDS.value: + second = 0 + if unit.value < DtUnit.MICRO_SECONDS.value: + microsecond = 0 + + result = dt.datetime(year, month, day, hour, minute, second, microsecond, + tzinfo=dateTime.tzinfo) + + return result + + +def addValueToDate(dateTime, value, unit): + """ Adds a value with unit to a dateTime. + + Uses dateutil.relativedelta.relativedelta from the standard library to do + the actual math. This function doesn't allow for fractional month or years, + so month and year are truncated to integers before adding. + + :param datetime dateTime: date time + :param float value: value to be added + :param DtUnit unit: of the value + :return: + """ + #logger.debug("addValueToDate({}, {}, {})".format(dateTime, value, unit)) + + if unit == DtUnit.YEARS: + intValue = int(value) # floats not implemented in relativeDelta(years) + return dateTime + relativedelta(years=intValue) + elif unit == DtUnit.MONTHS: + intValue = int(value) # floats not implemented in relativeDelta(mohths) + return dateTime + relativedelta(months=intValue) + elif unit == DtUnit.DAYS: + return dateTime + relativedelta(days=value) + elif unit == DtUnit.HOURS: + return dateTime + relativedelta(hours=value) + elif unit == DtUnit.MINUTES: + return dateTime + relativedelta(minutes=value) + elif unit == DtUnit.SECONDS: + return dateTime + relativedelta(seconds=value) + elif unit == DtUnit.MICRO_SECONDS: + return dateTime + relativedelta(microseconds=value) + else: + raise ValueError("Unexpected DtUnit: {}".format(unit)) + + +def bestUnit(durationInSeconds): + """ Gets the best tick spacing given a duration in seconds. + + :param durationInSeconds: time span duration in seconds + :return: DtUnit enumeration. + """ + + # Based on; https://stackoverflow.com/a/2144398/ + # If the duration is longer than two years the tick spacing will be in + # years. Else, if the duration is longer than two months, the spacing will + # be in months, Etcetera. + # + # This factor differs per unit. As a baseline it is 2, but for instance, + # for Months this needs to be higher (3>), This because it is impossible to + # have partial months so the tick spacing is always at least 1 month. A + # duration of two months would result in two ticks, which is too few. + # months would then results + + if durationInSeconds > SECONDS_PER_YEAR * 3: + return (durationInSeconds / SECONDS_PER_YEAR, DtUnit.YEARS) + elif durationInSeconds > SECONDS_PER_MONTH_AVERAGE * 3: + return (durationInSeconds / SECONDS_PER_MONTH_AVERAGE, DtUnit.MONTHS) + elif durationInSeconds > SECONDS_PER_DAY * 2: + return (durationInSeconds / SECONDS_PER_DAY, DtUnit.DAYS) + elif durationInSeconds > SECONDS_PER_HOUR * 2: + return (durationInSeconds / SECONDS_PER_HOUR, DtUnit.HOURS) + elif durationInSeconds > SECONDS_PER_MINUTE * 2: + return (durationInSeconds / SECONDS_PER_MINUTE, DtUnit.MINUTES) + elif durationInSeconds > 1 * 2: + return (durationInSeconds, DtUnit.SECONDS) + else: + return (durationInSeconds * MICROSECONDS_PER_SECOND, + DtUnit.MICRO_SECONDS) + + +NICE_DATE_VALUES = { + DtUnit.YEARS: [1, 2, 5, 10], + DtUnit.MONTHS: [1, 2, 3, 4, 6, 12], + DtUnit.DAYS: [1, 2, 3, 7, 14, 28], + DtUnit.HOURS: [1, 2, 3, 4, 6, 12], + DtUnit.MINUTES: [1, 2, 3, 5, 10, 15, 30], + DtUnit.SECONDS: [1, 2, 3, 5, 10, 15, 30], + DtUnit.MICRO_SECONDS : [1.0, 2.0, 5.0, 10.0], # floats for microsec +} + + +def bestFormatString(spacing, unit): + """ Finds the best format string given the spacing and DtUnit. + + If the spacing is a fractional number < 1 the format string will take this + into account + + :param spacing: spacing between ticks + :param DtUnit unit: + :return: Format string for use in strftime + :rtype: str + """ + isSmall = spacing < 1 + + if unit == DtUnit.YEARS: + return "%Y-m" if isSmall else "%Y" + elif unit == DtUnit.MONTHS: + return "%Y-%m-%d" if isSmall else "%Y-%m" + elif unit == DtUnit.DAYS: + return "%H:%M" if isSmall else "%Y-%m-%d" + elif unit == DtUnit.HOURS: + return "%H:%M" if isSmall else "%H:%M" + elif unit == DtUnit.MINUTES: + return "%H:%M:%S" if isSmall else "%H:%M" + elif unit == DtUnit.SECONDS: + return "%S.%f" if isSmall else "%H:%M:%S" + elif unit == DtUnit.MICRO_SECONDS: + return "%S.%f" + else: + raise ValueError("Unexpected DtUnit: {}".format(unit)) + + +def niceDateTimeElement(value, unit, isRound=False): + """ Uses the Nice Numbers algorithm to determine a nice value. + + The fractions are optimized for the unit of the date element. + """ + + niceValues = NICE_DATE_VALUES[unit] + elemValue = niceNumGeneric(value, niceValues, isRound=isRound) + + if unit == DtUnit.YEARS or unit == DtUnit.MONTHS: + elemValue = max(1, int(elemValue)) + + return elemValue + + +def findStartDate(dMin, dMax, nTicks): + """ Rounds a date down to the nearest nice number of ticks + """ + assert dMax > dMin, \ + "dMin ({}) should come before dMax ({})".format(dMin, dMax) + + delta = dMax - dMin + lengthSec = delta.total_seconds() + _logger.debug("findStartDate: {}, {} (duration = {} sec, {} days)" + .format(dMin, dMax, lengthSec, lengthSec / SECONDS_PER_DAY)) + + length, unit = bestUnit(delta.total_seconds()) + niceLength = niceDateTimeElement(length, unit) + + _logger.debug("Length: {:8.3f} {} (nice = {})" + .format(length, unit.name, niceLength)) + + niceSpacing = niceDateTimeElement(niceLength / nTicks, unit, isRound=True) + + _logger.debug("Spacing: {:8.3f} {} (nice = {})" + .format(niceLength / nTicks, unit.name, niceSpacing)) + + dVal = getDateElement(dMin, unit) + + if unit == DtUnit.MONTHS: # TODO: better rounding? + niceVal = math.floor((dVal-1) / niceSpacing) * niceSpacing + 1 + elif unit == DtUnit.DAYS: + niceVal = math.floor((dVal-1) / niceSpacing) * niceSpacing + 1 + else: + niceVal = math.floor(dVal / niceSpacing) * niceSpacing + + _logger.debug("StartValue: dVal = {}, niceVal: {} ({})" + .format(dVal, niceVal, unit.name)) + + startDate = roundToElement(dMin, unit) + startDate = setDateElement(startDate, niceVal, unit) + + return startDate, niceSpacing, unit + + +def dateRange(dMin, dMax, step, unit, includeFirstBeyond = False): + """ Generates a range of dates + + :param datetime dMin: start date + :param datetime dMax: end date + :param int step: the step size + :param DtUnit unit: the unit of the step size + :param bool includeFirstBeyond: if True the first date later than dMax will + be included in the range. If False (the default), the last generated + datetime will always be smaller than dMax. + :return: + """ + if (unit == DtUnit.YEARS or unit == DtUnit.MONTHS or + unit == DtUnit.MICRO_SECONDS): + + # Month and years will be converted to integers + assert int(step) > 0, "Integer value or tickstep is 0" + else: + assert step > 0, "tickstep is 0" + + dateTime = dMin + while dateTime < dMax: + yield dateTime + dateTime = addValueToDate(dateTime, step, unit) + + if includeFirstBeyond: + yield dateTime + + + +def calcTicks(dMin, dMax, nTicks): + """Returns tick positions. + + :param datetime.datetime dMin: The min value on the axis + :param datetime.datetime dMax: The max value on the axis + :param int nTicks: The target number of ticks. The actual number of found + ticks may differ. + :returns: (list of datetimes, DtUnit) tuple + """ + _logger.debug("Calc calcTicks({}, {}, nTicks={})" + .format(dMin, dMax, nTicks)) + + startDate, niceSpacing, unit = findStartDate(dMin, dMax, nTicks) + + result = [] + for d in dateRange(startDate, dMax, niceSpacing, unit, + includeFirstBeyond=True): + result.append(d) + + assert result[0] <= dMin, \ + "First nice date ({}) should be <= dMin {}".format(result[0], dMin) + + assert result[-1] >= dMax, \ + "Last nice date ({}) should be >= dMax {}".format(result[-1], dMax) + + return result, niceSpacing, unit + + +def calcTicksAdaptive(dMin, dMax, axisLength, tickDensity): + """ Calls calcTicks with a variable number of ticks, depending on axisLength + """ + # At least 2 ticks + nticks = max(2, int(round(tickDensity * axisLength))) + return calcTicks(dMin, dMax, nticks) + + + + + diff --git a/silx/gui/plot/_utils/test/__init__.py b/silx/gui/plot/_utils/test/__init__.py index 4a443ac..624dbcb 100644 --- a/silx/gui/plot/_utils/test/__init__.py +++ b/silx/gui/plot/_utils/test/__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 @@ -32,10 +32,12 @@ __date__ = "18/10/2016" import unittest +from .test_dtime_ticklayout import suite as test_dtime_ticklayout_suite from .test_ticklayout import suite as test_ticklayout_suite def suite(): testsuite = unittest.TestSuite() + testsuite.addTest(test_dtime_ticklayout_suite()) testsuite.addTest(test_ticklayout_suite()) return testsuite diff --git a/silx/gui/plot/_utils/test/testColormap.py b/silx/gui/plot/_utils/test/testColormap.py new file mode 100644 index 0000000..d77fa65 --- /dev/null +++ b/silx/gui/plot/_utils/test/testColormap.py @@ -0,0 +1,648 @@ +# 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/_utils/test/test_dtime_ticklayout.py b/silx/gui/plot/_utils/test/test_dtime_ticklayout.py new file mode 100644 index 0000000..2b87148 --- /dev/null +++ b/silx/gui/plot/_utils/test/test_dtime_ticklayout.py @@ -0,0 +1,93 @@ +# 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. +# +# ###########################################################################*/ + +from __future__ import absolute_import, division, unicode_literals + +__authors__ = ["P. Kenter"] +__license__ = "MIT" +__date__ = "06/04/2018" + + +import datetime as dt +import unittest + + +from silx.gui.plot._utils.dtime_ticklayout import ( + calcTicks, DtUnit, SECONDS_PER_YEAR) + + +class DtTestTickLayout(unittest.TestCase): + """Test ticks layout algorithms""" + + def testSmallMonthlySpacing(self): + """ Tests a range that did result in a spacing of less than 1 month. + It is impossible to add fractional month so the unit must be in days + """ + from dateutil import parser + d1 = parser.parse("2017-01-03 13:15:06.000044") + d2 = parser.parse("2017-03-08 09:16:16.307584") + _ticks, _units, spacing = calcTicks(d1, d2, nTicks=4) + + self.assertEqual(spacing, DtUnit.DAYS) + + + def testNoCrash(self): + """ Creates many combinations of and number-of-ticks and end-dates; + tests that it doesn't give an exception and returns a reasonable number + of ticks. + """ + d1 = dt.datetime(2017, 1, 3, 13, 15, 6, 44) + + value = 100e-6 # Start at 100 micro sec range. + + while value <= 200 * SECONDS_PER_YEAR: + + d2 = d1 + dt.timedelta(microseconds=value*1e6) # end date range + + for numTicks in range(2, 12): + ticks, _, _ = calcTicks(d1, d2, numTicks) + + margin = 2.5 + self.assertTrue( + numTicks/margin <= len(ticks) <= numTicks*margin, + "Condition {} <= {} <= {} failed for # ticks={} and d2={}:" + .format(numTicks/margin, len(ticks), numTicks * margin, + numTicks, d2)) + + value = value * 1.5 # let date period grow exponentially + + + + + +def suite(): + testsuite = unittest.TestSuite() + testsuite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(DtTestTickLayout)) + return testsuite + + +if __name__ == '__main__': + unittest.main() diff --git a/silx/gui/plot/_utils/ticklayout.py b/silx/gui/plot/_utils/ticklayout.py index 6e9f654..c9fd3e6 100644 --- a/silx/gui/plot/_utils/ticklayout.py +++ b/silx/gui/plot/_utils/ticklayout.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# Copyright (c) 2014-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 @@ -51,28 +51,65 @@ def numberOfDigits(tickSpacing): # Nice Numbers ################################################################ -def _niceNum(value, isRound=False): - expvalue = math.floor(math.log10(value)) - frac = value/pow(10., expvalue) - if isRound: - if frac < 1.5: - nicefrac = 1. - elif frac < 3.: - nicefrac = 2. - elif frac < 7.: - nicefrac = 5. - else: - nicefrac = 10. +# This is the original niceNum implementation. For the date time ticks a more +# generic implementation was needed. +# +# def _niceNum(value, isRound=False): +# expvalue = math.floor(math.log10(value)) +# frac = value/pow(10., expvalue) +# if isRound: +# if frac < 1.5: +# nicefrac = 1. +# elif frac < 3.: # In niceNumGeneric this is (2+5)/2 = 3.5 +# nicefrac = 2. +# elif frac < 7.: +# nicefrac = 5. # In niceNumGeneric this is (5+10)/2 = 7.5 +# else: +# nicefrac = 10. +# else: +# if frac <= 1.: +# nicefrac = 1. +# elif frac <= 2.: +# nicefrac = 2. +# elif frac <= 5.: +# nicefrac = 5. +# else: +# nicefrac = 10. +# return nicefrac * pow(10., expvalue) + + +def niceNumGeneric(value, niceFractions=None, isRound=False): + """ A more generic implementation of the _niceNum function + + Allows the user to specify the fractions instead of using a hardcoded + list of [1, 2, 5, 10.0]. + """ + if value == 0: + return value + + if niceFractions is None: # Use default values + niceFractions = 1., 2., 5., 10. + roundFractions = (1.5, 3., 7., 10.) if isRound else niceFractions + else: - if frac <= 1.: - nicefrac = 1. - elif frac <= 2.: - nicefrac = 2. - elif frac <= 5.: - nicefrac = 5. - else: - nicefrac = 10. - return nicefrac * pow(10., expvalue) + roundFractions = list(niceFractions) + if isRound: + # Take the average with the next element. The last remains the same. + for i in range(len(roundFractions) - 1): + roundFractions[i] = (niceFractions[i] + niceFractions[i+1]) / 2 + + highest = niceFractions[-1] + value = float(value) + + expvalue = math.floor(math.log(value, highest)) + frac = value / pow(highest, expvalue) + + for niceFrac, roundFrac in zip(niceFractions, roundFractions): + if frac <= roundFrac: + return niceFrac * pow(highest, expvalue) + + # should not come here + assert False, "should not come here" def niceNumbers(vMin, vMax, nTicks=5): @@ -89,8 +126,8 @@ def niceNumbers(vMin, vMax, nTicks=5): number of fractional digit to show :rtype: tuple """ - vrange = _niceNum(vMax - vMin, False) - spacing = _niceNum(vrange / nTicks, True) + vrange = niceNumGeneric(vMax - vMin, isRound=False) + spacing = niceNumGeneric(vrange / nTicks, isRound=True) graphmin = math.floor(vMin / spacing) * spacing graphmax = math.ceil(vMax / spacing) * spacing nfrac = numberOfDigits(spacing) diff --git a/silx/gui/plot/actions/control.py b/silx/gui/plot/actions/control.py index ac6dc2f..6e08f21 100644 --- a/silx/gui/plot/actions/control.py +++ b/silx/gui/plot/actions/control.py @@ -50,12 +50,11 @@ from __future__ import division __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] __license__ = "MIT" -__date__ = "15/02/2018" +__date__ = "24/04/2018" from . import PlotAction import logging from silx.gui.plot import items -from silx.gui.plot.ColormapDialog import ColormapDialog from silx.gui.plot._utils import applyZoomToPlot as _applyZoomToPlot from silx.gui import qt from silx.gui import icons @@ -328,6 +327,7 @@ class ColormapAction(PlotAction): triggered=self._actionTriggered, checkable=True, parent=parent) self.plot.sigActiveImageChanged.connect(self._updateColormap) + self.plot.sigActiveScatterChanged.connect(self._updateColormap) def setColorDialog(self, colorDialog): """Set a specific color dialog instead of using the default dialog.""" @@ -344,6 +344,7 @@ class ColormapAction(PlotAction): :parent QWidget parent: Parent of the new colormap :rtype: ColormapDialog """ + from silx.gui.dialog.ColormapDialog import ColormapDialog dialog = ColormapDialog(parent=parent) dialog.setModal(False) return dialog @@ -393,10 +394,19 @@ class ColormapAction(PlotAction): else: # No active image or active image is RGBA, - # set dialog from default info - colormap = self.plot.getDefaultColormap() - # Reset histogram and range if any - self._dialog.setData(None) + # Check for active scatter plot + scatter = self.plot._getActiveItem(kind='scatter') + if scatter is not None: + colormap = scatter.getColormap() + data = scatter.getValueData(copy=False) + self._dialog.setData(data) + + else: + # No active data image nor scatter, + # set dialog from default info + colormap = self.plot.getDefaultColormap() + # Reset histogram and range if any + self._dialog.setData(None) self._dialog.setColormap(colormap) @@ -408,7 +418,7 @@ class ColorBarAction(PlotAction): :param parent: See :class:`QAction` """ def __init__(self, plot, parent=None): - self._dialog = None # To store an instance of ColormapDialog + self._dialog = None # To store an instance of ColorBar super(ColorBarAction, self).__init__( plot, icon='colorbar', text='Colorbar', tooltip="Show/Hide the colorbar", diff --git a/silx/gui/plot/actions/histogram.py b/silx/gui/plot/actions/histogram.py index 40ef873..d6e3269 100644 --- a/silx/gui/plot/actions/histogram.py +++ b/silx/gui/plot/actions/histogram.py @@ -34,7 +34,7 @@ The following QAction are available: from __future__ import division __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] -__date__ = "27/06/2017" +__date__ = "30/04/2018" __license__ = "MIT" from . import PlotAction @@ -129,7 +129,7 @@ class PixelIntensitiesHistoAction(PlotAction): edges=edges, legend='pixel intensity', fill=True, - color='red') + color='#66aad7') plot.resetZoom() def eventFilter(self, qobject, event): diff --git a/silx/gui/plot/actions/io.py b/silx/gui/plot/actions/io.py index d6d5909..ac06942 100644 --- a/silx/gui/plot/actions/io.py +++ b/silx/gui/plot/actions/io.py @@ -44,13 +44,16 @@ from silx.io.utils import save1D, savespec from silx.io.nxdata import save_NXdata import logging import sys +import os.path from collections import OrderedDict import traceback import numpy -from silx.gui import qt +from silx.utils.deprecation import deprecated +from silx.gui import qt, printer +from silx.gui.dialog.GroupDialog import GroupDialog from silx.third_party.EdfFile import EdfFile from silx.third_party.TiffIO import TiffIO -from silx.gui._utils import convertArrayToQImage +from ...utils._image import convertArrayToQImage if sys.version_info[0] == 3: from io import BytesIO else: @@ -60,10 +63,26 @@ else: _logger = logging.getLogger(__name__) -_NEXUS_HDF5_EXT = [".nx5", ".nxs", ".hdf", ".hdf5", ".cxi", ".h5"] +_NEXUS_HDF5_EXT = [".h5", ".nx5", ".nxs", ".hdf", ".hdf5", ".cxi"] _NEXUS_HDF5_EXT_STR = ' '.join(['*' + ext for ext in _NEXUS_HDF5_EXT]) +def selectOutputGroup(h5filename): + """Open a dialog to prompt the user to select a group in + which to output data. + + :param str h5filename: name of an existing HDF5 file + :rtype: str + :return: Name of output group, or None if the dialog was cancelled + """ + dialog = GroupDialog() + dialog.addFile(h5filename) + dialog.setWindowTitle("Select an output group") + if not dialog.exec_(): + return None + return dialog.getSelectedDataUrl().data_path() + + class SaveAction(PlotAction): """QAction for saving Plot content. @@ -72,12 +91,11 @@ class SaveAction(PlotAction): :param plot: :class:`.PlotWidget` instance on which to operate. :param parent: See :class:`QAction`. """ - # TODO find a way to make the filter list selectable and extensible SNAPSHOT_FILTER_SVG = 'Plot Snapshot as SVG (*.svg)' SNAPSHOT_FILTER_PNG = 'Plot Snapshot as PNG (*.png)' - SNAPSHOT_FILTERS = (SNAPSHOT_FILTER_PNG, SNAPSHOT_FILTER_SVG) + DEFAULT_ALL_FILTERS = (SNAPSHOT_FILTER_PNG, SNAPSHOT_FILTER_SVG) # Dict of curve filters with CSV-like format # Using ordered dict to guarantee filters order @@ -101,10 +119,10 @@ class SaveAction(PlotAction): CURVE_FILTER_NXDATA = 'Curve as NXdata (%s)' % _NEXUS_HDF5_EXT_STR - CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [CURVE_FILTER_NPY, - CURVE_FILTER_NXDATA] + DEFAULT_CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [ + CURVE_FILTER_NPY, CURVE_FILTER_NXDATA] - ALL_CURVES_FILTERS = ("All curves as SpecFile (*.dat)", ) + DEFAULT_ALL_CURVES_FILTERS = ("All curves as Spe |