diff options
author | Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> | 2017-08-18 14:48:52 +0200 |
---|---|---|
committer | Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> | 2017-08-18 14:48:52 +0200 |
commit | f7bdc2acff3c13a6d632c28c4569690ab106eed7 (patch) | |
tree | 9d67cdb7152ee4e711379e03fe0546c7c3b97303 /silx |
Import Upstream version 0.5.0+dfsg
Diffstat (limited to 'silx')
611 files changed, 395651 insertions, 0 deletions
diff --git a/silx/__init__.py b/silx/__init__.py new file mode 100644 index 0000000..cfb306b --- /dev/null +++ b/silx/__init__.py @@ -0,0 +1,43 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-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. +# +# ###########################################################################*/ + +from __future__ import absolute_import, print_function, division + +__authors__ = ["Jérôme Kieffer"] +__license__ = "MIT" +__date__ = "23/05/2016" + +import os as _os +import logging as _logging + +_logging.basicConfig() # Make sure logging is initialised + +project = _os.path.basename(_os.path.dirname(_os.path.abspath(__file__))) + +try: + from ._version import __date__ as date # noqa + from ._version import version, version_info, hexversion, strictversion # noqa +except ImportError: + raise RuntimeError("Do NOT use %s from its sources: build it and use the built version" % project) diff --git a/silx/__main__.py b/silx/__main__.py new file mode 100644 index 0000000..4c05ef3 --- /dev/null +++ b/silx/__main__.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-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. +# +# ###########################################################################*/ +"""This module describe silx applications which are available through +the silx launcher. + +Your environment should provide a command `silx`. You can reach help with +`silx --help`, and check the version with `silx --version`. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "18/04/2017" + + +import logging +logging.basicConfig() + +import sys +from silx.utils.launcher import Launcher +import silx._version + + +def main(): + """Main function of the launcher + + This function is referenced in the setup.py file. + Thisfor it is executed by a launcher script generated by setuptools. + + :rtype: int + :returns: The execution status + """ + launcher = Launcher(prog="silx", version=silx._version.version) + launcher.add_command("view", + module_name="silx.app.view", + description="Browse a data file with a GUI") + status = launcher.execute(sys.argv) + return status + + +if __name__ == "__main__": + # executed when using python -m PROJECT_NAME + status = main() + sys.exit(status) diff --git a/silx/app/__init__.py b/silx/app/__init__.py new file mode 100644 index 0000000..9cbb8bb --- /dev/null +++ b/silx/app/__init__.py @@ -0,0 +1,29 @@ +# 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. +# +# ###########################################################################*/ +"""Application provided by the launcher""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "30/03/2017" diff --git a/silx/app/setup.py b/silx/app/setup.py new file mode 100644 index 0000000..bf6f3af --- /dev/null +++ b/silx/app/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__ = "30/03/2017" + +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') + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + setup(configuration=configuration) diff --git a/silx/app/test/__init__.py b/silx/app/test/__init__.py new file mode 100644 index 0000000..54241dc --- /dev/null +++ b/silx/app/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__ = "30/03/2017" + + +import logging +import os +import sys +import unittest + + +_logger = logging.getLogger(__name__) + + +def suite(): + test_suite = unittest.TestSuite() + return test_suite diff --git a/silx/app/test/test_view.py b/silx/app/test/test_view.py new file mode 100644 index 0000000..774bc01 --- /dev/null +++ b/silx/app/test/test_view.py @@ -0,0 +1,135 @@ +# 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__ = "12/04/2017" + + +import unittest +from silx.gui.test.utils import TestCaseQt +from .. import view +import sys + + +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 resize(self, size): + pass + + def show(self): + pass + + +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): + try: + result = view.main(["view", "--help"]) + self.assertNotEqual(result, 0) + except SystemExit as e: + result = e.args[0] + self.assertEqual(result, 0) + + def testWrongOption(self): + try: + result = view.main(["view", "--foo"]) + self.assertNotEqual(result, 0) + except SystemExit as e: + result = e.args[0] + self.assertNotEqual(result, 0) + + def testWrongFile(self): + try: + result = view.main(["view", "__file.not.found__"]) + self.assertNotEqual(result, 0) + except SystemExit as e: + result = e.args[0] + self.assertNotEqual(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""" + + def testConstruct(self): + 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 new file mode 100644 index 0000000..8fdabde --- /dev/null +++ b/silx/app/view.py @@ -0,0 +1,326 @@ +# 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. +# +# ############################################################################*/ +"""Browse a data file with a GUI""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "12/04/2017" + +import sys +import os +import argparse +import logging +import collections + + +logging.basicConfig() +_logger = logging.getLogger(__name__) +"""Module logger""" + +try: + # it should be loaded before h5py + import hdf5plugin # noqa +except ImportError: + hdf5plugin = None + +try: + import h5py + import silx.gui.hdf5 +except ImportError: + h5py = None + +try: + import fabio +except ImportError: + fabio = None + +from silx.gui import qt +from silx.gui.data.DataViewerFrame import DataViewerFrame + + +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) + """ + 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) + + self.__treeview.selectionModel().selectionChanged.connect(self.displayData) + + self.__treeview.addContextMenuCallback(self.customContextMenu) + # lambda function will never be called cause we store it as weakref + self.__treeview.addContextMenuCallback(lambda event: None) + # you have to store it first + self.__store_lambda = lambda event: self.closeAndSyncCustomContextMenu(event) + self.__treeview.addContextMenuCallback(self.__store_lambda) + + 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) + + extensions = collections.OrderedDict() + # expect h5py + extensions["HDF5 files"] = "*.h5" + # no dependancy + extensions["Spec files"] = "*.dat *.spec *.mca" + # expect fabio + extensions["EDF files"] = "*.edf" + extensions["TIFF image files"] = "*.tif *.tiff" + extensions["NumPy binary files"] = "*.npy" + extensions["CBF files"] = "*.cbf" + extensions["MarCCD image files"] = "*.mccd" + + filters = [] + filters.append("All supported files (%s)" % " ".join(extensions.values())) + 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): + import silx._version + message = """<p align="center"><b>Silx viewer</b> + <br /> + <br />{silx_version} + <br /> + <br /><a href="{project_url}">Upstream project on GitHub</a> + </p> + <p align="left"> + <dl> + <dt><b>Silx version</b></dt><dd>{silx_version}</dd> + <dt><b>Qt version</b></dt><dd>{qt_version}</dd> + <dt><b>Qt binding</b></dt><dd>{qt_binding}</dd> + <dt><b>Python version</b></dt><dd>{python_version}</dd> + <dt><b>Optional libraries</b></dt><dd>{optional_lib}</dd> + </dl> + </p> + <p> + Copyright (C) <a href="{esrf_url}">European Synchrotron Radiation Facility</a> + </p> + """ + def format_optional_lib(name, isAvailable): + if isAvailable: + template = '<b>%s</b> is <font color="green">installed</font>' + else: + template = '<b>%s</b> is <font color="red">not installed</font>' + return template % name + + optional_lib = [] + optional_lib.append(format_optional_lib("FabIO", fabio is not None)) + optional_lib.append(format_optional_lib("H5py", h5py is not None)) + optional_lib.append(format_optional_lib("hdf5plugin", hdf5plugin is not None)) + + info = dict( + esrf_url="http://www.esrf.eu", + project_url="https://github.com/silx-kit/silx", + silx_version=silx._version.version, + qt_binding=qt.BINDING, + qt_version=qt.qVersion(), + python_version=sys.version.replace("\n", "<br />"), + optional_lib="<br />".join(optional_lib) + ) + qt.QMessageBox.about(self, "About Menu", message.format(**info)) + + 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()) + 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 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() + menu = event.menu() + + hasDataset = False + for obj in selectedObjects: + if obj.ntype is h5py.Dataset: + hasDataset = True + break + + if len(menu.children()): + menu.addSeparator() + + if hasDataset: + action = qt.QAction("Do something on the datasets", event.source()) + menu.addAction(action) + + 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() + menu = event.menu() + + if len(menu.children()): + menu.addSeparator() + + 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', + type=argparse.FileType('rb'), + nargs=argparse.ZERO_OR_MORE, + help='Data file to show (h5 file, edf files, spec files)') + + options = parser.parse_args(argv[1:]) + + 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) + + app = qt.QApplication([]) + sys.excepthook = qt.exceptionHandler + window = Viewer() + window.resize(qt.QSize(640, 480)) + + for f in options.files: + filename = f.name + f.close() + window.appendFile(filename) + + window.show() + result = app.exec_() + # remove ending warnings relative to QTimer + app.deleteLater() + return result diff --git a/silx/gui/__init__.py b/silx/gui/__init__.py new file mode 100644 index 0000000..6baf238 --- /dev/null +++ b/silx/gui/__init__.py @@ -0,0 +1,29 @@ +# 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. +# +# ###########################################################################*/ +"""Set of Qt widgets""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "23/05/2016" diff --git a/silx/gui/_glutils/Context.py b/silx/gui/_glutils/Context.py new file mode 100644 index 0000000..7600992 --- /dev/null +++ b/silx/gui/_glutils/Context.py @@ -0,0 +1,63 @@ +# 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. +# +# ###########################################################################*/ +"""Abstraction of OpenGL context. + +It defines a way to get current OpenGL context to support multiple +OpenGL contexts. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +# context ##################################################################### + + +def _defaultGLContextGetter(): + return None + +_glContextGetter = _defaultGLContextGetter + + +def getGLContext(): + """Returns platform dependent object of current OpenGL context. + + This is useful to associate OpenGL resources with the context they are + created in. + + :return: Platform specific OpenGL context + :rtype: None by default or a platform dependent object""" + return _glContextGetter() + + +def setGLContextGetter(getter=_defaultGLContextGetter): + """Set a platform dependent function to retrieve the current OpenGL context + + :param getter: Platform dependent GL context getter + :type getter: Function with no args returning the current OpenGL context + """ + global _glContextGetter + _glContextGetter = getter diff --git a/silx/gui/_glutils/FramebufferTexture.py b/silx/gui/_glutils/FramebufferTexture.py new file mode 100644 index 0000000..b01eb41 --- /dev/null +++ b/silx/gui/_glutils/FramebufferTexture.py @@ -0,0 +1,164 @@ +# 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. +# +# ###########################################################################*/ +"""Association of a texture and a framebuffer object for off-screen rendering. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import logging + +from . import gl +from .Texture import Texture + + +_logger = logging.getLogger(__name__) + + +class FramebufferTexture(object): + """Framebuffer with a texture. + + Aimed at off-screen rendering to texture. + + :param internalFormat: OpenGL texture internal format + :param shape: Shape (height, width) of the framebuffer and texture + :type shape: 2-tuple of int + :param stencilFormat: Stencil renderbuffer format + :param depthFormat: Depth renderbuffer format + :param kwargs: Extra arguments for :class:`Texture` constructor + """ + + _PACKED_FORMAT = gl.GL_DEPTH24_STENCIL8, gl.GL_DEPTH_STENCIL + + def __init__(self, + internalFormat, + shape, + stencilFormat=gl.GL_DEPTH24_STENCIL8, + depthFormat=gl.GL_DEPTH24_STENCIL8, + **kwargs): + + self._texture = Texture(internalFormat, shape=shape, **kwargs) + + self._previousFramebuffer = 0 # Used by with statement + + self._name = gl.glGenFramebuffers(1) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self._name) + + # Attachments + gl.glFramebufferTexture2D(gl.GL_FRAMEBUFFER, + gl.GL_COLOR_ATTACHMENT0, + gl.GL_TEXTURE_2D, + self._texture.name, + 0) + + height, width = self._texture.shape + + if stencilFormat is not None: + self._stencilId = gl.glGenRenderbuffers(1) + gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._stencilId) + gl.glRenderbufferStorage(gl.GL_RENDERBUFFER, + stencilFormat, + width, height) + gl.glFramebufferRenderbuffer(gl.GL_FRAMEBUFFER, + gl.GL_STENCIL_ATTACHMENT, + gl.GL_RENDERBUFFER, + self._stencilId) + else: + self._stencilId = None + + if depthFormat is not None: + if self._stencilId and depthFormat in self._PACKED_FORMAT: + self._depthId = self._stencilId + else: + self._depthId = gl.glGenRenderbuffers(1) + gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._depthId) + gl.glRenderbufferStorage(gl.GL_RENDERBUFFER, + depthFormat, + width, height) + gl.glFramebufferRenderbuffer(gl.GL_FRAMEBUFFER, + gl.GL_DEPTH_ATTACHMENT, + gl.GL_RENDERBUFFER, + self._depthId) + else: + self._depthId = None + + assert gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER) == \ + gl.GL_FRAMEBUFFER_COMPLETE + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0) + + @property + def shape(self): + """Shape of the framebuffer (height, width)""" + return self._texture.shape + + @property + def texture(self): + """The texture this framebuffer is rendering to. + + The life-cycle of the texture is managed by this object""" + return self._texture + + @property + def name(self): + """OpenGL name of the framebuffer""" + if self._name is not None: + return self._name + else: + raise RuntimeError("No OpenGL framebuffer resource, \ + discard has already been called") + + def bind(self): + """Bind this framebuffer for rendering""" + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.name) + + # with statement + + def __enter__(self): + self._previousFramebuffer = gl.glGetInteger(gl.GL_FRAMEBUFFER_BINDING) + self.bind() + + def __exit__(self, exctype, excvalue, traceback): + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self._previousFramebuffer) + + def discard(self): + """Delete associated OpenGL resources including texture""" + if self._name is not None: + gl.glDeleteFramebuffers(self._name) + self._name = None + + if self._stencilId is not None: + gl.glDeleteRenderbuffers(self._stencilId) + if self._stencilId == self._depthId: + self._depthId = None + self._stencilId = None + if self._depthId is not None: + gl.glDeleteRenderbuffers(self._depthId) + self._depthId = None + + self._texture.discard() # Also discard the texture + else: + _logger.warning("Discard has already been called") diff --git a/silx/gui/_glutils/Program.py b/silx/gui/_glutils/Program.py new file mode 100644 index 0000000..48c12f5 --- /dev/null +++ b/silx/gui/_glutils/Program.py @@ -0,0 +1,202 @@ +# 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 provides a class to handle shader program compilation.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +import logging + +import numpy + +from . import gl +from .Context import getGLContext + +_logger = logging.getLogger(__name__) + + +class Program(object): + """Wrap OpenGL shader program. + + The program is compiled lazily (i.e., at first program :meth:`use`). + When the program is compiled, it stores attributes and uniforms locations. + So, attributes and uniforms must be used after :meth:`use`. + + This object supports multiple OpenGL contexts. + + :param str vertexShader: The source of the vertex shader. + :param str fragmentShader: The source of the fragment shader. + :param str attrib0: + Attribute's name to bind to position 0 (default: 'position'). + On certain platform, this attribute MUST be active and with an + array attached to it in order for the rendering to occur.... + """ + + def __init__(self, vertexShader, fragmentShader, + attrib0='position'): + self._vertexShader = vertexShader + self._fragmentShader = fragmentShader + self._attrib0 = attrib0 + self._programs = {} + + @staticmethod + def _compileGL(vertexShader, fragmentShader, attrib0): + program = gl.glCreateProgram() + + gl.glBindAttribLocation(program, 0, attrib0.encode('ascii')) + + vertex = gl.glCreateShader(gl.GL_VERTEX_SHADER) + gl.glShaderSource(vertex, vertexShader) + gl.glCompileShader(vertex) + if gl.glGetShaderiv(vertex, gl.GL_COMPILE_STATUS) != gl.GL_TRUE: + raise RuntimeError(gl.glGetShaderInfoLog(vertex)) + gl.glAttachShader(program, vertex) + gl.glDeleteShader(vertex) + + fragment = gl.glCreateShader(gl.GL_FRAGMENT_SHADER) + gl.glShaderSource(fragment, fragmentShader) + gl.glCompileShader(fragment) + if gl.glGetShaderiv(fragment, + gl.GL_COMPILE_STATUS) != gl.GL_TRUE: + raise RuntimeError(gl.glGetShaderInfoLog(fragment)) + gl.glAttachShader(program, fragment) + gl.glDeleteShader(fragment) + + gl.glLinkProgram(program) + if gl.glGetProgramiv(program, gl.GL_LINK_STATUS) != gl.GL_TRUE: + raise RuntimeError(gl.glGetProgramInfoLog(program)) + + attributes = {} + for index in range(gl.glGetProgramiv(program, + gl.GL_ACTIVE_ATTRIBUTES)): + name = gl.glGetActiveAttrib(program, index)[0] + namestr = name.decode('ascii') + attributes[namestr] = gl.glGetAttribLocation(program, name) + + uniforms = {} + for index in range(gl.glGetProgramiv(program, gl.GL_ACTIVE_UNIFORMS)): + name = gl.glGetActiveUniform(program, index)[0] + namestr = name.decode('ascii') + uniforms[namestr] = gl.glGetUniformLocation(program, name) + + return program, attributes, uniforms + + def _getProgramInfo(self): + glcontext = getGLContext() + if glcontext not in self._programs: + raise RuntimeError( + "Program was not compiled for current OpenGL context.") + return self._programs[glcontext] + + @property + def attributes(self): + """Vertex attributes names and locations as a dict of {str: int}. + + WARNING: + Read-only usage. + To use only with a valid OpenGL context and after :meth:`use` + has been called for this context. + """ + return self._getProgramInfo()[1] + + @property + def uniforms(self): + """Program uniforms names and locations as a dict of {str: int}. + + WARNING: + Read-only usage. + To use only with a valid OpenGL context and after :meth:`use` + has been called for this context. + """ + return self._getProgramInfo()[2] + + @property + def program(self): + """OpenGL id of the program. + + WARNING: + To use only with a valid OpenGL context and after :meth:`use` + has been called for this context. + """ + return self._getProgramInfo()[0] + + # def discard(self): + # pass # Not implemented yet + + def use(self): + """Make use of the program, compiling it if necessary""" + glcontext = getGLContext() + + if glcontext not in self._programs: + self._programs[glcontext] = self._compileGL( + self._vertexShader, + self._fragmentShader, + self._attrib0) + + if _logger.getEffectiveLevel() <= logging.DEBUG: + gl.glValidateProgram(self.program) + if gl.glGetProgramiv( + self.program, gl.GL_VALIDATE_STATUS) != gl.GL_TRUE: + _logger.debug('Cannot validate program: %s', + gl.glGetProgramInfoLog(self.program)) + + gl.glUseProgram(self.program) + + def setUniformMatrix(self, name, value, transpose=True, safe=False): + """Wrap glUniformMatrix[2|3|4]fv + + :param str name: The name of the uniform. + :param value: The 2D matrix (or the array of matrices, 3D). + Matrices are 2x2, 3x3 or 4x4. + :type value: numpy.ndarray with 2 or 3 dimensions of float32 + :param bool transpose: Whether to transpose (True, default) or not. + :param bool safe: False: raise an error if no uniform with this name; + True: silently ignores it. + + :raises KeyError: if no uniform corresponds to name. + """ + assert value.dtype == numpy.float32 + + shape = value.shape + assert len(shape) in (2, 3) + assert shape[-1] in (2, 3, 4) + assert shape[-1] == shape[-2] # As in OpenGL|ES 2.0 + + location = self.uniforms.get(name) + if location is not None: + count = 1 if len(shape) == 2 else shape[0] + transpose = gl.GL_TRUE if transpose else gl.GL_FALSE + + if shape[-1] == 2: + gl.glUniformMatrix2fv(location, count, transpose, value) + elif shape[-1] == 3: + gl.glUniformMatrix3fv(location, count, transpose, value) + elif shape[-1] == 4: + gl.glUniformMatrix4fv(location, count, transpose, value) + + elif not safe: + raise KeyError('No uniform: %s' % name) diff --git a/silx/gui/_glutils/Texture.py b/silx/gui/_glutils/Texture.py new file mode 100644 index 0000000..9f09a86 --- /dev/null +++ b/silx/gui/_glutils/Texture.py @@ -0,0 +1,308 @@ +# 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 provides a class wrapping OpenGL 2D and 3D texture.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "04/10/2016" + + +import collections +from ctypes import c_void_p +import logging + +import numpy + +from . import gl, utils + + +_logger = logging.getLogger(__name__) + + +class Texture(object): + """Base class to wrap OpenGL 2D and 3D texture + + :param internalFormat: OpenGL texture internal format + :param data: The data to copy to the texture or None for an empty texture + :type data: numpy.ndarray or None + :param format_: Input data format if different from internalFormat + :param shape: If data is None, shape of the texture + :type shape: 2 or 3-tuple of int (height, width) or (depth, height, width) + :param int texUnit: The texture unit to use + :param minFilter: OpenGL texture minimization filter (default: GL_NEAREST) + :param magFilter: OpenGL texture magnification filter (default: GL_LINEAR) + :param wrap: Texture wrap mode for dimensions: (t, s) or (r, t, s) + If a single value is provided, it used for all dimensions. + :type wrap: OpenGL wrap mode or 2 or 3-tuple of wrap mode + """ + + def __init__(self, internalFormat, data=None, format_=None, + shape=None, texUnit=0, + minFilter=None, magFilter=None, wrap=None): + + self._internalFormat = internalFormat + if format_ is None: + format_ = self.internalFormat + + if data is None: + assert shape is not None + else: + assert shape is None + data = numpy.array(data, copy=False, order='C') + if format_ != gl.GL_RED: + shape = data.shape[:-1] # Last dimension is channels + else: + shape = data.shape + + assert len(shape) in (2, 3) + self._shape = tuple(shape) + self._ndim = len(shape) + + self.texUnit = texUnit + + self._name = gl.glGenTextures(1) + self.bind(self.texUnit) + + self._minFilter = None + self.minFilter = minFilter if minFilter is not None else gl.GL_NEAREST + + self._magFilter = None + self.magFilter = magFilter if magFilter is not None else gl.GL_LINEAR + + if wrap is not None: + if not isinstance(wrap, collections.Iterable): + wrap = [wrap] * self.ndim + + assert len(wrap) == self.ndim + + gl.glTexParameter(self.target, + gl.GL_TEXTURE_WRAP_S, + wrap[-1]) + gl.glTexParameter(self.target, + gl.GL_TEXTURE_WRAP_T, + wrap[-2]) + if self.ndim == 3: + gl.glTexParameter(self.target, + gl.GL_TEXTURE_WRAP_R, + wrap[0]) + + gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) + + # This are the defaults, useless to set if not modified + # gl.glPixelStorei(gl.GL_UNPACK_ROW_LENGTH, 0) + # gl.glPixelStorei(gl.GL_UNPACK_SKIP_PIXELS, 0) + # gl.glPixelStorei(gl.GL_UNPACK_SKIP_ROWS, 0) + # gl.glPixelStorei(gl.GL_UNPACK_IMAGE_HEIGHT, 0) + # gl.glPixelStorei(gl.GL_UNPACK_SKIP_IMAGES, 0) + + if data is None: + data = c_void_p(0) + type_ = gl.GL_UNSIGNED_BYTE + else: + type_ = utils.numpyToGLType(data.dtype) + + if self.ndim == 2: + _logger.debug( + 'Creating 2D texture shape: (%d, %d),' + ' internal format: %s, format: %s, type: %s', + self.shape[0], self.shape[1], + str(self.internalFormat), str(format_), str(type_)) + + gl.glTexImage2D( + gl.GL_TEXTURE_2D, + 0, + self.internalFormat, + self.shape[1], + self.shape[0], + 0, + format_, + type_, + data) + else: + _logger.debug( + 'Creating 3D texture shape: (%d, %d, %d),' + ' internal format: %s, format: %s, type: %s', + self.shape[0], self.shape[1], self.shape[2], + str(self.internalFormat), str(format_), str(type_)) + + gl.glTexImage3D( + gl.GL_TEXTURE_3D, + 0, + self.internalFormat, + self.shape[2], + self.shape[1], + self.shape[0], + 0, + format_, + type_, + data) + + gl.glBindTexture(self.target, 0) + + @property + def target(self): + """OpenGL target type of this texture""" + return gl.GL_TEXTURE_2D if self.ndim == 2 else gl.GL_TEXTURE_3D + + @property + def ndim(self): + """The number of dimensions: 2 or 3""" + return self._ndim + + @property + def internalFormat(self): + """Texture internal format""" + return self._internalFormat + + @property + def shape(self): + """Shape of the texture: (height, width) or (depth, height, width)""" + return self._shape + + @property + def name(self): + """OpenGL texture name""" + if self._name is not None: + return self._name + else: + raise RuntimeError( + "No OpenGL texture resource, discard has already been called") + + @property + def minFilter(self): + """Minifying function parameter (GL_TEXTURE_MIN_FILTER)""" + return self._minFilter + + @minFilter.setter + def minFilter(self, minFilter): + if minFilter != self.minFilter: + self._minFilter = minFilter + self.bind() + gl.glTexParameter(self.target, + gl.GL_TEXTURE_MIN_FILTER, + self.minFilter) + + @property + def magFilter(self): + """Magnification function parameter (GL_TEXTURE_MAG_FILTER)""" + return self._magFilter + + @magFilter.setter + def magFilter(self, magFilter): + if magFilter != self.magFilter: + self._magFilter = magFilter + self.bind() + gl.glTexParameter(self.target, + gl.GL_TEXTURE_MAG_FILTER, + self.magFilter) + + def discard(self): + """Delete associated OpenGL texture""" + if self._name is not None: + gl.glDeleteTextures(self._name) + self._name = None + else: + _logger.warning("Discard as already been called") + + def bind(self, texUnit=None): + """Bind the texture to a texture unit. + + :param int texUnit: The texture unit to use + """ + if texUnit is None: + texUnit = self.texUnit + gl.glActiveTexture(gl.GL_TEXTURE0 + texUnit) + gl.glBindTexture(self.target, self.name) + + # with statement + + def __enter__(self): + self.bind() + + def __exit__(self, exc_type, exc_val, exc_tb): + gl.glActiveTexture(gl.GL_TEXTURE0 + self.texUnit) + gl.glBindTexture(self.target, 0) + + def update(self, + format_, + data, + offset=(0, 0, 0), + texUnit=None): + """Update the content of the texture. + + Texture is not resized, so data must fit into texture with the + given offset. + + :param format_: The OpenGL format of the data + :param data: The data to use to update the texture + :param offset: The offset in the texture where to copy the data + :type offset: 2 or 3-tuple of int + :param int texUnit: + The texture unit to use (default: the one provided at init) + """ + data = numpy.array(data, copy=False, order='C') + + assert data.ndim == self.ndim + assert len(offset) >= self.ndim + for i in range(self.ndim): + assert offset[i] + data.shape[i] <= self.shape[i] + + gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) + + # This are the defaults, useless to set if not modified + # gl.glPixelStorei(gl.GL_UNPACK_ROW_LENGTH, 0) + # gl.glPixelStorei(gl.GL_UNPACK_SKIP_PIXELS, 0) + # gl.glPixelStorei(gl.GL_UNPACK_SKIP_ROWS, 0) + # gl.glPixelStorei(gl.GL_UNPACK_IMAGE_HEIGHT, 0) + # gl.glPixelStorei(gl.GL_UNPACK_SKIP_IMAGES, 0) + + self.bind(texUnit) + + type_ = utils.numpyToGLType(data.dtype) + + if self.ndim == 2: + gl.glTexSubImage2D(gl.GL_TEXTURE_2D, + 0, + offset[1], + offset[0], + data.shape[1], + data.shape[0], + format_, + type_, + data) + gl.glBindTexture(gl.GL_TEXTURE_2D, 0) + else: + gl.glTexSubImage3D(gl.GL_TEXTURE_3D, + 0, + offset[2], + offset[1], + offset[0], + data.shape[2], + data.shape[1], + data.shape[0], + format_, + type_, + data) + gl.glBindTexture(gl.GL_TEXTURE_3D, 0) diff --git a/silx/gui/_glutils/VertexBuffer.py b/silx/gui/_glutils/VertexBuffer.py new file mode 100644 index 0000000..689b543 --- /dev/null +++ b/silx/gui/_glutils/VertexBuffer.py @@ -0,0 +1,266 @@ +# 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 provides a class managing an OpenGL vertex buffer.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "10/01/2017" + + +import logging +from ctypes import c_void_p +import numpy + +from . import gl +from .utils import numpyToGLType, sizeofGLType + + +_logger = logging.getLogger(__name__) + + +class VertexBuffer(object): + """Object handling an OpenGL vertex buffer object + + :param data: Data used to fill the vertex buffer + :type data: numpy.ndarray or None + :param int size: Size in bytes of the buffer or None for data size + :param usage: OpenGL vertex buffer expected usage pattern: + GL_STREAM_DRAW, GL_STATIC_DRAW (default) or GL_DYNAMIC_DRAW + :param target: Target buffer: + GL_ARRAY_BUFFER (default) or GL_ELEMENT_ARRAY_BUFFER + """ + # OpenGL|ES 2.0 subset: + _USAGES = gl.GL_STREAM_DRAW, gl.GL_STATIC_DRAW, gl.GL_DYNAMIC_DRAW + _TARGETS = gl.GL_ARRAY_BUFFER, gl.GL_ELEMENT_ARRAY_BUFFER + + def __init__(self, + data=None, + size=None, + usage=None, + target=None): + if usage is None: + usage = gl.GL_STATIC_DRAW + assert usage in self._USAGES + + if target is None: + target = gl.GL_ARRAY_BUFFER + assert target in self._TARGETS + + self._target = target + self._usage = usage + + self._name = gl.glGenBuffers(1) + self.bind() + + if data is None: + assert size is not None + self._size = size + gl.glBufferData(self._target, + self._size, + c_void_p(0), + self._usage) + else: + data = numpy.array(data, copy=False, order='C') + if size is not None: + assert size <= data.nbytes + + self._size = size or data.nbytes + gl.glBufferData(self._target, + self._size, + data, + self._usage) + + gl.glBindBuffer(self._target, 0) + + @property + def target(self): + """The target buffer of the vertex buffer""" + return self._target + + @property + def usage(self): + """The expected usage of the vertex buffer""" + return self._usage + + @property + def name(self): + """OpenGL Vertex Buffer object name (int)""" + if self._name is not None: + return self._name + else: + raise RuntimeError("No OpenGL buffer resource, \ + discard has already been called") + + @property + def size(self): + """Size in bytes of the Vertex Buffer Object (int)""" + if self._size is not None: + return self._size + else: + raise RuntimeError("No OpenGL buffer resource, \ + discard has already been called") + + def bind(self): + """Bind the vertex buffer""" + gl.glBindBuffer(self._target, self.name) + + def update(self, data, offset=0, size=None): + """Update vertex buffer content. + + :param numpy.ndarray data: The data to put in the vertex buffer + :param int offset: Offset in bytes in the buffer where to put the data + :param int size: If provided, size of data to copy + """ + data = numpy.array(data, copy=False, order='C') + if size is None: + size = data.nbytes + assert offset + size <= self.size + with self: + gl.glBufferSubData(self._target, offset, size, data) + + def discard(self): + """Delete the vertex buffer""" + if self._name is not None: + gl.glDeleteBuffers(self._name) + self._name = None + self._size = None + else: + _logger.warning("Discard has already been called") + + # with statement + + def __enter__(self): + self.bind() + + def __exit__(self, exctype, excvalue, traceback): + gl.glBindBuffer(self._target, 0) + + +class VertexBufferAttrib(object): + """Describes data stored in a vertex buffer + + Convenient class to store info for glVertexAttribPointer calls + + :param VertexBuffer vbo: The vertex buffer storing the data + :param int type_: The OpenGL type of the data + :param int size: The number of data elements stored in the VBO + :param int dimension: The number of `type_` element(s) in [1, 4] + :param int offset: Start offset of data in the vertex buffer + :param int stride: Data stride in the vertex buffer + """ + + _GL_TYPES = gl.GL_UNSIGNED_BYTE, gl.GL_FLOAT, gl.GL_INT + + def __init__(self, + vbo, + type_, + size, + dimension=1, + offset=0, + stride=0, + normalisation=False): + self.vbo = vbo + assert type_ in self._GL_TYPES + self.type_ = type_ + self.size = size + assert 1 <= dimension <= 4 + self.dimension = dimension + self.offset = offset + self.stride = stride + self.normalisation = bool(normalisation) + + @property + def itemsize(self): + """Size in bytes of a vertex buffer element (int)""" + return self.dimension * sizeofGLType(self.type_) + + itemSize = itemsize # Backward compatibility + + def setVertexAttrib(self, attribute): + """Call glVertexAttribPointer with objects information""" + normalisation = gl.GL_TRUE if self.normalisation else gl.GL_FALSE + with self.vbo: + gl.glVertexAttribPointer(attribute, + self.dimension, + self.type_, + normalisation, + self.stride, + c_void_p(self.offset)) + + def copy(self): + return VertexBufferAttrib(self.vbo, + self.type_, + self.size, + self.dimension, + self.offset, + self.stride, + self.normalisation) + + +def vertexBuffer(arrays, prefix=None, suffix=None, usage=None): + """Create a single vertex buffer from multiple 1D or 2D numpy arrays. + + It is possible to reserve memory before and after each array in the VBO + + :param arrays: Arrays of data to store + :type arrays: Iterable of numpy.ndarray + :param prefix: If given, number of elements to reserve before each array + :type prefix: Iterable of int or None + :param suffix: If given, number of elements to reserve after each array + :type suffix: Iterable of int or None + :param int usage: vertex buffer expected usage or None for default + :returns: List of VertexBufferAttrib objects sharing the same vertex buffer + """ + info = [] + vbosize = 0 + + if prefix is None: + prefix = (0,) * len(arrays) + if suffix is None: + suffix = (0,) * len(arrays) + + for data, pre, post in zip(arrays, prefix, suffix): + data = numpy.array(data, copy=False, order='C') + shape = data.shape + assert len(shape) <= 2 + type_ = numpyToGLType(data.dtype) + size = shape[0] + pre + post + dimension = 1 if len(shape) == 1 else shape[1] + sizeinbytes = size * dimension * sizeofGLType(type_) + sizeinbytes = 4 * ((sizeinbytes + 3) >> 2) # 4 bytes alignment + copyoffset = vbosize + pre * dimension * sizeofGLType(type_) + info.append((data, type_, size, dimension, + vbosize, sizeinbytes, copyoffset)) + vbosize += sizeinbytes + + vbo = VertexBuffer(size=vbosize, usage=usage) + + result = [] + for data, type_, size, dimension, offset, sizeinbytes, copyoffset in info: + copysize = data.shape[0] * dimension * sizeofGLType(type_) + vbo.update(data, offset=copyoffset, size=copysize) + result.append( + VertexBufferAttrib(vbo, type_, size, dimension, offset, 0)) + return result diff --git a/silx/gui/_glutils/__init__.py b/silx/gui/_glutils/__init__.py new file mode 100644 index 0000000..e86a58f --- /dev/null +++ b/silx/gui/_glutils/__init__.py @@ -0,0 +1,41 @@ +# 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 package provides utility functions to handle OpenGL resources. + +The :mod:`gl` module provides a wrapper to OpenGL based on PyOpenGL. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +# OpenGL convenient functions +from .Context import getGLContext, setGLContextGetter # noqa +from .FramebufferTexture import FramebufferTexture # noqa +from .Program import Program # noqa +from .Texture import Texture # noqa +from .VertexBuffer import VertexBuffer, VertexBufferAttrib, vertexBuffer # noqa +from .utils import sizeofGLType, isSupportedGLType, numpyToGLType # noqa diff --git a/silx/gui/_glutils/font.py b/silx/gui/_glutils/font.py new file mode 100644 index 0000000..566ae49 --- /dev/null +++ b/silx/gui/_glutils/font.py @@ -0,0 +1,152 @@ +# 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. +# +# ###########################################################################*/ +"""Text rasterisation feature leveraging Qt font and text layout support.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "13/10/2016" + + +import logging +import sys +import numpy +from .. import qt +from .._utils import convertQImageToArray + + +_logger = logging.getLogger(__name__) + + +def getDefaultFontFamily(): + """Returns the default font family of the application""" + return qt.QApplication.instance().font().family() + + +# Font weights +ULTRA_LIGHT = 0 +"""Lightest characters: Minimum font weight""" + +LIGHT = 25 +"""Light characters""" + +NORMAL = 50 +"""Normal characters""" + +SEMI_BOLD = 63 +"""Between normal and bold characters""" + +BOLD = 74 +"""Thicker characters""" + +BLACK = 87 +"""Really thick characters""" + +ULTRA_BLACK = 99 +"""Thickest characters: Maximum font weight""" + + +def rasterText(text, font, + size=-1, + weight=-1, + italic=False, + devicePixelRatio=1.0): + """Raster text using Qt. + + It supports multiple lines. + + :param str text: The text to raster + :param font: Font name or QFont to use + :type font: str or :class:`QFont` + :param int size: + Font size in points + Used only if font is given as name. + :param int weight: + Font weight in [0, 99], see QFont.Weight. + Used only if font is given as name. + :param bool italic: + True for italic font (default: False). + Used only if font is given as name. + :param float devicePixelRatio: + The current ratio between device and device-independent pixel + (default: 1.0) + :return: Corresponding image in gray scale and baseline offset from top + :rtype: (HxW numpy.ndarray of uint8, int) + """ + if not text: + _logger.info("Trying to raster empty text, replaced by white space") + text = ' ' # Replace empty text by white space to produce an image + + if not isinstance(font, qt.QFont): + font = qt.QFont(font, size, weight, italic) + + metrics = qt.QFontMetrics(font) + size = metrics.size(qt.Qt.TextExpandTabs, text) + bounds = metrics.boundingRect( + qt.QRect(0, 0, size.width(), size.height()), + qt.Qt.TextExpandTabs, + text) + + if (devicePixelRatio != 1.0 and + not hasattr(qt.QImage, 'setDevicePixelRatio')): # Qt 4 + _logger.error('devicePixelRatio not supported') + devicePixelRatio = 1.0 + + # Add extra border and handle devicePixelRatio + width = bounds.width() * devicePixelRatio + 2 + # align line size to 32 bits to ease conversion to numpy array + width = 4 * ((width + 3) // 4) + image = qt.QImage(width, + bounds.height() * devicePixelRatio, + qt.QImage.Format_RGB888) + if (devicePixelRatio != 1.0 and + hasattr(image, 'setDevicePixelRatio')): # Qt 5 + image.setDevicePixelRatio(devicePixelRatio) + + # TODO if Qt5 use Format_Grayscale8 instead + image.fill(0) + + # Raster text + painter = qt.QPainter() + painter.begin(image) + painter.setPen(qt.Qt.white) + painter.setFont(font) + painter.drawText(bounds, qt.Qt.TextExpandTabs, text) + painter.end() + + array = convertQImageToArray(image) + + # RGB to R + array = numpy.ascontiguousarray(array[:, :, 0]) + + # Remove leading and trailing empty columns but one on each side + column_cumsum = numpy.cumsum(numpy.sum(array, axis=0)) + array = array[:, column_cumsum.argmin():column_cumsum.argmax() + 2] + + # Remove leading and trailing empty rows but one on each side + row_cumsum = numpy.cumsum(numpy.sum(array, axis=1)) + min_row = row_cumsum.argmin() + array = array[min_row:row_cumsum.argmax() + 2, :] + + return array, metrics.ascent() - min_row diff --git a/silx/gui/_glutils/gl.py b/silx/gui/_glutils/gl.py new file mode 100644 index 0000000..4b9a7bb --- /dev/null +++ b/silx/gui/_glutils/gl.py @@ -0,0 +1,165 @@ +# 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 loads PyOpenGL and provides a namespace for OpenGL.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "25/07/2016" + + +from contextlib import contextmanager as _contextmanager +from ctypes import c_uint +import logging + +_logger = logging.getLogger(__name__) + +import OpenGL +# Set the following to true for debugging +if _logger.getEffectiveLevel() <= logging.DEBUG: + _logger.debug('Enabling PyOpenGL debug flags') + OpenGL.ERROR_LOGGING = True + OpenGL.ERROR_CHECKING = True + OpenGL.ERROR_ON_COPY = True +else: + OpenGL.ERROR_LOGGING = False + OpenGL.ERROR_CHECKING = False + OpenGL.ERROR_ON_COPY = False + +import OpenGL.GL as _GL +from OpenGL.GL import * # noqa + +# Extentions core in OpenGL 3 +from OpenGL.GL.ARB import framebuffer_object as _FBO +from OpenGL.GL.ARB.framebuffer_object import * # noqa +from OpenGL.GL.ARB.texture_rg import GL_R32F, GL_R16F # noqa +from OpenGL.GL.ARB.texture_rg import GL_R16, GL_R8 # noqa + +# PyOpenGL 3.0.1 does not define it +try: + GLchar +except NameError: + from ctypes import c_char + GLchar = c_char + + +def testGL(): + """Test if required OpenGL version and extensions are available. + + This MUST be run with an active OpenGL context. + """ + version = glGetString(GL_VERSION).split()[0] # get version number + major, minor = int(version[0]), int(version[2]) + if major < 2 or (major == 2 and minor < 1): + raise RuntimeError( + "Requires at least OpenGL version 2.1, running with %s" % version) + + from OpenGL.GL.ARB.framebuffer_object import glInitFramebufferObjectARB + from OpenGL.GL.ARB.texture_rg import glInitTextureRgARB + + if not glInitFramebufferObjectARB(): + raise RuntimeError( + "OpenGL GL_ARB_framebuffer_object extension required !") + + if not glInitTextureRgARB(): + raise RuntimeError("OpenGL GL_ARB_texture_rg extension required !") + + +# Additional setup +if hasattr(glget, 'addGLGetConstant'): + glget.addGLGetConstant(GL_FRAMEBUFFER_BINDING, (1,)) + + +@_contextmanager +def enabled(capacity, enable=True): + """Context manager enabling an OpenGL capacity. + + This is not checking the current state of the capacity. + + :param capacity: The OpenGL capacity enum to enable/disable + :param bool enable: + True (default) to enable during context, False to disable + """ + if enable: + glEnable(capacity) + yield + glDisable(capacity) + else: + glDisable(capacity) + yield + glEnable(capacity) + + +def disabled(capacity, disable=True): + """Context manager disabling an OpenGL capacity. + + This is not checking the current state of the capacity. + + :param capacity: The OpenGL capacity enum to disable/enable + :param bool disable: + True (default) to disable during context, False to enable + """ + return enabled(capacity, not disable) + + +# Additional OpenGL wrapping + +def glGetActiveAttrib(program, index): + """Wrap PyOpenGL glGetActiveAttrib""" + bufsize = glGetProgramiv(program, GL_ACTIVE_ATTRIBUTE_MAX_LENGTH) + length = GLsizei() + size = GLint() + type_ = GLenum() + name = (GLchar * bufsize)() + + _GL.glGetActiveAttrib(program, index, bufsize, length, size, type_, name) + return name.value, size.value, type_.value + + +def glDeleteRenderbuffers(buffers): + if not hasattr(buffers, '__len__'): # Support single int argument + buffers = [buffers] + length = len(buffers) + _FBO.glDeleteRenderbuffers(length, (c_uint * length)(*buffers)) + + +def glDeleteFramebuffers(buffers): + if not hasattr(buffers, '__len__'): # Support single int argument + buffers = [buffers] + length = len(buffers) + _FBO.glDeleteFramebuffers(length, (c_uint * length)(*buffers)) + + +def glDeleteBuffers(buffers): + if not hasattr(buffers, '__len__'): # Support single int argument + buffers = [buffers] + length = len(buffers) + _GL.glDeleteBuffers(length, (c_uint * length)(*buffers)) + + +def glDeleteTextures(textures): + if not hasattr(textures, '__len__'): # Support single int argument + textures = [textures] + length = len(textures) + _GL.glDeleteTextures((c_uint * length)(*textures)) diff --git a/silx/gui/_glutils/utils.py b/silx/gui/_glutils/utils.py new file mode 100644 index 0000000..73af338 --- /dev/null +++ b/silx/gui/_glutils/utils.py @@ -0,0 +1,70 @@ +# 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 provides conversion functions between OpenGL and numpy types. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "10/01/2017" + +from . import gl +import numpy + + +_GL_TYPE_SIZES = { + gl.GL_FLOAT: 4, + gl.GL_BYTE: 1, + gl.GL_SHORT: 2, + gl.GL_INT: 4, + gl.GL_UNSIGNED_BYTE: 1, + gl.GL_UNSIGNED_SHORT: 2, + gl.GL_UNSIGNED_INT: 4, +} + + +def sizeofGLType(type_): + """Returns the size in bytes of an element of type `type_`""" + return _GL_TYPE_SIZES[type_] + + +_TYPE_CONVERTER = { + numpy.dtype(numpy.float32): gl.GL_FLOAT, + numpy.dtype(numpy.int8): gl.GL_BYTE, + numpy.dtype(numpy.int16): gl.GL_SHORT, + numpy.dtype(numpy.int32): gl.GL_INT, + numpy.dtype(numpy.uint8): gl.GL_UNSIGNED_BYTE, + numpy.dtype(numpy.uint16): gl.GL_UNSIGNED_SHORT, + numpy.dtype(numpy.uint32): gl.GL_UNSIGNED_INT, +} + + +def isSupportedGLType(type_): + """Test if a numpy type or dtype can be converted to a GL type.""" + return numpy.dtype(type_) in _TYPE_CONVERTER + + +def numpyToGLType(type_): + """Returns the GL type corresponding the provided numpy type or dtype.""" + return _TYPE_CONVERTER[numpy.dtype(type_)] diff --git a/silx/gui/_utils.py b/silx/gui/_utils.py new file mode 100644 index 0000000..e29141f --- /dev/null +++ b/silx/gui/_utils.py @@ -0,0 +1,102 @@ +# 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 provides convenient functions to use with Qt objects. + +It provides conversion between numpy and QImage. +""" + +from __future__ import division + + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "16/01/2017" + + +import sys +import numpy + +from . import qt + + +def convertArrayToQImage(image): + """Convert an array-like RGB888 image to a QImage. + + The created QImage is using a copy of the array data. + + Limitation: Only supports RGB888 format. + + :param image: Array-like image data + :type image: numpy.ndarray of uint8 of dimension HxWx3 + :return: Corresponding Qt image + :rtype: QImage + """ + # Possible extension: add a format argument to support more formats + + image = numpy.array(image, copy=False, order='C', dtype=numpy.uint8) + + height, width, depth = image.shape + assert depth == 3 + + qimage = qt.QImage( + image.data, + width, + height, + image.strides[0], # bytesPerLine + qt.QImage.Format_RGB888) + + return qimage.copy() # Making a copy of the image and its data + + +def convertQImageToArray(image): + """Convert a RGB888 QImage to a numpy array. + + Limitation: Only supports RGB888 format. + If QImage is not RGB888 it gets converted to this format. + + :param QImage: The QImage to convert. + :return: The image array + :rtype: numpy.ndarray of uint8 of shape HxWx3 + """ + # Possible extension: avoid conversion to support more formats + + if image.format() != qt.QImage.Format_RGB888: + # Convert to RGB888 if needed + image = image.convertToFormat(qt.QImage.Format_RGB888) + + ptr = image.bits() + if qt.BINDING != 'PySide': + ptr.setsize(image.byteCount()) + if qt.BINDING == 'PyQt4' and sys.version_info[0] == 2: + ptr = ptr.asstring() + elif sys.version_info[0] == 3: # PySide with Python3 + ptr = ptr.tobytes() + + array = numpy.fromstring(ptr, dtype=numpy.uint8) + + # Lines are 32 bits aligned: remove padding bytes + array = array.reshape(image.height(), -1)[:, :image.width() * 3] + array.shape = image.height(), image.width(), 3 + return array diff --git a/silx/gui/console.py b/silx/gui/console.py new file mode 100644 index 0000000..13760b4 --- /dev/null +++ b/silx/gui/console.py @@ -0,0 +1,214 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides an IPython console widget. + +You can push variables - any python object - to the +console's interactive namespace. This provides users with an advanced way +of interacting with your program. For instance, if your program has a +:class:`PlotWidget` or a :class:`PlotWindow`, you can push a reference to +these widgets to allow your users to add curves, save data to files… by using +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 + imported while the dependencies are not satisfied. + +Basic usage example:: + + from silx.gui import qt + from silx.gui.console import IPythonWidget + + app = qt.QApplication([]) + + hello_button = qt.QPushButton("Hello World!", None) + hello_button.show() + + console = IPythonWidget() + console.show() + console.pushVariables({"the_button": hello_button}) + + app.exec_() + +This program will display a console widget and a push button in two separate +windows. You will be able to interact with the button from the console, +for example change its text:: + + >>> the_button.setText("Spam spam") + +An IPython interactive console is a powerful tool that enables you to work +with data and plot it. +See `this tutorial <https://plot.ly/python/ipython-notebook-tutorial/>`_ +for more information on some of the rich features of IPython. +""" +__authors__ = ["Tim Rae", "V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "24/05/2016" + +import logging + +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"). +try: + __IPYTHON__ +except NameError: + pass # Not in IPython +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 + + from IPython.qt.console.rich_ipython_widget import RichIPythonWidget + from IPython.qt.inprocess import QtInProcessKernelManager + + +class IPythonWidget(RichIPythonWidget): + """Live IPython console widget. + + :param custom_banner: Custom welcome message to be printed at the top of + the console. + """ + + def __init__(self, parent=None, custom_banner=None, *args, **kwargs): + if parent is not None: + kwargs["parent"] = parent + super(IPythonWidget, self).__init__(*args, **kwargs) + if custom_banner is not None: + self.banner = custom_banner + self.setWindowTitle(self.banner) + self.kernel_manager = kernel_manager = QtInProcessKernelManager() + kernel_manager.start_kernel() + self.kernel_client = kernel_client = self._kernel_manager.client() + kernel_client.start_channels() + + def stop(): + kernel_client.stop_channels() + kernel_manager.shutdown_kernel() + self.exit_requested.connect(stop) + + def sizeHint(self): + """Return a reasonable default size for usage in :class:`PlotWindow`""" + return qt.QSize(500, 300) + + def pushVariables(self, variable_dict): + """ Given a dictionary containing name / value pairs, push those + variables to the IPython console widget. + + :param variable_dict: Dictionary of variables to be pushed to the + console's interactive namespace (```{variable_name: object, …}```) + """ + self.kernel_manager.kernel.shell.push(variable_dict) + + +class IPythonDockWidget(qt.QDockWidget): + """Dock Widget including a :class:`IPythonWidget` inside + a vertical layout. + + :param available_vars: Dictionary of variables to be pushed to the + console's interactive namespace: ``{"variable_name": object, …}`` + :param custom_banner: Custom welcome message to be printed at the top of + the console + :param title: Dock widget title + :param parent: Parent :class:`qt.QMainWindow` containing this + :class:`qt.QDockWidget` + """ + def __init__(self, parent=None, available_vars=None, custom_banner=None, + title="Console"): + super(IPythonDockWidget, self).__init__(title, parent) + + self.ipyconsole = IPythonWidget(custom_banner=custom_banner) + + self.layout().setContentsMargins(0, 0, 0, 0) + self.setWidget(self.ipyconsole) + + if available_vars is not None: + self.ipyconsole.pushVariables(available_vars) + + def showEvent(self, event): + """Make sure this widget is raised when it is shown + (when it is first created as a tab in PlotWindow or when it is shown + again after hiding). + """ + self.raise_() + + +def main(): + """Run a Qt app with an IPython console""" + app = qt.QApplication([]) + widget = IPythonDockWidget() + widget.show() + app.exec_() + +if __name__ == '__main__': + main() diff --git a/silx/gui/data/ArrayTableModel.py b/silx/gui/data/ArrayTableModel.py new file mode 100644 index 0000000..87a2fc1 --- /dev/null +++ b/silx/gui/data/ArrayTableModel.py @@ -0,0 +1,610 @@ +# 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. +# +# ###########################################################################*/ +""" +This module defines a data model for displaying and editing arrays of any +number of dimensions in a table view. +""" +from __future__ import division +import numpy +import logging +from silx.gui import qt +from silx.gui.data.TextFormatter import TextFormatter + +__authors__ = ["V.A. Sole"] +__license__ = "MIT" +__date__ = "24/01/2017" + + +_logger = logging.getLogger(__name__) + + +def _is_array(data): + """Return True if object implements all necessary attributes to be used + as a numpy array. + + :param object data: Array-like object (numpy array, h5py dataset...) + :return: boolean + """ + # add more required attribute if necessary + for attr in ("shape", "dtype"): + if not hasattr(data, attr): + return False + return True + + +class ArrayTableModel(qt.QAbstractTableModel): + """This data model provides access to 2D slices in a N-dimensional + array. + + A slice for a 3-D array is characterized by a perspective (the number of + the axis orthogonal to the slice) and an index at which the slice + intersects the orthogonal axis. + + In the n-D case, only slices parallel to the last two axes are handled. A + slice is therefore characterized by a list of indices locating the + slice on all the :math:`n - 2` orthogonal axes. + + :param parent: Parent QObject + :param data: Numpy array, or object implementing a similar interface + (e.g. h5py dataset) + :param str fmt: Format string for representing numerical values. + Default is ``"%g"``. + :param sequence[int] perspective: See documentation + of :meth:`setPerspective`. + """ + def __init__(self, parent=None, data=None, perspective=None): + qt.QAbstractTableModel.__init__(self, parent) + + self._array = None + """n-dimensional numpy array""" + + self._bgcolors = None + """(n+1)-dimensional numpy array containing RGB(A) color data + for the background color + """ + + self._fgcolors = None + """(n+1)-dimensional numpy array containing RGB(A) color data + for the foreground color + """ + + self._formatter = None + """Formatter for text representation of data""" + + formatter = TextFormatter(self) + formatter.setUseQuoteForText(False) + self.setFormatter(formatter) + + self._index = None + """This attribute stores the slice index, as a list of indices + where the frame intersects orthogonal axis.""" + + self._perspective = None + """Sequence of dimensions orthogonal to the frame to be viewed. + For an array with ``n`` dimensions, this is a sequence of ``n-2`` + integers. the first dimension is numbered ``0``. + By default, the data frames use the last two dimensions as their axes + and therefore the perspective is a sequence of the first ``n-2`` + dimensions. + For example, for a 5-D array, the default perspective is ``(0, 1, 2)`` + and the default frames axes are ``(3, 4)``.""" + + # set _data and _perspective + self.setArrayData(data, perspective=perspective) + + def _getRowDim(self): + """The row axis is the first axis parallel to the frames + (lowest dimension number) + + Return None for 0-D (scalar) or 1-D arrays + """ + n_dimensions = len(self._array.shape) + if n_dimensions < 2: + # scalar or 1D array: no row index + return None + # take all dimensions and remove the orthogonal ones + frame_axes = set(range(0, n_dimensions)) - set(self._perspective) + # sanity check + assert len(frame_axes) == 2 + return min(frame_axes) + + def _getColumnDim(self): + """The column axis is the second (highest dimension) axis parallel + to the frames + + Return None for 0-D (scalar) + """ + n_dimensions = len(self._array.shape) + if n_dimensions < 1: + # scalar: no column index + return None + frame_axes = set(range(0, n_dimensions)) - set(self._perspective) + # sanity check + assert (len(frame_axes) == 2) if n_dimensions > 1 else (len(frame_axes) == 1) + return max(frame_axes) + + def _getIndexTuple(self, table_row, table_col): + """Return the n-dimensional index of a value in the original array, + based on its row and column indices in the table view + + :param table_row: Row index (0-based) of a table cell + :param table_col: Column index (0-based) of a table cell + :return: Tuple of indices of the element in the numpy array + """ + row_dim = self._getRowDim() + col_dim = self._getColumnDim() + + # get indices on all orthogonal axes + selection = list(self._index) + # insert indices on parallel axes + if row_dim is not None: + selection.insert(row_dim, table_row) + if col_dim is not None: + selection.insert(col_dim, table_col) + return tuple(selection) + + # Methods to be implemented to subclass QAbstractTableModel + def rowCount(self, parent_idx=None): + """QAbstractTableModel method + Return number of rows to be displayed in table""" + row_dim = self._getRowDim() + if row_dim is None: + # 0-D and 1-D arrays + return 1 + return self._array.shape[row_dim] + + def columnCount(self, parent_idx=None): + """QAbstractTableModel method + Return number of columns to be displayed in table""" + col_dim = self._getColumnDim() + if col_dim is None: + # 0-D array + return 1 + return self._array.shape[col_dim] + + def data(self, index, role=qt.Qt.DisplayRole): + """QAbstractTableModel method to access data values + in the format ready to be displayed""" + if index.isValid(): + selection = self._getIndexTuple(index.row(), + index.column()) + if role == qt.Qt.DisplayRole: + return self._formatter.toString(self._array[selection]) + + if role == qt.Qt.BackgroundRole and self._bgcolors is not None: + r, g, b = self._bgcolors[selection][0:3] + if self._bgcolors.shape[-1] == 3: + return qt.QColor(r, g, b) + if self._bgcolors.shape[-1] == 4: + a = self._bgcolors[selection][3] + return qt.QColor(r, g, b, a) + + if role == qt.Qt.ForegroundRole: + if self._fgcolors is not None: + r, g, b = self._fgcolors[selection][0:3] + if self._fgcolors.shape[-1] == 3: + return qt.QColor(r, g, b) + if self._fgcolors.shape[-1] == 4: + a = self._fgcolors[selection][3] + return qt.QColor(r, g, b, a) + + # no fg color given, use black or white + # based on luminosity threshold + elif self._bgcolors is not None: + r, g, b = self._bgcolors[selection][0:3] + lum = 0.21 * r + 0.72 * g + 0.07 * b + if lum < 128: + return qt.QColor(qt.Qt.white) + else: + return qt.QColor(qt.Qt.black) + + def headerData(self, section, orientation, role=qt.Qt.DisplayRole): + """QAbstractTableModel method + Return the 0-based row or column index, for display in the + horizontal and vertical headers""" + if role == qt.Qt.DisplayRole: + if orientation == qt.Qt.Vertical: + return "%d" % section + if orientation == qt.Qt.Horizontal: + return "%d" % section + return None + + def flags(self, index): + """QAbstractTableModel method to inform the view whether data + is editable or not.""" + if not self._editable: + return qt.QAbstractTableModel.flags(self, index) + return qt.QAbstractTableModel.flags(self, index) | qt.Qt.ItemIsEditable + + def setData(self, index, value, role=None): + """QAbstractTableModel method to handle editing data. + Cast the new value into the same format as the array before editing + the array value.""" + if index.isValid() and role == qt.Qt.EditRole: + try: + # cast value to same type as array + v = numpy.asscalar( + numpy.array(value, dtype=self._array.dtype)) + except ValueError: + return False + + selection = self._getIndexTuple(index.row(), + index.column()) + self._array[selection] = v + self.dataChanged.emit(index, index) + return True + else: + return False + + # Public methods + def setArrayData(self, data, copy=True, + perspective=None, editable=False): + """Set the data array and the viewing perspective. + + You can set ``copy=False`` if you need more performances, when dealing + with a large numpy array. In this case, a simple reference to the data + is used to access the data, rather than a copy of the array. + + .. warning:: + + Any change to the data model will affect your original data + array, when using a reference rather than a copy.. + + :param data: n-dimensional numpy array, or any object that can be + converted to a numpy array using ``numpy.array(data)`` (e.g. + a nested sequence). + :param bool copy: If *True* (default), a copy of the array is stored + and the original array is not modified if the table is edited. + If *False*, then the behavior depends on the data type: + if possible (if the original array is a proper numpy array) + a reference to the original array is used. + :param perspective: See documentation of :meth:`setPerspective`. + If None, the default perspective is the list of the first ``n-2`` + dimensions, to view frames parallel to the last two axes. + :param bool editable: Flag to enable editing data. Default *False*. + """ + if qt.qVersion() > "4.6": + self.beginResetModel() + else: + self.reset() + + if data is None: + # empty array + self._array = numpy.array([]) + elif copy: + # copy requested (default) + self._array = numpy.array(data, copy=True) + elif not _is_array(data): + raise TypeError("data is not a proper array. Try setting" + + " copy=True to convert it into a numpy array" + + " (this will cause the data to be copied!)") + # # copy not requested, but necessary + # _logger.warning( + # "data is not an array-like object. " + + # "Data must be copied.") + # self._array = numpy.array(data, copy=True) + else: + # Copy explicitly disabled & data implements required attributes. + # We can use a reference. + self._array = data + + # reset colors to None if new data shape is inconsistent + valid_color_shapes = (self._array.shape + (3,), + self._array.shape + (4,)) + if self._bgcolors is not None: + if self._bgcolors.shape not in valid_color_shapes: + self._bgcolors = None + if self._fgcolors is not None: + if self._fgcolors.shape not in valid_color_shapes: + self._fgcolors = None + + self.setEditable(editable) + + self._index = [0 for _i in range((len(self._array.shape) - 2))] + self._perspective = tuple(perspective) if perspective is not None else\ + tuple(range(0, len(self._array.shape) - 2)) + + if qt.qVersion() > "4.6": + self.endResetModel() + + def setArrayColors(self, bgcolors=None, fgcolors=None): + """Set the colors for all table cells by passing an array + of RGB or RGBA values (integers between 0 and 255). + + The shape of the colors array must be consistent with the data shape. + + If the data array is n-dimensional, the colors array must be + (n+1)-dimensional, with the first n-dimensions identical to the data + array dimensions, and the last dimension length-3 (RGB) or + length-4 (RGBA). + + :param bgcolors: RGB or RGBA colors array, defining the background color + for each cell in the table. + :param fgcolors: RGB or RGBA colors array, defining the foreground color + (text color) for each cell in the table. + """ + # array must be RGB or RGBA + valid_shapes = (self._array.shape + (3,), self._array.shape + (4,)) + errmsg = "Inconsistent shape for color array, should be %s or %s" % valid_shapes + + if bgcolors is not None: + if not _is_array(bgcolors): + bgcolors = numpy.array(bgcolors) + assert bgcolors.shape in valid_shapes, errmsg + + self._bgcolors = bgcolors + + if fgcolors is not None: + if not _is_array(fgcolors): + fgcolors = numpy.array(fgcolors) + assert fgcolors.shape in valid_shapes, errmsg + + self._fgcolors = fgcolors + + def setEditable(self, editable): + """Set flags to make the data editable. + + .. warning:: + + If the data is a reference to a h5py dataset open in read-only + mode, setting *editable=True* will fail and print a warning. + + .. warning:: + + Making the data editable means that the underlying data structure + in this data model will be modified. + If the data is a reference to a public object (open with + ``copy=False``), this could have side effects. If it is a + reference to an HDF5 dataset, this means the file will be + modified. + + :param bool editable: Flag to enable editing data. + :return: True if setting desired flag succeeded, False if it failed. + """ + self._editable = editable + if hasattr(self._array, "file"): + if hasattr(self._array.file, "mode"): + if editable and self._array.file.mode == "r": + _logger.warning( + "Data is a HDF5 dataset open in read-only " + + "mode. Editing must be disabled.") + self._editable = False + return False + return True + + def getData(self, copy=True): + """Return a copy of the data array, or a reference to it + if *copy=False* is passed as parameter. + + In case the shape was modified, to convert 0-D or 1-D data + into 2-D data, the original shape is restored in the returned data. + + :param bool copy: If *True* (default), return a copy of the data. If + *False*, return a reference. + :return: numpy array of data, or reference to original data object + if *copy=False* + """ + data = self._array if not copy else numpy.array(self._array, copy=True) + return data + + def setFrameIndex(self, index): + """Set the active slice index. + + This method is only relevant to arrays with at least 3 dimensions. + + :param index: Index of the active slice in the array. + In the general n-D case, this is a sequence of :math:`n - 2` + indices where the slice intersects the respective orthogonal axes. + :raise IndexError: If any index in the index sequence is out of bound + on its respective axis. + """ + shape = self._array.shape + if len(shape) < 3: + # index is ignored + return + + if qt.qVersion() > "4.6": + self.beginResetModel() + else: + self.reset() + + if len(shape) == 3: + len_ = shape[self._perspective[0]] + # accept integers as index in the case of 3-D arrays + if not hasattr(index, "__len__"): + self._index = [index] + else: + self._index = index + if not 0 <= self._index[0] < len_: + raise ValueError("Index must be a positive integer " + + "lower than %d" % len_) + else: + # general n-D case + for i_, idx in enumerate(index): + if not 0 <= idx < shape[self._perspective[i_]]: + raise IndexError("Invalid index %d " % idx + + "not in range 0-%d" % (shape[i_] - 1)) + self._index = index + + if qt.qVersion() > "4.6": + self.endResetModel() + + def setFormatter(self, formatter): + """Set the formatter object to be used to display data from the model + + :param TextFormatter formatter: Formatter to use + """ + if formatter is self._formatter: + return + + if qt.qVersion() > "4.6": + self.beginResetModel() + + if self._formatter is not None: + self._formatter.formatChanged.disconnect(self.__formatChanged) + + self._formatter = formatter + if self._formatter is not None: + self._formatter.formatChanged.connect(self.__formatChanged) + + if qt.qVersion() > "4.6": + self.endResetModel() + else: + self.reset() + + def getFormatter(self): + """Returns the text formatter used. + + :rtype: TextFormatter + """ + return self._formatter + + def __formatChanged(self): + """Called when the format changed. + """ + self.reset() + + def setPerspective(self, perspective): + """Set the perspective by defining a sequence listing all axes + orthogonal to the frame or 2-D slice to be visualized. + + Alternatively, you can use :meth:`setFrameAxes` for the complementary + approach of specifying the two axes parallel to the frame. + + In the 1-D or 2-D case, this parameter is irrelevant. + + In the 3-D case, if the unit vectors describing + your axes are :math:`\vec{x}, \vec{y}, \vec{z}`, a perspective of 0 + means you slices are parallel to :math:`\vec{y}\vec{z}`, 1 means they + are parallel to :math:`\vec{x}\vec{z}` and 2 means they + are parallel to :math:`\vec{x}\vec{y}`. + + In the n-D case, this parameter is a sequence of :math:`n-2` axes + numbers. + For instance if you want to display 2-D frames whose axes are the + second and third dimensions of a 5-D array, set the perspective to + ``(0, 3, 4)``. + + :param perspective: Sequence of dimensions/axes orthogonal to the + frames. + :raise: IndexError if any value in perspective is higher than the + number of dimensions minus one (first dimension is 0), or + if the number of values is different from the number of dimensions + minus two. + """ + n_dimensions = len(self._array.shape) + if n_dimensions < 3: + _logger.warning( + "perspective is not relevant for 1D and 2D arrays") + return + + if not hasattr(perspective, "__len__"): + # we can tolerate an integer for 3-D array + if n_dimensions == 3: + perspective = [perspective] + else: + raise ValueError("perspective must be a sequence of integers") + + # ensure unicity of dimensions in perspective + perspective = tuple(set(perspective)) + + if len(perspective) != n_dimensions - 2 or\ + min(perspective) < 0 or max(perspective) >= n_dimensions: + raise IndexError( + "Invalid perspective " + str(perspective) + + " for %d-D array " % n_dimensions + + "with shape " + str(self._array.shape)) + + if qt.qVersion() > "4.6": + self.beginResetModel() + else: + self.reset() + + self._perspective = perspective + + # reset index + self._index = [0 for _i in range(n_dimensions - 2)] + + if qt.qVersion() > "4.6": + self.endResetModel() + + def setFrameAxes(self, row_axis, col_axis): + """Set the perspective by specifying the two axes parallel to the frame + to be visualised. + + The complementary approach of defining the orthogonal axes can be used + with :meth:`setPerspective`. + + :param int row_axis: Index (0-based) of the first dimension used as a frame + axis + :param int col_axis: Index (0-based) of the 2nd dimension used as a frame + axis + :raise: IndexError if axes are invalid + """ + if row_axis > col_axis: + _logger.warning("The dimension of the row axis must be lower " + + "than the dimension of the column axis. Swapping.") + row_axis, col_axis = min(row_axis, col_axis), max(row_axis, col_axis) + + n_dimensions = len(self._array.shape) + if n_dimensions < 3: + _logger.warning( + "Frame axes cannot be changed for 1D and 2D arrays") + return + + perspective = tuple(set(range(0, n_dimensions)) - {row_axis, col_axis}) + + if len(perspective) != n_dimensions - 2 or\ + min(perspective) < 0 or max(perspective) >= n_dimensions: + raise IndexError( + "Invalid perspective " + str(perspective) + + " for %d-D array " % n_dimensions + + "with shape " + str(self._array.shape)) + + if qt.qVersion() > "4.6": + self.beginResetModel() + else: + self.reset() + + self._perspective = perspective + # reset index + self._index = [0 for _i in range(n_dimensions - 2)] + + if qt.qVersion() > "4.6": + self.endResetModel() + + +if __name__ == "__main__": + app = qt.QApplication([]) + w = qt.QTableView() + d = numpy.random.normal(0, 1, (5, 1000, 1000)) + for i in range(5): + d[i, :, :] += i * 10 + m = ArrayTableModel(data=d) + w.setModel(m) + m.setFrameIndex(3) + # m.setArrayData(numpy.ones((100,))) + w.show() + app.exec_() diff --git a/silx/gui/data/ArrayTableWidget.py b/silx/gui/data/ArrayTableWidget.py new file mode 100644 index 0000000..ba3fa11 --- /dev/null +++ b/silx/gui/data/ArrayTableWidget.py @@ -0,0 +1,490 @@ +# 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. +# +# ###########################################################################*/ +"""This module defines a widget designed to display data arrays with any +number of dimensions as 2D frames (images, slices) in a table view. +The dimensions not displayed in the table can be browsed using improved +sliders. + +The widget uses a TableView that relies on a custom abstract item +model: :class:`silx.gui.data.ArrayTableModel`. +""" +from __future__ import division +import sys + +from silx.gui import qt +from silx.gui.widgets.TableWidget import TableView +from .ArrayTableModel import ArrayTableModel +from silx.gui.widgets.FrameBrowser import HorizontalSliderWithBrowser + +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "24/01/2017" + + +class AxesSelector(qt.QWidget): + """Widget with two combo-boxes to select two dimensions among + all possible dimensions of an n-dimensional array. + + The first combobox contains values from :math:`0` to :math:`n-2`. + + The choices in the 2nd CB depend on the value selected in the first one. + If the value selected in the first CB is :math:`m`, the second one lets you + select values from :math:`m+1` to :math:`n-1`. + + The two axes can be used to select the row axis and the column axis t + display a slice of the array data in a table view. + """ + sigDimensionsChanged = qt.Signal(int, int) + """Signal emitted whenever one of the comboboxes is changed. + The signal carries the two selected dimensions.""" + + def __init__(self, parent=None, n=None): + qt.QWidget.__init__(self, parent) + self.layout = qt.QHBoxLayout(self) + self.layout.setContentsMargins(0, 2, 0, 2) + self.layout.setSpacing(10) + + self.rowsCB = qt.QComboBox(self) + self.columnsCB = qt.QComboBox(self) + + self.layout.addWidget(qt.QLabel("Rows dimension", self)) + self.layout.addWidget(self.rowsCB) + self.layout.addWidget(qt.QLabel(" ", self)) + self.layout.addWidget(qt.QLabel("Columns dimension", self)) + self.layout.addWidget(self.columnsCB) + self.layout.addStretch(1) + + self._slotsAreConnected = False + if n is not None: + self.setNDimensions(n) + + def setNDimensions(self, n): + """Initialize combo-boxes depending on number of dimensions of array. + Initially, the rows dimension is the second-to-last one, and the + columns dimension is the last one. + + Link the CBs together. MAke them emit a signal when their value is + changed. + + :param int n: Number of dimensions of array + """ + # remember the number of dimensions and the rows dimension + self.n = n + self._rowsDim = n - 2 + + # ensure slots are disconnected before (re)initializing widget + if self._slotsAreConnected: + self.rowsCB.currentIndexChanged.disconnect(self._rowDimChanged) + self.columnsCB.currentIndexChanged.disconnect(self._colDimChanged) + + self._clear() + self.rowsCB.addItems([str(i) for i in range(n - 1)]) + self.rowsCB.setCurrentIndex(n - 2) + if n >= 1: + self.columnsCB.addItem(str(n - 1)) + self.columnsCB.setCurrentIndex(0) + + # reconnect slots + self.rowsCB.currentIndexChanged.connect(self._rowDimChanged) + self.columnsCB.currentIndexChanged.connect(self._colDimChanged) + self._slotsAreConnected = True + + # emit new dimensions + if n > 2: + self.sigDimensionsChanged.emit(n - 2, n - 1) + + def setDimensions(self, row_dim, col_dim): + """Set the rows and columns dimensions. + + The rows dimension must be lower than the columns dimension. + + :param int row_dim: Rows dimension + :param int col_dim: Columns dimension + """ + if row_dim >= col_dim: + raise IndexError("Row dimension must be lower than column dimension") + if not (0 <= row_dim < self.n - 1): + raise IndexError("Row dimension must be between 0 and %d" % (self.n - 2)) + if not (row_dim < col_dim <= self.n - 1): + raise IndexError("Col dimension must be between %d and %d" % (row_dim + 1, self.n - 1)) + + # set the rows dimension; this triggers an update of columnsCB + self.rowsCB.setCurrentIndex(row_dim) + # columnsCB first item is "row_dim + 1". So index of "col_dim" is + # col_dim - (row_dim + 1) + self.columnsCB.setCurrentIndex(col_dim - row_dim - 1) + + def getDimensions(self): + """Return a 2-tuple of the rows dimension and the columns dimension. + + :return: 2-tuple of axes numbers (row_dimension, col_dimension) + """ + return self._getRowDim(), self._getColDim() + + def _clear(self): + """Empty the combo-boxes""" + self.rowsCB.clear() + self.columnsCB.clear() + + def _getRowDim(self): + """Get rows dimension, selected in :attr:`rowsCB` + """ + # rows combobox contains elements "0", ..."n-2", + # so the selected dim is always equal to the index + return self.rowsCB.currentIndex() + + def _getColDim(self): + """Get columns dimension, selected in :attr:`columnsCB`""" + # columns combobox contains elements "row_dim+1", "row_dim+2", ..., "n-1" + # so the selected dim is equal to row_dim + 1 + index + return self._rowsDim + 1 + self.columnsCB.currentIndex() + + def _rowDimChanged(self): + """Update columns combobox when the rows dimension is changed. + + Emit :attr:`sigDimensionsChanged`""" + old_col_dim = self._getColDim() + new_row_dim = self._getRowDim() + + # clear cols CB + self.columnsCB.currentIndexChanged.disconnect(self._colDimChanged) + self.columnsCB.clear() + # refill cols CB + for i in range(new_row_dim + 1, self.n): + self.columnsCB.addItem(str(i)) + + # keep previous col dimension if possible + new_col_cb_idx = old_col_dim - (new_row_dim + 1) + if new_col_cb_idx < 0: + # if row_dim is now greater than the previous col_dim, + # we select a new col_dim = row_dim + 1 (first element in cols CB) + new_col_cb_idx = 0 + self.columnsCB.setCurrentIndex(new_col_cb_idx) + + # reconnect slot + self.columnsCB.currentIndexChanged.connect(self._colDimChanged) + + self._rowsDim = new_row_dim + + self.sigDimensionsChanged.emit(self._getRowDim(), self._getColDim()) + + def _colDimChanged(self): + """Emit :attr:`sigDimensionsChanged`""" + self.sigDimensionsChanged.emit(self._getRowDim(), self._getColDim()) + + +def _get_shape(array_like): + """Return shape of an array like object. + + In case the object is a nested sequence (list of lists, tuples...), + the size of each dimension is assumed to be uniform, and is deduced from + the length of the first sequence. + + :param array_like: Array like object: numpy array, hdf5 dataset, + multi-dimensional sequence + :return: Shape of array, as a tuple of integers + """ + if hasattr(array_like, "shape"): + return array_like.shape + + shape = [] + subsequence = array_like + while hasattr(subsequence, "__len__"): + shape.append(len(subsequence)) + subsequence = subsequence[0] + + return tuple(shape) + + +class ArrayTableWidget(qt.QWidget): + """This widget is designed to display data of 2D frames (images, slices) + in a table view. The widget can load any n-dimensional array, and display + any 2-D frame/slice in the array. + + The index of the dimensions orthogonal to the displayed frame can be set + interactively using a browser widget (sliders, buttons and text entries). + + To set the data, use :meth:`setArrayData`. + To select the perspective, use :meth:`setPerspective` or + use :meth:`setFrameAxes`. + To select the frame, use :meth:`setFrameIndex`. + """ + def __init__(self, parent=None): + """ + + :param parent: parent QWidget + :param labels: list of labels for each dimension of the array + """ + qt.QWidget.__init__(self, parent) + self.mainLayout = qt.QVBoxLayout(self) + self.mainLayout.setContentsMargins(0, 0, 0, 0) + self.mainLayout.setSpacing(0) + + self.browserContainer = qt.QWidget(self) + self.browserLayout = qt.QGridLayout(self.browserContainer) + self.browserLayout.setContentsMargins(0, 0, 0, 0) + self.browserLayout.setSpacing(0) + + self._dimensionLabelsText = [] + """List of text labels sorted in the increasing order of the dimension + they apply to.""" + self._browserLabels = [] + """List of QLabel widgets.""" + self._browserWidgets = [] + """List of HorizontalSliderWithBrowser widgets.""" + + self.axesSelector = AxesSelector(self) + + self.view = TableView(self) + + self.mainLayout.addWidget(self.browserContainer) + self.mainLayout.addWidget(self.axesSelector) + self.mainLayout.addWidget(self.view) + + self.model = ArrayTableModel(self) + self.view.setModel(self.model) + + def setArrayData(self, data, labels=None, copy=True, editable=False): + """Set the data array. Update frame browsers and labels. + + :param data: Numpy array or similar object (e.g. nested sequence, + h5py dataset...) + :param labels: list of labels for each dimension of the array, or + boolean ``True`` to use default labels ("dimension 0", + "dimension 1", ...). `None` to disable labels (default). + :param bool copy: If *True*, store a copy of *data* in the model. If + *False*, store a reference to *data* if possible (only possible if + *data* is a proper numpy array or an object that implements the + same methods). + :param bool editable: Flag to enable editing data. Default is *False* + """ + self._data_shape = _get_shape(data) + + n_widgets = len(self._browserWidgets) + n_dimensions = len(self._data_shape) + + # Reset text of labels + self._dimensionLabelsText = [] + for i in range(n_dimensions): + if labels in [True, 1]: + label_text = "Dimension %d" % i + elif labels is None or i >= len(labels): + label_text = "" + else: + label_text = labels[i] + self._dimensionLabelsText.append(label_text) + + # not enough widgets, create new ones (we need n_dim - 2) + for i in range(n_widgets, n_dimensions - 2): + browser = HorizontalSliderWithBrowser(self.browserContainer) + self.browserLayout.addWidget(browser, i, 1) + self._browserWidgets.append(browser) + browser.valueChanged.connect(self._browserSlot) + browser.setEnabled(False) + browser.hide() + + label = qt.QLabel(self.browserContainer) + self._browserLabels.append(label) + self.browserLayout.addWidget(label, i, 0) + label.hide() + + n_widgets = len(self._browserWidgets) + for i in range(n_widgets): + label = self._browserLabels[i] + browser = self._browserWidgets[i] + + if (i + 2) < n_dimensions: + label.setText(self._dimensionLabelsText[i]) + browser.setRange(0, self._data_shape[i] - 1) + browser.setEnabled(True) + browser.show() + if labels is not None: + label.show() + else: + label.hide() + else: + browser.setEnabled(False) + browser.hide() + label.hide() + + # set model + self.model.setArrayData(data, copy=copy, editable=editable) + # some linux distributions need this call + self.view.setModel(self.model) + if editable: + self.view.enableCut() + self.view.enablePaste() + + # initialize & connect axesSelector + self.axesSelector.setNDimensions(n_dimensions) + self.axesSelector.sigDimensionsChanged.connect(self.setFrameAxes) + + def setArrayColors(self, bgcolors=None, fgcolors=None): + """Set the colors for all table cells by passing an array + of RGB or RGBA values (integers between 0 and 255). + + The shape of the colors array must be consistent with the data shape. + + If the data array is n-dimensional, the colors array must be + (n+1)-dimensional, with the first n-dimensions identical to the data + array dimensions, and the last dimension length-3 (RGB) or + length-4 (RGBA). + + :param bgcolors: RGB or RGBA colors array, defining the background color + for each cell in the table. + :param fgcolors: RGB or RGBA colors array, defining the foreground color + (text color) for each cell in the table. + """ + self.model.setArrayColors(bgcolors, fgcolors) + + def displayAxesSelector(self, isVisible): + """Allow to display or hide the axes selector. + + :param bool isVisible: True to display the axes selector. + """ + self.axesSelector.setVisible(isVisible) + + def setFrameIndex(self, index): + """Set the active slice/image index in the n-dimensional array. + + A frame is a 2D array extracted from an array. This frame is + necessarily parallel to 2 axes, and orthogonal to all other axes. + + The index of a frame is a sequence of indices along the orthogonal + axes, where the frame intersects the respective axis. The indices + are listed in the same order as the corresponding dimensions of the + data array. + + For example, it the data array has 5 dimensions, and we are + considering frames whose parallel axes are the 2nd and 4th dimensions + of the array, the frame index will be a sequence of length 3 + corresponding to the indices where the frame intersects the 1st, 3rd + and 5th axes. + + :param index: Sequence of indices defining the active data slice in + a n-dimensional array. The sequence length is :math:`n-2` + :raise: IndexError if any index in the index sequence is out of bound + on its respective axis. + """ + self.model.setFrameIndex(index) + + def _resetBrowsers(self, perspective): + """Adjust limits for browsers based on the perspective and the + size of the corresponding dimensions. Reset the index to 0. + Update the dimension in the labels. + + :param perspective: Sequence of axes/dimensions numbers (0-based) + defining the axes orthogonal to the frame. + """ + # for 3D arrays we can accept an int rather than a 1-tuple + if not hasattr(perspective, "__len__"): + perspective = [perspective] + + # perspective must be sorted + perspective = sorted(perspective) + + n_dimensions = len(self._data_shape) + for i in range(n_dimensions - 2): + browser = self._browserWidgets[i] + label = self._browserLabels[i] + browser.setRange(0, self._data_shape[perspective[i]] - 1) + browser.setValue(0) + label.setText(self._dimensionLabelsText[perspective[i]]) + + def setPerspective(self, perspective): + """Set the *perspective* by specifying which axes are orthogonal + to the frame. + + For the opposite approach (defining parallel axes), use + :meth:`setFrameAxes` instead. + + :param perspective: Sequence of unique axes numbers (0-based) defining + the orthogonal axes. For a n-dimensional array, the sequence + length is :math:`n-2`. The order is of the sequence is not taken + into account (the dimensions are displayed in increasing order + in the widget). + """ + self.model.setPerspective(perspective) + self._resetBrowsers(perspective) + + def setFrameAxes(self, row_axis, col_axis): + """Set the *perspective* by specifying which axes are parallel + to the frame. + + For the opposite approach (defining orthogonal axes), use + :meth:`setPerspective` instead. + + :param int row_axis: Index (0-based) of the first dimension used as a frame + axis + :param int col_axis: Index (0-based) of the 2nd dimension used as a frame + axis + """ + self.model.setFrameAxes(row_axis, col_axis) + n_dimensions = len(self._data_shape) + perspective = tuple(set(range(0, n_dimensions)) - {row_axis, col_axis}) + self._resetBrowsers(perspective) + + def _browserSlot(self, value): + index = [] + for browser in self._browserWidgets: + if browser.isEnabled(): + index.append(browser.value()) + self.setFrameIndex(index) + self.view.reset() + + def getData(self, copy=True): + """Return a copy of the data array, or a reference to it if + *copy=False* is passed as parameter. + + :param bool copy: If *True* (default), return a copy of the data. If + *False*, return a reference. + :return: Numpy array of data, or reference to original data object + if *copy=False* + """ + return self.model.getData(copy=copy) + + +def main(): + import numpy + a = qt.QApplication([]) + d = numpy.random.normal(0, 1, (4, 5, 1000, 1000)) + for j in range(4): + for i in range(5): + d[j, i, :, :] += i + 10 * j + w = ArrayTableWidget() + if "2" in sys.argv: + print("sending a single image") + w.setArrayData(d[0, 0]) + elif "3" in sys.argv: + print("sending 5 images") + w.setArrayData(d[0]) + else: + print("sending 4 * 5 images ") + w.setArrayData(d, labels=True) + w.show() + a.exec_() + +if __name__ == "__main__": + main() diff --git a/silx/gui/data/DataViewer.py b/silx/gui/data/DataViewer.py new file mode 100644 index 0000000..3a3ac64 --- /dev/null +++ b/silx/gui/data/DataViewer.py @@ -0,0 +1,464 @@ +# 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. +# +# ###########################################################################*/ +"""This module defines a widget designed to display data using to most adapted +view from available ones from silx. +""" +from __future__ import division + +from silx.gui.data import DataViews +from silx.gui.data.DataViews import _normalizeData +import logging +from silx.gui import qt +from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "26/04/2017" + + +_logger = logging.getLogger(__name__) + + +class DataViewer(qt.QFrame): + """Widget to display any kind of data + + .. image:: img/DataViewer.png + + The method :meth:`setData` allows to set any data to the widget. Mostly + `numpy.array` and `h5py.Dataset` are supported with adapted views. Other + data types are displayed using a text viewer. + + A default view is automatically selected when a data is set. The method + :meth:`setDisplayMode` allows to change the view. To have a graphical tool + to select the view, prefer using the widget :class:`DataViewerFrame`. + + The dimension of the input data and the expected dimension of the selected + view can differ. For example you can display an image (2D) from 4D + data. In this case a :class:`NumpyAxesSelector` is displayed to allow the + user to select the axis mapping and the slicing of other axes. + + .. code-block:: python + + import numpy + data = numpy.random.rand(500,500) + viewer = DataViewer() + viewer.setData(data) + viewer.setVisible(True) + """ + + EMPTY_MODE = 0 + PLOT1D_MODE = 10 + PLOT2D_MODE = 20 + PLOT3D_MODE = 30 + RAW_MODE = 40 + RAW_ARRAY_MODE = 41 + RAW_RECORD_MODE = 42 + RAW_SCALAR_MODE = 43 + STACK_MODE = 50 + HDF5_MODE = 60 + + displayedViewChanged = qt.Signal(object) + """Emitted when the displayed view changes""" + + dataChanged = qt.Signal() + """Emitted when the data changes""" + + currentAvailableViewsChanged = qt.Signal() + """Emitted when the current available views (which support the current + data) change""" + + def __init__(self, parent=None): + """Constructor + + :param QWidget parent: The parent of the widget + """ + super(DataViewer, self).__init__(parent) + + self.__stack = qt.QStackedWidget(self) + self.__numpySelection = NumpyAxesSelector(self) + self.__numpySelection.selectedAxisChanged.connect(self.__numpyAxisChanged) + self.__numpySelection.selectionChanged.connect(self.__numpySelectionChanged) + self.__numpySelection.customAxisChanged.connect(self.__numpyCustomAxisChanged) + + self.setLayout(qt.QVBoxLayout(self)) + self.layout().addWidget(self.__stack, 1) + + group = qt.QGroupBox(self) + group.setLayout(qt.QVBoxLayout()) + group.layout().addWidget(self.__numpySelection) + group.setTitle("Axis selection") + self.__axisSelection = group + + self.layout().addWidget(self.__axisSelection) + + self.__currentAvailableViews = [] + self.__currentView = None + self.__data = None + self.__useAxisSelection = False + self.__userSelectedView = None + + self.__views = [] + self.__index = {} + """store stack index for each views""" + + self._initializeViews() + + def _initializeViews(self): + """Inisialize the available views""" + views = self.createDefaultViews(self.__stack) + self.__views = list(views) + self.setDisplayMode(self.EMPTY_MODE) + + 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 + overwriten to provide a different set of viewers. + + :param QWidget parent: QWidget parent of the views + :rtype: list[silx.gui.data.DataViews.DataView] + """ + viewClasses = [ + DataViews._EmptyView, + DataViews._Hdf5View, + DataViews._NXdataView, + DataViews._Plot1dView, + DataViews._Plot2dView, + DataViews._Plot3dView, + DataViews._RawView, + DataViews._StackView, + ] + views = [] + for viewClass in viewClasses: + try: + view = viewClass(parent) + views.append(view) + except Exception: + _logger.warning("%s instantiation failed. View is ignored" % viewClass.__name__) + _logger.debug("Backtrace", exc_info=True) + + return views + + def clear(self): + """Clear the widget""" + self.setData(None) + + def normalizeData(self, data): + """Returns a normalized data if the embed a numpy or a dataset. + Else returns the data.""" + return _normalizeData(data) + + def __getStackIndex(self, view): + """Get the stack index containing the view. + + :param silx.gui.data.DataViews.DataView view: The view + """ + if view not in self.__index: + widget = view.getWidget() + index = self.__stack.addWidget(widget) + self.__index[view] = index + else: + index = self.__index[view] + return index + + def __clearCurrentView(self): + """Clear the current selected view""" + view = self.__currentView + if view is not None: + view.clear() + + def __numpyCustomAxisChanged(self, name, value): + view = self.__currentView + if view is not None: + view.setCustomAxisValue(name, value) + + def __updateNumpySelectionAxis(self): + """ + Update the numpy-selector according to the needed axis names + """ + previous = self.__numpySelection.blockSignals(True) + self.__numpySelection.clear() + info = DataViews.DataInfo(self.__data) + axisNames = self.__currentView.axesNames(self.__data, info) + if info.isArray and self.__data is not None and len(axisNames) > 0: + self.__useAxisSelection = True + self.__numpySelection.setAxisNames(axisNames) + self.__numpySelection.setCustomAxis(self.__currentView.customAxisNames()) + data = self.normalizeData(self.__data) + self.__numpySelection.setData(data) + if hasattr(data, "shape"): + isVisible = not (len(axisNames) == 1 and len(data.shape) == 1) + else: + isVisible = True + self.__axisSelection.setVisible(isVisible) + else: + self.__useAxisSelection = False + self.__axisSelection.setVisible(False) + self.__numpySelection.blockSignals(previous) + + def __updateDataInView(self): + """ + Update the views using the current data + """ + if self.__useAxisSelection: + self.__displayedData = self.__numpySelection.selectedData() + else: + self.__displayedData = self.__data + + qt.QTimer.singleShot(10, self.__setDataInView) + + def __setDataInView(self): + self.__currentView.setData(self.__displayedData) + + def setDisplayedView(self, view): + """Set the displayed view. + + Change the displayed view according to the view itself. + + :param silx.gui.data.DataViews.DataView view: The DataView to use to display the data + """ + self.__userSelectedView = view + self._setDisplayedView(view) + + def _setDisplayedView(self, view): + """Internal set of the displayed view. + + Change the displayed view according to the view itself. + + :param silx.gui.data.DataViews.DataView view: The DataView to use to display the data + """ + if self.__currentView is view: + return + self.__clearCurrentView() + self.__currentView = view + self.__updateNumpySelectionAxis() + self.__updateDataInView() + stackIndex = self.__getStackIndex(self.__currentView) + if self.__currentView is not None: + self.__currentView.select() + self.__stack.setCurrentIndex(stackIndex) + self.displayedViewChanged.emit(view) + + def getViewFromModeId(self, modeId): + """Returns the first available view which have the requested modeId. + + :param int modeId: Requested mode id + :rtype: silx.gui.data.DataViews.DataView + """ + for view in self.__views: + if view.modeId() == modeId: + return view + return view + + def setDisplayMode(self, modeId): + """Set the displayed view using display mode. + + Change the displayed view according to the requested mode. + + :param int modeId: Display mode, one of + + - `EMPTY_MODE`: display nothing + - `PLOT1D_MODE`: display the data as a curve + - `PLOT2D_MODE`: display the data as an image + - `PLOT3D_MODE`: display the data as an isosurface + - `RAW_MODE`: display the data as a table + - `STACK_MODE`: display the data as a stack of images + - `HDF5_MODE`: display the data as a table + """ + try: + view = self.getViewFromModeId(modeId) + except KeyError: + raise ValueError("Display mode %s is unknown" % modeId) + self._setDisplayedView(view) + + def displayedView(self): + """Returns the current displayed view. + + :rtype: silx.gui.data.DataViews.DataView + """ + return self.__currentView + + def addView(self, view): + """Allow to add a view to the dataview. + + If the current data support this view, it will be displayed. + + :param DataView view: A dataview + """ + self.__views.append(view) + # TODO It can be skipped if the view do not support the data + self.__updateAvailableViews() + + def removeView(self, view): + """Allow to remove a view which was available from the dataview. + + If the view was displayed, the widget will be updated. + + :param DataView view: A dataview + """ + self.__views.remove(view) + self.__stack.removeWidget(view.getWidget()) + # invalidate the full index. It will be updated as expected + self.__index = {} + + if self.__userSelectedView is view: + self.__userSelectedView = None + + if view is self.__currentView: + self.__updateView() + else: + # TODO It can be skipped if the view is not part of the + # available views + self.__updateAvailableViews() + + def __updateAvailableViews(self): + """ + Update available views from the current data. + """ + data = self.__data + # 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) + views = sorted(views, reverse=True) + + # store available views + if len(views) == 0: + self.__setCurrentAvailableViews([]) + available = [] + else: + available = [v[1] for v in views] + self.__setCurrentAvailableViews(available) + + def __updateView(self): + """Display the data using the widget which fit the best""" + data = self.__data + + # update available views for this data + self.__updateAvailableViews() + available = self.__currentAvailableViews + + # display the view with the most priority (the default view) + view = self.getDefaultViewFromAvailableViews(data, available) + self.__clearCurrentView() + try: + self._setDisplayedView(view) + except Exception as e: + # in case there is a problem to read the data, try to use a safe + # view + view = self.getSafeViewFromAvailableViews(data, available) + self._setDisplayedView(view) + raise e + + def getSafeViewFromAvailableViews(self, data, available): + """Returns a view which is sure to display something without failing + on rendering. + + :param object data: data which will be displayed + :param list[view] available: List of available views, from highest + priority to lowest. + :rtype: DataView + """ + hdf5View = self.getViewFromModeId(DataViewer.HDF5_MODE) + if hdf5View in available: + return hdf5View + return self.getViewFromModeId(DataViewer.EMPTY_MODE) + + def getDefaultViewFromAvailableViews(self, data, available): + """Returns the default view which will be used according to available + views. + + :param object data: data which will be displayed + :param list[view] available: List of available views, from highest + priority to lowest. + :rtype: DataView + """ + if len(available) > 0: + # returns the view with the highest priority + if self.__userSelectedView in available: + return self.__userSelectedView + self.__userSelectedView = None + view = available[0] + else: + # else returns the empty view + view = self.getViewFromModeId(DataViewer.EMPTY_MODE) + return view + + def __setCurrentAvailableViews(self, availableViews): + """Set the current available viewa + + :param List[DataView] availableViews: Current available viewa + """ + self.__currentAvailableViews = availableViews + self.currentAvailableViewsChanged.emit() + + def currentAvailableViews(self): + """Returns the list of available views for the current data + + :rtype: List[DataView] + """ + return self.__currentAvailableViews + + def availableViews(self): + """Returns the list of registered views + + :rtype: List[DataView] + """ + return self.__views + + def setData(self, data): + """Set the data to view. + + It mostly can be a h5py.Dataset or a numpy.ndarray. Other kind of + objects will be displayed as text rendering. + + :param numpy.ndarray data: The data. + """ + self.__data = data + self.__displayedData = None + self.__updateView() + self.__updateNumpySelectionAxis() + self.__updateDataInView() + self.dataChanged.emit() + + def __numpyAxisChanged(self): + """ + Called when axis selection of the numpy-selector changed + """ + self.__clearCurrentView() + + def __numpySelectionChanged(self): + """ + Called when data selection of the numpy-selector changed + """ + self.__updateDataInView() + + def data(self): + """Returns the data""" + return self.__data + + def displayMode(self): + """Returns the current display mode""" + return self.__currentView.modeId() diff --git a/silx/gui/data/DataViewerFrame.py b/silx/gui/data/DataViewerFrame.py new file mode 100644 index 0000000..b48fa7b --- /dev/null +++ b/silx/gui/data/DataViewerFrame.py @@ -0,0 +1,186 @@ +# 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. +# +# ###########################################################################*/ +"""This module contains a DataViewer with a view selector. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "10/04/2017" + +from silx.gui import qt +from .DataViewer import DataViewer +from .DataViewerSelector import DataViewerSelector + + +class DataViewerFrame(qt.QWidget): + """ + A :class:`DataViewer` with a view selector. + + .. image:: img/DataViewerFrame.png + + This widget provides the same API as :class:`DataViewer`. Therefore, for more + documentation, take a look at the documentation of the class + :class:`DataViewer`. + + .. code-block:: python + + import numpy + data = numpy.random.rand(500,500) + viewer = DataViewerFrame() + viewer.setData(data) + viewer.setVisible(True) + + """ + + displayedViewChanged = qt.Signal(object) + """Emitted when the displayed view changes""" + + dataChanged = qt.Signal() + """Emitted when the data changes""" + + def __init__(self, parent=None): + """ + Constructor + + :param qt.QWidget parent: + """ + super(DataViewerFrame, self).__init__(parent) + + class _DataViewer(DataViewer): + """Overwrite methods to avoid to create views while the instance + is not created. `initializeViews` have to be called manually.""" + + def _initializeViews(self): + pass + + def initializeViews(self): + """Avoid to create views while the instance is not created.""" + super(_DataViewer, self)._initializeViews() + + self.__dataViewer = _DataViewer(self) + # initialize views when `self.__dataViewer` is set + self.__dataViewer.initializeViews() + self.__dataViewer.setFrameShape(qt.QFrame.StyledPanel) + self.__dataViewer.setFrameShadow(qt.QFrame.Sunken) + self.__dataViewerSelector = DataViewerSelector(self, self.__dataViewer) + self.__dataViewerSelector.setFlat(True) + + layout = qt.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self.__dataViewer, 1) + layout.addWidget(self.__dataViewerSelector) + self.setLayout(layout) + + self.__dataViewer.dataChanged.connect(self.__dataChanged) + self.__dataViewer.displayedViewChanged.connect(self.__displayedViewChanged) + + def __dataChanged(self): + """Called when the data is changed""" + self.dataChanged.emit() + + def __displayedViewChanged(self, view): + """Called when the displayed view changes""" + self.displayedViewChanged.emit(view) + + def availableViews(self): + """Returns the list of registered views + + :rtype: List[DataView] + """ + return self.__dataViewer.availableViews() + + def currentAvailableViews(self): + """Returns the list of available views for the current data + + :rtype: List[DataView] + """ + return self.__dataViewer.currentAvailableViews() + + 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 + overwriten to provide a different set of viewers. + + :param QWidget parent: QWidget parent of the views + :rtype: list[silx.gui.data.DataViews.DataView] + """ + return self.__dataViewer.createDefaultViews(parent) + + def addView(self, view): + """Allow to add a view to the dataview. + + If the current data support this view, it will be displayed. + + :param DataView view: A dataview + """ + return self.__dataViewer.addView(view) + + def removeView(self, view): + """Allow to remove a view which was available from the dataview. + + If the view was displayed, the widget will be updated. + + :param DataView view: A dataview + """ + return self.__dataViewer.removeView(view) + + def setData(self, data): + """Set the data to view. + + It mostly can be a h5py.Dataset or a numpy.ndarray. Other kind of + objects will be displayed as text rendering. + + :param numpy.ndarray data: The data. + """ + self.__dataViewer.setData(data) + + def data(self): + """Returns the data""" + return self.__dataViewer.data() + + def setDisplayedView(self, view): + self.__dataViewer.setDisplayedView(view) + + def displayedView(self): + return self.__dataViewer.displayedView() + + def displayMode(self): + return self.__dataViewer.displayMode() + + def setDisplayMode(self, modeId): + """Set the displayed view using display mode. + + Change the displayed view according to the requested mode. + + :param int modeId: Display mode, one of + + - `EMPTY_MODE`: display nothing + - `PLOT1D_MODE`: display the data as a curve + - `PLOT2D_MODE`: display the data as an image + - `TEXT_MODE`: display the data as a text + - `ARRAY_MODE`: display the data as a table + """ + return self.__dataViewer.setDisplayMode(modeId) diff --git a/silx/gui/data/DataViewerSelector.py b/silx/gui/data/DataViewerSelector.py new file mode 100644 index 0000000..32cc636 --- /dev/null +++ b/silx/gui/data/DataViewerSelector.py @@ -0,0 +1,153 @@ +# 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. +# +# ###########################################################################*/ +"""This module defines a widget to be able to select the available view +of the DataViewer. +""" +from __future__ import division + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "26/01/2017" + +import weakref +import functools +from silx.gui import qt +from silx.gui.data.DataViewer import DataViewer +import silx.utils.weakref + + +class DataViewerSelector(qt.QWidget): + """Widget to be able to select a custom view from the DataViewer""" + + def __init__(self, parent=None, dataViewer=None): + """Constructor + + :param QWidget parent: The parent of the widget + :param DataViewer dataViewer: The connected `DataViewer` + """ + super(DataViewerSelector, self).__init__(parent) + + self.__group = None + self.__buttons = {} + self.__buttonDummy = None + self.__dataViewer = None + + if dataViewer is not None: + self.setDataViewer(dataViewer) + + def __updateButtons(self): + if self.__group is not None: + self.__group.deleteLater() + self.__buttons = {} + self.__buttonDummy = None + + self.__group = qt.QButtonGroup(self) + self.setLayout(qt.QHBoxLayout()) + self.layout().setContentsMargins(0, 0, 0, 0) + if self.__dataViewer is None: + return + + iconSize = qt.QSize(16, 16) + + for view in self.__dataViewer.availableViews(): + label = view.label() + icon = view.icon() + button = qt.QPushButton(label) + button.setIcon(icon) + button.setIconSize(iconSize) + button.setCheckable(True) + # the weak objects are needed to be able to destroy the widget safely + weakView = weakref.ref(view) + weakMethod = silx.utils.weakref.WeakMethodProxy(self.__setDisplayedView) + callback = functools.partial(weakMethod, weakView) + button.clicked.connect(callback) + self.layout().addWidget(button) + self.__group.addButton(button) + self.__buttons[view] = button + + button = qt.QPushButton("Dummy") + button.setCheckable(True) + button.setVisible(False) + self.layout().addWidget(button) + self.__group.addButton(button) + self.__buttonDummy = button + + self.layout().addStretch(1) + + self.__updateButtonsVisibility() + self.__displayedViewChanged(self.__dataViewer.displayedView()) + + def setDataViewer(self, dataViewer): + """Define the dataviewer connected to this status bar + + :param DataViewer dataViewer: The connected `DataViewer` + """ + if self.__dataViewer is dataViewer: + return + if self.__dataViewer is not None: + self.__dataViewer.dataChanged.disconnect(self.__updateButtonsVisibility) + self.__dataViewer.displayedViewChanged.disconnect(self.__displayedViewChanged) + self.__dataViewer = dataViewer + if self.__dataViewer is not None: + self.__dataViewer.dataChanged.connect(self.__updateButtonsVisibility) + self.__dataViewer.displayedViewChanged.connect(self.__displayedViewChanged) + self.__updateButtons() + + def setFlat(self, isFlat): + """Set the flat state of all the buttons. + + :param bool isFlat: True to display the buttons flatten. + """ + for b in self.__buttons.values(): + b.setFlat(isFlat) + self.__buttonDummy.setFlat(isFlat) + + def __displayedViewChanged(self, view): + """Called on displayed view changeS""" + selectedButton = self.__buttons.get(view, self.__buttonDummy) + selectedButton.setChecked(True) + + def __setDisplayedView(self, refView, clickEvent=None): + """Display a data using the requested view + + :param DataView view: Requested view + :param clickEvent: Event sent by the clicked event + """ + if self.__dataViewer is None: + return + view = refView() + if view is None: + return + self.__dataViewer.setDisplayedView(view) + + def __updateButtonsVisibility(self): + """Called on data changed""" + if self.__dataViewer is None: + for b in self.__buttons.values(): + b.setVisible(False) + else: + availableViews = set(self.__dataViewer.currentAvailableViews()) + for view, button in self.__buttons.items(): + button.setVisible(view in availableViews) diff --git a/silx/gui/data/DataViews.py b/silx/gui/data/DataViews.py new file mode 100644 index 0000000..d8d605a --- /dev/null +++ b/silx/gui/data/DataViews.py @@ -0,0 +1,988 @@ +# 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. +# +# ###########################################################################*/ +"""This module defines a views used by :class:`silx.gui.data.DataViewer`. +""" + +import logging +import numbers +import numpy + +import silx.io +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 NXdata + +__authors__ = ["V. Valls", "P. Knobel"] +__license__ = "MIT" +__date__ = "07/04/2017" + +_logger = logging.getLogger(__name__) + + +# DataViewer modes +EMPTY_MODE = 0 +PLOT1D_MODE = 10 +PLOT2D_MODE = 20 +PLOT3D_MODE = 30 +RAW_MODE = 40 +RAW_ARRAY_MODE = 41 +RAW_RECORD_MODE = 42 +RAW_SCALAR_MODE = 43 +STACK_MODE = 50 +HDF5_MODE = 60 + + +def _normalizeData(data): + """Returns a normalized data. + + If the data embed a numpy data or a dataset it is returned. + Else returns the input data.""" + if isinstance(data, H5Node): + return data.h5py_object + return data + + +def _normalizeComplex(data): + """Returns a normalized complex data. + + If the data is a numpy data with complex, returns the + absolute value. + Else returns the input data.""" + if hasattr(data, "dtype"): + isComplex = numpy.issubdtype(data.dtype, numpy.complex) + else: + isComplex = isinstance(data, numbers.Complex) + if isComplex: + data = numpy.absolute(data) + return data + + +class DataInfo(object): + """Store extracted information from a data""" + + def __init__(self, data): + data = self.normalizeData(data) + self.isArray = False + self.interpretation = None + self.isNumeric = False + self.isComplex = False + self.isRecord = False + self.isNXdata = False + self.shape = tuple() + self.dim = 0 + + if data is None: + return + + if silx.io.is_group(data) and nxdata.is_valid_nxdata(data): + self.isNXdata = True + nxd = nxdata.NXdata(data) + + if isinstance(data, numpy.ndarray): + self.isArray = True + elif silx.io.is_dataset(data) and data.shape != tuple(): + self.isArray = True + else: + self.isArray = False + + if silx.io.is_dataset(data): + self.interpretation = data.attrs.get("interpretation", None) + elif self.isNXdata: + self.interpretation = nxd.interpretation + else: + self.interpretation = None + + if hasattr(data, "dtype"): + self.isNumeric = numpy.issubdtype(data.dtype, numpy.number) + self.isRecord = data.dtype.fields is not None + self.isComplex = numpy.issubdtype(data.dtype, numpy.complex) + elif self.isNXdata: + self.isNumeric = numpy.issubdtype(nxd.signal.dtype, + numpy.number) + self.isComplex = numpy.issubdtype(nxd.signal.dtype, numpy.complex) + else: + self.isNumeric = isinstance(data, numbers.Number) + self.isComplex = isinstance(data, numbers.Complex) + self.isRecord = False + + if hasattr(data, "shape"): + self.shape = data.shape + elif self.isNXdata: + self.shape = nxd.signal.shape + else: + self.shape = tuple() + self.dim = len(self.shape) + + def normalizeData(self, data): + """Returns a normalized data if the embed a numpy or a dataset. + Else returns the data.""" + return _normalizeData(data) + + +class DataView(object): + """Holder for the data view.""" + + UNSUPPORTED = -1 + """Priority returned when the requested data can't be displayed by the + view.""" + + def __init__(self, parent, modeId=None, icon=None, label=None): + """Constructor + + :param qt.QWidget parent: Parent of the hold widget + """ + self.__parent = parent + self.__widget = None + self.__modeId = modeId + if label is None: + label = self.__class__.__name__ + self.__label = label + if icon is None: + icon = qt.QIcon() + self.__icon = icon + + def icon(self): + """Returns the default icon""" + return self.__icon + + def label(self): + """Returns the default label""" + return self.__label + + def modeId(self): + """Returns the mode id""" + return self.__modeId + + def normalizeData(self, data): + """Returns a normalized data if the embed a numpy or a dataset. + Else returns the data.""" + return _normalizeData(data) + + def customAxisNames(self): + """Returns names of axes which can be custom by the user and provided + to the view.""" + return [] + + def setCustomAxisValue(self, name, value): + """ + Set the value of a custom axis + + :param str name: Name of the custom axis + :param int value: Value of the custom axis + """ + pass + + def isWidgetInitialized(self): + """Returns true if the widget is already initialized. + """ + return self.__widget is not None + + def select(self): + """Called when the view is selected to display the data. + """ + return + + def getWidget(self): + """Returns the widget hold in the view and displaying the data. + + :returns: qt.QWidget + """ + if self.__widget is None: + self.__widget = self.createWidget(self.__parent) + return self.__widget + + def createWidget(self, parent): + """Create the the widget displaying the data + + :param qt.QWidget parent: Parent of the widget + :returns: qt.QWidget + """ + raise NotImplementedError() + + def clear(self): + """Clear the data from the view""" + return None + + def setData(self, data): + """Set the data displayed by the view + + :param data: Data to display + :type data: numpy.ndarray or h5py.Dataset + """ + return None + + def axesNames(self, data, info): + """Returns names of the expected axes of the view, according to the + input data. + + :param data: Data to display + :type data: numpy.ndarray or h5py.Dataset + :param DataInfo info: Pre-computed information on the data + :rtype: list[str] + """ + return [] + + def getDataPriority(self, data, info): + """ + Returns the priority of using this view according to a data. + + - `UNSUPPORTED` means this view can't display this data + - `1` means this view can display the data + - `100` means this view should be used for this data + - `1000` max value used by the views provided by silx + - ... + + :param object data: The data to check + :param DataInfo info: Pre-computed information on the data + :rtype: int + """ + return DataView.UNSUPPORTED + + def __lt__(self, other): + return str(self) < str(other) + + +class CompositeDataView(DataView): + """Data view which can display a data using different view according to + the kind of the data.""" + + def __init__(self, parent, modeId=None, icon=None, label=None): + """Constructor + + :param qt.QWidget parent: Parent of the hold widget + """ + super(CompositeDataView, self).__init__(parent, modeId, icon, label) + self.__views = {} + self.__currentView = None + + def addView(self, dataView): + """Add a new dataview to the available list.""" + self.__views[dataView] = None + + def getBestView(self, data, info): + """Returns the best view according to priorities.""" + info = DataInfo(data) + views = [(v.getDataPriority(data, info), v) for v in self.__views.keys()] + views = filter(lambda t: t[0] > DataView.UNSUPPORTED, views) + views = sorted(views, reverse=True) + + if len(views) == 0: + return None + elif views[0][0] == DataView.UNSUPPORTED: + return None + else: + return views[0][1] + + def customAxisNames(self): + if self.__currentView is None: + return + return self.__currentView.customAxisNames() + + def setCustomAxisValue(self, name, value): + if self.__currentView is None: + return + self.__currentView.setCustomAxisValue(name, value) + + def __updateDisplayedView(self): + widget = self.getWidget() + if self.__currentView is None: + return + + # load the widget if it is not yet done + index = self.__views[self.__currentView] + if index is None: + w = self.__currentView.getWidget() + index = widget.addWidget(w) + self.__views[self.__currentView] = index + if widget.currentIndex() != index: + widget.setCurrentIndex(index) + self.__currentView.select() + + def select(self): + self.__updateDisplayedView() + if self.__currentView is not None: + self.__currentView.select() + + def createWidget(self, parent): + return qt.QStackedWidget() + + def clear(self): + for v in self.__views.keys(): + v.clear() + + def setData(self, data): + if self.__currentView is None: + return + self.__updateDisplayedView() + self.__currentView.setData(data) + + def axesNames(self, data, info): + view = self.getBestView(data, info) + self.__currentView = view + return view.axesNames(data, info) + + def getDataPriority(self, data, info): + view = self.getBestView(data, info) + self.__currentView = view + if view is None: + return DataView.UNSUPPORTED + else: + return view.getDataPriority(data, info) + + +class _EmptyView(DataView): + """Dummy view to display nothing""" + + def __init__(self, parent): + DataView.__init__(self, parent, modeId=EMPTY_MODE) + + def axesNames(self, data, info): + return [] + + def createWidget(self, parent): + return qt.QLabel(parent) + + def getDataPriority(self, data, info): + return DataView.UNSUPPORTED + + +class _Plot1dView(DataView): + """View displaying data using a 1d plot""" + + def __init__(self, parent): + super(_Plot1dView, self).__init__( + parent=parent, + modeId=PLOT1D_MODE, + label="Curve", + icon=icons.getQIcon("view-1d")) + self.__resetZoomNextTime = True + + def createWidget(self, parent): + from silx.gui import plot + return plot.Plot1D(parent=parent) + + def clear(self): + self.getWidget().clear() + self.__resetZoomNextTime = True + + def normalizeData(self, data): + data = DataView.normalizeData(self, data) + data = _normalizeComplex(data) + return data + + def setData(self, data): + data = self.normalizeData(data) + self.getWidget().addCurve(legend="data", + x=range(len(data)), + y=data, + resetzoom=self.__resetZoomNextTime) + self.__resetZoomNextTime = True + + def axesNames(self, data, info): + return ["y"] + + def getDataPriority(self, data, info): + if data is None or not info.isArray or not info.isNumeric: + return DataView.UNSUPPORTED + if info.dim < 1: + return DataView.UNSUPPORTED + if info.interpretation == "spectrum": + return 1000 + if info.dim == 2 and info.shape[0] == 1: + return 210 + if info.dim == 1: + return 100 + else: + return 10 + + +class _Plot2dView(DataView): + """View displaying data using a 2d plot""" + + def __init__(self, parent): + super(_Plot2dView, self).__init__( + parent=parent, + modeId=PLOT2D_MODE, + label="Image", + icon=icons.getQIcon("view-2d")) + self.__resetZoomNextTime = True + + def createWidget(self, parent): + from silx.gui import plot + widget = plot.Plot2D(parent=parent) + widget.setKeepDataAspectRatio(True) + widget.setGraphXLabel('X') + widget.setGraphYLabel('Y') + return widget + + def clear(self): + self.getWidget().clear() + self.__resetZoomNextTime = True + + def normalizeData(self, data): + data = DataView.normalizeData(self, data) + data = _normalizeComplex(data) + return data + + def setData(self, data): + data = self.normalizeData(data) + self.getWidget().addImage(legend="data", + data=data, + resetzoom=self.__resetZoomNextTime) + self.__resetZoomNextTime = False + + def axesNames(self, data, info): + return ["y", "x"] + + def getDataPriority(self, data, info): + if data is None or not info.isArray or not info.isNumeric: + return DataView.UNSUPPORTED + if info.dim < 2: + return DataView.UNSUPPORTED + if info.interpretation == "image": + return 1000 + if info.dim == 2: + return 200 + else: + return 190 + + +class _Plot3dView(DataView): + """View displaying data using a 3d plot""" + + def __init__(self, parent): + super(_Plot3dView, self).__init__( + parent=parent, + modeId=PLOT3D_MODE, + label="Cube", + icon=icons.getQIcon("view-3d")) + try: + import silx.gui.plot3d #noqa + except ImportError: + _logger.warning("Plot3dView is not available") + _logger.debug("Backtrace", exc_info=True) + raise + self.__resetZoomNextTime = True + + def createWidget(self, parent): + from silx.gui.plot3d import ScalarFieldView + from silx.gui.plot3d import SFViewParamTree + + plot = ScalarFieldView.ScalarFieldView(parent) + plot.setAxesLabels(*reversed(self.axesNames(None, None))) + plot.addIsosurface( + lambda data: numpy.mean(data) + numpy.std(data), '#FF0000FF') + + # Create a parameter tree for the scalar field view + options = SFViewParamTree.TreeView(plot) + options.setSfView(plot) + + # Add the parameter tree to the main window in a dock widget + dock = qt.QDockWidget() + dock.setWidget(options) + plot.addDockWidget(qt.Qt.RightDockWidgetArea, dock) + + return plot + + def clear(self): + self.getWidget().setData(None) + self.__resetZoomNextTime = True + + def normalizeData(self, data): + data = DataView.normalizeData(self, data) + data = _normalizeComplex(data) + return data + + def setData(self, data): + data = self.normalizeData(data) + plot = self.getWidget() + plot.setData(data) + self.__resetZoomNextTime = False + + def axesNames(self, data, info): + return ["z", "y", "x"] + + def getDataPriority(self, data, info): + if data is None or not info.isArray or not info.isNumeric: + return DataView.UNSUPPORTED + if info.dim < 3: + return DataView.UNSUPPORTED + if min(data.shape) < 2: + return DataView.UNSUPPORTED + if info.dim == 3: + return 100 + else: + return 10 + + +class _ArrayView(DataView): + """View displaying data using a 2d table""" + + def __init__(self, parent): + DataView.__init__(self, parent, modeId=RAW_ARRAY_MODE) + + def createWidget(self, parent): + from silx.gui.data.ArrayTableWidget import ArrayTableWidget + widget = ArrayTableWidget(parent) + widget.displayAxesSelector(False) + return widget + + def clear(self): + self.getWidget().setArrayData(numpy.array([[]])) + + def setData(self, data): + data = self.normalizeData(data) + self.getWidget().setArrayData(data) + + def axesNames(self, data, info): + return ["col", "row"] + + def getDataPriority(self, data, info): + if data is None or not info.isArray or info.isRecord: + return DataView.UNSUPPORTED + if info.dim < 2: + return DataView.UNSUPPORTED + if info.interpretation in ["scalar", "scaler"]: + return 1000 + return 500 + + +class _StackView(DataView): + """View displaying data using a stack of images""" + + def __init__(self, parent): + super(_StackView, self).__init__( + parent=parent, + modeId=STACK_MODE, + label="Image stack", + icon=icons.getQIcon("view-2d-stack")) + self.__resetZoomNextTime = True + + def customAxisNames(self): + return ["depth"] + + def setCustomAxisValue(self, name, value): + if name == "depth": + self.getWidget().setFrameNumber(value) + else: + raise Exception("Unsupported axis") + + def createWidget(self, parent): + from silx.gui import plot + widget = plot.StackView(parent=parent) + widget.setKeepDataAspectRatio(True) + widget.setLabels(self.axesNames(None, None)) + # hide default option panel + widget.setOptionVisible(False) + return widget + + def clear(self): + self.getWidget().clear() + self.__resetZoomNextTime = True + + def normalizeData(self, data): + data = DataView.normalizeData(self, data) + data = _normalizeComplex(data) + return data + + def setData(self, data): + data = self.normalizeData(data) + self.getWidget().setStack(stack=data, reset=self.__resetZoomNextTime) + self.__resetZoomNextTime = False + + def axesNames(self, data, info): + return ["depth", "y", "x"] + + def getDataPriority(self, data, info): + if data is None or not info.isArray or not info.isNumeric: + return DataView.UNSUPPORTED + if info.dim < 3: + return DataView.UNSUPPORTED + if info.interpretation == "image": + return 500 + return 90 + + +class _ScalarView(DataView): + """View displaying data using text""" + + def __init__(self, parent): + DataView.__init__(self, parent, modeId=RAW_SCALAR_MODE) + + def createWidget(self, parent): + widget = qt.QTextEdit(parent) + widget.setTextInteractionFlags(qt.Qt.TextSelectableByMouse) + widget.setAlignment(qt.Qt.AlignLeft | qt.Qt.AlignTop) + self.__formatter = TextFormatter(parent) + return widget + + def clear(self): + self.getWidget().setText("") + + def setData(self, data): + data = self.normalizeData(data) + if silx.io.is_dataset(data): + data = data[()] + text = self.__formatter.toString(data) + self.getWidget().setText(text) + + def axesNames(self, data, info): + return [] + + def getDataPriority(self, data, info): + data = self.normalizeData(data) + if data is None: + return DataView.UNSUPPORTED + if silx.io.is_group(data): + return DataView.UNSUPPORTED + return 2 + + +class _RecordView(DataView): + """View displaying data using text""" + + def __init__(self, parent): + DataView.__init__(self, parent, modeId=RAW_RECORD_MODE) + + def createWidget(self, parent): + from .RecordTableView import RecordTableView + widget = RecordTableView(parent) + widget.setWordWrap(False) + return widget + + def clear(self): + self.getWidget().setArrayData(None) + + def setData(self, data): + data = self.normalizeData(data) + widget = self.getWidget() + widget.setArrayData(data) + widget.resizeRowsToContents() + widget.resizeColumnsToContents() + + def axesNames(self, data, info): + return ["data"] + + def getDataPriority(self, data, info): + if info.isRecord: + return 40 + if data is None or not info.isArray: + return DataView.UNSUPPORTED + if info.dim == 1: + if info.interpretation in ["scalar", "scaler"]: + return 1000 + if info.shape[0] == 1: + return 510 + return 500 + elif info.isRecord: + return 40 + return DataView.UNSUPPORTED + + +class _Hdf5View(DataView): + """View displaying data using text""" + + def __init__(self, parent): + super(_Hdf5View, self).__init__( + parent=parent, + modeId=HDF5_MODE, + label="HDF5", + icon=icons.getQIcon("view-hdf5")) + + def createWidget(self, parent): + from .Hdf5TableView import Hdf5TableView + widget = Hdf5TableView(parent) + return widget + + def clear(self): + widget = self.getWidget() + widget.setData(None) + + def setData(self, data): + widget = self.getWidget() + widget.setData(data) + + def axesNames(self, data, info): + return [] + + def getDataPriority(self, data, info): + widget = self.getWidget() + if widget.isSupportedData(data): + return 1 + else: + return DataView.UNSUPPORTED + + +class _RawView(CompositeDataView): + """View displaying data as raw data. + + This implementation use a 2d-array view, or a record array view, or a + raw text output. + """ + + def __init__(self, parent): + super(_RawView, self).__init__( + parent=parent, + modeId=RAW_MODE, + label="Raw", + icon=icons.getQIcon("view-raw")) + self.addView(_ScalarView(parent)) + self.addView(_ArrayView(parent)) + self.addView(_RecordView(parent)) + + +class _NXdataScalarView(DataView): + """DataView using a table view for displaying NXdata scalars: + 0-D signal or n-D signal with *@interpretation=scalar*""" + def __init__(self, parent): + DataView.__init__(self, parent) + + def createWidget(self, parent): + from silx.gui.data.ArrayTableWidget import ArrayTableWidget + widget = ArrayTableWidget(parent) + # widget.displayAxesSelector(False) + return widget + + def axesNames(self, data, info): + return ["col", "row"] + + def clear(self): + self.getWidget().setArrayData(numpy.array([[]]), + labels=True) + + def setData(self, data): + data = self.normalizeData(data) + signal = NXdata(data).signal + self.getWidget().setArrayData(signal, + labels=True) + + def getDataPriority(self, data, info): + data = self.normalizeData(data) + if info.isNXdata: + nxd = NXdata(data) + if nxd.signal_is_0d or nxd.interpretation in ["scalar", "scaler"]: + return 100 + return DataView.UNSUPPORTED + + +class _NXdataCurveView(DataView): + """DataView using a Plot1D for displaying NXdata curves: + 1-D signal or n-D signal with *@interpretation=spectrum*. + + It also handles basic scatter plots: + a 1-D signal with one axis whose values are not monotonically increasing. + """ + def __init__(self, parent): + DataView.__init__(self, parent) + + def createWidget(self, parent): + from silx.gui.data.NXdataWidgets import ArrayCurvePlot + widget = ArrayCurvePlot(parent) + return widget + + def axesNames(self, data, info): + # disabled (used by default axis selector widget in Hdf5Viewer) + return [] + + def clear(self): + self.getWidget().clear() + + def setData(self, data): + data = self.normalizeData(data) + nxd = NXdata(data) + signal_name = data.attrs["signal"] + group_name = data.name + if nxd.axes_names[-1] is not None: + x_errors = nxd.get_axis_errors(nxd.axes_names[-1]) + else: + x_errors = None + + self.getWidget().setCurveData(nxd.signal, nxd.axes[-1], + yerror=nxd.errors, xerror=x_errors, + ylabel=signal_name, xlabel=nxd.axes_names[-1], + title="NXdata group " + group_name) + + def getDataPriority(self, data, info): + data = self.normalizeData(data) + if info.isNXdata: + nxd = NXdata(data) + if nxd.is_x_y_value_scatter or nxd.is_unsupported_scatter: + return DataView.UNSUPPORTED + if nxd.signal_is_1d and \ + not nxd.interpretation in ["scalar", "scaler"]: + return 100 + if nxd.interpretation == "spectrum": + return 100 + return DataView.UNSUPPORTED + + +class _NXdataXYVScatterView(DataView): + """DataView using a Plot1D for displaying NXdata 3D scatters as + a scatter of coloured points (1-D signal with 2 axes)""" + def __init__(self, parent): + DataView.__init__(self, parent) + + def createWidget(self, parent): + from silx.gui.data.NXdataWidgets import ArrayCurvePlot + widget = ArrayCurvePlot(parent) + return widget + + def axesNames(self, data, info): + # disabled (used by default axis selector widget in Hdf5Viewer) + return [] + + def clear(self): + self.getWidget().clear() + + def setData(self, data): + data = self.normalizeData(data) + nxd = NXdata(data) + signal_name = data.attrs["signal"] + # signal_errors = nx.errors # not supported + group_name = data.name + x_axis, y_axis = nxd.axes[-2:] + + x_label, y_label = nxd.axes_names[-2:] + if x_label is not None: + x_errors = nxd.get_axis_errors(x_label) + else: + x_errors = None + + if y_label is not None: + y_errors = nxd.get_axis_errors(y_label) + else: + y_errors = None + + self.getWidget().setCurveData(y_axis, x_axis, values=nxd.signal, + yerror=y_errors, xerror=x_errors, + ylabel=signal_name, xlabel=x_label, + title="NXdata group " + group_name) + + def getDataPriority(self, data, info): + data = self.normalizeData(data) + if info.isNXdata: + if NXdata(data).is_x_y_value_scatter: + return 100 + return DataView.UNSUPPORTED + + +class _NXdataImageView(DataView): + """DataView using a Plot2D for displaying NXdata images: + 2-D signal or n-D signals with *@interpretation=spectrum*.""" + def __init__(self, parent): + DataView.__init__(self, parent) + + def createWidget(self, parent): + from silx.gui.data.NXdataWidgets import ArrayImagePlot + widget = ArrayImagePlot(parent) + return widget + + def axesNames(self, data, info): + return [] + + def clear(self): + self.getWidget().clear() + + def setData(self, data): + data = self.normalizeData(data) + nxd = NXdata(data) + signal_name = data.attrs["signal"] + group_name = data.name + y_axis, x_axis = nxd.axes[-2:] + y_label, x_label = nxd.axes_names[-2:] + + self.getWidget().setImageData( + nxd.signal, x_axis=x_axis, y_axis=y_axis, + signal_name=signal_name, xlabel=x_label, ylabel=y_label, + title="NXdata group %s: %s" % (group_name, signal_name)) + + def getDataPriority(self, data, info): + data = self.normalizeData(data) + if info.isNXdata: + nxd = NXdata(data) + if nxd.signal_is_2d: + if nxd.interpretation not in ["scalar", "spectrum", "scaler"]: + return 100 + if nxd.interpretation == "image": + return 100 + return DataView.UNSUPPORTED + + +class _NXdataStackView(DataView): + def __init__(self, parent): + DataView.__init__(self, parent) + + def createWidget(self, parent): + from silx.gui.data.NXdataWidgets import ArrayStackPlot + widget = ArrayStackPlot(parent) + return widget + + def axesNames(self, data, info): + return [] + + def clear(self): + self.getWidget().clear() + + def setData(self, data): + data = self.normalizeData(data) + nxd = NXdata(data) + signal_name = data.attrs["signal"] + group_name = data.name + z_axis, y_axis, x_axis = nxd.axes[-3:] + z_label, y_label, x_label = nxd.axes_names[-3:] + + self.getWidget().setStackData( + nxd.signal, x_axis=x_axis, y_axis=y_axis, z_axis=z_axis, + signal_name=signal_name, + xlabel=x_label, ylabel=y_label, zlabel=z_label, + title="NXdata group %s: %s" % (group_name, signal_name)) + + def getDataPriority(self, data, info): + data = self.normalizeData(data) + if info.isNXdata: + nxd = NXdata(data) + if nxd.signal_ndim >= 3: + if nxd.interpretation not in ["scalar", "scaler", + "spectrum", "image"]: + return 100 + return DataView.UNSUPPORTED + + +class _NXdataView(CompositeDataView): + """Composite view displaying NXdata groups using the most adequate + widget depending on the dimensionality.""" + def __init__(self, parent): + super(_NXdataView, self).__init__( + parent=parent, + label="NXdata", + icon=icons.getQIcon("view-nexus")) + + self.addView(_NXdataScalarView(parent)) + self.addView(_NXdataCurveView(parent)) + self.addView(_NXdataXYVScatterView(parent)) + self.addView(_NXdataImageView(parent)) + self.addView(_NXdataStackView(parent)) diff --git a/silx/gui/data/Hdf5TableView.py b/silx/gui/data/Hdf5TableView.py new file mode 100644 index 0000000..5d79907 --- /dev/null +++ b/silx/gui/data/Hdf5TableView.py @@ -0,0 +1,414 @@ +# 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 define model and widget to display 1D slices from numpy +array using compound data types or hdf5 databases. +""" +from __future__ import division + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/04/2017" + +import functools +import os.path +import logging +from silx.gui import qt +import silx.io +from .TextFormatter import TextFormatter +import silx.gui.hdf5 +from silx.gui.widgets import HierarchicalTableView + +_logger = logging.getLogger(__name__) + + +class _CellData(object): + """Store a table item + """ + def __init__(self, value=None, isHeader=False, span=None): + """ + Constructor + + :param str value: Label of this property + :param bool isHeader: True if the cell is an header + :param tuple span: Tuple of row, column span + """ + self.__value = value + self.__isHeader = isHeader + self.__span = span + + def isHeader(self): + """Returns true if the property is a sub-header title. + + :rtype: bool + """ + return self.__isHeader + + def value(self): + """Returns the value of the item. + """ + return self.__value + + def span(self): + """Returns the span size of the cell. + + :rtype: tuple + """ + return self.__span + + +class _TableData(object): + """Modelize a table with header, row and column span. + + It is mostly defined as a row based table. + """ + + def __init__(self, columnCount): + """Constructor. + + :param int columnCount: Define the number of column of the table + """ + self.__colCount = columnCount + self.__data = [] + + def rowCount(self): + """Returns the number of rows. + + :rtype: int + """ + return len(self.__data) + + def columnCount(self): + """Returns the number of columns. + + :rtype: int + """ + return self.__colCount + + def clear(self): + """Remove all the cells of the table""" + self.__data = [] + + def cellAt(self, row, column): + """Returns the cell at the row column location. Else None if there is + nothing. + + :rtype: _CellData + """ + if row < 0: + return None + if column < 0: + return None + if row >= len(self.__data): + return None + cells = self.__data[row] + if column >= len(cells): + return None + return cells[column] + + def addHeaderRow(self, headerLabel): + """Append the table with header on the full row. + + :param str headerLabel: label of the header. + """ + item = _CellData(value=headerLabel, isHeader=True, span=(1, self.__colCount)) + self.__data.append([item]) + + def addHeaderValueRow(self, headerLabel, value): + """Append the table with a row using the first column as an header and + other cells as a single cell for the value. + + :param str headerLabel: label of the header. + :param object value: value to store. + """ + header = _CellData(value=headerLabel, isHeader=True) + value = _CellData(value=value, span=(1, self.__colCount)) + self.__data.append([header, value]) + + def addRow(self, *args): + """Append the table with a row using arguments for each cells + + :param list[object] args: List of cell values for the row + """ + row = [] + for value in args: + if not isinstance(value, _CellData): + value = _CellData(value=value) + row.append(value) + self.__data.append(row) + + +class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): + """This data model provides access to HDF5 node content (File, Group, + Dataset). Main info, like name, file, attributes... are displayed + """ + + def __init__(self, parent=None, data=None): + """ + Constructor + + :param qt.QObject parent: Parent object + :param object data: An h5py-like object (file, group or dataset) + """ + super(Hdf5TableModel, self).__init__(parent) + + self.__obj = None + self.__data = _TableData(columnCount=4) + self.__formatter = None + formatter = TextFormatter(self) + self.setFormatter(formatter) + self.setObject(data) + + def rowCount(self, parent_idx=None): + """Returns number of rows to be displayed in table""" + return self.__data.rowCount() + + def columnCount(self, parent_idx=None): + """Returns number of columns to be displayed in table""" + return self.__data.columnCount() + + def data(self, index, role=qt.Qt.DisplayRole): + """QAbstractTableModel method to access data values + in the format ready to be displayed""" + if not index.isValid(): + return None + + cell = self.__data.cellAt(index.row(), index.column()) + if cell is None: + return None + + if role == self.SpanRole: + return cell.span() + elif role == self.IsHeaderRole: + return cell.isHeader() + elif role == qt.Qt.DisplayRole: + value = cell.value() + if callable(value): + value = value(self.__obj) + return str(value) + return None + + def flags(self, index): + """QAbstractTableModel method to inform the view whether data + is editable or not. + """ + return qt.QAbstractTableModel.flags(self, index) + + def isSupportedObject(self, h5pyObject): + """ + Returns true if the provided object can be modelized using this model. + """ + isSupported = False + isSupported = isSupported or silx.io.is_group(h5pyObject) + isSupported = isSupported or silx.io.is_dataset(h5pyObject) + isSupported = isSupported or isinstance(h5pyObject, silx.gui.hdf5.H5Node) + return isSupported + + def setObject(self, h5pyObject): + """Set the h5py-like object exposed by the model + + :param h5pyObject: 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. + """ + if qt.qVersion() > "4.6": + self.beginResetModel() + + if h5pyObject is None or self.isSupportedObject(h5pyObject): + self.__obj = h5pyObject + else: + _logger.warning("Object class %s unsupported. Object ignored.", type(h5pyObject)) + self.__initProperties() + + if qt.qVersion() > "4.6": + self.endResetModel() + else: + self.reset() + + def __initProperties(self): + """Initialize the list of available properties according to the defined + h5py-like object.""" + self.__data.clear() + if self.__obj is None: + return + + obj = self.__obj + + hdf5obj = obj + if isinstance(obj, silx.gui.hdf5.H5Node): + hdf5obj = obj.h5py_object + + if silx.io.is_file(hdf5obj): + objectType = "File" + elif silx.io.is_group(hdf5obj): + objectType = "Group" + elif silx.io.is_dataset(hdf5obj): + objectType = "Dataset" + else: + objectType = obj.__class__.__name__ + self.__data.addHeaderRow(headerLabel="HDF5 %s" % objectType) + self.__data.addHeaderRow(headerLabel="Path info") + + self.__data.addHeaderValueRow("basename", lambda x: os.path.basename(x.name)) + self.__data.addHeaderValueRow("name", lambda x: x.name) + if silx.io.is_file(obj): + self.__data.addHeaderValueRow("filename", lambda x: x.filename) + + if isinstance(obj, silx.gui.hdf5.H5Node): + # helpful informations if the object come from an HDF5 tree + self.__data.addHeaderValueRow("local_basename", lambda x: x.local_basename) + self.__data.addHeaderValueRow("local_name", lambda x: x.local_name) + self.__data.addHeaderValueRow("local_filename", lambda x: x.local_file.filename) + + if hasattr(obj, "dtype"): + self.__data.addHeaderRow(headerLabel="Data info") + self.__data.addHeaderValueRow("dtype", lambda x: x.dtype) + if hasattr(obj, "shape"): + self.__data.addHeaderValueRow("shape", lambda x: x.shape) + if hasattr(obj, "size"): + self.__data.addHeaderValueRow("size", lambda x: x.size) + if hasattr(obj, "chunks") and obj.chunks is not None: + self.__data.addHeaderValueRow("chunks", lambda x: x.chunks) + + # relative to compression + # h5py expose compression, compression_opts but are not initialized + # for external plugins, then we use id + # h5py also expose fletcher32 and shuffle attributes, but it is also + # part of the filters + if hasattr(obj, "shape") and hasattr(obj, "id"): + dcpl = obj.id.get_create_plist() + if dcpl.get_nfilters() > 0: + self.__data.addHeaderRow(headerLabel="Compression info") + pos = _CellData(value="Position", isHeader=True) + hdf5id = _CellData(value="HDF5 ID", isHeader=True) + name = _CellData(value="Name", isHeader=True) + options = _CellData(value="Options", isHeader=True) + self.__data.addRow(pos, hdf5id, name, options) + for index in range(dcpl.get_nfilters()): + callback = lambda index, dataIndex, x: self.__get_filter_info(x, index)[dataIndex] + pos = _CellData(value=functools.partial(callback, index, 0)) + hdf5id = _CellData(value=functools.partial(callback, index, 1)) + name = _CellData(value=functools.partial(callback, index, 2)) + options = _CellData(value=functools.partial(callback, index, 3)) + self.__data.addRow(pos, hdf5id, name, options) + + if hasattr(obj, "attrs"): + if len(obj.attrs) > 0: + 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)) + + def __get_filter_info(self, dataset, filterIndex): + """Get a tuple of readable info from dataset filters + + :param h5py.Dataset dataset: A h5py dataset + :param int filterId: + """ + try: + dcpl = dataset.id.get_create_plist() + info = dcpl.get_filter(filterIndex) + filterId, _flags, cdValues, name = info + name = self.__formatter.toString(name) + options = " ".join([self.__formatter.toString(i) for i in cdValues]) + return (filterIndex, filterId, name, options) + except Exception: + _logger.debug("Backtrace", exc_info=True) + return [filterIndex, None, None, None] + + def object(self): + """Returns the internal object modelized. + + :rtype: An h5py-like object + """ + return self.__obj + + def setFormatter(self, formatter): + """Set the formatter object to be used to display data from the model + + :param TextFormatter formatter: Formatter to use + """ + if formatter is self.__formatter: + return + + if qt.qVersion() > "4.6": + self.beginResetModel() + + if self.__formatter is not None: + self.__formatter.formatChanged.disconnect(self.__formatChanged) + + self.__formatter = formatter + if self.__formatter is not None: + self.__formatter.formatChanged.connect(self.__formatChanged) + + if qt.qVersion() > "4.6": + self.endResetModel() + else: + self.reset() + + def getFormatter(self): + """Returns the text formatter used. + + :rtype: TextFormatter + """ + return self.__formatter + + def __formatChanged(self): + """Called when the format changed. + """ + self.reset() + + +class Hdf5TableView(HierarchicalTableView.HierarchicalTableView): + """A widget to display metadata about a HDF5 node using a table.""" + + def __init__(self, parent=None): + super(Hdf5TableView, self).__init__(parent) + self.setModel(Hdf5TableModel(self)) + + def isSupportedData(self, data): + """ + Returns true if the provided object can be modelized using this model. + """ + return self.model().isSupportedObject(data) + + 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`, + a `h5py.File`, a `h5py.Group`. It also can be a, + `silx.gui.hdf5.H5Node` which is needed to display some local path + information. + """ + self.model().setObject(data) + header = self.horizontalHeader() + if qt.qVersion() < "5.0": + setResizeMode = header.setResizeMode + else: + setResizeMode = header.setSectionResizeMode + setResizeMode(0, qt.QHeaderView.Fixed) + setResizeMode(1, qt.QHeaderView.Stretch) + header.setStretchLastSection(True) diff --git a/silx/gui/data/NXdataWidgets.py b/silx/gui/data/NXdataWidgets.py new file mode 100644 index 0000000..343c7f9 --- /dev/null +++ b/silx/gui/data/NXdataWidgets.py @@ -0,0 +1,523 @@ +# 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 defines widgets used by _NXdataView. +""" +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "20/03/2017" + +import numpy + +from silx.gui import qt +from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector +from silx.gui.plot import Plot1D, Plot2D, StackView + +from silx.math.calibration import ArrayCalibration, NoCalibration, LinearCalibration + + +class ArrayCurvePlot(qt.QWidget): + """ + Widget for plotting a curve from a multi-dimensional signal array + and a 1D axis array. + + The signal array can have an arbitrary number of dimensions, the only + limitation being that the last dimension must have the same length as + the axis array. + + The widget provides sliders to select indices on the first (n - 1) + dimensions of the signal array, and buttons to add/replace selected + curves to the plot. + + This widget also handles simple 2D or 3D scatter plots (third dimension + displayed as colour of points). + """ + def __init__(self, parent=None): + """ + + :param parent: Parent QWidget + """ + super(ArrayCurvePlot, self).__init__(parent) + + self.__signal = None + self.__signal_name = None + self.__signal_errors = None + self.__axis = None + self.__axis_name = None + self.__axis_errors = None + self.__values = None + + self.__first_curve_added = False + + self._plot = Plot1D(self) + self._plot.setDefaultColormap( # for scatters + {"name": "viridis", + "vmin": 0., "vmax": 1., # ignored (autoscale) but mandatory + "normalization": "linear", + "autoscale": True}) + + self.selectorDock = qt.QDockWidget("Data selector", self._plot) + # not closable + self.selectorDock.setFeatures(qt.QDockWidget.DockWidgetMovable | + qt.QDockWidget.DockWidgetFloatable) + self._selector = NumpyAxesSelector(self.selectorDock) + self._selector.setNamedAxesSelectorVisibility(False) + self.__selector_is_connected = False + self.selectorDock.setWidget(self._selector) + self._plot.addTabbedDockWidget(self.selectorDock) + + layout = qt.QGridLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._plot, 0, 0) + + self.setLayout(layout) + + def setCurveData(self, y, x=None, values=None, + yerror=None, xerror=None, + ylabel=None, xlabel=None, title=None): + """ + + :param y: dataset to be represented by the y (vertical) axis. + For a scatter, this must be a 1D array and x and values must be + 1-D arrays of the same size. + In other cases, it can be a n-D array whose last dimension must + have the same length as x (and values must be None) + :param x: 1-D dataset used as the curve's x values. If provided, + its lengths must be equal to the length of the last dimension of + ``y`` (and equal to the length of ``value``, for a scatter plot). + :param values: Values, to be provided for a x-y-value scatter plot. + This will be used to compute the color map and assign colors + to the points. + :param yerror: 1-D dataset of errors for y, or None + :param xerror: 1-D dataset of errors for x, or None + :param ylabel: Label for Y axis + :param xlabel: Label for X axis + :param title: Graph title + """ + self.__signal = y + self.__signal_name = ylabel + self.__signal_errors = yerror + self.__axis = x + self.__axis_name = xlabel + self.__axis_errors = xerror + self.__values = values + + if self.__selector_is_connected: + self._selector.selectionChanged.disconnect(self._updateCurve) + self.__selector_is_connected = False + self._selector.setData(y) + self._selector.setAxisNames([ylabel or "Y"]) + + if len(y.shape) < 2: + self.selectorDock.hide() + else: + self.selectorDock.show() + + self._plot.setGraphTitle(title or "") + self._plot.setGraphXLabel(self.__axis_name or "X") + self._plot.setGraphYLabel(self.__signal_name or "Y") + self._updateCurve() + + if not self.__selector_is_connected: + self._selector.selectionChanged.connect(self._updateCurve) + self.__selector_is_connected = True + + def _updateCurve(self): + y = self._selector.selectedData() + x = self.__axis + if x is None: + x = numpy.arange(len(y)) + elif numpy.isscalar(x) or len(x) == 1: + # constant axis + x = x * numpy.ones_like(y) + elif len(x) == 2 and len(y) != 2: + # linear calibration a + b * x + x = x[0] + x[1] * numpy.arange(len(y)) + legend = self.__signal_name + "[" + for sl in self._selector.selection(): + if sl == slice(None): + legend += ":, " + else: + legend += str(sl) + ", " + legend = legend[:-2] + "]" + if self.__signal_errors is not None: + y_errors = self.__signal_errors[self._selector.selection()] + else: + y_errors = None + + self._plot.remove(kind=("curve", "scatter")) + + # values: x-y-v scatter + if self.__values is not None: + self._plot.addScatter(x, y, self.__values, + legend=legend, + xerror=self.__axis_errors, + yerror=y_errors) + + # x monotonically increasing: curve + elif numpy.all(numpy.diff(x) > 0): + self._plot.addCurve(x, y, legend=legend, + xerror=self.__axis_errors, + yerror=y_errors) + + # scatter + else: + self._plot.addScatter(x, y, value=numpy.ones_like(y), + legend=legend, + xerror=self.__axis_errors, + yerror=y_errors) + self._plot.resetZoom() + self._plot.setGraphXLabel(self.__axis_name) + self._plot.setGraphYLabel(self.__signal_name) + + def clear(self): + self._plot.clear() + + +class ArrayImagePlot(qt.QWidget): + """ + Widget for plotting an image from a multi-dimensional signal array + and two 1D axes array. + + The signal array can have an arbitrary number of dimensions, the only + limitation being that the last two dimensions must have the same length as + the axes arrays. + + Sliders are provided to select indices on the first (n - 2) dimensions of + the signal array, and the plot is updated to show the image corresponding + to the selection. + + If one or both of the axes does not have regularly spaced values, the + the image is plotted as a coloured scatter plot. + """ + def __init__(self, parent=None): + """ + + :param parent: Parent QWidget + """ + super(ArrayImagePlot, self).__init__(parent) + + self.__signal = None + self.__signal_name = None + self.__x_axis = None + self.__x_axis_name = None + self.__y_axis = None + self.__y_axis_name = None + + self._plot = Plot2D(self) + self._plot.setDefaultColormap( + {"name": "viridis", + "vmin": 0., "vmax": 1., # ignored (autoscale) but mandatory + "normalization": "linear", + "autoscale": True}) + + self.selectorDock = qt.QDockWidget("Data selector", self._plot) + # not closable + self.selectorDock.setFeatures(qt.QDockWidget.DockWidgetMovable | + qt.QDockWidget.DockWidgetFloatable) + self._legend = qt.QLabel(self) + self._selector = NumpyAxesSelector(self.selectorDock) + self._selector.setNamedAxesSelectorVisibility(False) + self.__selector_is_connected = False + + layout = qt.QVBoxLayout() + layout.addWidget(self._plot) + layout.addWidget(self._legend) + self.selectorDock.setWidget(self._selector) + self._plot.addTabbedDockWidget(self.selectorDock) + + self.setLayout(layout) + + def setImageData(self, signal, + x_axis=None, y_axis=None, + signal_name=None, + xlabel=None, ylabel=None, + title=None): + """ + + :param signal: n-D dataset, whose last 2 dimensions are used as the + image's values. + :param x_axis: 1-D dataset used as the image's x coordinates. If + provided, its lengths must be equal to the length of the last + dimension of ``signal``. + :param y_axis: 1-D dataset used as the image's y. If provided, + its lengths must be equal to the length of the 2nd to last + dimension of ``signal``. + :param signal_name: Label used in the legend + :param xlabel: Label for X axis + :param ylabel: Label for Y axis + :param title: Graph title + """ + if self.__selector_is_connected: + self._selector.selectionChanged.disconnect(self._updateImage) + self.__selector_is_connected = False + + self.__signal = signal + self.__signal_name = signal_name or "" + self.__x_axis = x_axis + self.__x_axis_name = xlabel + self.__y_axis = y_axis + self.__y_axis_name = ylabel + + self._selector.setData(signal) + self._selector.setAxisNames([ylabel or "Y", xlabel or "X"]) + + if len(signal.shape) < 3: + self.selectorDock.hide() + else: + self.selectorDock.show() + + self._plot.setGraphTitle(title or "") + self._plot.setGraphXLabel(self.__x_axis_name or "X") + self._plot.setGraphYLabel(self.__y_axis_name or "Y") + + self._updateImage() + + if not self.__selector_is_connected: + self._selector.selectionChanged.connect(self._updateImage) + self.__selector_is_connected = True + + def _updateImage(self): + legend = self.__signal_name + "[" + for sl in self._selector.selection(): + if sl == slice(None): + legend += ":, " + else: + legend += str(sl) + ", " + legend = legend[:-2] + "]" + self._legend.setText("Displayed data: " + legend) + + img = self._selector.selectedData() + x_axis = self.__x_axis + y_axis = self.__y_axis + + if x_axis is None and y_axis is None: + xcalib = NoCalibration() + ycalib = NoCalibration() + else: + if x_axis is None: + # no calibration + x_axis = numpy.arange(img.shape[-1]) + elif numpy.isscalar(x_axis) or len(x_axis) == 1: + # constant axis + x_axis = x_axis * numpy.ones((img.shape[-1], )) + elif len(x_axis) == 2: + # linear calibration + x_axis = x_axis[0] * numpy.arange(img.shape[-1]) + x_axis[1] + + if y_axis is None: + y_axis = numpy.arange(img.shape[-2]) + elif numpy.isscalar(y_axis) or len(y_axis) == 1: + y_axis = y_axis * numpy.ones((img.shape[-2], )) + elif len(y_axis) == 2: + y_axis = y_axis[0] * numpy.arange(img.shape[-2]) + y_axis[1] + + xcalib = ArrayCalibration(x_axis) + ycalib = ArrayCalibration(y_axis) + + self._plot.remove(kind=("scatter", "image")) + if xcalib.is_affine() and ycalib.is_affine(): + # regular image + xorigin, xscale = xcalib(0), xcalib.get_slope() + yorigin, yscale = ycalib(0), ycalib.get_slope() + origin = (xorigin, yorigin) + scale = (xscale, yscale) + + self._plot.addImage(img, legend=legend, + origin=origin, scale=scale) + else: + scatterx, scattery = numpy.meshgrid(x_axis, y_axis) + self._plot.addScatter(numpy.ravel(scatterx), + numpy.ravel(scattery), + numpy.ravel(img), + legend=legend) + self._plot.setGraphXLabel(self.__x_axis_name) + self._plot.setGraphYLabel(self.__y_axis_name) + self._plot.resetZoom() + + def clear(self): + self._plot.clear() + + +class ArrayStackPlot(qt.QWidget): + """ + Widget for plotting a n-D array (n >= 3) as a stack of images. + Three axis arrays can be provided to calibrate the axes. + + The signal array can have an arbitrary number of dimensions, the only + limitation being that the last 3 dimensions must have the same length as + the axes arrays. + + Sliders are provided to select indices on the first (n - 3) dimensions of + the signal array, and the plot is updated to load the stack corresponding + to the selection. + """ + def __init__(self, parent=None): + """ + + :param parent: Parent QWidget + """ + super(ArrayStackPlot, self).__init__(parent) + + self.__signal = None + self.__signal_name = None + # the Z, Y, X axes apply to the last three dimensions of the signal + # (in that order) + self.__z_axis = None + self.__z_axis_name = None + self.__y_axis = None + self.__y_axis_name = None + self.__x_axis = None + self.__x_axis_name = None + + self._stack_view = StackView(self) + self._hline = qt.QFrame(self) + self._hline.setFrameStyle(qt.QFrame.HLine) + self._hline.setFrameShadow(qt.QFrame.Sunken) + self._legend = qt.QLabel(self) + self._selector = NumpyAxesSelector(self) + self._selector.setNamedAxesSelectorVisibility(False) + self.__selector_is_connected = False + + layout = qt.QVBoxLayout() + layout.addWidget(self._stack_view) + layout.addWidget(self._hline) + layout.addWidget(self._legend) + layout.addWidget(self._selector) + + self.setLayout(layout) + + def setStackData(self, signal, + x_axis=None, y_axis=None, z_axis=None, + signal_name=None, + xlabel=None, ylabel=None, zlabel=None, + title=None): + """ + + :param signal: n-D dataset, whose last 3 dimensions are used as the + 3D stack values. + :param x_axis: 1-D dataset used as the image's x coordinates. If + provided, its lengths must be equal to the length of the last + dimension of ``signal``. + :param y_axis: 1-D dataset used as the image's y. If provided, + its lengths must be equal to the length of the 2nd to last + dimension of ``signal``. + :param z_axis: 1-D dataset used as the image's z. If provided, + its lengths must be equal to the length of the 3rd to last + dimension of ``signal``. + :param signal_name: Label used in the legend + :param xlabel: Label for X axis + :param ylabel: Label for Y axis + :param zlabel: Label for Z axis + :param title: Graph title + """ + if self.__selector_is_connected: + self._selector.selectionChanged.disconnect(self._updateStack) + self.__selector_is_connected = False + + self.__signal = signal + self.__signal_name = signal_name or "" + self.__x_axis = x_axis + self.__x_axis_name = xlabel + self.__y_axis = y_axis + self.__y_axis_name = ylabel + self.__z_axis = z_axis + self.__z_axis_name = zlabel + + self._selector.setData(signal) + self._selector.setAxisNames([ylabel or "Y", xlabel or "X", zlabel or "Z"]) + + self._stack_view.setGraphTitle(title or "") + # by default, the z axis is the image position (dimension not plotted) + self._stack_view.setGraphXLabel(self.__x_axis_name or "X") + self._stack_view.setGraphYLabel(self.__y_axis_name or "Y") + + self._updateStack() + + ndims = len(signal.shape) + self._stack_view.setFirstStackDimension(ndims - 3) + + # the legend label shows the selection slice producing the volume + # (only interesting for ndim > 3) + if ndims > 3: + self._selector.setVisible(True) + self._legend.setVisible(True) + self._hline.setVisible(True) + else: + self._selector.setVisible(False) + self._legend.setVisible(False) + self._hline.setVisible(False) + + if not self.__selector_is_connected: + self._selector.selectionChanged.connect(self._updateStack) + self.__selector_is_connected = True + + @staticmethod + def _get_origin_scale(axis): + """Assuming axis is a regularly spaced 1D array, + return a tuple (origin, scale) where: + - origin = axis[0] + - scale = (axis[n-1] - axis[0]) / (n -1) + :param axis: 1D numpy array + :return: Tuple (axis[0], (axis[-1] - axis[0]) / (len(axis) - 1)) + """ + return axis[0], (axis[-1] - axis[0]) / (len(axis) - 1) + + def _updateStack(self): + """Update displayed stack according to the current axes selector + data.""" + stk = self._selector.selectedData() + x_axis = self.__x_axis + y_axis = self.__y_axis + z_axis = self.__z_axis + + calibrations = [] + for axis in [z_axis, y_axis, x_axis]: + + if axis is None: + calibrations.append(NoCalibration()) + elif len(axis) == 2: + calibrations.append( + LinearCalibration(y_intercept=axis[0], + slope=axis[1])) + else: + calibrations.append(ArrayCalibration(axis)) + + legend = self.__signal_name + "[" + for sl in self._selector.selection(): + if sl == slice(None): + legend += ":, " + else: + legend += str(sl) + ", " + legend = legend[:-2] + "]" + self._legend.setText("Displayed data: " + legend) + + self._stack_view.setStack(stk, calibrations=calibrations) + self._stack_view.setLabels( + labels=[self.__z_axis_name, + self.__y_axis_name, + self.__x_axis_name]) + + def clear(self): + self._stack_view.clear() diff --git a/silx/gui/data/NumpyAxesSelector.py b/silx/gui/data/NumpyAxesSelector.py new file mode 100644 index 0000000..f4641da --- /dev/null +++ b/silx/gui/data/NumpyAxesSelector.py @@ -0,0 +1,468 @@ +# 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. +# +# ###########################################################################*/ +"""This module defines a widget able to convert a numpy array from n-dimensions +to a numpy array with less dimensions. +""" +from __future__ import division + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "16/01/2017" + +import numpy +import functools +from silx.gui.widgets.FrameBrowser import HorizontalSliderWithBrowser +from silx.gui import qt +import silx.utils.weakref + + +class _Axis(qt.QWidget): + """Widget displaying an axis. + + It allows to display and scroll in the axis, and provide a widget to + map the axis with a named axis (the one from the view). + """ + + valueChanged = qt.Signal(int) + """Emitted when the location on the axis change.""" + + axisNameChanged = qt.Signal(object) + """Emitted when the user change the name of the axis.""" + + def __init__(self, parent=None): + """Constructor + + :param parent: Parent of the widget + """ + super(_Axis, self).__init__(parent) + self.__axisNumber = None + self.__customAxisNames = set([]) + self.__label = qt.QLabel(self) + self.__axes = qt.QComboBox(self) + self.__axes.currentIndexChanged[int].connect(self.__axisMappingChanged) + self.__slider = HorizontalSliderWithBrowser(self) + self.__slider.valueChanged[int].connect(self.__sliderValueChanged) + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.__label) + layout.addWidget(self.__axes) + layout.addWidget(self.__slider, 10000) + layout.addStretch(1) + self.setLayout(layout) + + def slider(self): + """Returns the slider used to display axes location. + + :rtype: HorizontalSliderWithBrowser + """ + return self.__slider + + def setAxis(self, number, position, size): + """Set axis information. + + :param int number: The number of the axis (from the original numpy + array) + :param int position: The current position in the axis (for a slicing) + :param int size: The size of this axis (0..n) + """ + self.__label.setText("Dimension %s" % number) + self.__axisNumber = number + self.__slider.setMaximum(size - 1) + + def axisNumber(self): + """Returns the axis number. + + :rtype: int + """ + return self.__axisNumber + + def setAxisName(self, axisName): + """Set the current used axis name. + + If this name is not available an exception is raised. An empty string + means that no name is selected. + + :param str axisName: The new name of the axis + :raise ValueError: When the name is not available + """ + if axisName == "" and self.__axes.count() == 0: + self.__axes.setCurrentIndex(-1) + self.__updateSliderVisibility() + for index in range(self.__axes.count()): + name = self.__axes.itemData(index) + if name == axisName: + self.__axes.setCurrentIndex(index) + self.__updateSliderVisibility() + return + raise ValueError("Axis name '%s' not found", axisName) + + def axisName(self): + """Returns the selected axis name. + + If no names are selected, an empty string is retruned. + + :rtype: str + """ + index = self.__axes.currentIndex() + if index == -1: + return "" + return self.__axes.itemData(index) + + def setAxisNames(self, axesNames): + """Set the available list of names for the axis. + + :param list[str] axesNames: List of available names + """ + self.__axes.clear() + previous = self.__axes.blockSignals(True) + self.__axes.addItem(" ", "") + for axis in axesNames: + self.__axes.addItem(axis, axis) + self.__axes.blockSignals(previous) + self.__updateSliderVisibility() + + def setCustomAxis(self, axesNames): + """Set the available list of named axis which can be set to a value. + + :param list[str] axesNames: List of customable axis names + """ + self.__customAxisNames = set(axesNames) + self.__updateSliderVisibility() + + def __axisMappingChanged(self, index): + """Called when the selected name change. + + :param int index: Selected index + """ + self.__updateSliderVisibility() + name = self.axisName() + self.axisNameChanged.emit(name) + + def __updateSliderVisibility(self): + """Update the visibility of the slider according to axis names and + customable axis names.""" + name = self.axisName() + isVisible = name == "" or name in self.__customAxisNames + self.__slider.setVisible(isVisible) + + def value(self): + """Returns the current selected position in the axis. + + :rtype: int + """ + return self.__slider.value() + + def __sliderValueChanged(self, value): + """Called when the selected position in the axis change. + + :param int value: Position of the axis + """ + self.valueChanged.emit(value) + + def setNamedAxisSelectorVisibility(self, visible): + """Hide or show the named axis combobox. + If both the selector and the slider are hidden, + hide the entire widget. + + :param visible: boolean + """ + self.__axes.setVisible(visible) + name = self.axisName() + + if not visible and name != "": + self.setVisible(False) + else: + self.setVisible(True) + + +class NumpyAxesSelector(qt.QWidget): + """Widget to select a view from a numpy array. + + .. image:: img/NumpyAxesSelector.png + + The widget is set with an input data using :meth:`setData`, and a requested + output dimension using :meth:`setAxisNames`. + + Widgets are provided to selected expected input axis, and a slice on the + non-selected axis. + + The final selected array can be reached using the getter + :meth:`selectedData`, and the event `selectionChanged`. + + If the input data is a HDF5 Dataset, the selected output data will be a + new numpy array. + """ + + dataChanged = qt.Signal() + """Emitted when the input data change""" + + selectedAxisChanged = qt.Signal() + """Emitted when the selected axis change""" + + selectionChanged = qt.Signal() + """Emitted when the selected data change""" + + customAxisChanged = qt.Signal(str, int) + """Emitted when a custom axis change""" + + def __init__(self, parent=None): + """Constructor + + :param parent: Parent of the widget + """ + super(NumpyAxesSelector, self).__init__(parent) + + self.__data = None + self.__selectedData = None + self.__selection = tuple() + self.__axis = [] + self.__axisNames = [] + self.__customAxisNames = set([]) + self.__namedAxesVisibility = True + layout = qt.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSizeConstraint(qt.QLayout.SetMinAndMaxSize) + self.setLayout(layout) + + def clear(self): + """Clear the widget.""" + self.setData(None) + + def setAxisNames(self, axesNames): + """Set the axis names of the output selected data. + + Axis names are defined from slower to faster axis. + + The size of the list will constrain the dimension of the resulting + array. + + :param list[str] axesNames: List of string identifying axis names + """ + self.__axisNames = list(axesNames) + delta = len(self.__axis) - len(self.__axisNames) + if delta < 0: + delta = 0 + for index, axis in enumerate(self.__axis): + previous = axis.blockSignals(True) + axis.setAxisNames(self.__axisNames) + if index >= delta and index - delta < len(self.__axisNames): + axis.setAxisName(self.__axisNames[index - delta]) + else: + axis.setAxisName("") + axis.blockSignals(previous) + self.__updateSelectedData() + + def setCustomAxis(self, axesNames): + """Set the available list of named axis which can be set to a value. + + :param list[str] axesNames: List of customable axis names + """ + self.__customAxisNames = set(axesNames) + for axis in self.__axis: + axis.setCustomAxis(self.__customAxisNames) + + def setData(self, data): + """Set the input data unsed by the widget. + + :param numpy.ndarray data: The input data + """ + if self.__data is not None: + # clean up + for widget in self.__axis: + self.layout().removeWidget(widget) + widget.deleteLater() + self.__axis = [] + + self.__data = data + + if data is not None: + # create expected axes + dimensionNumber = len(data.shape) + delta = dimensionNumber - len(self.__axisNames) + for index in range(dimensionNumber): + axis = _Axis(self) + axis.setAxis(index, 0, data.shape[index]) + axis.setAxisNames(self.__axisNames) + axis.setCustomAxis(self.__customAxisNames) + if index >= delta and index - delta < len(self.__axisNames): + axis.setAxisName(self.__axisNames[index - delta]) + # this weak method was expected to be able to delete sub widget + callback = functools.partial(silx.utils.weakref.WeakMethodProxy(self.__axisValueChanged), axis) + axis.valueChanged.connect(callback) + # this weak method was expected to be able to delete sub widget + callback = functools.partial(silx.utils.weakref.WeakMethodProxy(self.__axisNameChanged), axis) + axis.axisNameChanged.connect(callback) + axis.setNamedAxisSelectorVisibility(self.__namedAxesVisibility) + self.layout().addWidget(axis) + self.__axis.append(axis) + self.__normalizeAxisGeometry() + + self.dataChanged.emit() + self.__updateSelectedData() + + def __normalizeAxisGeometry(self): + """Update axes geometry to align all axes components together.""" + if len(self.__axis) <= 0: + return + lineEditWidth = max([a.slider().lineEdit().minimumSize().width() for a in self.__axis]) + limitWidth = max([a.slider().limitWidget().minimumSizeHint().width() for a in self.__axis]) + for a in self.__axis: + a.slider().lineEdit().setFixedWidth(lineEditWidth) + a.slider().limitWidget().setFixedWidth(limitWidth) + + def __axisValueChanged(self, axis, value): + name = axis.axisName() + if name in self.__customAxisNames: + self.customAxisChanged.emit(name, value) + else: + self.__updateSelectedData() + + def __axisNameChanged(self, axis, name): + """Called when an axis name change. + + :param _Axis axis: The changed axis + :param str name: The new name of the axis + """ + names = [x.axisName() for x in self.__axis] + missingName = set(self.__axisNames) - set(names) - set("") + if len(missingName) == 0: + missingName = None + elif len(missingName) == 1: + missingName = list(missingName)[0] + else: + raise Exception("Unexpected state") + + axisChanged = True + + if axis.axisName() == "": + # set the removed label to another widget if it is possible + availableWidget = None + for widget in self.__axis: + if widget is axis: + continue + if widget.axisName() == "": + availableWidget = widget + break + if availableWidget is None: + # If there is no other solution we set the name at the same place + axisChanged = False + availableWidget = axis + previous = availableWidget.blockSignals(True) + availableWidget.setAxisName(missingName) + availableWidget.blockSignals(previous) + else: + # there is a duplicated name somewhere + # we swap it with the missing name or with nothing + dupWidget = None + for widget in self.__axis: + if widget is axis: + continue + if widget.axisName() == axis.axisName(): + dupWidget = widget + break + if missingName is None: + missingName = "" + previous = dupWidget.blockSignals(True) + dupWidget.setAxisName(missingName) + dupWidget.blockSignals(previous) + + if self.__data is None: + return + if axisChanged: + self.selectedAxisChanged.emit() + self.__updateSelectedData() + + def __updateSelectedData(self): + """Update the selected data according to the state of the widget. + + It fires a `selectionChanged` event. + """ + if self.__data is None: + if self.__selectedData is not None: + self.__selectedData = None + self.__selection = tuple() + self.selectionChanged.emit() + return + + selection = [] + axisNames = [] + for slider in self.__axis: + name = slider.axisName() + if name == "": + selection.append(slider.value()) + else: + selection.append(slice(None)) + axisNames.append(name) + + self.__selection = tuple(selection) + # get a view with few fixed dimensions + # with a h5py dataset, it create a copy + # TODO we can reuse the same memory in case of a copy + view = self.__data[self.__selection] + + # order axis as expected + source = [] + destination = [] + order = [] + for index, name in enumerate(self.__axisNames): + destination.append(index) + source.append(axisNames.index(name)) + for _, s in sorted(zip(destination, source)): + order.append(s) + view = numpy.transpose(view, order) + + self.__selectedData = view + self.selectionChanged.emit() + + def data(self): + """Returns the input data. + + :rtype: numpy.ndarray + """ + return self.__data + + def selectedData(self): + """Returns the output data. + + :rtype: numpy.ndarray + """ + return self.__selectedData + + def selection(self): + """Returns the selection tuple used to slice the data. + + :rtype: tuple + """ + return self.__selection + + def setNamedAxesSelectorVisibility(self, visible): + """Show or hide the combo-boxes allowing to map the plot axes + to the data dimension. + + :param visible: Boolean + """ + self.__namedAxesVisibility = visible + for axis in self.__axis: + axis.setNamedAxisSelectorVisibility(visible) diff --git a/silx/gui/data/RecordTableView.py b/silx/gui/data/RecordTableView.py new file mode 100644 index 0000000..ce6a178 --- /dev/null +++ b/silx/gui/data/RecordTableView.py @@ -0,0 +1,405 @@ +# 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 define model and widget to display 1D slices from numpy +array using compound data types or hdf5 databases. +""" +from __future__ import division + +import itertools +import numpy +from silx.gui import qt +import silx.io +from .TextFormatter import TextFormatter +from silx.gui.widgets.TableWidget import CopySelectedCellsAction + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "27/01/2017" + + +class _MultiLineItem(qt.QItemDelegate): + """Draw a multiline text without hiding anything. + + The paint method display a cell without any wrap. And an editor is + available to scroll into the selected cell. + """ + + def __init__(self, parent=None): + """ + Constructor + + :param qt.QWidget parent: Parent of the widget + """ + qt.QItemDelegate.__init__(self, parent) + self.__textOptions = qt.QTextOption() + self.__textOptions.setFlags(qt.QTextOption.IncludeTrailingSpaces | + qt.QTextOption.ShowTabsAndSpaces) + self.__textOptions.setWrapMode(qt.QTextOption.NoWrap) + self.__textOptions.setAlignment(qt.Qt.AlignTop | qt.Qt.AlignLeft) + + def paint(self, painter, option, index): + """ + Write multiline text without using any wrap or any alignment according + to the cell size. + + :param qt.QPainter painter: Painter context used to displayed the cell + :param qt.QStyleOptionViewItem option: Control how the editor is shown + :param qt.QIndex index: Index of the data to display + """ + painter.save() + + # set colors + painter.setPen(qt.QPen(qt.Qt.NoPen)) + if option.state & qt.QStyle.State_Selected: + brush = option.palette.highlight() + painter.setBrush(brush) + else: + brush = index.data(qt.Qt.BackgroundRole) + if brush is None: + # default background color for a cell + brush = qt.Qt.white + painter.setBrush(brush) + painter.drawRect(option.rect) + + if index.isValid(): + if option.state & qt.QStyle.State_Selected: + brush = option.palette.highlightedText() + else: + brush = index.data(qt.Qt.ForegroundRole) + if brush is None: + brush = option.palette.text() + painter.setPen(qt.QPen(brush.color())) + text = index.data(qt.Qt.DisplayRole) + painter.drawText(qt.QRectF(option.rect), text, self.__textOptions) + + painter.restore() + + def createEditor(self, parent, option, index): + """ + Returns the widget used to edit the item specified by index for editing. + + We use it not to edit the content but to show the content with a + convenient scroll bar. + + :param qt.QWidget parent: Parent of the widget + :param qt.QStyleOptionViewItem option: Control how the editor is shown + :param qt.QIndex index: Index of the data to display + """ + if not index.isValid(): + return super(_MultiLineItem, self).createEditor(parent, option, index) + + editor = qt.QTextEdit(parent) + editor.setReadOnly(True) + return editor + + def setEditorData(self, editor, index): + """ + Read data from the model and feed the editor. + + :param qt.QWidget editor: Editor widget + :param qt.QIndex index: Index of the data to display + """ + text = index.model().data(index, qt.Qt.EditRole) + editor.setText(text) + + def updateEditorGeometry(self, editor, option, index): + """ + Update the geometry of the editor according to the changes of the view. + + :param qt.QWidget editor: Editor widget + :param qt.QStyleOptionViewItem option: Control how the editor is shown + :param qt.QIndex index: Index of the data to display + """ + editor.setGeometry(option.rect) + + +class RecordTableModel(qt.QAbstractTableModel): + """This data model provides access to 1D slices from numpy array using + compound data types or hdf5 databases. + + Each entries are displayed in a single row, and each columns contain a + specific field of the compound type. + + It also allows to display 1D arrays of simple data types. + array. + + :param qt.QObject parent: Parent object + :param numpy.ndarray data: A numpy array or a h5py dataset + """ + def __init__(self, parent=None, data=None): + qt.QAbstractTableModel.__init__(self, parent) + + self.__data = None + self.__is_array = False + self.__fields = None + self.__formatter = None + self.__editFormatter = None + self.setFormatter(TextFormatter(self)) + + # set _data + self.setArrayData(data) + + # Methods to be implemented to subclass QAbstractTableModel + def rowCount(self, parent_idx=None): + """Returns number of rows to be displayed in table""" + if self.__data is None: + return 0 + elif not self.__is_array: + return 1 + else: + return len(self.__data) + + def columnCount(self, parent_idx=None): + """Returns number of columns to be displayed in table""" + if self.__fields is None: + return 1 + else: + return len(self.__fields) + + def data(self, index, role=qt.Qt.DisplayRole): + """QAbstractTableModel method to access data values + in the format ready to be displayed""" + if not index.isValid(): + return None + + if self.__data is None: + return None + + if self.__is_array: + if index.row() >= len(self.__data): + return None + data = self.__data[index.row()] + else: + if index.row() > 0: + return None + data = self.__data + + if self.__fields is not None: + if index.column() >= len(self.__fields): + return None + key = self.__fields[index.column()][1] + data = data[key[0]] + if len(key) > 1: + data = data[key[1]] + + if role == qt.Qt.DisplayRole: + return self.__formatter.toString(data) + elif role == qt.Qt.EditRole: + return self.__editFormatter.toString(data) + return None + + def headerData(self, section, orientation, role=qt.Qt.DisplayRole): + """Returns the 0-based row or column index, for display in the + horizontal and vertical headers""" + if section == -1: + # PyQt4 send -1 when there is columns but no rows + return None + + if role == qt.Qt.DisplayRole: + if orientation == qt.Qt.Vertical: + if not self.__is_array: + return "Scalar" + else: + return str(section) + if orientation == qt.Qt.Horizontal: + if self.__fields is None: + if section == 0: + return "Data" + else: + return None + else: + if section < len(self.__fields): + return self.__fields[section][0] + else: + return None + return None + + def flags(self, index): + """QAbstractTableModel method to inform the view whether data + is editable or not. + """ + return qt.QAbstractTableModel.flags(self, index) + + def setArrayData(self, data): + """Set the data array and the viewing perspective. + + You can set ``copy=False`` if you need more performances, when dealing + with a large numpy array. In this case, a simple reference to the data + is used to access the data, rather than a copy of the array. + + .. warning:: + + Any change to the data model will affect your original data + array, when using a reference rather than a copy.. + + :param data: 1D numpy array, or any object that can be + converted to a numpy array using ``numpy.array(data)`` (e.g. + a nested sequence). + """ + if qt.qVersion() > "4.6": + self.beginResetModel() + + self.__data = data + if isinstance(data, numpy.ndarray): + self.__is_array = True + elif silx.io.is_dataset(data) and data.shape != tuple(): + self.__is_array = True + else: + self.__is_array = False + + + self.__fields = [] + if data is not None: + if data.dtype.fields is not None: + for name, (dtype, _index) in data.dtype.fields.items(): + if dtype.shape != tuple(): + keys = itertools.product(*[range(x) for x in dtype.shape]) + for key in keys: + label = "%s%s" % (name, list(key)) + array_key = (name, key) + self.__fields.append((label, array_key)) + else: + self.__fields.append((name, (name,))) + else: + self.__fields = None + + if qt.qVersion() > "4.6": + self.endResetModel() + else: + self.reset() + + def arrayData(self): + """Returns the internal data. + + :rtype: numpy.ndarray of h5py.Dataset + """ + return self.__data + + def setFormatter(self, formatter): + """Set the formatter object to be used to display data from the model + + :param TextFormatter formatter: Formatter to use + """ + if formatter is self.__formatter: + return + + if qt.qVersion() > "4.6": + self.beginResetModel() + + if self.__formatter is not None: + self.__formatter.formatChanged.disconnect(self.__formatChanged) + + self.__formatter = formatter + self.__editFormatter = TextFormatter(formatter) + self.__editFormatter.setUseQuoteForText(False) + + if self.__formatter is not None: + self.__formatter.formatChanged.connect(self.__formatChanged) + + if qt.qVersion() > "4.6": + self.endResetModel() + else: + self.reset() + + def getFormatter(self): + """Returns the text formatter used. + + :rtype: TextFormatter + """ + return self.__formatter + + def __formatChanged(self): + """Called when the format changed. + """ + self.__editFormatter = TextFormatter(self, self.getFormatter()) + self.__editFormatter.setUseQuoteForText(False) + self.reset() + + +class _ShowEditorProxyModel(qt.QIdentityProxyModel): + """ + Allow to custom the flag edit of the model + """ + + def __init__(self, parent=None): + """ + Constructor + + :param qt.QObject arent: parent object + """ + super(_ShowEditorProxyModel, self).__init__(parent) + self.__forceEditable = False + + def flags(self, index): + flag = qt.QIdentityProxyModel.flags(self, index) + if self.__forceEditable: + flag = flag | qt.Qt.ItemIsEditable + return flag + + def forceCellEditor(self, show): + """ + Enable the editable flag to allow to display cell editor. + """ + if self.__forceEditable == show: + return + self.beginResetModel() + self.__forceEditable = show + self.endResetModel() + + +class RecordTableView(qt.QTableView): + """TableView using DatabaseTableModel as default model. + """ + def __init__(self, parent=None): + """ + Constructor + + :param qt.QWidget parent: parent QWidget + """ + qt.QTableView.__init__(self, parent) + + model = _ShowEditorProxyModel(self) + model.setSourceModel(RecordTableModel()) + self.setModel(model) + self.__multilineView = _MultiLineItem(self) + self.setEditTriggers(qt.QAbstractItemView.AllEditTriggers) + self._copyAction = CopySelectedCellsAction(self) + self.addAction(self._copyAction) + + def copy(self): + self._copyAction.trigger() + + def setArrayData(self, data): + self.model().sourceModel().setArrayData(data) + if data is not None: + if issubclass(data.dtype.type, (numpy.string_, numpy.unicode_)): + # TODO it would be nice to also fix fields + # but using it only for string array is already very useful + self.setItemDelegateForColumn(0, self.__multilineView) + self.model().forceCellEditor(True) + else: + self.setItemDelegateForColumn(0, None) + self.model().forceCellEditor(False) diff --git a/silx/gui/data/TextFormatter.py b/silx/gui/data/TextFormatter.py new file mode 100644 index 0000000..f074de5 --- /dev/null +++ b/silx/gui/data/TextFormatter.py @@ -0,0 +1,222 @@ +# 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 package provides a class sharred by widget from the +data module to format data as text in the same way.""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "26/04/2017" + +import numpy +import numbers +import binascii +from silx.third_party import six +from silx.gui import qt + + +class TextFormatter(qt.QObject): + """Formatter to convert data to string. + + The method :meth:`toString` returns a formatted string from an input data + using parameters set to this object. + + It support most python and numpy data, expecting dictionary. Unsupported + data are displayed using the string representation of the object (`str`). + + It provides a set of parameters to custom the formatting of integer and + float values (:meth:`setIntegerFormat`, :meth:`setFloatFormat`). + + It also allows to custom the use of quotes to display text data + (:meth:`setUseQuoteForText`), and custom unit used to display imaginary + numbers (:meth:`setImaginaryUnit`). + + The object emit an event `formatChanged` every time a parametter is + changed. + """ + + formatChanged = qt.Signal() + """Emitted when properties of the formatter change.""" + + def __init__(self, parent=None, formatter=None): + """ + Constructor + + :param qt.QObject parent: Owner of the object + :param TextFormatter formatter: Instantiate this object from the + formatter + """ + qt.QObject.__init__(self, parent) + if formatter is not None: + self.__integerFormat = formatter.integerFormat() + self.__floatFormat = formatter.floatFormat() + self.__useQuoteForText = formatter.useQuoteForText() + self.__imaginaryUnit = formatter.imaginaryUnit() + else: + self.__integerFormat = "%d" + self.__floatFormat = "%g" + self.__useQuoteForText = True + self.__imaginaryUnit = u"j" + + def integerFormat(self): + """Returns the format string controlling how the integer data + are formated by this object. + + This is the C-style format string used by python when formatting + strings with the modulus operator. + + :rtype: str + """ + return self.__integerFormat + + def setIntegerFormat(self, value): + """Set format string controlling how the integer data are + formated by this object. + + :param str value: Format string (e.g. "%d", "%i", "%08i"). + This is the C-style format string used by python when formatting + strings with the modulus operator. + """ + if self.__integerFormat == value: + return + self.__integerFormat = value + self.formatChanged.emit() + + def floatFormat(self): + """Returns the format string controlling how the floating-point data + are formated by this object. + + This is the C-style format string used by python when formatting + strings with the modulus operator. + + :rtype: str + """ + return self.__floatFormat + + def setFloatFormat(self, value): + """Set format string controlling how the floating-point data are + formated by this object. + + :param str value: Format string (e.g. "%.3f", "%d", "%-10.2f", + "%10.3e"). + This is the C-style format string used by python when formatting + strings with the modulus operator. + """ + if self.__floatFormat == value: + return + self.__floatFormat = value + self.formatChanged.emit() + + def useQuoteForText(self): + """Returns true if the string data are formatted using double quotes. + + Else, no quotes are used. + """ + return self.__integerFormat + + def setUseQuoteForText(self, useQuote): + """Set the use of quotes to delimit string data. + + :param bool useQuote: True to use quotes. + """ + if self.__useQuoteForText == useQuote: + return + self.__useQuoteForText = useQuote + self.formatChanged.emit() + + def imaginaryUnit(self): + """Returns the unit display for imaginary numbers. + + :rtype: str + """ + return self.__imaginaryUnit + + def setImaginaryUnit(self, imaginaryUnit): + """Set the unit display for imaginary numbers. + + :param str imaginaryUnit: Unit displayed after imaginary numbers + """ + if self.__imaginaryUnit == imaginaryUnit: + return + self.__imaginaryUnit = imaginaryUnit + self.formatChanged.emit() + + def toString(self, data): + """Format a data into a string using formatter options + + :param object data: Data to render + :rtype: str + """ + if isinstance(data, tuple): + text = [self.toString(d) for d in data] + return "(" + " ".join(text) + ")" + elif isinstance(data, (list, numpy.ndarray)): + text = [self.toString(d) for d in data] + return "[" + " ".join(text) + "]" + elif isinstance(data, numpy.void): + dtype = data.dtype + if data.dtype.fields is not None: + text = [self.toString(data[f]) for f in dtype.fields] + return "(" + " ".join(text) + ")" + return "0x" + binascii.hexlify(data).decode("ascii") + elif isinstance(data, (numpy.string_, numpy.object_, bytes)): + # This have to be done before checking python string inheritance + try: + text = "%s" % data.decode("utf-8") + if self.__useQuoteForText: + text = "\"%s\"" % text.replace("\"", "\\\"") + return text + except UnicodeDecodeError: + pass + return "0x" + binascii.hexlify(data).decode("ascii") + elif isinstance(data, six.string_types): + text = "%s" % data + if self.__useQuoteForText: + text = "\"%s\"" % text.replace("\"", "\\\"") + return text + elif isinstance(data, (numpy.integer, numbers.Integral)): + return self.__integerFormat % data + 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)): + text = "" + if data.real != 0: + text += self.__floatFormat % data.real + if data.real != 0 and data.imag != 0: + if data.imag < 0: + template = self.__floatFormat + " - " + self.__floatFormat + self.__imaginaryUnit + params = (data.real, -data.imag) + else: + template = self.__floatFormat + " + " + self.__floatFormat + self.__imaginaryUnit + params = (data.real, data.imag) + else: + if data.imag != 0: + template = self.__floatFormat + self.__imaginaryUnit + params = (data.imag) + else: + template = self.__floatFormat + params = (data.real) + return template % params + return str(data) diff --git a/silx/gui/data/__init__.py b/silx/gui/data/__init__.py new file mode 100644 index 0000000..560062d --- /dev/null +++ b/silx/gui/data/__init__.py @@ -0,0 +1,35 @@ +# 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. +# +# ###########################################################################*/ +"""This package provides a set of Qt widgets for displaying data arrays using +table views and plot widgets. + +.. note:: + + Widgets in this package may rely on additional dependencies that are + not mandatory for *silx*. + :class:`DataViewer.DataViewer` relies on :mod:`silx.gui.plot` which + depends on *matplotlib*. It also optionally depends on *PyOpenGL* for 3D + visualization. +""" diff --git a/silx/gui/data/setup.py b/silx/gui/data/setup.py new file mode 100644 index 0000000..23ccbdd --- /dev/null +++ b/silx/gui/data/setup.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__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "16/01/2017" + + +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('data', parent_package, top_path) + config.add_subpackage('test') + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + setup(configuration=configuration) diff --git a/silx/gui/data/test/__init__.py b/silx/gui/data/test/__init__.py new file mode 100644 index 0000000..08c044b --- /dev/null +++ b/silx/gui/data/test/__init__.py @@ -0,0 +1,45 @@ +# 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. +# +# ###########################################################################*/ +import unittest + +from . import test_arraywidget +from . import test_numpyaxesselector +from . import test_dataviewer +from . import test_textformatter + +__authors__ = ["V. Valls", "P. Knobel"] +__license__ = "MIT" +__date__ = "24/01/2017" + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTests( + [test_arraywidget.suite(), + test_numpyaxesselector.suite(), + test_dataviewer.suite(), + test_textformatter.suite(), + ]) + return test_suite diff --git a/silx/gui/data/test/test_arraywidget.py b/silx/gui/data/test/test_arraywidget.py new file mode 100644 index 0000000..bbd7ee5 --- /dev/null +++ b/silx/gui/data/test/test_arraywidget.py @@ -0,0 +1,320 @@ +# 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__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "05/12/2016" + +import os +import tempfile +import unittest + +import numpy + +from silx.gui import qt +from silx.gui.data import ArrayTableWidget +from silx.gui.test.utils import TestCaseQt + +try: + import h5py +except ImportError: + h5py = None + + +class TestArrayWidget(TestCaseQt): + """Basic test for ArrayTableWidget with a numpy array""" + def setUp(self): + super(TestArrayWidget, self).setUp() + self.aw = ArrayTableWidget.ArrayTableWidget() + + def tearDown(self): + del self.aw + super(TestArrayWidget, self).tearDown() + + def testShow(self): + """test for errors""" + self.aw.show() + self.qWaitForWindowExposed(self.aw) + + def testSetData0D(self): + a = 1 + self.aw.setArrayData(a) + b = self.aw.getData(copy=True) + + self.assertTrue(numpy.array_equal(a, b)) + + # scalar/0D data has no frame index + self.assertEqual(len(self.aw.model._index), 0) + # and no perspective + self.assertEqual(len(self.aw.model._perspective), 0) + + def testSetData1D(self): + a = [1, 2] + self.aw.setArrayData(a) + b = self.aw.getData(copy=True) + + self.assertTrue(numpy.array_equal(a, b)) + + # 1D data has no frame index + self.assertEqual(len(self.aw.model._index), 0) + # and no perspective + self.assertEqual(len(self.aw.model._perspective), 0) + + def testSetData4D(self): + a = numpy.reshape(numpy.linspace(0.213, 1.234, 1250), + (5, 5, 5, 10)) + self.aw.setArrayData(a) + + # default perspective (0, 1) + self.assertEqual(list(self.aw.model._perspective), + [0, 1]) + self.aw.setPerspective((1, 3)) + self.assertEqual(list(self.aw.model._perspective), + [1, 3]) + + b = self.aw.getData(copy=True) + self.assertTrue(numpy.array_equal(a, b)) + + # 4D data has a 2-tuple as frame index + self.assertEqual(len(self.aw.model._index), 2) + # default index is (0, 0) + self.assertEqual(list(self.aw.model._index), + [0, 0]) + self.aw.setFrameIndex((3, 1)) + + self.assertEqual(list(self.aw.model._index), + [3, 1]) + + def testColors(self): + a = numpy.arange(256, dtype=numpy.uint8) + self.aw.setArrayData(a) + + bgcolor = numpy.empty(a.shape + (3,), dtype=numpy.uint8) + # Black & white palette + bgcolor[..., 0] = a + bgcolor[..., 1] = a + bgcolor[..., 2] = a + + fgcolor = numpy.bitwise_xor(bgcolor, 255) + + self.aw.setArrayColors(bgcolor, fgcolor) + + # test colors are as expected in model + for i in range(256): + # all RGB channels for BG equal to data value + self.assertEqual( + self.aw.model.data(self.aw.model.index(0, i), + role=qt.Qt.BackgroundRole), + qt.QColor(i, i, i), + "Unexpected background color" + ) + + # all RGB channels for FG equal to XOR(data value, 255) + self.assertEqual( + self.aw.model.data(self.aw.model.index(0, i), + role=qt.Qt.ForegroundRole), + qt.QColor(i ^ 255, i ^ 255, i ^ 255), + "Unexpected text color" + ) + + # test colors are reset to None when a new data array is loaded + # with different shape + self.aw.setArrayData(numpy.arange(300)) + + for i in range(300): + # all RGB channels for BG equal to data value + self.assertIsNone( + self.aw.model.data(self.aw.model.index(0, i), + role=qt.Qt.BackgroundRole)) + + def testDefaultFlagNotEditable(self): + """editable should be False by default, in setArrayData""" + self.aw.setArrayData([[0]]) + idx = self.aw.model.createIndex(0, 0) + # model is editable + self.assertFalse( + self.aw.model.flags(idx) & qt.Qt.ItemIsEditable) + + def testFlagEditable(self): + self.aw.setArrayData([[0]], editable=True) + idx = self.aw.model.createIndex(0, 0) + # model is editable + self.assertTrue( + self.aw.model.flags(idx) & qt.Qt.ItemIsEditable) + + def testFlagNotEditable(self): + self.aw.setArrayData([[0]], editable=False) + idx = self.aw.model.createIndex(0, 0) + # model is editable + self.assertFalse( + self.aw.model.flags(idx) & qt.Qt.ItemIsEditable) + + def testReferenceReturned(self): + """when setting the data with copy=False and + retrieving it with getData(copy=False), we should recover + the same original object. + """ + # n-D (n >=2) + a0 = numpy.reshape(numpy.linspace(0.213, 1.234, 1000), + (10, 10, 10)) + self.aw.setArrayData(a0, copy=False) + a1 = self.aw.getData(copy=False) + + self.assertIs(a0, a1) + + # 1D + b0 = numpy.linspace(0.213, 1.234, 1000) + self.aw.setArrayData(b0, copy=False) + b1 = self.aw.getData(copy=False) + self.assertIs(b0, b1) + + +@unittest.skipIf(h5py is None, "Could not import h5py") +class TestH5pyArrayWidget(TestCaseQt): + """Basic test for ArrayTableWidget with a dataset. + + Test flags, for dataset open in read-only or read-write modes""" + def setUp(self): + super(TestH5pyArrayWidget, self).setUp() + self.aw = ArrayTableWidget.ArrayTableWidget() + self.data = numpy.reshape(numpy.linspace(0.213, 1.234, 1000), + (10, 10, 10)) + # create an h5py file with a dataset + self.tempdir = tempfile.mkdtemp() + self.h5_fname = os.path.join(self.tempdir, "array.h5") + h5f = h5py.File(self.h5_fname) + h5f["my_array"] = self.data + h5f["my_scalar"] = 3.14 + h5f["my_1D_array"] = numpy.array(numpy.arange(1000)) + h5f.close() + + def tearDown(self): + del self.aw + os.unlink(self.h5_fname) + os.rmdir(self.tempdir) + super(TestH5pyArrayWidget, self).tearDown() + + def testShow(self): + self.aw.show() + self.qWaitForWindowExposed(self.aw) + + def testReadOnly(self): + """Open H5 dataset in read-only mode, ensure the model is not editable.""" + h5f = h5py.File(self.h5_fname, "r") + a = h5f["my_array"] + # ArrayTableModel relies on following condition + self.assertTrue(a.file.mode == "r") + + self.aw.setArrayData(a, copy=False, editable=True) + + self.assertIsInstance(a, h5py.Dataset) # simple sanity check + # internal representation must be a reference to original data (copy=False) + self.assertIsInstance(self.aw.model._array, h5py.Dataset) + self.assertTrue(self.aw.model._array.file.mode == "r") + + b = self.aw.getData() + self.assertTrue(numpy.array_equal(self.data, b)) + + # model must have detected read-only dataset and disabled editing + self.assertFalse(self.aw.model._editable) + idx = self.aw.model.createIndex(0, 0) + self.assertFalse( + self.aw.model.flags(idx) & qt.Qt.ItemIsEditable) + + # force editing read-only datasets raises IOError + self.assertRaises(IOError, self.aw.model.setData, + idx, 123.4, role=qt.Qt.EditRole) + h5f.close() + + def testReadWrite(self): + h5f = h5py.File(self.h5_fname, "r+") + a = h5f["my_array"] + self.assertTrue(a.file.mode == "r+") + + self.aw.setArrayData(a, copy=False, editable=True) + b = self.aw.getData(copy=False) + self.assertTrue(numpy.array_equal(self.data, b)) + + idx = self.aw.model.createIndex(0, 0) + # model is editable + self.assertTrue( + self.aw.model.flags(idx) & qt.Qt.ItemIsEditable) + h5f.close() + + def testSetData0D(self): + h5f = h5py.File(self.h5_fname, "r+") + a = h5f["my_scalar"] + self.aw.setArrayData(a) + b = self.aw.getData(copy=True) + + self.assertTrue(numpy.array_equal(a, b)) + + h5f.close() + + def testSetData1D(self): + h5f = h5py.File(self.h5_fname, "r+") + a = h5f["my_1D_array"] + self.aw.setArrayData(a) + b = self.aw.getData(copy=True) + + self.assertTrue(numpy.array_equal(a, b)) + + h5f.close() + + def testReferenceReturned(self): + """when setting the data with copy=False and + retrieving it with getData(copy=False), we should recover + the same original object. + + This only works for array with at least 2D. For 1D and 0D + arrays, a view is created at some point, which in the case + of an hdf5 dataset creates a copy.""" + h5f = h5py.File(self.h5_fname, "r+") + + # n-D + a0 = h5f["my_array"] + self.aw.setArrayData(a0, copy=False) + a1 = self.aw.getData(copy=False) + self.assertIs(a0, a1) + + # 1D + b0 = h5f["my_1D_array"] + self.aw.setArrayData(b0, copy=False) + b1 = self.aw.getData(copy=False) + self.assertIs(b0, b1) + + h5f.close() + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(TestArrayWidget)) + test_suite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(TestH5pyArrayWidget)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/data/test/test_dataviewer.py b/silx/gui/data/test/test_dataviewer.py new file mode 100644 index 0000000..5a0de0b --- /dev/null +++ b/silx/gui/data/test/test_dataviewer.py @@ -0,0 +1,281 @@ +# 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__ = "10/04/2017" + +import os +import tempfile +import unittest +from contextlib import contextmanager + +import numpy +from ..DataViewer import DataViewer +from ..DataViews import DataView +from .. import DataViews + +from silx.gui import qt + +from silx.gui.data.DataViewerFrame import DataViewerFrame +from silx.gui.test.utils import SignalListener +from silx.gui.test.utils import TestCaseQt + +from silx.gui.hdf5.test import _mock + +try: + import h5py +except ImportError: + h5py = None + + +class _DataViewMock(DataView): + """Dummy view to display nothing""" + + def __init__(self, parent): + DataView.__init__(self, parent) + + def axesNames(self, data, info): + return [] + + def createWidget(self, parent): + return qt.QLabel(parent) + + def getDataPriority(self, data, info): + return 0 + + +class AbstractDataViewerTests(TestCaseQt): + + def create_widget(self): + raise NotImplementedError() + + @contextmanager + def h5_temporary_file(self): + # create tmp file + fd, tmp_name = tempfile.mkstemp(suffix=".h5") + os.close(fd) + data = numpy.arange(3 * 3 * 3) + data.shape = 3, 3, 3 + # create h5 data + h5file = h5py.File(tmp_name, "w") + h5file["data"] = data + yield h5file + # clean up + h5file.close() + os.unlink(tmp_name) + + def test_text_data(self): + data_list = ["aaa", int, 8, self] + widget = self.create_widget() + for data in data_list: + widget.setData(data) + self.assertEqual(DataViewer.RAW_MODE, widget.displayMode()) + + def test_plot_1d_data(self): + data = numpy.arange(3 ** 1) + data.shape = [3] * 1 + widget = self.create_widget() + widget.setData(data) + availableModes = set([v.modeId() for v in widget.currentAvailableViews()]) + self.assertEqual(DataViewer.RAW_MODE, widget.displayMode()) + self.assertIn(DataViewer.PLOT1D_MODE, availableModes) + + def test_plot_2d_data(self): + data = numpy.arange(3 ** 2) + data.shape = [3] * 2 + widget = self.create_widget() + widget.setData(data) + availableModes = set([v.modeId() for v in widget.currentAvailableViews()]) + self.assertEqual(DataViewer.RAW_MODE, widget.displayMode()) + self.assertIn(DataViewer.PLOT2D_MODE, availableModes) + + def test_plot_3d_data(self): + data = numpy.arange(3 ** 3) + data.shape = [3] * 3 + widget = self.create_widget() + widget.setData(data) + availableModes = set([v.modeId() for v in widget.currentAvailableViews()]) + try: + import silx.gui.plot3d # noqa + self.assertIn(DataViewer.PLOT3D_MODE, availableModes) + except ImportError: + self.assertIn(DataViewer.STACK_MODE, availableModes) + self.assertEqual(DataViewer.RAW_MODE, widget.displayMode()) + + def test_array_1d_data(self): + data = numpy.array(["aaa"] * (3 ** 1)) + data.shape = [3] * 1 + widget = self.create_widget() + widget.setData(data) + self.assertEqual(DataViewer.RAW_MODE, widget.displayedView().modeId()) + + def test_array_2d_data(self): + data = numpy.array(["aaa"] * (3 ** 2)) + data.shape = [3] * 2 + widget = self.create_widget() + widget.setData(data) + self.assertEqual(DataViewer.RAW_MODE, widget.displayedView().modeId()) + + def test_array_4d_data(self): + data = numpy.array(["aaa"] * (3 ** 4)) + data.shape = [3] * 4 + widget = self.create_widget() + widget.setData(data) + self.assertEqual(DataViewer.RAW_MODE, widget.displayedView().modeId()) + + def test_record_4d_data(self): + data = numpy.zeros(3 ** 4, dtype='3int8, float32, (2,3)float64') + data.shape = [3] * 4 + widget = self.create_widget() + widget.setData(data) + self.assertEqual(DataViewer.RAW_MODE, widget.displayedView().modeId()) + + def test_3d_h5_dataset(self): + if h5py is None: + self.skipTest("h5py library is not available") + with self.h5_temporary_file() as h5file: + dataset = h5file["data"] + widget = self.create_widget() + widget.setData(dataset) + + def test_data_event(self): + listener = SignalListener() + widget = self.create_widget() + widget.dataChanged.connect(listener) + widget.setData(10) + widget.setData(None) + self.assertEquals(listener.callCount(), 2) + + def test_display_mode_event(self): + listener = SignalListener() + widget = self.create_widget() + widget.displayedViewChanged.connect(listener) + widget.setData(10) + widget.setData(None) + modes = [v.modeId() for v in listener.arguments(argumentIndex=0)] + self.assertEquals(modes, [DataViewer.RAW_MODE, DataViewer.EMPTY_MODE]) + listener.clear() + + def test_change_display_mode(self): + data = numpy.arange(10 ** 4) + data.shape = [10] * 4 + widget = self.create_widget() + widget.setData(data) + widget.setDisplayMode(DataViewer.PLOT1D_MODE) + self.assertEquals(widget.displayedView().modeId(), DataViewer.PLOT1D_MODE) + widget.setDisplayMode(DataViewer.PLOT2D_MODE) + self.assertEquals(widget.displayedView().modeId(), DataViewer.PLOT2D_MODE) + widget.setDisplayMode(DataViewer.RAW_MODE) + self.assertEquals(widget.displayedView().modeId(), DataViewer.RAW_MODE) + widget.setDisplayMode(DataViewer.EMPTY_MODE) + self.assertEquals(widget.displayedView().modeId(), DataViewer.EMPTY_MODE) + + def test_create_default_views(self): + widget = self.create_widget() + views = widget.createDefaultViews() + self.assertTrue(len(views) > 0) + + def test_add_view(self): + widget = self.create_widget() + view = _DataViewMock(widget) + widget.addView(view) + self.assertTrue(view in widget.availableViews()) + self.assertTrue(view in widget.currentAvailableViews()) + + def test_remove_view(self): + widget = self.create_widget() + widget.setData("foobar") + view = widget.currentAvailableViews()[0] + widget.removeView(view) + self.assertTrue(view not in widget.availableViews()) + self.assertTrue(view not in widget.currentAvailableViews()) + +class TestDataViewer(AbstractDataViewerTests): + def create_widget(self): + return DataViewer() + + +class TestDataViewerFrame(AbstractDataViewerTests): + def create_widget(self): + return DataViewerFrame() + + +class TestDataView(TestCaseQt): + + def createComplexData(self): + line = [1, 2j, 3+3j, 4] + image = [line, line, line, line] + cube = [image, image, image, image] + data = numpy.array(cube, + dtype=numpy.complex) + return data + + def createDataViewWithData(self, dataViewClass, data): + viewer = dataViewClass(None) + widget = viewer.getWidget() + viewer.setData(data) + return widget + + def testCurveWithComplex(self): + data = self.createComplexData() + dataViewClass = DataViews._Plot1dView + widget = self.createDataViewWithData(dataViewClass, data[0, 0]) + self.qWaitForWindowExposed(widget) + + def testImageWithComplex(self): + data = self.createComplexData() + dataViewClass = DataViews._Plot2dView + widget = self.createDataViewWithData(dataViewClass, data[0]) + self.qWaitForWindowExposed(widget) + + def testCubeWithComplex(self): + self.skipTest("OpenGL widget not yet tested") + try: + import silx.gui.plot3d # noqa + except ImportError: + self.skipTest("OpenGL not available") + data = self.createComplexData() + dataViewClass = DataViews._Plot3dView + widget = self.createDataViewWithData(dataViewClass, data) + self.qWaitForWindowExposed(widget) + + def testImageStackWithComplex(self): + data = self.createComplexData() + dataViewClass = DataViews._StackView + widget = self.createDataViewWithData(dataViewClass, data) + self.qWaitForWindowExposed(widget) + + +def suite(): + test_suite = unittest.TestSuite() + loadTestsFromTestCase = unittest.defaultTestLoader.loadTestsFromTestCase + test_suite.addTest(loadTestsFromTestCase(TestDataViewer)) + test_suite.addTest(loadTestsFromTestCase(TestDataViewerFrame)) + test_suite.addTest(loadTestsFromTestCase(TestDataView)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/data/test/test_numpyaxesselector.py b/silx/gui/data/test/test_numpyaxesselector.py new file mode 100644 index 0000000..cc15f83 --- /dev/null +++ b/silx/gui/data/test/test_numpyaxesselector.py @@ -0,0 +1,152 @@ +# 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__ = "15/12/2016" + +import os +import tempfile +import unittest +from contextlib import contextmanager + +import numpy + +from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector +from silx.gui.test.utils import SignalListener +from silx.gui.test.utils import TestCaseQt + +try: + import h5py +except ImportError: + h5py = None + + +class TestNumpyAxesSelector(TestCaseQt): + + def test_creation(self): + data = numpy.arange(3 * 3 * 3) + data.shape = 3, 3, 3 + widget = NumpyAxesSelector() + widget.setVisible(True) + + def test_none(self): + data = numpy.arange(3 * 3 * 3) + widget = NumpyAxesSelector() + widget.setData(data) + widget.setData(None) + result = widget.selectedData() + self.assertIsNone(result) + + def test_output_samedim(self): + data = numpy.arange(3 * 3 * 3) + data.shape = 3, 3, 3 + expectedResult = data + + widget = NumpyAxesSelector() + widget.setAxisNames(["x", "y", "z"]) + widget.setData(data) + result = widget.selectedData() + self.assertTrue(numpy.array_equal(result, expectedResult)) + + def test_output_lessdim(self): + data = numpy.arange(3 * 3 * 3) + data.shape = 3, 3, 3 + expectedResult = data[0] + + widget = NumpyAxesSelector() + widget.setAxisNames(["y", "x"]) + widget.setData(data) + result = widget.selectedData() + self.assertTrue(numpy.array_equal(result, expectedResult)) + + def test_output_1dim(self): + data = numpy.arange(3 * 3 * 3) + data.shape = 3, 3, 3 + expectedResult = data[0, 0, 0] + + widget = NumpyAxesSelector() + widget.setData(data) + result = widget.selectedData() + self.assertTrue(numpy.array_equal(result, expectedResult)) + + @contextmanager + def h5_temporary_file(self): + # create tmp file + fd, tmp_name = tempfile.mkstemp(suffix=".h5") + os.close(fd) + data = numpy.arange(3 * 3 * 3) + data.shape = 3, 3, 3 + # create h5 data + h5file = h5py.File(tmp_name, "w") + h5file["data"] = data + yield h5file + # clean up + h5file.close() + os.unlink(tmp_name) + + def test_h5py_dataset(self): + if h5py is None: + self.skipTest("h5py library is not available") + with self.h5_temporary_file() as h5file: + dataset = h5file["data"] + expectedResult = dataset[0] + + widget = NumpyAxesSelector() + widget.setData(dataset) + widget.setAxisNames(["y", "x"]) + result = widget.selectedData() + self.assertTrue(numpy.array_equal(result, expectedResult)) + + def test_data_event(self): + data = numpy.arange(3 * 3 * 3) + widget = NumpyAxesSelector() + listener = SignalListener() + widget.dataChanged.connect(listener) + widget.setData(data) + widget.setData(None) + self.assertEqual(listener.callCount(), 2) + + def test_selected_data_event(self): + data = numpy.arange(3 * 3 * 3) + data.shape = 3, 3, 3 + widget = NumpyAxesSelector() + listener = SignalListener() + widget.selectionChanged.connect(listener) + widget.setData(data) + widget.setAxisNames(["x"]) + widget.setData(None) + self.assertEqual(listener.callCount(), 3) + listener.clear() + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(TestNumpyAxesSelector)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/data/test/test_textformatter.py b/silx/gui/data/test/test_textformatter.py new file mode 100644 index 0000000..f21e033 --- /dev/null +++ b/silx/gui/data/test/test_textformatter.py @@ -0,0 +1,94 @@ +# 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__ = "24/01/2017" + +import unittest + +from silx.gui.test.utils import TestCaseQt +from silx.gui.test.utils import SignalListener +from ..TextFormatter import TextFormatter + + +class TestTextFormatter(TestCaseQt): + + def test_copy(self): + formatter = TextFormatter() + copy = TextFormatter(formatter=formatter) + self.assertIsNot(formatter, copy) + copy.setFloatFormat("%.3f") + self.assertEquals(formatter.integerFormat(), copy.integerFormat()) + self.assertNotEquals(formatter.floatFormat(), copy.floatFormat()) + self.assertEquals(formatter.useQuoteForText(), copy.useQuoteForText()) + self.assertEquals(formatter.imaginaryUnit(), copy.imaginaryUnit()) + + def test_event(self): + listener = SignalListener() + formatter = TextFormatter() + formatter.formatChanged.connect(listener) + formatter.setFloatFormat("%.3f") + formatter.setIntegerFormat("%03i") + formatter.setUseQuoteForText(False) + formatter.setImaginaryUnit("z") + self.assertEquals(listener.callCount(), 4) + + def test_int(self): + formatter = TextFormatter() + formatter.setIntegerFormat("%05i") + result = formatter.toString(512) + self.assertEquals(result, "00512") + + def test_float(self): + formatter = TextFormatter() + formatter.setFloatFormat("%.3f") + result = formatter.toString(1.3) + self.assertEquals(result, "1.300") + + def test_complex(self): + formatter = TextFormatter() + formatter.setFloatFormat("%.1f") + formatter.setImaginaryUnit("i") + result = formatter.toString(1.0 + 5j) + result = result.replace(" ", "") + self.assertEquals(result, "1.0+5.0i") + + def test_string(self): + formatter = TextFormatter() + formatter.setIntegerFormat("%.1f") + formatter.setImaginaryUnit("z") + result = formatter.toString("toto") + self.assertEquals(result, '"toto"') + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(TestTextFormatter)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/fit/BackgroundWidget.py b/silx/gui/fit/BackgroundWidget.py new file mode 100644 index 0000000..577a8c7 --- /dev/null +++ b/silx/gui/fit/BackgroundWidget.py @@ -0,0 +1,530 @@ +# coding: utf-8 +#/*########################################################################## +# Copyright (C) 2004-2017 V.A. Sole, European Synchrotron Radiation Facility +# +# This file is part of the PyMca X-ray Fluorescence Toolkit developed at +# the ESRF by the Software group. +# +# 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 background configuration widget +:class:`BackgroundWidget` and a corresponding dialog window +:class:`BackgroundDialog`.""" +import sys +import numpy +from silx.gui import qt +from silx.gui.plot import PlotWidget +from silx.math.fit import filters + +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "24/01/2017" + + +class HorizontalSpacer(qt.QWidget): + def __init__(self, *args): + qt.QWidget.__init__(self, *args) + self.setSizePolicy(qt.QSizePolicy(qt.QSizePolicy.Expanding, + qt.QSizePolicy.Fixed)) + + +class BackgroundParamWidget(qt.QWidget): + """Background configuration composite widget. + + Strip and snip filters parameters can be adjusted using input widgets. + + Updating the widgets causes :attr:`sigBackgroundParamWidgetSignal` to + be emitted. + """ + sigBackgroundParamWidgetSignal = qt.pyqtSignal(object) + + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent) + + self.mainLayout = qt.QGridLayout(self) + self.mainLayout.setColumnStretch(1, 1) + + # Algorithm choice --------------------------------------------------- + self.algorithmComboLabel = qt.QLabel(self) + self.algorithmComboLabel.setText("Background algorithm") + self.algorithmCombo = qt.QComboBox(self) + self.algorithmCombo.addItem("Strip") + self.algorithmCombo.addItem("Snip") + self.algorithmCombo.activated[int].connect( + self._algorithmComboActivated) + + # Strip parameters --------------------------------------------------- + self.stripWidthLabel = qt.QLabel(self) + self.stripWidthLabel.setText("Strip Width") + + self.stripWidthSpin = qt.QSpinBox(self) + self.stripWidthSpin.setMaximum(100) + self.stripWidthSpin.setMinimum(1) + self.stripWidthSpin.valueChanged[int].connect(self._emitSignal) + + self.stripIterLabel = qt.QLabel(self) + self.stripIterLabel.setText("Strip Iterations") + self.stripIterValue = qt.QLineEdit(self) + validator = qt.QIntValidator(self.stripIterValue) + self.stripIterValue._v = validator + self.stripIterValue.setText("0") + self.stripIterValue.editingFinished[()].connect(self._emitSignal) + self.stripIterValue.setToolTip( + "Number of iterations for strip algorithm.\n" + + "If greater than 999, an 2nd pass of strip filter is " + + "applied to remove artifacts created by first pass.") + + # Snip parameters ---------------------------------------------------- + self.snipWidthLabel = qt.QLabel(self) + self.snipWidthLabel.setText("Snip Width") + + self.snipWidthSpin = qt.QSpinBox(self) + self.snipWidthSpin.setMaximum(300) + self.snipWidthSpin.setMinimum(0) + self.snipWidthSpin.valueChanged[int].connect(self._emitSignal) + + + # Smoothing parameters ----------------------------------------------- + self.smoothingFlagCheck = qt.QCheckBox(self) + self.smoothingFlagCheck.setText("Smoothing Width (Savitsky-Golay)") + self.smoothingFlagCheck.toggled.connect(self._smoothingToggled) + + self.smoothingSpin = qt.QSpinBox(self) + self.smoothingSpin.setMinimum(3) + #self.smoothingSpin.setMaximum(40) + self.smoothingSpin.setSingleStep(2) + self.smoothingSpin.valueChanged[int].connect(self._emitSignal) + + # Anchors ------------------------------------------------------------ + + self.anchorsGroup = qt.QWidget(self) + anchorsLayout = qt.QHBoxLayout(self.anchorsGroup) + anchorsLayout.setSpacing(2) + anchorsLayout.setContentsMargins(0, 0, 0, 0) + + self.anchorsFlagCheck = qt.QCheckBox(self.anchorsGroup) + self.anchorsFlagCheck.setText("Use anchors") + self.anchorsFlagCheck.setToolTip( + "Define X coordinates of points that must remain fixed") + self.anchorsFlagCheck.stateChanged[int].connect( + self._anchorsToggled) + anchorsLayout.addWidget(self.anchorsFlagCheck) + + maxnchannel = 16384 * 4 # Fixme ? + self.anchorsList = [] + num_anchors = 4 + for i in range(num_anchors): + anchorSpin = qt.QSpinBox(self.anchorsGroup) + anchorSpin.setMinimum(0) + anchorSpin.setMaximum(maxnchannel) + anchorSpin.valueChanged[int].connect(self._emitSignal) + anchorsLayout.addWidget(anchorSpin) + self.anchorsList.append(anchorSpin) + + # Layout ------------------------------------------------------------ + self.mainLayout.addWidget(self.algorithmComboLabel, 0, 0) + self.mainLayout.addWidget(self.algorithmCombo, 0, 2) + self.mainLayout.addWidget(self.stripWidthLabel, 1, 0) + self.mainLayout.addWidget(self.stripWidthSpin, 1, 2) + self.mainLayout.addWidget(self.stripIterLabel, 2, 0) + self.mainLayout.addWidget(self.stripIterValue, 2, 2) + self.mainLayout.addWidget(self.snipWidthLabel, 3, 0) + self.mainLayout.addWidget(self.snipWidthSpin, 3, 2) + self.mainLayout.addWidget(self.smoothingFlagCheck, 4, 0) + self.mainLayout.addWidget(self.smoothingSpin, 4, 2) + self.mainLayout.addWidget(self.anchorsGroup, 5, 0, 1, 4) + + # Initialize interface ----------------------------------------------- + self._setAlgorithm("strip") + self.smoothingFlagCheck.setChecked(False) + self._smoothingToggled(is_checked=False) + self.anchorsFlagCheck.setChecked(False) + self._anchorsToggled(is_checked=False) + + def _algorithmComboActivated(self, algorithm_index): + self._setAlgorithm("strip" if algorithm_index == 0 else "snip") + + def _setAlgorithm(self, algorithm): + """Enable/disable snip and snip input widgets, depending on the + chosen algorithm. + :param algorithm: "snip" or "strip" + """ + if algorithm not in ["strip", "snip"]: + raise ValueError( + "Unknown background filter algorithm %s" % algorithm) + + self.algorithm = algorithm + self.stripWidthSpin.setEnabled(algorithm == "strip") + self.stripIterValue.setEnabled(algorithm == "strip") + self.snipWidthSpin.setEnabled(algorithm == "snip") + + def _smoothingToggled(self, is_checked): + """Enable/disable smoothing input widgets, emit dictionary""" + self.smoothingSpin.setEnabled(is_checked) + self._emitSignal() + + def _anchorsToggled(self, is_checked): + """Enable/disable all spin widgets defining anchor X coordinates, + emit signal. + """ + for anchor_spin in self.anchorsList: + anchor_spin.setEnabled(is_checked) + self._emitSignal() + + def setParameters(self, ddict): + """Set values for all input widgets. + + :param dict ddict: Input dictionary, must have the same + keys as the dictionary output by :meth:`getParameters` + """ + if "algorithm" in ddict: + self._setAlgorithm(ddict["algorithm"]) + + if "SnipWidth" in ddict: + self.snipWidthSpin.setValue(int(ddict["SnipWidth"])) + + if "StripWidth" in ddict: + self.stripWidthSpin.setValue(int(ddict["StripWidth"])) + + if "StripIterations" in ddict: + self.stripIterValue.setText("%d" % int(ddict["StripIterations"])) + + if "SmoothingFlag" in ddict: + self.smoothingFlagCheck.setChecked(bool(ddict["SmoothingFlag"])) + + if "SmoothingWidth" in ddict: + self.smoothingSpin.setValue(int(ddict["SmoothingWidth"])) + + if "AnchorsFlag" in ddict: + self.anchorsFlagCheck.setChecked(bool(ddict["AnchorsFlag"])) + + if "AnchorsList" in ddict: + anchorslist = ddict["AnchorsList"] + if anchorslist in [None, 'None']: + anchorslist = [] + for spin in self.anchorsList: + spin.setValue(0) + + i = 0 + for value in anchorslist: + self.anchorsList[i].setValue(int(value)) + i += 1 + + def getParameters(self): + """Return dictionary of parameters defined in the GUI + + The returned dictionary contains following values: + + - *algorithm*: *"strip"* or *"snip"* + - *StripWidth*: width of strip iterator + - *StripIterations*: number of iterations + - *StripThreshold*: curvature parameter (currently fixed to 1.0) + - *SnipWidth*: width of snip algorithm + - *SmoothingFlag*: flag to enable/disable smoothing + - *SmoothingWidth*: width of Savitsky-Golay smoothing filter + - *AnchorsFlag*: flag to enable/disable anchors + - *AnchorsList*: list of anchors (X coordinates of fixed values) + """ + stripitertext = self.stripIterValue.text() + stripiter = int(stripitertext) if len(stripitertext) else 0 + + return {"algorithm": self.algorithm, + "StripThreshold": 1.0, + "SnipWidth": self.snipWidthSpin.value(), + "StripIterations": stripiter, + "StripWidth": self.stripWidthSpin.value(), + "SmoothingFlag": self.smoothingFlagCheck.isChecked(), + "SmoothingWidth": self.smoothingSpin.value(), + "AnchorsFlag": self.anchorsFlagCheck.isChecked(), + "AnchorsList": [spin.value() for spin in self.anchorsList]} + + def _emitSignal(self, dummy=None): + self.sigBackgroundParamWidgetSignal.emit( + {'event': 'ParametersChanged', + 'parameters': self.getParameters()}) + + +class BackgroundWidget(qt.QWidget): + """Background configuration widget, with a :class:`PlotWindow`. + + Strip and snip filters parameters can be adjusted using input widgets, + and the computed backgrounds are plotted next to the original data to + show the result.""" + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent) + self.setWindowTitle("Strip and SNIP Configuration Window") + self.mainLayout = qt.QVBoxLayout(self) + self.mainLayout.setContentsMargins(0, 0, 0, 0) + self.mainLayout.setSpacing(2) + self.parametersWidget = BackgroundParamWidget(self) + self.graphWidget = PlotWidget(parent=self) + self.mainLayout.addWidget(self.parametersWidget) + self.mainLayout.addWidget(self.graphWidget) + self._x = None + self._y = None + self.parametersWidget.sigBackgroundParamWidgetSignal.connect(self._slot) + + def getParameters(self): + """Return dictionary of parameters defined in the GUI + + The returned dictionary contains following values: + + - *algorithm*: *"strip"* or *"snip"* + - *StripWidth*: width of strip iterator + - *StripIterations*: number of iterations + - *StripThreshold*: strip curvature (currently fixed to 1.0) + - *SnipWidth*: width of snip algorithm + - *SmoothingFlag*: flag to enable/disable smoothing + - *SmoothingWidth*: width of Savitsky-Golay smoothing filter + - *AnchorsFlag*: flag to enable/disable anchors + - *AnchorsList*: list of anchors (X coordinates of fixed values) + """ + return self.parametersWidget.getParameters() + + def setParameters(self, ddict): + """Set values for all input widgets. + + :param dict ddict: Input dictionary, must have the same + keys as the dictionary output by :meth:`getParameters` + """ + return self.parametersWidget.setParameters(ddict) + + def setData(self, x, y, xmin=None, xmax=None): + """Set data for the original curve, and _update strip and snip + curves accordingly. + + :param x: Array or sequence of curve abscissa values + :param y: Array or sequence of curve ordinate values + :param xmin: Min value to be displayed on the X axis + :param xmax: Max value to be displayed on the X axis + """ + self._x = x + self._y = y + self._xmin = xmin + self._xmax = xmax + self._update(resetzoom=True) + + def _slot(self, ddict): + self._update() + + def _update(self, resetzoom=False): + """Compute strip and snip backgrounds, update the curves + """ + if self._y is None: + return + + pars = self.getParameters() + + # smoothed data + y = numpy.ravel(numpy.array(self._y)).astype(numpy.float) + if pars["SmoothingFlag"]: + ysmooth = filters.savitsky_golay(y, pars['SmoothingWidth']) + f = [0.25, 0.5, 0.25] + ysmooth[1:-1] = numpy.convolve(ysmooth, f, mode=0) + ysmooth[0] = 0.5 * (ysmooth[0] + ysmooth[1]) + ysmooth[-1] = 0.5 * (ysmooth[-1] + ysmooth[-2]) + else: + ysmooth = y + + + # loop for anchors + x = self._x + niter = pars['StripIterations'] + anchors_indices = [] + if pars['AnchorsFlag'] and pars['AnchorsList'] is not None: + ravelled = x + for channel in pars['AnchorsList']: + if channel <= ravelled[0]: + continue + index = numpy.nonzero(ravelled >= channel)[0] + if len(index): + index = min(index) + if index > 0: + anchors_indices.append(index) + + stripBackground = filters.strip(ysmooth, + w=pars['StripWidth'], + niterations=niter, + factor=pars['StripThreshold'], + anchors=anchors_indices) + + if niter >= 1000: + # final smoothing + stripBackground = filters.strip(stripBackground, + w=1, + niterations=50*pars['StripWidth'], + factor=pars['StripThreshold'], + anchors=anchors_indices) + + if len(anchors_indices) == 0: + anchors_indices = [0, len(ysmooth)-1] + anchors_indices.sort() + snipBackground = 0.0 * ysmooth + lastAnchor = 0 + for anchor in anchors_indices: + if (anchor > lastAnchor) and (anchor < len(ysmooth)): + snipBackground[lastAnchor:anchor] =\ + filters.snip1d(ysmooth[lastAnchor:anchor], + pars['SnipWidth']) + lastAnchor = anchor + if lastAnchor < len(ysmooth): + snipBackground[lastAnchor:] =\ + filters.snip1d(ysmooth[lastAnchor:], + pars['SnipWidth']) + + self.graphWidget.addCurve(x, y, + legend='Input Data', + replace=True, + resetzoom=resetzoom) + self.graphWidget.addCurve(x, stripBackground, + legend='Strip Background', + resetzoom=False) + self.graphWidget.addCurve(x, snipBackground, + legend='SNIP Background', + resetzoom=False) + if self._xmin is not None and self._xmax is not None: + self.graphWidget.setGraphXLimits(xmin=self._xmin, xmax=self._xmax) + + +class BackgroundDialog(qt.QDialog): + """QDialog window featuring a :class:`BackgroundWidget`""" + def __init__(self, parent=None): + qt.QDialog.__init__(self, parent) + self.setWindowTitle("Strip and Snip Configuration Window") + self.mainLayout = qt.QVBoxLayout(self) + self.mainLayout.setContentsMargins(0, 0, 0, 0) + self.mainLayout.setSpacing(2) + self.parametersWidget = BackgroundWidget(self) + self.mainLayout.addWidget(self.parametersWidget) + hbox = qt.QWidget(self) + hboxLayout = qt.QHBoxLayout(hbox) + hboxLayout.setContentsMargins(0, 0, 0, 0) + hboxLayout.setSpacing(2) + self.okButton = qt.QPushButton(hbox) + self.okButton.setText("OK") + self.okButton.setAutoDefault(False) + self.dismissButton = qt.QPushButton(hbox) + self.dismissButton.setText("Cancel") + self.dismissButton.setAutoDefault(False) + hboxLayout.addWidget(HorizontalSpacer(hbox)) + hboxLayout.addWidget(self.okButton) + hboxLayout.addWidget(self.dismissButton) + self.mainLayout.addWidget(hbox) + self.dismissButton.clicked.connect(self.reject) + self.okButton.clicked.connect(self.accept) + + self.output = {} + """Configuration dictionary containing following fields: + + - *SmoothingFlag* + - *SmoothingWidth* + - *StripWidth* + - *StripIterations* + - *StripThreshold* + - *SnipWidth* + - *AnchorsFlag* + - *AnchorsList* + """ + + # self.parametersWidget.parametersWidget.sigBackgroundParamWidgetSignal.connect(self.updateOutput) + + # def updateOutput(self, ddict): + # self.output = ddict + + def accept(self): + """Update :attr:`output`, then call :meth:`QDialog.accept` + """ + self.output = self.getParameters() + super(BackgroundDialog, self).accept() + + def sizeHint(self): + return qt.QSize(int(1.5*qt.QDialog.sizeHint(self).width()), + qt.QDialog.sizeHint(self).height()) + + def setData(self, x, y, xmin=None, xmax=None): + """See :meth:`BackgroundWidget.setData`""" + return self.parametersWidget.setData(x, y, xmin, xmax) + + def getParameters(self): + """See :meth:`BackgroundWidget.getParameters`""" + return self.parametersWidget.getParameters() + + def setParameters(self, ddict): + """See :meth:`BackgroundWidget.setParameters`""" + return self.parametersWidget.setParameters(ddict) + + def setDefault(self, ddict): + """Alias for :meth:`setParameters`""" + return self.setParameters(ddict) + + +def getBgDialog(parent=None, default=None, modal=True): + """Instantiate and return a bg configuration dialog, adapted + for configuring standard background theories from + :mod:`silx.math.fit.bgtheories`. + + :return: Instance of :class:`BackgroundDialog` + """ + bgd = BackgroundDialog(parent=parent) + # apply default to newly added pages + bgd.setParameters(default) + + return bgd + + +def main(): + # synthetic data + from silx.math.fit.functions import sum_gauss + + x = numpy.arange(5000) + # (height1, center1, fwhm1, ...) 5 peaks + params1 = (50, 500, 100, + 20, 2000, 200, + 50, 2250, 100, + 40, 3000, 75, + 23, 4000, 150) + y0 = sum_gauss(x, *params1) + + # random values between [-1;1] + noise = 2 * numpy.random.random(5000) - 1 + # make it +- 5% + noise *= 0.05 + + # 2 gaussians with very large fwhm, as background signal + actual_bg = sum_gauss(x, 15, 3500, 3000, 5, 1000, 1500) + + # Add 5% random noise to gaussians and add background + y = y0 + numpy.average(y0) * noise + actual_bg + + # Open widget + a = qt.QApplication(sys.argv) + a.lastWindowClosed.connect(a.quit) + + def mySlot(ddict): + print(ddict) + + w = BackgroundDialog() + w.parametersWidget.parametersWidget.sigBackgroundParamWidgetSignal.connect(mySlot) + w.setData(x, y) + w.exec_() + #a.exec_() + +if __name__ == "__main__": + main() diff --git a/silx/gui/fit/FitConfig.py b/silx/gui/fit/FitConfig.py new file mode 100644 index 0000000..70b6fbe --- /dev/null +++ b/silx/gui/fit/FitConfig.py @@ -0,0 +1,540 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2004-2016 V.A. Sole, European Synchrotron Radiation Facility +# +# This file is part of the PyMca X-ray Fluorescence Toolkit developed at +# the ESRF by the Software group. +# +# 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 defines widgets used to build a fit configuration dialog. +The resulting dialog widget outputs a dictionary of configuration parameters. +""" +from silx.gui import qt + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "30/11/2016" + + +class TabsDialog(qt.QDialog): + """Dialog widget containing a QTabWidget :attr:`tabWidget` + and a buttons: + + # - buttonHelp + - buttonDefaults + - buttonOk + - buttonCancel + + This dialog defines a __len__ returning the number of tabs, + and an __iter__ method yielding the tab widgets. + """ + def __init__(self, parent=None): + qt.QDialog.__init__(self, parent) + self.tabWidget = qt.QTabWidget(self) + + layout = qt.QVBoxLayout(self) + layout.addWidget(self.tabWidget) + + layout2 = qt.QHBoxLayout(None) + + # self.buttonHelp = qt.QPushButton(self) + # self.buttonHelp.setText("Help") + # layout2.addWidget(self.buttonHelp) + + self.buttonDefault = qt.QPushButton(self) + self.buttonDefault.setText("Default") + layout2.addWidget(self.buttonDefault) + + spacer = qt.QSpacerItem(20, 20, + qt.QSizePolicy.Expanding, + qt.QSizePolicy.Minimum) + layout2.addItem(spacer) + + self.buttonOk = qt.QPushButton(self) + self.buttonOk.setText("OK") + layout2.addWidget(self.buttonOk) + + self.buttonCancel = qt.QPushButton(self) + self.buttonCancel.setText("Cancel") + layout2.addWidget(self.buttonCancel) + + layout.addLayout(layout2) + + self.buttonOk.clicked.connect(self.accept) + self.buttonCancel.clicked.connect(self.reject) + + def __len__(self): + """Return number of tabs""" + return self.tabWidget.count() + + def __iter__(self): + """Return the next tab widget in :attr:`tabWidget` every + time this method is called. + + :return: Tab widget + :rtype: QWidget + """ + for widget_index in range(len(self)): + yield self.tabWidget.widget(widget_index) + + def addTab(self, page, label): + """Add a new tab + + :param page: Content of new page. Must be a widget with + a get() method returning a dictionary. + :param str label: Tab label + """ + self.tabWidget.addTab(page, label) + + def getTabLabels(self): + """ + Return a list of all tab labels in :attr:`tabWidget` + """ + return [self.tabWidget.tabText(i) for i in range(len(self))] + + +class TabsDialogData(TabsDialog): + """This dialog adds a data attribute to :class:`TabsDialog`. + + Data input in widgets, such as text entries or checkboxes, is stored in an + attribute :attr:`output` when the user clicks the OK button. + + A default dictionary can be supplied when this dialog is initialized, to + be used as default data for :attr:`output`. + """ + def __init__(self, parent=None, modal=True, default=None): + """ + + :param parent: Parent :class:`QWidget` + :param modal: If `True`, dialog is modal, meaning this dialog remains + in front of it's parent window and disables it until the user is + done interacting with the dialog + :param default: Default dictionary, used to initialize and reset + :attr:`output`. + """ + TabsDialog.__init__(self, parent) + self.setModal(modal) + self.setWindowTitle("Fit configuration") + + self.output = {} + + self.default = {} if default is None else default + + self.buttonDefault.clicked.connect(self.setDefault) + # self.keyPressEvent(qt.Qt.Key_Enter). + + def keyPressEvent(self, event): + """Redefining this method to ignore Enter key + (for some reason it activates buttonDefault callback which + resets all widgets) + """ + if event.key() in [qt.Qt.Key_Enter, qt.Qt.Key_Return]: + return + TabsDialog.keyPressEvent(self, event) + + def accept(self): + """When *OK* is clicked, update :attr:`output` with data from + various widgets + """ + self.output.update(self.default) + + # loop over all tab widgets (uses TabsDialog.__iter__) + for tabWidget in self: + self.output.update(tabWidget.get()) + + # avoid pathological None cases + for key in self.output.keys(): + if self.output[key] is None: + if key in self.default: + self.output[key] = self.default[key] + super(TabsDialogData, self).accept() + + def reject(self): + """When the *Cancel* button is clicked, reinitialize :attr:`output` + and quit + """ + self.setDefault() + super(TabsDialogData, self).reject() + + def setDefault(self, newdefault=None): + """Reinitialize :attr:`output` with :attr:`default` or with + new dictionary ``newdefault`` if provided. + Call :meth:`setDefault` for each tab widget, if available. + """ + self.output = {} + if newdefault is None: + newdefault = self.default + else: + self.default = newdefault + self.output.update(newdefault) + + for tabWidget in self: + if hasattr(tabWidget, "setDefault"): + tabWidget.setDefault(self.output) + + +class ConstraintsPage(qt.QGroupBox): + """Checkable QGroupBox widget filled with QCheckBox widgets, + to configure the fit estimation for standard fit theories. + """ + def __init__(self, parent=None, title="Set constraints"): + super(ConstraintsPage, self).__init__(parent) + self.setTitle(title) + self.setToolTip("Disable 'Set constraints' to remove all " + + "constraints on all fit parameters") + self.setCheckable(True) + + layout = qt.QVBoxLayout(self) + self.setLayout(layout) + + self.positiveHeightCB = qt.QCheckBox("Force positive height/area", self) + self.positiveHeightCB.setToolTip("Fit must find positive peaks") + layout.addWidget(self.positiveHeightCB) + + self.positionInIntervalCB = qt.QCheckBox("Force position in interval", self) + self.positionInIntervalCB.setToolTip( + "Fit must position peak within X limits") + layout.addWidget(self.positionInIntervalCB) + + self.positiveFwhmCB = qt.QCheckBox("Force positive FWHM", self) + self.positiveFwhmCB.setToolTip("Fit must find a positive FWHM") + layout.addWidget(self.positiveFwhmCB) + + self.sameFwhmCB = qt.QCheckBox("Force same FWHM for all peaks", self) + self.sameFwhmCB.setToolTip("Fit must find same FWHM for all peaks") + layout.addWidget(self.sameFwhmCB) + + self.quotedEtaCB = qt.QCheckBox("Force Eta between 0 and 1", self) + self.quotedEtaCB.setToolTip( + "Fit must find Eta between 0 and 1 for pseudo-Voigt function") + layout.addWidget(self.quotedEtaCB) + + layout.addStretch() + + self.setDefault() + + def setDefault(self, default_dict=None): + """Set default state for all widgets. + + :param default_dict: If a default config dictionary is provided as + a parameter, its values are used as default state.""" + if default_dict is None: + default_dict = {} + # this one uses reverse logic: if checked, NoConstraintsFlag must be False + self.setChecked( + not default_dict.get('NoConstraintsFlag', False)) + self.positiveHeightCB.setChecked( + default_dict.get('PositiveHeightAreaFlag', True)) + self.positionInIntervalCB.setChecked( + default_dict.get('QuotedPositionFlag', False)) + self.positiveFwhmCB.setChecked( + default_dict.get('PositiveFwhmFlag', True)) + self.sameFwhmCB.setChecked( + default_dict.get('SameFwhmFlag', False)) + self.quotedEtaCB.setChecked( + default_dict.get('QuotedEtaFlag', False)) + + def get(self): + """Return a dictionary of constraint flags, to be processed by the + :meth:`configure` method of the selected fit theory.""" + ddict = { + 'NoConstraintsFlag': not self.isChecked(), + 'PositiveHeightAreaFlag': self.positiveHeightCB.isChecked(), + 'QuotedPositionFlag': self.positionInIntervalCB.isChecked(), + 'PositiveFwhmFlag': self.positiveFwhmCB.isChecked(), + 'SameFwhmFlag': self.sameFwhmCB.isChecked(), + 'QuotedEtaFlag': self.quotedEtaCB.isChecked(), + } + return ddict + + +class SearchPage(qt.QWidget): + def __init__(self, parent=None): + super(SearchPage, self).__init__(parent) + layout = qt.QVBoxLayout(self) + + self.manualFwhmGB = qt.QGroupBox("Define FWHM manually", self) + self.manualFwhmGB.setCheckable(True) + self.manualFwhmGB.setToolTip( + "If disabled, the FWHM parameter used for peak search is " + + "estimated based on the highest peak in the data") + layout.addWidget(self.manualFwhmGB) + # ------------ GroupBox fwhm-------------------------- + layout2 = qt.QHBoxLayout(self.manualFwhmGB) + self.manualFwhmGB.setLayout(layout2) + + label = qt.QLabel("Fwhm Points", self.manualFwhmGB) + layout2.addWidget(label) + + self.fwhmPointsSpin = qt.QSpinBox(self.manualFwhmGB) + self.fwhmPointsSpin.setRange(0, 999999) + self.fwhmPointsSpin.setToolTip("Typical peak fwhm (number of data points)") + layout2.addWidget(self.fwhmPointsSpin) + # ---------------------------------------------------- + + self.manualScalingGB = qt.QGroupBox("Define scaling manually", self) + self.manualScalingGB.setCheckable(True) + self.manualScalingGB.setToolTip( + "If disabled, the Y scaling used for peak search is " + + "estimated automatically") + layout.addWidget(self.manualScalingGB) + # ------------ GroupBox scaling----------------------- + layout3 = qt.QHBoxLayout(self.manualScalingGB) + self.manualScalingGB.setLayout(layout3) + + label = qt.QLabel("Y Scaling", self.manualScalingGB) + layout3.addWidget(label) + + self.yScalingEntry = qt.QLineEdit(self.manualScalingGB) + self.yScalingEntry.setToolTip( + "Data values will be multiplied by this value prior to peak" + + " search") + self.yScalingEntry.setValidator(qt.QDoubleValidator()) + layout3.addWidget(self.yScalingEntry) + # ---------------------------------------------------- + + # ------------------- grid layout -------------------- + containerWidget = qt.QWidget(self) + layout4 = qt.QHBoxLayout(containerWidget) + containerWidget.setLayout(layout4) + + label = qt.QLabel("Sensitivity", containerWidget) + layout4.addWidget(label) + + self.sensitivityEntry = qt.QLineEdit(containerWidget) + self.sensitivityEntry.setToolTip( + "Peak search sensitivity threshold, expressed as a multiple " + + "of the standard deviation of the noise.\nMinimum value is 1 " + + "(to be detected, peak must be higher than the estimated noise)") + sensivalidator = qt.QDoubleValidator() + sensivalidator.setBottom(1.0) + self.sensitivityEntry.setValidator(sensivalidator) + layout4.addWidget(self.sensitivityEntry) + # ---------------------------------------------------- + layout.addWidget(containerWidget) + + self.forcePeakPresenceCB = qt.QCheckBox("Force peak presence", self) + self.forcePeakPresenceCB.setToolTip( + "If peak search algorithm is unsuccessful, place one peak " + + "at the maximum of the curve") + layout.addWidget(self.forcePeakPresenceCB) + + layout.addStretch() + + self.setDefault() + + def setDefault(self, default_dict=None): + """Set default values for all widgets. + + :param default_dict: If a default config dictionary is provided as + a parameter, its values are used as default values.""" + if default_dict is None: + default_dict = {} + self.manualFwhmGB.setChecked( + not default_dict.get('AutoFwhm', True)) + self.fwhmPointsSpin.setValue( + default_dict.get('FwhmPoints', 8)) + self.sensitivityEntry.setText( + str(default_dict.get('Sensitivity', 1.0))) + self.manualScalingGB.setChecked( + not default_dict.get('AutoScaling', False)) + self.yScalingEntry.setText( + str(default_dict.get('Yscaling', 1.0))) + self.forcePeakPresenceCB.setChecked( + default_dict.get('ForcePeakPresence', False)) + + def get(self): + """Return a dictionary of peak search parameters, to be processed by + the :meth:`configure` method of the selected fit theory.""" + ddict = { + 'AutoFwhm': not self.manualFwhmGB.isChecked(), + 'FwhmPoints': self.fwhmPointsSpin.value(), + 'Sensitivity': safe_float(self.sensitivityEntry.text()), + 'AutoScaling': not self.manualScalingGB.isChecked(), + 'Yscaling': safe_float(self.yScalingEntry.text()), + 'ForcePeakPresence': self.forcePeakPresenceCB.isChecked() + } + return ddict + + +class BackgroundPage(qt.QGroupBox): + """Background subtraction configuration, specific to fittheories + estimation functions.""" + def __init__(self, parent=None, + title="Subtract strip background prior to estimation"): + super(BackgroundPage, self).__init__(parent) + self.setTitle(title) + self.setCheckable(True) + self.setToolTip( + "The strip algorithm strips away peaks to compute the " + + "background signal.\nAt each iteration, a sample is compared " + + "to the average of the two samples at a given distance in both" + + " directions,\n and if its value is higher than the average," + "it is replaced by the average.") + + layout = qt.QGridLayout(self) + self.setLayout(layout) + + for i, label_text in enumerate( + ["Strip width (in samples)", + "Number of iterations", + "Strip threshold factor"]): + label = qt.QLabel(label_text) + layout.addWidget(label, i, 0) + + self.stripWidthSpin = qt.QSpinBox(self) + self.stripWidthSpin.setToolTip( + "Width, in number of samples, of the strip operator") + self.stripWidthSpin.setRange(1, 999999) + + layout.addWidget(self.stripWidthSpin, 0, 1) + + self.numIterationsSpin = qt.QSpinBox(self) + self.numIterationsSpin.setToolTip( + "Number of iterations of the strip algorithm") + self.numIterationsSpin.setRange(1, 999999) + layout.addWidget(self.numIterationsSpin, 1, 1) + + self.thresholdFactorEntry = qt.QLineEdit(self) + self.thresholdFactorEntry.setToolTip( + "Factor used by the strip algorithm to decide whether a sample" + + "value should be stripped.\nThe value must be higher than the " + + "average of the 2 samples at +- w times this factor.\n") + self.thresholdFactorEntry.setValidator(qt.QDoubleValidator()) + layout.addWidget(self.thresholdFactorEntry, 2, 1) + + self.smoothStripGB = qt.QGroupBox("Apply smoothing prior to strip", self) + self.smoothStripGB.setCheckable(True) + self.smoothStripGB.setToolTip( + "Apply a smoothing before subtracting strip background" + + " in fit and estimate processes") + smoothlayout = qt.QHBoxLayout(self.smoothStripGB) + label = qt.QLabel("Smoothing width (Savitsky-Golay)") + smoothlayout.addWidget(label) + self.smoothingWidthSpin = qt.QSpinBox(self) + self.smoothingWidthSpin.setToolTip( + "Width parameter for Savitsky-Golay smoothing (number of samples, must be odd)") + self.smoothingWidthSpin.setRange(3, 101) + self.smoothingWidthSpin.setSingleStep(2) + smoothlayout.addWidget(self.smoothingWidthSpin) + + layout.addWidget(self.smoothStripGB, 3, 0, 1, 2) + + layout.setRowStretch(4, 1) + + self.setDefault() + + def setDefault(self, default_dict=None): + """Set default values for all widgets. + + :param default_dict: If a default config dictionary is provided as + a parameter, its values are used as default values.""" + if default_dict is None: + default_dict = {} + + self.setChecked( + default_dict.get('StripBackgroundFlag', True)) + + self.stripWidthSpin.setValue( + default_dict.get('StripWidth', 2)) + self.numIterationsSpin.setValue( + default_dict.get('StripIterations', 5000)) + self.thresholdFactorEntry.setText( + str(default_dict.get('StripThreshold', 1.0))) + self.smoothStripGB.setChecked( + default_dict.get('SmoothingFlag', False)) + self.smoothingWidthSpin.setValue( + default_dict.get('SmoothingWidth', 3)) + + def get(self): + """Return a dictionary of background subtraction parameters, to be + processed by the :meth:`configure` method of the selected fit theory. + """ + ddict = { + 'StripBackgroundFlag': self.isChecked(), + 'StripWidth': self.stripWidthSpin.value(), + 'StripIterations': self.numIterationsSpin.value(), + 'StripThreshold': safe_float(self.thresholdFactorEntry.text()), + 'SmoothingFlag': self.smoothStripGB.isChecked(), + 'SmoothingWidth': self.smoothingWidthSpin.value() + } + return ddict + + +def safe_float(string_, default=1.0): + """Convert a string into a float. + If the conversion fails, return the default value. + """ + try: + ret = float(string_) + except ValueError: + return default + else: + return ret + + +def safe_int(string_, default=1): + """Convert a string into a integer. + If the conversion fails, return the default value. + """ + try: + ret = int(float(string_)) + except ValueError: + return default + else: + return ret + + +def getFitConfigDialog(parent=None, default=None, modal=True): + """Instantiate and return a fit configuration dialog, adapted + for configuring standard fit theories from + :mod:`silx.math.fit.fittheories`. + + :return: Instance of :class:`TabsDialogData` with 3 tabs: + :class:`ConstraintsPage`, :class:`SearchPage` and + :class:`BackgroundPage` + """ + tdd = TabsDialogData(parent=parent, default=default) + tdd.addTab(ConstraintsPage(), label="Constraints") + tdd.addTab(SearchPage(), label="Peak search") + tdd.addTab(BackgroundPage(), label="Background") + # apply default to newly added pages + tdd.setDefault() + + return tdd + + +def main(): + a = qt.QApplication([]) + + mw = qt.QMainWindow() + mw.show() + + tdd = getFitConfigDialog(mw, default={"a": 1}) + tdd.show() + tdd.exec_() + print("TabsDialogData result: ", tdd.result()) + print("TabsDialogData output: ", tdd.output) + + a.exec_() + +if __name__ == "__main__": + main() diff --git a/silx/gui/fit/FitWidget.py b/silx/gui/fit/FitWidget.py new file mode 100644 index 0000000..a5c3cfd --- /dev/null +++ b/silx/gui/fit/FitWidget.py @@ -0,0 +1,727 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# +# This file is part of the PyMca X-ray Fluorescence Toolkit developed at +# the ESRF by the Software group. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ######################################################################### */ +"""This module provides a widget designed to configure and run a fitting +process with constraints on parameters. + +The main class is :class:`FitWidget`. It relies on +:mod:`silx.math.fit.fitmanager`, which relies on :func:`silx.math.fit.leastsq`. + +The user can choose between functions before running the fit. These function can +be user defined, or by default are loaded from +:mod:`silx.math.fit.fittheories`. +""" + +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "15/02/2017" + +import logging +import sys +import traceback +import warnings + +from silx.math.fit import fittheories +from silx.math.fit import fitmanager, functions +from silx.gui import qt +from .FitWidgets import (FitActionsButtons, FitStatusLines, + FitConfigWidget, ParametersTab) +from .FitConfig import getFitConfigDialog +from .BackgroundWidget import getBgDialog, BackgroundDialog + +QTVERSION = qt.qVersion() +DEBUG = 0 +_logger = logging.getLogger(__name__) + + +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "30/11/2016" + + +class FitWidget(qt.QWidget): + """This widget can be used to configure, run and display results of a + fitting process. + + The standard steps for using this widget is to initialize it, then load + the data to be fitted. + + Optionally, you can also load user defined fit theories. If you skip this + step, a series of default fit functions will be presented (gaussian-like + functions), and you can later load your custom fit theories from an + external file using the GUI. + + A fit theory is a fit function and its associated features: + + - estimation function, + - list of parameter names + - numerical derivative algorithm + - configuration widget + + Once the widget is up and running, the user may select a fit theory and a + background theory, change configuration parameters specific to the theory + run the estimation, set constraints on parameters and run the actual fit. + + The results are displayed in a table. + """ + sigFitWidgetSignal = qt.Signal(object) + """This signal is emitted by the estimation and fit methods. + It carries a dictionary with two items: + + - *event*: one of the following strings + + - *EstimateStarted*, + - *FitStarted* + - *EstimateFinished*, + - *FitFinished* + - *EstimateFailed* + - *FitFailed* + + - *data*: None, or fit/estimate results (see documentation for + :attr:`silx.math.fit.fitmanager.FitManager.fit_results`) + """ + + def __init__(self, parent=None, title=None, fitmngr=None, + enableconfig=True, enablestatus=True, enablebuttons=True): + """ + + :param parent: Parent widget + :param title: Window title + :param fitmngr: User defined instance of + :class:`silx.math.fit.fitmanager.FitManager`, or ``None`` + :param enableconfig: If ``True``, activate widgets to modify the fit + configuration (select between several fit functions or background + functions, apply global constraints, peak search parameters…) + :param enablestatus: If ``True``, add a fit status widget, to display + a message when fit estimation is available and when fit results + are available, as well as a measure of the fit error. + :param enablebuttons: If ``True``, add buttons to run estimation and + fitting. + """ + if title is None: + title = "FitWidget" + qt.QWidget.__init__(self, parent) + + self.setWindowTitle(title) + layout = qt.QVBoxLayout(self) + + self.fitmanager = self._setFitManager(fitmngr) + """Instance of :class:`FitManager`. + This is the underlying data model of this FitWidget. + + If no custom theories are defined, the default ones from + :mod:`silx.math.fit.fittheories` are imported. + """ + + # reference fitmanager.configure method for direct access + self.configure = self.fitmanager.configure + self.fitconfig = self.fitmanager.fitconfig + + self.configdialogs = {} + """This dictionary defines the fit configuration widgets + associated with the fit theories in :attr:`fitmanager.theories` + + Keys must correspond to existing theory names, i.e. existing keys + in :attr:`fitmanager.theories`. + + Values must be instances of QDialog widgets with an additional + *output* attribute, a dictionary storing configuration parameters + interpreted by the corresponding fit theory. + + The dialog can also define a *setDefault* method to initialize the + widget values with values in a dictionary passed as a parameter. + This will be executed first. + + In case the widget does not actually inherit :class:`QDialog`, it + must at least implement the following methods (executed in this + particular order): + + - :meth:`show`: should cause the widget to become visible to the + user) + - :meth:`exec_`: should run while the user is interacting with the + widget, interrupting the rest of the program. It should + typically end (*return*) when the user clicks an *OK* + or a *Cancel* button. + - :meth:`result`: must return ``True`` if the new configuration in + attribute :attr:`output` is to be accepted (user clicked *OK*), + or return ``False`` if :attr:`output` is to be rejected (user + clicked *Cancel*) + + To associate a custom configuration widget with a fit theory, use + :meth:`associateConfigDialog`. E.g.:: + + fw = FitWidget() + my_config_widget = MyGaussianConfigWidget(parent=fw) + fw.associateConfigDialog(theory_name="Gaussians", + config_widget=my_config_widget) + """ + + self.bgconfigdialogs = {} + """Same as :attr:`configdialogs`, except that the widget is associated + with a background theory in :attr:`fitmanager.bgtheories`""" + + self._associateDefaultConfigDialogs() + + self.guiConfig = None + """Configuration widget at the top of FitWidget, to select + fit function, background function, and open an advanced + configuration dialog.""" + + self.guiParameters = ParametersTab(self) + """Table widget for display of fit parameters and constraints""" + + if enableconfig: + self.guiConfig = FitConfigWidget(self) + """Function selector and configuration widget""" + + self.guiConfig.FunConfigureButton.clicked.connect( + self.__funConfigureGuiSlot) + self.guiConfig.BgConfigureButton.clicked.connect( + self.__bgConfigureGuiSlot) + + self.guiConfig.WeightCheckBox.setChecked( + self.fitconfig.get("WeightFlag", False)) + self.guiConfig.WeightCheckBox.stateChanged[int].connect(self.weightEvent) + + self.guiConfig.BkgComBox.activated[str].connect(self.bkgEvent) + self.guiConfig.FunComBox.activated[str].connect(self.funEvent) + self._populateFunctions() + + layout.addWidget(self.guiConfig) + + layout.addWidget(self.guiParameters) + + if enablestatus: + self.guistatus = FitStatusLines(self) + """Status bar""" + layout.addWidget(self.guistatus) + + if enablebuttons: + self.guibuttons = FitActionsButtons(self) + """Widget with estimate, start fit and dismiss buttons""" + self.guibuttons.EstimateButton.clicked.connect(self.estimate) + self.guibuttons.StartFitButton.clicked.connect(self.startFit) + self.guibuttons.DismissButton.clicked.connect(self.dismiss) + layout.addWidget(self.guibuttons) + + def _setFitManager(self, fitinstance): + """Initialize a :class:`FitManager` instance, to be assigned to + :attr:`fitmanager`, or use a custom FitManager instance. + + :param fitinstance: Existing instance of FitManager, possibly + customized by the user, or None to load a default instance.""" + if isinstance(fitinstance, fitmanager.FitManager): + # customized + fitmngr = fitinstance + else: + # initialize default instance + fitmngr = fitmanager.FitManager() + + # initialize the default fitting functions in case + # none is present + if not len(fitmngr.theories): + fitmngr.loadtheories(fittheories) + + return fitmngr + + def _associateDefaultConfigDialogs(self): + """Fill :attr:`bgconfigdialogs` and :attr:`configdialogs` by calling + :meth:`associateConfigDialog` with default config dialog widgets. + """ + # associate silx.gui.fit.FitConfig with all theories + # Users can later associate their own custom dialogs to + # replace the default. + configdialog = getFitConfigDialog(parent=self, + default=self.fitconfig) + for theory in self.fitmanager.theories: + self.associateConfigDialog(theory, configdialog) + for bgtheory in self.fitmanager.bgtheories: + self.associateConfigDialog(bgtheory, configdialog, + theory_is_background=True) + + # associate silx.gui.fit.BackgroundWidget with Strip and Snip + bgdialog = getBgDialog(parent=self, + default=self.fitconfig) + for bgtheory in ["Strip", "Snip"]: + if bgtheory in self.fitmanager.bgtheories: + self.associateConfigDialog(bgtheory, bgdialog, + theory_is_background=True) + + def _populateFunctions(self): + """Fill combo-boxes with fit theories and background theories + loaded by :attr:`fitmanager`. + Run :meth:`fitmanager.configure` to ensure the custom configuration + of the selected theory has been loaded into :attr:`fitconfig`""" + for theory_name in self.fitmanager.bgtheories: + self.guiConfig.BkgComBox.addItem(theory_name) + self.guiConfig.BkgComBox.setItemData( + self.guiConfig.BkgComBox.findText(theory_name), + self.fitmanager.bgtheories[theory_name].description, + qt.Qt.ToolTipRole) + + for theory_name in self.fitmanager.theories: + self.guiConfig.FunComBox.addItem(theory_name) + self.guiConfig.FunComBox.setItemData( + self.guiConfig.FunComBox.findText(theory_name), + self.fitmanager.theories[theory_name].description, + qt.Qt.ToolTipRole) + + # - activate selected fit theory (if any) + # - activate selected bg theory (if any) + configuration = self.fitmanager.configure() + if self.fitmanager.selectedtheory is None: + # take the first one by default + self.guiConfig.FunComBox.setCurrentIndex(1) + self.funEvent(list(self.fitmanager.theories.keys())[0]) + else: + idx = list(self.fitmanager.theories).index(self.fitmanager.selectedtheory) + self.guiConfig.FunComBox.setCurrentIndex(idx + 1) + self.funEvent(self.fitmanager.selectedtheory) + + if self.fitmanager.selectedbg is None: + self.guiConfig.BkgComBox.setCurrentIndex(1) + self.bkgEvent(list(self.fitmanager.bgtheories.keys())[0]) + else: + idx = list(self.fitmanager.bgtheories).index(self.fitmanager.selectedbg) + self.guiConfig.BkgComBox.setCurrentIndex(idx + 1) + self.bkgEvent(self.fitmanager.selectedbg) + + configuration.update(self.configure()) + + def setdata(self, x, y, sigmay=None, xmin=None, xmax=None): + warnings.warn("Method renamed to setData", + DeprecationWarning) + self.setData(x, y, sigmay, xmin, xmax) + + def setData(self, x, y, sigmay=None, xmin=None, xmax=None): + """Set data to be fitted. + + :param x: Abscissa data. If ``None``, :attr:`xdata`` is set to + ``numpy.array([0.0, 1.0, 2.0, ..., len(y)-1])`` + :type x: Sequence or numpy array or None + :param y: The dependant data ``y = f(x)``. ``y`` must have the same + shape as ``x`` if ``x`` is not ``None``. + :type y: Sequence or numpy array or None + :param sigmay: The uncertainties in the ``ydata`` array. These are + used as weights in the least-squares problem. + If ``None``, the uncertainties are assumed to be 1. + :type sigmay: Sequence or numpy array or None + :param xmin: Lower value of x values to use for fitting + :param xmax: Upper value of x values to use for fitting + """ + self.fitmanager.setdata(x=x, y=y, sigmay=sigmay, + xmin=xmin, xmax=xmax) + for config_dialog in self.bgconfigdialogs.values(): + if isinstance(config_dialog, BackgroundDialog): + config_dialog.setData(x, y, xmin=xmin, xmax=xmax) + + def associateConfigDialog(self, theory_name, config_widget, + theory_is_background=False): + """Associate an instance of custom configuration dialog widget to + a fit theory or to a background theory. + + This adds or modifies an item in the correspondence table + :attr:`configdialogs` or :attr:`bgconfigdialogs`. + + :param str theory_name: Name of fit theory. This must be a key of dict + :attr:`fitmanager.theories` + :param config_widget: Custom configuration widget. See documentation + for :attr:`configdialogs` + :param bool theory_is_background: If flag is *True*, add dialog to + :attr:`bgconfigdialogs` rather than :attr:`configdialogs` + (default). + :raise: KeyError if parameter ``theory_name`` does not match an + existing fit theory or background theory in :attr:`fitmanager`. + :raise: AttributeError if the widget does not implement the mandatory + methods (*show*, *exec_*, *result*, *setDefault*) or the mandatory + attribute (*output*). + """ + theories = self.fitmanager.bgtheories if theory_is_background else\ + self.fitmanager.theories + + if theory_name not in theories: + raise KeyError("%s does not match an existing fitmanager theory") + + if config_widget is not None: + for mandatory_attr in ["show", "exec_", "result", "output"]: + if not hasattr(config_widget, mandatory_attr): + raise AttributeError( + "Custom configuration widget must define " + + "attribute or method " + mandatory_attr) + + if theory_is_background: + self.bgconfigdialogs[theory_name] = config_widget + else: + self.configdialogs[theory_name] = config_widget + + def _emitSignal(self, ddict): + """Emit pyqtSignal after estimation completed + (``ddict = {'event': 'EstimateFinished', 'data': fit_results}``) + and after fit completed + (``ddict = {'event': 'FitFinished', 'data': fit_results}``)""" + self.sigFitWidgetSignal.emit(ddict) + + def __funConfigureGuiSlot(self): + """Open an advanced configuration dialog widget""" + self.__configureGui(dialog_type="function") + + def __bgConfigureGuiSlot(self): + """Open an advanced configuration dialog widget""" + self.__configureGui(dialog_type="background") + + def __configureGui(self, newconfiguration=None, dialog_type="function"): + """Open an advanced configuration dialog widget to get a configuration + dictionary, or use a supplied configuration dictionary. Call + :meth:`configure` with this dictionary as a parameter. Update the gui + accordingly. Reinitialize the fit results in the table and in + :attr:`fitmanager`. + + :param newconfiguration: User supplied configuration dictionary. If ``None``, + open a dialog widget that returns a dictionary.""" + configuration = self.configure() + # get new dictionary + if newconfiguration is None: + newconfiguration = self.configureDialog(configuration, dialog_type) + # update configuration + configuration.update(self.configure(**newconfiguration)) + # set fit function theory + try: + i = 1 + \ + list(self.fitmanager.theories.keys()).index( + self.fitmanager.selectedtheory) + self.guiConfig.FunComBox.setCurrentIndex(i) + self.funEvent(self.fitmanager.selectedtheory) + except ValueError: + _logger.error("Function not in list %s", + self.fitmanager.selectedtheory) + self.funEvent(list(self.fitmanager.theories.keys())[0]) + # current background + try: + i = 1 + \ + list(self.fitmanager.bgtheories.keys()).index( + self.fitmanager.selectedbg) + self.guiConfig.BkgComBox.setCurrentIndex(i) + self.bkgEvent(self.fitmanager.selectedbg) + except ValueError: + _logger.error("Background not in list %s", + self.fitmanager.selectedbg) + self.bkgEvent(list(self.fitmanager.bgtheories.keys())[0]) + + # update the Gui + self.__initialParameters() + + def configureDialog(self, oldconfiguration, dialog_type="function"): + """Display a dialog, allowing the user to define fit configuration + parameters. + + By default, a common dialog is used for all fit theories. But if the + defined a custom dialog using :meth:`associateConfigDialog`, it is + used instead. + + :param dict oldconfiguration: Dictionary containing previous configuration + :param str dialog_type: "function" or "background" + :return: User defined parameters in a dictionary + """ + newconfiguration = {} + newconfiguration.update(oldconfiguration) + + if dialog_type == "function": + theory = self.fitmanager.selectedtheory + configdialog = self.configdialogs[theory] + elif dialog_type == "background": + theory = self.fitmanager.selectedbg + configdialog = self.bgconfigdialogs[theory] + + # this should only happen if a user specifically associates None + # with a theory, to have no configuration option + if configdialog is None: + return {} + + # update state of configdialog before showing it + if hasattr(configdialog, "setDefault"): + configdialog.setDefault(newconfiguration) + configdialog.show() + configdialog.exec_() + if configdialog.result(): + newconfiguration.update(configdialog.output) + + return newconfiguration + + def estimate(self): + """Run parameter estimation function then emit + :attr:`sigFitWidgetSignal` with a dictionary containing a status + message and a list of fit parameters estimations + in the format defined in + :attr:`silx.math.fit.fitmanager.FitManager.fit_results` + + The emitted dictionary has an *"event"* key that can have + following values: + + - *'EstimateStarted'* + - *'EstimateFailed'* + - *'EstimateFinished'* + """ + try: + theory_name = self.fitmanager.selectedtheory + estimation_function = self.fitmanager.theories[theory_name].estimate + if estimation_function is not None: + ddict = {'event': 'EstimateStarted', + 'data': None} + self._emitSignal(ddict) + self.fitmanager.estimate(callback=self.fitStatus) + else: + msg = qt.QMessageBox(self) + msg.setIcon(qt.QMessageBox.Information) + text = "Function does not define a way to estimate\n" + text += "the initial parameters. Please, fill them\n" + text += "yourself in the table and press Start Fit\n" + msg.setText(text) + msg.setWindowTitle('FitWidget Message') + msg.exec_() + return + except: # noqa (we want to catch and report all errors) + msg = qt.QMessageBox(self) + msg.setIcon(qt.QMessageBox.Critical) + msg.setText("Error on estimate: %s" % traceback.format_exc()) + msg.exec_() + ddict = { + 'event': 'EstimateFailed', + 'data': None} + self._emitSignal(ddict) + return + + self.guiParameters.fillFromFit( + self.fitmanager.fit_results, view='Fit') + self.guiParameters.removeAllViews(keep='Fit') + ddict = { + 'event': 'EstimateFinished', + 'data': self.fitmanager.fit_results} + self._emitSignal(ddict) + + def startfit(self): + warnings.warn("Method renamed to startFit", + DeprecationWarning) + self.startFit() + + def startFit(self): + """Run fit, then emit :attr:`sigFitWidgetSignal` with a dictionary + containing a status message and a list of fit + parameters results in the format defined in + :attr:`silx.math.fit.fitmanager.FitManager.fit_results` + + The emitted dictionary has an *"event"* key that can have + following values: + + - *'FitStarted'* + - *'FitFailed'* + - *'FitFinished'* + """ + self.fitmanager.fit_results = self.guiParameters.getFitResults() + try: + ddict = {'event': 'FitStarted', + 'data': None} + self._emitSignal(ddict) + self.fitmanager.runfit(callback=self.fitStatus) + except: # noqa (we want to catch and report all errors) + msg = qt.QMessageBox(self) + msg.setIcon(qt.QMessageBox.Critical) + msg.setText("Error on Fit: %s" % traceback.format_exc()) + msg.exec_() + ddict = { + 'event': 'FitFailed', + 'data': None + } + self._emitSignal(ddict) + return + + self.guiParameters.fillFromFit( + self.fitmanager.fit_results, view='Fit') + self.guiParameters.removeAllViews(keep='Fit') + ddict = { + 'event': 'FitFinished', + 'data': self.fitmanager.fit_results + } + self._emitSignal(ddict) + return + + def bkgEvent(self, bgtheory): + """Select background theory, then reinitialize parameters""" + bgtheory = str(bgtheory) + if bgtheory in self.fitmanager.bgtheories: + self.fitmanager.setbackground(bgtheory) + else: + functionsfile = qt.QFileDialog.getOpenFileName( + self, "Select python module with your function(s)", "", + "Python Files (*.py);;All Files (*)") + + if len(functionsfile): + try: + self.fitmanager.loadbgtheories(functionsfile) + except ImportError: + qt.QMessageBox.critical(self, "ERROR", + "Function not imported") + return + else: + # empty the ComboBox + while self.guiConfig.BkgComBox.count() > 1: + self.guiConfig.BkgComBox.removeItem(1) + # and fill it again + for key in self.fitmanager.bgtheories: + self.guiConfig.BkgComBox.addItem(str(key)) + + i = 1 + \ + list(self.fitmanager.bgtheories.keys()).index( + self.fitmanager.selectedbg) + self.guiConfig.BkgComBox.setCurrentIndex(i) + self.__initialParameters() + + def funEvent(self, theoryname): + """Select a fit theory to be used for fitting. If this theory exists + in :attr:`fitmanager`, use it. Then, reinitialize table. + + :param theoryname: Name of the fit theory to use for fitting. If this theory + exists in :attr:`fitmanager`, use it. Else, open a file dialog to open + a custom fit function definition file with + :meth:`fitmanager.loadtheories`. + """ + theoryname = str(theoryname) + if theoryname in self.fitmanager.theories: + self.fitmanager.settheory(theoryname) + else: + # open a load file dialog + functionsfile = qt.QFileDialog.getOpenFileName( + self, "Select python module with your function(s)", "", + "Python Files (*.py);;All Files (*)") + + if len(functionsfile): + try: + self.fitmanager.loadtheories(functionsfile) + except ImportError: + qt.QMessageBox.critical(self, "ERROR", + "Function not imported") + return + else: + # empty the ComboBox + while self.guiConfig.FunComBox.count() > 1: + self.guiConfig.FunComBox.removeItem(1) + # and fill it again + for key in self.fitmanager.theories: + self.guiConfig.FunComBox.addItem(str(key)) + + i = 1 + \ + list(self.fitmanager.theories.keys()).index( + self.fitmanager.selectedtheory) + self.guiConfig.FunComBox.setCurrentIndex(i) + self.__initialParameters() + + def weightEvent(self, flag): + """This is called when WeightCheckBox is clicked, to configure the + *WeightFlag* field in :attr:`fitmanager.fitconfig` and set weights + in the least-square problem.""" + self.configure(WeightFlag=flag) + if flag: + self.fitmanager.enableweight() + else: + # set weights back to 1 + self.fitmanager.disableweight() + + def __initialParameters(self): + """Fill the fit parameters names with names of the parameters of + the selected background theory and the selected fit theory. + Initialize :attr:`fitmanager.fit_results` with these names, and + initialize the table with them. This creates a view called "Fit" + in :attr:`guiParameters`""" + self.fitmanager.parameter_names = [] + self.fitmanager.fit_results = [] + for pname in self.fitmanager.bgtheories[self.fitmanager.selectedbg].parameters: + self.fitmanager.parameter_names.append(pname) + self.fitmanager.fit_results.append({'name': pname, + 'estimation': 0, + 'group': 0, + 'code': 'FREE', + 'cons1': 0, + 'cons2': 0, + 'fitresult': 0.0, + 'sigma': 0.0, + 'xmin': None, + 'xmax': None}) + if self.fitmanager.selectedtheory is not None: + theory = self.fitmanager.selectedtheory + for pname in self.fitmanager.theories[theory].parameters: + self.fitmanager.parameter_names.append(pname + "1") + self.fitmanager.fit_results.append({'name': pname + "1", + 'estimation': 0, + 'group': 1, + 'code': 'FREE', + 'cons1': 0, + 'cons2': 0, + 'fitresult': 0.0, + 'sigma': 0.0, + 'xmin': None, + 'xmax': None}) + + self.guiParameters.fillFromFit( + self.fitmanager.fit_results, view='Fit') + + def fitStatus(self, data): + """Set *status* and *chisq* in status bar""" + if 'chisq' in data: + if data['chisq'] is None: + self.guistatus.ChisqLine.setText(" ") + else: + chisq = data['chisq'] + self.guistatus.ChisqLine.setText("%6.2f" % chisq) + + if 'status' in data: + status = data['status'] + self.guistatus.StatusLine.setText(str(status)) + + def dismiss(self): + """Close FitWidget""" + self.close() + + +if __name__ == "__main__": + import numpy + + x = numpy.arange(1500).astype(numpy.float) + constant_bg = 3.14 + + p = [1000, 100., 30.0, + 500, 300., 25., + 1700, 500., 35., + 750, 700., 30.0, + 1234, 900., 29.5, + 302, 1100., 30.5, + 75, 1300., 21.] + y = functions.sum_gauss(x, *p) + constant_bg + + a = qt.QApplication(sys.argv) + w = FitWidget() + w.setData(x=x, y=y) + w.show() + a.exec_() diff --git a/silx/gui/fit/FitWidgets.py b/silx/gui/fit/FitWidgets.py new file mode 100644 index 0000000..408666b --- /dev/null +++ b/silx/gui/fit/FitWidgets.py @@ -0,0 +1,559 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2004-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. +# +# ######################################################################### */ +"""Collection of widgets used to build +:class:`silx.gui.fit.FitWidget.FitWidget`""" + +from collections import OrderedDict + +from silx.gui import qt +from silx.gui.fit.Parameters import Parameters + +QTVERSION = qt.qVersion() + +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "13/10/2016" + + +class FitActionsButtons(qt.QWidget): + """Widget with 3 ``QPushButton``: + + The buttons can be accessed as public attributes:: + + - ``EstimateButton`` + - ``StartFitButton`` + - ``DismissButton`` + + You will typically need to access these attributes to connect the buttons + to actions. For instance, if you have 3 functions ``estimate``, + ``runfit`` and ``dismiss``, you can connect them like this:: + + >>> fit_actions_buttons = FitActionsButtons() + >>> fit_actions_buttons.EstimateButton.clicked.connect(estimate) + >>> fit_actions_buttons.StartFitButton.clicked.connect(runfit) + >>> fit_actions_buttons.DismissButton.clicked.connect(dismiss) + + """ + + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent) + + self.resize(234, 53) + + grid_layout = qt.QGridLayout(self) + grid_layout.setContentsMargins(11, 11, 11, 11) + grid_layout.setSpacing(6) + layout = qt.QHBoxLayout(None) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + self.EstimateButton = qt.QPushButton(self) + self.EstimateButton.setText("Estimate") + layout.addWidget(self.EstimateButton) + spacer = qt.QSpacerItem(20, 20, + qt.QSizePolicy.Expanding, + qt.QSizePolicy.Minimum) + layout.addItem(spacer) + + self.StartFitButton = qt.QPushButton(self) + self.StartFitButton.setText("Start Fit") + layout.addWidget(self.StartFitButton) + spacer_2 = qt.QSpacerItem(20, 20, + qt.QSizePolicy.Expanding, + qt.QSizePolicy.Minimum) + layout.addItem(spacer_2) + + self.DismissButton = qt.QPushButton(self) + self.DismissButton.setText("Dismiss") + layout.addWidget(self.DismissButton) + + grid_layout.addLayout(layout, 0, 0) + + +class FitStatusLines(qt.QWidget): + """Widget with 2 greyed out write-only ``QLineEdit``. + + These text widgets can be accessed as public attributes:: + + - ``StatusLine`` + - ``ChisqLine`` + + You will typically need to access these widgets to update the displayed + text:: + + >>> fit_status_lines = FitStatusLines() + >>> fit_status_lines.StatusLine.setText("Ready") + >>> fit_status_lines.ChisqLine.setText("%6.2f" % 0.01) + + """ + + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent) + + self.resize(535, 47) + + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + self.StatusLabel = qt.QLabel(self) + self.StatusLabel.setText("Status:") + layout.addWidget(self.StatusLabel) + + self.StatusLine = qt.QLineEdit(self) + self.StatusLine.setText("Ready") + self.StatusLine.setReadOnly(1) + layout.addWidget(self.StatusLine) + + self.ChisqLabel = qt.QLabel(self) + self.ChisqLabel.setText("Reduced chisq:") + layout.addWidget(self.ChisqLabel) + + self.ChisqLine = qt.QLineEdit(self) + self.ChisqLine.setMaximumSize(qt.QSize(16000, 32767)) + self.ChisqLine.setText("") + self.ChisqLine.setReadOnly(1) + layout.addWidget(self.ChisqLine) + + +class FitConfigWidget(qt.QWidget): + """Widget whose purpose is to select a fit theory and a background + theory, load a new fit theory definition file and provide + a "Configure" button to open an advanced configuration dialog. + + This is used in :class:`silx.gui.fit.FitWidget.FitWidget`, to offer + an interface to quickly modify the main parameters prior to running a fit: + + - select a fitting function through :attr:`FunComBox` + - select a background function through :attr:`BkgComBox` + - open a dialog for modifying advanced parameters through + :attr:`FunConfigureButton` + """ + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent) + + self.setWindowTitle("FitConfigGUI") + + layout = qt.QGridLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + self.FunLabel = qt.QLabel(self) + self.FunLabel.setText("Function") + layout.addWidget(self.FunLabel, 0, 0) + + self.FunComBox = qt.QComboBox(self) + self.FunComBox.addItem("Add Function(s)") + self.FunComBox.setItemData(self.FunComBox.findText("Add Function(s)"), + "Load fit theories from a file", + qt.Qt.ToolTipRole) + layout.addWidget(self.FunComBox, 0, 1) + + self.BkgLabel = qt.QLabel(self) + self.BkgLabel.setText("Background") + layout.addWidget(self.BkgLabel, 1, 0) + + self.BkgComBox = qt.QComboBox(self) + self.BkgComBox.addItem("Add Background(s)") + self.BkgComBox.setItemData(self.BkgComBox.findText("Add Background(s)"), + "Load background theories from a file", + qt.Qt.ToolTipRole) + layout.addWidget(self.BkgComBox, 1, 1) + + self.FunConfigureButton = qt.QPushButton(self) + self.FunConfigureButton.setText("Configure") + self.FunConfigureButton.setToolTip( + "Open a configuration dialog for the selected function") + layout.addWidget(self.FunConfigureButton, 0, 2) + + self.BgConfigureButton = qt.QPushButton(self) + self.BgConfigureButton.setText("Configure") + self.BgConfigureButton.setToolTip( + "Open a configuration dialog for the selected background") + layout.addWidget(self.BgConfigureButton, 1, 2) + + self.WeightCheckBox = qt.QCheckBox(self) + self.WeightCheckBox.setText("Weighted fit") + self.WeightCheckBox.setToolTip( + "Enable usage of weights in the least-square problem.\n Use" + + " the uncertainties (sigma) if provided, else use sqrt(y).") + + layout.addWidget(self.WeightCheckBox, 0, 3, 2, 1) + + layout.setColumnStretch(4, 1) + + +class ParametersTab(qt.QTabWidget): + """This widget provides tabs to display and modify fit parameters. Each + tab contains a table with fit data such as parameter names, estimated + values, fit constraints, and final fit results. + + The usual way to initialize the table is to fill it with the fit + parameters from a :class:`silx.math.fit.fitmanager.FitManager` object, after + the estimation process or after the final fit. + + In the following example we use a :class:`ParametersTab` to display the + results of two separate fits:: + + from silx.math.fit import fittheories + from silx.math.fit import fitmanager + from silx.math.fit import functions + from silx.gui import qt + import numpy + + a = qt.QApplication([]) + + # Create synthetic data + x = numpy.arange(1000) + y1 = functions.sum_gauss(x, 100, 400, 100) + + fit = fitmanager.FitManager(x=x, y=y1) + + fitfuns = fittheories.FitTheories() + fit.addtheory(theory="Gaussian", + function=functions.sum_gauss, + parameters=("height", "peak center", "fwhm"), + estimate=fitfuns.estimate_height_position_fwhm) + fit.settheory('Gaussian') + fit.configure(PositiveFwhmFlag=True, + PositiveHeightAreaFlag=True, + AutoFwhm=True,) + + # Fit + fit.estimate() + fit.runfit() + + # Show first fit result in a tab in our widget + w = ParametersTab() + w.show() + w.fillFromFit(fit.fit_results, view='Gaussians') + + # new synthetic data + y2 = functions.sum_splitgauss(x, + 100, 400, 100, 40, + 10, 600, 50, 500, + 80, 850, 10, 50) + fit.setData(x=x, y=y2) + + # Define new theory + fit.addtheory(theory="Asymetric gaussian", + function=functions.sum_splitgauss, + parameters=("height", "peak center", "left fwhm", "right fwhm"), + estimate=fitfuns.estimate_splitgauss) + fit.settheory('Asymetric gaussian') + + # Fit + fit.estimate() + fit.runfit() + + # Show first fit result in another tab in our widget + w.fillFromFit(fit.fit_results, view='Asymetric gaussians') + a.exec_() + + """ + + def __init__(self, parent=None, name="FitParameters"): + """ + + :param parent: Parent widget + :param name: Widget title + """ + qt.QTabWidget.__init__(self, parent) + self.setWindowTitle(name) + self.setContentsMargins(0, 0, 0, 0) + + self.views = OrderedDict() + """Dictionary of views. Keys are view names, + items are :class:`Parameters` widgets""" + + self.latest_view = None + """Name of latest view""" + + # the widgets/tables themselves + self.tables = {} + """Dictionary of :class:`silx.gui.fit.parameters.Parameters` objects. + These objects store fit results + """ + + self.setContentsMargins(10, 10, 10, 10) + + def setView(self, view=None, fitresults=None): + """Add or update a table. Fill it with data from a fit + + :param view: Tab name to be added or updated. If ``None``, use the + latest view. + :param fitresults: Fit data to be added to the table + :raise: KeyError if no view name specified and no latest view + available. + """ + if view is None: + if self.latest_view is not None: + view = self.latest_view + else: + raise KeyError( + "No view available. You must specify a view" + + " name the first time you call this method." + ) + + if view in self.tables.keys(): + table = self.tables[view] + else: + # create the parameters instance + self.tables[view] = Parameters(self) + table = self.tables[view] + self.views[view] = table + self.addTab(table, str(view)) + + if fitresults is not None: + table.fillFromFit(fitresults) + + self.setCurrentWidget(self.views[view]) + self.latest_view = view + + def renameView(self, oldname=None, newname=None): + """Rename a view (tab) + + :param oldname: Name of the view to be renamed + :param newname: New name of the view""" + error = 1 + if newname is not None: + if newname not in self.views.keys(): + if oldname in self.views.keys(): + parameterlist = self.tables[oldname].getFitResults() + self.setView(view=newname, fitresults=parameterlist) + self.removeView(oldname) + error = 0 + return error + + def fillFromFit(self, fitparameterslist, view=None): + """Update a view with data from a fit (alias for :meth:`setView`) + + :param view: Tab name to be added or updated (default: latest view) + :param fitparameterslist: Fit data to be added to the table + """ + self.setView(view=view, fitresults=fitparameterslist) + + def getFitResults(self, name=None): + """Call :meth:`getFitResults` for the + :class:`silx.gui.fit.parameters.Parameters` corresponding to the + latest table or to the named table (if ``name`` is not + ``None``). This return a list of dictionaries in the format used by + :class:`silx.math.fit.fitmanager.FitManager` to store fit parameter + results. + + :param name: View name. + """ + if name is None: + name = self.latest_view + return self.tables[name].getFitResults() + + def removeView(self, name): + """Remove a view by name. + + :param name: View name. + """ + if name in self.views: + index = self.indexOf(self.tables[name]) + self.removeTab(index) + index = self.indexOf(self.views[name]) + self.removeTab(index) + del self.tables[name] + del self.views[name] + + def removeAllViews(self, keep=None): + """Remove all views, except the one specified (argument + ``keep``) + + :param keep: Name of the view to be kept.""" + for view in self.tables: + if view != keep: + self.removeView(view) + + def getHtmlText(self, name=None): + """Return the table data as HTML + + :param name: View name.""" + if name is None: + name = self.latest_view + table = self.tables[name] + lemon = ("#%x%x%x" % (255, 250, 205)).upper() + hcolor = ("#%x%x%x" % (230, 240, 249)).upper() + text = "" + text += "<nobr>" + text += "<table>" + text += "<tr>" + ncols = table.columnCount() + for l in range(ncols): + text += ('<td align="left" bgcolor="%s"><b>' % hcolor) + if QTVERSION < '4.0.0': + text += (str(table.horizontalHeader().label(l))) + else: + text += (str(table.horizontalHeaderItem(l).text())) + text += "</b></td>" + text += "</tr>" + nrows = table.rowCount() + for r in range(nrows): + text += "<tr>" + item = table.item(r, 0) + newtext = "" + if item is not None: + newtext = str(item.text()) + if len(newtext): + color = "white" + b = "<b>" + else: + b = "" + color = lemon + try: + # MyQTable item has color defined + cc = table.item(r, 0).color + cc = ("#%x%x%x" % (cc.red(), cc.green(), cc.blue())).upper() + color = cc + except: + pass + for c in range(ncols): + item = table.item(r, c) + newtext = "" + if item is not None: + newtext = str(item.text()) + if len(newtext): + finalcolor = color + else: + finalcolor = "white" + if c < 2: + text += ('<td align="left" bgcolor="%s">%s' % + (finalcolor, b)) + else: + text += ('<td align="right" bgcolor="%s">%s' % + (finalcolor, b)) + text += newtext + if len(b): + text += "</td>" + else: + text += "</b></td>" + item = table.item(r, 0) + newtext = "" + if item is not None: + newtext = str(item.text()) + if len(newtext): + text += "</b>" + text += "</tr>" + text += "\n" + text += "</table>" + text += "</nobr>" + return text + + def getText(self, name=None): + """Return the table data as CSV formatted text, using tabulation + characters as separators. + + :param name: View name.""" + if name is None: + name = self.latest_view + table = self.tables[name] + text = "" + ncols = table.columnCount() + for l in range(ncols): + text += (str(table.horizontalHeaderItem(l).text())) + "\t" + text += "\n" + nrows = table.rowCount() + for r in range(nrows): + for c in range(ncols): + newtext = "" + if c != 4: + item = table.item(r, c) + if item is not None: + newtext = str(item.text()) + else: + item = table.cellWidget(r, c) + if item is not None: + newtext = str(item.currentText()) + text += newtext + "\t" + text += "\n" + text += "\n" + return text + + +def test(): + from silx.math.fit import fittheories + from silx.math.fit import fitmanager + from silx.math.fit import functions + from silx.gui.plot.PlotWindow import PlotWindow + import numpy + + a = qt.QApplication([]) + + x = numpy.arange(1000) + y1 = functions.sum_gauss(x, 100, 400, 100) + + fit = fitmanager.FitManager(x=x, y=y1) + + fitfuns = fittheories.FitTheories() + fit.addtheory(name="Gaussian", + function=functions.sum_gauss, + parameters=("height", "peak center", "fwhm"), + estimate=fitfuns.estimate_height_position_fwhm) + fit.settheory('Gaussian') + fit.configure(PositiveFwhmFlag=True, + PositiveHeightAreaFlag=True, + AutoFwhm=True,) + + # Fit + fit.estimate() + fit.runfit() + + w = ParametersTab() + w.show() + w.fillFromFit(fit.fit_results, view='Gaussians') + + y2 = functions.sum_splitgauss(x, + 100, 400, 100, 40, + 10, 600, 50, 500, + 80, 850, 10, 50) + fit.setdata(x=x, y=y2) + + # Define new theory + fit.addtheory(name="Asymetric gaussian", + function=functions.sum_splitgauss, + parameters=("height", "peak center", "left fwhm", "right fwhm"), + estimate=fitfuns.estimate_splitgauss) + fit.settheory('Asymetric gaussian') + + # Fit + fit.estimate() + fit.runfit() + + w.fillFromFit(fit.fit_results, view='Asymetric gaussians') + + # Plot + pw = PlotWindow(control=True) + pw.addCurve(x, y1, "Gaussians") + pw.addCurve(x, y2, "Asymetric gaussians") + pw.show() + + a.exec_() + + +if __name__ == "__main__": + test() diff --git a/silx/gui/fit/Parameters.py b/silx/gui/fit/Parameters.py new file mode 100644 index 0000000..62e3278 --- /dev/null +++ b/silx/gui/fit/Parameters.py @@ -0,0 +1,882 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2004-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ######################################################################### */ +"""This module defines a table widget that is specialized in displaying fit +parameter results and associated constraints.""" +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "25/11/2016" + +import sys +from collections import OrderedDict + +from silx.gui import qt +from silx.gui.widgets.TableWidget import TableWidget + + +def float_else_zero(sstring): + """Return converted string to float. If conversion fail, return zero. + + :param sstring: String to be converted + :return: ``float(sstrinq)`` if ``sstring`` can be converted to float + (e.g. ``"3.14"``), else ``0`` + """ + try: + return float(sstring) + except ValueError: + return 0 + + +class QComboTableItem(qt.QComboBox): + """:class:`qt.QComboBox` augmented with a ``sigCellChanged`` signal + to emit a tuple of ``(row, column)`` coordinates when the value is + changed. + + This signal can be used to locate the modified combo box in a table. + + :param row: Row number of the table cell containing this widget + :param col: Column number of the table cell containing this widget""" + sigCellChanged = qt.Signal(int, int) + """Signal emitted when this ``QComboBox`` is activated. + A ``(row, column)`` tuple is passed.""" + + def __init__(self, parent=None, row=None, col=None): + self._row = row + self._col = col + qt.QComboBox.__init__(self, parent) + self.activated[int].connect(self._cellChanged) + + def _cellChanged(self, idx): # noqa + self.sigCellChanged.emit(self._row, self._col) + + +class QCheckBoxItem(qt.QCheckBox): + """:class:`qt.QCheckBox` augmented with a ``sigCellChanged`` signal + to emit a tuple of ``(row, column)`` coordinates when the check box has + been clicked on. + + This signal can be used to locate the modified check box in a table. + + :param row: Row number of the table cell containing this widget + :param col: Column number of the table cell containing this widget""" + sigCellChanged = qt.Signal(int, int) + """Signal emitted when this ``QCheckBox`` is clicked. + A ``(row, column)`` tuple is passed.""" + + def __init__(self, parent=None, row=None, col=None): + self._row = row + self._col = col + qt.QCheckBox.__init__(self, parent) + self.clicked.connect(self._cellChanged) + + def _cellChanged(self): + self.sigCellChanged.emit(self._row, self._col) + + +class Parameters(TableWidget): + """:class:`TableWidget` customized to display fit results + and to interact with :class:`FitManager` objects. + + Data and references to cell widgets are kept in a dictionary + attribute :attr:`parameters`. + + :param parent: Parent widget + :param labels: Column headers. If ``None``, default headers will be used. + :type labels: List of strings or None + :param paramlist: List of fit parameters to be displayed for each fitted + peak. + :type paramlist: list[str] or None + """ + def __init__(self, parent=None, paramlist=None): + TableWidget.__init__(self, parent) + self.setContentsMargins(0, 0, 0, 0) + + labels = ['Parameter', 'Estimation', 'Fit Value', 'Sigma', + 'Constraints', 'Min/Parame', 'Max/Factor/Delta'] + tooltips = ["Fit parameter name", + "Estimated value for fit parameter. You can edit this column.", + "Actual value for parameter, after fit", + "Uncertainty (same unit as the parameter)", + "Constraint to be applied to the parameter for fit", + "First parameter for constraint (name of another param or min value)", + "Second parameter for constraint (max value, or factor/delta)"] + + self.columnKeys = ['name', 'estimation', 'fitresult', + 'sigma', 'code', 'val1', 'val2'] + """This list assigns shorter keys to refer to columns than the + displayed labels.""" + + self.__configuring = False + + # column headers and associated tooltips + self.setColumnCount(len(labels)) + + for i, label in enumerate(labels): + item = self.horizontalHeaderItem(i) + if item is None: + item = qt.QTableWidgetItem(label, + qt.QTableWidgetItem.Type) + self.setHorizontalHeaderItem(i, item) + + item.setText(label) + if tooltips is not None: + item.setToolTip(tooltips[i]) + + # resize columns + for col_key in ["name", "estimation", "sigma", "val1", "val2"]: + col_idx = self.columnIndexByField(col_key) + self.resizeColumnToContents(col_idx) + + # Initialize the table with one line per supplied parameter + paramlist = paramlist if paramlist is not None else [] + self.parameters = OrderedDict() + """This attribute stores all the data in an ordered dictionary. + New data can be added using :meth:`newParameterLine`. + Existing data can be modified using :meth:`configureLine` + + Keys of the dictionary are: + + - 'name': parameter name + - 'line': line index for the parameter in the table + - 'estimation' + - 'fitresult' + - 'sigma' + - 'code': constraint code (one of the elements of + :attr:`code_options`) + - 'val1': first parameter related to constraint, formatted + as a string, as typed in the table + - 'val2': second parameter related to constraint, formatted + as a string, as typed in the table + - 'cons1': scalar representation of 'val1' + (e.g. when val1 is the name of a fit parameter, cons1 + will be the line index of this parameter) + - 'cons2': scalar representation of 'val2' + - 'vmin': equal to 'val1' when 'code' is "QUOTED" + - 'vmax': equal to 'val2' when 'code' is "QUOTED" + - 'relatedto': name of related parameter when this parameter + is constrained to another parameter (same as 'val1') + - 'factor': same as 'val2' when 'code' is 'FACTOR' + - 'delta': same as 'val2' when 'code' is 'DELTA' + - 'sum': same as 'val2' when 'code' is 'SUM' + - 'group': group index for the parameter + - 'xmin': data range minimum + - 'xmax': data range maximum + """ + for line, param in enumerate(paramlist): + self.newParameterLine(param, line) + + self.code_options = ["FREE", "POSITIVE", "QUOTED", "FIXED", + "FACTOR", "DELTA", "SUM", "IGNORE", "ADD"] + """Possible values in the combo boxes in the 'Constraints' column. + """ + + # connect signal + self.cellChanged[int, int].connect(self.onCellChanged) + + def newParameterLine(self, param, line): + """Add a line to the :class:`QTableWidget`. + + Each line represents one of the fit parameters for one of + the fitted peaks. + + :param param: Name of the fit parameter + :type param: str + :param line: 0-based line index + :type line: int + """ + # get current number of lines + nlines = self.rowCount() + self.__configuring = True + if line >= nlines: + self.setRowCount(line + 1) + + # default configuration for fit parameters + self.parameters[param] = OrderedDict((('line', line), + ('estimation', '0'), + ('fitresult', ''), + ('sigma', ''), + ('code', 'FREE'), + ('val1', ''), + ('val2', ''), + ('cons1', 0), + ('cons2', 0), + ('vmin', '0'), + ('vmax', '1'), + ('relatedto', ''), + ('factor', '1.0'), + ('delta', '0.0'), + ('sum', '0.0'), + ('group', ''), + ('name', param), + ('xmin', None), + ('xmax', None))) + self.setReadWrite(param, 'estimation') + self.setReadOnly(param, ['name', 'fitresult', 'sigma', 'val1', 'val2']) + + # Constraint codes + a = [] + for option in self.code_options: + a.append(option) + + code_column_index = self.columnIndexByField('code') + cellWidget = self.cellWidget(line, code_column_index) + if cellWidget is None: + cellWidget = QComboTableItem(self, row=line, + col=code_column_index) + cellWidget.addItems(a) + self.setCellWidget(line, code_column_index, cellWidget) + cellWidget.sigCellChanged[int, int].connect(self.onCellChanged) + self.parameters[param]['code_item'] = cellWidget + self.parameters[param]['relatedto_item'] = None + self.__configuring = False + + def columnIndexByField(self, field): + """ + + :param field: Field name (column key) + :return: Index of the column with this field name + """ + return self.columnKeys.index(field) + + def fillFromFit(self, fitresults): + """Fill table with values from a list of dictionaries + (see :attr:`silx.math.fit.fitmanager.FitManager.fit_results`) + + :param fitresults: List of parameters as recorded + in the ``paramlist`` attribute of a :class:`FitManager` object + :type fitresults: list[dict] + """ + self.setRowCount(len(fitresults)) + + # Reinitialize and fill self.parameters + self.parameters = OrderedDict() + for (line, param) in enumerate(fitresults): + self.newParameterLine(param['name'], line) + + for param in fitresults: + name = param['name'] + code = str(param['code']) + if code not in self.code_options: + # convert code from int to descriptive string + code = self.code_options[int(code)] + val1 = param['cons1'] + val2 = param['cons2'] + estimation = param['estimation'] + group = param['group'] + sigma = param['sigma'] + fitresult = param['fitresult'] + + xmin = param.get('xmin') + xmax = param.get('xmax') + + self.configureLine(name=name, + code=code, + val1=val1, val2=val2, + estimation=estimation, + fitresult=fitresult, + sigma=sigma, + group=group, + xmin=xmin, xmax=xmax) + + def getConfiguration(self): + """Return ``FitManager.paramlist`` dictionary + encapsulated in another dictionary""" + return {'parameters': self.getFitResults()} + + def setConfiguration(self, ddict): + """Fill table with values from a ``FitManager.paramlist`` dictionary + encapsulated in another dictionary""" + self.fillFromFit(ddict['parameters']) + + def getFitResults(self): + """Return fit parameters as a list of dictionaries in the format used + by :class:`FitManager` (attribute ``paramlist``). + """ + fitparameterslist = [] + for param in self.parameters: + fitparam = {} + name = param + estimation, [code, cons1, cons2] = self.getEstimationConstraints(name) + buf = str(self.parameters[param]['fitresult']) + xmin = self.parameters[param]['xmin'] + xmax = self.parameters[param]['xmax'] + if len(buf): + fitresult = float(buf) + else: + fitresult = 0.0 + buf = str(self.parameters[param]['sigma']) + if len(buf): + sigma = float(buf) + else: + sigma = 0.0 + buf = str(self.parameters[param]['group']) + if len(buf): + group = float(buf) + else: + group = 0 + fitparam['name'] = name + fitparam['estimation'] = estimation + fitparam['fitresult'] = fitresult + fitparam['sigma'] = sigma + fitparam['group'] = group + fitparam['code'] = code + fitparam['cons1'] = cons1 + fitparam['cons2'] = cons2 + fitparam['xmin'] = xmin + fitparam['xmax'] = xmax + fitparameterslist.append(fitparam) + return fitparameterslist + + def onCellChanged(self, row, col): + """Slot called when ``cellChanged`` signal is emitted. + Checks the validity of the new text in the cell, then calls + :meth:`configureLine` to update the internal ``self.parameters`` + dictionary. + + :param row: Row number of the changed cell (0-based index) + :param col: Column number of the changed cell (0-based index) + """ + if (col != self.columnIndexByField("code")) and (col != -1): + if row != self.currentRow(): + return + if col != self.currentColumn(): + return + if self.__configuring: + return + param = list(self.parameters)[row] + field = self.columnKeys[col] + oldvalue = self.parameters[param][field] + if col != 4: + item = self.item(row, col) + if item is not None: + newvalue = item.text() + else: + newvalue = '' + else: + # this is the combobox + widget = self.cellWidget(row, col) + newvalue = widget.currentText() + if self.validate(param, field, oldvalue, newvalue): + paramdict = {"name": param, field: newvalue} + self.configureLine(**paramdict) + else: + if field == 'code': + # New code not valid, try restoring the old one + index = self.code_options.index(oldvalue) + self.__configuring = True + try: + self.parameters[param]['code_item'].setCurrentIndex(index) + finally: + self.__configuring = False + else: + paramdict = {"name": param, field: oldvalue} + self.configureLine(**paramdict) + + def validate(self, param, field, oldvalue, newvalue): + """Check validity of ``newvalue`` when a cell's value is modified. + + :param param: Fit parameter name + :param field: Column name + :param oldvalue: Cell value before change attempt + :param newvalue: New value to be validated + :return: True if new cell value is valid, else False + """ + if field == 'code': + return self.setCodeValue(param, oldvalue, newvalue) + # FIXME: validate() shouldn't have side effects. Move this bit to configureLine()? + if field == 'val1' and str(self.parameters[param]['code']) in ['DELTA', 'FACTOR', 'SUM']: + _, candidates = self.getRelatedCandidates(param) + # We expect val1 to be a fit parameter name + if str(newvalue) in candidates: + return True + else: + return False + # except for code, val1 and name (which is read-only and does not need + # validation), all fields must always be convertible to float + else: + try: + float(str(newvalue)) + except ValueError: + return False + return True + + def setCodeValue(self, param, oldvalue, newvalue): + """Update 'code' and 'relatedto' fields when code cell is + changed. + + :param param: Fit parameter name + :param oldvalue: Cell value before change attempt + :param newvalue: New value to be validated + :return: ``True`` if code was successfully updated + """ + + if str(newvalue) in ['FREE', 'POSITIVE', 'QUOTED', 'FIXED']: + self.configureLine(name=param, + code=newvalue) + if str(oldvalue) == 'IGNORE': + self.freeRestOfGroup(param) + return True + elif str(newvalue) in ['FACTOR', 'DELTA', 'SUM']: + # I should check here that some parameter is set + best, candidates = self.getRelatedCandidates(param) + if len(candidates) == 0: + return False + self.configureLine(name=param, + code=newvalue, + relatedto=best) + if str(oldvalue) == 'IGNORE': + self.freeRestOfGroup(param) + return True + + elif str(newvalue) == 'IGNORE': + # I should check if the group can be ignored + # for the time being I just fix all of them to ignore + group = int(float(str(self.parameters[param]['group']))) + candidates = [] + for param in self.parameters.keys(): + if group == int(float(str(self.parameters[param]['group']))): + candidates.append(param) + # print candidates + # I should check here if there is any relation to them + for param in candidates: + self.configureLine(name=param, + code=newvalue) + return True + elif str(newvalue) == 'ADD': + group = int(float(str(self.parameters[param]['group']))) + if group == 0: + # One cannot add a background group + return False + i = 0 + for param in self.parameters: + if i <= int(float(str(self.parameters[param]['group']))): + i += 1 + if (group == 0) and (i == 1): # FIXME: why +1? + i += 1 + self.addGroup(i, group) + return False + elif str(newvalue) == 'SHOW': + print(self.getEstimationConstraints(param)) + return False + + def addGroup(self, newg, gtype): + """Add a fit parameter group with the same fit parameters as an + existing group. + + This function is called when the user selects "ADD" in the + "constraints" combobox. + + :param int newg: New group number + :param int gtype: Group number whose parameters we want to copy + + """ + newparam = [] + # loop through parameters until we encounter group number `gtype` + for param in list(self.parameters): + paramgroup = int(float(str(self.parameters[param]['group']))) + # copy parameter names in group number `gtype` + if paramgroup == gtype: + # but replace `gtype` with `newg` + newparam.append(param.rstrip("0123456789") + "%d" % newg) + + xmin = self.parameters[param]['xmin'] + xmax = self.parameters[param]['xmax'] + + # Add new parameters (one table line per parameter) and configureLine each + # one by updating xmin and xmax to the same values as group `gtype` + line = len(list(self.parameters)) + for param in newparam: + self.newParameterLine(param, line) + line += 1 + for param in newparam: + self.configureLine(name=param, group=newg, xmin=xmin, xmax=xmax) + + def freeRestOfGroup(self, workparam): + """Set ``code`` to ``"FREE"`` for all fit parameters belonging to + the same group as ``workparam``. This is done when the entire group + of parameters was previously ignored and one of them has his code + set to something different than ``"IGNORE"``. + + :param workparam: Fit parameter name + """ + if workparam in self.parameters.keys(): + group = int(float(str(self.parameters[workparam]['group']))) + for param in self.parameters: + if param != workparam and\ + group == int(float(str(self.parameters[param]['group']))): + self.configureLine(name=param, + code='FREE', + cons1=0, + cons2=0, + val1='', + val2='') + + def getRelatedCandidates(self, workparam): + """If fit parameter ``workparam`` has a constraint that involves other + fit parameters, find possible candidates and try to guess which one + is the most likely. + + :param workparam: Fit parameter name + :return: (best_candidate, possible_candidates) tuple + :rtype: (str, list[str]) + """ + candidates = [] + for param_name in self.parameters: + if param_name != workparam: + # ignore parameters that are fixed by a constraint + if str(self.parameters[param_name]['code']) not in\ + ['IGNORE', 'FACTOR', 'DELTA', 'SUM']: + candidates.append(param_name) + # take the previous one (before code cell changed) if possible + if str(self.parameters[workparam]['relatedto']) in candidates: + best = str(self.parameters[workparam]['relatedto']) + return best, candidates + # take the first with same base name (after removing numbers) + for param_name in candidates: + basename = param_name.rstrip("0123456789") + try: + pos = workparam.index(basename) + if pos == 0: + best = param_name + return best, candidates + except ValueError: + pass + # take the first + return candidates[0], candidates + + def setReadOnly(self, parameter, fields): + """Make table cells read-only by setting it's flags and omitting + flag ``qt.Qt.ItemIsEditable`` + + :param parameter: Fit parameter names identifying the rows + :type parameter: str or list[str] + :param fields: Field names identifying the columns + :type fields: str or list[str] + """ + editflags = qt.Qt.ItemIsSelectable | qt.Qt.ItemIsEnabled + self.setField(parameter, fields, editflags) + + def setReadWrite(self, parameter, fields): + """Make table cells read-write by setting it's flags including + flag ``qt.Qt.ItemIsEditable`` + + :param parameter: Fit parameter names identifying the rows + :type parameter: str or list[str] + :param fields: Field names identifying the columns + :type fields: str or list[str] + """ + editflags = qt.Qt.ItemIsSelectable |\ + qt.Qt.ItemIsEnabled |\ + qt.Qt.ItemIsEditable + self.setField(parameter, fields, editflags) + + def setField(self, parameter, fields, edit_flags): + """Set text and flags in a table cell. + + :param parameter: Fit parameter names identifying the rows + :type parameter: str or list[str] + :param fields: Field names identifying the columns + :type fields: str or list[str] + :param edit_flags: Flag combination, e.g:: + + qt.Qt.ItemIsSelectable | qt.Qt.ItemIsEnabled | + qt.Qt.ItemIsEditable + """ + if isinstance(parameter, list) or \ + isinstance(parameter, tuple): + paramlist = parameter + else: + paramlist = [parameter] + if isinstance(fields, list) or \ + isinstance(fields, tuple): + fieldlist = fields + else: + fieldlist = [fields] + + # Set _configuring flag to ignore cellChanged signals in + # self.onCellChanged + _oldvalue = self.__configuring + self.__configuring = True + + # 2D loop through parameter list and field list + # to update their cells + for param in paramlist: + row = list(self.parameters.keys()).index(param) + for field in fieldlist: + col = self.columnIndexByField(field) + if field != 'code': + key = field + "_item" + item = self.item(row, col) + if item is None: + item = qt.QTableWidgetItem() + item.setText(self.parameters[param][field]) + self.setItem(row, col, item) + else: + item.setText(self.parameters[param][field]) + self.parameters[param][key] = item + item.setFlags(edit_flags) + + # Restore previous _configuring flag + self.__configuring = _oldvalue + + def configureLine(self, name, code=None, val1=None, val2=None, + sigma=None, estimation=None, fitresult=None, + group=None, xmin=None, xmax=None, relatedto=None, + cons1=None, cons2=None): + """This function updates values in a line of the table + + :param name: Name of the parameter (serves as unique identifier for + a line). + :param code: Constraint code *FREE, FIXED, POSITIVE, DELTA, FACTOR, + SUM, QUOTED, IGNORE* + :param val1: Constraint 1 (can be the index or name of another + parameter for code *DELTA, FACTOR, SUM*, or a min value + for code *QUOTED*) + :param val2: Constraint 2 + :param sigma: Standard deviation for a fit parameter + :param estimation: Estimated initial value for a fit parameter (used + as input to iterative fit) + :param fitresult: Final result of fit + :param group: Group number of a fit parameter (peak number when doing + multi-peak fitting, as each peak corresponds to a group + of several consecutive parameters) + :param xmin: + :param xmax: + :param relatedto: Index or name of another fit parameter + to which this parameter is related to (constraints) + :param cons1: similar meaning to ``val1``, but is always a number + :param cons2: similar meaning to ``val2``, but is always a number + :return: + """ + paramlist = list(self.parameters.keys()) + + if name not in self.parameters: + raise KeyError("'%s' is not in the parameter list" % name) + + # update code first, if specified + if code is not None: + code = str(code) + self.parameters[name]['code'] = code + # update combobox + index = self.parameters[name]['code_item'].findText(code) + self.parameters[name]['code_item'].setCurrentIndex(index) + else: + # set code to previous value, used later for setting val1 val2 + code = self.parameters[name]['code'] + + # val1 and sigma have special formats + if val1 is not None: + fmt = None if self.parameters[name]['code'] in\ + ['DELTA', 'FACTOR', 'SUM'] else "%8g" + self._updateField(name, "val1", val1, fmat=fmt) + + if sigma is not None: + self._updateField(name, "sigma", sigma, fmat="%6.3g") + + # other fields are formatted as "%8g" + keys_params = (("val2", val2), ("estimation", estimation), + ("fitresult", fitresult)) + for key, value in keys_params: + if value is not None: + self._updateField(name, key, value, fmat="%8g") + + # the rest of the parameters are treated as strings and don't need + # validation + keys_params = (("group", group), ("xmin", xmin), + ("xmax", xmax), ("relatedto", relatedto), + ("cons1", cons1), ("cons2", cons2)) + for key, value in keys_params: + if value is not None: + self.parameters[name][key] = str(value) + + # val1 and val2 have different meanings depending on the code + if code == 'QUOTED': + if val1 is not None: + self.parameters[name]['vmin'] = self.parameters[name]['val1'] + else: + self.parameters[name]['val1'] = self.parameters[name]['vmin'] + if val2 is not None: + self.parameters[name]['vmax'] = self.parameters[name]['val2'] + else: + self.parameters[name]['val2'] = self.parameters[name]['vmax'] + + # cons1 and cons2 are scalar representations of val1 and val2 + self.parameters[name]['cons1'] =\ + float_else_zero(self.parameters[name]['val1']) + self.parameters[name]['cons2'] =\ + float_else_zero(self.parameters[name]['val2']) + + # cons1, cons2 = min(val1, val2), max(val1, val2) + if self.parameters[name]['cons1'] > self.parameters[name]['cons2']: + self.parameters[name]['cons1'], self.parameters[name]['cons2'] =\ + self.parameters[name]['cons2'], self.parameters[name]['cons1'] + + elif code in ['DELTA', 'SUM', 'FACTOR']: + # For these codes, val1 is the fit parameter name on which the + # constraint depends + if val1 is not None and val1 in paramlist: + self.parameters[name]['relatedto'] = self.parameters[name]["val1"] + + elif val1 is not None: + # val1 could be the index of the fit parameter + try: + self.parameters[name]['relatedto'] = paramlist[int(val1)] + except ValueError: + self.parameters[name]['relatedto'] = self.parameters[name]["val1"] + + elif relatedto is not None: + # code changed, val1 not specified but relatedto specified: + # set val1 to relatedto (pre-fill best guess) + self.parameters[name]["val1"] = relatedto + + # update fields "delta", "sum" or "factor" + key = code.lower() + self.parameters[name][key] = self.parameters[name]["val2"] + + # FIXME: val1 is sometimes specified as an index rather than a param name + self.parameters[name]['val1'] = self.parameters[name]['relatedto'] + + # cons1 is the index of the fit parameter in the ordered dictionary + if self.parameters[name]['val1'] in paramlist: + self.parameters[name]['cons1'] =\ + paramlist.index(self.parameters[name]['val1']) + + # cons2 is the constraint value (factor, delta or sum) + try: + self.parameters[name]['cons2'] =\ + float(str(self.parameters[name]['val2'])) + except ValueError: + self.parameters[name]['cons2'] = 1.0 if code == "FACTOR" else 0.0 + + elif code in ['FREE', 'POSITIVE', 'IGNORE', 'FIXED']: + self.parameters[name]['val1'] = "" + self.parameters[name]['val2'] = "" + self.parameters[name]['cons1'] = 0 + self.parameters[name]['cons2'] = 0 + + self._updateCellRWFlags(name, code) + + def _updateField(self, name, field, value, fmat=None): + """Update field in ``self.parameters`` dictionary, if the new value + is valid. + + :param name: Fit parameter name + :param field: Field name + :param value: New value to assign + :type value: String + :param fmat: Format string (e.g. "%8g") to be applied if value represents + a scalar. If ``None``, format is not modified. If ``value`` is an + empty string, ``fmat`` is ignored. + """ + if value is not None: + oldvalue = self.parameters[name][field] + if fmat is not None: + newvalue = fmat % float(value) if value != "" else "" + else: + newvalue = value + self.parameters[name][field] = newvalue if\ + self.validate(name, field, oldvalue, newvalue) else\ + oldvalue + + def _updateCellRWFlags(self, name, code=None): + """Set read-only or read-write flags in a row, + depending on the constraint code + + :param name: Fit parameter name identifying the row + :param code: Constraint code, in `'FREE', 'POSITIVE', 'IGNORE',` + `'FIXED', 'FACTOR', 'DELTA', 'SUM', 'ADD'` + :return: + """ + if code in ['FREE', 'POSITIVE', 'IGNORE', 'FIXED']: + self.setReadWrite(name, 'estimation') + self.setReadOnly(name, ['fitresult', 'sigma', 'val1', 'val2']) + else: + self.setReadWrite(name, ['estimation', 'val1', 'val2']) + self.setReadOnly(name, ['fitresult', 'sigma']) + + def getEstimationConstraints(self, param): + """ + Return tuple ``(estimation, constraints)`` where ``estimation`` is the + value in the ``estimate`` field and ``constraints`` are the relevant + constraints according to the active code + """ + estimation = None + constraints = None + if param in self.parameters.keys(): + buf = str(self.parameters[param]['estimation']) + if len(buf): + estimation = float(buf) + else: + estimation = 0 + if str(self.parameters[param]['code']) in self.code_options: + code = self.code_options.index( + str(self.parameters[param]['code'])) + else: + code = str(self.parameters[param]['code']) + cons1 = self.parameters[param]['cons1'] + cons2 = self.parameters[param]['cons2'] + constraints = [code, cons1, cons2] + return estimation, constraints + + +def main(args): + from silx.math.fit import fittheories + from silx.math.fit import fitmanager + try: + from PyMca5 import PyMcaDataDir + except ImportError: + raise ImportError("This demo requires PyMca data. Install PyMca5.") + import numpy + import os + app = qt.QApplication(args) + tab = Parameters(paramlist=['Height', 'Position', 'FWHM']) + tab.showGrid() + tab.configureLine(name='Height', estimation='1234', group=0) + tab.configureLine(name='Position', code='FIXED', group=1) + tab.configureLine(name='FWHM', group=1) + + y = numpy.loadtxt(os.path.join(PyMcaDataDir.PYMCA_DATA_DIR, + "XRFSpectrum.mca")) # FIXME + + x = numpy.arange(len(y)) * 0.0502883 - 0.492773 + fit = fitmanager.FitManager() + fit.setdata(x=x, y=y, xmin=20, xmax=150) + + fit.loadtheories(fittheories) + + fit.settheory('ahypermet') + fit.configure(Yscaling=1., + PositiveFwhmFlag=True, + PositiveHeightAreaFlag=True, + FwhmPoints=16, + QuotedPositionFlag=1, + HypermetTails=1) + fit.setbackground('Linear') + fit.estimate() + fit.runfit() + tab.fillFromFit(fit.fit_results) + tab.show() + app.exec_() + +if __name__ == "__main__": + main(sys.argv) diff --git a/silx/gui/fit/__init__.py b/silx/gui/fit/__init__.py new file mode 100644 index 0000000..e4fd3ab --- /dev/null +++ b/silx/gui/fit/__init__.py @@ -0,0 +1,28 @@ +# 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__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "07/07/2016" + +from .FitWidget import FitWidget diff --git a/silx/gui/fit/setup.py b/silx/gui/fit/setup.py new file mode 100644 index 0000000..6672363 --- /dev/null +++ b/silx/gui/fit/setup.py @@ -0,0 +1,43 @@ +# 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__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "21/07/2016" + + +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('fit', parent_package, top_path) + config.add_subpackage('test') + + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + + setup(configuration=configuration) diff --git a/silx/gui/fit/test/__init__.py b/silx/gui/fit/test/__init__.py new file mode 100644 index 0000000..2236d64 --- /dev/null +++ b/silx/gui/fit/test/__init__.py @@ -0,0 +1,43 @@ +# 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. +# +# ###########################################################################*/ +import unittest + +from .testFitWidget import suite as testFitWidgetSuite +from .testFitConfig import suite as testFitConfigSuite +from .testBackgroundWidget import suite as testBackgroundWidgetSuite + + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "21/07/2016" + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTests( + [testFitWidgetSuite(), + testFitConfigSuite(), + testBackgroundWidgetSuite()]) + return test_suite diff --git a/silx/gui/fit/test/testBackgroundWidget.py b/silx/gui/fit/test/testBackgroundWidget.py new file mode 100644 index 0000000..2e366e4 --- /dev/null +++ b/silx/gui/fit/test/testBackgroundWidget.py @@ -0,0 +1,83 @@ +# 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. +# +# ###########################################################################*/ +import unittest + +from ...test.utils import TestCaseQt + +from .. import BackgroundWidget + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "05/12/2016" + + +class TestBackgroundWidget(TestCaseQt): + def setUp(self): + super(TestBackgroundWidget, self).setUp() + self.bgdialog = BackgroundWidget.BackgroundDialog() + self.bgdialog.setData(list([0, 1, 2, 3]), + list([0, 1, 4, 8])) + self.qWaitForWindowExposed(self.bgdialog) + + def tearDown(self): + del self.bgdialog + super(TestBackgroundWidget, self).tearDown() + + def testShow(self): + self.bgdialog.show() + self.bgdialog.hide() + + def testAccept(self): + self.bgdialog.accept() + self.assertTrue(self.bgdialog.result()) + + def testReject(self): + self.bgdialog.reject() + self.assertFalse(self.bgdialog.result()) + + def testDefaultOutput(self): + self.bgdialog.accept() + output = self.bgdialog.output + + for key in ["algorithm", "StripThreshold", "SnipWidth", + "StripIterations", "StripWidth", "SmoothingFlag", + "SmoothingWidth", "AnchorsFlag", "AnchorsList"]: + self.assertIn(key, output) + + self.assertFalse(output["AnchorsFlag"]) + self.assertEqual(output["StripWidth"], 1) + self.assertEqual(output["SmoothingFlag"], False) + self.assertEqual(output["SmoothingWidth"], 3) + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(TestBackgroundWidget)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/fit/test/testFitConfig.py b/silx/gui/fit/test/testFitConfig.py new file mode 100644 index 0000000..eea35cc --- /dev/null +++ b/silx/gui/fit/test/testFitConfig.py @@ -0,0 +1,95 @@ +# 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. +# +# ###########################################################################*/ +"""Basic tests for :class:`FitConfig`""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "05/12/2016" + +import unittest + +from ...test.utils import TestCaseQt +from .. import FitConfig + + +class TestFitConfig(TestCaseQt): + """Basic test for FitWidget""" + + def setUp(self): + super(TestFitConfig, self).setUp() + self.fit_config = FitConfig.getFitConfigDialog(modal=False) + self.qWaitForWindowExposed(self.fit_config) + + def tearDown(self): + del self.fit_config + super(TestFitConfig, self).tearDown() + + def testShow(self): + self.fit_config.show() + self.fit_config.hide() + + def testAccept(self): + self.fit_config.accept() + self.assertTrue(self.fit_config.result()) + + def testReject(self): + self.fit_config.reject() + self.assertFalse(self.fit_config.result()) + + def testDefaultOutput(self): + self.fit_config.accept() + output = self.fit_config.output + + for key in ["AutoFwhm", + "PositiveHeightAreaFlag", + "QuotedPositionFlag", + "PositiveFwhmFlag", + "SameFwhmFlag", + "QuotedEtaFlag", + "NoConstraintsFlag", + "FwhmPoints", + "Sensitivity", + "Yscaling", + "ForcePeakPresence", + "StripBackgroundFlag", + "StripWidth", + "StripIterations", + "StripThreshold", + "SmoothingFlag"]: + self.assertIn(key, output) + + self.assertTrue(output["AutoFwhm"]) + self.assertEqual(output["StripWidth"], 2) + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(TestFitConfig)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/fit/test/testFitWidget.py b/silx/gui/fit/test/testFitWidget.py new file mode 100644 index 0000000..d542fd0 --- /dev/null +++ b/silx/gui/fit/test/testFitWidget.py @@ -0,0 +1,135 @@ +# 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. +# +# ###########################################################################*/ +"""Basic tests for :class:`FitWidget`""" + +import unittest + +from ...test.utils import TestCaseQt + +from ... import qt +from .. import FitWidget + +from ....math.fit.fittheory import FitTheory +from ....math.fit.fitmanager import FitManager + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "05/12/2016" + + +class TestFitWidget(TestCaseQt): + """Basic test for FitWidget""" + + def setUp(self): + super(TestFitWidget, self).setUp() + self.fit_widget = FitWidget() + self.fit_widget.show() + self.qWaitForWindowExposed(self.fit_widget) + + def tearDown(self): + self.fit_widget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.fit_widget.close() + del self.fit_widget + super(TestFitWidget, self).tearDown() + + def testShow(self): + pass + + def testInteract(self): + self.mouseClick(self.fit_widget, qt.Qt.LeftButton) + self.keyClick(self.fit_widget, qt.Qt.Key_Enter) + self.qapp.processEvents() + + def testCustomConfigWidget(self): + class CustomConfigWidget(qt.QDialog): + def __init__(self): + qt.QDialog.__init__(self) + self.setModal(True) + self.ok = qt.QPushButton("ok", self) + self.ok.clicked.connect(self.accept) + cancel = qt.QPushButton("cancel", self) + cancel.clicked.connect(self.reject) + layout = qt.QVBoxLayout(self) + layout.addWidget(self.ok) + layout.addWidget(cancel) + self.output = {"hello": "world"} + + def fitfun(x, a, b): + return a * x + b + + x = list(range(0, 100)) + y = [fitfun(x_, 2, 3) for x_ in x] + + def conf(**kw): + return {"spam": "eggs", + "hello": "world!"} + + theory = FitTheory( + function=fitfun, + parameters=["a", "b"], + configure=conf) + + fitmngr = FitManager() + fitmngr.setdata(x, y) + fitmngr.addtheory("foo", theory) + fitmngr.addtheory("bar", theory) + fitmngr.addbgtheory("spam", theory) + + fw = FitWidget(fitmngr=fitmngr) + fw.associateConfigDialog("spam", CustomConfigWidget(), + theory_is_background=True) + fw.associateConfigDialog("foo", CustomConfigWidget()) + fw.show() + self.qWaitForWindowExposed(fw) + + fw.bgconfigdialogs["spam"].accept() + self.assertTrue(fw.bgconfigdialogs["spam"].result()) + + self.assertEqual(fw.bgconfigdialogs["spam"].output, + {"hello": "world"}) + + fw.bgconfigdialogs["spam"].reject() + self.assertFalse(fw.bgconfigdialogs["spam"].result()) + + fw.configdialogs["foo"].accept() + self.assertTrue(fw.configdialogs["foo"].result()) + + # todo: figure out how to click fw.configdialog.ok to close dialog + # open dialog + # self.mouseClick(fw.guiConfig.FunConfigureButton, qt.Qt.LeftButton) + # clove dialog + # self.mouseClick(fw.configdialogs["foo"].ok, qt.Qt.LeftButton) + # self.qapp.processEvents() + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(TestFitWidget)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/hdf5/Hdf5HeaderView.py b/silx/gui/hdf5/Hdf5HeaderView.py new file mode 100644 index 0000000..5912230 --- /dev/null +++ b/silx/gui/hdf5/Hdf5HeaderView.py @@ -0,0 +1,192 @@ +# 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__ = "08/11/2016" + + +from .. import qt + +QTVERSION = qt.qVersion() + + +class Hdf5HeaderView(qt.QHeaderView): + """ + Default HDF5 header + + Manage auto-resize and context menu to display/hide columns + """ + + def __init__(self, orientation, parent=None): + """ + Constructor + + :param orientation qt.Qt.Orientation: Orientation of the header + :param parent qt.QWidget: Parent of the widget + """ + super(Hdf5HeaderView, self).__init__(orientation, parent) + self.setContextMenuPolicy(qt.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self.__createContextMenu) + + # default initialization done by QTreeView for it's own header + if QTVERSION < "5.0": + self.setClickable(True) + self.setMovable(True) + else: + self.setSectionsClickable(True) + self.setSectionsMovable(True) + self.setDefaultAlignment(qt.Qt.AlignLeft | qt.Qt.AlignVCenter) + self.setStretchLastSection(True) + + self.__auto_resize = True + self.__hide_columns_popup = True + + def setModel(self, model): + """Override model to configure view when a model is expected + + `qt.QHeaderView.setResizeMode` expect already existing columns + to work. + + :param model qt.QAbstractItemModel: A model + """ + super(Hdf5HeaderView, self).setModel(model) + self.__updateAutoResize() + + def __updateAutoResize(self): + """Update the view according to the state of the auto-resize""" + if QTVERSION < "5.0": + setResizeMode = self.setResizeMode + else: + setResizeMode = self.setSectionResizeMode + + if self.__auto_resize: + setResizeMode(0, qt.QHeaderView.ResizeToContents) + setResizeMode(1, qt.QHeaderView.ResizeToContents) + setResizeMode(2, qt.QHeaderView.ResizeToContents) + setResizeMode(3, qt.QHeaderView.Interactive) + setResizeMode(4, qt.QHeaderView.Interactive) + setResizeMode(5, qt.QHeaderView.ResizeToContents) + else: + setResizeMode(0, qt.QHeaderView.Interactive) + setResizeMode(1, qt.QHeaderView.Interactive) + setResizeMode(2, qt.QHeaderView.Interactive) + setResizeMode(3, qt.QHeaderView.Interactive) + setResizeMode(4, qt.QHeaderView.Interactive) + setResizeMode(5, qt.QHeaderView.Interactive) + + def setAutoResizeColumns(self, autoResize): + """Enable/disable auto-resize. When auto-resized, the header take care + of the content of the column to set fixed size of some of them, or to + auto fix the size according to the content. + + :param autoResize bool: Enable/disable auto-resize + """ + if self.__auto_resize == autoResize: + return + self.__auto_resize = autoResize + self.__updateAutoResize() + + def hasAutoResizeColumns(self): + """Is auto-resize enabled. + + :rtype: bool + """ + return self.__auto_resize + + autoResizeColumns = qt.Property(bool, hasAutoResizeColumns, setAutoResizeColumns) + """Property to enable/disable auto-resize.""" + + def setEnableHideColumnsPopup(self, enablePopup): + """Enable/disable a popup to allow to hide/show each column of the + model. + + :param bool enablePopup: Enable/disable popup to hide/show columns + """ + self.__hide_columns_popup = enablePopup + + def hasHideColumnsPopup(self): + """Is popup to hide/show columns is enabled. + + :rtype: bool + """ + return self.__hide_columns_popup + + enableHideColumnsPopup = qt.Property(bool, hasHideColumnsPopup, setAutoResizeColumns) + """Property to enable/disable popup allowing to hide/show columns.""" + + def __genHideSectionEvent(self, column): + """Generate a callback which change the column visibility according to + the event parameter + + :param int column: logical id of the column + :rtype: callable + """ + return lambda checked: self.setSectionHidden(column, not checked) + + def __createContextMenu(self, pos): + """Callback to create and display a context menu + + :param pos qt.QPoint: Requested position for the context menu + """ + if not self.__hide_columns_popup: + return + + model = self.model() + if model.columnCount() > 1: + menu = qt.QMenu(self) + menu.setTitle("Display/hide columns") + + action = qt.QAction("Display/hide column", self) + action.setEnabled(False) + menu.addAction(action) + + for column in range(model.columnCount()): + if column == 0: + # skip the main column + continue + text = model.headerData(column, qt.Qt.Horizontal, qt.Qt.DisplayRole) + action = qt.QAction("%s displayed" % text, self) + action.setCheckable(True) + action.setChecked(not self.isSectionHidden(column)) + action.toggled.connect(self.__genHideSectionEvent(column)) + menu.addAction(action) + + menu.popup(self.viewport().mapToGlobal(pos)) + + def setSections(self, logicalIndexes): + """ + Defines order of visible sections by logical indexes. + + Use `Hdf5TreeModel.NAME_COLUMN` to set the list. + + :param list logicalIndexes: List of logical indexes to display + """ + for pos, column_id in enumerate(logicalIndexes): + current_pos = self.visualIndex(column_id) + self.moveSection(current_pos, pos) + self.setSectionHidden(column_id, False) + for column_id in set(range(self.model().columnCount())) - set(logicalIndexes): + self.setSectionHidden(column_id, True) diff --git a/silx/gui/hdf5/Hdf5Item.py b/silx/gui/hdf5/Hdf5Item.py new file mode 100644 index 0000000..40793a4 --- /dev/null +++ b/silx/gui/hdf5/Hdf5Item.py @@ -0,0 +1,421 @@ +# 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__ = "20/01/2017" + + +import numpy +import logging +import collections +from .. import qt +from .. import icons +from . import _utils +from .Hdf5Node import Hdf5Node +import silx.io.utils +from silx.gui.data.TextFormatter import TextFormatter + +_logger = logging.getLogger(__name__) + +try: + import h5py +except ImportError as e: + _logger.error("Module %s requires h5py", __name__) + raise e + +_formatter = TextFormatter() + + +class Hdf5Item(Hdf5Node): + """Subclass of :class:`qt.QStandardItem` to represent an HDF5-like + item (dataset, file, group or link) as an element of a HDF5-like + tree structure. + """ + + def __init__(self, text, obj, parent, key=None, h5pyClass=None, isBroken=False, populateAll=False): + """ + :param str text: text displayed + :param object obj: Pointer to h5py data. See the `obj` attribute. + """ + self.__obj = obj + self.__key = key + self.__h5pyClass = h5pyClass + self.__isBroken = isBroken + self.__error = None + self.__text = text + Hdf5Node.__init__(self, parent, populateAll=populateAll) + + @property + def obj(self): + if self.__key: + self.__initH5pyObject() + return self.__obj + + @property + def basename(self): + return self.__text + + @property + def h5pyClass(self): + """Returns the class of the stored object. + + When the object is in lazy loading, this method should be able to + return the type of the futrue loaded object. It allows to delay the + real load of the object. + + :rtype: h5py.File or h5py.Dataset or h5py.Group + """ + if self.__h5pyClass is None: + self.__h5pyClass = silx.io.utils.get_h5py_class(self.obj) + return self.__h5pyClass + + def isGroupObj(self): + """Returns true if the stored HDF5 object is a group (contains sub + groups or datasets). + + :rtype: bool + """ + return issubclass(self.h5pyClass, h5py.Group) + + def isBrokenObj(self): + """Returns true if the stored HDF5 object is broken. + + The stored object is then an h5py link (external or not) which point + to nowhere (tbhe external file is not here, the expected dataset is + still not on the file...) + + :rtype: bool + """ + return self.__isBroken + + def _expectedChildCount(self): + if self.isGroupObj(): + return len(self.obj) + return 0 + + def __initH5pyObject(self): + """Lazy load of the HDF5 node. It is reached from the parent node + with the key of the node.""" + parent_obj = self.parent.obj + + try: + obj = parent_obj.get(self.__key) + except Exception as e: + _logger.debug("Internal h5py error", exc_info=True) + try: + self.__obj = parent_obj.get(self.__key, getlink=True) + except Exception: + self.__obj = None + self.__error = e.args[0] + self.__isBroken = True + else: + if obj is None: + # that's a broken link + self.__obj = parent_obj.get(self.__key, getlink=True) + + # TODO monkey-patch file (ask that in h5py for consistency) + if not hasattr(self.__obj, "name"): + parent_name = parent_obj.name + if parent_name == "/": + self.__obj.name = "/" + self.__key + else: + self.__obj.name = parent_name + "/" + self.__key + # TODO monkey-patch file (ask that in h5py for consistency) + if not hasattr(self.__obj, "file"): + self.__obj.file = parent_obj.file + + if isinstance(self.__obj, h5py.ExternalLink): + message = "External link broken. Path %s::%s does not exist" % (self.__obj.filename, self.__obj.path) + elif isinstance(self.__obj, h5py.SoftLink): + message = "Soft link broken. Path %s does not exist" % (self.__obj.path) + else: + name = self.obj.__class__.__name__.split(".")[-1].capitalize() + message = "%s broken" % (name) + self.__error = message + self.__isBroken = True + else: + self.__obj = obj + + self.__key = None + + def _populateChild(self, populateAll=False): + if self.isGroupObj(): + for name in self.obj: + try: + class_ = self.obj.get(name, getclass=True) + has_error = False + except Exception as e: + _logger.error("Internal h5py error", exc_info=True) + try: + class_ = self.obj.get(name, getclass=True, getlink=True) + except Exception as e: + class_ = h5py.HardLink + has_error = True + item = Hdf5Item(text=name, obj=None, parent=self, key=name, h5pyClass=class_, isBroken=has_error) + self.appendChild(item) + + def hasChildren(self): + """Retuens true of this node have chrild. + + :rtype: bool + """ + if not self.isGroupObj(): + return False + return Hdf5Node.hasChildren(self) + + def _getDefaultIcon(self): + """Returns the icon displayed by the main column. + + :rtype: qt.QIcon + """ + style = qt.QApplication.style() + if self.__isBroken: + icon = style.standardIcon(qt.QStyle.SP_MessageBoxCritical) + return icon + class_ = self.h5pyClass + if issubclass(class_, h5py.File): + return style.standardIcon(qt.QStyle.SP_FileIcon) + elif issubclass(class_, h5py.Group): + return style.standardIcon(qt.QStyle.SP_DirIcon) + elif issubclass(class_, h5py.SoftLink): + return style.standardIcon(qt.QStyle.SP_DirLinkIcon) + elif issubclass(class_, h5py.ExternalLink): + return style.standardIcon(qt.QStyle.SP_FileLinkIcon) + elif issubclass(class_, h5py.Dataset): + if len(self.obj.shape) < 4: + name = "item-%ddim" % len(self.obj.shape) + else: + name = "item-ndim" + if str(self.obj.dtype) == "object": + name = "item-object" + icon = icons.getQIcon(name) + return icon + return None + + def _humanReadableShape(self, dataset): + if dataset.shape == tuple(): + return "scalar" + shape = [str(i) for i in dataset.shape] + text = u" \u00D7 ".join(shape) + return text + + def _humanReadableValue(self, dataset): + if dataset.shape == tuple(): + numpy_object = dataset[()] + text = _formatter.toString(numpy_object) + else: + if dataset.size < 5 and dataset.compression is None: + numpy_object = dataset[0:5] + text = _formatter.toString(numpy_object) + else: + dimension = len(dataset.shape) + if dataset.compression is not None: + text = "Compressed %dD data" % dimension + else: + text = "%dD data" % dimension + return text + + def _humanReadableDType(self, dtype, full=False): + if dtype.type == numpy.string_: + text = "string" + elif dtype.type == numpy.unicode_: + text = "string" + elif dtype.type == numpy.object_: + text = "object" + elif dtype.type == numpy.bool_: + text = "bool" + elif dtype.type == numpy.void: + if dtype.fields is None: + text = "raw" + else: + if not full: + text = "compound" + else: + compound = [d[0] for d in dtype.fields.values()] + compound = [self._humanReadableDType(d) for d in compound] + text = "compound(%s)" % ", ".join(compound) + else: + text = str(dtype) + return text + + def _humanReadableType(self, dataset, full=False): + return self._humanReadableDType(dataset.dtype, full) + + def _setTooltipAttributes(self, attributeDict): + """ + Add key/value attributes that will be displayed in the item tooltip + + :param Dict[str,str] attributeDict: Key/value attributes + """ + if issubclass(self.h5pyClass, h5py.Dataset): + attributeDict["Title"] = "HDF5 Dataset" + attributeDict["Name"] = self.basename + attributeDict["Path"] = self.obj.name + attributeDict["Shape"] = self._humanReadableShape(self.obj) + attributeDict["Value"] = self._humanReadableValue(self.obj) + attributeDict["Data type"] = self._humanReadableType(self.obj, full=True) + elif issubclass(self.h5pyClass, h5py.Group): + attributeDict["Title"] = "HDF5 Group" + attributeDict["Name"] = self.basename + attributeDict["Path"] = self.obj.name + elif issubclass(self.h5pyClass, h5py.File): + attributeDict["Title"] = "HDF5 File" + attributeDict["Name"] = self.basename + attributeDict["Path"] = "/" + elif isinstance(self.obj, h5py.ExternalLink): + attributeDict["Title"] = "HDF5 External Link" + attributeDict["Name"] = self.basename + attributeDict["Path"] = self.obj.name + attributeDict["Linked path"] = self.obj.path + attributeDict["Linked file"] = self.obj.filename + elif isinstance(self.obj, h5py.SoftLink): + attributeDict["Title"] = "HDF5 Soft Link" + attributeDict["Name"] = self.basename + attributeDict["Path"] = self.obj.name + attributeDict["Linked path"] = self.obj.path + else: + pass + + def _getDefaultTooltip(self): + """Returns the default tooltip + + :rtype: str + """ + if self.__error is not None: + self.obj # lazy loading of the object + return self.__error + + attrs = collections.OrderedDict() + self._setTooltipAttributes(attrs) + + title = attrs.pop("Title", None) + if len(attrs) > 0: + tooltip = _utils.htmlFromDict(attrs, title=title) + else: + tooltip = "" + + return tooltip + + def dataName(self, role): + """Data for the name column""" + if role == qt.Qt.TextAlignmentRole: + return qt.Qt.AlignTop | qt.Qt.AlignLeft + if role == qt.Qt.DisplayRole: + return self.__text + if role == qt.Qt.DecorationRole: + return self._getDefaultIcon() + if role == qt.Qt.ToolTipRole: + return self._getDefaultTooltip() + return None + + def dataType(self, role): + """Data for the type column""" + if role == qt.Qt.DecorationRole: + return None + if role == qt.Qt.TextAlignmentRole: + return qt.Qt.AlignTop | qt.Qt.AlignLeft + if role == qt.Qt.DisplayRole: + if self.__error is not None: + return "" + class_ = self.h5pyClass + if issubclass(class_, h5py.Dataset): + text = self._humanReadableType(self.obj) + else: + text = "" + return text + + return None + + def dataShape(self, role): + """Data for the shape column""" + if role == qt.Qt.DecorationRole: + return None + if role == qt.Qt.TextAlignmentRole: + return qt.Qt.AlignTop | qt.Qt.AlignLeft + if role == qt.Qt.DisplayRole: + if self.__error is not None: + return "" + class_ = self.h5pyClass + if not issubclass(class_, h5py.Dataset): + return "" + return self._humanReadableShape(self.obj) + return None + + def dataValue(self, role): + """Data for the value column""" + if role == qt.Qt.DecorationRole: + return None + if role == qt.Qt.TextAlignmentRole: + return qt.Qt.AlignTop | qt.Qt.AlignLeft + if role == qt.Qt.DisplayRole: + if self.__error is not None: + return "" + if not issubclass(self.h5pyClass, h5py.Dataset): + return "" + return self._humanReadableValue(self.obj) + return None + + def dataDescription(self, role): + """Data for the description column""" + if role == qt.Qt.DecorationRole: + return None + if role == qt.Qt.TextAlignmentRole: + return qt.Qt.AlignTop | qt.Qt.AlignLeft + if role == qt.Qt.DisplayRole: + if self.__isBroken: + self.obj # lazy loading of the object + return self.__error + if "desc" in self.obj.attrs: + text = self.obj.attrs["desc"] + else: + return "" + return text + if role == qt.Qt.ToolTipRole: + if self.__error is not None: + self.obj # lazy loading of the object + self.__initH5pyObject() + return self.__error + if "desc" in self.obj.attrs: + text = self.obj.attrs["desc"] + else: + return "" + return "Description: %s" % text + return None + + def dataNode(self, role): + """Data for the node column""" + if role == qt.Qt.DecorationRole: + return None + if role == qt.Qt.TextAlignmentRole: + return qt.Qt.AlignTop | qt.Qt.AlignLeft + if role == qt.Qt.DisplayRole: + class_ = self.h5pyClass + text = class_.__name__.split(".")[-1] + return text + if role == qt.Qt.ToolTipRole: + class_ = self.h5pyClass + return "Class name: %s" % self.__class__ + return None diff --git a/silx/gui/hdf5/Hdf5LoadingItem.py b/silx/gui/hdf5/Hdf5LoadingItem.py new file mode 100644 index 0000000..4467366 --- /dev/null +++ b/silx/gui/hdf5/Hdf5LoadingItem.py @@ -0,0 +1,68 @@ +# 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__ = "23/09/2016" + + +from .. import qt +from .Hdf5Node import Hdf5Node + + +class Hdf5LoadingItem(Hdf5Node): + """Item displayed when an Hdf5Node is loading. + + At the end of the loading this item is replaced by the loaded one. + """ + + def __init__(self, text, parent, animatedIcon): + """Constructor""" + Hdf5Node.__init__(self, parent) + self.__text = text + self.__animatedIcon = animatedIcon + self.__animatedIcon.register(self) + + @property + def obj(self): + return None + + def dataName(self, role): + if role == qt.Qt.DecorationRole: + return self.__animatedIcon.currentIcon() + if role == qt.Qt.TextAlignmentRole: + return qt.Qt.AlignTop | qt.Qt.AlignLeft + if role == qt.Qt.DisplayRole: + return self.__text + return None + + def dataDescription(self, role): + if role == qt.Qt.DecorationRole: + return None + if role == qt.Qt.TextAlignmentRole: + return qt.Qt.AlignTop | qt.Qt.AlignLeft + if role == qt.Qt.DisplayRole: + return "Loading..." + return None diff --git a/silx/gui/hdf5/Hdf5Node.py b/silx/gui/hdf5/Hdf5Node.py new file mode 100644 index 0000000..31bb097 --- /dev/null +++ b/silx/gui/hdf5/Hdf5Node.py @@ -0,0 +1,210 @@ +# 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__ = "23/09/2016" + + +class Hdf5Node(object): + """Abstract tree node + + It provides link to the childs and to the parents, and a link to an + external object. + """ + def __init__(self, parent=None, populateAll=False): + """ + Constructor + + :param Hdf5Node parent: Parent of the node, if exists, else None + :param bool populateAll: If true, populate all the tree node. Else + everything is lazy loaded. + """ + self.__child = None + self.__parent = parent + if populateAll: + self.__child = [] + self._populateChild(populateAll=True) + + @property + def parent(self): + """Parent of the n |