diff options
author | Picca Frédéric-Emmanuel <picca@debian.org> | 2017-10-07 07:59:01 +0200 |
---|---|---|
committer | Picca Frédéric-Emmanuel <picca@debian.org> | 2017-10-07 07:59:01 +0200 |
commit | bfa4dba15485b4192f8bbe13345e9658c97ecf76 (patch) | |
tree | fb9c6e5860881fbde902f7cbdbd41dc4a3a9fb5d /silx | |
parent | f7bdc2acff3c13a6d632c28c4569690ab106eed7 (diff) |
New upstream version 0.6.0+dfsg
Diffstat (limited to 'silx')
438 files changed, 129883 insertions, 75418 deletions
diff --git a/silx/__init__.py b/silx/__init__.py index cfb306b..8dab7e1 100644 --- a/silx/__init__.py +++ b/silx/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2015-2016 European Synchrotron Radiation Facility +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -32,7 +32,10 @@ __date__ = "23/05/2016" import os as _os import logging as _logging -_logging.basicConfig() # Make sure logging is initialised + +# Attach a do nothing logging handler for silx +_logging.getLogger(__name__).addHandler(_logging.NullHandler()) + project = _os.path.basename(_os.path.dirname(_os.path.abspath(__file__))) diff --git a/silx/__main__.py b/silx/__main__.py index 4c05ef3..8323b03 100644 --- a/silx/__main__.py +++ b/silx/__main__.py @@ -2,7 +2,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2016 European Synchrotron Radiation Facility +# 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 @@ -30,9 +30,9 @@ Your environment should provide a command `silx`. You can reach help with `silx --help`, and check the version with `silx --version`. """ -__authors__ = ["V. Valls"] +__authors__ = ["V. Valls", "P. Knobel"] __license__ = "MIT" -__date__ = "18/04/2017" +__date__ = "29/06/2017" import logging @@ -46,8 +46,8 @@ 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. + This function is referenced in the setup.py file, to create a + launcher script generated by setuptools. :rtype: int :returns: The execution status @@ -56,6 +56,12 @@ def main(): launcher.add_command("view", module_name="silx.app.view", description="Browse a data file with a GUI") + launcher.add_command("convert", + module_name="silx.app.convert", + description="Convert and concatenate files into a HDF5 file") + launcher.add_command("test", + module_name="silx.app.test_", + description="Launch silx unittest") status = launcher.execute(sys.argv) return status diff --git a/silx/app/convert.py b/silx/app/convert.py new file mode 100644 index 0000000..a092ec1 --- /dev/null +++ b/silx/app/convert.py @@ -0,0 +1,283 @@ +# 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. +# +# ############################################################################*/ +"""Convert silx supported data files into HDF5 files""" + +import ast +import sys +import os +import argparse +from glob import glob +import logging +import numpy +import silx + + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "12/09/2017" + + +_logger = logging.getLogger(__name__) +"""Module logger""" + + +def main(argv): + """ + Main function to launch the converter as an application + + :param argv: Command line arguments + :returns: exit status + """ + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + 'input_files', + nargs="+", + help='Input files (EDF, SPEC)') + parser.add_argument( + '-o', '--output-uri', + nargs="?", + help='Output file (HDF5). If omitted, it will be the ' + 'concatenated input file names, with a ".h5" suffix added.' + ' An URI can be provided to write the data into a specific ' + 'group in the output file: /path/to/file::/path/to/group') + parser.add_argument( + '-m', '--mode', + default="w-", + help='Write mode: "r+" (read/write, file must exist), ' + '"w" (write, existing file is lost), ' + '"w-" (write, fail if file exists) or ' + '"a" (read/write if exists, create otherwise)') + parser.add_argument( + '--no-root-group', + action="store_true", + help='This option disables the default behavior of creating a ' + 'root group (entry) for each file to be converted. When ' + 'merging multiple input files, this can cause conflicts ' + 'when datasets have the same name (see --overwrite-data).') + parser.add_argument( + '--overwrite-data', + action="store_true", + help='If the output path exists and an input dataset has the same' + ' name as an existing output dataset, overwrite the output ' + 'dataset (in modes "r+" or "a").') + parser.add_argument( + '--min-size', + type=int, + default=500, + help='Minimum number of elements required to be in a dataset to ' + 'apply compression or chunking (default 500).') + parser.add_argument( + '--chunks', + nargs="?", + const="auto", + help='Chunk shape. Provide an argument that evaluates as a python ' + 'tuple (e.g. "(1024, 768)"). If this option is provided without ' + 'specifying an argument, the h5py library will guess a chunk for ' + 'you. Note that if you specify an explicit chunking shape, it ' + 'will be applied identically to all datasets with a large enough ' + 'size (see --min-size). ') + parser.add_argument( + '--compression', + nargs="?", + const="gzip", + help='Compression filter. By default, the datasets in the output ' + 'file are not compressed. If this option is specified without ' + 'argument, the GZIP compression is used. Additional compression ' + 'filters may be available, depending on your HDF5 installation.') + + def check_gzip_compression_opts(value): + ivalue = int(value) + if ivalue < 0 or ivalue > 9: + raise argparse.ArgumentTypeError( + "--compression-opts must be an int from 0 to 9") + return ivalue + + parser.add_argument( + '--compression-opts', + type=check_gzip_compression_opts, + help='Compression options. For "gzip", this may be an integer from ' + '0 to 9, with a default of 4. This is only supported for GZIP.') + parser.add_argument( + '--shuffle', + action="store_true", + help='Enables the byte shuffle filter, may improve the compression ' + 'ratio for block oriented compressors like GZIP or LZF.') + parser.add_argument( + '--fletcher32', + action="store_true", + help='Adds a checksum to each chunk to detect data corruption.') + parser.add_argument( + '--debug', + action="store_true", + default=False, + help='Set logging system in debug mode') + + options = parser.parse_args(argv[1:]) + + # some shells (windows) don't interpret wildcard characters (*, ?, []) + old_input_list = list(options.input_files) + options.input_files = [] + for fname in old_input_list: + globbed_files = glob(fname) + if not globbed_files: + # no files found, keep the name as it is, to raise an error later + options.input_files += [fname] + else: + options.input_files += globbed_files + old_input_list = None + + if options.debug: + logging.root.setLevel(logging.DEBUG) + + # Import most of the things here to be sure to use the right logging level + try: + # it should be loaded before h5py + import hdf5plugin # noqa + except ImportError: + _logger.debug("Backtrace", exc_info=True) + hdf5plugin = None + + try: + import h5py + from silx.io.convert import write_to_h5 + except ImportError: + _logger.debug("Backtrace", exc_info=True) + h5py = None + write_to_h5 = None + + if h5py is None: + message = "Module 'h5py' is not installed but is mandatory."\ + + " You can install it using \"pip install h5py\"." + _logger.error(message) + return -1 + + if hdf5plugin is None: + message = "Module 'hdf5plugin' is not installed. It supports additional hdf5"\ + + " compressions. You can install it using \"pip install hdf5plugin\"." + _logger.debug(message) + + # Test that the output path is writeable + if options.output_uri is None: + input_basenames = [os.path.basename(name) for name in options.input_files] + output_name = ''.join(input_basenames) + ".h5" + _logger.info("No output file specified, using %s", output_name) + hdf5_path = "/" + else: + if "::" in options.output_uri: + output_name, hdf5_path = options.output_uri.split("::") + else: + output_name, hdf5_path = options.output_uri, "/" + + if os.path.isfile(output_name): + if options.mode == "w-": + _logger.error("Output file %s exists and mode is 'w-'" + " (write, file must not exist). Aborting.", + output_name) + return -1 + elif not os.access(output_name, os.W_OK): + _logger.error("Output file %s exists and is not writeable.", + output_name) + return -1 + elif options.mode == "w": + _logger.info("Output file %s exists and mode is 'w'. " + "Overwriting existing file.", output_name) + elif options.mode in ["a", "r+"]: + _logger.info("Appending data to existing file %s.", + output_name) + else: + if options.mode == "r+": + _logger.error("Output file %s does not exist and mode is 'r+'" + " (append, file must exist). Aborting.", + output_name) + return -1 + else: + _logger.info("Creating new output file %s.", + output_name) + + # Test that all input files exist and are readable + bad_input = False + for fname in options.input_files: + if not os.access(fname, os.R_OK): + _logger.error("Cannot read input file %s.", + fname) + bad_input = True + if bad_input: + _logger.error("Aborting.") + return -1 + + # create_dataset special args + create_dataset_args = {} + if options.chunks is not None: + if options.chunks.lower() in ["auto", "true"]: + create_dataset_args["chunks"] = True + else: + try: + chunks = ast.literal_eval(options.chunks) + except (ValueError, SyntaxError): + _logger.error("Invalid --chunks argument %s", options.chunks) + return -1 + if not isinstance(chunks, (tuple, list)): + _logger.error("--chunks argument str does not evaluate to a tuple") + return -1 + else: + nitems = numpy.prod(chunks) + nbytes = nitems * 8 + if nbytes > 10**6: + _logger.warning("Requested chunk size might be larger than" + " the default 1MB chunk cache, for float64" + " data. This can dramatically affect I/O " + "performances.") + create_dataset_args["chunks"] = chunks + + if options.compression is not None: + create_dataset_args["compression"] = options.compression + + if options.compression_opts is not None: + create_dataset_args["compression_opts"] = options.compression_opts + + if options.shuffle: + create_dataset_args["shuffle"] = True + + if options.fletcher32: + create_dataset_args["fletcher32"] = True + + with h5py.File(output_name, mode=options.mode) as h5f: + for input_name in options.input_files: + hdf5_path_for_file = hdf5_path + if not options.no_root_group: + hdf5_path_for_file = hdf5_path.rstrip("/") + "/" + os.path.basename(input_name) + write_to_h5(input_name, h5f, + h5path=hdf5_path_for_file, + overwrite_data=options.overwrite_data, + create_dataset_args=create_dataset_args, + min_size=options.min_size) + + # append the convert command to the creator attribute, for NeXus files + creator = h5f[hdf5_path_for_file].attrs.get("creator", b"").decode() + convert_command = " ".join(argv) + if convert_command not in creator: + h5f[hdf5_path_for_file].attrs["creator"] = \ + numpy.string_(creator + "; convert command: %s" % " ".join(argv)) + + return 0 diff --git a/silx/app/qtutils.py b/silx/app/qtutils.py new file mode 100644 index 0000000..4c29c84 --- /dev/null +++ b/silx/app/qtutils.py @@ -0,0 +1,243 @@ +# 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. +# +# ############################################################################*/ +"""Qt utils for Silx applications""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "22/09/2017" + +import sys + +try: + # it should be loaded before h5py + import hdf5plugin # noqa +except ImportError: + hdf5plugin = None + +try: + import h5py +except ImportError: + h5py = None + +try: + import fabio +except ImportError: + fabio = None + +from silx.gui import qt +from silx.gui import icons + +_LICENSE_TEMPLATE = """<p align="center"> +<b>Copyright (C) {year} European Synchrotron Radiation Facility</b> +</p> + +<p align="justify"> +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: +</p> + +<p align="justify"> +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +</p> + +<p align="justify"> +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. +</p> +""" + + +class About(qt.QDialog): + """ + Util dialog to display an common about box for all the silx GUIs. + """ + + def __init__(self, parent=None): + """ + :param files_: List of HDF5 or Spec files (pathes or + :class:`silx.io.spech5.SpecH5` or :class:`h5py.File` + instances) + """ + super(About, self).__init__(parent) + self.__createLayout() + self.setSizePolicy(qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed) + self.setModal(True) + self.setApplicationName(None) + + def __createLayout(self): + layout = qt.QVBoxLayout(self) + layout.setContentsMargins(24, 15, 24, 20) + layout.setSpacing(8) + + self.__label = qt.QLabel(self) + self.__label.setWordWrap(True) + flags = self.__label.textInteractionFlags() + flags = flags | qt.Qt.TextSelectableByKeyboard + flags = flags | qt.Qt.TextSelectableByMouse + self.__label.setTextInteractionFlags(flags) + self.__label.setOpenExternalLinks(True) + self.__label.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Preferred) + + licenseButton = qt.QPushButton(self) + licenseButton.setText("License...") + licenseButton.clicked.connect(self.__displayLicense) + licenseButton.setAutoDefault(False) + + self.__options = qt.QDialogButtonBox() + self.__options.addButton(licenseButton, qt.QDialogButtonBox.ActionRole) + okButton = self.__options.addButton(qt.QDialogButtonBox.Ok) + okButton.setDefault(True) + okButton.clicked.connect(self.accept) + + layout.addWidget(self.__label) + layout.addWidget(self.__options) + layout.setStretch(0, 100) + layout.setStretch(1, 0) + + def getHtmlLicense(self): + """Returns the text license in HTML format. + + :rtype: str + """ + from silx._version import __date__ as date + year = date.split("/")[2] + info = dict( + year=year + ) + textLicense = _LICENSE_TEMPLATE.format(**info) + return textLicense + + def __displayLicense(self): + """Displays the license used by silx.""" + text = self.getHtmlLicense() + licenseDialog = qt.QMessageBox(self) + licenseDialog.setWindowTitle("License") + licenseDialog.setText(text) + licenseDialog.exec_() + + def setApplicationName(self, name): + self.__applicationName = name + if name is None: + self.setWindowTitle("About") + else: + self.setWindowTitle("About %s" % name) + self.__updateText() + + @staticmethod + def __formatOptionalLibraries(name, isAvailable): + """Utils to format availability of features""" + 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 + + def __updateText(self): + """Update the content of the dialog according to the settings.""" + import silx._version + + message = """<table> + <tr><td width="50%" align="center" valign="middle"> + <img src="{silx_image_path}" width="100" /> + </td><td width="50%" align="center" valign="middle"> + <b>{application_name}</b> + <br /> + <br />{silx_version} + <br /> + <br /><a href="{project_url}">Upstream project on GitHub</a> + </td></tr> + </table> + <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> + Copyright (C) <a href="{esrf_url}">European Synchrotron Radiation Facility</a> + </p> + """ + optional_lib = [] + optional_lib.append(self.__formatOptionalLibraries("FabIO", fabio is not None)) + optional_lib.append(self.__formatOptionalLibraries("H5py", h5py is not None)) + optional_lib.append(self.__formatOptionalLibraries("hdf5plugin", hdf5plugin is not None)) + + # Access to the logo in SVG or PNG + logo = icons.getQFile("../logo/silx") + + info = dict( + application_name=self.__applicationName, + 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), + silx_image_path=logo.fileName() + ) + + self.__label.setText(message.format(**info)) + self.__updateSize() + + def __updateSize(self): + """Force the size to a QMessageBox like size.""" + screenSize = qt.QApplication.desktop().availableGeometry(qt.QCursor.pos()).size() + hardLimit = min(screenSize.width() - 480, 1000) + if screenSize.width() <= 1024: + hardLimit = screenSize.width() + softLimit = min(screenSize.width() / 2, 420) + + layoutMinimumSize = self.layout().totalMinimumSize() + width = layoutMinimumSize.width() + if width > softLimit: + width = softLimit + if width > hardLimit: + width = hardLimit + + height = layoutMinimumSize.height() + self.setFixedSize(width, height) + + @staticmethod + def about(parent, applicationName): + """Displays a silx about box with title and text text. + + :param qt.QWidget parent: The parent widget + :param str title: The title of the dialog + :param str applicationName: The content of the dialog + """ + dialog = About(parent) + dialog.setApplicationName(applicationName) + dialog.exec_() diff --git a/silx/app/test/__init__.py b/silx/app/test/__init__.py index 54241dc..0c22386 100644 --- a/silx/app/test/__init__.py +++ b/silx/app/test/__init__.py @@ -26,16 +26,14 @@ __authors__ = ["V. Valls"] __license__ = "MIT" __date__ = "30/03/2017" - -import logging -import os -import sys import unittest - -_logger = logging.getLogger(__name__) +from . import test_view +from . import test_convert def suite(): test_suite = unittest.TestSuite() + test_suite.addTest(test_view.suite()) + test_suite.addTest(test_convert.suite()) return test_suite diff --git a/silx/app/test/test_convert.py b/silx/app/test/test_convert.py new file mode 100644 index 0000000..3215460 --- /dev/null +++ b/silx/app/test/test_convert.py @@ -0,0 +1,182 @@ +# 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.convert""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "12/09/2017" + + +import os +import sys +import tempfile +import unittest +import io +import gc + +try: + import h5py +except ImportError: + h5py = None + +import silx +from .. import convert +from silx.test import utils + + + +# content of a spec file +sftext = """#F /tmp/sf.dat +#E 1455180875 +#D Thu Feb 11 09:54:35 2016 +#C imaging User = opid17 +#O0 Pslit HGap MRTSlit UP MRTSlit DOWN +#O1 Sslit1 VOff Sslit1 HOff Sslit1 VGap +#o0 pshg mrtu mrtd +#o2 ss1vo ss1ho ss1vg + +#J0 Seconds IA ion.mono Current +#J1 xbpmc2 idgap1 Inorm + +#S 1 ascan ss1vo -4.55687 -0.556875 40 0.2 +#D Thu Feb 11 09:55:20 2016 +#T 0.2 (Seconds) +#P0 180.005 -0.66875 0.87125 +#P1 14.74255 16.197579 12.238283 +#N 4 +#L MRTSlit UP second column 3rd_col +-1.23 5.89 8 +8.478100E+01 5 1.56 +3.14 2.73 -3.14 +1.2 2.3 3.4 + +#S 1 aaaaaa +#D Thu Feb 11 10:00:32 2016 +#@MCADEV 1 +#@MCA %16C +#@CHANN 3 0 2 1 +#@CALIB 1 2 3 +#N 3 +#L uno duo +1 2 +@A 0 1 2 +@A 10 9 8 +3 4 +@A 3.1 4 5 +@A 7 6 5 +5 6 +@A 6 7.7 8 +@A 4 3 2 +""" + + +class TestConvertCommand(unittest.TestCase): + """Test command line parsing""" + + def testHelp(self): + # option -h must cause a `raise SystemExit` or a `return 0` + try: + result = convert.main(["convert", "--help"]) + except SystemExit as e: + result = e.args[0] + self.assertEqual(result, 0) + + @unittest.skipUnless(h5py is None, + "h5py is installed, this test is specific to h5py missing") + @utils.test_logging(convert._logger.name, error=1) + def testH5pyNotInstalled(self): + result = convert.main(["convert", "foo.spec", "bar.edf"]) + # we explicitly return -1 if h5py is not imported + self.assertNotEqual(result, 0) + + @unittest.skipIf(h5py is None, "h5py is required to test convert") + def testWrongOption(self): + # presence of a wrong option must cause a SystemExit or a return + # with a non-zero status + try: + result = convert.main(["convert", "--foo"]) + except SystemExit as e: + result = e.args[0] + self.assertNotEqual(result, 0) + + @unittest.skipIf(h5py is None, "h5py is required to test convert") + @utils.test_logging(convert._logger.name, error=3) + # one error log per missing file + one "Aborted" error log + def testWrongFiles(self): + result = convert.main(["convert", "foo.spec", "bar.edf"]) + self.assertNotEqual(result, 0) + + @unittest.skipIf(h5py is None, "h5py is required to test convert") + def testFile(self): + # create a writable temp directory + tempdir = tempfile.mkdtemp() + + # write a temporary SPEC file + specname = os.path.join(tempdir, "input.dat") + with io.open(specname, "wb") as fd: + if sys.version < '3.0': + fd.write(sftext) + else: + fd.write(bytes(sftext, 'ascii')) + + # convert it + h5name = os.path.join(tempdir, "output.h5") + command_list = ["convert", "-m", "w", + "--no-root-group", specname, "-o", h5name] + result = convert.main(command_list) + + self.assertEqual(result, 0) + self.assertTrue(os.path.isfile(h5name)) + + with h5py.File(h5name, "r") as h5f: + title12 = h5f["/1.2/title"][()] + if sys.version > '3.0': + title12 = title12.decode() + self.assertEqual(title12, + "1 aaaaaa") + + creator = h5f.attrs.get("creator") + self.assertIsNotNone(creator, "No creator attribute in NXroot group") + creator = creator.decode() # make sure we can compare creator with native string + self.assertTrue(creator.startswith("silx %s" % silx.version)) + command = " ".join(command_list) + self.assertTrue(creator.endswith(command)) + + # delete input file + gc.collect() # necessary to free spec file on Windows + os.unlink(specname) + os.unlink(h5name) + os.rmdir(tempdir) + + +def suite(): + test_suite = unittest.TestSuite() + loader = unittest.defaultTestLoader.loadTestsFromTestCase + test_suite.addTest(loader(TestConvertCommand)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/app/test/test_view.py b/silx/app/test/test_view.py index 774bc01..e55e4f3 100644 --- a/silx/app/test/test_view.py +++ b/silx/app/test/test_view.py @@ -26,13 +26,29 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "12/04/2017" +__date__ = "29/09/2017" import unittest -from silx.gui.test.utils import TestCaseQt -from .. import view import sys +import os + + +# TODO: factor this code with silx.gui.test +with_qt = False +if sys.platform.startswith('linux') and not os.environ.get('DISPLAY', ''): + reason = 'test disabled (DISPLAY env. variable not set)' + view = None + TestCaseQt = unittest.TestCase +elif os.environ.get('WITH_QT_TEST', 'True') == 'False': + reason = "test disabled (env. variable WITH_QT_TEST=False)" + view = None + TestCaseQt = unittest.TestCase +else: + from silx.gui.test.utils import TestCaseQt + from .. import view + with_qt = True + reason = "" class QApplicationMock(object): @@ -64,6 +80,7 @@ class ViewerMock(object): pass +@unittest.skipUnless(with_qt, "Qt binding required for TestLauncher") class TestLauncher(unittest.TestCase): """Test command line parsing""" @@ -83,9 +100,9 @@ class TestLauncher(unittest.TestCase): super(TestLauncher, cls).tearDownClass() def testHelp(self): + # option -h must cause a raise SystemExit or a return 0 try: result = view.main(["view", "--help"]) - self.assertNotEqual(result, 0) except SystemExit as e: result = e.args[0] self.assertEqual(result, 0) @@ -93,7 +110,6 @@ class TestLauncher(unittest.TestCase): 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) @@ -101,10 +117,9 @@ class TestLauncher(unittest.TestCase): 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) + self.assertEqual(result, 0) def testFile(self): # sys.executable is an existing readable file @@ -118,8 +133,10 @@ class TestLauncher(unittest.TestCase): class TestViewer(TestCaseQt): """Test for Viewer class""" + @unittest.skipUnless(with_qt, reason) def testConstruct(self): - widget = view.Viewer() + if view is not None: + widget = view.Viewer() self.qWaitForWindowExposed(widget) diff --git a/silx/app/test_.py b/silx/app/test_.py new file mode 100644 index 0000000..7f95085 --- /dev/null +++ b/silx/app/test_.py @@ -0,0 +1,175 @@ +# 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. +# +# ############################################################################*/ +"""Launch unittests of the library""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "04/08/2017" + +import sys +import os +import argparse +import logging +import unittest + + +class StreamHandlerUnittestReady(logging.StreamHandler): + """The unittest class TestResult redefine sys.stdout/err to capture + stdout/err from tests and to display them only when a test fail. + + This class allow to use unittest stdout-capture by using the last sys.stdout + and not a cached one. + """ + + def emit(self, record): + """ + :type record: logging.LogRecord + """ + self.stream = sys.stderr + super(StreamHandlerUnittestReady, self).emit(record) + + def flush(self): + pass + + +def createBasicHandler(): + """Create the handler using the basic configuration""" + hdlr = StreamHandlerUnittestReady() + fs = logging.BASIC_FORMAT + dfs = None + fmt = logging.Formatter(fs, dfs) + hdlr.setFormatter(fmt) + return hdlr + + +# Use an handler compatible with unittests, else use_buffer is not working +for h in logging.root.handlers: + logging.root.removeHandler(h) +logging.root.addHandler(createBasicHandler()) +logging.captureWarnings(True) + +_logger = logging.getLogger(__name__) +"""Module logger""" + + +class TextTestResultWithSkipList(unittest.TextTestResult): + """Override default TextTestResult to display list of skipped tests at the + end + """ + + def printErrors(self): + unittest.TextTestResult.printErrors(self) + # Print skipped tests at the end + self.printErrorList("SKIPPED", self.skipped) + + +def main(argv): + """ + Main function to launch the unittests as an application + + :param argv: Command line arguments + :returns: exit status + """ + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("-v", "--verbose", default=0, + action="count", dest="verbose", + help="Increase verbosity. Option -v prints additional " + + "INFO messages. Use -vv for full verbosity, " + + "including debug messages and test help strings.") + parser.add_argument("-x", "--no-gui", dest="gui", default=True, + action="store_false", + help="Disable the test of the graphical use interface") + parser.add_argument("-g", "--no-opengl", dest="opengl", default=True, + action="store_false", + help="Disable tests using OpenGL") + parser.add_argument("-o", "--no-opencl", dest="opencl", default=True, + action="store_false", + help="Disable the test of the OpenCL part") + parser.add_argument("-l", "--low-mem", dest="low_mem", default=False, + action="store_true", + help="Disable test with large memory consumption (>100Mbyte") + parser.add_argument("--qt-binding", dest="qt_binding", default=None, + help="Force using a Qt binding, from 'PyQt4', 'PyQt5', or 'PySide'") + + options = parser.parse_args(argv[1:]) + + test_verbosity = 1 + use_buffer = True + if options.verbose == 1: + logging.root.setLevel(logging.INFO) + _logger.info("Set log level: INFO") + test_verbosity = 2 + use_buffer = False + elif options.verbose > 1: + logging.root.setLevel(logging.DEBUG) + _logger.info("Set log level: DEBUG") + test_verbosity = 2 + use_buffer = False + + if not options.gui: + os.environ["WITH_QT_TEST"] = "False" + + if not options.opencl: + os.environ["SILX_OPENCL"] = "False" + + if not options.opengl: + os.environ["WITH_GL_TEST"] = "False" + + if options.low_mem: + os.environ["SILX_TEST_LOW_MEM"] = "True" + + if options.qt_binding: + binding = options.qt_binding.lower() + if binding == "pyqt4": + _logger.info("Force using PyQt4") + import PyQt4.QtCore # noqa + elif binding == "pyqt5": + _logger.info("Force using PyQt5") + import PyQt5.QtCore # noqa + elif binding == "pyside": + _logger.info("Force using PySide") + import PySide.QtCore # noqa + else: + raise ValueError("Qt binding '%s' is unknown" % options.qt_binding) + + # Run the tests + runnerArgs = {} + runnerArgs["verbosity"] = test_verbosity + runnerArgs["buffer"] = use_buffer + runner = unittest.TextTestRunner(**runnerArgs) + runner.resultclass = TextTestResultWithSkipList + + # Display the result when using CTRL-C + unittest.installHandler() + + import silx.test + test_suite = unittest.TestSuite() + test_suite.addTest(silx.test.suite()) + result = runner.run(test_suite) + + if result.wasSuccessful(): + exit_status = 0 + else: + exit_status = 1 + return exit_status diff --git a/silx/app/view.py b/silx/app/view.py index 8fdabde..e8507f4 100644 --- a/silx/app/view.py +++ b/silx/app/view.py @@ -1,6 +1,6 @@ # coding: utf-8 # /*########################################################################## -# Copyright (C) 2016 European Synchrotron Radiation Facility +# 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 @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "12/04/2017" +__date__ = "02/10/2017" import sys import os @@ -33,30 +33,10 @@ 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): @@ -71,6 +51,10 @@ class Viewer(qt.QMainWindow): :class:`silx.io.spech5.SpecH5` or :class:`h5py.File` instances) """ + # Import it here to be sure to use the right logging level + import silx.gui.hdf5 + from silx.gui.data.DataViewerFrame import DataViewerFrame + qt.QMainWindow.__init__(self) self.setWindowTitle("Silx viewer") @@ -97,14 +81,15 @@ class Viewer(qt.QMainWindow): self.setCentralWidget(main_panel) - self.__treeview.selectionModel().selectionChanged.connect(self.displayData) + model = self.__treeview.selectionModel() + model.selectionChanged.connect(self.displayData) + self.__treeview.addContextMenuCallback(self.closeAndSyncCustomContextMenu) - 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) + treeModel = self.__treeview.findHdf5TreeModel() + columns = list(treeModel.COLUMN_IDS) + columns.remove(treeModel.DESCRIPTION_COLUMN) + columns.remove(treeModel.NODE_COLUMN) + self.__treeview.header().setSections(columns) self.createActions() self.createMenus() @@ -159,15 +144,17 @@ class Viewer(qt.QMainWindow): extensions = collections.OrderedDict() # expect h5py - extensions["HDF5 files"] = "*.h5" + extensions["HDF5 files"] = "*.h5 *.hdf" + extensions["NeXus files"] = "*.nx *.nxs *.h5 *.hdf" # no dependancy - extensions["Spec files"] = "*.dat *.spec *.mca" + extensions["NeXus layout from spec files"] = "*.dat *.spec *.mca" + extensions["Numpy binary files"] = "*.npz *.npy" # 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" + extensions["NeXus layout from raster images"] = "*.edf *.tif *.tiff *.cbf *.mccd" + extensions["NeXus layout from EDF files"] = "*.edf" + extensions["NeXus layout from TIFF image files"] = "*.tif *.tiff" + extensions["NeXus layout from CBF files"] = "*.cbf" + extensions["NeXus layout from MarCCD image files"] = "*.mccd" filters = [] filters.append("All supported files (%s)" % " ".join(extensions.values())) @@ -180,48 +167,8 @@ class Viewer(qt.QMainWindow): 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)) + from . import qtutils + qtutils.About.about(self, "Silx viewer") def appendFile(self, filename): self.__treeview.findHdf5TreeModel().appendFile(filename) @@ -229,7 +176,7 @@ class Viewer(qt.QMainWindow): def displayData(self): """Called to update the dataviewer with the selected data. """ - selected = list(self.__treeview.selectedH5Nodes()) + selected = list(self.__treeview.selectedH5Nodes(ignoreBrokenLinks=False)) if len(selected) == 1: # Update the viewer for a single selection data = selected[0] @@ -238,40 +185,20 @@ class Viewer(qt.QMainWindow): 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() + selectedObjects = event.source().selectedH5Nodes(ignoreBrokenLinks=False) menu = event.menu() if len(menu.children()): menu.addSeparator() + # Import it here to be sure to use the right logging level + import h5py for obj in selectedObjects: if obj.ntype is h5py.File: action = qt.QAction("Remove %s" % obj.local_filename, event.source()) @@ -292,12 +219,43 @@ def main(argv): 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)') + parser.add_argument( + '--debug', + dest="debug", + action="store_true", + default=False, + help='Set logging system in debug mode') + parser.add_argument( + '--use-opengl-plot', + dest="use_opengl_plot", + action="store_true", + default=False, + help='Use OpenGL for plots (instead of matplotlib)') options = parser.parse_args(argv[1:]) + if options.debug: + logging.root.setLevel(logging.DEBUG) + + # + # Import most of the things here to be sure to use the right logging level + # + + try: + # it should be loaded before h5py + import hdf5plugin # noqa + except ImportError: + _logger.debug("Backtrace", exc_info=True) + hdf5plugin = None + + try: + import h5py + except ImportError: + _logger.debug("Backtrace", exc_info=True) + h5py = None + if h5py is None: message = "Module 'h5py' is not installed but is mandatory."\ + " You can install it using \"pip install h5py\"." @@ -309,15 +267,27 @@ def main(argv): + " compressions. You can install it using \"pip install hdf5plugin\"." _logger.warning(message) + # + # Run the application + # + + if options.use_opengl_plot: + from silx.gui.plot import PlotWidget + PlotWidget.setDefaultBackend("opengl") + app = qt.QApplication([]) + qt.QLocale.setDefault(qt.QLocale.c()) + sys.excepthook = qt.exceptionHandler window = Viewer() window.resize(qt.QSize(640, 480)) - for f in options.files: - filename = f.name - f.close() - window.appendFile(filename) + for filename in options.files: + try: + window.appendFile(filename) + except IOError as e: + _logger.error(e.args[0]) + _logger.debug("Backtrace", exc_info=True) window.show() result = app.exec_() diff --git a/silx/gui/_glutils/FramebufferTexture.py b/silx/gui/_glutils/FramebufferTexture.py index b01eb41..cc05080 100644 --- a/silx/gui/_glutils/FramebufferTexture.py +++ b/silx/gui/_glutils/FramebufferTexture.py @@ -66,49 +66,48 @@ class FramebufferTexture(object): 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) + with self: # Bind FBO + # 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, - depthFormat, + stencilFormat, width, height) - gl.glFramebufferRenderbuffer(gl.GL_FRAMEBUFFER, - gl.GL_DEPTH_ATTACHMENT, - gl.GL_RENDERBUFFER, - self._depthId) - else: - self._depthId = None + 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) + assert (gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER) == + gl.GL_FRAMEBUFFER_COMPLETE) @property def shape(self): @@ -143,6 +142,7 @@ class FramebufferTexture(object): def __exit__(self, exctype, excvalue, traceback): gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self._previousFramebuffer) + self._previousFramebuffer = None def discard(self): """Delete associated OpenGL resources including texture""" diff --git a/silx/gui/_glutils/OpenGLWidget.py b/silx/gui/_glutils/OpenGLWidget.py new file mode 100644 index 0000000..6cbf8f0 --- /dev/null +++ b/silx/gui/_glutils/OpenGLWidget.py @@ -0,0 +1,409 @@ +# 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 compatibility layer for OpenGL widget. + +It provides a compatibility layer for Qt OpenGL widget used in silx +across Qt<=5.3 QtOpenGL.QGLWidget and QOpenGLWidget. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "26/07/2017" + + +import logging +import sys + +from .. import qt +from .._glutils import gl + + +_logger = logging.getLogger(__name__) + + +# Probe OpenGL availability and widget +ERROR = '' # Error message from probing Qt OpenGL support +_BaseOpenGLWidget = None # Qt OpenGL widget to use + +if hasattr(qt, 'QOpenGLWidget'): # PyQt>=5.4 + _logger.info('Using QOpenGLWidget') + _BaseOpenGLWidget = qt.QOpenGLWidget + +elif not qt.HAS_OPENGL: # QtOpenGL not installed + ERROR = '%s.QtOpenGL not available' % qt.BINDING + +elif qt.QApplication.instance() and not qt.QGLFormat.hasOpenGL(): + # qt.QGLFormat.hasOpenGL MUST be called with a QApplication created + # so this is only checked if the QApplication is already created + ERROR = 'Qt reports OpenGL not available' + +else: + _logger.info('Using QGLWidget') + _BaseOpenGLWidget = qt.QGLWidget + + +# Internal class wrapping Qt OpenGL widget +if _BaseOpenGLWidget is None: + _logger.error('OpenGL-based widget disabled: %s', ERROR) + _OpenGLWidget = None + +else: + class _OpenGLWidget(_BaseOpenGLWidget): + """Wrapper over QOpenGLWidget and QGLWidget""" + + sigOpenGLContextError = qt.Signal(str) + """Signal emitted when an OpenGL context error is detected at runtime. + + It provides the error reason as a str. + """ + + def __init__(self, parent, + alphaBufferSize=0, + depthBufferSize=24, + stencilBufferSize=8, + version=(2, 0), + f=qt.Qt.WindowFlags()): + # True if using QGLWidget, False if using QOpenGLWidget + self.__legacy = not hasattr(qt, 'QOpenGLWidget') + + self.__devicePixelRatio = 1.0 + self.__requestedOpenGLVersion = int(version[0]), int(version[1]) + self.__isValid = False + + if self.__legacy: # QGLWidget + format_ = qt.QGLFormat() + format_.setAlphaBufferSize(alphaBufferSize) + format_.setAlpha(alphaBufferSize != 0) + format_.setDepthBufferSize(depthBufferSize) + format_.setDepth(depthBufferSize != 0) + format_.setStencilBufferSize(stencilBufferSize) + format_.setStencil(stencilBufferSize != 0) + format_.setVersion(*self.__requestedOpenGLVersion) + format_.setDoubleBuffer(True) + + super(_OpenGLWidget, self).__init__(format_, parent, None, f) + + else: # QOpenGLWidget + super(_OpenGLWidget, self).__init__(parent, f) + + format_ = qt.QSurfaceFormat() + format_.setAlphaBufferSize(alphaBufferSize) + format_.setDepthBufferSize(depthBufferSize) + format_.setStencilBufferSize(stencilBufferSize) + format_.setVersion(*self.__requestedOpenGLVersion) + format_.setSwapBehavior(qt.QSurfaceFormat.DoubleBuffer) + self.setFormat(format_) + + + def getDevicePixelRatio(self): + """Returns the ratio device-independent / device pixel size + + It should be either 1.0 or 2.0. + + :return: Scale factor between screen and Qt units + :rtype: float + """ + return self.__devicePixelRatio + + def getRequestedOpenGLVersion(self): + """Returns the requested OpenGL version. + + :return: (major, minor) + :rtype: 2-tuple of int""" + return self.__requestedOpenGLVersion + + def getOpenGLVersion(self): + """Returns the available OpenGL version. + + :return: (major, minor) + :rtype: 2-tuple of int""" + if self.__legacy: # QGLWidget + supportedVersion = 0, 0 + + # Go through all OpenGL version flags checking support + flags = self.format().openGLVersionFlags() + for version in ((1, 1), (1, 2), (1, 3), (1, 4), (1, 5), + (2, 0), (2, 1), + (3, 0), (3, 1), (3, 2), (3, 3), + (4, 0)): + versionFlag = getattr(qt.QGLFormat, + 'OpenGL_Version_%d_%d' % version) + if not versionFlag & flags: + break + supportedVersion = version + return supportedVersion + + else: # QOpenGLWidget + return self.format().version() + + # QOpenGLWidget methods + + def isValid(self): + """Returns True if OpenGL is available. + + This adds extra checks to Qt isValid method. + + :rtype: bool + """ + return self.__isValid and super(_OpenGLWidget, self).isValid() + + def defaultFramebufferObject(self): + """Returns the framebuffer object handle. + + See :meth:`QOpenGLWidget.defaultFramebufferObject` + """ + if self.__legacy: # QGLWidget + return 0 + else: # QOpenGLWidget + return super(_OpenGLWidget, self).defaultFramebufferObject() + + # *GL overridden methods + + def initializeGL(self): + parent = self.parent() + if parent is None: + _logger.error('_OpenGLWidget has no parent') + return + + # Check OpenGL version + if self.getOpenGLVersion() >= self.getRequestedOpenGLVersion(): + version = gl.glGetString(gl.GL_VERSION) + if version: + self.__isValid = True + else: + errMsg = 'OpenGL not available' + if sys.platform.startswith('linux'): + errMsg += ': If connected remotely, ' \ + 'GLX forwarding might be disabled.' + _logger.error(errMsg) + self.sigOpenGLContextError.emit(errMsg) + self.__isValid = False + + else: + errMsg = 'OpenGL %d.%d not available' % \ + self.getRequestedOpenGLVersion() + _logger.error('OpenGL widget disabled: %s', errMsg) + self.sigOpenGLContextError.emit(errMsg) + self.__isValid = False + + if self.isValid(): + parent.initializeGL() + + def paintGL(self): + parent = self.parent() + if parent is None: + _logger.error('_OpenGLWidget has no parent') + return + + if qt.BINDING == 'PyQt5': + devicePixelRatio = self.window().windowHandle().devicePixelRatio() + + if devicePixelRatio != self.getDevicePixelRatio(): + # Update devicePixelRatio and call resizeOpenGL + # as resizeGL is not always called. + self.__devicePixelRatio = devicePixelRatio + self.makeCurrent() + parent.resizeGL(self.width(), self.height()) + + if self.isValid(): + parent.paintGL() + + def resizeGL(self, width, height): + parent = self.parent() + if parent is None: + _logger.error('_OpenGLWidget has no parent') + return + + if self.isValid(): + # Call parent resizeGL with device-independent pixel unit + # This works over both QGLWidget and QOpenGLWidget + parent.resizeGL(self.width(), self.height()) + + +class OpenGLWidget(qt.QWidget): + """OpenGL widget wrapper over QGLWidget and QOpenGLWidget + + This wrapper API implements a subset of QOpenGLWidget API. + The constructor takes a different set of arguments. + Methods returning object like :meth:`context` returns either + QGL* or QOpenGL* objects. + + :param parent: Parent widget see :class:`QWidget` + :param int alphaBufferSize: + Size in bits of the alpha channel (default: 0). + Set to 0 to disable alpha channel. + :param int depthBufferSize: + Size in bits of the depth buffer (default: 24). + Set to 0 to disable depth buffer. + :param int stencilBufferSize: + Size in bits of the stencil buffer (default: 8). + Set to 0 to disable stencil buffer + :param version: Requested OpenGL version (default: (2, 0)). + :type version: 2-tuple of int + :param f: see :class:`QWidget` + """ + + def __init__(self, parent=None, + alphaBufferSize=0, + depthBufferSize=24, + stencilBufferSize=8, + version=(2, 0), + f=qt.Qt.WindowFlags()): + super(OpenGLWidget, self).__init__(parent, f) + + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + + if _OpenGLWidget is None: + self.__openGLWidget = None + label = self._createErrorQLabel(ERROR) + self.layout().addWidget(label) + + else: + self.__openGLWidget = _OpenGLWidget( + parent=self, + alphaBufferSize=alphaBufferSize, + depthBufferSize=depthBufferSize, + stencilBufferSize=stencilBufferSize, + version=version, + f=f) + # Async connection need, otherwise issue when hiding OpenGL + # widget while doing the rendering.. + self.__openGLWidget.sigOpenGLContextError.connect( + self._handleOpenGLInitError, qt.Qt.QueuedConnection) + self.layout().addWidget(self.__openGLWidget) + + @staticmethod + def _createErrorQLabel(error): + """Create QLabel displaying error message in place of OpenGL widget + + :param str error: The error message to display""" + label = qt.QLabel() + label.setText('OpenGL-based widget disabled:\n%s' % error) + label.setAlignment(qt.Qt.AlignCenter) + label.setWordWrap(True) + return label + + def _handleOpenGLInitError(self, error): + """Handle runtime errors in OpenGL widget""" + if self.__openGLWidget is not None: + self.__openGLWidget.setVisible(False) + self.__openGLWidget.setParent(None) + self.__openGLWidget = None + + label = self._createErrorQLabel(error) + self.layout().addWidget(label) + + # Additional API + + def getDevicePixelRatio(self): + """Returns the ratio device-independent / device pixel size + + It should be either 1.0 or 2.0. + + :return: Scale factor between screen and Qt units + :rtype: float + """ + if self.__openGLWidget is None: + return 1. + else: + return self.__openGLWidget.getDevicePixelRatio() + + def getOpenGLVersion(self): + """Returns the available OpenGL version. + + :return: (major, minor) + :rtype: 2-tuple of int""" + if self.__openGLWidget is None: + return 0, 0 + else: + return self.__openGLWidget.getOpenGLVersion() + + # QOpenGLWidget API + + def isValid(self): + """Returns True if OpenGL with the requested version is available. + + :rtype: bool + """ + if self.__openGLWidget is None: + return False + else: + return self.__openGLWidget.isValid() + + def context(self): + """Return Qt OpenGL context object or None. + + See :meth:`QOpenGLWidget.context` and :meth:`QGLWidget.context` + """ + if self.__openGLWidget is None: + return None + else: + return self.__openGLWidget.context() + + def defaultFramebufferObject(self): + """Returns the framebuffer object handle. + + See :meth:`QOpenGLWidget.defaultFramebufferObject` + """ + if self.__openGLWidget is None: + return 0 + else: + return self.__openGLWidget.defaultFramebufferObject() + + def makeCurrent(self): + """Make the underlying OpenGL widget's context current. + + See :meth:`QOpenGLWidget.makeCurrent` + """ + if self.__openGLWidget is not None: + self.__openGLWidget.makeCurrent() + + def update(self): + """Async update of the OpenGL widget. + + See :meth:`QOpenGLWidget.update` + """ + if self.__openGLWidget is not None: + self.__openGLWidget.update() + + # QOpenGLWidget API to override + + def initializeGL(self): + """Override to implement OpenGL initialization.""" + pass + + def paintGL(self): + """Override to implement OpenGL rendering.""" + pass + + def resizeGL(self, width, height): + """Override to implement resize of OpenGL framebuffer. + + :param int width: Width in device-independent pixels + :param int height: Height in device-independent pixels + """ + pass diff --git a/silx/gui/_glutils/VertexBuffer.py b/silx/gui/_glutils/VertexBuffer.py index 689b543..b74b748 100644 --- a/silx/gui/_glutils/VertexBuffer.py +++ b/silx/gui/_glutils/VertexBuffer.py @@ -180,7 +180,7 @@ class VertexBufferAttrib(object): dimension=1, offset=0, stride=0, - normalisation=False): + normalization=False): self.vbo = vbo assert type_ in self._GL_TYPES self.type_ = type_ @@ -189,7 +189,7 @@ class VertexBufferAttrib(object): self.dimension = dimension self.offset = offset self.stride = stride - self.normalisation = bool(normalisation) + self.normalization = bool(normalization) @property def itemsize(self): @@ -200,12 +200,12 @@ class VertexBufferAttrib(object): def setVertexAttrib(self, attribute): """Call glVertexAttribPointer with objects information""" - normalisation = gl.GL_TRUE if self.normalisation else gl.GL_FALSE + normalization = gl.GL_TRUE if self.normalization else gl.GL_FALSE with self.vbo: gl.glVertexAttribPointer(attribute, self.dimension, self.type_, - normalisation, + normalization, self.stride, c_void_p(self.offset)) @@ -216,7 +216,7 @@ class VertexBufferAttrib(object): self.dimension, self.offset, self.stride, - self.normalisation) + self.normalization) def vertexBuffer(arrays, prefix=None, suffix=None, usage=None): diff --git a/silx/gui/_glutils/__init__.py b/silx/gui/_glutils/__init__.py index e86a58f..15e48e1 100644 --- a/silx/gui/_glutils/__init__.py +++ b/silx/gui/_glutils/__init__.py @@ -33,6 +33,7 @@ __date__ = "25/07/2016" # OpenGL convenient functions +from .OpenGLWidget import OpenGLWidget # noqa from .Context import getGLContext, setGLContextGetter # noqa from .FramebufferTexture import FramebufferTexture # noqa from .Program import Program # noqa diff --git a/silx/gui/_glutils/font.py b/silx/gui/_glutils/font.py index 566ae49..2be2c04 100644 --- a/silx/gui/_glutils/font.py +++ b/silx/gui/_glutils/font.py @@ -98,27 +98,39 @@ def rasterText(text, font, _logger.info("Trying to raster empty text, replaced by white space") text = ' ' # Replace empty text by white space to produce an image + if (devicePixelRatio != 1.0 and + not hasattr(qt.QImage, 'setDevicePixelRatio')): # Qt 4 + _logger.error('devicePixelRatio not supported') + devicePixelRatio = 1.0 + if not isinstance(font, qt.QFont): font = qt.QFont(font, size, weight, italic) + # get text size + image = qt.QImage(1, 1, qt.QImage.Format_RGB888) + painter = qt.QPainter() + painter.begin(image) + painter.setPen(qt.Qt.white) + painter.setFont(font) + bounds = painter.boundingRect( + qt.QRect(0, 0, 4096, 4096), qt.Qt.TextExpandTabs, text) + painter.end() + 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 + # This does not provide the correct text bbox on macOS + # size = metrics.size(qt.Qt.TextExpandTabs, text) + # bounds = metrics.boundingRect( + # qt.QRect(0, 0, size.width(), size.height()), + # qt.Qt.TextExpandTabs, + # text) # 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, + bounds.height() * devicePixelRatio + 2, qt.QImage.Format_RGB888) if (devicePixelRatio != 1.0 and hasattr(image, 'setDevicePixelRatio')): # Qt 5 diff --git a/silx/gui/console.py b/silx/gui/console.py index 13760b4..7812e2d 100644 --- a/silx/gui/console.py +++ b/silx/gui/console.py @@ -136,6 +136,8 @@ if qtconsole is None: class IPythonWidget(RichIPythonWidget): """Live IPython console widget. + .. image:: img/IPythonWidget.png + :param custom_banner: Custom welcome message to be printed at the top of the console. """ @@ -175,6 +177,8 @@ class IPythonDockWidget(qt.QDockWidget): """Dock Widget including a :class:`IPythonWidget` inside a vertical layout. + .. image:: img/IPythonDockWidget.png + :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 diff --git a/silx/gui/data/ArrayTableModel.py b/silx/gui/data/ArrayTableModel.py index 87a2fc1..ad4d33a 100644 --- a/silx/gui/data/ArrayTableModel.py +++ b/silx/gui/data/ArrayTableModel.py @@ -34,7 +34,7 @@ from silx.gui.data.TextFormatter import TextFormatter __authors__ = ["V.A. Sole"] __license__ = "MIT" -__date__ = "24/01/2017" +__date__ = "27/09/2017" _logger = logging.getLogger(__name__) @@ -191,7 +191,7 @@ class ArrayTableModel(qt.QAbstractTableModel): selection = self._getIndexTuple(index.row(), index.column()) if role == qt.Qt.DisplayRole: - return self._formatter.toString(self._array[selection]) + return self._formatter.toString(self._array[selection], self._array.dtype) if role == qt.Qt.BackgroundRole and self._bgcolors is not None: r, g, b = self._bgcolors[selection][0:3] @@ -296,6 +296,9 @@ class ArrayTableModel(qt.QAbstractTableModel): elif copy: # copy requested (default) self._array = numpy.array(data, copy=True) + if hasattr(data, "dtype"): + # Avoid to lose the monkey-patched h5py dtype + self._array.dtype = data.dtype elif not _is_array(data): raise TypeError("data is not a proper array. Try setting" + " copy=True to convert it into a numpy array" + diff --git a/silx/gui/data/ArrayTableWidget.py b/silx/gui/data/ArrayTableWidget.py index ba3fa11..cb8e915 100644 --- a/silx/gui/data/ArrayTableWidget.py +++ b/silx/gui/data/ArrayTableWidget.py @@ -230,6 +230,8 @@ class ArrayTableWidget(qt.QWidget): To select the perspective, use :meth:`setPerspective` or use :meth:`setFrameAxes`. To select the frame, use :meth:`setFrameIndex`. + + .. image:: img/ArrayTableWidget.png """ def __init__(self, parent=None): """ diff --git a/silx/gui/data/DataViewer.py b/silx/gui/data/DataViewer.py index 3a3ac64..750c654 100644 --- a/silx/gui/data/DataViewer.py +++ b/silx/gui/data/DataViewer.py @@ -22,8 +22,8 @@ # THE SOFTWARE. # # ###########################################################################*/ -"""This module defines a widget designed to display data using to most adapted -view from available ones from silx. +"""This module defines a widget designed to display data using the most adapted +view from the ones provided by silx. """ from __future__ import division @@ -35,7 +35,7 @@ from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "26/04/2017" +__date__ = "03/10/2017" _logger = logging.getLogger(__name__) @@ -144,7 +144,7 @@ class DataViewer(qt.QFrame): DataViews._Hdf5View, DataViews._NXdataView, DataViews._Plot1dView, - DataViews._Plot2dView, + DataViews._ImageView, DataViews._Plot3dView, DataViews._RawView, DataViews._StackView, @@ -201,7 +201,7 @@ class DataViewer(qt.QFrame): 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: + if info.isArray and info.size != 0 and self.__data is not None and axisNames is not None: self.__useAxisSelection = True self.__numpySelection.setAxisNames(axisNames) self.__numpySelection.setCustomAxis(self.__currentView.customAxisNames()) diff --git a/silx/gui/data/DataViewerFrame.py b/silx/gui/data/DataViewerFrame.py index b48fa7b..e050d4a 100644 --- a/silx/gui/data/DataViewerFrame.py +++ b/silx/gui/data/DataViewerFrame.py @@ -27,7 +27,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "10/04/2017" +__date__ = "21/09/2017" from silx.gui import qt from .DataViewer import DataViewer @@ -79,6 +79,14 @@ class DataViewerFrame(qt.QWidget): """Avoid to create views while the instance is not created.""" super(_DataViewer, self)._initializeViews() + def _createDefaultViews(self, parent): + """Expose the original `createDefaultViews` function""" + return super(_DataViewer, self).createDefaultViews() + + def createDefaultViews(self, parent=None): + """Allow the DataViewerFrame to override this function""" + return self.parent().createDefaultViews(parent) + self.__dataViewer = _DataViewer(self) # initialize views when `self.__dataViewer` is set self.__dataViewer.initializeViews() @@ -127,7 +135,7 @@ class DataViewerFrame(qt.QWidget): :param QWidget parent: QWidget parent of the views :rtype: list[silx.gui.data.DataViews.DataView] """ - return self.__dataViewer.createDefaultViews(parent) + return self.__dataViewer._createDefaultViews(parent) def addView(self, view): """Allow to add a view to the dataview. diff --git a/silx/gui/data/DataViews.py b/silx/gui/data/DataViews.py index d8d605a..1ad997b 100644 --- a/silx/gui/data/DataViews.py +++ b/silx/gui/data/DataViews.py @@ -25,6 +25,7 @@ """This module defines a views used by :class:`silx.gui.data.DataViewer`. """ +from collections import OrderedDict import logging import numbers import numpy @@ -34,11 +35,11 @@ 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 +from silx.io.nxdata import NXdata, get_attr_as_string __authors__ = ["V. Valls", "P. Knobel"] __license__ = "MIT" -__date__ = "07/04/2017" +__date__ = "03/10/2017" _logger = logging.getLogger(__name__) @@ -52,6 +53,7 @@ RAW_MODE = 40 RAW_ARRAY_MODE = 41 RAW_RECORD_MODE = 42 RAW_SCALAR_MODE = 43 +RAW_HEXA_MODE = 44 STACK_MODE = 50 HDF5_MODE = 60 @@ -62,6 +64,8 @@ def _normalizeData(data): If the data embed a numpy data or a dataset it is returned. Else returns the input data.""" if isinstance(data, H5Node): + if data.is_broken: + return None return data.h5py_object return data @@ -89,11 +93,14 @@ class DataInfo(object): self.isArray = False self.interpretation = None self.isNumeric = False + self.isVoid = False self.isComplex = False + self.isBoolean = False self.isRecord = False self.isNXdata = False self.shape = tuple() self.dim = 0 + self.size = 0 if data is None: return @@ -110,23 +117,32 @@ class DataInfo(object): self.isArray = False if silx.io.is_dataset(data): - self.interpretation = data.attrs.get("interpretation", None) + if "interpretation" in data.attrs: + self.interpretation = get_attr_as_string(data, "interpretation") + else: + self.interpretation = None elif self.isNXdata: self.interpretation = nxd.interpretation else: self.interpretation = None if hasattr(data, "dtype"): + if numpy.issubdtype(data.dtype, numpy.void): + # That's a real opaque type, else it is a structured type + self.isVoid = data.dtype.fields is None self.isNumeric = numpy.issubdtype(data.dtype, numpy.number) self.isRecord = data.dtype.fields is not None self.isComplex = numpy.issubdtype(data.dtype, numpy.complex) + self.isBoolean = numpy.issubdtype(data.dtype, numpy.bool_) elif self.isNXdata: self.isNumeric = numpy.issubdtype(nxd.signal.dtype, numpy.number) self.isComplex = numpy.issubdtype(nxd.signal.dtype, numpy.complex) + self.isBoolean = numpy.issubdtype(nxd.signal.dtype, numpy.bool_) else: self.isNumeric = isinstance(data, numbers.Number) self.isComplex = isinstance(data, numbers.Complex) + self.isBoolean = isinstance(data, bool) self.isRecord = False if hasattr(data, "shape"): @@ -135,7 +151,13 @@ class DataInfo(object): self.shape = nxd.signal.shape else: self.shape = tuple() - self.dim = len(self.shape) + if self.shape is not None: + self.dim = len(self.shape) + + if hasattr(data, "size"): + self.size = int(data.size) + else: + self.size = 1 def normalizeData(self, data): """Returns a normalized data if the embed a numpy or a dataset. @@ -237,12 +259,12 @@ class DataView(object): def axesNames(self, data, info): """Returns names of the expected axes of the view, according to the - input data. + input data. A none value will disable the default axes selectior. :param data: Data to display :type data: numpy.ndarray or h5py.Dataset :param DataInfo info: Pre-computed information on the data - :rtype: list[str] + :rtype: list[str] or None """ return [] @@ -276,7 +298,7 @@ class CompositeDataView(DataView): :param qt.QWidget parent: Parent of the hold widget """ super(CompositeDataView, self).__init__(parent, modeId, icon, label) - self.__views = {} + self.__views = OrderedDict() self.__currentView = None def addView(self, dataView): @@ -285,10 +307,9 @@ class CompositeDataView(DataView): 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) + views = sorted(views, key=lambda t: t[0], reverse=True) if len(views) == 0: return None @@ -361,7 +382,7 @@ class _EmptyView(DataView): DataView.__init__(self, parent, modeId=EMPTY_MODE) def axesNames(self, data, info): - return [] + return None def createWidget(self, parent): return qt.QLabel(parent) @@ -406,6 +427,8 @@ class _Plot1dView(DataView): return ["y"] def getDataPriority(self, data, info): + if info.size <= 0: + return DataView.UNSUPPORTED if data is None or not info.isArray or not info.isNumeric: return DataView.UNSUPPORTED if info.dim < 1: @@ -434,9 +457,10 @@ class _Plot2dView(DataView): def createWidget(self, parent): from silx.gui import plot widget = plot.Plot2D(parent=parent) + widget.getIntensityHistogramAction().setVisible(True) widget.setKeepDataAspectRatio(True) - widget.setGraphXLabel('X') - widget.setGraphYLabel('Y') + widget.getXAxis().setLabel('X') + widget.getYAxis().setLabel('Y') return widget def clear(self): @@ -459,7 +483,11 @@ class _Plot2dView(DataView): return ["y", "x"] def getDataPriority(self, data, info): - if data is None or not info.isArray or not info.isNumeric: + if info.size <= 0: + return DataView.UNSUPPORTED + if (data is None or + not info.isArray or + not (info.isNumeric or info.isBoolean)): return DataView.UNSUPPORTED if info.dim < 2: return DataView.UNSUPPORTED @@ -494,8 +522,15 @@ class _Plot3dView(DataView): plot = ScalarFieldView.ScalarFieldView(parent) plot.setAxesLabels(*reversed(self.axesNames(None, None))) - plot.addIsosurface( - lambda data: numpy.mean(data) + numpy.std(data), '#FF0000FF') + + def computeIsolevel(data): + data = data[numpy.isfinite(data)] + if len(data) == 0: + return 0 + else: + return numpy.mean(data) + numpy.std(data) + + plot.addIsosurface(computeIsolevel, '#FF0000FF') # Create a parameter tree for the scalar field view options = SFViewParamTree.TreeView(plot) @@ -527,6 +562,8 @@ class _Plot3dView(DataView): return ["z", "y", "x"] def getDataPriority(self, data, info): + if info.size <= 0: + return DataView.UNSUPPORTED if data is None or not info.isArray or not info.isNumeric: return DataView.UNSUPPORTED if info.dim < 3: @@ -539,6 +576,54 @@ class _Plot3dView(DataView): return 10 +class _ComplexImageView(DataView): + """View displaying data using a ComplexImageView""" + + def __init__(self, parent): + super(_ComplexImageView, self).__init__( + parent=parent, + modeId=PLOT2D_MODE, + label="Complex Image", + icon=icons.getQIcon("view-2d")) + + def createWidget(self, parent): + from silx.gui.plot.ComplexImageView import ComplexImageView + widget = ComplexImageView(parent=parent) + widget.getPlot().getIntensityHistogramAction().setVisible(True) + widget.getPlot().setKeepDataAspectRatio(True) + widget.getXAxis().setLabel('X') + widget.getYAxis().setLabel('Y') + return widget + + def clear(self): + self.getWidget().setData(None) + + def normalizeData(self, data): + data = DataView.normalizeData(self, data) + return data + + def setData(self, data): + data = self.normalizeData(data) + self.getWidget().setData(data) + + def axesNames(self, data, info): + return ["y", "x"] + + def getDataPriority(self, data, info): + if info.size <= 0: + return DataView.UNSUPPORTED + if data is None or not info.isArray or not info.isComplex: + 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 _ArrayView(DataView): """View displaying data using a 2d table""" @@ -562,6 +647,8 @@ class _ArrayView(DataView): return ["col", "row"] def getDataPriority(self, data, info): + if info.size <= 0: + return DataView.UNSUPPORTED if data is None or not info.isArray or info.isRecord: return DataView.UNSUPPORTED if info.dim < 2: @@ -618,6 +705,8 @@ class _StackView(DataView): return ["depth", "y", "x"] def getDataPriority(self, data, info): + if info.size <= 0: + return DataView.UNSUPPORTED if data is None or not info.isArray or not info.isNumeric: return DataView.UNSUPPORTED if info.dim < 3: @@ -644,17 +733,21 @@ class _ScalarView(DataView): self.getWidget().setText("") def setData(self, data): - data = self.normalizeData(data) - if silx.io.is_dataset(data): - data = data[()] - text = self.__formatter.toString(data) + d = self.normalizeData(data) + if silx.io.is_dataset(d): + d = d[()] + text = self.__formatter.toString(d, data.dtype) self.getWidget().setText(text) def axesNames(self, data, info): return [] def getDataPriority(self, data, info): + if info.size <= 0: + return DataView.UNSUPPORTED data = self.normalizeData(data) + if info.shape is None: + return DataView.UNSUPPORTED if data is None: return DataView.UNSUPPORTED if silx.io.is_group(data): @@ -681,13 +774,16 @@ class _RecordView(DataView): data = self.normalizeData(data) widget = self.getWidget() widget.setArrayData(data) - widget.resizeRowsToContents() - widget.resizeColumnsToContents() + if len(data) < 100: + widget.resizeRowsToContents() + widget.resizeColumnsToContents() def axesNames(self, data, info): return ["data"] def getDataPriority(self, data, info): + if info.size <= 0: + return DataView.UNSUPPORTED if info.isRecord: return 40 if data is None or not info.isArray: @@ -703,6 +799,36 @@ class _RecordView(DataView): return DataView.UNSUPPORTED +class _HexaView(DataView): + """View displaying data using text""" + + def __init__(self, parent): + DataView.__init__(self, parent, modeId=RAW_HEXA_MODE) + + def createWidget(self, parent): + from .HexaTableView import HexaTableView + widget = HexaTableView(parent) + return widget + + def clear(self): + self.getWidget().setArrayData(None) + + def setData(self, data): + data = self.normalizeData(data) + widget = self.getWidget() + widget.setArrayData(data) + + def axesNames(self, data, info): + return [] + + def getDataPriority(self, data, info): + if info.size <= 0: + return DataView.UNSUPPORTED + if info.isVoid: + return 2000 + return DataView.UNSUPPORTED + + class _Hdf5View(DataView): """View displaying data using text""" @@ -727,7 +853,7 @@ class _Hdf5View(DataView): widget.setData(data) def axesNames(self, data, info): - return [] + return None def getDataPriority(self, data, info): widget = self.getWidget() @@ -750,11 +876,28 @@ class _RawView(CompositeDataView): modeId=RAW_MODE, label="Raw", icon=icons.getQIcon("view-raw")) + self.addView(_HexaView(parent)) self.addView(_ScalarView(parent)) self.addView(_ArrayView(parent)) self.addView(_RecordView(parent)) +class _ImageView(CompositeDataView): + """View displaying data as 2D image + + It choose between Plot2D and ComplexImageView widgets + """ + + def __init__(self, parent): + super(_ImageView, self).__init__( + parent=parent, + modeId=PLOT2D_MODE, + label="Image", + icon=icons.getQIcon("view-2d")) + self.addView(_ComplexImageView(parent)) + self.addView(_Plot2dView(parent)) + + class _NXdataScalarView(DataView): """DataView using a table view for displaying NXdata scalars: 0-D signal or n-D signal with *@interpretation=scalar*""" @@ -806,7 +949,7 @@ class _NXdataCurveView(DataView): def axesNames(self, data, info): # disabled (used by default axis selector widget in Hdf5Viewer) - return [] + return None def clear(self): self.getWidget().clear() @@ -814,10 +957,10 @@ class _NXdataCurveView(DataView): def setData(self, data): data = self.normalizeData(data) nxd = NXdata(data) - signal_name = data.attrs["signal"] + signal_name = get_attr_as_string(data, "signal") group_name = data.name - if nxd.axes_names[-1] is not None: - x_errors = nxd.get_axis_errors(nxd.axes_names[-1]) + if nxd.axes_dataset_names[-1] is not None: + x_errors = nxd.get_axis_errors(nxd.axes_dataset_names[-1]) else: x_errors = None @@ -853,7 +996,7 @@ class _NXdataXYVScatterView(DataView): def axesNames(self, data, info): # disabled (used by default axis selector widget in Hdf5Viewer) - return [] + return None def clear(self): self.getWidget().clear() @@ -861,7 +1004,7 @@ class _NXdataXYVScatterView(DataView): def setData(self, data): data = self.normalizeData(data) nxd = NXdata(data) - signal_name = data.attrs["signal"] + signal_name = get_attr_as_string(data, "signal") # signal_errors = nx.errors # not supported group_name = data.name x_axis, y_axis = nxd.axes[-2:] @@ -902,7 +1045,8 @@ class _NXdataImageView(DataView): return widget def axesNames(self, data, info): - return [] + # disabled (used by default axis selector widget in Hdf5Viewer) + return None def clear(self): self.getWidget().clear() @@ -910,7 +1054,7 @@ class _NXdataImageView(DataView): def setData(self, data): data = self.normalizeData(data) nxd = NXdata(data) - signal_name = data.attrs["signal"] + signal_name = get_attr_as_string(data, "signal") group_name = data.name y_axis, x_axis = nxd.axes[-2:] y_label, x_label = nxd.axes_names[-2:] @@ -942,7 +1086,8 @@ class _NXdataStackView(DataView): return widget def axesNames(self, data, info): - return [] + # disabled (used by default axis selector widget in Hdf5Viewer) + return None def clear(self): self.getWidget().clear() @@ -950,7 +1095,7 @@ class _NXdataStackView(DataView): def setData(self, data): data = self.normalizeData(data) nxd = NXdata(data) - signal_name = data.attrs["signal"] + signal_name = get_attr_as_string(data, "signal") group_name = data.name z_axis, y_axis, x_axis = nxd.axes[-3:] z_label, y_label, x_label = nxd.axes_names[-3:] diff --git a/silx/gui/data/Hdf5TableView.py b/silx/gui/data/Hdf5TableView.py index 5d79907..ba737e3 100644 --- a/silx/gui/data/Hdf5TableView.py +++ b/silx/gui/data/Hdf5TableView.py @@ -30,7 +30,7 @@ from __future__ import division __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "07/04/2017" +__date__ = "29/09/2017" import functools import os.path @@ -40,6 +40,13 @@ import silx.io from .TextFormatter import TextFormatter import silx.gui.hdf5 from silx.gui.widgets import HierarchicalTableView +from ..hdf5.Hdf5Formatter import Hdf5Formatter + +try: + import h5py +except ImportError: + h5py = None + _logger = logging.getLogger(__name__) @@ -177,6 +184,7 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): self.__obj = None self.__data = _TableData(columnCount=4) self.__formatter = None + self.__hdf5Formatter = Hdf5Formatter(self) formatter = TextFormatter(self) self.setFormatter(formatter) self.setObject(data) @@ -207,7 +215,7 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): value = cell.value() if callable(value): value = value(self.__obj) - return str(value) + return value return None def flags(self, index): @@ -248,6 +256,22 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): else: self.reset() + def __formatHdf5Type(self, dataset): + """Format the HDF5 type""" + return self.__hdf5Formatter.humanReadableHdf5Type(dataset) + + def __formatDType(self, dataset): + """Format the numpy dtype""" + return self.__hdf5Formatter.humanReadableType(dataset, full=True) + + def __formatShape(self, dataset): + """Format the shape""" + if dataset.shape is None or len(dataset.shape) <= 1: + return self.__hdf5Formatter.humanReadableShape(dataset) + size = dataset.size + shape = self.__hdf5Formatter.humanReadableShape(dataset) + return u"%s = %s" % (shape, size) + def __initProperties(self): """Initialize the list of available properties according to the defined h5py-like object.""" @@ -270,26 +294,48 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): 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) + SEPARATOR = "::" + self.__data.addHeaderRow(headerLabel="Path info") 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) + self.__data.addHeaderValueRow("Basename", lambda x: x.local_basename) + self.__data.addHeaderValueRow("Name", lambda x: x.local_name) + local = lambda x: x.local_filename + SEPARATOR + x.local_name + self.__data.addHeaderValueRow("Local", local) + physical = lambda x: x.physical_filename + SEPARATOR + x.physical_name + self.__data.addHeaderValueRow("Physical", physical) + else: + # it's a real H5py object + self.__data.addHeaderValueRow("Basename", lambda x: os.path.basename(x.name)) + self.__data.addHeaderValueRow("Name", lambda x: x.name) + self.__data.addHeaderValueRow("File", lambda x: x.file.filename) + + if hasattr(obj, "path"): + # That's a link + if hasattr(obj, "filename"): + link = lambda x: x.filename + SEPARATOR + x.path + else: + link = lambda x: x.path + self.__data.addHeaderValueRow("Link", link) + else: + if silx.io.is_file(obj): + physical = lambda x: x.filename + SEPARATOR + x.name + else: + physical = lambda x: x.file.filename + SEPARATOR + x.name + self.__data.addHeaderValueRow("Physical", physical) if hasattr(obj, "dtype"): + self.__data.addHeaderRow(headerLabel="Data info") - self.__data.addHeaderValueRow("dtype", lambda x: x.dtype) + + if h5py is not None and hasattr(obj, "id"): + # display the HDF5 type + self.__data.addHeaderValueRow("HDF5 type", self.__formatHdf5Type) + self.__data.addHeaderValueRow("dtype", self.__formatDType) if hasattr(obj, "shape"): - self.__data.addHeaderValueRow("shape", lambda x: x.shape) - if hasattr(obj, "size"): - self.__data.addHeaderValueRow("size", lambda x: x.size) + self.__data.addHeaderValueRow("shape", self.__formatShape) if hasattr(obj, "chunks") and obj.chunks is not None: self.__data.addHeaderValueRow("chunks", lambda x: x.chunks) @@ -354,6 +400,8 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): if formatter is self.__formatter: return + self.__hdf5Formatter.setTextFormatter(formatter) + if qt.qVersion() > "4.6": self.beginResetModel() diff --git a/silx/gui/data/HexaTableView.py b/silx/gui/data/HexaTableView.py new file mode 100644 index 0000000..1b2a7e9 --- /dev/null +++ b/silx/gui/data/HexaTableView.py @@ -0,0 +1,278 @@ +# 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 model and widget to display raw data using an +hexadecimal viewer. +""" +from __future__ import division + +import numpy +import collections +from silx.gui import qt +import silx.io.utils +from silx.third_party import six +from silx.gui.widgets.TableWidget import CopySelectedCellsAction + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "27/09/2017" + + +class _VoidConnector(object): + """Byte connector to a numpy.void data. + + It uses a cache of 32 x 1KB and a direct read access API from HDF5. + """ + + def __init__(self, data): + self.__cache = collections.OrderedDict() + self.__len = data.itemsize + self.__data = data + + def __getBuffer(self, bufferId): + if bufferId not in self.__cache: + pos = bufferId << 10 + data = self.__data.tobytes()[pos:pos + 1024] + self.__cache[bufferId] = data + if len(self.__cache) > 32: + self.__cache.popitem() + else: + data = self.__cache[bufferId] + return data + + def __getitem__(self, pos): + """Returns the value of the byte at the given position. + + :param uint pos: Position of the byte + :rtype: int + """ + bufferId = pos >> 10 + bufferPos = pos & 0b1111111111 + data = self.__getBuffer(bufferId) + value = data[bufferPos] + if six.PY2: + return ord(value) + else: + return value + + def __len__(self): + """ + Returns the number of available bytes. + + :rtype: uint + """ + return self.__len + + +class HexaTableModel(qt.QAbstractTableModel): + """This data model provides access to a numpy void data. + + Bytes are displayed one by one as a hexadecimal viewer. + + The 16th first columns display bytes as hexadecimal, the last column + displays the same data as ASCII. + + :param qt.QObject parent: Parent object + :param data: A numpy array or a h5py dataset + """ + def __init__(self, parent=None, data=None): + qt.QAbstractTableModel.__init__(self, parent) + + self.__data = None + self.__connector = None + self.setArrayData(data) + + if hasattr(qt.QFontDatabase, "systemFont"): + self.__font = qt.QFontDatabase.systemFont(qt.QFontDatabase.FixedFont) + else: + self.__font = qt.QFont("Monospace") + self.__font.setStyleHint(qt.QFont.TypeWriter) + self.__palette = qt.QPalette() + + def rowCount(self, parent_idx=None): + """Returns number of rows to be displayed in table""" + if self.__connector is None: + return 0 + return ((len(self.__connector) - 1) >> 4) + 1 + + def columnCount(self, parent_idx=None): + """Returns number of columns to be displayed in table""" + return 0x10 + 1 + + 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.__connector is None: + return None + + row = index.row() + column = index.column() + + if role == qt.Qt.DisplayRole: + if column == 0x10: + start = (row << 4) + text = "" + for i in range(0x10): + pos = start + i + if pos >= len(self.__connector): + break + value = self.__connector[pos] + if value > 0x20 and value < 0x7F: + text += chr(value) + else: + text += "." + return text + else: + pos = (row << 4) + column + if pos < len(self.__connector): + value = self.__connector[pos] + return "%02X" % value + else: + return "" + elif role == qt.Qt.FontRole: + return self.__font + + elif role == qt.Qt.BackgroundColorRole: + pos = (row << 4) + column + if column != 0x10 and pos >= len(self.__connector): + return self.__palette.color(qt.QPalette.Disabled, qt.QPalette.Background) + else: + return None + + 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: + return "%02X" % (section << 4) + if orientation == qt.Qt.Horizontal: + if section == 0x10: + return "ASCII" + else: + return "%02X" % section + elif role == qt.Qt.FontRole: + return self.__font + elif role == qt.Qt.TextAlignmentRole: + if orientation == qt.Qt.Vertical: + return qt.Qt.AlignRight + if orientation == qt.Qt.Horizontal: + if section == 0x10: + return qt.Qt.AlignLeft + else: + return qt.Qt.AlignCenter + return None + + def flags(self, index): + """QAbstractTableModel method to inform the view whether data + is editable or not. + """ + row = index.row() + column = index.column() + pos = (row << 4) + column + if column != 0x10 and pos >= len(self.__connector): + return qt.Qt.NoItemFlags + return qt.QAbstractTableModel.flags(self, index) + + def setArrayData(self, data): + """Set the data array. + + :param data: A numpy object or a dataset. + """ + if qt.qVersion() > "4.6": + self.beginResetModel() + + self.__connector = None + self.__data = data + if self.__data is not None: + if silx.io.utils.is_dataset(self.__data): + data = data[()] + elif isinstance(self.__data, numpy.ndarray): + data = data[()] + self.__connector = _VoidConnector(data) + + 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 + + +class HexaTableView(qt.QTableView): + """TableView using HexaTableModel as default model. + + It customs the column size to provide a better layout. + """ + def __init__(self, parent=None): + """ + Constructor + + :param qt.QWidget parent: parent QWidget + """ + qt.QTableView.__init__(self, parent) + + model = HexaTableModel(self) + self.setModel(model) + self._copyAction = CopySelectedCellsAction(self) + self.addAction(self._copyAction) + + def copy(self): + self._copyAction.trigger() + + def setArrayData(self, data): + """Set the data array. + + :param data: A numpy object or a dataset. + """ + self.model().setArrayData(data) + self.__fixHeader() + + def __fixHeader(self): + """Update the view according to the state of the auto-resize""" + header = self.horizontalHeader() + if qt.qVersion() < "5.0": + setResizeMode = header.setResizeMode + else: + setResizeMode = header.setSectionResizeMode + + header.setDefaultSectionSize(30) + header.setStretchLastSection(True) + for i in range(0x10): + setResizeMode(i, qt.QHeaderView.Fixed) + setResizeMode(0x10, qt.QHeaderView.Stretch) diff --git a/silx/gui/data/NXdataWidgets.py b/silx/gui/data/NXdataWidgets.py index 343c7f9..b820380 100644 --- a/silx/gui/data/NXdataWidgets.py +++ b/silx/gui/data/NXdataWidgets.py @@ -26,7 +26,7 @@ """ __authors__ = ["P. Knobel"] __license__ = "MIT" -__date__ = "20/03/2017" +__date__ = "27/06/2017" import numpy @@ -135,8 +135,8 @@ class ArrayCurvePlot(qt.QWidget): 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._plot.getXAxis().setLabel(self.__axis_name or "X") + self._plot.getYAxis().setLabel(self.__signal_name or "Y") self._updateCurve() if not self.__selector_is_connected: @@ -188,8 +188,8 @@ class ArrayCurvePlot(qt.QWidget): xerror=self.__axis_errors, yerror=y_errors) self._plot.resetZoom() - self._plot.setGraphXLabel(self.__axis_name) - self._plot.setGraphYLabel(self.__signal_name) + self._plot.getXAxis().setLabel(self.__axis_name) + self._plot.getYAxis().setLabel(self.__signal_name) def clear(self): self._plot.clear() @@ -289,8 +289,8 @@ class ArrayImagePlot(qt.QWidget): 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._plot.getXAxis().setLabel(self.__x_axis_name or "X") + self._plot.getYAxis().setLabel(self.__y_axis_name or "Y") self._updateImage() @@ -352,8 +352,8 @@ class ArrayImagePlot(qt.QWidget): numpy.ravel(scattery), numpy.ravel(img), legend=legend) - self._plot.setGraphXLabel(self.__x_axis_name) - self._plot.setGraphYLabel(self.__y_axis_name) + self._plot.getXAxis().setLabel(self.__x_axis_name) + self._plot.getYAxis().setLabel(self.__y_axis_name) self._plot.resetZoom() def clear(self): @@ -450,8 +450,8 @@ class ArrayStackPlot(qt.QWidget): 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._stack_view.getPlot().getXAxis().setLabel(self.__x_axis_name or "X") + self._stack_view.getPlot().getYAxis().setLabel(self.__y_axis_name or "Y") self._updateStack() diff --git a/silx/gui/data/RecordTableView.py b/silx/gui/data/RecordTableView.py index ce6a178..54881b7 100644 --- a/silx/gui/data/RecordTableView.py +++ b/silx/gui/data/RecordTableView.py @@ -37,7 +37,7 @@ from silx.gui.widgets.TableWidget import CopySelectedCellsAction __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "27/01/2017" +__date__ = "02/10/2017" class _MultiLineItem(qt.QItemDelegate): @@ -206,9 +206,9 @@ class RecordTableModel(qt.QAbstractTableModel): data = data[key[1]] if role == qt.Qt.DisplayRole: - return self.__formatter.toString(data) + return self.__formatter.toString(data, dtype=self.__data.dtype) elif role == qt.Qt.EditRole: - return self.__editFormatter.toString(data) + return self.__editFormatter.toString(data, dtype=self.__data.dtype) return None def headerData(self, section, orientation, role=qt.Qt.DisplayRole): @@ -270,11 +270,11 @@ class RecordTableModel(qt.QAbstractTableModel): 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(): + fields = sorted(data.dtype.fields.items(), key=lambda e: e[1][1]) + for name, (dtype, _index) in fields: if dtype.shape != tuple(): keys = itertools.product(*[range(x) for x in dtype.shape]) for key in keys: diff --git a/silx/gui/data/TextFormatter.py b/silx/gui/data/TextFormatter.py index f074de5..37e1f48 100644 --- a/silx/gui/data/TextFormatter.py +++ b/silx/gui/data/TextFormatter.py @@ -27,14 +27,18 @@ data module to format data as text in the same way.""" __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "26/04/2017" +__date__ = "27/09/2017" import numpy import numbers -import binascii from silx.third_party import six from silx.gui import qt +try: + import h5py +except ImportError: + h5py = None + class TextFormatter(qt.QObject): """Formatter to convert data to string. @@ -73,11 +77,13 @@ class TextFormatter(qt.QObject): self.__floatFormat = formatter.floatFormat() self.__useQuoteForText = formatter.useQuoteForText() self.__imaginaryUnit = formatter.imaginaryUnit() + self.__enumFormat = formatter.enumFormat() else: self.__integerFormat = "%d" self.__floatFormat = "%g" self.__useQuoteForText = True self.__imaginaryUnit = u"j" + self.__enumFormat = u"%(name)s(%(value)d)" def integerFormat(self): """Returns the format string controlling how the integer data @@ -162,40 +168,151 @@ class TextFormatter(qt.QObject): self.__imaginaryUnit = imaginaryUnit self.formatChanged.emit() - def toString(self, data): + def setEnumFormat(self, value): + """Set format string controlling how the enum data are + formated by this object. + + :param str value: Format string (e.g. "%(name)s(%(value)d)"). + This is the C-style format string used by python when formatting + strings with the modulus operator. + """ + if self.__enumFormat == value: + return + self.__enumFormat = value + self.formatChanged.emit() + + def enumFormat(self): + """Returns the format string controlling how the enum 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.__enumFormat + + def __formatText(self, text): + if self.__useQuoteForText: + text = "\"%s\"" % text.replace("\\", "\\\\").replace("\"", "\\\"") + return text + + def __formatBinary(self, data): + if isinstance(data, numpy.void): + if six.PY2: + data = [ord(d) for d in data.item()] + else: + data = data.item().astype(numpy.uint8) + else: + data = [ord(d) for d in data] + data = ["\\x%02X" % d for d in data] + if self.__useQuoteForText: + return "b\"%s\"" % "".join(data) + else: + return "".join(data) + + def __formatSafeAscii(self, data): + if six.PY2: + data = [ord(d) for d in data] + data = [chr(d) if (d > 0x20 and d < 0x7F) else "\\x%02X" % d for d in data] + if self.__useQuoteForText: + data = [c if c != '"' else "\\" + c for c in data] + return "b\"%s\"" % "".join(data) + else: + return "".join(data) + + def __formatH5pyObject(self, data, dtype): + # That's an HDF5 object + ref = h5py.check_dtype(ref=dtype) + if ref is not None: + if bool(data): + return "REF" + else: + return "NULL_REF" + vlen = h5py.check_dtype(vlen=dtype) + if vlen is not None: + if vlen == six.text_type: + # HDF5 UTF8 + return self.__formatText(data) + elif vlen == six.binary_type: + # HDF5 ASCII + try: + text = "%s" % data.decode("ascii") + return self.__formatText(text) + except UnicodeDecodeError: + return self.__formatSafeAscii(data) + return None + + def toString(self, data, dtype=None): """Format a data into a string using formatter options :param object data: Data to render + :param dtype: enforce a dtype (mostly used to remember the h5py dtype, + special h5py dtypes are not propagated from array to items) :rtype: str """ if isinstance(data, tuple): text = [self.toString(d) for d in data] return "(" + " ".join(text) + ")" - elif isinstance(data, (list, numpy.ndarray)): + elif isinstance(data, list): text = [self.toString(d) for d in data] return "[" + " ".join(text) + "]" + elif isinstance(data, (numpy.ndarray)): + if dtype is None: + dtype = data.dtype + if data.shape == (): + # it is a scaler + return self.toString(data[()], dtype) + else: + text = [self.toString(d, dtype) for d in data] + return "[" + " ".join(text) + "]" elif isinstance(data, numpy.void): - dtype = data.dtype + if dtype is None: + dtype = data.dtype if data.dtype.fields is not None: - text = [self.toString(data[f]) for f in dtype.fields] + text = [self.toString(data[f], dtype) 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 + return self.__formatBinary(data) + elif isinstance(data, (numpy.unicode_, six.text_type)): + return self.__formatText(data) + elif isinstance(data, (numpy.string_, six.binary_type)): + if dtype is not None: + # Maybe a sub item from HDF5 + if dtype.kind == 'S': + try: + text = "%s" % data.decode("ascii") + return self.__formatText(text) + except UnicodeDecodeError: + return self.__formatSafeAscii(data) + elif dtype.kind == 'O': + if h5py is not None: + text = self.__formatH5pyObject(data, dtype) + if text is not None: + return text try: + # Try ascii/utf-8 text = "%s" % data.decode("utf-8") - if self.__useQuoteForText: - text = "\"%s\"" % text.replace("\"", "\\\"") - return text + return self.__formatText(text) except UnicodeDecodeError: pass - return "0x" + binascii.hexlify(data).decode("ascii") + return self.__formatBinary(data) 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.__formatText(text) + elif isinstance(data, (numpy.integer)): + if dtype is None: + dtype = data.dtype + if h5py is not None: + enumType = h5py.check_dtype(enum=dtype) + if enumType is not None: + for key, value in enumType.items(): + if value == data: + result = {} + result["name"] = key + result["value"] = data + return self.__enumFormat % result + return self.__integerFormat % data + elif isinstance(data, (numbers.Integral)): return self.__integerFormat % data elif isinstance(data, (numbers.Real, numpy.floating)): # It have to be done before complex checking @@ -219,4 +336,21 @@ class TextFormatter(qt.QObject): template = self.__floatFormat params = (data.real) return template % params + elif h5py is not None and isinstance(data, h5py.h5r.Reference): + dtype = h5py.special_dtype(ref=h5py.Reference) + text = self.__formatH5pyObject(data, dtype) + return text + elif h5py is not None and isinstance(data, h5py.h5r.RegionReference): + dtype = h5py.special_dtype(ref=h5py.RegionReference) + text = self.__formatH5pyObject(data, dtype) + return text + elif isinstance(data, numpy.object_) or dtype is not None: + if dtype is None: + dtype = data.dtype + if h5py is not None: + text = self.__formatH5pyObject(data, dtype) + if text is not None: + return text + # That's a numpy object + return str(data) return str(data) diff --git a/silx/gui/data/test/test_dataviewer.py b/silx/gui/data/test/test_dataviewer.py index 5a0de0b..dd3114a 100644 --- a/silx/gui/data/test/test_dataviewer.py +++ b/silx/gui/data/test/test_dataviewer.py @@ -24,7 +24,7 @@ # ###########################################################################*/ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "10/04/2017" +__date__ = "22/08/2017" import os import tempfile @@ -42,8 +42,6 @@ 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: @@ -111,6 +109,24 @@ class AbstractDataViewerTests(TestCaseQt): self.assertEqual(DataViewer.RAW_MODE, widget.displayMode()) self.assertIn(DataViewer.PLOT2D_MODE, availableModes) + def test_plot_2d_bool(self): + data = numpy.zeros((10, 10), dtype=numpy.bool) + data[::2, ::2] = True + 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_2d_complex_data(self): + data = numpy.arange(3 ** 2, dtype=numpy.complex) + 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 @@ -212,6 +228,7 @@ class AbstractDataViewerTests(TestCaseQt): self.assertTrue(view not in widget.availableViews()) self.assertTrue(view not in widget.currentAvailableViews()) + class TestDataViewer(AbstractDataViewerTests): def create_widget(self): return DataViewer() @@ -225,11 +242,10 @@ class TestDataViewerFrame(AbstractDataViewerTests): class TestDataView(TestCaseQt): def createComplexData(self): - line = [1, 2j, 3+3j, 4] + line = [1, 2j, 3 + 3j, 4] image = [line, line, line, line] cube = [image, image, image, image] - data = numpy.array(cube, - dtype=numpy.complex) + data = numpy.array(cube, dtype=numpy.complex) return data def createDataViewWithData(self, dataViewClass, data): diff --git a/silx/gui/data/test/test_textformatter.py b/silx/gui/data/test/test_textformatter.py index f21e033..2a7a66b 100644 --- a/silx/gui/data/test/test_textformatter.py +++ b/silx/gui/data/test/test_textformatter.py @@ -24,13 +24,22 @@ # ###########################################################################*/ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "24/01/2017" +__date__ = "27/09/2017" import unittest +import shutil +import tempfile +import numpy from silx.gui.test.utils import TestCaseQt from silx.gui.test.utils import SignalListener from ..TextFormatter import TextFormatter +from silx.third_party import six + +try: + import h5py +except ImportError: + h5py = None class TestTextFormatter(TestCaseQt): @@ -83,10 +92,108 @@ class TestTextFormatter(TestCaseQt): self.assertEquals(result, '"toto"') +class TestTextFormatterWithH5py(TestCaseQt): + + @classmethod + def setUpClass(cls): + super(TestTextFormatterWithH5py, cls).setUpClass() + if h5py is None: + raise unittest.SkipTest("h5py is not available") + + cls.tmpDirectory = tempfile.mkdtemp() + cls.h5File = h5py.File("%s/formatter.h5" % cls.tmpDirectory, mode="w") + cls.formatter = TextFormatter() + + @classmethod + def tearDownClass(cls): + super(TestTextFormatterWithH5py, cls).tearDownClass() + cls.h5File.close() + cls.h5File = None + shutil.rmtree(cls.tmpDirectory) + + def create_dataset(self, data, dtype=None): + testName = "%s" % self.id() + dataset = self.h5File.create_dataset(testName, data=data, dtype=dtype) + return dataset + + def testAscii(self): + d = self.create_dataset(data=b"abc") + result = self.formatter.toString(d[()], dtype=d.dtype) + self.assertEquals(result, '"abc"') + + def testUnicode(self): + d = self.create_dataset(data=u"i\u2661cookies") + result = self.formatter.toString(d[()], dtype=d.dtype) + self.assertEquals(len(result), 11) + self.assertEquals(result, u'"i\u2661cookies"') + + def testBadAscii(self): + d = self.create_dataset(data=b"\xF0\x9F\x92\x94") + result = self.formatter.toString(d[()], dtype=d.dtype) + self.assertEquals(result, 'b"\\xF0\\x9F\\x92\\x94"') + + def testVoid(self): + d = self.create_dataset(data=numpy.void(b"abc\xF0")) + result = self.formatter.toString(d[()], dtype=d.dtype) + self.assertEquals(result, 'b"\\x61\\x62\\x63\\xF0"') + + def testEnum(self): + dtype = h5py.special_dtype(enum=('i', {"RED": 0, "GREEN": 1, "BLUE": 42})) + d = numpy.array(42, dtype=dtype) + d = self.create_dataset(data=d) + result = self.formatter.toString(d[()], dtype=d.dtype) + self.assertEquals(result, 'BLUE(42)') + + def testRef(self): + dtype = h5py.special_dtype(ref=h5py.Reference) + d = numpy.array(self.h5File.ref, dtype=dtype) + d = self.create_dataset(data=d) + result = self.formatter.toString(d[()], dtype=d.dtype) + self.assertEquals(result, 'REF') + + def testArrayAscii(self): + d = self.create_dataset(data=[b"abc"]) + result = self.formatter.toString(d[()], dtype=d.dtype) + self.assertEquals(result, '["abc"]') + + def testArrayUnicode(self): + dtype = h5py.special_dtype(vlen=six.text_type) + d = numpy.array([u"i\u2661cookies"], dtype=dtype) + d = self.create_dataset(data=d) + result = self.formatter.toString(d[()], dtype=d.dtype) + self.assertEquals(len(result), 13) + self.assertEquals(result, u'["i\u2661cookies"]') + + def testArrayBadAscii(self): + d = self.create_dataset(data=[b"\xF0\x9F\x92\x94"]) + result = self.formatter.toString(d[()], dtype=d.dtype) + self.assertEquals(result, '[b"\\xF0\\x9F\\x92\\x94"]') + + def testArrayVoid(self): + d = self.create_dataset(data=numpy.void([b"abc\xF0"])) + result = self.formatter.toString(d[()], dtype=d.dtype) + self.assertEquals(result, '[b"\\x61\\x62\\x63\\xF0"]') + + def testArrayEnum(self): + dtype = h5py.special_dtype(enum=('i', {"RED": 0, "GREEN": 1, "BLUE": 42})) + d = numpy.array([42, 1, 100], dtype=dtype) + d = self.create_dataset(data=d) + result = self.formatter.toString(d[()], dtype=d.dtype) + self.assertEquals(result, '[BLUE(42) GREEN(1) 100]') + + def testArrayRef(self): + dtype = h5py.special_dtype(ref=h5py.Reference) + d = numpy.array([self.h5File.ref, None], dtype=dtype) + d = self.create_dataset(data=d) + result = self.formatter.toString(d[()], dtype=d.dtype) + self.assertEquals(result, '[REF NULL_REF]') + + def suite(): + loadTests = unittest.defaultTestLoader.loadTestsFromTestCase test_suite = unittest.TestSuite() - test_suite.addTest( - unittest.defaultTestLoader.loadTestsFromTestCase(TestTextFormatter)) + test_suite.addTest(loadTests(TestTextFormatter)) + test_suite.addTest(loadTests(TestTextFormatterWithH5py)) return test_suite diff --git a/silx/gui/fit/BackgroundWidget.py b/silx/gui/fit/BackgroundWidget.py index 577a8c7..2171e87 100644 --- a/silx/gui/fit/BackgroundWidget.py +++ b/silx/gui/fit/BackgroundWidget.py @@ -26,7 +26,11 @@ # #########################################################################*/ """This module provides a background configuration widget :class:`BackgroundWidget` and a corresponding dialog window -:class:`BackgroundDialog`.""" +:class:`BackgroundDialog`. + +.. image:: img/BackgroundDialog.png + :height: 300px +""" import sys import numpy from silx.gui import qt @@ -35,7 +39,7 @@ from silx.math.fit import filters __authors__ = ["V.A. Sole", "P. Knobel"] __license__ = "MIT" -__date__ = "24/01/2017" +__date__ = "28/06/2017" class HorizontalSpacer(qt.QWidget): @@ -262,7 +266,7 @@ class BackgroundParamWidget(qt.QWidget): class BackgroundWidget(qt.QWidget): - """Background configuration widget, with a :class:`PlotWindow`. + """Background configuration widget, with a plot to preview the results. Strip and snip filters parameters can be adjusted using input widgets, and the computed backgrounds are plotted next to the original data to @@ -400,7 +404,7 @@ class BackgroundWidget(qt.QWidget): 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) + self.graphWidget.getXAxis().setLimits(self._xmin, self._xmax) class BackgroundDialog(qt.QDialog): @@ -467,11 +471,11 @@ class BackgroundDialog(qt.QDialog): return self.parametersWidget.getParameters() def setParameters(self, ddict): - """See :meth:`BackgroundWidget.setParameters`""" + """See :meth:`BackgroundWidget.setPrintGeometry`""" return self.parametersWidget.setParameters(ddict) def setDefault(self, ddict): - """Alias for :meth:`setParameters`""" + """Alias for :meth:`setPrintGeometry`""" return self.setParameters(ddict) diff --git a/silx/gui/fit/FitConfig.py b/silx/gui/fit/FitConfig.py index 70b6fbe..04e411b 100644 --- a/silx/gui/fit/FitConfig.py +++ b/silx/gui/fit/FitConfig.py @@ -307,7 +307,7 @@ class SearchPage(qt.QWidget): self.yScalingEntry.setToolTip( "Data values will be multiplied by this value prior to peak" + " search") - self.yScalingEntry.setValidator(qt.QDoubleValidator()) + self.yScalingEntry.setValidator(qt.QDoubleValidator(self)) layout3.addWidget(self.yScalingEntry) # ---------------------------------------------------- @@ -324,7 +324,7 @@ class SearchPage(qt.QWidget): "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 = qt.QDoubleValidator(self) sensivalidator.setBottom(1.0) self.sensitivityEntry.setValidator(sensivalidator) layout4.addWidget(self.sensitivityEntry) @@ -418,7 +418,7 @@ class BackgroundPage(qt.QGroupBox): "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()) + self.thresholdFactorEntry.setValidator(qt.QDoubleValidator(self)) layout.addWidget(self.thresholdFactorEntry, 2, 1) self.smoothStripGB = qt.QGroupBox("Apply smoothing prior to strip", self) diff --git a/silx/gui/fit/FitWidget.py b/silx/gui/fit/FitWidget.py index a5c3cfd..7012b63 100644 --- a/silx/gui/fit/FitWidget.py +++ b/silx/gui/fit/FitWidget.py @@ -87,6 +87,8 @@ class FitWidget(qt.QWidget): run the estimation, set constraints on parameters and run the actual fit. The results are displayed in a table. + + .. image:: img/FitWidget.png """ sigFitWidgetSignal = qt.Signal(object) """This signal is emitted by the estimation and fit methods. diff --git a/silx/gui/hdf5/Hdf5Formatter.py b/silx/gui/hdf5/Hdf5Formatter.py new file mode 100644 index 0000000..3a4c1c1 --- /dev/null +++ b/silx/gui/hdf5/Hdf5Formatter.py @@ -0,0 +1,229 @@ +# 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 widgets to format HDF5 data as +text.""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "27/09/2017" + +import numpy +from silx.third_party import six +from silx.gui import qt +from silx.gui.data.TextFormatter import TextFormatter + +try: + import h5py +except ImportError: + h5py = None + + +class Hdf5Formatter(qt.QObject): + """Formatter to convert HDF5 data to string. + """ + + formatChanged = qt.Signal() + """Emitted when properties of the formatter change.""" + + def __init__(self, parent=None, textFormatter=None): + """ + Constructor + + :param qt.QObject parent: Owner of the object + :param TextFormatter formatter: Text formatter + """ + qt.QObject.__init__(self, parent) + if textFormatter is not None: + self.__formatter = textFormatter + else: + self.__formatter = TextFormatter(self) + self.__formatter.formatChanged.connect(self.__formatChanged) + + def textFormatter(self): + """Returns the used text formatter + + :rtype: TextFormatter + """ + return self.__formatter + + def setTextFormatter(self, textFormatter): + """Set the text formatter to be used + + :param TextFormatter textFormatter: The text formatter to use + """ + if textFormatter is None: + raise ValueError("Formatter expected but None found") + if self.__formatter is textFormatter: + return + self.__formatter.formatChanged.disconnect(self.__formatChanged) + self.__formatter = textFormatter + self.__formatter.formatChanged.connect(self.__formatChanged) + self.__formatChanged() + + def __formatChanged(self): + self.formatChanged.emit() + + def humanReadableShape(self, dataset): + if dataset.shape is None: + return "none" + 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 is None: + return "No data" + + dtype = dataset.dtype + if dataset.dtype.type == numpy.void: + if dtype.fields is None: + return "Raw data" + + if dataset.shape == tuple(): + numpy_object = dataset[()] + text = self.__formatter.toString(numpy_object, dtype=dataset.dtype) + else: + if dataset.size < 5 and dataset.compression is None: + numpy_object = dataset[0:5] + text = self.__formatter.toString(numpy_object, dtype=dataset.dtype) + else: + dimension = len(dataset.shape) + if dataset.compression is not None: + text = "Compressed %dD data" % dimension + else: + text = "%dD data" % dimension + return text + + def humanReadableType(self, dataset, full=False): + dtype = dataset.dtype + return self.humanReadableDType(dtype, full) + + def humanReadableDType(self, dtype, full=False): + if dtype == six.binary_type or numpy.issubdtype(dtype, numpy.string_): + text = "string" + if full: + text = "ASCII " + text + return text + elif dtype == six.text_type or numpy.issubdtype(dtype, numpy.unicode_): + text = "string" + if full: + text = "UTF-8 " + text + return text + elif dtype.type == numpy.object_: + ref = h5py.check_dtype(ref=dtype) + if ref is not None: + return "reference" + vlen = h5py.check_dtype(vlen=dtype) + if vlen is not None: + text = self.humanReadableDType(vlen, full=full) + if full: + text = "variable-length " + text + return text + return "object" + elif dtype.type == numpy.bool_: + return "bool" + elif dtype.type == numpy.void: + if dtype.fields is None: + return "opaque" + else: + if not full: + return "compound" + else: + compound = [d[0] for d in dtype.fields.values()] + compound = [self.humanReadableDType(d) for d in compound] + return "compound(%s)" % ", ".join(compound) + elif numpy.issubdtype(dtype, numpy.integer): + if h5py is not None: + enumType = h5py.check_dtype(enum=dtype) + if enumType is not None: + return "enum" + + text = str(dtype.newbyteorder('N')) + if full: + if dtype.byteorder == "<": + text = "Little-endian " + text + elif dtype.byteorder == ">": + text = "Big-endian " + text + elif dtype.byteorder == "=": + text = "Native " + text + + dtype = dtype.newbyteorder('N') + return text + + def humanReadableHdf5Type(self, dataset): + """Format the internal HDF5 type as a string""" + t = dataset.id.get_type() + class_ = t.get_class() + if class_ == h5py.h5t.NO_CLASS: + return "NO_CLASS" + elif class_ == h5py.h5t.INTEGER: + return "INTEGER" + elif class_ == h5py.h5t.FLOAT: + return "FLOAT" + elif class_ == h5py.h5t.TIME: + return "TIME" + elif class_ == h5py.h5t.STRING: + charset = t.get_cset() + strpad = t.get_strpad() + text = "" + + if strpad == h5py.h5t.STR_NULLTERM: + text += "NULLTERM" + elif strpad == h5py.h5t.STR_NULLPAD: + text += "NULLPAD" + elif strpad == h5py.h5t.STR_SPACEPAD: + text += "SPACEPAD" + else: + text += "UNKNOWN_STRPAD" + + if t.is_variable_str(): + text += " VARIABLE" + + if charset == h5py.h5t.CSET_ASCII: + text += " ASCII" + elif charset == h5py.h5t.CSET_UTF8: + text += " UTF8" + else: + text += " UNKNOWN_CSET" + + return text + " STRING" + elif class_ == h5py.h5t.BITFIELD: + return "BITFIELD" + elif class_ == h5py.h5t.OPAQUE: + return "OPAQUE" + elif class_ == h5py.h5t.COMPOUND: + return "COMPOUND" + elif class_ == h5py.h5t.REFERENCE: + return "REFERENCE" + elif class_ == h5py.h5t.ENUM: + return "ENUM" + elif class_ == h5py.h5t.VLEN: + return "VLEN" + elif class_ == h5py.h5t.ARRAY: + return "ARRAY" + else: + return "UNKNOWN_CLASS" diff --git a/silx/gui/hdf5/Hdf5HeaderView.py b/silx/gui/hdf5/Hdf5HeaderView.py index 5912230..7baa6e0 100644 --- a/silx/gui/hdf5/Hdf5HeaderView.py +++ b/silx/gui/hdf5/Hdf5HeaderView.py @@ -25,10 +25,11 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "08/11/2016" +__date__ = "16/06/2017" from .. import qt +from .Hdf5TreeModel import Hdf5TreeModel QTVERSION = qt.qVersion() @@ -83,19 +84,21 @@ class Hdf5HeaderView(qt.QHeaderView): 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) + setResizeMode(Hdf5TreeModel.NAME_COLUMN, qt.QHeaderView.ResizeToContents) + setResizeMode(Hdf5TreeModel.TYPE_COLUMN, qt.QHeaderView.ResizeToContents) + setResizeMode(Hdf5TreeModel.SHAPE_COLUMN, qt.QHeaderView.ResizeToContents) + setResizeMode(Hdf5TreeModel.VALUE_COLUMN, qt.QHeaderView.Interactive) + setResizeMode(Hdf5TreeModel.DESCRIPTION_COLUMN, qt.QHeaderView.Interactive) + setResizeMode(Hdf5TreeModel.NODE_COLUMN, qt.QHeaderView.ResizeToContents) + setResizeMode(Hdf5TreeModel.LINK_COLUMN, 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) + setResizeMode(Hdf5TreeModel.NAME_COLUMN, qt.QHeaderView.Interactive) + setResizeMode(Hdf5TreeModel.TYPE_COLUMN, qt.QHeaderView.Interactive) + setResizeMode(Hdf5TreeModel.SHAPE_COLUMN, qt.QHeaderView.Interactive) + setResizeMode(Hdf5TreeModel.VALUE_COLUMN, qt.QHeaderView.Interactive) + setResizeMode(Hdf5TreeModel.DESCRIPTION_COLUMN, qt.QHeaderView.Interactive) + setResizeMode(Hdf5TreeModel.NODE_COLUMN, qt.QHeaderView.Interactive) + setResizeMode(Hdf5TreeModel.LINK_COLUMN, qt.QHeaderView.Interactive) def setAutoResizeColumns(self, autoResize): """Enable/disable auto-resize. When auto-resized, the header take care diff --git a/silx/gui/hdf5/Hdf5Item.py b/silx/gui/hdf5/Hdf5Item.py index 40793a4..f131f61 100644 --- a/silx/gui/hdf5/Hdf5Item.py +++ b/silx/gui/hdf5/Hdf5Item.py @@ -25,10 +25,9 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "20/01/2017" +__date__ = "26/09/2017" -import numpy import logging import collections from .. import qt @@ -37,6 +36,7 @@ from . import _utils from .Hdf5Node import Hdf5Node import silx.io.utils from silx.gui.data.TextFormatter import TextFormatter +from ..hdf5.Hdf5Formatter import Hdf5Formatter _logger = logging.getLogger(__name__) @@ -47,6 +47,8 @@ except ImportError as e: raise e _formatter = TextFormatter() +_hdf5Formatter = Hdf5Formatter(textFormatter=_formatter) +# FIXME: The formatter should be an attribute of the Hdf5Model class Hdf5Item(Hdf5Node): @@ -55,7 +57,7 @@ class Hdf5Item(Hdf5Node): tree structure. """ - def __init__(self, text, obj, parent, key=None, h5pyClass=None, isBroken=False, populateAll=False): + def __init__(self, text, obj, parent, key=None, h5pyClass=None, linkClass=None, populateAll=False): """ :param str text: text displayed :param object obj: Pointer to h5py data. See the `obj` attribute. @@ -63,9 +65,10 @@ class Hdf5Item(Hdf5Node): self.__obj = obj self.__key = key self.__h5pyClass = h5pyClass - self.__isBroken = isBroken + self.__isBroken = obj is None and h5pyClass is None self.__error = None self.__text = text + self.__linkClass = linkClass Hdf5Node.__init__(self, parent, populateAll=populateAll) @property @@ -88,16 +91,26 @@ class Hdf5Item(Hdf5Node): :rtype: h5py.File or h5py.Dataset or h5py.Group """ - if self.__h5pyClass is None: + if self.__h5pyClass is None and self.obj is not None: self.__h5pyClass = silx.io.utils.get_h5py_class(self.obj) return self.__h5pyClass + @property + def linkClass(self): + """Returns the link class object of this node + + :type: h5py.SoftLink or h5py.HardLink or h5py.ExternalLink or None + """ + return self.__linkClass + def isGroupObj(self): """Returns true if the stored HDF5 object is a group (contains sub groups or datasets). :rtype: bool """ + if self.h5pyClass is None: + return False return issubclass(self.h5pyClass, h5py.Group) def isBrokenObj(self): @@ -111,6 +124,14 @@ class Hdf5Item(Hdf5Node): """ return self.__isBroken + def _getFormatter(self): + """ + Returns an Hdf5Formatter + + :rtype: Hdf5Formatter + """ + return _hdf5Formatter + def _expectedChildCount(self): if self.isGroupObj(): return len(self.obj) @@ -158,6 +179,22 @@ class Hdf5Item(Hdf5Node): self.__isBroken = True else: self.__obj = obj + if not self.isGroupObj(): + try: + # pre-fetch of the data + if obj.shape is None: + pass + elif obj.shape == tuple(): + obj[()] + else: + if obj.compression is None and obj.size > 0: + key = tuple([0] * len(obj.shape)) + obj[key] + except Exception as e: + _logger.debug(e, exc_info=True) + message = "%s broken. %s" % (self.__obj.name, e.args[0]) + self.__error = message + self.__isBroken = True self.__key = None @@ -166,15 +203,15 @@ class Hdf5Item(Hdf5Node): for name in self.obj: try: class_ = self.obj.get(name, getclass=True) - has_error = False + link = self.obj.get(name, getclass=True, getlink=True) except Exception as e: - _logger.error("Internal h5py error", exc_info=True) + _logger.warn("Internal h5py error", exc_info=True) + class_ = None try: - class_ = self.obj.get(name, getclass=True, getlink=True) + link = 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) + link = h5py.HardLink + item = Hdf5Item(text=name, obj=None, parent=self, key=name, h5pyClass=class_, linkClass=link) self.appendChild(item) def hasChildren(self): @@ -191,6 +228,8 @@ class Hdf5Item(Hdf5Node): :rtype: qt.QIcon """ + # Pre-fetch the object, in case it is broken + obj = self.obj style = qt.QApplication.style() if self.__isBroken: icon = style.standardIcon(qt.QStyle.SP_MessageBoxCritical) @@ -205,99 +244,53 @@ class Hdf5Item(Hdf5Node): 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) + if obj.shape is None: + name = "item-none" + elif len(obj.shape) < 4: + name = "item-%ddim" % len(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): + def _createTooltipAttributes(self): """ Add key/value attributes that will be displayed in the item tooltip :param Dict[str,str] attributeDict: Key/value attributes """ + attributeDict = collections.OrderedDict() + if issubclass(self.h5pyClass, h5py.Dataset): - attributeDict["Title"] = "HDF5 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) + attributeDict["Shape"] = self._getFormatter().humanReadableShape(self.obj) + attributeDict["Value"] = self._getFormatter().humanReadableValue(self.obj) + attributeDict["Data type"] = self._getFormatter().humanReadableType(self.obj, full=True) elif issubclass(self.h5pyClass, h5py.Group): - attributeDict["Title"] = "HDF5 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["#Title"] = "HDF5 File" attributeDict["Name"] = self.basename attributeDict["Path"] = "/" elif isinstance(self.obj, h5py.ExternalLink): - attributeDict["Title"] = "HDF5 External Link" + 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["#Title"] = "HDF5 Soft Link" attributeDict["Name"] = self.basename attributeDict["Path"] = self.obj.name attributeDict["Linked path"] = self.obj.path else: pass + return attributeDict def _getDefaultTooltip(self): """Returns the default tooltip @@ -308,10 +301,8 @@ class Hdf5Item(Hdf5Node): self.obj # lazy loading of the object return self.__error - attrs = collections.OrderedDict() - self._setTooltipAttributes(attrs) - - title = attrs.pop("Title", None) + attrs = self._createTooltipAttributes() + title = attrs.pop("#Title", None) if len(attrs) > 0: tooltip = _utils.htmlFromDict(attrs, title=title) else: @@ -342,7 +333,7 @@ class Hdf5Item(Hdf5Node): return "" class_ = self.h5pyClass if issubclass(class_, h5py.Dataset): - text = self._humanReadableType(self.obj) + text = self._getFormatter().humanReadableType(self.obj) else: text = "" return text @@ -361,7 +352,7 @@ class Hdf5Item(Hdf5Node): class_ = self.h5pyClass if not issubclass(class_, h5py.Dataset): return "" - return self._humanReadableShape(self.obj) + return self._getFormatter().humanReadableShape(self.obj) return None def dataValue(self, role): @@ -375,7 +366,7 @@ class Hdf5Item(Hdf5Node): return "" if not issubclass(self.h5pyClass, h5py.Dataset): return "" - return self._humanReadableValue(self.obj) + return self._getFormatter().humanReadableValue(self.obj) return None def dataDescription(self, role): @@ -412,10 +403,41 @@ class Hdf5Item(Hdf5Node): if role == qt.Qt.TextAlignmentRole: return qt.Qt.AlignTop | qt.Qt.AlignLeft if role == qt.Qt.DisplayRole: + if self.isBrokenObj(): + return "" class_ = self.h5pyClass text = class_.__name__.split(".")[-1] return text if role == qt.Qt.ToolTipRole: class_ = self.h5pyClass + if class_ is None: + return "" return "Class name: %s" % self.__class__ return None + + def dataLink(self, role): + """Data for the link column + + Overwrite it to implement the content of the 'link' column. + + :rtype: qt.QVariant + """ + if role == qt.Qt.DecorationRole: + return None + if role == qt.Qt.TextAlignmentRole: + return qt.Qt.AlignTop | qt.Qt.AlignLeft + if role == qt.Qt.DisplayRole: + link = self.linkClass + if link is None: + return "" + elif link is h5py.ExternalLink: + return "External" + elif link is h5py.SoftLink: + return "Soft" + elif link is h5py.HardLink: + return "" + else: + return link.__name__ + if role == qt.Qt.ToolTipRole: + return None + return None diff --git a/silx/gui/hdf5/Hdf5Node.py b/silx/gui/hdf5/Hdf5Node.py index 31bb097..0fcb407 100644 --- a/silx/gui/hdf5/Hdf5Node.py +++ b/silx/gui/hdf5/Hdf5Node.py @@ -25,7 +25,9 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "23/09/2016" +__date__ = "16/06/2017" + +import weakref class Hdf5Node(object): @@ -43,7 +45,9 @@ class Hdf5Node(object): everything is lazy loaded. """ self.__child = None - self.__parent = parent + self.__parent = None + if parent is not None: + self.__parent = weakref.ref(parent) if populateAll: self.__child = [] self._populateChild(populateAll=True) @@ -54,7 +58,12 @@ class Hdf5Node(object): :rtype: Hdf5Node """ - return self.__parent + if self.__parent is None: + return None + parent = self.__parent() + if parent is None: + self.__parent = parent + return parent def setParent(self, parent): """Redefine the parent of the node. @@ -63,7 +72,10 @@ class Hdf5Node(object): :param Hdf5Node parent: The new parent """ - self.__parent = parent + if parent is None: + self.__parent = None + else: + self.__parent = weakref.ref(parent) def appendChild(self, child): """Append a child to the node. @@ -208,3 +220,12 @@ class Hdf5Node(object): :rtype: qt.QVariant """ return None + + def dataLink(self, role): + """Data for the link column + + Overwrite it to implement the content of the 'link' column. + + :rtype: qt.QVariant + """ + return None diff --git a/silx/gui/hdf5/Hdf5TreeModel.py b/silx/gui/hdf5/Hdf5TreeModel.py index fb5de06..41fa91c 100644 --- a/silx/gui/hdf5/Hdf5TreeModel.py +++ b/silx/gui/hdf5/Hdf5TreeModel.py @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "19/12/2016" +__date__ = "22/09/2017" import os @@ -71,6 +71,25 @@ else: return x +def _createRootLabel(h5obj): + """ + Create label for the very first npde of the tree. + + :param h5obj: The h5py object to display in the GUI + :type h5obj: h5py-like object + :rtpye: str + """ + if silx_io.is_file(h5obj): + label = os.path.basename(h5obj.filename) + else: + filename = os.path.basename(h5obj.file.filename) + path = h5obj.name + if path.startswith("/"): + path = path[1:] + label = "%s::%s" % (filename, path) + return label + + class LoadingItemRunnable(qt.QRunnable): """Runner to process item loading from a file""" @@ -107,12 +126,7 @@ class LoadingItemRunnable(qt.QRunnable): :param h5py.File h5obj: The h5py object to display in the GUI :rtpye: Hdf5Node """ - if silx_io.is_file(h5obj): - text = os.path.basename(h5obj.filename) - else: - filename = os.path.basename(h5obj.file.filename) - path = h5obj.name - text = "%s::%s" % (filename, path) + text = _createRootLabel(h5obj) item = Hdf5Item(text=text, obj=h5obj, parent=oldItem.parent, populateAll=True) return item @@ -121,6 +135,7 @@ class LoadingItemRunnable(qt.QRunnable): """Process the file loading. The worker is used as holder of the data and the signal. The result is sent as a signal. """ + h5file = None try: h5file = silx_io.open(self.filename) newItem = self.__loadItemTree(self.oldItem, h5file) @@ -129,6 +144,8 @@ class LoadingItemRunnable(qt.QRunnable): # Should be logged error = e newItem = None + if h5file is not None: + h5file.close() # Take care of None value in case of PySide newItem = _wrapNone(newItem) @@ -174,6 +191,9 @@ class Hdf5TreeModel(qt.QAbstractItemModel): NODE_COLUMN = 5 """Column id containing HDF5 node type""" + LINK_COLUMN = 6 + """Column id containing HDF5 link type""" + COLUMN_IDS = [ NAME_COLUMN, TYPE_COLUMN, @@ -181,20 +201,21 @@ class Hdf5TreeModel(qt.QAbstractItemModel): VALUE_COLUMN, DESCRIPTION_COLUMN, NODE_COLUMN, + LINK_COLUMN, ] """List of logical columns available""" def __init__(self, parent=None): super(Hdf5TreeModel, self).__init__(parent) - self.treeView = parent - self.header_labels = [None] * 6 + self.header_labels = [None] * len(self.COLUMN_IDS) self.header_labels[self.NAME_COLUMN] = 'Name' self.header_labels[self.TYPE_COLUMN] = 'Type' self.header_labels[self.SHAPE_COLUMN] = 'Shape' self.header_labels[self.VALUE_COLUMN] = 'Value' self.header_labels[self.DESCRIPTION_COLUMN] = 'Description' self.header_labels[self.NODE_COLUMN] = 'Node' + self.header_labels[self.LINK_COLUMN] = 'Link' # Create items self.__root = Hdf5Node() @@ -205,14 +226,36 @@ class Hdf5TreeModel(qt.QAbstractItemModel): self.__animatedIcon.iconChanged.connect(self.__updateLoadingItems) self.__runnerSet = set([]) - # store used icons to avoid to avoid the cache to release it + # store used icons to avoid the cache to release it self.__icons = [] + self.__icons.append(icons.getQIcon("item-none")) self.__icons.append(icons.getQIcon("item-0dim")) self.__icons.append(icons.getQIcon("item-1dim")) self.__icons.append(icons.getQIcon("item-2dim")) self.__icons.append(icons.getQIcon("item-3dim")) self.__icons.append(icons.getQIcon("item-ndim")) - self.__icons.append(icons.getQIcon("item-object")) + + self.__openedFiles = [] + """Store the list of files opened by the model itself.""" + # FIXME: It should managed one by one by Hdf5Item itself + + def __del__(self): + self._closeOpened() + s = super(Hdf5TreeModel, self) + if hasattr(s, "__del__"): + # else it fail on Python 3 + s.__del__() + + def _closeOpened(self): + """Close files which was opened by this model. + + This function may be removed in the future. + + File are opened by the model when it was inserted using + `insertFileAsync`, `insertFile`, `appendFile`.""" + for h5file in self.__openedFiles: + h5file.close() + self.__openedFiles = [] def __updateLoadingItems(self, icon): for i in range(self.__root.childCount()): @@ -240,6 +283,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): self.__root.removeChildAtIndex(row) self.endRemoveRows() if newItem is not None: + self.__openedFiles.append(newItem.obj) self.beginInsertRows(rootIndex, row, row) self.__root.insertChild(row, newItem) self.endInsertRows() @@ -423,11 +467,13 @@ class Hdf5TreeModel(qt.QAbstractItemModel): return node.dataDescription(role) elif index.column() == self.NODE_COLUMN: return node.dataNode(role) + elif index.column() == self.LINK_COLUMN: + return node.dataLink(role) else: return None def columnCount(self, parent=qt.QModelIndex()): - return len(self.header_labels) + return len(self.COLUMN_IDS) def hasChildren(self, parent=qt.QModelIndex()): node = self.nodeFromIndex(parent) @@ -536,12 +582,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): or any other class of h5py file structure. """ if text is None: - if silx_io.is_file(h5pyObject): - text = os.path.basename(h5pyObject.filename) - else: - filename = os.path.basename(h5pyObject.file.filename) - path = h5pyObject.name - text = "%s::%s" % (filename, path) + text = _createRootLabel(h5pyObject) if row == -1: row = self.__root.childCount() self.insertNode(row, Hdf5Item(text=text, obj=h5pyObject, parent=self.__root)) @@ -572,6 +613,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): """ try: h5file = silx_io.open(filename) + self.__openedFiles.append(h5file) self.insertH5pyObject(h5file, row=row) except IOError: _logger.debug("File '%s' can't be read.", filename, exc_info=True) diff --git a/silx/gui/hdf5/Hdf5TreeView.py b/silx/gui/hdf5/Hdf5TreeView.py index 09f6fcf..0a4198e 100644 --- a/silx/gui/hdf5/Hdf5TreeView.py +++ b/silx/gui/hdf5/Hdf5TreeView.py @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "27/09/2016" +__date__ = "20/09/2017" import logging @@ -43,6 +43,8 @@ _logger = logging.getLogger(__name__) class Hdf5TreeView(qt.QTreeView): """TreeView which allow to browse HDF5 file structure. + .. image:: img/Hdf5TreeView.png + It provides columns width auto-resizing and additional signals. @@ -192,6 +194,87 @@ class Hdf5TreeView(qt.QTreeView): continue yield _utils.H5Node(item) + def setSelectedH5Node(self, h5Object): + """ + Select the specified node of the tree using an h5py node. + + - If the item is found, parent items are expended, and then the item + is selected. + - If the item is not found, the selection do not change. + - A none argument allow to deselect everything + + :param h5py.Npde h5Object: The node to select + """ + if h5Object is None: + self.setCurrentIndex(qt.QModelIndex()) + return + + filename = h5Object.file.filename + + # Seach for the right roots + rootIndices = [] + model = self.model() + for index in range(model.rowCount(qt.QModelIndex())): + index = model.index(index, 0, qt.QModelIndex()) + obj = model.data(index, Hdf5TreeModel.H5PY_OBJECT_ROLE) + if obj.file.filename == filename: + # We can have many roots with different subtree of the same + # root + rootIndices.append(index) + + if len(rootIndices) == 0: + # No root found + return + + path = h5Object.name + "/" + path = path.replace("//", "/") + + # Search for the right node + found = False + foundIndices = [] + for _ in range(1000 * len(rootIndices)): + # Avoid too much iterations, in case of recurssive links + if len(foundIndices) == 0: + if len(rootIndices) == 0: + # Nothing found + break + # Start fron a new root + foundIndices.append(rootIndices.pop(0)) + + obj = model.data(index, Hdf5TreeModel.H5PY_OBJECT_ROLE) + p = obj.name + "/" + p = p.replace("//", "/") + if path == p: + found = True + break + + parentIndex = foundIndices[-1] + for index in range(model.rowCount(parentIndex)): + index = model.index(index, 0, parentIndex) + obj = model.data(index, Hdf5TreeModel.H5PY_OBJECT_ROLE) + + p = obj.name + "/" + p = p.replace("//", "/") + if path == p: + foundIndices.append(index) + found = True + break + elif path.startswith(p): + foundIndices.append(index) + break + else: + # Nothing found, start again with another root + foundIndices = [] + + if found: + break + + if found: + # Update the GUI + for index in foundIndices[:-1]: + self.expand(index) + self.setCurrentIndex(foundIndices[-1]) + def mousePressEvent(self, event): """Override mousePressEvent to provide a consistante compatible API between Qt4 and Qt5 diff --git a/silx/gui/hdf5/NexusSortFilterProxyModel.py b/silx/gui/hdf5/NexusSortFilterProxyModel.py index 9a4268c..49a22d3 100644 --- a/silx/gui/hdf5/NexusSortFilterProxyModel.py +++ b/silx/gui/hdf5/NexusSortFilterProxyModel.py @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "12/04/2017" +__date__ = "16/06/2017" import logging @@ -86,7 +86,8 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel): def __isNXentry(self, node): """Returns true if the node is an NXentry""" - if not issubclass(node.h5pyClass, h5py.Group): + class_ = node.h5pyClass + if class_ is None or not issubclass(node.h5pyClass, h5py.Group): return False nxClass = node.obj.attrs.get("NX_class", None) return nxClass == "NXentry" diff --git a/silx/gui/hdf5/_utils.py b/silx/gui/hdf5/_utils.py index af9c79f..048aa20 100644 --- a/silx/gui/hdf5/_utils.py +++ b/silx/gui/hdf5/_utils.py @@ -28,11 +28,10 @@ package `silx.gui.hdf5` package. __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "26/04/2017" +__date__ = "29/09/2017" import logging -import numpy from .. import qt import silx.io.utils from silx.utils.html import escape @@ -138,10 +137,61 @@ class H5Node(object): :param Hdf5Item h5py_item: An Hdf5Item """ self.__h5py_object = h5py_item.obj + self.__h5py_target = None self.__h5py_item = h5py_item def __getattr__(self, name): - return object.__getattribute__(self.__h5py_object, name) + if hasattr(self.__h5py_object, name): + attr = getattr(self.__h5py_object, name) + return attr + raise AttributeError("H5Node has no attribute %s" % name) + + def __get_target(self, obj): + """ + Return the actual physical target of the provided object. + + Objects can contains links in the middle of the path, this function + check each groups and remove this prefix in case of the link by the + link of the path. + + :param obj: A valid h5py object (File, group or dataset) + :type obj: h5py.Dataset or h5py.Group or h5py.File + :rtype: h5py.Dataset or h5py.Group or h5py.File + """ + elements = obj.name.split("/") + if obj.name == "/": + return obj + elif obj.name.startswith("/"): + elements.pop(0) + path = "" + while len(elements) > 0: + e = elements.pop(0) + path = path + "/" + e + link = obj.parent.get(path, getlink=True) + + if isinstance(link, h5py.ExternalLink): + subpath = "/".join(elements) + external_obj = obj.parent.get(self.basename + "/" + subpath) + return self.__get_target(external_obj) + elif silx.io.utils.is_softlink(link): + # Restart from this stat + path = "" + root_elements = link.path.split("/") + if link.path == "/": + root_elements = [] + elif link.path.startswith("/"): + root_elements.pop(0) + for name in reversed(root_elements): + elements.insert(0, name) + + return obj.file[path] + + @property + def h5py_target(self): + if self.__h5py_target is not None: + return self.__h5py_target + self.__h5py_target = self.__get_target(self.__h5py_object) + return self.__h5py_target @property def h5py_object(self): @@ -170,8 +220,18 @@ class H5Node(object): return self.__h5py_object.name.split("/")[-1] @property + def is_broken(self): + """Returns true if the node is a broken link. + + :rtype: bool + """ + if self.__h5py_item is None: + raise RuntimeError("h5py_item is not defined") + return self.__h5py_item.isBrokenObj() + + @property def local_name(self): - """Returns the local path of this h5py node. + """Returns the path from the master file root to this node. For links, this path is not equal to the h5py one. @@ -183,34 +243,46 @@ class H5Node(object): result = [] item = self.__h5py_item while item is not None: - if issubclass(item.h5pyClass, h5py.File): + # stop before the root item (item without parent) + if item.parent.parent is None: + name = item.obj.name + if name != "/": + result.append(item.obj.name) break - result.append(item.basename) + else: + result.append(item.basename) item = item.parent if item is None: raise RuntimeError("The item does not have parent holding h5py.File") if result == []: return "/" - result.append("") + if not result[-1].startswith("/"): + result.append("") result.reverse() - return "/".join(result) + name = "/".join(result) + return name - def __file_item(self): - """Returns the parent item holding the :class:`h5py.File` object + def __get_local_file(self): + """Returns the file of the root of this tree :rtype: h5py.File - :raises RuntimeException: If no file are found """ item = self.__h5py_item - while item is not None: - if issubclass(item.h5pyClass, h5py.File): - return item + while item.parent.parent is not None: + class_ = item.h5pyClass + if class_ is not None and issubclass(class_, h5py.File): + break item = item.parent - raise RuntimeError("The item does not have parent holding h5py.File") + + class_ = item.h5pyClass + if class_ is not None and issubclass(class_, h5py.File): + return item.obj + else: + return item.obj.file @property def local_file(self): - """Returns the local :class:`h5py.File` object. + """Returns the master file in which is this node. For path containing external links, this file is not equal to the h5py one. @@ -218,12 +290,11 @@ class H5Node(object): :rtype: h5py.File :raises RuntimeException: If no file are found """ - item = self.__file_item() - return item.obj + return self.__get_local_file() @property def local_filename(self): - """Returns the local filename of the h5py node. + """Returns the filename from the master file of this node. For path containing external links, this path is not equal to the filename provided by h5py. @@ -235,13 +306,84 @@ class H5Node(object): @property def local_basename(self): - """Returns the local filename of the h5py node. + """Returns the basename from the master file root to this node. For path containing links, this basename can be different than the basename provided by h5py. :rtype: str """ - if issubclass(self.__h5py_item.h5pyClass, h5py.File): + class_ = self.__h5py_item.h5pyClass + if class_ is not None and issubclass(class_, h5py.File): return "" return self.__h5py_item.basename + + @property + def physical_file(self): + """Returns the physical file in which is this node. + + .. versionadded:: 0.6 + + :rtype: h5py.File + :raises RuntimeError: If no file are found + """ + if isinstance(self.__h5py_object, h5py.ExternalLink): + # It means the link is broken + raise RuntimeError("No file node found") + if isinstance(self.__h5py_object, h5py.SoftLink): + # It means the link is broken + return self.local_file + + physical_obj = self.h5py_target + return physical_obj.file + + @property + def physical_name(self): + """Returns the path from the location this h5py node is physically + stored. + + For broken links, this filename can be different from the + filename provided by h5py. + + :rtype: str + """ + if isinstance(self.__h5py_object, h5py.ExternalLink): + # It means the link is broken + return self.__h5py_object.path + if isinstance(self.__h5py_object, h5py.SoftLink): + # It means the link is broken + return self.__h5py_object.path + + physical_obj = self.h5py_target + return physical_obj.name + + @property + def physical_filename(self): + """Returns the filename from the location this h5py node is physically + stored. + + For broken links, this filename can be different from the + filename provided by h5py. + + :rtype: str + """ + if isinstance(self.__h5py_object, h5py.ExternalLink): + # It means the link is broken + return self.__h5py_object.filename + if isinstance(self.__h5py_object, h5py.SoftLink): + # It means the link is broken + return self.local_file.filename + + return self.physical_file.filename + + @property + def physical_basename(self): + """Returns the basename from the location this h5py node is physically + stored. + + For broken links, this basename can be different from the + basename provided by h5py. + + :rtype: str + """ + return self.physical_name.split("/")[-1] diff --git a/silx/gui/hdf5/test/_mock.py b/silx/gui/hdf5/test/_mock.py deleted file mode 100644 index eada590..0000000 --- a/silx/gui/hdf5/test/_mock.py +++ /dev/null @@ -1,130 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -# ###########################################################################*/ -"""Mock for silx.gui.hdf5 module""" - -__authors__ = ["V. Valls"] -__license__ = "MIT" -__date__ = "12/04/2017" - - -import numpy -try: - import h5py -except ImportError: - h5py = None - - -class Node(object): - - def __init__(self, basename, parent, h5py_class): - self.basename = basename - self.h5py_class = h5py_class - self.attrs = {} - self.parent = parent - if parent is not None: - self.parent._add(self) - - @property - def name(self): - if self.parent is None: - return self.basename - if self.parent.name == "": - return self.basename - return self.parent.name + "/" + self.basename - - @property - def file(self): - if self.parent is None: - return self - return self.parent.file - - -class Group(Node): - """Mock an h5py Group""" - - def __init__(self, name, parent, h5py_class=h5py.Group): - super(Group, self).__init__(name, parent, h5py_class) - self.__items = {} - - def _add(self, node): - self.__items[node.basename] = node - - def __getitem__(self, key): - return self.__items[key] - - def __iter__(self): - for k in self.__items: - yield k - - def __len__(self): - return len(self.__items) - - def get(self, name, getclass=False, getlink=False): - result = self.__items[name] - if getclass: - return result.h5py_class - return result - - def create_dataset(self, name, data): - return Dataset(name, self, data) - - def create_group(self, name): - return Group(name, self) - - def create_NXentry(self, name): - group = Group(name, self) - group.attrs["NX_class"] = "NXentry" - return group - - -class File(Group): - """Mock an h5py File""" - - def __init__(self, filename): - super(File, self).__init__("", None, h5py.File) - self.filename = filename - - -class Dataset(Node): - """Mock an h5py Dataset""" - - def __init__(self, name, parent, value): - super(Dataset, self).__init__(name, parent, h5py.Dataset) - self.__value = value - self.shape = self.__value.shape - self.dtype = self.__value.dtype - self.size = self.__value.size - self.compression = None - self.compression_opts = None - - def __getitem__(self, key): - if not isinstance(self.__value, numpy.ndarray): - if key == tuple(): - return self.__value - elif key == Ellipsis: - return numpy.array(self.__value) - else: - raise ValueError("Bad key") - return self.__value[key] diff --git a/silx/gui/hdf5/test/test_hdf5.py b/silx/gui/hdf5/test/test_hdf5.py index 3bf4897..8e375f2 100644 --- a/silx/gui/hdf5/test/test_hdf5.py +++ b/silx/gui/hdf5/test/test_hdf5.py @@ -26,7 +26,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "12/04/2017" +__date__ = "22/09/2017" import time @@ -34,11 +34,12 @@ import os import unittest import tempfile import numpy +import shutil from contextlib import contextmanager from silx.gui import qt from silx.gui.test.utils import TestCaseQt from silx.gui import hdf5 -from . import _mock +from silx.io import commonh5 try: import h5py @@ -54,6 +55,13 @@ class _Holder(object): _called += 1 +def create_NXentry(group, name): + attrs = {"NX_class": "NXentry"} + node = commonh5.Group(name, parent=group, attrs=attrs) + group.add_node(node) + return node + + class TestHdf5TreeModel(TestCaseQt): def setUp(self): @@ -124,14 +132,14 @@ class TestHdf5TreeModel(TestCaseQt): h5File.close() def testInsertObject(self): - h5 = _mock.File("/foo/bar/1.mock") + h5 = commonh5.File("/foo/bar/1.mock", "w") model = hdf5.Hdf5TreeModel() self.assertEquals(model.rowCount(qt.QModelIndex()), 0) model.insertH5pyObject(h5) self.assertEquals(model.rowCount(qt.QModelIndex()), 1) def testRemoveObject(self): - h5 = _mock.File("/foo/bar/1.mock") + h5 = commonh5.File("/foo/bar/1.mock", "w") model = hdf5.Hdf5TreeModel() self.assertEquals(model.rowCount(qt.QModelIndex()), 0) model.insertH5pyObject(h5) @@ -223,7 +231,7 @@ class TestHdf5TreeModel(TestCaseQt): return model.data(index, qt.Qt.DisplayRole) def testFileData(self): - h5 = _mock.File("/foo/bar/1.mock") + h5 = commonh5.File("/foo/bar/1.mock", "w") model = hdf5.Hdf5TreeModel() model.insertH5pyObject(h5) displayed = self.getRowDataAsDict(model, row=0) @@ -236,7 +244,7 @@ class TestHdf5TreeModel(TestCaseQt): self.assertEquals(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "File") def testGroupData(self): - h5 = _mock.File("/foo/bar/1.mock") + h5 = commonh5.File("/foo/bar/1.mock", "w") d = h5.create_group("foo") d.attrs["desc"] = "fooo" @@ -252,9 +260,9 @@ class TestHdf5TreeModel(TestCaseQt): self.assertEquals(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "Group") def testDatasetData(self): - h5 = _mock.File("/foo/bar/1.mock") + h5 = commonh5.File("/foo/bar/1.mock", "w") value = numpy.array([1, 2, 3]) - d = h5.create_dataset("foo", value) + d = h5.create_dataset("foo", data=value) model = hdf5.Hdf5TreeModel() model.insertH5pyObject(d) @@ -269,8 +277,8 @@ class TestHdf5TreeModel(TestCaseQt): def testDropLastAsFirst(self): model = hdf5.Hdf5TreeModel() - h5_1 = _mock.File("/foo/bar/1.mock") - h5_2 = _mock.File("/foo/bar/2.mock") + h5_1 = commonh5.File("/foo/bar/1.mock", "w") + h5_2 = commonh5.File("/foo/bar/2.mock", "w") model.insertH5pyObject(h5_1) model.insertH5pyObject(h5_2) self.assertEquals(self.getItemName(model, 0), "1.mock") @@ -283,8 +291,8 @@ class TestHdf5TreeModel(TestCaseQt): def testDropFirstAsLast(self): model = hdf5.Hdf5TreeModel() - h5_1 = _mock.File("/foo/bar/1.mock") - h5_2 = _mock.File("/foo/bar/2.mock") + h5_1 = commonh5.File("/foo/bar/1.mock", "w") + h5_2 = commonh5.File("/foo/bar/2.mock", "w") model.insertH5pyObject(h5_1) model.insertH5pyObject(h5_2) self.assertEquals(self.getItemName(model, 0), "1.mock") @@ -297,7 +305,7 @@ class TestHdf5TreeModel(TestCaseQt): def testRootParent(self): model = hdf5.Hdf5TreeModel() - h5_1 = _mock.File("/foo/bar/1.mock") + h5_1 = commonh5.File("/foo/bar/1.mock", "w") model.insertH5pyObject(h5_1) index = model.index(0, 0, qt.QModelIndex()) index = model.parent(index) @@ -318,10 +326,10 @@ class TestNexusSortFilterProxyModel(TestCaseQt): def testNXentryStartTime(self): """Test NXentry with start_time""" model = hdf5.Hdf5TreeModel() - h5 = _mock.File("/foo/bar/1.mock") - h5.create_NXentry("a").create_dataset("start_time", numpy.string_("2015")) - h5.create_NXentry("b").create_dataset("start_time", numpy.string_("2013")) - h5.create_NXentry("c").create_dataset("start_time", numpy.string_("2014")) + h5 = commonh5.File("/foo/bar/1.mock", "w") + create_NXentry(h5, "a").create_dataset("start_time", data=numpy.string_("2015")) + create_NXentry(h5, "b").create_dataset("start_time", data=numpy.string_("2013")) + create_NXentry(h5, "c").create_dataset("start_time", data=numpy.string_("2014")) model.insertH5pyObject(h5) proxy = hdf5.NexusSortFilterProxyModel() @@ -333,10 +341,10 @@ class TestNexusSortFilterProxyModel(TestCaseQt): def testNXentryStartTimeInArray(self): """Test NXentry with start_time""" model = hdf5.Hdf5TreeModel() - h5 = _mock.File("/foo/bar/1.mock") - h5.create_NXentry("a").create_dataset("start_time", numpy.array([numpy.string_("2015")])) - h5.create_NXentry("b").create_dataset("start_time", numpy.array([numpy.string_("2013")])) - h5.create_NXentry("c").create_dataset("start_time", numpy.array([numpy.string_("2014")])) + h5 = commonh5.File("/foo/bar/1.mock", "w") + create_NXentry(h5, "a").create_dataset("start_time", data=numpy.array([numpy.string_("2015")])) + create_NXentry(h5, "b").create_dataset("start_time", data=numpy.array([numpy.string_("2013")])) + create_NXentry(h5, "c").create_dataset("start_time", data=numpy.array([numpy.string_("2014")])) model.insertH5pyObject(h5) proxy = hdf5.NexusSortFilterProxyModel() @@ -348,10 +356,10 @@ class TestNexusSortFilterProxyModel(TestCaseQt): def testNXentryEndTimeInArray(self): """Test NXentry with end_time""" model = hdf5.Hdf5TreeModel() - h5 = _mock.File("/foo/bar/1.mock") - h5.create_NXentry("a").create_dataset("end_time", numpy.array([numpy.string_("2015")])) - h5.create_NXentry("b").create_dataset("end_time", numpy.array([numpy.string_("2013")])) - h5.create_NXentry("c").create_dataset("end_time", numpy.array([numpy.string_("2014")])) + h5 = commonh5.File("/foo/bar/1.mock", "w") + create_NXentry(h5, "a").create_dataset("end_time", data=numpy.array([numpy.string_("2015")])) + create_NXentry(h5, "b").create_dataset("end_time", data=numpy.array([numpy.string_("2013")])) + create_NXentry(h5, "c").create_dataset("end_time", data=numpy.array([numpy.string_("2014")])) model.insertH5pyObject(h5) proxy = hdf5.NexusSortFilterProxyModel() @@ -363,10 +371,10 @@ class TestNexusSortFilterProxyModel(TestCaseQt): def testNXentryName(self): """Test NXentry without start_time or end_time""" model = hdf5.Hdf5TreeModel() - h5 = _mock.File("/foo/bar/1.mock") - h5.create_NXentry("a") - h5.create_NXentry("c") - h5.create_NXentry("b") + h5 = commonh5.File("/foo/bar/1.mock", "w") + create_NXentry(h5, "a") + create_NXentry(h5, "c") + create_NXentry(h5, "b") model.insertH5pyObject(h5) proxy = hdf5.NexusSortFilterProxyModel() @@ -378,10 +386,10 @@ class TestNexusSortFilterProxyModel(TestCaseQt): def testStartTime(self): """If it is not NXentry, start_time is not used""" model = hdf5.Hdf5TreeModel() - h5 = _mock.File("/foo/bar/1.mock") - h5.create_group("a").create_dataset("start_time", numpy.string_("2015")) - h5.create_group("b").create_dataset("start_time", numpy.string_("2013")) - h5.create_group("c").create_dataset("start_time", numpy.string_("2014")) + h5 = commonh5.File("/foo/bar/1.mock", "w") + h5.create_group("a").create_dataset("start_time", data=numpy.string_("2015")) + h5.create_group("b").create_dataset("start_time", data=numpy.string_("2013")) + h5.create_group("c").create_dataset("start_time", data=numpy.string_("2014")) model.insertH5pyObject(h5) proxy = hdf5.NexusSortFilterProxyModel() @@ -392,7 +400,7 @@ class TestNexusSortFilterProxyModel(TestCaseQt): def testName(self): model = hdf5.Hdf5TreeModel() - h5 = _mock.File("/foo/bar/1.mock") + h5 = commonh5.File("/foo/bar/1.mock", "w") h5.create_group("a") h5.create_group("c") h5.create_group("b") @@ -406,7 +414,7 @@ class TestNexusSortFilterProxyModel(TestCaseQt): def testNumber(self): model = hdf5.Hdf5TreeModel() - h5 = _mock.File("/foo/bar/1.mock") + h5 = commonh5.File("/foo/bar/1.mock", "w") h5.create_group("a1") h5.create_group("a20") h5.create_group("a3") @@ -420,7 +428,7 @@ class TestNexusSortFilterProxyModel(TestCaseQt): def testMultiNumber(self): model = hdf5.Hdf5TreeModel() - h5 = _mock.File("/foo/bar/1.mock") + h5 = commonh5.File("/foo/bar/1.mock", "w") h5.create_group("a1-1") h5.create_group("a20-1") h5.create_group("a3-1") @@ -436,7 +444,7 @@ class TestNexusSortFilterProxyModel(TestCaseQt): def testUnconsistantTypes(self): model = hdf5.Hdf5TreeModel() - h5 = _mock.File("/foo/bar/1.mock") + h5 = commonh5.File("/foo/bar/1.mock", "w") h5.create_group("aaa100") h5.create_group("100aaa") model.insertH5pyObject(h5) @@ -448,11 +456,235 @@ class TestNexusSortFilterProxyModel(TestCaseQt): self.assertListEqual(names, ["100aaa", "aaa100"]) -class TestHdf5(TestCaseQt): +class TestH5Node(TestCaseQt): + + @classmethod + def setUpClass(cls): + super(TestH5Node, cls).setUpClass() + if h5py is None: + raise unittest.SkipTest("h5py is not available") + + cls.tmpDirectory = tempfile.mkdtemp() + cls.h5Filename = cls.createResource(cls.tmpDirectory) + cls.h5File = h5py.File(cls.h5Filename, mode="r") + cls.model = cls.createModel(cls.h5File) + + @classmethod + def createResource(cls, directory): + filename = os.path.join(directory, "base.h5") + externalFilename = os.path.join(directory, "base__external.h5") + + externalh5 = h5py.File(externalFilename, mode="w") + externalh5["target/dataset"] = 50 + externalh5["target/link"] = h5py.SoftLink("/target/dataset") + externalh5.close() + + h5 = h5py.File(filename, mode="w") + h5["group/dataset"] = 50 + h5["link/soft_link"] = h5py.SoftLink("/group/dataset") + h5["link/soft_link_to_group"] = h5py.SoftLink("/group") + h5["link/soft_link_to_link"] = h5py.SoftLink("/link/soft_link") + h5["link/soft_link_to_file"] = h5py.SoftLink("/") + h5["link/external_link"] = h5py.ExternalLink(externalFilename, "/target/dataset") + h5["link/external_link_to_link"] = h5py.ExternalLink(externalFilename, "/target/link") + h5["broken_link/external_broken_file"] = h5py.ExternalLink(externalFilename + "_not_exists", "/target/link") + h5["broken_link/external_broken_link"] = h5py.ExternalLink(externalFilename, "/target/not_exists") + h5["broken_link/soft_broken_link"] = h5py.SoftLink("/group/not_exists") + h5["broken_link/soft_link_to_broken_link"] = h5py.SoftLink("/group/not_exists") + h5.close() + + return filename + + @classmethod + def createModel(cls, h5pyFile): + model = hdf5.Hdf5TreeModel() + model.insertH5pyObject(h5pyFile) + return model + + @classmethod + def tearDownClass(cls): + cls.model = None + cls.h5File.close() + shutil.rmtree(cls.tmpDirectory) + super(TestH5Node, cls).tearDownClass() + + def getIndexFromPath(self, model, path): + """ + :param qt.QAbstractItemModel: model + """ + index = qt.QModelIndex() + for name in path: + for row in range(model.rowCount(index)): + i = model.index(row, 0, index) + label = model.data(i) + if label == name: + index = i + break + else: + raise RuntimeError("Path not found") + return index + + def getH5NodeFromPath(self, model, path): + index = self.getIndexFromPath(model, path) + item = model.data(index, hdf5.Hdf5TreeModel.H5PY_ITEM_ROLE) + h5node = hdf5.H5Node(item) + return h5node + + def testFile(self): + path = ["base.h5"] + h5node = self.getH5NodeFromPath(self.model, path) + + self.assertEqual(h5node.physical_filename, h5node.local_filename) + self.assertIn("base.h5", h5node.physical_filename) + self.assertEqual(h5node.physical_basename, "") + self.assertEqual(h5node.physical_name, "/") + self.assertEqual(h5node.local_basename, "") + self.assertEqual(h5node.local_name, "/") + + def testGroup(self): + path = ["base.h5", "group"] + h5node = self.getH5NodeFromPath(self.model, path) + + self.assertEqual(h5node.physical_filename, h5node.local_filename) + self.assertIn("base.h5", h5node.physical_filename) + self.assertEqual(h5node.physical_basename, "group") + self.assertEqual(h5node.physical_name, "/group") + self.assertEqual(h5node.local_basename, "group") + self.assertEqual(h5node.local_name, "/group") + + def testDataset(self): + path = ["base.h5", "group", "dataset"] + h5node = self.getH5NodeFromPath(self.model, path) + + self.assertEqual(h5node.physical_filename, h5node.local_filename) + self.assertIn("base.h5", h5node.physical_filename) + self.assertEqual(h5node.physical_basename, "dataset") + self.assertEqual(h5node.physical_name, "/group/dataset") + self.assertEqual(h5node.local_basename, "dataset") + self.assertEqual(h5node.local_name, "/group/dataset") + + def testSoftLink(self): + path = ["base.h5", "link", "soft_link"] + h5node = self.getH5NodeFromPath(self.model, path) + + self.assertEqual(h5node.physical_filename, h5node.local_filename) + self.assertIn("base.h5", h5node.physical_filename) + self.assertEqual(h5node.physical_basename, "dataset") + self.assertEqual(h5node.physical_name, "/group/dataset") + self.assertEqual(h5node.local_basename, "soft_link") + self.assertEqual(h5node.local_name, "/link/soft_link") + + def testSoftLinkToLink(self): + path = ["base.h5", "link", "soft_link_to_link"] + h5node = self.getH5NodeFromPath(self.model, path) + + self.assertEqual(h5node.physical_filename, h5node.local_filename) + self.assertIn("base.h5", h5node.physical_filename) + self.assertEqual(h5node.physical_basename, "dataset") + self.assertEqual(h5node.physical_name, "/group/dataset") + self.assertEqual(h5node.local_basename, "soft_link_to_link") + self.assertEqual(h5node.local_name, "/link/soft_link_to_link") + + def testExternalLink(self): + path = ["base.h5", "link", "external_link"] + h5node = self.getH5NodeFromPath(self.model, path) + + self.assertNotEqual(h5node.physical_filename, h5node.local_filename) + self.assertIn("base.h5", h5node.local_filename) + self.assertIn("base__external.h5", h5node.physical_filename) + self.assertEqual(h5node.physical_basename, "dataset") + self.assertEqual(h5node.physical_name, "/target/dataset") + self.assertEqual(h5node.local_basename, "external_link") + self.assertEqual(h5node.local_name, "/link/external_link") + + def testExternalLinkToLink(self): + path = ["base.h5", "link", "external_link_to_link"] + h5node = self.getH5NodeFromPath(self.model, path) + + self.assertNotEqual(h5node.physical_filename, h5node.local_filename) + self.assertIn("base.h5", h5node.local_filename) + self.assertIn("base__external.h5", h5node.physical_filename) + + self.assertNotEqual(h5node.physical_filename, h5node.local_filename) + self.assertEqual(h5node.physical_basename, "dataset") + self.assertEqual(h5node.physical_name, "/target/dataset") + self.assertEqual(h5node.local_basename, "external_link_to_link") + self.assertEqual(h5node.local_name, "/link/external_link_to_link") + + def testExternalBrokenFile(self): + path = ["base.h5", "broken_link", "external_broken_file"] + h5node = self.getH5NodeFromPath(self.model, path) + + self.assertNotEqual(h5node.physical_filename, h5node.local_filename) + self.assertIn("base.h5", h5node.local_filename) + self.assertIn("not_exists", h5node.physical_filename) + self.assertEqual(h5node.physical_basename, "link") + self.assertEqual(h5node.physical_name, "/target/link") + self.assertEqual(h5node.local_basename, "external_broken_file") + self.assertEqual(h5node.local_name, "/broken_link/external_broken_file") + + def testExternalBrokenLink(self): + path = ["base.h5", "broken_link", "external_broken_link"] + h5node = self.getH5NodeFromPath(self.model, path) + + self.assertNotEqual(h5node.physical_filename, h5node.local_filename) + self.assertIn("base.h5", h5node.local_filename) + self.assertIn("__external", h5node.physical_filename) + self.assertEqual(h5node.physical_basename, "not_exists") + self.assertEqual(h5node.physical_name, "/target/not_exists") + self.assertEqual(h5node.local_basename, "external_broken_link") + self.assertEqual(h5node.local_name, "/broken_link/external_broken_link") + + def testSoftBrokenLink(self): + path = ["base.h5", "broken_link", "soft_broken_link"] + h5node = self.getH5NodeFromPath(self.model, path) + + self.assertEqual(h5node.physical_filename, h5node.local_filename) + self.assertIn("base.h5", h5node.physical_filename) + self.assertEqual(h5node.physical_basename, "not_exists") + self.assertEqual(h5node.physical_name, "/group/not_exists") + self.assertEqual(h5node.local_basename, "soft_broken_link") + self.assertEqual(h5node.local_name, "/broken_link/soft_broken_link") + + def testSoftLinkToBrokenLink(self): + path = ["base.h5", "broken_link", "soft_link_to_broken_link"] + h5node = self.getH5NodeFromPath(self.model, path) + + self.assertEqual(h5node.physical_filename, h5node.local_filename) + self.assertIn("base.h5", h5node.physical_filename) + self.assertEqual(h5node.physical_basename, "not_exists") + self.assertEqual(h5node.physical_name, "/group/not_exists") + self.assertEqual(h5node.local_basename, "soft_link_to_broken_link") + self.assertEqual(h5node.local_name, "/broken_link/soft_link_to_broken_link") + + def testDatasetFromSoftLinkToGroup(self): + path = ["base.h5", "link", "soft_link_to_group", "dataset"] + h5node = self.getH5NodeFromPath(self.model, path) + + self.assertEqual(h5node.physical_filename, h5node.local_filename) + self.assertIn("base.h5", h5node.physical_filename) + self.assertEqual(h5node.physical_basename, "dataset") + self.assertEqual(h5node.physical_name, "/group/dataset") + self.assertEqual(h5node.local_basename, "dataset") + self.assertEqual(h5node.local_name, "/link/soft_link_to_group/dataset") + + def testDatasetFromSoftLinkToFile(self): + path = ["base.h5", "link", "soft_link_to_file", "link", "soft_link_to_group", "dataset"] + h5node = self.getH5NodeFromPath(self.model, path) + + self.assertEqual(h5node.physical_filename, h5node.local_filename) + self.assertIn("base.h5", h5node.physical_filename) + self.assertEqual(h5node.physical_basename, "dataset") + self.assertEqual(h5node.physical_name, "/group/dataset") + self.assertEqual(h5node.local_basename, "dataset") + self.assertEqual(h5node.local_name, "/link/soft_link_to_file/link/soft_link_to_group/dataset") + + +class TestHdf5TreeView(TestCaseQt): """Test to check that icons module.""" def setUp(self): - super(TestHdf5, self).setUp() + super(TestHdf5TreeView, self).setUp() if h5py is None: self.skipTest("h5py is not available") @@ -464,15 +696,147 @@ class TestHdf5(TestCaseQt): view = hdf5.Hdf5TreeView() view._createContextMenu(qt.QPoint(0, 0)) + def testSelection_Simple(self): + tree = commonh5.File("/foo/bar/1.mock", "w") + item = tree.create_group("a/b/c/d") + item.create_group("e").create_group("f") + + model = hdf5.Hdf5TreeModel() + model.insertH5pyObject(tree) + view = hdf5.Hdf5TreeView() + view.setModel(model) + view.setSelectedH5Node(item) + + selected = list(view.selectedH5Nodes())[0] + self.assertIs(item, selected.h5py_object) + + def testSelection_NotFound(self): + tree2 = commonh5.File("/foo/bar/2.mock", "w") + tree = commonh5.File("/foo/bar/1.mock", "w") + item = tree.create_group("a/b/c/d") + item.create_group("e").create_group("f") + + model = hdf5.Hdf5TreeModel() + model.insertH5pyObject(tree) + view = hdf5.Hdf5TreeView() + view.setModel(model) + view.setSelectedH5Node(tree2) + + selection = list(view.selectedH5Nodes()) + self.assertEqual(len(selection), 0) + + def testSelection_ManyGroupFromSameFile(self): + tree = commonh5.File("/foo/bar/1.mock", "w") + group1 = tree.create_group("a1") + group2 = tree.create_group("a2") + group3 = tree.create_group("a3") + group1.create_group("b/c/d") + item = group2.create_group("b/c/d") + group3.create_group("b/c/d") + + model = hdf5.Hdf5TreeModel() + model.insertH5pyObject(group1) + model.insertH5pyObject(group2) + model.insertH5pyObject(group3) + view = hdf5.Hdf5TreeView() + view.setModel(model) + view.setSelectedH5Node(item) + + selected = list(view.selectedH5Nodes())[0] + self.assertIs(item, selected.h5py_object) + + def testSelection_RootFromSubTree(self): + tree = commonh5.File("/foo/bar/1.mock", "w") + group = tree.create_group("a1") + group.create_group("b/c/d") + + model = hdf5.Hdf5TreeModel() + model.insertH5pyObject(group) + view = hdf5.Hdf5TreeView() + view.setModel(model) + view.setSelectedH5Node(group) + + selected = list(view.selectedH5Nodes())[0] + self.assertIs(group, selected.h5py_object) + + def testSelection_FileFromSubTree(self): + tree = commonh5.File("/foo/bar/1.mock", "w") + group = tree.create_group("a1") + group.create_group("b").create_group("b").create_group("d") + + model = hdf5.Hdf5TreeModel() + model.insertH5pyObject(group) + view = hdf5.Hdf5TreeView() + view.setModel(model) + view.setSelectedH5Node(tree) + + selection = list(view.selectedH5Nodes()) + self.assertEquals(len(selection), 0) + + def testSelection_Tree(self): + tree1 = commonh5.File("/foo/bar/1.mock", "w") + tree2 = commonh5.File("/foo/bar/2.mock", "w") + tree3 = commonh5.File("/foo/bar/3.mock", "w") + tree1.create_group("a/b/c") + tree2.create_group("a/b/c") + tree3.create_group("a/b/c") + item = tree2 + + model = hdf5.Hdf5TreeModel() + model.insertH5pyObject(tree1) + model.insertH5pyObject(tree2) + model.insertH5pyObject(tree3) + view = hdf5.Hdf5TreeView() + view.setModel(model) + view.setSelectedH5Node(item) + + selected = list(view.selectedH5Nodes())[0] + self.assertIs(item, selected.h5py_object) + + def testSelection_RecurssiveLink(self): + """ + Recurssive link selection + + This example is not really working as expected cause commonh5 do not + support recurssive links. + But item.name == "/a/b" and the result is found. + """ + tree = commonh5.File("/foo/bar/1.mock", "w") + group = tree.create_group("a") + group.add_node(commonh5.SoftLink("b", "/")) + + item = tree["/a/b/a/b/a/b/a/b/a/b/a/b/a/b/a/b"] + + model = hdf5.Hdf5TreeModel() + model.insertH5pyObject(tree) + view = hdf5.Hdf5TreeView() + view.setModel(model) + view.setSelectedH5Node(item) + + selected = list(view.selectedH5Nodes())[0] + self.assertEqual(item.name, selected.h5py_object.name) + + def testSelection_SelectNone(self): + tree = commonh5.File("/foo/bar/1.mock", "w") + + model = hdf5.Hdf5TreeModel() + model.insertH5pyObject(tree) + view = hdf5.Hdf5TreeView() + view.setModel(model) + view.setSelectedH5Node(tree) + view.setSelectedH5Node(None) + + selection = list(view.selectedH5Nodes()) + self.assertEqual(len(selection), 0) + def suite(): test_suite = unittest.TestSuite() - test_suite.addTest( - unittest.defaultTestLoader.loadTestsFromTestCase(TestHdf5TreeModel)) - test_suite.addTest( - unittest.defaultTestLoader.loadTestsFromTestCase(TestNexusSortFilterProxyModel)) - test_suite.addTest( - unittest.defaultTestLoader.loadTestsFromTestCase(TestHdf5)) + loadTests = unittest.defaultTestLoader.loadTestsFromTestCase + test_suite.addTest(loadTests(TestHdf5TreeModel)) + test_suite.addTest(loadTests(TestNexusSortFilterProxyModel)) + test_suite.addTest(loadTests(TestHdf5TreeView)) + test_suite.addTest(loadTests(TestH5Node)) return test_suite diff --git a/silx/gui/icons.py b/silx/gui/icons.py index eaf83b8..07654c1 100644 --- a/silx/gui/icons.py +++ b/silx/gui/icons.py @@ -29,15 +29,16 @@ Use :func:`getQIcon` to create Qt QIcon from the name identifying an icon. __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "25/04/2017" +__date__ = "06/09/2017" +import os import logging import weakref from . import qt -from silx.resources import resource_filename +import silx.resources from silx.utils import weakref as silxweakref -from silx.utils.decorators import deprecated +from silx.utils.deprecation import deprecated _logger = logging.getLogger(__name__) @@ -192,7 +193,7 @@ class MultiImageAnimatedIcon(AbstractAnimatedIcon): self.__frames = [] for i in range(100): try: - pixmap = getQPixmap("animated/%s-%02d" % (filename, i)) + pixmap = getQPixmap("%s/%02d" % (filename, i)) except ValueError: break icon = qt.QIcon(pixmap) @@ -258,13 +259,22 @@ def getWaitIcon(): def getAnimatedIcon(name): - """Create an AbstractAnimatedIcon from a name. + """Create an AbstractAnimatedIcon from a resource name. + + The resource name can be prefixed by the name of a resource directory. For + example "silx:foo.png" identify the resource "foo.png" from the resource + directory "silx". + + If no prefix are specified, the file with be returned from the silx + resource directory with a specific path "gui/icons". + + See also :func:`silx.resources.register_resource_directory`. Try to load a mng or a gif file, then try to load a multi-image animated icon. - In Qt5 mng or gif are not used. It does not take care very well of the - transparency. + In Qt5 mng or gif are not used, because the transparency is not very well + managed. :param str name: Name of the icon, in one of the defined icons in this module. @@ -302,6 +312,15 @@ def getAnimatedIcon(name): def getQIcon(name): """Create a QIcon from its name. + The resource name can be prefixed by the name of a resource directory. For + example "silx:foo.png" identify the resource "foo.png" from the resource + directory "silx". + + If no prefix are specified, the file with be returned from the silx + resource directory with a specific path "gui/icons". + + See also :func:`silx.resources.register_resource_directory`. + :param str name: Name of the icon, in one of the defined icons in this module. :return: Corresponding QIcon @@ -319,6 +338,15 @@ def getQIcon(name): def getQPixmap(name): """Create a QPixmap from its name. + The resource name can be prefixed by the name of a resource directory. For + example "silx:foo.png" identify the resource "foo.png" from the resource + directory "silx". + + If no prefix are specified, the file with be returned from the silx + resource directory with a specific path "gui/icons". + + See also :func:`silx.resources.register_resource_directory`. + :param str name: Name of the icon, in one of the defined icons in this module. :return: Corresponding QPixmap @@ -332,6 +360,15 @@ def getQFile(name): """Create a QFile from an icon name. Filename is found according to supported Qt formats. + The resource name can be prefixed by the name of a resource directory. For + example "silx:foo.png" identify the resource "foo.png" from the resource + directory "silx". + + If no prefix are specified, the file with be returned from the silx + resource directory with a specific path "gui/icons". + + See also :func:`silx.resources.register_resource_directory`. + :param str name: Name of the icon, in one of the defined icons in this module. :return: Corresponding QFile @@ -353,7 +390,8 @@ def getQFile(name): for format_ in _supported_formats: format_ = str(format_) - filename = resource_filename('gui/icons/%s.%s' % (name, format_)) + filename = silx.resources._resource_filename('%s.%s' % (name, format_), + default_directory=os.path.join('gui', 'icons')) qfile = qt.QFile(filename) if qfile.exists(): return qfile diff --git a/silx/gui/plot/ColorBar.py b/silx/gui/plot/ColorBar.py index 93e3c36..8f4bde2 100644 --- a/silx/gui/plot/ColorBar.py +++ b/silx/gui/plot/ColorBar.py @@ -33,11 +33,8 @@ __date__ = "11/04/2017" import logging import numpy from ._utils import ticklayout -from ._utils import clipColormapLogRange - - -from .. import qt -from silx.gui.plot import Colors +from .. import qt, icons +from silx.gui.plot import Colormap _logger = logging.getLogger(__name__) @@ -66,12 +63,17 @@ class ColorBarWidget(qt.QWidget): :param parent: See :class:`QWidget` :param plot: PlotWidget the colorbar is attached to (optional) - :param str legend: the label to set to the colormap + :param str legend: the label to set to the colorbar """ def __init__(self, parent=None, plot=None, legend=None): - super(ColorBarWidget, self).__init__(parent) + self._isConnected = False self._plot = None + self._viewAction = None + self._colormap = None + self._data = None + + super(ColorBarWidget, self).__init__(parent) self.__buildGUI() self.setLegend(legend) @@ -90,8 +92,6 @@ class ColorBarWidget(qt.QWidget): self.layout().addWidget(self.legend) self.layout().setSizeConstraint(qt.QLayout.SetMinAndMaxSize) - self.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Expanding) - self.layout().setContentsMargins(0, 0, 0, 0) def getPlot(self): """Returns the :class:`Plot` associated to this widget or None""" @@ -100,46 +100,75 @@ class ColorBarWidget(qt.QWidget): def setPlot(self, plot): """Associate a plot to the ColorBar - :param plot: the plot to associate with the colorbar. If None will remove - any connection with a previous plot. + :param plot: the plot to associate with the colorbar. + If None will remove any connection with a previous plot. """ - # removing previous plot if any - if self._plot is not None: - self._plot.sigActiveImageChanged.disconnect(self._activeImageChanged) - - # setting the new plot + self._disconnectPlot() self._plot = plot - if self._plot is not None: + self._connectPlot() + + def _disconnectPlot(self): + """Disconnect from Plot signals""" + if self._plot is not None and self._isConnected: + self._isConnected = False + self._plot.sigActiveImageChanged.disconnect( + self._activeImageChanged) + self._plot.sigPlotSignal.disconnect(self._defaultColormapChanged) + + def _connectPlot(self): + """Connect to Plot signals""" + if self._plot is not None and not self._isConnected: + activeImageLegend = self._plot.getActiveImage(just_legend=True) + if activeImageLegend is None: # Show plot default colormap + self._syncWithDefaultColormap() + else: # Show active image colormap + self._activeImageChanged(None, activeImageLegend) self._plot.sigActiveImageChanged.connect(self._activeImageChanged) - self._activeImageChanged(self._plot.getActiveImage(just_legend=True)) + self._plot.sigPlotSignal.connect(self._defaultColormapChanged) + self._isConnected = True + + def showEvent(self, event): + self._connectPlot() + if self._viewAction is not None: + self._viewAction.setChecked(True) + + def hideEvent(self, event): + self._disconnectPlot() + if self._viewAction is not None: + self._viewAction.setChecked(False) def getColormap(self): - """Return the colormap displayed in the colorbar as a dict. + """ + + :return: the :class:`.Colormap` colormap displayed in the colorbar. - It returns None if no colormap is set. - See :class:`silx.gui.plot.Plot` documentation for the description of the colormap - dict description. """ - return self._colormap.copy() + return self.getColorScaleBar().getColormap() - def setColormap(self, colormap): + def setColormap(self, colormap, data=None): """Set the colormap to be displayed. - :param dict colormap: The colormap to apply on the ColorBarWidget + :param colormap: The colormap to apply on the + ColorBarWidget + :type colormap: :class:`.Colormap` + :param numpy.ndarray data: the data to display, needed if the colormap + require an autoscale """ + self._data = data + self.getColorScaleBar().setColormap(colormap=colormap, + data=data) + if self._colormap is not None: + self._colormap.sigChanged.disconnect(self._colormapHasChanged) self._colormap = colormap - if self._colormap is None: - return - - if self._colormap['normalization'] not in ('log', 'linear'): - raise ValueError('Wrong normalization %s' % self._colormap['normalization']) + if self._colormap is not None: + self._colormap.sigChanged.connect(self._colormapHasChanged) - if self._colormap['normalization'] is 'log': - if self._colormap['vmin'] < 1. or self._colormap['vmax'] < 1.: - _logger.warning('Log colormap with bound <= 1: changing bounds.') - clipColormapLogRange(colormap) - - self.getColorScaleBar().setColormap(self._colormap) + def _colormapHasChanged(self): + """handler of the Colormap.sigChanged signal + """ + assert self._colormap is not None + self.setColormap(colormap=self._colormap, + data=self._data) def setLegend(self, legend): """Set the legend displayed along the colorbar @@ -150,7 +179,7 @@ class ColorBarWidget(qt.QWidget): self.legend.hide() self.legend.setText("") else: - assert(type(legend) is str) + assert type(legend) is str self.legend.show() self.legend.setText(legend) @@ -163,10 +192,10 @@ class ColorBarWidget(qt.QWidget): """ return self.legend.getText() - def _activeImageChanged(self, legend): + def _activeImageChanged(self, previous, legend): """Handle plot active curve changed""" - if legend is None: # No active image, display default colormap - self._syncWithDefaultColormap() + if legend is None: # No active image, display no colormap + self.setColormap(colormap=None) return # Sync with active image @@ -174,32 +203,25 @@ class ColorBarWidget(qt.QWidget): # RGB(A) image, display default colormap if image.ndim != 2: - self._syncWithDefaultColormap() + self.setColormap(colormap=None) return # data image, sync with image colormap # do we need the copy here : used in the case we are changing # vmin and vmax but should have already be done by the plot - cmap = self._plot.getActiveImage().getColormap().copy() - if cmap['autoscale']: - if cmap['normalization'] == 'log': - data = image[ - numpy.logical_and(image > 0, numpy.isfinite(image))] - else: - data = image[numpy.isfinite(image)] - cmap['vmin'], cmap['vmax'] = data.min(), data.max() - - self.setColormap(cmap) + self.setColormap(colormap=self._plot.getActiveImage().getColormap(), + data=image) - def _defaultColormapChanged(self): + def _defaultColormapChanged(self, event): """Handle plot default colormap changed""" - if self._plot.getActiveImage() is None: + if (event['event'] == 'defaultColormapChanged' and + self._plot.getActiveImage() is None): # No active image, take default colormap update into account self._syncWithDefaultColormap() - def _syncWithDefaultColormap(self): + def _syncWithDefaultColormap(self, data=None): """Update colorbar according to plot default colormap""" - self.setColormap(self._plot.getDefaultColormap()) + self.setColormap(self._plot.getDefaultColormap(), data) def getColorScaleBar(self): """ @@ -208,6 +230,21 @@ class ColorBarWidget(qt.QWidget): and ticks""" return self._colorScale + def getToggleViewAction(self): + """Returns a checkable action controlling this widget's visibility. + + :rtype: QAction + """ + if self._viewAction is None: + self._viewAction = qt.QAction(self) + self._viewAction.setText('Colorbar') + self._viewAction.setIcon(icons.getQIcon('colorbar')) + self._viewAction.setToolTip('Show/Hide the colorbar') + self._viewAction.setCheckable(True) + self._viewAction.setChecked(self.isVisible()) + self._viewAction.toggled[bool].connect(self.setVisible) + return self._viewAction + class _VerticalLegend(qt.QLabel): """Display vertically the given text @@ -251,12 +288,11 @@ class ColorScaleBar(qt.QWidget): To run the following sample code, a QApplication must be initialized. - >>> colormap={'name':'gray', - ... 'normalization':'log', - ... 'vmin':1, - ... 'vmax':100000, - ... 'autoscale':False - ... } + >>> colormap = Colormap(name='gray', + ... norm='log', + ... vmin=1, + ... vmax=100000, + ... ) >>> colorscale = ColorScaleBar(parent=None, ... colormap=colormap ) >>> colorscale.show() @@ -272,15 +308,8 @@ class ColorScaleBar(qt.QWidget): """The tick bar need a margin to display all labels at the correct place. So the ColorScale should have the same margin in order for both to fit""" - _MIN_LIM_SCI_FORM = -1000 - """Used for the min and max label to know when we should display it under - the scientific form""" - - _MAX_LIM_SCI_FORM = 1000 - """Used for the min and max label to know when we should display it under - the scientific form""" - - def __init__(self, parent=None, colormap=None, displayTicksValues=True): + def __init__(self, parent=None, colormap=None, data=None, + displayTicksValues=True): super(ColorScaleBar, self).__init__(parent) self.minVal = None @@ -292,33 +321,41 @@ class ColorScaleBar(qt.QWidget): # create the left side group (ColorScale) self.colorScale = _ColorScale(colormap=colormap, - parent=self, - margin=ColorScaleBar._TEXT_MARGIN) + data=data, + parent=self, + margin=ColorScaleBar._TEXT_MARGIN) + if colormap: + vmin, vmax = colormap.getColormapRange(data) + else: + vmin, vmax = Colormap.DEFAULT_MIN_LIN, Colormap.DEFAULT_MAX_LIN - self.tickbar = _TickBar(vmin=colormap['vmin'] if colormap else 0.0, - vmax=colormap['vmax'] if colormap else 1.0, - norm=colormap['normalization'] if colormap else 'linear', - parent=self, - displayValues=displayTicksValues, - margin=ColorScaleBar._TEXT_MARGIN) + norm = colormap.getNormalization() if colormap else Colormap.Colormap.LINEAR + self.tickbar = _TickBar(vmin=vmin, + vmax=vmax, + norm=norm, + parent=self, + displayValues=displayTicksValues, + margin=ColorScaleBar._TEXT_MARGIN) - self.layout().addWidget(self.tickbar, 1, 0) - self.layout().addWidget(self.colorScale, 1, 1) + self.layout().addWidget(self.tickbar, 1, 0, 1, 1, qt.Qt.AlignRight) + self.layout().addWidget(self.colorScale, 1, 1, qt.Qt.AlignLeft) self.layout().setContentsMargins(0, 0, 0, 0) self.layout().setSpacing(0) # max label self._maxLabel = qt.QLabel(str(1.0), parent=self) - self._maxLabel.setAlignment(qt.Qt.AlignHCenter) - self._maxLabel.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Minimum) - self.layout().addWidget(self._maxLabel, 0, 1) + self._maxLabel.setToolTip(str(0.0)) + self.layout().addWidget(self._maxLabel, 0, 0, 1, 2, qt.Qt.AlignRight) # min label self._minLabel = qt.QLabel(str(0.0), parent=self) - self._minLabel.setAlignment(qt.Qt.AlignHCenter) - self._minLabel.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Minimum) - self.layout().addWidget(self._minLabel, 2, 1) + self._minLabel.setToolTip(str(0.0)) + self.layout().addWidget(self._minLabel, 2, 0, 1, 2, qt.Qt.AlignRight) + + self.layout().setSizeConstraint(qt.QLayout.SetMinAndMaxSize) + self.layout().setColumnStretch(0, 1) + self.layout().setRowStretch(1, 1) def getTickBar(self): """ @@ -334,19 +371,34 @@ class ColorScaleBar(qt.QWidget): """ return self.colorScale - def setColormap(self, colormap): + def getColormap(self): + """ + + :returns: the colormap. + :rtype: :class:`.Colormap` + """ + return self.colorScale.getColormap() + + def setColormap(self, colormap, data=None): """Set the new colormap to be displayed - :param dict colormap: the colormap to set + :param Colormap colormap: the colormap to set + :param numpy.ndarray data: the data to display, needed if the colormap + require an autoscale """ - if colormap is not None: - self.colorScale.setColormap(colormap) + self.colorScale.setColormap(colormap, data) - self.tickbar.update(vmin=colormap['vmin'], - vmax=colormap['vmax'], - norm=colormap['normalization']) + if colormap is not None: + vmin, vmax = colormap.getColormapRange(data) + norm = colormap.getNormalization() + else: + vmin, vmax = None, None + norm = None - self._setMinMaxLabels(colormap['vmin'], colormap['vmax']) + self.tickbar.update(vmin=vmin, + vmax=vmax, + norm=norm) + self._setMinMaxLabels(vmin, vmax) def setMinMaxVisible(self, val=True): """Change visibility of the min label and the max label @@ -359,17 +411,29 @@ class ColorScaleBar(qt.QWidget): def _updateMinMax(self): """Update the min and max label if we are in the case of the configuration 'minMaxValueOnly'""" - if self._minLabel is not None and self._maxLabel is not None: - if self.minVal is not None: - if ColorScaleBar._MIN_LIM_SCI_FORM <= self.minVal <= ColorScaleBar._MAX_LIM_SCI_FORM: - self._minLabel.setText(str(self.minVal)) - else: - self._minLabel.setText("{0:.0e}".format(self.minVal)) - if self.maxVal is not None: - if ColorScaleBar._MIN_LIM_SCI_FORM <= self.maxVal <= ColorScaleBar._MAX_LIM_SCI_FORM: - self._maxLabel.setText(str(self.maxVal)) - else: - self._maxLabel.setText("{0:.0e}".format(self.maxVal)) + if self.minVal is None: + text, tooltip = '', '' + else: + if self.minVal == 0 or 0 <= numpy.log10(abs(self.minVal)) < 7: + text = '%.7g' % self.minVal + else: + text = '%.2e' % self.minVal + tooltip = repr(self.minVal) + + self._minLabel.setText(text) + self._minLabel.setToolTip(tooltip) + + if self.maxVal is None: + text, tooltip = '', '' + else: + if self.maxVal == 0 or 0 <= numpy.log10(abs(self.maxVal)) < 7: + text = '%.7g' % self.maxVal + else: + text = '%.2e' % self.maxVal + tooltip = repr(self.maxVal) + + self._maxLabel.setText(text) + self._maxLabel.setToolTip(tooltip) def _setMinMaxLabels(self, minVal, maxVal): """Change the value of the min and max labels to be displayed. @@ -400,12 +464,11 @@ class _ColorScale(qt.QWidget): To run the following sample code, a QApplication must be initialized. - >>> colormap={'name':'viridis', - ... 'normalization':'log', - ... 'vmin':1, - ... 'vmax':100000, - ... 'autoscale':False - ... } + >>> colormap = Colormap(name='viridis', + ... norm='log', + ... vmin=1, + ... vmax=100000, + ... ) >>> colorscale = ColorScale(parent=None, ... colormap=colormap) >>> colorscale.show() @@ -423,83 +486,94 @@ class _ColorScale(qt.QWidget): _NB_CONTROL_POINTS = 256 - def __init__(self, colormap, parent=None, margin=5): + def __init__(self, colormap, parent=None, margin=5, data=None): qt.QWidget.__init__(self, parent) - self.colormap = None - self.setColormap(colormap) + self._colormap = None + self.margin = margin + self.setColormap(colormap, data) self.setLayout(qt.QVBoxLayout()) - self.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Expanding) + self.setSizePolicy(qt.QSizePolicy.Fixed, qt.QSizePolicy.Expanding) # needed to get the mouse event without waiting for button click self.setMouseTracking(True) self.setMargin(margin) self.setContentsMargins(0, 0, 0, 0) - def setColormap(self, colormap): + self.setMinimumHeight(self._NB_CONTROL_POINTS // 2 + 2 * self.margin) + self.setFixedWidth(25) + + def setColormap(self, colormap, data=None): """Set the new colormap to be displayed :param dict colormap: the colormap to set + :param data: Optional data for which to compute colormap range. """ - if colormap is None: - return + self._colormap = colormap + self.setEnabled(colormap is not None) - if colormap['normalization'] not in ('log', 'linear'): - raise ValueError("Unrecognized normalization, should be 'linear' or 'log'") + if colormap is None: + self.vmin, self.vmax = None, None + else: + assert colormap.getNormalization() in Colormap.Colormap.NORMALIZATIONS + self.vmin, self.vmax = self._colormap.getColormapRange(data=data) + self._updateColorGradient() + self.update() - if colormap['normalization'] is 'log': - if not (colormap['vmin'] > 0 and colormap['vmax'] > 0): - raise ValueError('vmin and vmax should be positives') - self.colormap = colormap - self._computeColorPoints() + def getColormap(self): + """Returns the colormap - def _computeColorPoints(self): - """Compute the color points for the gradient + :rtype: :class:`.Colormap` """ - if self.colormap is None: + return None if self._colormap is None else self._colormap + + def _updateColorGradient(self): + """Compute the color gradient""" + colormap = self.getColormap() + if colormap is None: return - vmin = self.colormap['vmin'] - vmax = self.colormap['vmax'] - steps = (vmax - vmin)/float(_ColorScale._NB_CONTROL_POINTS) - self.ctrPoints = numpy.arange(vmin, vmax, steps) - self.colorsCtrPts = Colors.applyColormapToData(self.ctrPoints, - name=self.colormap['name'], - normalization='linear', - autoscale=self.colormap['autoscale'], - vmin=vmin, - vmax=vmax) + indices = numpy.linspace(0., 1., self._NB_CONTROL_POINTS) + colormapDisp = Colormap.Colormap(name=colormap.getName(), + normalization=Colormap.Colormap.LINEAR, + vmin=None, + vmax=None, + colors=colormap.getColormapLUT()) + colors = colormapDisp.applyToData(indices) + self._gradient = qt.QLinearGradient(0, 1, 0, 0) + self._gradient.setCoordinateMode(qt.QGradient.StretchToDeviceMode) + self._gradient.setStops( + [(i, qt.QColor(*color)) for i, color in zip(indices, colors)] + ) def paintEvent(self, event): """""" - qt.QWidget.paintEvent(self, event) - if self.colormap is None: - return - - vmin = self.colormap['vmin'] - vmax = self.colormap['vmax'] - painter = qt.QPainter(self) - gradient = qt.QLinearGradient(0, 0, 0, self.rect().height() - 2*self.margin) - for iPt, pt in enumerate(self.ctrPoints): - colormapPosition = 1 - (pt-vmin) / (vmax-vmin) - assert(colormapPosition >= 0.0) - assert(colormapPosition <= 1.0) - gradient.setColorAt(colormapPosition, qt.QColor(*(self.colorsCtrPts[iPt]))) + if self.getColormap() is not None: + painter.setBrush(self._gradient) + penColor = self.palette().color(qt.QPalette.Active, + qt.QPalette.Foreground) + else: + penColor = self.palette().color(qt.QPalette.Disabled, + qt.QPalette.Foreground) + painter.setPen(penColor) - painter.setBrush(gradient) - painter.drawRect( - qt.QRect(0, self.margin, self.width(), self.height() - 2.*self.margin)) + painter.drawRect(qt.QRect( + 0, + self.margin, + self.width() - 1., + self.height() - 2. * self.margin - 1.)) def mouseMoveEvent(self, event): - """""" - self.setToolTip(str(self.getValueFromRelativePosition(self._getRelativePosition(event.y())))) + tooltip = str(self.getValueFromRelativePosition( + self._getRelativePosition(event.y()))) + qt.QToolTip.showText(event.globalPos(), tooltip, self) super(_ColorScale, self).mouseMoveEvent(event) def _getRelativePosition(self, yPixel): """yPixel : pixel position into _ColorScale widget reference """ # widgets are bottom-top referencial but we display in top-bottom referential - return 1 - float(yPixel)/float(self.height() - 2*self.margin) + return 1. - (yPixel - self.margin) / float(self.height() - 2 * self.margin) def getValueFromRelativePosition(self, value): """Return the value in the colorMap from a relative position in the @@ -508,17 +582,22 @@ class _ColorScale(qt.QWidget): :param value: float value in [0, 1] :return: the value in [colormap['vmin'], colormap['vmax']] """ + colormap = self.getColormap() + if colormap is None: + return + value = max(0.0, value) value = min(value, 1.0) - vmin = self.colormap['vmin'] - vmax = self.colormap['vmax'] - if self.colormap['normalization'] is 'linear': + + vmin = self.vmin + vmax = self.vmax + if colormap.getNormalization() == Colormap.Colormap.LINEAR: return vmin + (vmax - vmin) * value - elif self.colormap['normalization'] is 'log': + elif colormap.getNormalization() == Colormap.Colormap.LOGARITHM: rpos = (numpy.log10(vmax) - numpy.log10(vmin)) * value + numpy.log10(vmin) return numpy.power(10., rpos) else: - err = "normalization type (%s) is not managed by the _ColorScale Widget" % self.colormap['normalization'] + err = "normalization type (%s) is not managed by the _ColorScale Widget" % colormap['normalization'] raise ValueError(err) def setMargin(self, margin): @@ -529,6 +608,7 @@ class _ColorScale(qt.QWidget): :param int margin: the margin to apply on the top and bottom. """ self.margin = margin + self.update() class _TickBar(qt.QWidget): @@ -536,7 +616,7 @@ class _TickBar(qt.QWidget): To run the following sample code, a QApplication must be initialized. - >>> bar = TickBar(1, 1000, norm='log', parent=None, displayValues=True) + >>> bar = _TickBar(1, 1000, norm='log', parent=None, displayValues=True) >>> bar.show() .. image:: img/tickbar.png @@ -569,24 +649,19 @@ class _TickBar(qt.QWidget): def __init__(self, vmin, vmax, norm, parent=None, displayValues=True, nticks=None, margin=5): super(_TickBar, self).__init__(parent) + self.margin = margin + self._nticks = None + self.ticks = () + self.subTicks = () self._forcedDisplayType = None self.ticksDensity = _TickBar.DEFAULT_TICK_DENSITY self._vmin = vmin self._vmax = vmax - # TODO : should be grouped into a global function, called by all - # logScale displayer to make sure we have the same behavior everywhere - if self._vmin < 1. or self._vmax < 1.: - _logger.warning( - 'Log colormap with bound <= 1: changing bounds.') - self._vmin, self._vmax = 1., 10. - self._norm = norm self.displayValues = displayValues self.setTicksNumber(nticks) - self.setMargin(margin) - self.setLayout(qt.QVBoxLayout()) self.setMargin(margin) self.setContentsMargins(0, 0, 0, 0) @@ -597,8 +672,8 @@ class _TickBar(qt.QWidget): self._resetWidth() def _resetWidth(self): - self.width = _TickBar._WIDTH_DISP_VAL if self.displayValues else _TickBar._WIDTH_NO_DISP_VAL - self.setFixedWidth(self.width) + width = self._WIDTH_DISP_VAL if self.displayValues else self._WIDTH_NO_DISP_VAL + self.setFixedWidth(width) def update(self, vmin, vmax, norm): self._vmin = vmin @@ -623,7 +698,6 @@ class _TickBar(qt.QWidget): optimal number of ticks from the tick density. """ self._nticks = nticks - self.ticks = None self.computeTicks() qt.QWidget.update(self) @@ -644,9 +718,13 @@ class _TickBar(qt.QWidget): if nticks is None: nticks = self._getOptimalNbTicks() - if self._norm == 'log': + if self._vmin == self._vmax: + # No range: no ticks + self.ticks = () + self.subTicks = () + elif self._norm == Colormap.Colormap.LOGARITHM: self._computeTicksLog(nticks) - elif self._norm == 'linear': + elif self._norm == Colormap.Colormap.LINEAR: self._computeTicksLin(nticks) else: err = 'TickBar - Wrong normalization %s' % self._norm @@ -693,22 +771,19 @@ class _TickBar(qt.QWidget): painter.setFont(font) # paint ticks - if self.ticks is not None: - for val in self.ticks: - self._paintTick(val, painter, majorTick=True) - - # paint subticks - for val in self.subTicks: - self._paintTick(val, painter, majorTick=False) + for val in self.ticks: + self._paintTick(val, painter, majorTick=True) - qt.QWidget.paintEvent(self, event) + # paint subticks + for val in self.subTicks: + self._paintTick(val, painter, majorTick=False) def _getRelativePosition(self, val): """Return the relative position of val according to min and max value """ - if self._norm == 'linear': + if self._norm == Colormap.Colormap.LINEAR: return 1 - (val - self._vmin) / (self._vmax - self._vmin) - elif self._norm == 'log': + elif self._norm == Colormap.Colormap.LOGARITHM: return 1 - (numpy.log10(val) - numpy.log10(self._vmin))/(numpy.log10(self._vmax) - numpy.log(self._vmin)) else: raise ValueError('Norm is not recognized') @@ -720,7 +795,7 @@ class _TickBar(qt.QWidget): with a smaller width """ fm = qt.QFontMetrics(painter.font()) - viewportHeight = self.rect().height() - self.margin * 2 + viewportHeight = self.rect().height() - self.margin * 2 - 1 relativePos = self._getRelativePosition(val) height = viewportHeight * relativePos height += self.margin @@ -728,9 +803,9 @@ class _TickBar(qt.QWidget): if majorTick is False: lineWidth /= 2 - painter.drawLine(qt.QLine(self.width - lineWidth, + painter.drawLine(qt.QLine(self.width() - lineWidth, height, - self.width, + self.width(), height)) if self.displayValues and majorTick is True: @@ -774,7 +849,6 @@ class _TickBar(qt.QWidget): :param QFont font: the font we want want to use durint the painting """ - assert(type(self._vmin) == type(self._vmax)) form = self._getStandardFormat() fm = qt.QFontMetrics(font) diff --git a/silx/gui/plot/Colormap.py b/silx/gui/plot/Colormap.py new file mode 100644 index 0000000..abe8546 --- /dev/null +++ b/silx/gui/plot/Colormap.py @@ -0,0 +1,410 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides the Colormap object +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent", "H.Payno"] +__license__ = "MIT" +__date__ = "05/12/2016" + +from silx.gui import qt +import copy as copy_mdl +import numpy +from .matplotlib import Colormap as MPLColormap +import logging +from silx.math.combo import min_max + +_logger = logging.getLogger(__file__) + +DEFAULT_COLORMAPS = ( + 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue') +"""Tuple of supported colormap names.""" + +DEFAULT_MIN_LIN = 0 +"""Default min value if in linear normalization""" +DEFAULT_MAX_LIN = 1 +"""Default max value if in linear normalization""" +DEFAULT_MIN_LOG = 1 +"""Default min value if in log normalization""" +DEFAULT_MAX_LOG = 10 +"""Default max value if in log normalization""" + + +class Colormap(qt.QObject): + """Description of a colormap + + :param str name: Name of the colormap + :param tuple colors: optional, custom colormap. + Nx3 or Nx4 numpy array of RGB(A) colors, + either uint8 or float in [0, 1]. + If 'name' is None, then this array is used as the colormap. + :param str norm: Normalization: 'linear' (default) or 'log' + :param float vmin: + Lower bound of the colormap or None for autoscale (default) + :param float vmax: + Upper bounds of the colormap or None for autoscale (default) + """ + + LINEAR = 'linear' + """constant for linear normalization""" + + LOGARITHM = 'log' + """constant for logarithmic normalization""" + + NORMALIZATIONS = (LINEAR, LOGARITHM) + """Tuple of managed normalizations""" + + sigChanged = qt.Signal() + + def __init__(self, name='gray', colors=None, normalization=LINEAR, vmin=None, vmax=None): + qt.QObject.__init__(self) + assert normalization in Colormap.NORMALIZATIONS + assert not (name is None and colors is None) + if normalization is Colormap.LOGARITHM: + if (vmin is not None and vmin < 0) or (vmax is not None and vmax < 0): + m = "Unsuported vmin (%s) and/or vmax (%s) given for a log scale." + m += ' Autoscale will be performed.' + m = m % (vmin, vmax) + _logger.warning(m) + vmin = None + vmax = None + + self._name = str(name) if name is not None else None + self._setColors(colors) + self._normalization = str(normalization) + self._vmin = float(vmin) if vmin is not None else None + self._vmax = float(vmax) if vmax is not None else None + + def isAutoscale(self): + """Return True if both min and max are in autoscale mode""" + return self._vmin is None or self._vmax is None + + def getName(self): + """Return the name of the colormap + :rtype: str + """ + return self._name + + def _setColors(self, colors): + if colors is None: + self._colors = None + else: + self._colors = numpy.array(colors, copy=True) + + def setName(self, name): + """Set the name of the colormap and load the colors corresponding to + the name + + :param str name: the name of the colormap (should be in ['gray', + 'reversed gray', 'temperature', 'red', 'green', 'blue', 'jet', + 'viridis', 'magma', 'inferno', 'plasma'] + """ + assert name in self.getSupportedColormaps() + self._name = str(name) + self._colors = None + self.sigChanged.emit() + + def getColormapLUT(self): + """Return the list of colors for the colormap. None if not setted + + :return: the list of colors for the colormap. None if not setted + :rtype: numpy.ndarray + """ + return self._colors + + def setColormapLUT(self, colors): + """ + Set the colors of the colormap. + + :param numpy.ndarray colors: the colors of the LUT + + .. warning: this will set the value of name to an empty string + """ + self._setColors(colors) + if len(colors) is 0: + self._colors = None + + self._name = None + self.sigChanged.emit() + + def getNormalization(self): + """Return the normalization of the colormap ('log' or 'linear') + + :return: the normalization of the colormap + :rtype: str + """ + return self._normalization + + def setNormalization(self, norm): + """Set the norm ('log', 'linear') + + :param str norm: the norm to set + """ + self._normalization = str(norm) + self.sigChanged.emit() + + def getVMin(self): + """Return the lower bound of the colormap + + :return: the lower bound of the colormap + :rtype: float or None + """ + return self._vmin + + def setVMin(self, vmin): + """Set the minimal value of the colormap + + :param float vmin: Lower bound of the colormap or None for autoscale + (default) + value) + """ + if vmin is not None: + if self._vmax is not None and vmin >= self._vmax: + err = "Can't set vmin because vmin >= vmax." + err += "vmin = %s, vmax = %s" %(vmin, self._vmax) + raise ValueError(err) + + self._vmin = vmin + self.sigChanged.emit() + + def getVMax(self): + """Return the upper bounds of the colormap or None + + :return: the upper bounds of the colormap or None + :rtype: float or None + """ + return self._vmax + + def setVMax(self, vmax): + """Set the maximal value of the colormap + + :param float vmax: Upper bounds of the colormap or None for autoscale + (default) + """ + if vmax is not None: + if self._vmin is not None and vmax <= self._vmin: + err = "Can't set vmax because vmax <= vmin." + err += "vmin = %s, vmax = %s" %(self._vmin, vmax) + raise ValueError(err) + + self._vmax = vmax + self.sigChanged.emit() + + def getColormapRange(self, data=None): + """Return (vmin, vmax) + + :return: the tuple vmin, vmax fitting vmin, vmax, normalization and + data if any given + :rtype: tuple + """ + vmin = self._vmin + vmax = self._vmax + assert vmin is None or vmax is None or vmin <= vmax # TODO handle this in setters + + if self.getNormalization() == self.LOGARITHM: + # Handle negative bounds as autoscale + if vmin is not None and (vmin is not None and vmin <= 0.): + mess = 'negative vmin, moving to autoscale for lower bound' + _logger.warning(mess) + vmin = None + if vmax is not None and (vmax is not None and vmax <= 0.): + mess = 'negative vmax, moving to autoscale for upper bound' + _logger.warning(mess) + vmax = None + + if vmin is None or vmax is None: # Handle autoscale + # Get min/max from data + if data is not None: + data = numpy.array(data, copy=False) + if data.size == 0: # Fallback an array but no data + min_, max_ = self._getDefaultMin(), self._getDefaultMax() + else: + if self.getNormalization() == self.LOGARITHM: + result = min_max(data, min_positive=True, finite=True) + min_ = result.min_positive # >0 or None + max_ = result.maximum # can be <= 0 + else: + min_, max_ = min_max(data, min_positive=False, finite=True) + + # Handle fallback + if min_ is None or not numpy.isfinite(min_): + min_ = self._getDefaultMin() + if max_ is None or not numpy.isfinite(max_): + max_ = self._getDefaultMax() + else: # Fallback if no data is provided + min_, max_ = self._getDefaultMin(), self._getDefaultMax() + + if vmin is None: # Set vmin respecting provided vmax + vmin = min_ if vmax is None else min(min_, vmax) + + if vmax is None: + vmax = max(max_, vmin) # Handle max_ <= 0 for log scale + + return vmin, vmax + + def setVRange(self, vmin, vmax): + """ + Set bounds to the colormap + + :param vmin: Lower bound of the colormap or None for autoscale + (default) + :param vmax: Upper bounds of the colormap or None for autoscale + (default) + """ + if vmin is not None and vmax is not None: + if vmin >= vmax: + err = "Can't set vmin and vmax because vmin >= vmax" + err += "vmin = %s, vmax = %s" %(vmin, self._vmax) + raise ValueError(err) + + self._vmin = vmin + self._vmax = vmax + self.sigChanged.emit() + + def __getitem__(self, item): + if item == 'autoscale': + return self.isAutoscale() + elif item == 'name': + return self.getName() + elif item == 'normalization': + return self.getNormalization() + elif item == 'vmin': + return self.getVMin() + elif item == 'vmax': + return self.getVMax() + elif item == 'colors': + return self.getColormapLUT() + else: + raise KeyError(item) + + def _toDict(self): + """Return the equivalent colormap as a dictionary + (old colormap representation) + + :return: the representation of the Colormap as a dictionary + :rtype: dict + """ + return { + 'name': self._name, + 'colors': copy_mdl.copy(self._colors), + 'vmin': self._vmin, + 'vmax': self._vmax, + 'autoscale': self.isAutoscale(), + 'normalization': self._normalization + } + + def _setFromDict(self, dic): + """Set values to the colormap from a dictionary + + :param dict dic: the colormap as a dictionary + """ + name = dic['name'] if 'name' in dic else None + colors = dic['colors'] if 'colors' in dic else None + vmin = dic['vmin'] if 'vmin' in dic else None + vmax = dic['vmax'] if 'vmax' in dic else None + if 'normalization' in dic: + normalization = dic['normalization'] + else: + warn = 'Normalization not given in the dictionary, ' + warn += 'set by default to ' + Colormap.LINEAR + _logger.warning(warn) + normalization = Colormap.LINEAR + + if name is None and colors is None: + err = 'The colormap should have a name defined or a tuple of colors' + raise ValueError(err) + if normalization not in Colormap.NORMALIZATIONS: + err = 'Given normalization is not recoginized (%s)' % normalization + raise ValueError(err) + + # If autoscale, then set boundaries to None + if dic.get('autoscale', False): + vmin, vmax = None, None + + self._name = name + self._colors = colors + self._vmin = vmin + self._vmax = vmax + self._autoscale = True if (vmin is None and vmax is None) else False + self._normalization = normalization + + self.sigChanged.emit() + + @staticmethod + def _fromDict(dic): + colormap = Colormap(name="") + colormap._setFromDict(dic) + return colormap + + def copy(self): + """ + + :return: a copy of the Colormap object + """ + return Colormap(name=self._name, + colors=copy_mdl.copy(self._colors), + vmin=self._vmin, + vmax=self._vmax, + normalization=self._normalization) + + def applyToData(self, data): + """Apply the colormap to the data + + :param numpy.ndarray data: The data to convert. + """ + rgbaImage = MPLColormap.applyColormapToData(colormap=self, data=data) + return rgbaImage + + @staticmethod + def getSupportedColormaps(): + """Get the supported colormap names as a tuple of str. + + The list should at least contain and start by: + ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue') + :rtype: tuple + """ + maps = MPLColormap.getSupportedColormaps() + return DEFAULT_COLORMAPS + maps + + def __str__(self): + return str(self._toDict()) + + def _getDefaultMin(self): + return DEFAULT_MIN_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MIN_LOG + + def _getDefaultMax(self): + return DEFAULT_MAX_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MAX_LOG + + def __eq__(self, other): + """Compare colormap values and not pointers""" + return (self.getName() == other.getName() and + self.getNormalization() == other.getNormalization() and + self.getVMin() == other.getVMin() and + self.getVMax() == other.getVMax() and + numpy.array_equal(self.getColormapLUT(), other.getColormapLUT()) + ) + diff --git a/silx/gui/plot/ColormapDialog.py b/silx/gui/plot/ColormapDialog.py index ad1425c..748dd72 100644 --- a/silx/gui/plot/ColormapDialog.py +++ b/silx/gui/plot/ColormapDialog.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2016 European Synchrotron Radiation Facility +# 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 @@ -42,7 +42,7 @@ Create the colormap dialog and set the colormap description and data range: Get the colormap description (compatible with :class:`Plot`) from the dialog: >>> cmap = dialog.getColormap() ->>> cmap['name'] +>>> cmap.getName() 'red' It is also possible to display an histogram of the image in the dialog. @@ -61,7 +61,7 @@ from __future__ import division __authors__ = ["V.A. Sole", "T. Vincent"] __license__ = "MIT" -__date__ = "29/03/2016" +__date__ = "02/10/2017" import logging @@ -69,37 +69,13 @@ import logging import numpy from .. import qt +from .Colormap import Colormap from . import PlotWidget - +from silx.gui.widgets.FloatEdit import FloatEdit _logger = logging.getLogger(__name__) -class _FloatEdit(qt.QLineEdit): - """Field to edit a float value. - - :param parent: See :class:`QLineEdit` - :param float value: The value to set the QLineEdit to. - """ - def __init__(self, parent=None, value=None): - qt.QLineEdit.__init__(self, parent) - self.setValidator(qt.QDoubleValidator()) - self.setAlignment(qt.Qt.AlignRight) - if value is not None: - self.setValue(value) - - def value(self): - """Return the QLineEdit current value as a float.""" - return float(self.text()) - - def setValue(self, value): - """Set the current value of the LineEdit - - :param float value: The value to set the QLineEdit to. - """ - self.setText('%g' % value) - - class ColormapDialog(qt.QDialog): """A QDialog widget to set the colormap. @@ -107,7 +83,7 @@ class ColormapDialog(qt.QDialog): :param str title: The QDialog title """ - sigColormapChanged = qt.Signal(dict) + sigColormapChanged = qt.Signal(Colormap) """Signal triggered when the colormap is changed. It provides a dict describing the colormap to the slot. @@ -122,10 +98,13 @@ class ColormapDialog(qt.QDialog): self._dataRange = None self._minMaxWasEdited = False - self._colormapList = ( + colormaps = [ 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue', 'jet', - 'viridis', 'magma', 'inferno', 'plasma') + 'viridis', 'magma', 'inferno', 'plasma'] + if 'hsv' in Colormap.getSupportedColormaps(): + colormaps.append('hsv') + self._colormapList = tuple(colormaps) # Make the GUI vLayout = qt.QVBoxLayout(self) @@ -172,14 +151,14 @@ class ColormapDialog(qt.QDialog): formLayout.addRow('Range:', self._rangeAutoscaleButton) # Min row - self._minValue = _FloatEdit(value=1.) + self._minValue = FloatEdit(parent=self, value=1.) self._minValue.setEnabled(False) self._minValue.textEdited.connect(self._minMaxTextEdited) self._minValue.editingFinished.connect(self._minEditingFinished) formLayout.addRow('\tMin:', self._minValue) # Max row - self._maxValue = _FloatEdit(value=10.) + self._maxValue = FloatEdit(parent=self, value=10.) self._maxValue.setEnabled(False) self._maxValue.textEdited.connect(self._minMaxTextEdited) self._maxValue.editingFinished.connect(self._maxEditingFinished) @@ -214,8 +193,8 @@ class ColormapDialog(qt.QDialog): """Init the plot to display the range and the values""" self._plot = PlotWidget() self._plot.setDataMargins(yMinMargin=0.125, yMaxMargin=0.125) - self._plot.setGraphXLabel("Data Values") - self._plot.setGraphYLabel("") + self._plot.getXAxis().setLabel("Data Values") + self._plot.getYAxis().setLabel("") self._plot.setInteractiveMode('select', zoomOnWheel=False) self._plot.setActiveCurveHandling(False) self._plot.setMinimumSize(qt.QSize(250, 200)) @@ -392,17 +371,22 @@ class ColormapDialog(qt.QDialog): self._plotUpdate() def getColormap(self): - """Return the colormap description as a dict. + """Return the colormap description as a :class:`.Colormap`. - See :class:`Plot` for documentation on the colormap dict. """ isNormLinear = self._normButtonLinear.isChecked() - colormap = { - 'name': str(self._comboBoxColormap.currentText()).lower(), - 'normalization': 'linear' if isNormLinear else 'log', - 'autoscale': self._rangeAutoscaleButton.isChecked(), - 'vmin': self._minValue.value(), - 'vmax': self._maxValue.value()} + if self._rangeAutoscaleButton.isChecked(): + vmin = None + vmax = None + else: + vmin = self._minValue.value() + vmax = self._maxValue.value() + norm = Colormap.LINEAR if isNormLinear else Colormap.LOGARITHM + colormap = Colormap( + name=str(self._comboBoxColormap.currentText()).lower(), + normalization=norm, + vmin=vmin, + vmax=vmax) return colormap def setColormap(self, name=None, normalization=None, @@ -423,9 +407,9 @@ class ColormapDialog(qt.QDialog): self._comboBoxColormap.setCurrentIndex(index) if normalization is not None: - assert normalization in ('linear', 'log') - self._normButtonLinear.setChecked(normalization == 'linear') - self._normButtonLog.setChecked(normalization == 'log') + assert normalization in Colormap.NORMALIZATIONS + self._normButtonLinear.setChecked(normalization == Colormap.LINEAR) + self._normButtonLog.setChecked(normalization == Colormap.LOGARITHM) if vmin is not None: self._minValue.setValue(vmin) diff --git a/silx/gui/plot/Colors.py b/silx/gui/plot/Colors.py index 7a3cd97..2d44d4d 100644 --- a/silx/gui/plot/Colors.py +++ b/silx/gui/plot/Colors.py @@ -24,20 +24,18 @@ # ###########################################################################*/ """Color conversion function, color dictionary and colormap tools.""" -__authors__ = ["V.A. Sole", "T. VINCENT"] +from __future__ import absolute_import + +__authors__ = ["V.A. Sole", "T. Vincent"] __license__ = "MIT" -__date__ = "16/01/2017" +__date__ = "15/05/2017" +from silx.utils.deprecation import deprecated import logging - import numpy -import matplotlib -import matplotlib.colors -import matplotlib.cm - -from . import MPLColormap +from .Colormap import Colormap _logger = logging.getLogger(__name__) @@ -143,159 +141,7 @@ def cursorColorForColormap(colormapName): return _COLORMAP_CURSOR_COLORS.get(colormapName, 'black') -_CMAPS = {} # Store additional colormaps - - -def getMPLColormap(name): - """Returns matplotlib colormap corresponding to given name - - :param str name: The name of the colormap - :return: The corresponding colormap - :rtype: matplolib.colors.Colormap - """ - if not _CMAPS: # Lazy initialization of own colormaps - cdict = {'red': ((0.0, 0.0, 0.0), - (1.0, 1.0, 1.0)), - 'green': ((0.0, 0.0, 0.0), - (1.0, 0.0, 0.0)), - 'blue': ((0.0, 0.0, 0.0), - (1.0, 0.0, 0.0))} - _CMAPS['red'] = matplotlib.colors.LinearSegmentedColormap( - 'red', cdict, 256) - - cdict = {'red': ((0.0, 0.0, 0.0), - (1.0, 0.0, 0.0)), - 'green': ((0.0, 0.0, 0.0), - (1.0, 1.0, 1.0)), - 'blue': ((0.0, 0.0, 0.0), - (1.0, 0.0, 0.0))} - _CMAPS['green'] = matplotlib.colors.LinearSegmentedColormap( - 'green', cdict, 256) - - cdict = {'red': ((0.0, 0.0, 0.0), - (1.0, 0.0, 0.0)), - 'green': ((0.0, 0.0, 0.0), - (1.0, 0.0, 0.0)), - 'blue': ((0.0, 0.0, 0.0), - (1.0, 1.0, 1.0))} - _CMAPS['blue'] = matplotlib.colors.LinearSegmentedColormap( - 'blue', cdict, 256) - - # Temperature as defined in spslut - cdict = {'red': ((0.0, 0.0, 0.0), - (0.5, 0.0, 0.0), - (0.75, 1.0, 1.0), - (1.0, 1.0, 1.0)), - 'green': ((0.0, 0.0, 0.0), - (0.25, 1.0, 1.0), - (0.75, 1.0, 1.0), - (1.0, 0.0, 0.0)), - 'blue': ((0.0, 1.0, 1.0), - (0.25, 1.0, 1.0), - (0.5, 0.0, 0.0), - (1.0, 0.0, 0.0))} - # but limited to 256 colors for a faster display (of the colorbar) - _CMAPS['temperature'] = \ - matplotlib.colors.LinearSegmentedColormap( - 'temperature', cdict, 256) - - # reversed gray - cdict = {'red': ((0.0, 1.0, 1.0), - (1.0, 0.0, 0.0)), - 'green': ((0.0, 1.0, 1.0), - (1.0, 0.0, 0.0)), - 'blue': ((0.0, 1.0, 1.0), - (1.0, 0.0, 0.0))} - - _CMAPS['reversed gray'] = \ - matplotlib.colors.LinearSegmentedColormap( - 'yerg', cdict, 256) - - if name in _CMAPS: - return _CMAPS[name] - elif hasattr(MPLColormap, name): # viridis and sister colormaps - return getattr(MPLColormap, name) - else: - # matplotlib built-in - return matplotlib.cm.get_cmap(name) - - -def getMPLScalarMappable(colormap, data=None): - """Returns matplotlib ScalarMappable corresponding to colormap - - :param dict colormap: The colormap to convert - :param numpy.ndarray data: - The data on which the colormap is applied. - If provided, it is used to compute autoscale. - :return: matplotlib object corresponding to colormap - :rtype: matplotlib.cm.ScalarMappable - """ - assert colormap is not None - - if colormap['name'] is not None: - cmap = getMPLColormap(colormap['name']) - - else: # No name, use custom colors - if 'colors' not in colormap: - raise ValueError( - 'addImage: colormap no name nor list of colors.') - colors = numpy.array(colormap['colors'], copy=True) - assert len(colors.shape) == 2 - assert colors.shape[-1] in (3, 4) - if colors.dtype == numpy.uint8: - # Convert to float in [0., 1.] - colors = colors.astype(numpy.float32) / 255. - cmap = matplotlib.colors.ListedColormap(colors) - - if colormap['normalization'].startswith('log'): - vmin, vmax = None, None - if not colormap['autoscale']: - if colormap['vmin'] > 0.: - vmin = colormap['vmin'] - if colormap['vmax'] > 0.: - vmax = colormap['vmax'] - - if vmin is None or vmax is None: - _logger.warning('Log colormap with negative bounds, ' + - 'changing bounds to positive ones.') - elif vmin > vmax: - _logger.warning('Colormap bounds are inverted.') - vmin, vmax = vmax, vmin - - # Set unset/negative bounds to positive bounds - if (vmin is None or vmax is None) and data is not None: - finiteData = data[numpy.isfinite(data)] - posData = finiteData[finiteData > 0] - if vmax is None: - # 1. as an ultimate fallback - vmax = posData.max() if posData.size > 0 else 1. - if vmin is None: - vmin = posData.min() if posData.size > 0 else vmax - if vmin > vmax: - vmin = vmax - - norm = matplotlib.colors.LogNorm(vmin, vmax) - - else: # Linear normalization - if colormap['autoscale']: - if data is None: - vmin, vmax = None, None - else: - finiteData = data[numpy.isfinite(data)] - vmin = finiteData.min() - vmax = finiteData.max() - else: - vmin = colormap['vmin'] - vmax = colormap['vmax'] - if vmin > vmax: - _logger.warning('Colormap bounds are inverted.') - vmin, vmax = vmax, vmin - - norm = matplotlib.colors.Normalize(vmin, vmax) - - return matplotlib.cm.ScalarMappable(norm=norm, cmap=cmap) - - +@deprecated(replacement='silx.gui.plot.Colormap.applyColormap') def applyColormapToData(data, name='gray', normalization='linear', @@ -324,36 +170,19 @@ def applyColormapToData(data, :return: The computed RGBA image :rtype: numpy.ndarray of uint8 """ - # Debian 7 specific support - # No transparent colormap with matplotlib < 1.2.0 - # Add support for transparent colormap for uint8 data with - # colormap with 256 colors, linear norm, [0, 255] range - if matplotlib.__version__ < '1.2.0': - if name is None and colors is not None: - colors = numpy.array(colors, copy=False) - if (colors.shape[-1] == 4 and - not numpy.all(numpy.equal(colors[3], 255))): - # This is a transparent colormap - if (colors.shape == (256, 4) and - normalization == 'linear' and - not autoscale and - vmin == 0 and vmax == 255 and - data.dtype == numpy.uint8): - # Supported case, convert data to RGBA - return colors[data.reshape(-1)].reshape( - data.shape + (4,)) - else: - _logger.warning( - 'matplotlib %s does not support transparent ' - 'colormap.', matplotlib.__version__) - - colormap = dict(name=name, - normalization=normalization, - autoscale=autoscale, - vmin=vmin, - vmax=vmax, - colors=colors) - scalarMappable = getMPLScalarMappable(colormap, data) - rgbaImage = scalarMappable.to_rgba(data, bytes=True) - - return rgbaImage + colormap = Colormap(name=name, + normalization=normalization, + vmin=vmin, + vmax=vmax, + colors=colors) + return colormap.applyToData(data) + + +@deprecated(replacement='silx.gui.plot.Colormap.getSupportedColormaps') +def getSupportedColormaps(): + """Get the supported colormap names as a tuple of str. + + The list should at least contain and start by: + ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue') + """ + return Colormap.getSupportedColormaps() diff --git a/silx/gui/plot/ComplexImageView.py b/silx/gui/plot/ComplexImageView.py new file mode 100644 index 0000000..1463293 --- /dev/null +++ b/silx/gui/plot/ComplexImageView.py @@ -0,0 +1,670 @@ +# 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 a widget to view 2D complex data. + +The :class:`ComplexImageView` widget is dedicated to visualize a single 2D dataset +of complex data. +""" + +from __future__ import absolute_import + +__authors__ = ["Vincent Favre-Nicolin", "T. Vincent"] +__license__ = "MIT" +__date__ = "02/10/2017" + + +import logging +import numpy + +from .. import qt, icons +from .PlotWindow import Plot2D +from .Colormap import Colormap +from . import items +from silx.gui.widgets.FloatEdit import FloatEdit + +_logger = logging.getLogger(__name__) + + +_PHASE_COLORMAP = Colormap( + name='hsv', + vmin=-numpy.pi, + vmax=numpy.pi) +"""Colormap to use for phase""" + +# Complex colormap functions + +def _phase2rgb(data): + """Creates RGBA image with colour-coded phase. + + :param numpy.ndarray data: The data to convert + :return: Array of RGBA colors + :rtype: numpy.ndarray + """ + if data.size == 0: + return numpy.zeros((0, 0, 4), dtype=numpy.uint8) + + phase = numpy.angle(data) + return _PHASE_COLORMAP.applyToData(phase) + + +def _complex2rgbalog(data, amin=0., dlogs=2, smax=None): + """Returns RGBA colors: colour-coded phases and log10(amplitude) in alpha. + + :param numpy.ndarray data: the complex data array to convert to RGBA + :param float amin: the minimum value for the alpha channel + :param float dlogs: amplitude range displayed, in log10 units + :param float smax: + if specified, all values above max will be displayed with an alpha=1 + """ + if data.size == 0: + return numpy.zeros((0, 0, 4), dtype=numpy.uint8) + + rgba = _phase2rgb(data) + sabs = numpy.absolute(data) + if smax is not None: + sabs[sabs > smax] = smax + a = numpy.log10(sabs + 1e-20) + a -= a.max() - dlogs # display dlogs orders of magnitude + rgba[..., 3] = 255 * (amin + a / dlogs * (1 - amin) * (a > 0)) + return rgba + + +def _complex2rgbalin(data, gamma=1.0, smax=None): + """Returns RGBA colors: colour-coded phase and linear amplitude in alpha. + + :param numpy.ndarray data: + :param float gamma: Optional exponent gamma applied to the amplitude + :param float smax: + """ + if data.size == 0: + return numpy.zeros((0, 0, 4), dtype=numpy.uint8) + + rgba = _phase2rgb(data) + a = numpy.absolute(data) + if smax is not None: + a[a > smax] = smax + a /= a.max() + rgba[..., 3] = 255 * a**gamma + return rgba + + +# Dedicated plot item + +class _ImageComplexData(items.ImageData): + """Specific plot item to force colormap when using complex colormap. + + This is returning the specific colormap when displaying + colored phase + amplitude. + """ + + def __init__(self): + super(_ImageComplexData, self).__init__() + self._readOnlyColormap = False + self._mode = 'absolute' + self._colormaps = { # Default colormaps for all modes + 'absolute': Colormap(), + 'phase': _PHASE_COLORMAP.copy(), + 'real': Colormap(), + 'imaginary': Colormap(), + 'amplitude_phase': _PHASE_COLORMAP.copy(), + 'log10_amplitude_phase': _PHASE_COLORMAP.copy(), + } + + _READ_ONLY_MODES = 'amplitude_phase', 'log10_amplitude_phase' + """Modes that requires a read-only colormap.""" + + def setVisualizationMode(self, mode): + """Set the visualization mode to use. + + :param str mode: + """ + mode = str(mode) + assert mode in self._colormaps + + if mode != self._mode: + # Save current colormap + self._colormaps[self._mode] = self.getColormap() + self._mode = mode + + # Set colormap for new mode + self.setColormap(self._colormaps[mode]) + + def getVisualizationMode(self): + """Returns the visualization mode in use.""" + return self._mode + + def _isReadOnlyColormap(self): + """Returns True if colormap should not be modified.""" + return self.getVisualizationMode() in self._READ_ONLY_MODES + + def setColormap(self, colormap): + if not self._isReadOnlyColormap(): + super(_ImageComplexData, self).setColormap(colormap) + + def getColormap(self): + if self._isReadOnlyColormap(): + return _PHASE_COLORMAP.copy() + else: + return super(_ImageComplexData, self).getColormap() + + +# Widgets + +class _AmplitudeRangeDialog(qt.QDialog): + """QDialog asking for the amplitude range to display.""" + + sigRangeChanged = qt.Signal(tuple) + """Signal emitted when the range has changed. + + It provides the new range as a 2-tuple: (max, delta) + """ + + def __init__(self, + parent=None, + amplitudeRange=None, + displayedRange=(None, 2)): + super(_AmplitudeRangeDialog, self).__init__(parent) + self.setWindowTitle('Set Displayed Amplitude Range') + + if amplitudeRange is not None: + amplitudeRange = min(amplitudeRange), max(amplitudeRange) + self._amplitudeRange = amplitudeRange + self._defaultDisplayedRange = displayedRange + + layout = qt.QFormLayout() + self.setLayout(layout) + + if self._amplitudeRange is not None: + min_, max_ = self._amplitudeRange + layout.addRow( + qt.QLabel('Data Amplitude Range: [%g, %g]' % (min_, max_))) + + self._maxLineEdit = FloatEdit(parent=self) + self._maxLineEdit.validator().setBottom(0.) + self._maxLineEdit.setAlignment(qt.Qt.AlignRight) + + self._maxLineEdit.editingFinished.connect(self._rangeUpdated) + layout.addRow('Displayed Max.:', self._maxLineEdit) + + self._autoscale = qt.QCheckBox('autoscale') + self._autoscale.toggled.connect(self._autoscaleCheckBoxToggled) + layout.addRow('', self._autoscale) + + self._deltaLineEdit = FloatEdit(parent=self) + self._deltaLineEdit.validator().setBottom(1.) + self._deltaLineEdit.setAlignment(qt.Qt.AlignRight) + self._deltaLineEdit.editingFinished.connect(self._rangeUpdated) + layout.addRow('Displayed delta (log10 unit):', self._deltaLineEdit) + + buttons = qt.QDialogButtonBox(self) + buttons.addButton(qt.QDialogButtonBox.Ok) + buttons.addButton(qt.QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addRow(buttons) + + # Set dialog from default values + self._resetDialogToDefault() + + self.rejected.connect(self._handleRejected) + + def _resetDialogToDefault(self): + """Set Widgets of the dialog from range information + """ + max_, delta = self._defaultDisplayedRange + + if max_ is not None: # Not in autoscale + displayedMax = max_ + elif self._amplitudeRange is not None: # Autoscale with data + displayedMax = self._amplitudeRange[1] + else: # Autoscale without data + displayedMax = '' + if displayedMax == "": + self._maxLineEdit.setText("") + else: + self._maxLineEdit.setValue(displayedMax) + self._maxLineEdit.setEnabled(max_ is not None) + + self._deltaLineEdit.setValue(delta) + + self._autoscale.setChecked(self._defaultDisplayedRange[0] is None) + + def getRangeInfo(self): + """Returns the current range as a 2-tuple (max, delta (in log10))""" + if self._autoscale.isChecked(): + max_ = None + else: + maxStr = self._maxLineEdit.text() + max_ = self._maxLineEdit.value() if maxStr else None + return max_, self._deltaLineEdit.value() if self._deltaLineEdit.text() else 2 + + def _handleRejected(self): + """Reset range info to default when rejected""" + self._resetDialogToDefault() + self._rangeUpdated() + + def _rangeUpdated(self): + """Handle QLineEdit editing finised""" + self.sigRangeChanged.emit(self.getRangeInfo()) + + def _autoscaleCheckBoxToggled(self, checked): + """Handle autoscale checkbox state changes""" + if checked: # Use default values + if self._amplitudeRange is None: + max_ = '' + else: + max_ = self._amplitudeRange[1] + if max_ == "": + self._maxLineEdit.setText("") + else: + self._maxLineEdit.setValue(max_) + self._maxLineEdit.setEnabled(not checked) + self._rangeUpdated() + + +class _ComplexDataToolButton(qt.QToolButton): + """QToolButton providing choices of complex data visualization modes + + :param parent: See :class:`QToolButton` + :param plot: The :class:`ComplexImageView` to control + """ + + _MODES = [ + ('absolute', 'math-amplitude', 'Amplitude'), + ('phase', 'math-phase', 'Phase'), + ('real', 'math-real', 'Real part'), + ('imaginary', 'math-imaginary', 'Imaginary part'), + ('amplitude_phase', 'math-phase-color', 'Amplitude and Phase'), + ('log10_amplitude_phase', 'math-phase-color-log', 'Log10(Amp.) and Phase')] + + _RANGE_DIALOG_TEXT = 'Set Amplitude Range...' + + def __init__(self, parent=None, plot=None): + super(_ComplexDataToolButton, self).__init__(parent=parent) + + assert plot is not None + self._plot2DComplex = plot + + menu = qt.QMenu(self) + menu.triggered.connect(self._triggered) + self.setMenu(menu) + + for _, icon, text in self._MODES: + action = qt.QAction(icons.getQIcon(icon), text, self) + action.setIconVisibleInMenu(True) + menu.addAction(action) + + self._rangeDialogAction = qt.QAction(self) + self._rangeDialogAction.setText(self._RANGE_DIALOG_TEXT) + menu.addAction(self._rangeDialogAction) + + self.setPopupMode(qt.QToolButton.InstantPopup) + + self._modeChanged(self._plot2DComplex.getVisualizationMode()) + self._plot2DComplex.sigVisualizationModeChanged.connect( + self._modeChanged) + + def _modeChanged(self, mode): + """Handle change of visualization modes""" + for actionMode, icon, text in self._MODES: + if actionMode == mode: + self.setIcon(icons.getQIcon(icon)) + self.setToolTip('Display the ' + text.lower()) + break + + self._rangeDialogAction.setEnabled(mode == 'log10_amplitude_phase') + + def _triggered(self, action): + """Handle triggering of menu actions""" + actionText = action.text() + + if actionText == self._RANGE_DIALOG_TEXT: # Show dialog + # Get amplitude range + data = self._plot2DComplex.getData(copy=False) + + if data.size > 0: + absolute = numpy.absolute(data) + dataRange = (numpy.nanmin(absolute), numpy.nanmax(absolute)) + else: + dataRange = None + + # Show dialog + dialog = _AmplitudeRangeDialog( + parent=self, + amplitudeRange=dataRange, + displayedRange=self._plot2DComplex._getAmplitudeRangeInfo()) + dialog.sigRangeChanged.connect(self._rangeChanged) + dialog.exec_() + dialog.sigRangeChanged.disconnect(self._rangeChanged) + + else: # update mode + for mode, _, text in self._MODES: + if actionText == text: + self._plot2DComplex.setVisualizationMode(mode) + + def _rangeChanged(self, range_): + """Handle updates of range in the dialog""" + self._plot2DComplex._setAmplitudeRangeInfo(*range_) + + +class ComplexImageView(qt.QWidget): + """Display an image of complex data and allow to choose the visualization. + + :param parent: See :class:`QMainWindow` + """ + + sigDataChanged = qt.Signal() + """Signal emitted when data has changed.""" + + sigVisualizationModeChanged = qt.Signal(str) + """Signal emitted when the visualization mode has changed. + + It provides the new visualization mode. + """ + + def __init__(self, parent=None): + super(ComplexImageView, self).__init__(parent) + if parent is None: + self.setWindowTitle('ComplexImageView') + + self._mode = 'absolute' + self._amplitudeRangeInfo = None, 2 + self._data = numpy.zeros((0, 0), dtype=numpy.complex) + self._displayedData = numpy.zeros((0, 0), dtype=numpy.float) + + self._plot2D = Plot2D(self) + + layout = qt.QHBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._plot2D) + self.setLayout(layout) + + # Create and add image to the plot + self._plotImage = _ImageComplexData() + self._plotImage._setLegend('__ComplexImageView__complex_image__') + self._plotImage.setData(self._displayedData) + self._plotImage.setVisualizationMode(self._mode) + self._plot2D._add(self._plotImage) + self._plot2D.setActiveImage(self._plotImage.getLegend()) + + toolBar = qt.QToolBar('Complex', self) + toolBar.addWidget( + _ComplexDataToolButton(parent=self, plot=self)) + + self._plot2D.insertToolBar(self._plot2D.getProfileToolbar(), toolBar) + + def getPlot(self): + """Return the PlotWidget displaying the data""" + return self._plot2D + + def _convertData(self, data, mode): + """Convert complex data according to provided mode. + + :param numpy.ndarray data: The complex data to convert + :param str mode: The visualization mode + :return: The data corresponding to the mode + :rtype: 2D numpy.ndarray of float or RGBA image + """ + if mode == 'absolute': + return numpy.absolute(data) + elif mode == 'phase': + return numpy.angle(data) + elif mode == 'real': + return numpy.real(data) + elif mode == 'imaginary': + return numpy.imag(data) + elif mode == 'amplitude_phase': + return _complex2rgbalin(data) + elif mode == 'log10_amplitude_phase': + max_, delta = self._getAmplitudeRangeInfo() + return _complex2rgbalog(data, dlogs=delta, smax=max_) + else: + _logger.error( + 'Unsupported conversion mode: %s, fallback to absolute', + str(mode)) + return numpy.absolute(data) + + def _updatePlot(self): + """Update the image in the plot""" + + mode = self.getVisualizationMode() + + self.getPlot().getColormapAction().setDisabled( + mode in ('amplitude_phase', 'log10_amplitude_phase')) + + self._plotImage.setVisualizationMode(mode) + + image = self.getDisplayedData(copy=False) + if mode in ('amplitude_phase', 'log10_amplitude_phase'): + # Combined view + absolute = numpy.absolute(self.getData(copy=False)) + self._plotImage.setData( + absolute, alternative=image, copy=False) + else: + self._plotImage.setData( + image, alternative=None, copy=False) + + def setData(self, data=None, copy=True): + """Set the complex data to display. + + :param numpy.ndarray data: 2D complex data + :param bool copy: True (default) to copy the data, + False to use provided data (do not modify!). + """ + if data is None: + data = numpy.zeros((0, 0), dtype=numpy.complex) + else: + data = numpy.array(data, copy=copy) + + assert data.ndim == 2 + if data.dtype.kind != 'c': # Convert to complex + data = numpy.array(data, dtype=numpy.complex) + shape_changed = (self._data.shape != data.shape) + self._data = data + self._displayedData = self._convertData( + data, self.getVisualizationMode()) + + self._updatePlot() + if shape_changed: + self.getPlot().resetZoom() + + self.sigDataChanged.emit() + + def getData(self, copy=True): + """Get the currently displayed complex data. + + :param bool copy: True (default) to return a copy of the data, + False to return internal data (do not modify!). + :return: The complex data array. + :rtype: numpy.ndarray of complex with 2 dimensions + """ + return numpy.array(self._data, copy=copy) + + def getDisplayedData(self, copy=True): + """Returns the displayed data depending on the visualization mode + + WARNING: The returned data can be a uint8 RGBA image + + :param bool copy: True (default) to return a copy of the data, + False to return internal data (do not modify!) + :rtype: numpy.ndarray of float with 2 dims or RGBA image (uint8). + """ + return numpy.array(self._displayedData, copy=copy) + + @staticmethod + def getSupportedVisualizationModes(): + """Returns the supported visualization modes. + + Supported visualization modes are: + + - amplitude: The absolute value provided by numpy.absolute + - phase: The phase (or argument) provided by numpy.angle + - real: Real part + - imaginary: Imaginary part + - amplitude_phase: Color-coded phase with amplitude as alpha. + - log10_amplitude_phase: + Color-coded phase with log10(amplitude) as alpha. + + :rtype: tuple of str + """ + return ('absolute', + 'phase', + 'real', + 'imaginary', + 'amplitude_phase', + 'log10_amplitude_phase') + + def setVisualizationMode(self, mode): + """Set the mode of visualization of the complex data. + + See :meth:`getSupportedVisualizationModes` for the list of + supported modes. + + :param str mode: The mode to use. + """ + assert mode in self.getSupportedVisualizationModes() + if mode != self._mode: + self._mode = mode + self._displayedData = self._convertData( + self.getData(copy=False), mode) + self._updatePlot() + self.sigVisualizationModeChanged.emit(mode) + + def getVisualizationMode(self): + """Get the current visualization mode of the complex data. + + :rtype: str + """ + return self._mode + + def _setAmplitudeRangeInfo(self, max_=None, delta=2): + """Set the amplitude range to display for 'log10_amplitude_phase' mode. + + :param max_: Max of the amplitude range. + If None it autoscales to data max. + :param float delta: Delta range in log10 to display + """ + self._amplitudeRangeInfo = max_, float(delta) + mode = self.getVisualizationMode() + if mode == 'log10_amplitude_phase': + self._displayedData = self._convertData( + self.getData(copy=False), mode) + self._updatePlot() + + def _getAmplitudeRangeInfo(self): + """Returns the amplitude range to use for 'log10_amplitude_phase' mode. + + :return: (max, delta), if max is None, then it autoscales to data max + :rtype: 2-tuple""" + return self._amplitudeRangeInfo + + # Image item proxy + + def setColormap(self, colormap): + """Set the colormap to use for amplitude, phase, real or imaginary. + + WARNING: This colormap is not used when displaying both + amplitude and phase. + + :param Colormap colormap: The colormap + """ + self._plotImage.setColormap(colormap) + + def getColormap(self): + """Returns the colormap used to display the data. + + :rtype: Colormap + """ + # Returns internal colormap and bypass forcing colormap + return items.ImageData.getColormap(self._plotImage) + + def getOrigin(self): + """Returns the offset from origin at which to display the image. + + :rtype: 2-tuple of float + """ + return self._plotImage.getOrigin() + + def setOrigin(self, origin): + """Set the offset from origin at which to display the image. + + :param origin: (ox, oy) Offset from origin + :type origin: float or 2-tuple of float + """ + self._plotImage.setOrigin(origin) + + def getScale(self): + """Returns the scale of the image in data coordinates. + + :rtype: 2-tuple of float + """ + return self._plotImage.getScale() + + def setScale(self, scale): + """Set the scale of the image + + :param scale: (sx, sy) Scale of the image + :type scale: float or 2-tuple of float + """ + self._plotImage.setScale(scale) + + # PlotWidget API proxy + + def getXAxis(self): + """Returns the X axis + + :rtype: :class:`.items.Axis` + """ + return self.getPlot().getXAxis() + + def getYAxis(self): + """Returns an Y axis + + :rtype: :class:`.items.Axis` + """ + return self.getPlot().getYAxis(axis='left') + + def getGraphTitle(self): + """Return the plot main title as a str.""" + return self.getPlot().getGraphTitle() + + def setGraphTitle(self, title=""): + """Set the plot main title. + + :param str title: Main title of the plot (default: '') + """ + self.getPlot().setGraphTitle(title) + + def setKeepDataAspectRatio(self, flag): + """Set whether the plot keeps data aspect ratio or not. + + :param bool flag: True to respect data aspect ratio + """ + self.getPlot().setKeepDataAspectRatio(flag) + + def isKeepDataAspectRatio(self): + """Returns whether the plot is keeping data aspect ratio or not.""" + return self.getPlot().isKeepDataAspectRatio() diff --git a/silx/gui/plot/CurvesROIWidget.py b/silx/gui/plot/CurvesROIWidget.py index 13c3de0..4b10cd6 100644 --- a/silx/gui/plot/CurvesROIWidget.py +++ b/silx/gui/plot/CurvesROIWidget.py @@ -46,7 +46,7 @@ ROI are defined by : __authors__ = ["V.A. Sole", "T. Vincent"] __license__ = "MIT" -__date__ = "26/04/2017" +__date__ = "27/06/2017" from collections import OrderedDict @@ -168,9 +168,9 @@ class CurvesROIWidget(qt.QWidget): The dictionary keys are the ROI names. Each value is a sub-dictionary of ROI info with the following fields: - - ``"from"``: x coordinate of the left limit, as a float - - ``"to"``: x coordinate of the right limit, as a float - - ``"type"``: type of ROI, as a string (e.g "channels", "energy") + - ``"from"``: x coordinate of the left limit, as a float + - ``"to"``: x coordinate of the right limit, as a float + - ``"type"``: type of ROI, as a string (e.g "channels", "energy") :param roidict: Dictionary of ROIs @@ -194,9 +194,11 @@ class CurvesROIWidget(qt.QWidget): The dictionary keys are the ROI names. Each value is a sub-dictionary of ROI info with the following fields: - - ``"from"``: x coordinate of the left limit, as a float - - ``"to"``: x coordinate of the right limit, as a float - - ``"type"``: type of ROI, as a string (e.g "channels", "energy") + - ``"from"``: x coordinate of the left limit, as a float + - ``"to"``: x coordinate of the right limit, as a float + - ``"type"``: type of ROI, as a string (e.g "channels", "energy") + + :param order: Field used for ordering the ROIs. One of "from", "to", "type", "netcounts", "rawcounts". None (default) to get the same order as displayed in the widget. @@ -742,7 +744,7 @@ class CurvesROIDockWidget(qt.QDockWidget): """Handle ROI widget signal""" _logger.debug("PlotWindow._roiSignal %s", str(ddict)) if ddict['event'] == "AddROI": - xmin, xmax = self.plot.getGraphXLimits() + xmin, xmax = self.plot.getXAxis().getLimits() fromdata = xmin + 0.25 * (xmax - xmin) todata = xmin + 0.75 * (xmax - xmin) self.plot.remove('ROI min', kind='marker') @@ -786,7 +788,7 @@ class CurvesROIDockWidget(qt.QDockWidget): if newroi == "ICR": roiDict[newroi]['type'] = "Default" else: - roiDict[newroi]['type'] = self.plot.getGraphXLabel() + roiDict[newroi]['type'] = self.plot.getXAxis().getLabel() roiDict[newroi]['from'] = fromdata roiDict[newroi]['to'] = todata self.roiWidget.fillFromROIDict(roilist=roiList, diff --git a/silx/gui/plot/ImageView.py b/silx/gui/plot/ImageView.py index 780215e..803a2fc 100644 --- a/silx/gui/plot/ImageView.py +++ b/silx/gui/plot/ImageView.py @@ -34,12 +34,7 @@ Basic usage of :class:`ImageView` is through the following methods: default colormap to use and update the currently displayed image. - :meth:`ImageView.setImage` to update the displayed image. -The :class:`ImageView` uses :class:`PlotWindow` and also -exposes :class:`silx.gui.plot.Plot` API for further control -(plot title, axes labels, adding other images, ...). - -For an example of use, see the implementation of :class:`ImageViewMainWindow`, -and `example/imageview.py`. +For an example of use, see `imageview.py` in :ref:`sample-code`. """ from __future__ import division @@ -47,7 +42,7 @@ from __future__ import division __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "13/10/2016" +__date__ = "17/08/2017" import logging @@ -55,7 +50,8 @@ import numpy from .. import qt -from . import items, PlotWindow, PlotWidget, PlotActions +from . import items, PlotWindow, PlotWidget, actions +from .Colormap import Colormap from .Colors import cursorColorForColormap from .PlotTools import LimitsToolBar from .Profile import ProfileToolBar @@ -253,9 +249,13 @@ class ImageView(PlotWindow): Use :meth:`setImage` to control the displayed image. This class also provides the :class:`silx.gui.plot.Plot` API. + The :class:`ImageView` inherits from :class:`.PlotWindow` (which provides + the toolbars) and also exposes :class:`.PlotWidget` API for further + plot control (plot title, axes labels, aspect ratio, ...). + :param parent: The parent of this widget or None. :param backend: The backend to use for the plot (default: matplotlib). - See :class:`.Plot` for the list of supported backend. + See :class:`.PlotWidget` for the list of supported backend. :type backend: str or :class:`BackendBase.BackendBase` """ @@ -318,7 +318,7 @@ class ImageView(PlotWindow): self._histoHPlot = PlotWidget(backend=backend) self._histoHPlot.setInteractiveMode('zoom') - self._histoHPlot.setCallback(self._histoHPlotCB) + self._histoHPlot.sigPlotSignal.connect(self._histoHPlotCB) self._histoHPlot.getWidgetHandle().sizeHint = sizeHint self._histoHPlot.getWidgetHandle().minimumSizeHint = sizeHint @@ -326,39 +326,39 @@ class ImageView(PlotWindow): self.setInteractiveMode('zoom') # Color set in setColormap self.sigPlotSignal.connect(self._imagePlotCB) - self.sigSetYAxisInverted.connect(self._updateYAxisInverted) + self.getYAxis().sigInvertedChanged.connect(self._updateYAxisInverted) self.sigActiveImageChanged.connect(self._activeImageChangedSlot) self._histoVPlot = PlotWidget(backend=backend) self._histoVPlot.setInteractiveMode('zoom') - self._histoVPlot.setCallback(self._histoVPlotCB) + self._histoVPlot.sigPlotSignal.connect(self._histoVPlotCB) self._histoVPlot.getWidgetHandle().sizeHint = sizeHint self._histoVPlot.getWidgetHandle().minimumSizeHint = sizeHint self._radarView = RadarView() self._radarView.visibleRectDragged.connect(self._radarViewCB) - self._layout = qt.QGridLayout() - self._layout.addWidget(self.getWidgetHandle(), 0, 0) - self._layout.addWidget(self._histoVPlot.getWidgetHandle(), 0, 1) - self._layout.addWidget(self._histoHPlot.getWidgetHandle(), 1, 0) - self._layout.addWidget(self._radarView, 1, 1) + layout = qt.QGridLayout() + layout.addWidget(self.getWidgetHandle(), 0, 0) + layout.addWidget(self._histoVPlot.getWidgetHandle(), 0, 1) + layout.addWidget(self._histoHPlot.getWidgetHandle(), 1, 0) + layout.addWidget(self._radarView, 1, 1) - self._layout.setColumnMinimumWidth(0, self.IMAGE_MIN_SIZE) - self._layout.setColumnStretch(0, 1) - self._layout.setColumnMinimumWidth(1, self.HISTOGRAMS_HEIGHT) - self._layout.setColumnStretch(1, 0) + layout.setColumnMinimumWidth(0, self.IMAGE_MIN_SIZE) + layout.setColumnStretch(0, 1) + layout.setColumnMinimumWidth(1, self.HISTOGRAMS_HEIGHT) + layout.setColumnStretch(1, 0) - self._layout.setRowMinimumHeight(0, self.IMAGE_MIN_SIZE) - self._layout.setRowStretch(0, 1) - self._layout.setRowMinimumHeight(1, self.HISTOGRAMS_HEIGHT) - self._layout.setRowStretch(1, 0) + layout.setRowMinimumHeight(0, self.IMAGE_MIN_SIZE) + layout.setRowStretch(0, 1) + layout.setRowMinimumHeight(1, self.HISTOGRAMS_HEIGHT) + layout.setRowStretch(1, 0) - self._layout.setSpacing(0) - self._layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) centralWidget = qt.QWidget() - centralWidget.setLayout(self._layout) + centralWidget.setLayout(layout) self.setCentralWidget(centralWidget) def _dirtyCache(self): @@ -376,8 +376,8 @@ class ImageView(PlotWindow): scale = activeImage.getScale() height, width = data.shape - xMin, xMax = self.getGraphXLimits() - yMin, yMax = self.getGraphYLimits() + xMin, xMax = self.getXAxis().getLimits() + yMin, yMax = self.getYAxis().getLimits() # Convert plot area limits to image coordinates # and work in image coordinates (i.e., in pixels) @@ -440,8 +440,8 @@ class ImageView(PlotWindow): vOffset = 0.1 * (vMax - vMin) if vOffset == 0.: vOffset = 1. - self._histoHPlot.setGraphYLimits(vMin - vOffset, - vMax + vOffset) + self._histoHPlot.getYAxis().setLimits(vMin - vOffset, + vMax + vOffset) coords = numpy.arange(2 * histoVVisibleData.size) yCoords = (coords + 1) // 2 + subsetYMin @@ -458,8 +458,8 @@ class ImageView(PlotWindow): vOffset = 0.1 * (vMax - vMin) if vOffset == 0.: vOffset = 1. - self._histoVPlot.setGraphXLimits(vMin - vOffset, - vMax + vOffset) + self._histoVPlot.getXAxis().setLimits(vMin - vOffset, + vMax + vOffset) else: self._dirtyCache() self._histoHPlot.remove(kind='curve') @@ -472,8 +472,8 @@ class ImageView(PlotWindow): Takes care of y coordinate conversion. """ - xMin, xMax = self.getGraphXLimits() - yMin, yMax = self.getGraphYLimits() + xMin, xMax = self.getXAxis().getLimits() + yMin, yMax = self.getYAxis().getLimits() self._radarView.setVisibleRect(xMin, yMin, xMax - xMin, yMax - yMin) # Plots event listeners @@ -499,6 +499,9 @@ class ImageView(PlotWindow): data[y][x]) elif eventDict['event'] == 'limitsChanged': + self._updateHistogramsLimits() + + def _updateHistogramsLimits(self): # Do not handle histograms limitsChanged while # updating their limits from here. self._updatingLimits = True @@ -506,15 +509,14 @@ class ImageView(PlotWindow): # Refresh histograms self._updateHistograms() - # could use eventDict['xdata'], eventDict['ydata'] instead - xMin, xMax = self.getGraphXLimits() - yMin, yMax = self.getGraphYLimits() + xMin, xMax = self.getXAxis().getLimits() + yMin, yMax = self.getYAxis().getLimits() # Set horizontal histo limits - self._histoHPlot.setGraphXLimits(xMin, xMax) + self._histoHPlot.getXAxis().setLimits(xMin, xMax) # Set vertical histo limits - self._histoVPlot.setGraphYLimits(yMin, yMax) + self._histoVPlot.getYAxis().setLimits(yMin, yMax) self._updateRadarView() @@ -542,9 +544,9 @@ class ImageView(PlotWindow): elif eventDict['event'] == 'limitsChanged': if (not self._updatingLimits and - eventDict['xdata'] != self.getGraphXLimits()): + eventDict['xdata'] != self.getXAxis().getLimits()): xMin, xMax = eventDict['xdata'] - self.setGraphXLimits(xMin, xMax) + self.getXAxis().setLimits(xMin, xMax) def _histoVPlotCB(self, eventDict): """Callback for vertical histogram plot events.""" @@ -568,9 +570,9 @@ class ImageView(PlotWindow): elif eventDict['event'] == 'limitsChanged': if (not self._updatingLimits and - eventDict['ydata'] != self.getGraphYLimits()): + eventDict['ydata'] != self.getYAxis().getLimits()): yMin, yMax = eventDict['ydata'] - self.setGraphYLimits(yMin, yMax) + self.getYAxis().setLimits(yMin, yMax) def _radarViewCB(self, left, top, width, height): """Slot for radar view visible rectangle changes.""" @@ -582,9 +584,9 @@ class ImageView(PlotWindow): """Sync image, vertical histogram and radar view axis orientation.""" if inverted is None: # Do not perform this when called from plot signal - inverted = self.isYAxisInverted() + inverted = self.getYAxis().isInverted() - self._histoVPlot.setYAxisInverted(inverted) + self._histoVPlot.getYAxis().setInverted(inverted) # Use scale to invert radarView # RadarView default Y direction is from top to bottom @@ -643,7 +645,7 @@ class ImageView(PlotWindow): self._radarView.visibleRectDragged.disconnect(self._radarViewCB) self._radarView = radarView self._radarView.visibleRectDragged.connect(self._radarViewCB) - self._layout.addWidget(self._radarView, 1, 1) + self.centralWidget().layout().addWidget(self._radarView, 1, 1) self._updateYAxisInverted() @@ -693,42 +695,46 @@ class ImageView(PlotWindow): :param numpy.ndarray colors: Only used if name is None. Custom colormap colors as Nx3 or Nx4 RGB or RGBA arrays """ - cmapDict = self.getDefaultColormap() + cmap = self.getDefaultColormap() + + if isinstance(colormap, Colormap): + # Replace colormap + cmap = colormap + + self.setDefaultColormap(cmap) + + # Update active image colormap + activeImage = self.getActiveImage() + if isinstance(activeImage, items.ColormapMixIn): + activeImage.setColormap(cmap) - if isinstance(colormap, dict): + elif isinstance(colormap, dict): # Support colormap parameter as a dict assert normalization is None assert autoscale is None assert vmin is None assert vmax is None assert colors is None - for key, value in colormap.items(): - cmapDict[key] = value + cmap._setFromDict(colormap) else: if colormap is not None: - cmapDict['name'] = colormap + cmap.setName(colormap) if normalization is not None: - cmapDict['normalization'] = normalization - if autoscale is not None: - cmapDict['autoscale'] = autoscale - if vmin is not None: - cmapDict['vmin'] = vmin - if vmax is not None: - cmapDict['vmax'] = vmax + cmap.setNormalization(normalization) + if autoscale: + cmap.setVRange(None, None) + else: + if vmin is not None: + cmap.setVMin(vmin) + if vmax is not None: + cmap.setVMax(vmax) if colors is not None: - cmapDict['colors'] = colors + cmap.setColormapLUT(colors) - cursorColor = cursorColorForColormap(cmapDict['name']) + cursorColor = cursorColorForColormap(cmap.getName()) self.setInteractiveMode('zoom', color=cursorColor) - self.setDefaultColormap(cmapDict) - - # Update active image colormap - activeImage = self.getActiveImage() - if isinstance(activeImage, items.ColormapMixIn): - activeImage.setColormap(self.getColormap()) - def setImage(self, image, origin=(0, 0), scale=(1., 1.), copy=True, reset=True): """Set the image to display. @@ -768,7 +774,7 @@ class ImageView(PlotWindow): legend=self._imageLegend, origin=origin, scale=scale, colormap=self.getColormap(), - replace=False) + replace=False, resetzoom=False) self.setActiveImage(self._imageLegend) self._updateHistograms() @@ -779,6 +785,8 @@ class ImageView(PlotWindow): if reset: self.resetZoom() + else: + self._updateHistogramsLimits() # ImageViewMainWindow ######################################################### @@ -793,8 +801,8 @@ class ImageViewMainWindow(ImageView): super(ImageViewMainWindow, self).__init__(parent, backend) self.setWindowFlags(qt.Qt.Window) - self.setGraphXLabel('X') - self.setGraphYLabel('Y') + self.getXAxis().setLabel('X') + self.getYAxis().setLabel('Y') self.setGraphTitle('Image') # Add toolbars and status bar @@ -814,11 +822,10 @@ class ImageViewMainWindow(ImageView): menu.addSeparator() menu.addAction(self.resetZoomAction) menu.addAction(self.colormapAction) - menu.addAction(PlotActions.KeepAspectRatioAction(self, self)) - menu.addAction(PlotActions.YAxisInvertedAction(self, self)) + menu.addAction(actions.control.KeepAspectRatioAction(self, self)) + menu.addAction(actions.control.YAxisInvertedAction(self, self)) menu = self.menuBar().addMenu('Profile') - menu.addAction(self.profile.browseAction) menu.addAction(self.profile.hLineAction) menu.addAction(self.profile.vLineAction) menu.addAction(self.profile.lineAction) diff --git a/silx/gui/plot/ItemsSelectionDialog.py b/silx/gui/plot/ItemsSelectionDialog.py new file mode 100644 index 0000000..acb287a --- /dev/null +++ b/silx/gui/plot/ItemsSelectionDialog.py @@ -0,0 +1,282 @@ +# 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 a dialog widget to select plot items. + +.. autoclass:: ItemsSelectionDialog + +""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "28/06/2017" + +import logging + +from silx.gui import qt +from silx.gui.plot.PlotWidget import PlotWidget + +_logger = logging.getLogger(__name__) + + +class KindsSelector(qt.QListWidget): + """List widget allowing to select plot item kinds + ("curve", "scatter", "image"...) + """ + sigSelectedKindsChanged = qt.Signal(list) + + def __init__(self, parent=None, kinds=None): + """ + + :param parent: Parent QWidget or None + :param tuple(str) kinds: Sequence of kinds. If None, the default + behavior is to provide a checkbox for all possible item kinds. + """ + qt.QListWidget.__init__(self, parent) + + self.plot_item_kinds = [] + + self.setAvailableKinds(kinds if kinds is not None else PlotWidget.ITEM_KINDS) + + self.setSelectionMode(qt.QAbstractItemView.ExtendedSelection) + self.selectAll() + + self.itemSelectionChanged.connect(self.emitSigKindsSelectionChanged) + + def emitSigKindsSelectionChanged(self): + self.sigSelectedKindsChanged.emit(self.selectedKinds) + + @property + def selectedKinds(self): + """Tuple of all selected kinds (as strings).""" + # check for updates when self.itemSelectionChanged + return [item.text() for item in self.selectedItems()] + + def setAvailableKinds(self, kinds): + """Set a list of kinds to be displayed. + + :param list[str] kinds: Sequence of kinds + """ + self.plot_item_kinds = kinds + + self.clear() + for kind in self.plot_item_kinds: + item = qt.QListWidgetItem(self) + item.setText(kind) + self.addItem(item) + + def selectAll(self): + """Select all available kinds.""" + if self.selectionMode() in [qt.QAbstractItemView.SingleSelection, + qt.QAbstractItemView.NoSelection]: + raise RuntimeError("selectAll requires a multiple selection mode") + for i in range(self.count()): + self.item(i).setSelected(True) + + +class PlotItemsSelector(qt.QTableWidget): + """Table widget displaying the legend and kind of all + plot items corresponding to a list of specified kinds. + + Selected plot items are provided as property :attr:`selectedPlotItems`. + You can be warned of selection changes by listening to signal + :attr:`itemSelectionChanged`. + """ + def __init__(self, parent=None, plot=None): + if plot is None or not isinstance(plot, PlotWidget): + raise AttributeError("parameter plot is required") + self.plot = plot + """:class:`PlotWidget` instance""" + + self.plot_item_kinds = None + """List of plot item kinds (strings)""" + + qt.QTableWidget.__init__(self, parent) + + self.setColumnCount(2) + + self.setSelectionBehavior(qt.QTableWidget.SelectRows) + + def _clear(self): + self.clear() + self.setHorizontalHeaderLabels(["legend", "type"]) + + def setAllKindsFilter(self): + """Display all kinds of plot items.""" + self.setKindsFilter(PlotWidget.ITEM_KINDS) + + def setKindsFilter(self, kinds): + """Set list of all kinds of plot items to be displayed. + + :param list[str] kinds: Sequence of kinds + """ + if not set(kinds) <= set(PlotWidget.ITEM_KINDS): + raise KeyError("Illegal plot item kinds: %s" % + set(kinds) - set(PlotWidget.ITEM_KINDS)) + self.plot_item_kinds = kinds + + self.updatePlotItems() + + def updatePlotItems(self): + self._clear() + + nrows = len(self.plot._getItems(kind=self.plot_item_kinds, + just_legend=True)) + self.setRowCount(nrows) + + # respect order of kinds as set in method setKindsFilter + i = 0 + for kind in self.plot_item_kinds: + for plot_item in self.plot._getItems(kind=kind): + legend_twitem = qt.QTableWidgetItem(plot_item.getLegend()) + self.setItem(i, 0, legend_twitem) + + kind_twitem = qt.QTableWidgetItem(kind) + self.setItem(i, 1, kind_twitem) + i += 1 + + @property + def selectedPlotItems(self): + """List of all selected items""" + selection_model = self.selectionModel() + selected_rows_idx = selection_model.selectedRows() + selected_rows = [idx.row() for idx in selected_rows_idx] + + items = [] + for row in selected_rows: + legend = self.item(row, 0).text() + kind = self.item(row, 1).text() + items.append(self.plot._getItem(kind, legend)) + + return items + + +class ItemsSelectionDialog(qt.QDialog): + """This widget is a modal dialog allowing to select one or more plot + items, in a table displaying their legend and kind. + + Public methods: + + - :meth:`getSelectedItems` + - :meth:`setAvailableKinds` + - :meth:`setItemsSelectionMode` + + This widget inherits QDialog and therefore implements the usual + dialog methods, e.g. :meth:`exec_`. + + A trivial usage example would be:: + + isd = ItemsSelectionDialog(plot=my_plot_widget) + isd.setItemsSelectionMode(qt.QTableWidget.SingleSelection) + result = isd.exec_() + if result: + for item in isd.getSelectedItems(): + print(item.getLegend(), type(item)) + else: + print("Selection cancelled") + """ + def __init__(self, parent=None, plot=None): + if plot is None or not isinstance(plot, PlotWidget): + raise AttributeError("parameter plot is required") + qt.QDialog.__init__(self, parent) + + self.setWindowTitle("Plot items selector") + + kind_selector_label = qt.QLabel("Filter item kinds:", self) + item_selector_label = qt.QLabel("Select items:", self) + + self.kind_selector = KindsSelector(self) + self.kind_selector.setToolTip( + "select one or more item kinds to show them in the item list") + + self.item_selector = PlotItemsSelector(self, plot) + self.item_selector.setToolTip("select items") + + self.item_selector.setKindsFilter(self.kind_selector.selectedKinds) + self.kind_selector.sigSelectedKindsChanged.connect( + self.item_selector.setKindsFilter + ) + + okb = qt.QPushButton("OK", self) + okb.clicked.connect(self.accept) + + cancelb = qt.QPushButton("Cancel", self) + cancelb.clicked.connect(self.reject) + + layout = qt.QGridLayout(self) + layout.addWidget(kind_selector_label, 0, 0) + layout.addWidget(item_selector_label, 0, 1) + layout.addWidget(self.kind_selector, 1, 0) + layout.addWidget(self.item_selector, 1, 1) + layout.addWidget(okb, 2, 0) + layout.addWidget(cancelb, 2, 1) + + self.setLayout(layout) + + def getSelectedItems(self): + """Return a list of selected plot items + + :return: List of selected plot items + :rtype: list[silx.gui.plot.items.Item]""" + return self.item_selector.selectedPlotItems + + def setAvailableKinds(self, kinds): + """Set a list of kinds to be displayed. + + :param list[str] kinds: Sequence of kinds + """ + self.kind_selector.setAvailableKinds(kinds) + + def selectAllKinds(self): + self.kind_selector.selectAll() + + def setItemsSelectionMode(self, mode): + """Set selection mode for plot item (single item selection, + multiple...). + + :param mode: One of :class:`QTableWidget` selection modes + """ + if mode == self.item_selector.SingleSelection: + self.item_selector.setToolTip( + "Select one item by clicking on it.") + elif mode == self.item_selector.MultiSelection: + self.item_selector.setToolTip( + "Select one or more items by clicking with the left mouse" + " button.\nYou can unselect items by clicking them again.\n" + "Multiple items can be toggled by dragging the mouse over them.") + elif mode == self.item_selector.ExtendedSelection: + self.item_selector.setToolTip( + "Select one or more items. You can select multiple items " + "by keeping the Ctrl key pushed when clicking.\nYou can " + "select a range of items by clicking on the first and " + "last while keeping the Shift key pushed.") + elif mode == self.item_selector.ContiguousSelection: + self.item_selector.setToolTip( + "Select one item by clicking on it. If you press the Shift" + " key while clicking on a second item,\nall items between " + "the two will be selected.") + elif mode == self.item_selector.NoSelection: + raise ValueError("The NoSelection mode is not allowed " + "in this context.") + self.item_selector.setSelectionMode(mode) diff --git a/silx/gui/plot/LegendSelector.py b/silx/gui/plot/LegendSelector.py index 3af9050..31bc3db 100644 --- a/silx/gui/plot/LegendSelector.py +++ b/silx/gui/plot/LegendSelector.py @@ -29,7 +29,7 @@ This widget is meant to work with :class:`PlotWindow`. __authors__ = ["V.A. Sole", "T. Rueter", "T. Vincent"] __license__ = "MIT" -__data__ = "28/04/2016" +__data__ = "08/08/2016" import logging @@ -259,6 +259,7 @@ class LegendModel(qt.QAbstractListModel): legendList = [] self.legendList = [] self.insertLegendList(0, legendList) + self._palette = qt.QPalette() def __getitem__(self, idx): if idx >= len(self.legendList): @@ -282,6 +283,7 @@ class LegendModel(qt.QAbstractListModel): raise IndexError('list index out of range') item = self.legendList[idx] + isActive = item[1].get("active", False) if role == qt.Qt.DisplayRole: # Data to be rendered in the form of text legend = str(item[0]) @@ -295,14 +297,19 @@ class LegendModel(qt.QAbstractListModel): return alignment elif role == qt.Qt.BackgroundRole: # Background color, must be QBrush - if idx % 2: + if isActive: + brush = self._palette.brush(qt.QPalette.Normal, qt.QPalette.Highlight) + elif idx % 2: brush = qt.QBrush(qt.QColor(240, 240, 240)) else: brush = qt.QBrush(qt.Qt.white) return brush elif role == qt.Qt.ForegroundRole: # ForegroundRole color, must be QBrush - brush = qt.QBrush(qt.Qt.blue) + if isActive: + brush = self._palette.brush(qt.QPalette.Normal, qt.QPalette.HighlightedText) + else: + brush = self._palette.brush(qt.QPalette.Normal, qt.QPalette.WindowText) return brush elif role == qt.Qt.CheckStateRole: return bool(item[2]) # item[2] == True @@ -513,6 +520,7 @@ class LegendListItemWidget(qt.QItemDelegate): textAlign = modelIndex.data(qt.Qt.TextAlignmentRole) painter.setBrush(textBrush) painter.setFont(self.legend.font()) + painter.setPen(textBrush.color()) painter.drawText(labelRect, textAlign, legendText) # Draw icon @@ -614,7 +622,7 @@ class LegendListView(qt.QListView): self.setSelectionMode(qt.QAbstractItemView.NoSelection) if model is None: - model = LegendModel() + model = LegendModel(parent=self) self.setModel(model) self.setContextMenu(contextMenu) @@ -1053,15 +1061,18 @@ class LegendsDockWidget(qt.QDockWidget): # Use active color if curve is active if legend == self.plot.getActiveCurve(just_legend=True): color = qt.QColor(self.plot.getActiveCurveColor()) + isActive = True else: color = qt.QColor.fromRgbF(*curve.getColor()) + isActive = False curveInfo = { 'color': color, 'linewidth': curve.getLineWidth(), 'linestyle': curve.getLineStyle(), 'symbol': curve.getSymbol(), - 'selected': not self.plot.isCurveHidden(legend)} + 'selected': not self.plot.isCurveHidden(legend), + 'active': isActive} legendList.append((legend, curveInfo)) self._legendWidget.setLegendList(legendList) diff --git a/silx/gui/plot/LimitsHistory.py b/silx/gui/plot/LimitsHistory.py new file mode 100644 index 0000000..a323548 --- /dev/null +++ b/silx/gui/plot/LimitsHistory.py @@ -0,0 +1,83 @@ +# 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 handling of :class:`PlotWidget` limits history. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "19/07/2017" + + +from .. import qt + + +class LimitsHistory(qt.QObject): + """Class handling history of limits of a :class:`PlotWidget`. + + :param PlotWidget parent: The plot widget this object is bound to. + """ + + def __init__(self, parent): + self._history = [] + super(LimitsHistory, self).__init__(parent) + self.setParent(parent) + + def setParent(self, parent): + """See :meth:`QObject.setParent`. + + :param PlotWidget parent: The PlotWidget this object is bound to. + """ + self.clear() # Clear history when changing parent + super(LimitsHistory, self).setParent(parent) + + def push(self): + """Append current limits to the history.""" + plot = self.parent() + xmin, xmax = plot.getXAxis().getLimits() + ymin, ymax = plot.getYAxis(axis='left').getLimits() + y2min, y2max = plot.getYAxis(axis='right').getLimits() + self._history.append((xmin, xmax, ymin, ymax, y2min, y2max)) + + def pop(self): + """Restore previously limits stored in the history. + + :return: True if limits were restored, False if history was empty. + :rtype: bool + """ + plot = self.parent() + if self._history: + limits = self._history.pop(-1) + plot.setLimits(*limits) + return True + else: + plot.resetZoom() + return False + + def clear(self): + """Clear stored limits states.""" + self._history = [] + + def __len__(self): + return len(self._history) diff --git a/silx/gui/plot/MPLColormap.py b/silx/gui/plot/MPLColormap.py deleted file mode 100644 index 49b11d7..0000000 --- a/silx/gui/plot/MPLColormap.py +++ /dev/null @@ -1,1062 +0,0 @@ -# New matplotlib colormaps by Nathaniel J. Smith, Stefan van der Walt, -# and (in the case of viridis) Eric Firing. -# -# This file and the colormaps in it are released under the CC0 license / -# public domain dedication. We would appreciate credit if you use or -# redistribute these colormaps, but do not impose any legal restrictions. -# -# To the extent possible under law, the persons who associated CC0 with -# mpl-colormaps have waived all copyright and related or neighboring rights -# to mpl-colormaps. -# -# You should have received a copy of the CC0 legalcode along with this -# work. If not, see <http://creativecommons.org/publicdomain/zero/1.0/>. -"""Matplotlib's new colormaps""" - - -from matplotlib.colors import ListedColormap - - -__all__ = ['magma', 'inferno', 'plasma', 'viridis'] - -_magma_data = [[0.001462, 0.000466, 0.013866], - [0.002258, 0.001295, 0.018331], - [0.003279, 0.002305, 0.023708], - [0.004512, 0.003490, 0.029965], - [0.005950, 0.004843, 0.037130], - [0.007588, 0.006356, 0.044973], - [0.009426, 0.008022, 0.052844], - [0.011465, 0.009828, 0.060750], - [0.013708, 0.011771, 0.068667], - [0.016156, 0.013840, 0.076603], - [0.018815, 0.016026, 0.084584], - [0.021692, 0.018320, 0.092610], - [0.024792, 0.020715, 0.100676], - [0.028123, 0.023201, 0.108787], - [0.031696, 0.025765, 0.116965], - [0.035520, 0.028397, 0.125209], - [0.039608, 0.031090, 0.133515], - [0.043830, 0.033830, 0.141886], - [0.048062, 0.036607, 0.150327], - [0.052320, 0.039407, 0.158841], - [0.056615, 0.042160, 0.167446], - [0.060949, 0.044794, 0.176129], - [0.065330, 0.047318, 0.184892], - [0.069764, 0.049726, 0.193735], - [0.074257, 0.052017, 0.202660], - [0.078815, 0.054184, 0.211667], - [0.083446, 0.056225, 0.220755], - [0.088155, 0.058133, 0.229922], - [0.092949, 0.059904, 0.239164], - [0.097833, 0.061531, 0.248477], - [0.102815, 0.063010, 0.257854], - [0.107899, 0.064335, 0.267289], - [0.113094, 0.065492, 0.276784], - [0.118405, 0.066479, 0.286321], - [0.123833, 0.067295, 0.295879], - [0.129380, 0.067935, 0.305443], - [0.135053, 0.068391, 0.315000], - [0.140858, 0.068654, 0.324538], - [0.146785, 0.068738, 0.334011], - [0.152839, 0.068637, 0.343404], - [0.159018, 0.068354, 0.352688], - [0.165308, 0.067911, 0.361816], - [0.171713, 0.067305, 0.370771], - [0.178212, 0.066576, 0.379497], - [0.184801, 0.065732, 0.387973], - [0.191460, 0.064818, 0.396152], - [0.198177, 0.063862, 0.404009], - [0.204935, 0.062907, 0.411514], - [0.211718, 0.061992, 0.418647], - [0.218512, 0.061158, 0.425392], - [0.225302, 0.060445, 0.431742], - [0.232077, 0.059889, 0.437695], - [0.238826, 0.059517, 0.443256], - [0.245543, 0.059352, 0.448436], - [0.252220, 0.059415, 0.453248], - [0.258857, 0.059706, 0.457710], - [0.265447, 0.060237, 0.461840], - [0.271994, 0.060994, 0.465660], - [0.278493, 0.061978, 0.469190], - [0.284951, 0.063168, 0.472451], - [0.291366, 0.064553, 0.475462], - [0.297740, 0.066117, 0.478243], - [0.304081, 0.067835, 0.480812], - [0.310382, 0.069702, 0.483186], - [0.316654, 0.071690, 0.485380], - [0.322899, 0.073782, 0.487408], - [0.329114, 0.075972, 0.489287], - [0.335308, 0.078236, 0.491024], - [0.341482, 0.080564, 0.492631], - [0.347636, 0.082946, 0.494121], - [0.353773, 0.085373, 0.495501], - [0.359898, 0.087831, 0.496778], - [0.366012, 0.090314, 0.497960], - [0.372116, 0.092816, 0.499053], - [0.378211, 0.095332, 0.500067], - [0.384299, 0.097855, 0.501002], - [0.390384, 0.100379, 0.501864], - [0.396467, 0.102902, 0.502658], - [0.402548, 0.105420, 0.503386], - [0.408629, 0.107930, 0.504052], - [0.414709, 0.110431, 0.504662], - [0.420791, 0.112920, 0.505215], - [0.426877, 0.115395, 0.505714], - [0.432967, 0.117855, 0.506160], - [0.439062, 0.120298, 0.506555], - [0.445163, 0.122724, 0.506901], - [0.451271, 0.125132, 0.507198], - [0.457386, 0.127522, 0.507448], - [0.463508, 0.129893, 0.507652], - [0.469640, 0.132245, 0.507809], - [0.475780, 0.134577, 0.507921], - [0.481929, 0.136891, 0.507989], - [0.488088, 0.139186, 0.508011], - [0.494258, 0.141462, 0.507988], - [0.500438, 0.143719, 0.507920], - [0.506629, 0.145958, 0.507806], - [0.512831, 0.148179, 0.507648], - [0.519045, 0.150383, 0.507443], - [0.525270, 0.152569, 0.507192], - [0.531507, 0.154739, 0.506895], - [0.537755, 0.156894, 0.506551], - [0.544015, 0.159033, 0.506159], - [0.550287, 0.161158, 0.505719], - [0.556571, 0.163269, 0.505230], - [0.562866, 0.165368, 0.504692], - [0.569172, 0.167454, 0.504105], - [0.575490, 0.169530, 0.503466], - [0.581819, 0.171596, 0.502777], - [0.588158, 0.173652, 0.502035], - [0.594508, 0.175701, 0.501241], - [0.600868, 0.177743, 0.500394], - [0.607238, 0.179779, 0.499492], - [0.613617, 0.181811, 0.498536], - [0.620005, 0.183840, 0.497524], - [0.626401, 0.185867, 0.496456], - [0.632805, 0.187893, 0.495332], - [0.639216, 0.189921, 0.494150], - [0.645633, 0.191952, 0.492910], - [0.652056, 0.193986, 0.491611], - [0.658483, 0.196027, 0.490253], - [0.664915, 0.198075, 0.488836], - [0.671349, 0.200133, 0.487358], - [0.677786, 0.202203, 0.485819], - [0.684224, 0.204286, 0.484219], - [0.690661, 0.206384, 0.482558], - [0.697098, 0.208501, 0.480835], - [0.703532, 0.210638, 0.479049], - [0.709962, 0.212797, 0.477201], - [0.716387, 0.214982, 0.475290], - [0.722805, 0.217194, 0.473316], - [0.729216, 0.219437, 0.471279], - [0.735616, 0.221713, 0.469180], - [0.742004, 0.224025, 0.467018], - [0.748378, 0.226377, 0.464794], - [0.754737, 0.228772, 0.462509], - [0.761077, 0.231214, 0.460162], - [0.767398, 0.233705, 0.457755], - [0.773695, 0.236249, 0.455289], - [0.779968, 0.238851, 0.452765], - [0.786212, 0.241514, 0.450184], - [0.792427, 0.244242, 0.447543], - [0.798608, 0.247040, 0.444848], - [0.804752, 0.249911, 0.442102], - [0.810855, 0.252861, 0.439305], - [0.816914, 0.255895, 0.436461], - [0.822926, 0.259016, 0.433573], - [0.828886, 0.262229, 0.430644], - [0.834791, 0.265540, 0.427671], - [0.840636, 0.268953, 0.424666], - [0.846416, 0.272473, 0.421631], - [0.852126, 0.276106, 0.418573], - [0.857763, 0.279857, 0.415496], - [0.863320, 0.283729, 0.412403], - [0.868793, 0.287728, 0.409303], - [0.874176, 0.291859, 0.406205], - [0.879464, 0.296125, 0.403118], - [0.884651, 0.300530, 0.400047], - [0.889731, 0.305079, 0.397002], - [0.894700, 0.309773, 0.393995], - [0.899552, 0.314616, 0.391037], - [0.904281, 0.319610, 0.388137], - [0.908884, 0.324755, 0.385308], - [0.913354, 0.330052, 0.382563], - [0.917689, 0.335500, 0.379915], - [0.921884, 0.341098, 0.377376], - [0.925937, 0.346844, 0.374959], - [0.929845, 0.352734, 0.372677], - [0.933606, 0.358764, 0.370541], - [0.937221, 0.364929, 0.368567], - [0.940687, 0.371224, 0.366762], - [0.944006, 0.377643, 0.365136], - [0.947180, 0.384178, 0.363701], - [0.950210, 0.390820, 0.362468], - [0.953099, 0.397563, 0.361438], - [0.955849, 0.404400, 0.360619], - [0.958464, 0.411324, 0.360014], - [0.960949, 0.418323, 0.359630], - [0.963310, 0.425390, 0.359469], - [0.965549, 0.432519, 0.359529], - [0.967671, 0.439703, 0.359810], - [0.969680, 0.446936, 0.360311], - [0.971582, 0.454210, 0.361030], - [0.973381, 0.461520, 0.361965], - [0.975082, 0.468861, 0.363111], - [0.976690, 0.476226, 0.364466], - [0.978210, 0.483612, 0.366025], - [0.979645, 0.491014, 0.367783], - [0.981000, 0.498428, 0.369734], - [0.982279, 0.505851, 0.371874], - [0.983485, 0.513280, 0.374198], - [0.984622, 0.520713, 0.376698], - [0.985693, 0.528148, 0.379371], - [0.986700, 0.535582, 0.382210], - [0.987646, 0.543015, 0.385210], - [0.988533, 0.550446, 0.388365], - [0.989363, 0.557873, 0.391671], - [0.990138, 0.565296, 0.395122], - [0.990871, 0.572706, 0.398714], - [0.991558, 0.580107, 0.402441], - [0.992196, 0.587502, 0.406299], - [0.992785, 0.594891, 0.410283], - [0.993326, 0.602275, 0.414390], - [0.993834, 0.609644, 0.418613], - [0.994309, 0.616999, 0.422950], - [0.994738, 0.624350, 0.427397], - [0.995122, 0.631696, 0.431951], - [0.995480, 0.639027, 0.436607], - [0.995810, 0.646344, 0.441361], - [0.996096, 0.653659, 0.446213], - [0.996341, 0.660969, 0.451160], - [0.996580, 0.668256, 0.456192], - [0.996775, 0.675541, 0.461314], - [0.996925, 0.682828, 0.466526], - [0.997077, 0.690088, 0.471811], - [0.997186, 0.697349, 0.477182], - [0.997254, 0.704611, 0.482635], - [0.997325, 0.711848, 0.488154], - [0.997351, 0.719089, 0.493755], - [0.997351, 0.726324, 0.499428], - [0.997341, 0.733545, 0.505167], - [0.997285, 0.740772, 0.510983], - [0.997228, 0.747981, 0.516859], - [0.997138, 0.755190, 0.522806], - [0.997019, 0.762398, 0.528821], - [0.996898, 0.769591, 0.534892], - [0.996727, 0.776795, 0.541039], - [0.996571, 0.783977, 0.547233], - [0.996369, 0.791167, 0.553499], - [0.996162, 0.798348, 0.559820], - [0.995932, 0.805527, 0.566202], - [0.995680, 0.812706, 0.572645], - [0.995424, 0.819875, 0.579140], - [0.995131, 0.827052, 0.585701], - [0.994851, 0.834213, 0.592307], - [0.994524, 0.841387, 0.598983], - [0.994222, 0.848540, 0.605696], - [0.993866, 0.855711, 0.612482], - [0.993545, 0.862859, 0.619299], - [0.993170, 0.870024, 0.626189], - [0.992831, 0.877168, 0.633109], - [0.992440, 0.884330, 0.640099], - [0.992089, 0.891470, 0.647116], - [0.991688, 0.898627, 0.654202], - [0.991332, 0.905763, 0.661309], - [0.990930, 0.912915, 0.668481], - [0.990570, 0.920049, 0.675675], - [0.990175, 0.927196, 0.682926], - [0.989815, 0.934329, 0.690198], - [0.989434, 0.941470, 0.697519], - [0.989077, 0.948604, 0.704863], - [0.988717, 0.955742, 0.712242], - [0.988367, 0.962878, 0.719649], - [0.988033, 0.970012, 0.727077], - [0.987691, 0.977154, 0.734536], - [0.987387, 0.984288, 0.742002], - [0.987053, 0.991438, 0.749504]] - -_inferno_data = [[0.001462, 0.000466, 0.013866], - [0.002267, 0.001270, 0.018570], - [0.003299, 0.002249, 0.024239], - [0.004547, 0.003392, 0.030909], - [0.006006, 0.004692, 0.038558], - [0.007676, 0.006136, 0.046836], - [0.009561, 0.007713, 0.055143], - [0.011663, 0.009417, 0.063460], - [0.013995, 0.011225, 0.071862], - [0.016561, 0.013136, 0.080282], - [0.019373, 0.015133, 0.088767], - [0.022447, 0.017199, 0.097327], - [0.025793, 0.019331, 0.105930], - [0.029432, 0.021503, 0.114621], - [0.033385, 0.023702, 0.123397], - [0.037668, 0.025921, 0.132232], - [0.042253, 0.028139, 0.141141], - [0.046915, 0.030324, 0.150164], - [0.051644, 0.032474, 0.159254], - [0.056449, 0.034569, 0.168414], - [0.061340, 0.036590, 0.177642], - [0.066331, 0.038504, 0.186962], - [0.071429, 0.040294, 0.196354], - [0.076637, 0.041905, 0.205799], - [0.081962, 0.043328, 0.215289], - [0.087411, 0.044556, 0.224813], - [0.092990, 0.045583, 0.234358], - [0.098702, 0.046402, 0.243904], - [0.104551, 0.047008, 0.253430], - [0.110536, 0.047399, 0.262912], - [0.116656, 0.047574, 0.272321], - [0.122908, 0.047536, 0.281624], - [0.129285, 0.047293, 0.290788], - [0.135778, 0.046856, 0.299776], - [0.142378, 0.046242, 0.308553], - [0.149073, 0.045468, 0.317085], - [0.155850, 0.044559, 0.325338], - [0.162689, 0.043554, 0.333277], - [0.169575, 0.042489, 0.340874], - [0.176493, 0.041402, 0.348111], - [0.183429, 0.040329, 0.354971], - [0.190367, 0.039309, 0.361447], - [0.197297, 0.038400, 0.367535], - [0.204209, 0.037632, 0.373238], - [0.211095, 0.037030, 0.378563], - [0.217949, 0.036615, 0.383522], - [0.224763, 0.036405, 0.388129], - [0.231538, 0.036405, 0.392400], - [0.238273, 0.036621, 0.396353], - [0.244967, 0.037055, 0.400007], - [0.251620, 0.037705, 0.403378], - [0.258234, 0.038571, 0.406485], - [0.264810, 0.039647, 0.409345], - [0.271347, 0.040922, 0.411976], - [0.277850, 0.042353, 0.414392], - [0.284321, 0.043933, 0.416608], - [0.290763, 0.045644, 0.418637], - [0.297178, 0.047470, 0.420491], - [0.303568, 0.049396, 0.422182], - [0.309935, 0.051407, 0.423721], - [0.316282, 0.053490, 0.425116], - [0.322610, 0.055634, 0.426377], - [0.328921, 0.057827, 0.427511], - [0.335217, 0.060060, 0.428524], - [0.341500, 0.062325, 0.429425], - [0.347771, 0.064616, 0.430217], - [0.354032, 0.066925, 0.430906], - [0.360284, 0.069247, 0.431497], - [0.366529, 0.071579, 0.431994], - [0.372768, 0.073915, 0.432400], - [0.379001, 0.076253, 0.432719], - [0.385228, 0.078591, 0.432955], - [0.391453, 0.080927, 0.433109], - [0.397674, 0.083257, 0.433183], - [0.403894, 0.085580, 0.433179], - [0.410113, 0.087896, 0.433098], - [0.416331, 0.090203, 0.432943], - [0.422549, 0.092501, 0.432714], - [0.428768, 0.094790, 0.432412], - [0.434987, 0.097069, 0.432039], - [0.441207, 0.099338, 0.431594], - [0.447428, 0.101597, 0.431080], - [0.453651, 0.103848, 0.430498], - [0.459875, 0.106089, 0.429846], - [0.466100, 0.108322, 0.429125], - [0.472328, 0.110547, 0.428334], - [0.478558, 0.112764, 0.427475], - [0.484789, 0.114974, 0.426548], - [0.491022, 0.117179, 0.425552], - [0.497257, 0.119379, 0.424488], - [0.503493, 0.121575, 0.423356], - [0.509730, 0.123769, 0.422156], - [0.515967, 0.125960, 0.420887], - [0.522206, 0.128150, 0.419549], - [0.528444, 0.130341, 0.418142], - [0.534683, 0.132534, 0.416667], - [0.540920, 0.134729, 0.415123], - [0.547157, 0.136929, 0.413511], - [0.553392, 0.139134, 0.411829], - [0.559624, 0.141346, 0.410078], - [0.565854, 0.143567, 0.408258], - [0.572081, 0.145797, 0.406369], - [0.578304, 0.148039, 0.404411], - [0.584521, 0.150294, 0.402385], - [0.590734, 0.152563, 0.400290], - [0.596940, 0.154848, 0.398125], - [0.603139, 0.157151, 0.395891], - [0.609330, 0.159474, 0.393589], - [0.615513, 0.161817, 0.391219], - [0.621685, 0.164184, 0.388781], - [0.627847, 0.166575, 0.386276], - [0.633998, 0.168992, 0.383704], - [0.640135, 0.171438, 0.381065], - [0.646260, 0.173914, 0.378359], - [0.652369, 0.176421, 0.375586], - [0.658463, 0.178962, 0.372748], - [0.664540, 0.181539, 0.369846], - [0.670599, 0.184153, 0.366879], - [0.676638, 0.186807, 0.363849], - [0.682656, 0.189501, 0.360757], - [0.688653, 0.192239, 0.357603], - [0.694627, 0.195021, 0.354388], - [0.700576, 0.197851, 0.351113], - [0.706500, 0.200728, 0.347777], - [0.712396, 0.203656, 0.344383], - [0.718264, 0.206636, 0.340931], - [0.724103, 0.209670, 0.337424], - [0.729909, 0.212759, 0.333861], - [0.735683, 0.215906, 0.330245], - [0.741423, 0.219112, 0.326576], - [0.747127, 0.222378, 0.322856], - [0.752794, 0.225706, 0.319085], - [0.758422, 0.229097, 0.315266], - [0.764010, 0.232554, 0.311399], - [0.769556, 0.236077, 0.307485], - [0.775059, 0.239667, 0.303526], - [0.780517, 0.243327, 0.299523], - [0.785929, 0.247056, 0.295477], - [0.791293, 0.250856, 0.291390], - [0.796607, 0.254728, 0.287264], - [0.801871, 0.258674, 0.283099], - [0.807082, 0.262692, 0.278898], - [0.812239, 0.266786, 0.274661], - [0.817341, 0.270954, 0.270390], - [0.822386, 0.275197, 0.266085], - [0.827372, 0.279517, 0.261750], - [0.832299, 0.283913, 0.257383], - [0.837165, 0.288385, 0.252988], - [0.841969, 0.292933, 0.248564], - [0.846709, 0.297559, 0.244113], - [0.851384, 0.302260, 0.239636], - [0.855992, 0.307038, 0.235133], - [0.860533, 0.311892, 0.230606], - [0.865006, 0.316822, 0.226055], - [0.869409, 0.321827, 0.221482], - [0.873741, 0.326906, 0.216886], - [0.878001, 0.332060, 0.212268], - [0.882188, 0.337287, 0.207628], - [0.886302, 0.342586, 0.202968], - [0.890341, 0.347957, 0.198286], - [0.894305, 0.353399, 0.193584], - [0.898192, 0.358911, 0.188860], - [0.902003, 0.364492, 0.184116], - [0.905735, 0.370140, 0.179350], - [0.909390, 0.375856, 0.174563], - [0.912966, 0.381636, 0.169755], - [0.916462, 0.387481, 0.164924], - [0.919879, 0.393389, 0.160070], - [0.923215, 0.399359, 0.155193], - [0.926470, 0.405389, 0.150292], - [0.929644, 0.411479, 0.145367], - [0.932737, 0.417627, 0.140417], - [0.935747, 0.423831, 0.135440], - [0.938675, 0.430091, 0.130438], - [0.941521, 0.436405, 0.125409], - [0.944285, 0.442772, 0.120354], - [0.946965, 0.449191, 0.115272], - [0.949562, 0.455660, 0.110164], - [0.952075, 0.462178, 0.105031], - [0.954506, 0.468744, 0.099874], - [0.956852, 0.475356, 0.094695], - [0.959114, 0.482014, 0.089499], - [0.961293, 0.488716, 0.084289], - [0.963387, 0.495462, 0.079073], - [0.965397, 0.502249, 0.073859], - [0.967322, 0.509078, 0.068659], - [0.969163, 0.515946, 0.063488], - [0.970919, 0.522853, 0.058367], - [0.972590, 0.529798, 0.053324], - [0.974176, 0.536780, 0.048392], - [0.975677, 0.543798, 0.043618], - [0.977092, 0.550850, 0.039050], - [0.978422, 0.557937, 0.034931], - [0.979666, 0.565057, 0.031409], - [0.980824, 0.572209, 0.028508], - [0.981895, 0.579392, 0.026250], - [0.982881, 0.586606, 0.024661], - [0.983779, 0.593849, 0.023770], - [0.984591, 0.601122, 0.023606], - [0.985315, 0.608422, 0.024202], - [0.985952, 0.615750, 0.025592], - [0.986502, 0.623105, 0.027814], - [0.986964, 0.630485, 0.030908], - [0.987337, 0.637890, 0.034916], - [0.987622, 0.645320, 0.039886], - [0.987819, 0.652773, 0.045581], - [0.987926, 0.660250, 0.051750], - [0.987945, 0.667748, 0.058329], - [0.987874, 0.675267, 0.065257], - [0.987714, 0.682807, 0.072489], - [0.987464, 0.690366, 0.079990], - [0.987124, 0.697944, 0.087731], - [0.986694, 0.705540, 0.095694], - [0.986175, 0.713153, 0.103863], - [0.985566, 0.720782, 0.112229], - [0.984865, 0.728427, 0.120785], - [0.984075, 0.736087, 0.129527], - [0.983196, 0.743758, 0.138453], - [0.982228, 0.751442, 0.147565], - [0.981173, 0.759135, 0.156863], - [0.980032, 0.766837, 0.166353], - [0.978806, 0.774545, 0.176037], - [0.977497, 0.782258, 0.185923], - [0.976108, 0.789974, 0.196018], - [0.974638, 0.797692, 0.206332], - [0.973088, 0.805409, 0.216877], - [0.971468, 0.813122, 0.227658], - [0.969783, 0.820825, 0.238686], - [0.968041, 0.828515, 0.249972], - [0.966243, 0.836191, 0.261534], - [0.964394, 0.843848, 0.273391], - [0.962517, 0.851476, 0.285546], - [0.960626, 0.859069, 0.298010], - [0.958720, 0.866624, 0.310820], - [0.956834, 0.874129, 0.323974], - [0.954997, 0.881569, 0.337475], - [0.953215, 0.888942, 0.351369], - [0.951546, 0.896226, 0.365627], - [0.950018, 0.903409, 0.380271], - [0.948683, 0.910473, 0.395289], - [0.947594, 0.917399, 0.410665], - [0.946809, 0.924168, 0.426373], - [0.946392, 0.930761, 0.442367], - [0.946403, 0.937159, 0.458592], - [0.946903, 0.943348, 0.474970], - [0.947937, 0.949318, 0.491426], - [0.949545, 0.955063, 0.507860], - [0.951740, 0.960587, 0.524203], - [0.954529, 0.965896, 0.540361], - [0.957896, 0.971003, 0.556275], - [0.961812, 0.975924, 0.571925], - [0.966249, 0.980678, 0.587206], - [0.971162, 0.985282, 0.602154], - [0.976511, 0.989753, 0.616760], - [0.982257, 0.994109, 0.631017], - [0.988362, 0.998364, 0.644924]] - -_plasma_data = [[0.050383, 0.029803, 0.527975], - [0.063536, 0.028426, 0.533124], - [0.075353, 0.027206, 0.538007], - [0.086222, 0.026125, 0.542658], - [0.096379, 0.025165, 0.547103], - [0.105980, 0.024309, 0.551368], - [0.115124, 0.023556, 0.555468], - [0.123903, 0.022878, 0.559423], - [0.132381, 0.022258, 0.563250], - [0.140603, 0.021687, 0.566959], - [0.148607, 0.021154, 0.570562], - [0.156421, 0.020651, 0.574065], - [0.164070, 0.020171, 0.577478], - [0.171574, 0.019706, 0.580806], - [0.178950, 0.019252, 0.584054], - [0.186213, 0.018803, 0.587228], - [0.193374, 0.018354, 0.590330], - [0.200445, 0.017902, 0.593364], - [0.207435, 0.017442, 0.596333], - [0.214350, 0.016973, 0.599239], - [0.221197, 0.016497, 0.602083], - [0.227983, 0.016007, 0.604867], - [0.234715, 0.015502, 0.607592], - [0.241396, 0.014979, 0.610259], - [0.248032, 0.014439, 0.612868], - [0.254627, 0.013882, 0.615419], - [0.261183, 0.013308, 0.617911], - [0.267703, 0.012716, 0.620346], - [0.274191, 0.012109, 0.622722], - [0.280648, 0.011488, 0.625038], - [0.287076, 0.010855, 0.627295], - [0.293478, 0.010213, 0.629490], - [0.299855, 0.009561, 0.631624], - [0.306210, 0.008902, 0.633694], - [0.312543, 0.008239, 0.635700], - [0.318856, 0.007576, 0.637640], - [0.325150, 0.006915, 0.639512], - [0.331426, 0.006261, 0.641316], - [0.337683, 0.005618, 0.643049], - [0.343925, 0.004991, 0.644710], - [0.350150, 0.004382, 0.646298], - [0.356359, 0.003798, 0.647810], - [0.362553, 0.003243, 0.649245], - [0.368733, 0.002724, 0.650601], - [0.374897, 0.002245, 0.651876], - [0.381047, 0.001814, 0.653068], - [0.387183, 0.001434, 0.654177], - [0.393304, 0.001114, 0.655199], - [0.399411, 0.000859, 0.656133], - [0.405503, 0.000678, 0.656977], - [0.411580, 0.000577, 0.657730], - [0.417642, 0.000564, 0.658390], - [0.423689, 0.000646, 0.658956], - [0.429719, 0.000831, 0.659425], - [0.435734, 0.001127, 0.659797], - [0.441732, 0.001540, 0.660069], - [0.447714, 0.002080, 0.660240], - [0.453677, 0.002755, 0.660310], - [0.459623, 0.003574, 0.660277], - [0.465550, 0.004545, 0.660139], - [0.471457, 0.005678, 0.659897], - [0.477344, 0.006980, 0.659549], - [0.483210, 0.008460, 0.659095], - [0.489055, 0.010127, 0.658534], - [0.494877, 0.011990, 0.657865], - [0.500678, 0.014055, 0.657088], - [0.506454, 0.016333, 0.656202], - [0.512206, 0.018833, 0.655209], - [0.517933, 0.021563, 0.654109], - [0.523633, 0.024532, 0.652901], - [0.529306, 0.027747, 0.651586], - [0.534952, 0.031217, 0.650165], - [0.540570, 0.034950, 0.648640], - [0.546157, 0.038954, 0.647010], - [0.551715, 0.043136, 0.645277], - [0.557243, 0.047331, 0.643443], - [0.562738, 0.051545, 0.641509], - [0.568201, 0.055778, 0.639477], - [0.573632, 0.060028, 0.637349], - [0.579029, 0.064296, 0.635126], - [0.584391, 0.068579, 0.632812], - [0.589719, 0.072878, 0.630408], - [0.595011, 0.077190, 0.627917], - [0.600266, 0.081516, 0.625342], - [0.605485, 0.085854, 0.622686], - [0.610667, 0.090204, 0.619951], - [0.615812, 0.094564, 0.617140], - [0.620919, 0.098934, 0.614257], - [0.625987, 0.103312, 0.611305], - [0.631017, 0.107699, 0.608287], - [0.636008, 0.112092, 0.605205], - [0.640959, 0.116492, 0.602065], - [0.645872, 0.120898, 0.598867], - [0.650746, 0.125309, 0.595617], - [0.655580, 0.129725, 0.592317], - [0.660374, 0.134144, 0.588971], - [0.665129, 0.138566, 0.585582], - [0.669845, 0.142992, 0.582154], - [0.674522, 0.147419, 0.578688], - [0.679160, 0.151848, 0.575189], - [0.683758, 0.156278, 0.571660], - [0.688318, 0.160709, 0.568103], - [0.692840, 0.165141, 0.564522], - [0.697324, 0.169573, 0.560919], - [0.701769, 0.174005, 0.557296], - [0.706178, 0.178437, 0.553657], - [0.710549, 0.182868, 0.550004], - [0.714883, 0.187299, 0.546338], - [0.719181, 0.191729, 0.542663], - [0.723444, 0.196158, 0.538981], - [0.727670, 0.200586, 0.535293], - [0.731862, 0.205013, 0.531601], - [0.736019, 0.209439, 0.527908], - [0.740143, 0.213864, 0.524216], - [0.744232, 0.218288, 0.520524], - [0.748289, 0.222711, 0.516834], - [0.752312, 0.227133, 0.513149], - [0.756304, 0.231555, 0.509468], - [0.760264, 0.235976, 0.505794], - [0.764193, 0.240396, 0.502126], - [0.768090, 0.244817, 0.498465], - [0.771958, 0.249237, 0.494813], - [0.775796, 0.253658, 0.491171], - [0.779604, 0.258078, 0.487539], - [0.783383, 0.262500, 0.483918], - [0.787133, 0.266922, 0.480307], - [0.790855, 0.271345, 0.476706], - [0.794549, 0.275770, 0.473117], - [0.798216, 0.280197, 0.469538], - [0.801855, 0.284626, 0.465971], - [0.805467, 0.289057, 0.462415], - [0.809052, 0.293491, 0.458870], - [0.812612, 0.297928, 0.455338], - [0.816144, 0.302368, 0.451816], - [0.819651, 0.306812, 0.448306], - [0.823132, 0.311261, 0.444806], - [0.826588, 0.315714, 0.441316], - [0.830018, 0.320172, 0.437836], - [0.833422, 0.324635, 0.434366], - [0.836801, 0.329105, 0.430905], - [0.840155, 0.333580, 0.427455], - [0.843484, 0.338062, 0.424013], - [0.846788, 0.342551, 0.420579], - [0.850066, 0.347048, 0.417153], - [0.853319, 0.351553, 0.413734], - [0.856547, 0.356066, 0.410322], - [0.859750, 0.360588, 0.406917], - [0.862927, 0.365119, 0.403519], - [0.866078, 0.369660, 0.400126], - [0.869203, 0.374212, 0.396738], - [0.872303, 0.378774, 0.393355], - [0.875376, 0.383347, 0.389976], - [0.878423, 0.387932, 0.386600], - [0.881443, 0.392529, 0.383229], - [0.884436, 0.397139, 0.379860], - [0.887402, 0.401762, 0.376494], - [0.890340, 0.406398, 0.373130], - [0.893250, 0.411048, 0.369768], - [0.896131, 0.415712, 0.366407], - [0.898984, 0.420392, 0.363047], - [0.901807, 0.425087, 0.359688], - [0.904601, 0.429797, 0.356329], - [0.907365, 0.434524, 0.352970], - [0.910098, 0.439268, 0.349610], - [0.912800, 0.444029, 0.346251], - [0.915471, 0.448807, 0.342890], - [0.918109, 0.453603, 0.339529], - [0.920714, 0.458417, 0.336166], - [0.923287, 0.463251, 0.332801], - [0.925825, 0.468103, 0.329435], - [0.928329, 0.472975, 0.326067], - [0.930798, 0.477867, 0.322697], - [0.933232, 0.482780, 0.319325], - [0.935630, 0.487712, 0.315952], - [0.937990, 0.492667, 0.312575], - [0.940313, 0.497642, 0.309197], - [0.942598, 0.502639, 0.305816], - [0.944844, 0.507658, 0.302433], - [0.947051, 0.512699, 0.299049], - [0.949217, 0.517763, 0.295662], - [0.951344, 0.522850, 0.292275], - [0.953428, 0.527960, 0.288883], - [0.955470, 0.533093, 0.285490], - [0.957469, 0.538250, 0.282096], - [0.959424, 0.543431, 0.278701], - [0.961336, 0.548636, 0.275305], - [0.963203, 0.553865, 0.271909], - [0.965024, 0.559118, 0.268513], - [0.966798, 0.564396, 0.265118], - [0.968526, 0.569700, 0.261721], - [0.970205, 0.575028, 0.258325], - [0.971835, 0.580382, 0.254931], - [0.973416, 0.585761, 0.251540], - [0.974947, 0.591165, 0.248151], - [0.976428, 0.596595, 0.244767], - [0.977856, 0.602051, 0.241387], - [0.979233, 0.607532, 0.238013], - [0.980556, 0.613039, 0.234646], - [0.981826, 0.618572, 0.231287], - [0.983041, 0.624131, 0.227937], - [0.984199, 0.629718, 0.224595], - [0.985301, 0.635330, 0.221265], - [0.986345, 0.640969, 0.217948], - [0.987332, 0.646633, 0.214648], - [0.988260, 0.652325, 0.211364], - [0.989128, 0.658043, 0.208100], - [0.989935, 0.663787, 0.204859], - [0.990681, 0.669558, 0.201642], - [0.991365, 0.675355, 0.198453], - [0.991985, 0.681179, 0.195295], - [0.992541, 0.687030, 0.192170], - [0.993032, 0.692907, 0.189084], - [0.993456, 0.698810, 0.186041], - [0.993814, 0.704741, 0.183043], - [0.994103, 0.710698, 0.180097], - [0.994324, 0.716681, 0.177208], - [0.994474, 0.722691, 0.174381], - [0.994553, 0.728728, 0.171622], - [0.994561, 0.734791, 0.168938], - [0.994495, 0.740880, 0.166335], - [0.994355, 0.746995, 0.163821], - [0.994141, 0.753137, 0.161404], - [0.993851, 0.759304, 0.159092], - [0.993482, 0.765499, 0.156891], - [0.993033, 0.771720, 0.154808], - [0.992505, 0.777967, 0.152855], - [0.991897, 0.784239, 0.151042], - [0.991209, 0.790537, 0.149377], - [0.990439, 0.796859, 0.147870], - [0.989587, 0.803205, 0.146529], - [0.988648, 0.809579, 0.145357], - [0.987621, 0.815978, 0.144363], - [0.986509, 0.822401, 0.143557], - [0.985314, 0.828846, 0.142945], - [0.984031, 0.835315, 0.142528], - [0.982653, 0.841812, 0.142303], - [0.981190, 0.848329, 0.142279], - [0.979644, 0.854866, 0.142453], - [0.977995, 0.861432, 0.142808], - [0.976265, 0.868016, 0.143351], - [0.974443, 0.874622, 0.144061], - [0.972530, 0.881250, 0.144923], - [0.970533, 0.887896, 0.145919], - [0.968443, 0.894564, 0.147014], - [0.966271, 0.901249, 0.148180], - [0.964021, 0.907950, 0.149370], - [0.961681, 0.914672, 0.150520], - [0.959276, 0.921407, 0.151566], - [0.956808, 0.928152, 0.152409], - [0.954287, 0.934908, 0.152921], - [0.951726, 0.941671, 0.152925], - [0.949151, 0.948435, 0.152178], - [0.946602, 0.955190, 0.150328], - [0.944152, 0.961916, 0.146861], - [0.941896, 0.968590, 0.140956], - [0.940015, 0.975158, 0.131326]] - -_viridis_data = [[0.267004, 0.004874, 0.329415], - [0.268510, 0.009605, 0.335427], - [0.269944, 0.014625, 0.341379], - [0.271305, 0.019942, 0.347269], - [0.272594, 0.025563, 0.353093], - [0.273809, 0.031497, 0.358853], - [0.274952, 0.037752, 0.364543], - [0.276022, 0.044167, 0.370164], - [0.277018, 0.050344, 0.375715], - [0.277941, 0.056324, 0.381191], - [0.278791, 0.062145, 0.386592], - [0.279566, 0.067836, 0.391917], - [0.280267, 0.073417, 0.397163], - [0.280894, 0.078907, 0.402329], - [0.281446, 0.084320, 0.407414], - [0.281924, 0.089666, 0.412415], - [0.282327, 0.094955, 0.417331], - [0.282656, 0.100196, 0.422160], - [0.282910, 0.105393, 0.426902], - [0.283091, 0.110553, 0.431554], - [0.283197, 0.115680, 0.436115], - [0.283229, 0.120777, 0.440584], - [0.283187, 0.125848, 0.444960], - [0.283072, 0.130895, 0.449241], - [0.282884, 0.135920, 0.453427], - [0.282623, 0.140926, 0.457517], - [0.282290, 0.145912, 0.461510], - [0.281887, 0.150881, 0.465405], - [0.281412, 0.155834, 0.469201], - [0.280868, 0.160771, 0.472899], - [0.280255, 0.165693, 0.476498], - [0.279574, 0.170599, 0.479997], - [0.278826, 0.175490, 0.483397], - [0.278012, 0.180367, 0.486697], - [0.277134, 0.185228, 0.489898], - [0.276194, 0.190074, 0.493001], - [0.275191, 0.194905, 0.496005], - [0.274128, 0.199721, 0.498911], - [0.273006, 0.204520, 0.501721], - [0.271828, 0.209303, 0.504434], - [0.270595, 0.214069, 0.507052], - [0.269308, 0.218818, 0.509577], - [0.267968, 0.223549, 0.512008], - [0.266580, 0.228262, 0.514349], - [0.265145, 0.232956, 0.516599], - [0.263663, 0.237631, 0.518762], - [0.262138, 0.242286, 0.520837], - [0.260571, 0.246922, 0.522828], - [0.258965, 0.251537, 0.524736], - [0.257322, 0.256130, 0.526563], - [0.255645, 0.260703, 0.528312], - [0.253935, 0.265254, 0.529983], - [0.252194, 0.269783, 0.531579], - [0.250425, 0.274290, 0.533103], - [0.248629, 0.278775, 0.534556], - [0.246811, 0.283237, 0.535941], - [0.244972, 0.287675, 0.537260], - [0.243113, 0.292092, 0.538516], - [0.241237, 0.296485, 0.539709], - [0.239346, 0.300855, 0.540844], - [0.237441, 0.305202, 0.541921], - [0.235526, 0.309527, 0.542944], - [0.233603, 0.313828, 0.543914], - [0.231674, 0.318106, 0.544834], - [0.229739, 0.322361, 0.545706], - [0.227802, 0.326594, 0.546532], - [0.225863, 0.330805, 0.547314], - [0.223925, 0.334994, 0.548053], - [0.221989, 0.339161, 0.548752], - [0.220057, 0.343307, 0.549413], - [0.218130, 0.347432, 0.550038], - [0.216210, 0.351535, 0.550627], - [0.214298, 0.355619, 0.551184], - [0.212395, 0.359683, 0.551710], - [0.210503, 0.363727, 0.552206], - [0.208623, 0.367752, 0.552675], - [0.206756, 0.371758, 0.553117], - [0.204903, 0.375746, 0.553533], - [0.203063, 0.379716, 0.553925], - [0.201239, 0.383670, 0.554294], - [0.199430, 0.387607, 0.554642], - [0.197636, 0.391528, 0.554969], - [0.195860, 0.395433, 0.555276], - [0.194100, 0.399323, 0.555565], - [0.192357, 0.403199, 0.555836], - [0.190631, 0.407061, 0.556089], - [0.188923, 0.410910, 0.556326], - [0.187231, 0.414746, 0.556547], - [0.185556, 0.418570, 0.556753], - [0.183898, 0.422383, 0.556944], - [0.182256, 0.426184, 0.557120], - [0.180629, 0.429975, 0.557282], - [0.179019, 0.433756, 0.557430], - [0.177423, 0.437527, 0.557565], - [0.175841, 0.441290, 0.557685], - [0.174274, 0.445044, 0.557792], - [0.172719, 0.448791, 0.557885], - [0.171176, 0.452530, 0.557965], - [0.169646, 0.456262, 0.558030], - [0.168126, 0.459988, 0.558082], - [0.166617, 0.463708, 0.558119], - [0.165117, 0.467423, 0.558141], - [0.163625, 0.471133, 0.558148], - [0.162142, 0.474838, 0.558140], - [0.160665, 0.478540, 0.558115], - [0.159194, 0.482237, 0.558073], - [0.157729, 0.485932, 0.558013], - [0.156270, 0.489624, 0.557936], - [0.154815, 0.493313, 0.557840], - [0.153364, 0.497000, 0.557724], - [0.151918, 0.500685, 0.557587], - [0.150476, 0.504369, 0.557430], - [0.149039, 0.508051, 0.557250], - [0.147607, 0.511733, 0.557049], - [0.146180, 0.515413, 0.556823], - [0.144759, 0.519093, 0.556572], - [0.143343, 0.522773, 0.556295], - [0.141935, 0.526453, 0.555991], - [0.140536, 0.530132, 0.555659], - [0.139147, 0.533812, 0.555298], - [0.137770, 0.537492, 0.554906], - [0.136408, 0.541173, 0.554483], - [0.135066, 0.544853, 0.554029], - [0.133743, 0.548535, 0.553541], - [0.132444, 0.552216, 0.553018], - [0.131172, 0.555899, 0.552459], - [0.129933, 0.559582, 0.551864], - [0.128729, 0.563265, 0.551229], - [0.127568, 0.566949, 0.550556], - [0.126453, 0.570633, 0.549841], - [0.125394, 0.574318, 0.549086], - [0.124395, 0.578002, 0.548287], - [0.123463, 0.581687, 0.547445], - [0.122606, 0.585371, 0.546557], - [0.121831, 0.589055, 0.545623], - [0.121148, 0.592739, 0.544641], - [0.120565, 0.596422, 0.543611], - [0.120092, 0.600104, 0.542530], - [0.119738, 0.603785, 0.541400], - [0.119512, 0.607464, 0.540218], - [0.119423, 0.611141, 0.538982], - [0.119483, 0.614817, 0.537692], - [0.119699, 0.618490, 0.536347], - [0.120081, 0.622161, 0.534946], - [0.120638, 0.625828, 0.533488], - [0.121380, 0.629492, 0.531973], - [0.122312, 0.633153, 0.530398], - [0.123444, 0.636809, 0.528763], - [0.124780, 0.640461, 0.527068], - [0.126326, 0.644107, 0.525311], - [0.128087, 0.647749, 0.523491], - [0.130067, 0.651384, 0.521608], - [0.132268, 0.655014, 0.519661], - [0.134692, 0.658636, 0.517649], - [0.137339, 0.662252, 0.515571], - [0.140210, 0.665859, 0.513427], - [0.143303, 0.669459, 0.511215], - [0.146616, 0.673050, 0.508936], - [0.150148, 0.676631, 0.506589], - [0.153894, 0.680203, 0.504172], - [0.157851, 0.683765, 0.501686], - [0.162016, 0.687316, 0.499129], - [0.166383, 0.690856, 0.496502], - [0.170948, 0.694384, 0.493803], - [0.175707, 0.697900, 0.491033], - [0.180653, 0.701402, 0.488189], - [0.185783, 0.704891, 0.485273], - [0.191090, 0.708366, 0.482284], - [0.196571, 0.711827, 0.479221], - [0.202219, 0.715272, 0.476084], - [0.208030, 0.718701, 0.472873], - [0.214000, 0.722114, 0.469588], - [0.220124, 0.725509, 0.466226], - [0.226397, 0.728888, 0.462789], - [0.232815, 0.732247, 0.459277], - [0.239374, 0.735588, 0.455688], - [0.246070, 0.738910, 0.452024], - [0.252899, 0.742211, 0.448284], - [0.259857, 0.745492, 0.444467], - [0.266941, 0.748751, 0.440573], - [0.274149, 0.751988, 0.436601], - [0.281477, 0.755203, 0.432552], - [0.288921, 0.758394, 0.428426], - [0.296479, 0.761561, 0.424223], - [0.304148, 0.764704, 0.419943], - [0.311925, 0.767822, 0.415586], - [0.319809, 0.770914, 0.411152], - [0.327796, 0.773980, 0.406640], - [0.335885, 0.777018, 0.402049], - [0.344074, 0.780029, 0.397381], - [0.352360, 0.783011, 0.392636], - [0.360741, 0.785964, 0.387814], - [0.369214, 0.788888, 0.382914], - [0.377779, 0.791781, 0.377939], - [0.386433, 0.794644, 0.372886], - [0.395174, 0.797475, 0.367757], - [0.404001, 0.800275, 0.362552], - [0.412913, 0.803041, 0.357269], - [0.421908, 0.805774, 0.351910], - [0.430983, 0.808473, 0.346476], - [0.440137, 0.811138, 0.340967], - [0.449368, 0.813768, 0.335384], - [0.458674, 0.816363, 0.329727], - [0.468053, 0.818921, 0.323998], - [0.477504, 0.821444, 0.318195], - [0.487026, 0.823929, 0.312321], - [0.496615, 0.826376, 0.306377], - [0.506271, 0.828786, 0.300362], - [0.515992, 0.831158, 0.294279], - [0.525776, 0.833491, 0.288127], - [0.535621, 0.835785, 0.281908], - [0.545524, 0.838039, 0.275626], - [0.555484, 0.840254, 0.269281], - [0.565498, 0.842430, 0.262877], - [0.575563, 0.844566, 0.256415], - [0.585678, 0.846661, 0.249897], - [0.595839, 0.848717, 0.243329], - [0.606045, 0.850733, 0.236712], - [0.616293, 0.852709, 0.230052], - [0.626579, 0.854645, 0.223353], - [0.636902, 0.856542, 0.216620], - [0.647257, 0.858400, 0.209861], - [0.657642, 0.860219, 0.203082], - [0.668054, 0.861999, 0.196293], - [0.678489, 0.863742, 0.189503], - [0.688944, 0.865448, 0.182725], - [0.699415, 0.867117, 0.175971], - [0.709898, 0.868751, 0.169257], - [0.720391, 0.870350, 0.162603], - [0.730889, 0.871916, 0.156029], - [0.741388, 0.873449, 0.149561], - [0.751884, 0.874951, 0.143228], - [0.762373, 0.876424, 0.137064], - [0.772852, 0.877868, 0.131109], - [0.783315, 0.879285, 0.125405], - [0.793760, 0.880678, 0.120005], - [0.804182, 0.882046, 0.114965], - [0.814576, 0.883393, 0.110347], - [0.824940, 0.884720, 0.106217], - [0.835270, 0.886029, 0.102646], - [0.845561, 0.887322, 0.099702], - [0.855810, 0.888601, 0.097452], - [0.866013, 0.889868, 0.095953], - [0.876168, 0.891125, 0.095250], - [0.886271, 0.892374, 0.095374], - [0.896320, 0.893616, 0.096335], - [0.906311, 0.894855, 0.098125], - [0.916242, 0.896091, 0.100717], - [0.926106, 0.897330, 0.104071], - [0.935904, 0.898570, 0.108131], - [0.945636, 0.899815, 0.112838], - [0.955300, 0.901065, 0.118128], - [0.964894, 0.902323, 0.123941], - [0.974417, 0.903590, 0.130215], - [0.983868, 0.904867, 0.136897], - [0.993248, 0.906157, 0.143936]] - - -cmaps = {} -for (name, data) in (('magma', _magma_data), - ('inferno', _inferno_data), - ('plasma', _plasma_data), - ('viridis', _viridis_data)): - - cmaps[name] = ListedColormap(data, name=name) - -magma = cmaps['magma'] -inferno = cmaps['inferno'] -plasma = cmaps['plasma'] -viridis = cmaps['viridis'] diff --git a/silx/gui/plot/MaskToolsWidget.py b/silx/gui/plot/MaskToolsWidget.py index 6407d44..09c5ca5 100644 --- a/silx/gui/plot/MaskToolsWidget.py +++ b/silx/gui/plot/MaskToolsWidget.py @@ -35,13 +35,14 @@ from __future__ import division __authors__ = ["T. Vincent", "P. Knobel"] __license__ = "MIT" -__date__ = "20/04/2017" +__date__ = "20/06/2017" import os import sys import numpy import logging +import collections from silx.image import shapes @@ -211,17 +212,13 @@ class MaskToolsWidget(BaseMaskToolsWidget): _maxLevelNumber = 255 def __init__(self, parent=None, plot=None): + super(MaskToolsWidget, self).__init__(parent, plot, + mask=ImageMask()) self._origin = (0., 0.) # Mask origin in plot self._scale = (1., 1.) # Mask scale in plot self._z = 1 # Mask layer in plot self._data = numpy.zeros((0, 0), dtype=numpy.uint8) # Store image - self._mask = ImageMask() - - super(MaskToolsWidget, self).__init__(parent, plot) - - self._initWidgets() - def setSelectionMask(self, mask, copy=True): """Set the mask to a new array. @@ -239,6 +236,13 @@ class MaskToolsWidget(BaseMaskToolsWidget): _logger.error('Not an image, shape: %d', len(mask.shape)) return None + # ensure all mask attributes are synchronized with the active image + # and connect listener + activeImage = self.plot.getActiveImage() + if activeImage is not None and activeImage.getLegend() != self._maskName: + self._activeImageChanged() + self.plot.sigActiveImageChanged.connect(self._activeImageChanged) + if self._data.shape[0:2] == (0, 0) or mask.shape == self._data.shape[0:2]: self._mask.setMask(mask, copy=copy) self._mask.commit() @@ -262,12 +266,22 @@ class MaskToolsWidget(BaseMaskToolsWidget): """Update mask image in plot""" mask = self.getSelectionMask(copy=False) if len(mask): - self.plot.addImage(mask, legend=self._maskName, - colormap=self._colormap, - origin=self._origin, - scale=self._scale, - z=self._z, - replace=False, resetzoom=False) + # get the mask from the plot + maskItem = self.plot.getImage(self._maskName) + mustBeAdded = maskItem is None + if mustBeAdded: + maskItem = items.MaskImageData() + maskItem._setLegend(self._maskName) + # update the items + maskItem.setData(mask, copy=False) + maskItem.setColormap(self._colormap) + maskItem.setOrigin(self._origin) + maskItem.setScale(self._scale) + maskItem.setZValue(self._z) + + if mustBeAdded: + self.plot._add(maskItem) + elif self.plot.getImage(self._maskName): self.plot.remove(self._maskName, kind='image') @@ -281,7 +295,11 @@ class MaskToolsWidget(BaseMaskToolsWidget): self.plot.sigActiveImageChanged.connect(self._activeImageChanged) def hideEvent(self, event): - self.plot.sigActiveImageChanged.disconnect(self._activeImageChanged) + try: + self.plot.sigActiveImageChanged.disconnect( + self._activeImageChanged) + except (RuntimeError, TypeError): + pass if not self.browseAction.isChecked(): self.browseAction.trigger() # Disable drawing tool @@ -337,8 +355,9 @@ class MaskToolsWidget(BaseMaskToolsWidget): def _activeImageChanged(self, *args): """Update widget and mask according to active image changes""" activeImage = self.plot.getActiveImage() - if activeImage is None or activeImage.getLegend() == self._maskName: - # No active image or active image is the mask... + if (activeImage is None or activeImage.getLegend() == self._maskName or + activeImage.getData(copy=False).size == 0): + # No active image or active image is the mask or image has no data... self.setEnabled(False) self._data = numpy.zeros((0, 0), dtype=numpy.uint8) @@ -390,6 +409,14 @@ class MaskToolsWidget(BaseMaskToolsWidget): _logger.error("Can't load filename '%s'", filename) _logger.debug("Backtrace", exc_info=True) raise RuntimeError('File "%s" is not a numpy file.', filename) + elif extension in ["tif", "tiff"]: + try: + image = TiffIO(filename, mode="r") + mask = image.getImage(0) + except Exception as e: + _logger.error("Can't load filename %s", filename) + _logger.debug("Backtrace", exc_info=True) + raise e elif extension == "edf": try: mask = EdfFile(filename, access='r').GetData(0) @@ -423,14 +450,21 @@ class MaskToolsWidget(BaseMaskToolsWidget): dialog = qt.QFileDialog(self) dialog.setWindowTitle("Load Mask") dialog.setModal(1) - filters = [ - 'EDF (*.edf)', - 'TIFF (*.tif)', - 'NumPy binary file (*.npy)', - # Fit2D mask is displayed anyway fabio is here or not - # to show to the user that the option exists - 'Fit2D mask (*.msk)', - ] + + extensions = collections.OrderedDict() + extensions["EDF files"] = "*.edf" + extensions["TIFF files"] = "*.tif *.tiff" + extensions["NumPy binary files"] = "*.npy" + # Fit2D mask is displayed anyway fabio is here or not + # to show to the user that the option exists + extensions["Fit2D mask files"] = "*.msk" + + 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.ExistingFile) dialog.setDirectory(self.maskFileDir) @@ -610,6 +644,5 @@ class MaskToolsDockWidget(BaseMaskToolsDockWidget): :paran str name: The title of this widget """ def __init__(self, parent=None, plot=None, name='Mask'): - super(MaskToolsDockWidget, self).__init__(parent, name) - self.setWidget(MaskToolsWidget(plot=plot)) - self.widget().sigMaskChanged.connect(self._emitSigMaskChanged) + widget = MaskToolsWidget(plot=plot) + super(MaskToolsDockWidget, self).__init__(parent, name, widget) diff --git a/silx/gui/plot/Plot.py b/silx/gui/plot/Plot.py deleted file mode 100644 index fe0a7b8..0000000 --- a/silx/gui/plot/Plot.py +++ /dev/null @@ -1,2925 +0,0 @@ -# coding: utf-8 -# /*########################################################################## -# -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# ###########################################################################*/ -"""Plot API for 1D and 2D data. - -The :class:`Plot` implements the plot API initially provided in PyMca. - - -Colormap --------- - -The :class:`Plot` uses a dictionary to describe a colormap. -This dictionary has the following keys: - -- 'name': str, name of the colormap. Available colormap are returned by - :meth:`Plot.getSupportedColormaps`. - At least 'gray', 'reversed gray', 'temperature', - 'red', 'green', 'blue' are supported. -- 'normalization': Either 'linear' or 'log' -- 'autoscale': bool, True to get bounds from the min and max of the - data, False to use [vmin, vmax] -- 'vmin': float, min value, ignored if autoscale is True -- 'vmax': float, max value, ignored if autoscale is True -- 'colors': optional, custom colormap. - Nx3 or Nx4 numpy array of RGB(A) colors, - either uint8 or float in [0, 1]. - If 'name' is None, then this array is used as the colormap. - - -Plot Events ------------ - -The Plot sends some event to the registered callback -(See :meth:`Plot.setCallback`). -Those events are sent as a dictionary with a key 'event' describing the kind -of event. - -Drawing events -.............. - -'drawingProgress' and 'drawingFinished' events are sent during drawing -interaction (See :meth:`Plot.setInteractiveMode`). - -- 'event': 'drawingProgress' or 'drawingFinished' -- 'parameters': dict of parameters used by the drawing mode. - It has the following keys: 'shape', 'label', 'color'. - See :meth:`Plot.setInteractiveMode`. -- 'points': Points (x, y) in data coordinates of the drawn shape. - For 'hline' and 'vline', it is the 2 points defining the line. - For 'line' and 'rectangle', it is the coordinates of the start - drawing point and the latest drawing point. - For 'polygon', it is the coordinates of all points of the shape. -- 'type': The type of drawing in 'line', 'hline', 'polygon', 'rectangle', - 'vline'. -- 'xdata' and 'ydata': X coords and Y coords of shape points in data - coordinates (as in 'points'). - -When the type is 'rectangle', the following additional keys are provided: - -- 'x' and 'y': The origin of the rectangle in data coordinates -- 'widht' and 'height': The size of the rectangle in data coordinates - - -Mouse events -............ - -'mouseMoved', 'mouseClicked' and 'mouseDoubleClicked' events are sent for -mouse events. - -They provide the following keys: - -- 'event': 'mouseMoved', 'mouseClicked' or 'mouseDoubleClicked' -- 'button': the mouse button that was pressed in 'left', 'middle', 'right' -- 'x' and 'y': The mouse position in data coordinates -- 'xpixel' and 'ypixel': The mouse position in pixels - - -Marker events -............. - -'hover', 'markerClicked', 'markerMoving' and 'markerMoved' events are -sent during interaction with markers. - -'hover' is sent when the mouse cursor is over a marker. -'markerClicker' is sent when the user click on a selectable marker. -'markerMoving' and 'markerMoved' are sent when a draggable marker is moved. - -They provide the following keys: - -- 'event': 'hover', 'markerClicked', 'markerMoving' or 'markerMoved' -- 'button': the mouse button that is pressed in 'left', 'middle', 'right' -- 'draggable': True if the marker is draggable, False otherwise -- 'label': The legend associated with the clicked image or curve -- 'selectable': True if the marker is selectable, False otherwise -- 'type': 'marker' -- 'x' and 'y': The mouse position in data coordinates -- 'xdata' and 'ydata': The marker position in data coordinates - -'markerClicked' and 'markerMoving' events have a 'xpixel' and a 'ypixel' -additional keys, that provide the mouse position in pixels. - - -Image and curve events -...................... - -'curveClicked' and 'imageClicked' events are sent when a selectable curve -or image is clicked. - -Both share the following keys: - -- 'event': 'curveClicked' or 'imageClicked' -- 'button': the mouse button that was pressed in 'left', 'middle', 'right' -- 'label': The legend associated with the clicked image or curve -- 'type': The type of item in 'curve', 'image' -- 'x' and 'y': The clicked position in data coordinates -- 'xpixel' and 'ypixel': The clicked position in pixels - -'curveClicked' events have a 'xdata' and a 'ydata' additional keys, that -provide the coordinates of the picked points of the curve. -There can be more than one point of the curve being picked, and if a line of -the curve is picked, only the first point of the line is included in the list. - -'imageClicked' have a 'col' and a 'row' additional keys, that provide -the column and row index in the image array that was clicked. - - -Limits changed events -..................... - -'limitsChanged' events are sent when the limits of the plot are changed. -This can results from user interaction or API calls. - -It provides the following keys: - -- 'event': 'limitsChanged' -- 'source': id of the widget that emitted this event. -- 'xdata': Range of X in graph coordinates: (xMin, xMax). -- 'ydata': Range of Y in graph coordinates: (yMin, yMax). -- 'y2data': Range of right axis in graph coordinates (y2Min, y2Max) or None. - -Plot state change events -........................ - -The following events are emitted when the plot is modified. -They provide the new state: - -- 'setGraphCursor' event with a 'state' key (bool) -- 'setGraphGrid' event with a 'which' key (str), see :meth:`setGraphGrid` -- 'setKeepDataAspectRatio' event with a 'state' key (bool) -- 'setXAxisAutoScale' event with a 'state' key (bool) -- 'setXAxisLogarithmic' event with a 'state' key (bool) -- 'setYAxisAutoScale' event with a 'state' key (bool) -- 'setYAxisInverted' event with a 'state' key (bool) -- 'setYAxisLogarithmic' event with a 'state' key (bool) - -A 'contentChanged' event is triggered when the content of the plot is updated. -It provides the following keys: - -- 'action': The change of the plot: 'add' or 'remove' -- 'kind': The kind of primitive changed: 'curve', 'image', 'item' or 'marker' -- 'legend': The legend of the primitive changed. - -'activeCurveChanged' and 'activeImageChanged' events with the following keys: - -- 'legend': Name (str) of the current active item or None if no active item. -- 'previous': Name (str) of the previous active item or None if no item was - active. It is the same as 'legend' if 'updated' == True -- 'updated': (bool) True if active item name did not changed, - but active item data or style was updated. - -'interactiveModeChanged' event with a 'source' key identifying the object -setting the interactive mode. -""" - -from __future__ import division - - -__authors__ = ["V.A. Sole", "T. Vincent"] -__license__ = "MIT" -__date__ = "16/02/2017" - - -from collections import OrderedDict, namedtuple -import itertools -import logging - -import numpy - -# Import matplotlib backend here to init matplotlib our way -from .backends.BackendMatplotlib import BackendMatplotlibQt - -try: - from matplotlib import cm as matplotlib_cm -except ImportError: - matplotlib_cm = None - -from . import Colors -from . import PlotInteraction -from . import PlotEvents -from . import _utils - -from . import items - - -_logger = logging.getLogger(__name__) - - -_COLORDICT = Colors.COLORDICT -_COLORLIST = [_COLORDICT['black'], - _COLORDICT['blue'], - _COLORDICT['red'], - _COLORDICT['green'], - _COLORDICT['pink'], - _COLORDICT['yellow'], - _COLORDICT['brown'], - _COLORDICT['cyan'], - _COLORDICT['magenta'], - _COLORDICT['orange'], - _COLORDICT['violet'], - # _COLORDICT['bluegreen'], - _COLORDICT['grey'], - _COLORDICT['darkBlue'], - _COLORDICT['darkRed'], - _COLORDICT['darkGreen'], - _COLORDICT['darkCyan'], - _COLORDICT['darkMagenta'], - _COLORDICT['darkYellow'], - _COLORDICT['darkBrown']] - - -""" -Object returned when requesting the data range. -""" -_PlotDataRange = namedtuple('PlotDataRange', - ['x', 'y', 'yright']) - - -class Plot(object): - """This class implements the plot API initially provided in PyMca. - - Supported backends: - - - 'matplotlib' and 'mpl': Matplotlib with Qt. - - 'opengl' and 'gl': OpenGL backend (requires PyOpenGL and OpenGL >= 2.1) - - 'none': No backend, to run headless for testing purpose. - - :param parent: The parent widget of the plot (Default: None) - :param backend: The backend to use. A str in: - 'matplotlib', 'mpl', 'opengl', 'gl', 'none' - or a :class:`BackendBase.BackendBase` class - """ - - DEFAULT_BACKEND = 'matplotlib' - """Class attribute setting the default backend for all instances.""" - - colorList = _COLORLIST - colorDict = _COLORDICT - - def __init__(self, parent=None, backend=None): - self._autoreplot = False - self._dirty = False - self._cursorInPlot = False - - if backend is None: - backend = self.DEFAULT_BACKEND - - if hasattr(backend, "__call__"): - self._backend = backend(self, parent) - - elif hasattr(backend, "lower"): - lowerCaseString = backend.lower() - if lowerCaseString in ("matplotlib", "mpl"): - backendClass = BackendMatplotlibQt - elif lowerCaseString in ('gl', 'opengl'): - from .backends.BackendOpenGL import BackendOpenGL - backendClass = BackendOpenGL - elif lowerCaseString == 'none': - from .backends.BackendBase import BackendBase as backendClass - else: - raise ValueError("Backend not supported %s" % backend) - self._backend = backendClass(self, parent) - - else: - raise ValueError("Backend not supported %s" % str(backend)) - - super(Plot, self).__init__() - - self.setCallback() # set _callback - - # Items handling - self._content = OrderedDict() - self._contentToUpdate = set() - - self._dataRange = None - - # line types - self._styleList = ['-', '--', '-.', ':'] - self._colorIndex = 0 - self._styleIndex = 0 - - self._activeCurveHandling = True - self._activeCurveColor = "#000000" - self._activeLegend = {'curve': None, 'image': None, - 'scatter': None} - - # default properties - self._cursorConfiguration = None - - self._logY = False - self._logX = False - self._xAutoScale = True - self._yAutoScale = True - self._grid = None - - # Store default labels provided to setGraph[X|Y]Label - self._defaultLabels = {'x': '', 'y': '', 'yright': ''} - # Store currently displayed labels - # Current label can differ from input one with active curve handling - self._currentLabels = {'x': '', 'y': '', 'yright': ''} - - self._graphTitle = '' - - self.setGraphTitle() - self.setGraphXLabel() - self.setGraphYLabel() - self.setGraphYLabel('', axis='right') - - self.setDefaultColormap() # Init default colormap - - self.setDefaultPlotPoints(False) - self.setDefaultPlotLines(True) - - self._eventHandler = PlotInteraction.PlotInteraction(self) - self._eventHandler.setInteractiveMode('zoom', color=(0., 0., 0., 1.)) - - self._pressedButtons = [] # Currently pressed mouse buttons - - self._defaultDataMargins = (0., 0., 0., 0.) - - # Only activate autoreplot at the end - # This avoids errors when loaded in Qt designer - self._dirty = False - self._autoreplot = True - - def _getDirtyPlot(self): - """Return the plot dirty flag. - - If False, the plot has not changed since last replot. - If True, the full plot need to be redrawn. - If 'overlay', only the overlay has changed since last replot. - - It can be accessed by backend to check the dirty state. - - :return: False, True, 'overlay' - """ - return self._dirty - - def _setDirtyPlot(self, overlayOnly=False): - """Mark the plot as needing redraw - - :param bool overlayOnly: True to redraw only the overlay, - False to redraw everything - """ - wasDirty = self._dirty - - if not self._dirty and overlayOnly: - self._dirty = 'overlay' - else: - self._dirty = True - - if self._autoreplot and not wasDirty: - self._backend.postRedisplay() - - def _invalidateDataRange(self): - """ - Notifies this Plot instance that the range has changed and will have - to be recomputed. - """ - self._dataRange = None - - def _updateDataRange(self): - """ - Recomputes the range of the data displayed on this Plot. - """ - xMin = yMinLeft = yMinRight = float('nan') - xMax = yMaxLeft = yMaxRight = float('nan') - - for item in self._content.values(): - if item.isVisible(): - bounds = item.getBounds() - if bounds is not None: - xMin = numpy.nanmin([xMin, bounds[0]]) - xMax = numpy.nanmax([xMax, bounds[1]]) - # Take care of right axis - if (isinstance(item, items.YAxisMixIn) and - item.getYAxis() == 'right'): - yMinRight = numpy.nanmin([yMinRight, bounds[2]]) - yMaxRight = numpy.nanmax([yMaxRight, bounds[3]]) - else: - yMinLeft = numpy.nanmin([yMinLeft, bounds[2]]) - yMaxLeft = numpy.nanmax([yMaxLeft, bounds[3]]) - - def lGetRange(x, y): - return None if numpy.isnan(x) and numpy.isnan(y) else (x, y) - xRange = lGetRange(xMin, xMax) - yLeftRange = lGetRange(yMinLeft, yMaxLeft) - yRightRange = lGetRange(yMinRight, yMaxRight) - - self._dataRange = _PlotDataRange(x=xRange, - y=yLeftRange, - yright=yRightRange) - - def getDataRange(self): - """ - Returns this Plot's data range. - - :return: a namedtuple with the following members : - x, y (left y axis), yright. Each member is a tuple (min, max) - or None if no data is associated with the axis. - :rtype: namedtuple - """ - if self._dataRange is None: - self._updateDataRange() - return self._dataRange - - # Content management - - @staticmethod - def _itemKey(item): - """Build the key of given :class:`Item` in the plot - - :param Item item: The item to make the key from - :return: (legend, kind) - :rtype: (str, str) - """ - if isinstance(item, items.Curve): - kind = 'curve' - elif isinstance(item, items.ImageBase): - kind = 'image' - elif isinstance(item, items.Scatter): - kind = 'scatter' - elif isinstance(item, (items.Marker, - items.XMarker, items.YMarker)): - kind = 'marker' - elif isinstance(item, items.Shape): - kind = 'item' - elif isinstance(item, items.Histogram): - kind = 'histogram' - else: - raise ValueError('Unsupported item type %s' % type(item)) - - return item.getLegend(), kind - - def _add(self, item): - """Add the given :class:`Item` to the plot. - - :param Item item: The item to append to the plot content - """ - key = self._itemKey(item) - if key in self._content: - raise RuntimeError('Item already in the plot') - - # Add item to plot - self._content[key] = item - item._setPlot(self) - if item.isVisible(): - self._itemRequiresUpdate(item) - if isinstance(item, (items.Curve, items.ImageBase)): - self._invalidateDataRange() # TODO handle this automatically - - def _remove(self, item): - """Remove the given :class:`Item` from the plot. - - :param Item item: The item to remove from the plot content - """ - key = self._itemKey(item) - if key not in self._content: - raise RuntimeError('Item not in the plot') - - # Remove item from plot - self._content.pop(key) - self._contentToUpdate.discard(item) - if item.isVisible(): - self._setDirtyPlot(overlayOnly=item.isOverlay()) - if item.getBounds() is not None: - self._invalidateDataRange() - item._removeBackendRenderer(self._backend) - item._setPlot(None) - - def _itemRequiresUpdate(self, item): - """Called by items in the plot for asynchronous update - - :param Item item: The item that required update - """ - assert item.getPlot() == self - self._contentToUpdate.add(item) - self._setDirtyPlot(overlayOnly=item.isOverlay()) - - # Add - - # add * input arguments management: - # If an arg is set, then use it. - # Else: - # If a curve with the same legend exists, then use its arg value - # Else, use a default value. - # Store used value. - # This value is used when curve is updated either internally or by user. - - def addCurve(self, x, y, legend=None, info=None, - replace=False, replot=None, - color=None, symbol=None, - linewidth=None, linestyle=None, - xlabel=None, ylabel=None, yaxis=None, - xerror=None, yerror=None, z=None, selectable=None, - fill=None, resetzoom=True, - histogram=None, copy=True, **kw): - """Add a 1D curve given by x an y to the graph. - - Curves are uniquely identified by their legend. - To add multiple curves, call :meth:`addCurve` multiple times with - different legend argument. - To replace an existing curve, call :meth:`addCurve` with the - existing curve legend. - If you want to display the curve values as an histogram see the - histogram parameter or :meth:`addHistogram`. - - When curve parameters are not provided, if a curve with the - same legend is displayed in the plot, its parameters are used. - - :param numpy.ndarray x: The data corresponding to the x coordinates. - If you attempt to plot an histogram you can set edges values in x. - In this case len(x) = len(y) + 1 - :param numpy.ndarray y: The data corresponding to the y coordinates - :param str legend: The legend to be associated to the curve (or None) - :param info: User-defined information associated to the curve - :param bool replace: True (the default) to delete already existing - curves - :param color: color(s) to be used - :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or - one of the predefined color names defined in Colors.py - :param str symbol: Symbol to be drawn at each (x, y) position:: - - - 'o' circle - - '.' point - - ',' pixel - - '+' cross - - 'x' x-cross - - 'd' diamond - - 's' square - - None (the default) to use default symbol - - :param float linewidth: The width of the curve in pixels (Default: 1). - :param str linestyle: Type of line:: - - - ' ' no line - - '-' solid line - - '--' dashed line - - '-.' dash-dot line - - ':' dotted line - - None (the default) to use default line style - - :param str xlabel: Label to show on the X axis when the curve is active - or None to keep default axis label. - :param str ylabel: Label to show on the Y axis when the curve is active - or None to keep default axis label. - :param str yaxis: The Y axis this curve is attached to. - Either 'left' (the default) or 'right' - :param xerror: Values with the uncertainties on the x values - :type xerror: A float, or a numpy.ndarray of float32. - If it is an array, it can either be a 1D array of - same length as the data or a 2D array with 2 rows - of same length as the data: row 0 for positive errors, - row 1 for negative errors. - :param yerror: Values with the uncertainties on the y values - :type yerror: A float, or a numpy.ndarray of float32. See xerror. - :param int z: Layer on which to draw the curve (default: 1) - This allows to control the overlay. - :param bool selectable: Indicate if the curve can be selected. - (Default: True) - :param bool fill: True to fill the curve, False otherwise (default). - :param bool resetzoom: True (the default) to reset the zoom. - :param str histogram: if not None then the curve will be draw as an - histogram. The step for each values of the curve can be set to the - left, center or right of the original x curve values. - If histogram is not None and len(x) == len(y)+1 then x is directly - take as edges of the histogram. - Type of histogram:: - - - None (default) - - 'left' - - 'right' - - 'center' - :param bool copy: True make a copy of the data (default), - False to use provided arrays. - :returns: The key string identify this curve - """ - # Deprecation warnings - if replot is not None: - _logger.warning( - 'addCurve deprecated replot argument, use resetzoom instead') - resetzoom = replot and resetzoom - - if kw: - _logger.warning('addCurve: deprecated extra arguments') - - # This is an histogram, use addHistogram - if histogram is not None: - histoLegend = self.addHistogram(histogram=y, - edges=x, - legend=legend, - color=color, - fill=fill, - align=histogram, - copy=copy) - histo = self.getHistogram(histoLegend) - - histo.setInfo(info) - if linewidth is not None: - histo.setLineWidth(linewidth) - if linestyle is not None: - histo.setLineStyle(linestyle) - if xlabel is not None: - _logger.warning( - 'addCurve: Histogram does not support xlabel argument') - if ylabel is not None: - _logger.warning( - 'addCurve: Histogram does not support ylabel argument') - if yaxis is not None: - histo.setYAxis(yaxis) - if z is not None: - histo.setZValue(z) - if selectable is not None: - _logger.warning( - 'addCurve: Histogram does not support selectable argument') - - return - - legend = 'Unnamed curve 1.1' if legend is None else str(legend) - - # Check if curve was previously active - wasActive = self.getActiveCurve(just_legend=True) == legend - - # Create/Update curve object - curve = self.getCurve(legend) - if curve is None: - # No previous curve, create a default one and add it to the plot - curve = items.Curve() if histogram is None else items.Histogram() - curve._setLegend(legend) - # Set default color, linestyle and symbol - default_color, default_linestyle = self._getColorAndStyle() - curve.setColor(default_color) - curve.setLineStyle(default_linestyle) - curve.setSymbol(self._defaultPlotPoints) - self._add(curve) - - # Override previous/default values with provided ones - curve.setInfo(info) - if color is not None: - curve.setColor(color) - if symbol is not None: - curve.setSymbol(symbol) - if linewidth is not None: - curve.setLineWidth(linewidth) - if linestyle is not None: - curve.setLineStyle(linestyle) - if xlabel is not None: - curve._setXLabel(xlabel) - if ylabel is not None: - curve._setYLabel(ylabel) - if yaxis is not None: - curve.setYAxis(yaxis) - if z is not None: - curve.setZValue(z) - if selectable is not None: - curve._setSelectable(selectable) - if fill is not None: - curve.setFill(fill) - - # Set curve data - # If errors not provided, reuse previous ones - # TODO: Issue if size of data change but not that of errors - if xerror is None: - xerror = curve.getXErrorData(copy=False) - if yerror is None: - yerror = curve.getYErrorData(copy=False) - - curve.setData(x, y, xerror, yerror, copy=copy) - - if replace: # Then remove all other curves - for c in self.getAllCurves(withhidden=True): - if c is not curve: - self._remove(c) - - self.notify( - 'contentChanged', action='add', kind='curve', legend=legend) - - if wasActive: - self.setActiveCurve(curve.getLegend()) - - if resetzoom: - # We ask for a zoom reset in order to handle the plot scaling - # if the user does not want that, autoscale of the different - # axes has to be set to off. - self.resetZoom() - - return legend - - def addHistogram(self, - histogram, - edges, - legend=None, - color=None, - fill=None, - align='center', - resetzoom=True, - copy=True): - """Add an histogram to the graph. - - This is NOT computing the histogram, this method takes as parameter - already computed histogram values. - - Histogram are uniquely identified by their legend. - To add multiple histograms, call :meth:`addHistogram` multiple times - with different legend argument. - - When histogram parameters are not provided, if an histogram with the - same legend is displayed in the plot, its parameters are used. - - :param numpy.ndarray histogram: The values of the histogram. - :param numpy.ndarray edges: - The bin edges of the histogram. - If histogram and edges have the same length, the bin edges - are computed according to the align parameter. - :param str legend: - The legend to be associated to the histogram (or None) - :param color: color to be used - :type color: str ("#RRGGBB") or RGB unsigned byte array or - one of the predefined color names defined in Colors.py - :param bool fill: True to fill the curve, False otherwise (default). - :param str align: - In case histogram values and edges have the same length N, - the N+1 bin edges are computed according to the alignment in: - 'center' (default), 'left', 'right'. - :param bool resetzoom: True (the default) to reset the zoom. - :param bool copy: True make a copy of the data (default), - False to use provided arrays. - :returns: The key string identify this histogram - """ - legend = 'Unnamed histogram' if legend is None else str(legend) - - # Create/Update histogram object - histo = self.getHistogram(legend) - if histo is None: - # No previous histogram, create a default one and - # add it to the plot - histo = items.Histogram() - histo._setLegend(legend) - histo.setColor(self._getColorAndStyle()[0]) - self._add(histo) - - # Override previous/default values with provided ones - if color is not None: - histo.setColor(color) - if fill is not None: - histo.setFill(fill) - - # Set histogram data - histo.setData(histogram, edges, align=align, copy=copy) - - self.notify( - 'contentChanged', action='add', kind='histogram', legend=legend) - - if resetzoom: - # We ask for a zoom reset in order to handle the plot scaling - # if the user does not want that, autoscale of the different - # axes has to be set to off. - self.resetZoom() - - return legend - - def addImage(self, data, legend=None, info=None, - replace=True, replot=None, - xScale=None, yScale=None, z=None, - selectable=None, draggable=None, - colormap=None, pixmap=None, - xlabel=None, ylabel=None, - origin=None, scale=None, - resetzoom=True, copy=True, **kw): - """Add a 2D dataset or an image to the plot. - - It displays either an array of data using a colormap or a RGB(A) image. - - Images are uniquely identified by their legend. - To add multiple images, call :meth:`addImage` multiple times with - different legend argument. - To replace/update an existing image, call :meth:`addImage` with the - existing image legend. - - When image parameters are not provided, if an image with the - same legend is displayed in the plot, its parameters are used. - - :param numpy.ndarray data: (nrows, ncolumns) data or - (nrows, ncolumns, RGBA) ubyte array - :param str legend: The legend to be associated to the image (or None) - :param info: User-defined information associated to the image - :param bool replace: True (default) to delete already existing images - :param int z: Layer on which to draw the image (default: 0) - This allows to control the overlay. - :param bool selectable: Indicate if the image can be selected. - (default: False) - :param bool draggable: Indicate if the image can be moved. - (default: False) - :param dict colormap: Description of the colormap to use (or None) - This is ignored if data is a RGB(A) image. - See :mod:`Plot` for the documentation - of the colormap dict. - :param pixmap: Pixmap representation of the data (if any) - :type pixmap: (nrows, ncolumns, RGBA) ubyte array or None (default) - :param str xlabel: X axis label to show when this curve is active, - or None to keep default axis label. - :param str ylabel: Y axis label to show when this curve is active, - or None to keep default axis label. - :param origin: (origin X, origin Y) of the data. - It is possible to pass a single float if both - coordinates are equal. - Default: (0., 0.) - :type origin: float or 2-tuple of float - :param scale: (scale X, scale Y) of the data. - It is possible to pass a single float if both - coordinates are equal. - Default: (1., 1.) - :type scale: float or 2-tuple of float - :param bool resetzoom: True (the default) to reset the zoom. - :param bool copy: True make a copy of the data (default), - False to use provided arrays. - :returns: The key string identify this image - """ - # Deprecation warnings - if xScale is not None or yScale is not None: - _logger.warning( - 'addImage deprecated xScale and yScale arguments,' - 'use origin, scale arguments instead.') - if origin is None and scale is None: - origin = xScale[0], yScale[0] - scale = xScale[1], yScale[1] - else: - _logger.warning( - 'addCurve: xScale, yScale and origin, scale arguments' - ' are conflicting. xScale and yScale are ignored.' - ' Use only origin, scale arguments.') - - if replot is not None: - _logger.warning( - 'addImage deprecated replot argument, use resetzoom instead') - resetzoom = replot and resetzoom - - if kw: - _logger.warning('addImage: deprecated extra arguments') - - legend = "Unnamed Image 1.1" if legend is None else str(legend) - - # Check if image was previously active - wasActive = self.getActiveImage(just_legend=True) == legend - - data = numpy.array(data, copy=False) - assert data.ndim in (2, 3) - - image = self.getImage(legend) - if image is not None and image.getData(copy=False).ndim != data.ndim: - # Update a data image with RGBA image or the other way around: - # Remove previous image - # In this case, we don't retrieve defaults from the previous image - self._remove(image) - image = None - - if image is None: - # No previous image, create a default one and add it to the plot - if data.ndim == 2: - image = items.ImageData() - image.setColormap(self.getDefaultColormap()) - else: - image = items.ImageRgba() - image._setLegend(legend) - self._add(image) - - # Override previous/default values with provided ones - image.setInfo(info) - if origin is not None: - image.setOrigin(origin) - if scale is not None: - image.setScale(scale) - if z is not None: - image.setZValue(z) - if selectable is not None: - image._setSelectable(selectable) - if draggable is not None: - image._setDraggable(draggable) - if colormap is not None and isinstance(image, items.ColormapMixIn): - image.setColormap(colormap) - if xlabel is not None: - image._setXLabel(xlabel) - if ylabel is not None: - image._setYLabel(ylabel) - - if data.ndim == 2: - image.setData(data, alternative=pixmap, copy=copy) - else: # RGB(A) image - if pixmap is not None: - _logger.warning( - 'addImage: pixmap argument ignored when data is RGB(A)') - image.setData(data, copy=copy) - - if replace: - for img in self.getAllImages(): - if img is not image: - self._remove(img) - - if len(self.getAllImages()) == 1 or wasActive: - self.setActiveImage(legend) - - self.notify( - 'contentChanged', action='add', kind='image', legend=legend) - - if resetzoom: - # We ask for a zoom reset in order to handle the plot scaling - # if the user does not want that, autoscale of the different - # axes has to be set to off. - self.resetZoom() - - return legend - - def addScatter(self, x, y, value, legend=None, colormap=None, - info=None, symbol=None, xerror=None, yerror=None, - z=None, copy=True): - """Add a (x, y, value) scatter to the graph. - - Scatters are uniquely identified by their legend. - To add multiple scatters, call :meth:`addScatter` multiple times with - different legend argument. - To replace/update an existing scatter, call :meth:`addScatter` with the - existing scatter legend. - - When scatter parameters are not provided, if a scatter with the - same legend is displayed in the plot, its parameters are used. - - :param numpy.ndarray x: The data corresponding to the x coordinates. - :param numpy.ndarray y: The data corresponding to the y coordinates - :param numpy.ndarray value: The data value associated with each point - :param str legend: The legend to be associated to the scatter (or None) - :param dict colormap: The colormap to be used for the scatter (or None) - See :mod:`Plot` for the documentation - of the colormap dict. - :param info: User-defined information associated to the curve - :param str symbol: Symbol to be drawn at each (x, y) position:: - - - 'o' circle - - '.' point - - ',' pixel - - '+' cross - - 'x' x-cross - - 'd' diamond - - 's' square - - None (the default) to use default symbol - - :param xerror: Values with the uncertainties on the x values - :type xerror: A float, or a numpy.ndarray of float32. - If it is an array, it can either be a 1D array of - same length as the data or a 2D array with 2 rows - of same length as the data: row 0 for positive errors, - row 1 for negative errors. - :param yerror: Values with the uncertainties on the y values - :type yerror: A float, or a numpy.ndarray of float32. See xerror. - :param int z: Layer on which to draw the scatter (default: 1) - This allows to control the overlay. - - :param bool copy: True make a copy of the data (default), - False to use provided arrays. - :returns: The key string identify this scatter - """ - legend = 'Unnamed scatter 1.1' if legend is None else str(legend) - - # Check if scatter was previously active - wasActive = self._getActiveItem(kind='scatter', - just_legend=True) == legend - - # Create/Update curve object - scatter = self._getItem(kind='scatter', legend=legend) - if scatter is None: - # No previous scatter, create a default one and add it to the plot - scatter = items.Scatter() - scatter._setLegend(legend) - scatter.setColormap(self.getDefaultColormap()) - self._add(scatter) - - # Override previous/default values with provided ones - scatter.setInfo(info) - if symbol is not None: - scatter.setSymbol(symbol) - if z is not None: - scatter.setZValue(z) - if colormap is not None: - scatter.setColormap(colormap) - - # Set scatter data - # If errors not provided, reuse previous ones - if xerror is None: - xerror = scatter.getXErrorData(copy=False) - if xerror is not None and len(xerror) != len(x): - xerror = None - if yerror is None: - yerror = scatter.getYErrorData(copy=False) - if yerror is not None and len(yerror) != len(y): - yerror = None - - scatter.setData(x, y, value, xerror, yerror, copy=copy) - - self.notify( - 'contentChanged', action='add', kind='scatter', legend=legend) - - if len(self._getItems(kind="scatter")) == 1 or wasActive: - self._setActiveItem('scatter', scatter.getLegend()) - - return legend - - def addItem(self, xdata, ydata, legend=None, info=None, - replace=False, - shape="polygon", color='black', fill=True, - overlay=False, z=None, **kw): - """Add an item (i.e. a shape) to the plot. - - Items are uniquely identified by their legend. - To add multiple items, call :meth:`addItem` multiple times with - different legend argument. - To replace/update an existing item, call :meth:`addItem` with the - existing item legend. - - :param numpy.ndarray xdata: The X coords of the points of the shape - :param numpy.ndarray ydata: The Y coords of the points of the shape - :param str legend: The legend to be associated to the item - :param info: User-defined information associated to the item - :param bool replace: True (default) to delete already existing images - :param str shape: Type of item to be drawn in - hline, polygon (the default), rectangle, vline, - polylines - :param str color: Color of the item, e.g., 'blue', 'b', '#FF0000' - (Default: 'black') - :param bool fill: True (the default) to fill the shape - :param bool overlay: True if item is an overlay (Default: False). - This allows for rendering optimization if this - item is changed often. - :param int z: Layer on which to draw the item (default: 2) - :returns: The key string identify this item - """ - # expected to receive the same parameters as the signal - - if kw: - _logger.warning('addItem deprecated parameters: %s', str(kw)) - - legend = "Unnamed Item 1.1" if legend is None else str(legend) - - z = int(z) if z is not None else 2 - - if replace: - self.remove(kind='item') - else: - self.remove(legend, kind='item') - - item = items.Shape(shape) - item._setLegend(legend) - item.setInfo(info) - item.setColor(color) - item.setFill(fill) - item.setOverlay(overlay) - item.setZValue(z) - item.setPoints(numpy.array((xdata, ydata)).T) - - self._add(item) - - self.notify('contentChanged', action='add', kind='item', legend=legend) - - return legend - - def addXMarker(self, x, legend=None, - text=None, - color=None, - selectable=False, - draggable=False, - constraint=None, - **kw): - """Add a vertical line marker to the plot. - - Markers are uniquely identified by their legend. - As opposed to curves, images and items, two calls to - :meth:`addXMarker` without legend argument adds two markers with - different identifying legends. - - :param float x: Position of the marker on the X axis in data - coordinates - :param str legend: Legend associated to the marker to identify it - :param str text: Text to display on the marker. - :param str color: Color of the marker, e.g., 'blue', 'b', '#FF0000' - (Default: 'black') - :param bool selectable: Indicate if the marker can be selected. - (default: False) - :param bool draggable: Indicate if the marker can be moved. - (default: False) - :param constraint: A function filtering marker displacement by - dragging operations or None for no filter. - This function is called each time a marker is - moved. - This parameter is only used if draggable is True. - :type constraint: None or a callable that takes the coordinates of - the current cursor position in the plot as input - and that returns the filtered coordinates. - :return: The key string identify this marker - """ - if kw: - _logger.warning( - 'addXMarker deprecated extra parameters: %s', str(kw)) - - return self._addMarker(x=x, y=None, legend=legend, - text=text, color=color, - selectable=selectable, draggable=draggable, - symbol=None, constraint=constraint) - - def addYMarker(self, y, - legend=None, - text=None, - color=None, - selectable=False, - draggable=False, - constraint=None, - **kw): - """Add a horizontal line marker to the plot. - - Markers are uniquely identified by their legend. - As opposed to curves, images and items, two calls to - :meth:`addYMarker` without legend argument adds two markers with - different identifying legends. - - :param float y: Position of the marker on the Y axis in data - coordinates - :param str legend: Legend associated to the marker to identify it - :param str text: Text to display next to the marker. - :param str color: Color of the marker, e.g., 'blue', 'b', '#FF0000' - (Default: 'black') - :param bool selectable: Indicate if the marker can be selected. - (default: False) - :param bool draggable: Indicate if the marker can be moved. - (default: False) - :param constraint: A function filtering marker displacement by - dragging operations or None for no filter. - This function is called each time a marker is - moved. - This parameter is only used if draggable is True. - :type constraint: None or a callable that takes the coordinates of - the current cursor position in the plot as input - and that returns the filtered coordinates. - :return: The key string identify this marker - """ - if kw: - _logger.warning( - 'addYMarker deprecated extra parameters: %s', str(kw)) - - return self._addMarker(x=None, y=y, legend=legend, - text=text, color=color, - selectable=selectable, draggable=draggable, - symbol=None, constraint=constraint) - - def addMarker(self, x, y, legend=None, - text=None, - color=None, - selectable=False, - draggable=False, - symbol='+', - constraint=None, - **kw): - """Add a point marker to the plot. - - Markers are uniquely identified by their legend. - As opposed to curves, images and items, two calls to - :meth:`addMarker` without legend argument adds two markers with - different identifying legends. - - :param float x: Position of the marker on the X axis in data - coordinates - :param float y: Position of the marker on the Y axis in data - coordinates - :param str legend: Legend associated to the marker to identify it - :param str text: Text to display next to the marker - :param str color: Color of the marker, e.g., 'blue', 'b', '#FF0000' - (Default: 'black') - :param bool selectable: Indicate if the marker can be selected. - (default: False) - :param bool draggable: Indicate if the marker can be moved. - (default: False) - :param str symbol: Symbol representing the marker in:: - - - 'o' circle - - '.' point - - ',' pixel - - '+' cross (the default) - - 'x' x-cross - - 'd' diamond - - 's' square - - :param constraint: A function filtering marker displacement by - dragging operations or None for no filter. - This function is called each time a marker is - moved. - This parameter is only used if draggable is True. - :type constraint: None or a callable that takes the coordinates of - the current cursor position in the plot as input - and that returns the filtered coordinates. - :return: The key string identify this marker - """ - if kw: - _logger.warning( - 'addMarker deprecated extra parameters: %s', str(kw)) - - if x is None: - xmin, xmax = self.getGraphXLimits() - x = 0.5 * (xmax + xmin) - - if y is None: - ymin, ymax = self.getGraphYLimits() - y = 0.5 * (ymax + ymin) - - return self._addMarker(x=x, y=y, legend=legend, - text=text, color=color, - selectable=selectable, draggable=draggable, - symbol=symbol, constraint=constraint) - - def _addMarker(self, x, y, legend, - text, color, - selectable, draggable, - symbol, constraint): - """Common method for adding point, vline and hline marker. - - See :meth:`addMarker` for argument documentation. - """ - assert (x, y) != (None, None) - - if legend is None: # Find an unused legend - markerLegends = self._getAllMarkers(just_legend=True) - for index in itertools.count(): - legend = "Unnamed Marker %d" % index - if legend not in markerLegends: - break # Keep this legend - legend = str(legend) - - if x is None: - markerClass = items.YMarker - elif y is None: - markerClass = items.XMarker - else: - markerClass = items.Marker - - # Create/Update marker object - marker = self._getMarker(legend) - if marker is not None and not isinstance(marker, markerClass): - _logger.warning('Adding marker with same legend' - ' but different type replaces it') - self._remove(marker) - marker = None - - if marker is None: - # No previous marker, create one - marker = markerClass() - marker._setLegend(legend) - self._add(marker) - - if text is not None: - marker.setText(text) - if color is not None: - marker.setColor(color) - if selectable is not None: - marker._setSelectable(selectable) - if draggable is not None: - marker._setDraggable(draggable) - if symbol is not None: - marker.setSymbol(symbol) - - # TODO to improve, but this ensure constraint is applied - marker.setPosition(x, y) - if constraint is not None: - marker._setConstraint(constraint) - marker.setPosition(x, y) - - self.notify( - 'contentChanged', action='add', kind='marker', legend=legend) - - return legend - - # Hide - - def isCurveHidden(self, legend): - """Returns True if the curve associated to legend is hidden, else False - - :param str legend: The legend key identifying the curve - :return: True if the associated curve is hidden, False otherwise - """ - curve = self._getItem('curve', legend) - return curve is not None and not curve.isVisible() - - def hideCurve(self, legend, flag=True, replot=None): - """Show/Hide the curve associated to legend. - - Even when hidden, the curve is kept in the list of curves. - - :param str legend: The legend associated to the curve to be hidden - :param bool flag: True (default) to hide the curve, False to show it - """ - if replot is not None: - _logger.warning('hideCurve deprecated replot parameter') - - curve = self._getItem('curve', legend) - if curve is None: - _logger.warning('Curve not in plot: %s', legend) - return - - isVisible = not flag - if isVisible != curve.isVisible(): - curve.setVisible(isVisible) - - # Remove - - ITEM_KINDS = 'curve', 'image', 'scatter', 'item', 'marker', 'histogram' - - def remove(self, legend=None, kind=ITEM_KINDS): - """Remove one or all element(s) of the given legend and kind. - - Examples: - - - ``remove()`` clears the plot - - ``remove(kind='curve')`` removes all curves from the plot - - ``remove('myCurve', kind='curve')`` removes the curve with - legend 'myCurve' from the plot. - - ``remove('myImage, kind='image')`` removes the image with - legend 'myImage' from the plot. - - ``remove('myImage')`` removes elements (for instance curve, image, - item and marker) with legend 'myImage'. - - :param str legend: The legend associated to the element to remove, - or None to remove - :param kind: The kind of elements to remove from the plot. - In: 'all', 'curve', 'image', 'item', 'marker'. - By default, it removes all kind of elements. - :type kind: str or tuple of str to specify multiple kinds. - """ - if kind is 'all': # Replace all by tuple of all kinds - kind = self.ITEM_KINDS - - if kind in self.ITEM_KINDS: # Kind is a str, make it a tuple - kind = (kind,) - - for aKind in kind: - assert aKind in self.ITEM_KINDS - - if legend is None: # This is a clear - # Clear each given kind - for aKind in kind: - for legend in self._getItems( - kind=aKind, just_legend=True, withhidden=True): - self.remove(legend=legend, kind=aKind) - - else: # This is removing a single element - # Remove each given kind - for aKind in kind: - item = self._getItem(aKind, legend) - if item is not None: - if aKind in ('curve', 'image'): - if self._getActiveItem(aKind) == item: - # Reset active item - self._setActiveItem(aKind, None) - - self._remove(item) - - if (aKind == 'curve' and - not self.getAllCurves(just_legend=True, - withhidden=True)): - self._colorIndex = 0 - self._styleIndex = 0 - - self.notify('contentChanged', action='remove', - kind=aKind, legend=legend) - - def removeCurve(self, legend): - """Remove the curve associated to legend from the graph. - - :param str legend: The legend associated to the curve to be deleted - """ - if legend is None: - return - self.remove(legend, kind='curve') - - def removeImage(self, legend): - """Remove the image associated to legend from the graph. - - :param str legend: The legend associated to the image to be deleted - """ - if legend is None: - return - self.remove(legend, kind='image') - - def removeItem(self, legend): - """Remove the item associated to legend from the graph. - - :param str legend: The legend associated to the item to be deleted - """ - if legend is None: - return - self.remove(legend, kind='item') - - def removeMarker(self, legend): - """Remove the marker associated to legend from the graph. - - :param str legend: The legend associated to the marker to be deleted - """ - if legend is None: - return - self.remove(legend, kind='marker') - - # Clear - - def clear(self): - """Remove everything from the plot.""" - self.remove() - - def clearCurves(self): - """Remove all the curves from the plot.""" - self.remove(kind='curve') - - def clearImages(self): - """Remove all the images from the plot.""" - self.remove(kind='image') - - def clearItems(self): - """Remove all the items from the plot. """ - self.remove(kind='item') - - def clearMarkers(self): - """Remove all the markers from the plot.""" - self.remove(kind='marker') - - # Interaction - - def getGraphCursor(self): - """Returns the state of the crosshair cursor. - - See :meth:`setGraphCursor`. - - :return: None if the crosshair cursor is not active, - else a tuple (color, linewidth, linestyle). - """ - return self._cursorConfiguration - - def setGraphCursor(self, flag=False, color='black', - linewidth=1, linestyle='-'): - """Toggle the display of a crosshair cursor and set its attributes. - - :param bool flag: Toggle the display of a crosshair cursor. - The crosshair cursor is hidden by default. - :param color: The color to use for the crosshair. - :type color: A string (either a predefined color name in Colors.py - or "#RRGGBB")) or a 4 columns unsigned byte array - (Default: black). - :param int linewidth: The width of the lines of the crosshair - (Default: 1). - :param str linestyle: Type of line:: - - - ' ' no line - - '-' solid line (the default) - - '--' dashed line - - '-.' dash-dot line - - ':' dotted line - """ - if flag: - self._cursorConfiguration = color, linewidth, linestyle - else: - self._cursorConfiguration = None - - self._backend.setGraphCursor(flag=flag, color=color, - linewidth=linewidth, linestyle=linestyle) - self._setDirtyPlot() - self.notify('setGraphCursor', - state=self._cursorConfiguration is not None) - - def pan(self, direction, factor=0.1): - """Pan the graph in the given direction by the given factor. - - Warning: Pan of right Y axis not implemented! - - :param str direction: One of 'up', 'down', 'left', 'right'. - :param float factor: Proportion of the range used to pan the graph. - Must be strictly positive. - """ - assert direction in ('up', 'down', 'left', 'right') - assert factor > 0. - - if direction in ('left', 'right'): - xFactor = factor if direction == 'right' else - factor - xMin, xMax = self.getGraphXLimits() - - xMin, xMax = _utils.applyPan(xMin, xMax, xFactor, - self.isXAxisLogarithmic()) - self.setGraphXLimits(xMin, xMax) - - else: # direction in ('up', 'down') - sign = -1. if self.isYAxisInverted() else 1. - yFactor = sign * (factor if direction == 'up' else -factor) - yMin, yMax = self.getGraphYLimits() - yIsLog = self.isYAxisLogarithmic() - - yMin, yMax = _utils.applyPan(yMin, yMax, yFactor, yIsLog) - self.setGraphYLimits(yMin, yMax, axis='left') - - y2Min, y2Max = self.getGraphYLimits(axis='right') - - y2Min, y2Max = _utils.applyPan(y2Min, y2Max, yFactor, yIsLog) - self.setGraphYLimits(y2Min, y2Max, axis='right') - - # Active Curve/Image - - def isActiveCurveHandling(self): - """Returns True if active curve selection is enabled.""" - return self._activeCurveHandling - - def setActiveCurveHandling(self, flag=True): - """Enable/Disable active curve selection. - - :param bool flag: True (the default) to enable active curve selection. - """ - if not flag: - self.setActiveCurve(None) # Reset active curve - - self._activeCurveHandling = bool(flag) - - def getActiveCurveColor(self): - """Get the color used to display the currently active curve. - - See :meth:`setActiveCurveColor`. - """ - return self._activeCurveColor - - def setActiveCurveColor(self, color="#000000"): - """Set the color to use to display the currently active curve. - - :param str color: Color of the active curve, - e.g., 'blue', 'b', '#FF0000' (Default: 'black') - """ - if color is None: - color = "black" - if color in self.colorDict: - color = self.colorDict[color] - self._activeCurveColor = color - - def getActiveCurve(self, just_legend=False): - """Return the currently active curve. - - It returns None in case of not having an active curve. - - :param bool just_legend: True to get the legend of the curve, - False (the default) to get the curve data - and info. - :return: Active curve's legend or corresponding - :class:`.items.Curve` - :rtype: str or :class:`.items.Curve` or None - """ - if not self.isActiveCurveHandling(): - return None - - return self._getActiveItem(kind='curve', just_legend=just_legend) - - def setActiveCurve(self, legend, replot=None): - """Make the curve associated to legend the active curve. - - :param legend: The legend associated to the curve - or None to have no active curve. - :type legend: str or None - """ - if replot is not None: - _logger.warning('setActiveCurve deprecated replot parameter') - - if not self.isActiveCurveHandling(): - return - - return self._setActiveItem(kind='curve', legend=legend) - - def getActiveImage(self, just_legend=False): - """Returns the currently active image. - - It returns None in case of not having an active image. - - :param bool just_legend: True to get the legend of the image, - False (the default) to get the image data - and info. - :return: Active image's legend or corresponding image object - :rtype: str, :class:`.items.ImageData`, :class:`.items.ImageRgba` - or None - """ - return self._getActiveItem(kind='image', just_legend=just_legend) - - def setActiveImage(self, legend, replot=None): - """Make the image associated to legend the active image. - - :param str legend: The legend associated to the image - or None to have no active image. - """ - if replot is not None: - _logger.warning('setActiveImage deprecated replot parameter') - - return self._setActiveItem(kind='image', legend=legend) - - def _getActiveItem(self, kind, just_legend=False): - """Return the currently active item of that kind if any - - :param str kind: Type of item: 'curve', 'scatter' or 'image' - :param bool just_legend: True to get the legend, - False (default) to get the item - :return: legend or item or None if no active item - """ - assert kind in ('curve', 'scatter', 'image') - - if self._activeLegend[kind] is None: - return None - - if (self._activeLegend[kind], kind) not in self._content: - self._activeLegend[kind] = None - return None - - if just_legend: - return self._activeLegend[kind] - else: - return self._getItem(kind, self._activeLegend[kind]) - - def _setActiveItem(self, kind, legend): - """Make the curve associated to legend the active curve. - - :param str kind: Type of item: 'curve' or 'image' - :param legend: The legend associated to the curve - or None to have no active curve. - :type legend: str or None - """ - assert kind in ('curve', 'image', 'scatter') - - xLabel = self._defaultLabels['x'] - yLabel = self._defaultLabels['y'] - yRightLabel = self._defaultLabels['yright'] - - oldActiveItem = self._getActiveItem(kind=kind) - - # Curve specific: Reset highlight of previous active curve - if kind == 'curve' and oldActiveItem is not None: - oldActiveItem.setHighlighted(False) - - if legend is None: - self._activeLegend[kind] = None - else: - legend = str(legend) - item = self._getItem(kind, legend) - if item is None: - _logger.warning("This %s does not exist: %s", kind, legend) - self._activeLegend[kind] = None - else: - self._activeLegend[kind] = legend - - # Curve specific: handle highlight - if kind == 'curve': - item.setHighlightedColor(self.getActiveCurveColor()) - item.setHighlighted(True) - - if isinstance(item, items.LabelsMixIn): - if item.getXLabel() is not None: - xLabel = item.getXLabel() - if item.getYLabel() is not None: - if (isinstance(item, items.YAxisMixIn) and - item.getYAxis() == 'right'): - yRightLabel = item.getYLabel() - else: - yLabel = item.getYLabel() - - # Store current labels and update plot - self._currentLabels['x'] = xLabel - self._currentLabels['y'] = yLabel - self._currentLabels['yright'] = yRightLabel - - self._backend.setGraphXLabel(xLabel) - self._backend.setGraphYLabel(yLabel, axis='left') - self._backend.setGraphYLabel(yRightLabel, axis='right') - - self._setDirtyPlot() - - activeLegend = self._activeLegend[kind] - if oldActiveItem is not None or activeLegend is not None: - if oldActiveItem is None: - oldActiveLegend = None - else: - oldActiveLegend = oldActiveItem.getLegend() - self.notify( - 'active' + kind[0].upper() + kind[1:] + 'Changed', - updated=oldActiveLegend != activeLegend, - previous=oldActiveLegend, - legend=activeLegend) - - return activeLegend - - # Getters - - def getAllCurves(self, just_legend=False, withhidden=False): - """Returns all curves legend or info and data. - - It returns an empty list in case of not having any curve. - - If just_legend is False, it returns a list of :class:`items.Curve` - objects describing the curves. - If just_legend is True, it returns a list of curves' legend. - - :param bool just_legend: True to get the legend of the curves, - False (the default) to get the curves' data - and info. - :param bool withhidden: False (default) to skip hidden curves. - :return: list of curves' legend or :class:`.items.Curve` - :rtype: list of str or list of :class:`.items.Curve` - """ - return self._getItems(kind='curve', - just_legend=just_legend, - withhidden=withhidden) - - def getCurve(self, legend=None): - """Get the object describing a specific curve. - - It returns None in case no matching curve is found. - - :param str legend: - The legend identifying the curve. - If not provided or None (the default), the active curve is returned - or if there is no active curve, the latest updated curve that is - not hidden is returned if there are curves in the plot. - :return: None or :class:`.items.Curve` object - """ - return self._getItem(kind='curve', legend=legend) - - def getAllImages(self, just_legend=False): - """Returns all images legend or objects. - - It returns an empty list in case of not having any image. - - If just_legend is False, it returns a list of :class:`items.ImageBase` - objects describing the images. - If just_legend is True, it returns a list of legends. - - :param bool just_legend: True to get the legend of the images, - False (the default) to get the images' - object. - :return: list of images' legend or :class:`.items.ImageBase` - :rtype: list of str or list of :class:`.items.ImageBase` - """ - return self._getItems(kind='image', - just_legend=just_legend, - withhidden=True) - - def getImage(self, legend=None): - """Get the object describing a specific image. - - It returns None in case no matching image is found. - - :param str legend: - The legend identifying the image. - If not provided or None (the default), the active image is returned - or if there is no active image, the latest updated image - is returned if there are images in the plot. - :return: None or :class:`.items.ImageBase` object - """ - return self._getItem(kind='image', legend=legend) - - def getScatter(self, legend=None): - """Get the object describing a specific scatter. - - It returns None in case no matching scatter is found. - - :param str legend: - The legend identifying the scatter. - If not provided or None (the default), the active scatter is - returned or if there is no active scatter, the latest updated - scatter is returned if there are scatters in the plot. - :return: None or :class:`.items.Scatter` object - """ - return self._getItem(kind='scatter', legend=legend) - - def getHistogram(self, legend=None): - """Get the object describing a specific histogram. - - It returns None in case no matching histogram is found. - - :param str legend: - The legend identifying the histogram. - If not provided or None (the default), the latest updated scatter - is returned if there are histograms in the plot. - :return: None or :class:`.items.Histogram` object - """ - return self._getItem(kind='histogram', legend=legend) - - def _getItems(self, kind, just_legend=False, withhidden=False): - """Retrieve all items of a kind in the plot - - :param str kind: Type of item: 'curve' or 'image' - :param bool just_legend: True to get the legend of the curves, - False (the default) to get the curves' data - and info. - :param bool withhidden: False (default) to skip hidden curves. - :return: list of legends or item objects - """ - assert kind in self.ITEM_KINDS - output = [] - for (legend, type_), item in self._content.items(): - if type_ == kind and (withhidden or item.isVisible()): - output.append(legend if just_legend else item) - return output - - def _getItem(self, kind, legend=None): - """Get an item from the plot: either an image or a curve. - - Returns None if no match found - - :param str kind: Type of item: 'curve' or 'image' - :param str legend: Legend of the item or - None to get active or last item - :return: Object describing the item or None - """ - assert kind in self.ITEM_KINDS - - if legend is not None: - return self._content.get((legend, kind), None) - else: - if kind in ('curve', 'image', 'scatter'): - item = self._getActiveItem(kind=kind) - if item is not None: # Return active item if available - return item - # Return last visible item if any - allItems = self._getItems( - kind=kind, just_legend=False, withhidden=False) - return allItems[-1] if allItems else None - - # Limits - - def _notifyLimitsChanged(self): - """Send an event when plot area limits are changed.""" - xRange = self.getGraphXLimits() - yRange = self.getGraphYLimits(axis='left') - y2Range = self.getGraphYLimits(axis='right') - event = PlotEvents.prepareLimitsChangedSignal( - id(self.getWidgetHandle()), xRange, yRange, y2Range) - self.notify(**event) - - def _checkLimits(self, min_, max_, axis): - """Makes sure axis range is not empty - - :param float min_: Min axis value - :param float max_: Max axis value - :param str axis: 'x', 'y' or 'y2' the axis to deal with - :return: (min, max) making sure min < max - :rtype: 2-tuple of float - """ - if max_ < min_: - _logger.info('%s axis: max < min, inverting limits.', axis) - min_, max_ = max_, min_ - elif max_ == min_: - _logger.info('%s axis: max == min, expanding limits.', axis) - if min_ == 0.: - min_, max_ = -0.1, 0.1 - elif min_ < 0: - min_, max_ = min_ * 1.1, min_ * 0.9 - else: # xmin > 0 - min_, max_ = min_ * 0.9, min_ * 1.1 - - return min_, max_ - - def getGraphXLimits(self): - """Get the graph X (bottom) limits. - - :return: Minimum and maximum values of the X axis - """ - return self._backend.getGraphXLimits() - - def setGraphXLimits(self, xmin, xmax, replot=None): - """Set the graph X (bottom) limits. - - :param float xmin: minimum bottom axis value - :param float xmax: maximum bottom axis value - """ - if replot is not None: - _logger.warning('setGraphXLimits deprecated replot parameter') - - xmin, xmax = self._checkLimits(xmin, xmax, axis='x') - - self._backend.setGraphXLimits(xmin, xmax) - self._setDirtyPlot() - - self._notifyLimitsChanged() - - def getGraphYLimits(self, axis='left'): - """Get the graph Y limits. - - :param str axis: The axis for which to get the limits: - Either 'left' or 'right' - :return: Minimum and maximum values of the X axis - """ - assert axis in ('left', 'right') - return self._backend.getGraphYLimits(axis) - - def setGraphYLimits(self, ymin, ymax, axis='left', replot=None): - """Set the graph Y limits. - - :param float ymin: minimum bottom axis value - :param float ymax: maximum bottom axis value - :param str axis: The axis for which to get the limits: - Either 'left' or 'right' - """ - if replot is not None: - _logger.warning('setGraphYLimits deprecated replot parameter') - - assert axis in ('left', 'right') - - ymin, ymax = self._checkLimits(ymin, ymax, - axis='y' if axis == 'left' else 'y2') - - self._backend.setGraphYLimits(ymin, ymax, axis) - self._setDirtyPlot() - - self._notifyLimitsChanged() - - def setLimits(self, xmin, xmax, ymin, ymax, y2min=None, y2max=None): - """Set the limits of the X and Y axes at once. - - If y2min or y2max is None, the right Y axis limits are not updated. - - :param float xmin: minimum bottom axis value - :param float xmax: maximum bottom axis value - :param float ymin: minimum left axis value - :param float ymax: maximum left axis value - :param float y2min: minimum right axis value or None (the default) - :param float y2max: maximum right axis value or None (the default) - """ - # Deal with incorrect values - xmin, xmax = self._checkLimits(xmin, xmax, axis='x') - ymin, ymax = self._checkLimits(ymin, ymax, axis='y') - - if y2min is None or y2max is None: - # if one limit is None, both are ignored - y2min, y2max = None, None - else: - y2min, y2max = self._checkLimits(y2min, y2max, axis='y2') - - self._backend.setLimits(xmin, xmax, ymin, ymax, y2min, y2max) - self._setDirtyPlot() - self._notifyLimitsChanged() - - # Title and labels - - def getGraphTitle(self): - """Return the plot main title as a str.""" - return self._graphTitle - - def setGraphTitle(self, title=""): - """Set the plot main title. - - :param str title: Main title of the plot (default: '') - """ - self._graphTitle = str(title) - self._backend.setGraphTitle(title) - self._setDirtyPlot() - - def getGraphXLabel(self): - """Return the current X axis label as a str.""" - return self._currentLabels['x'] - - def setGraphXLabel(self, label="X"): - """Set the plot X axis label. - - The provided label can be temporarily replaced by the X label of the - active curve if any. - - :param str label: The X axis label (default: 'X') - """ - self._defaultLabels['x'] = label - self._currentLabels['x'] = label - self._backend.setGraphXLabel(label) - self._setDirtyPlot() - - def getGraphYLabel(self, axis='left'): - """Return the current Y axis label as a str. - - :param str axis: The Y axis for which to get the label (left or right) - """ - assert axis in ('left', 'right') - - return self._currentLabels['y' if axis == 'left' else 'yright'] - - def setGraphYLabel(self, label="Y", axis='left'): - """Set the plot Y axis label. - - The provided label can be temporarily replaced by the Y label of the - active curve if any. - - :param str label: The Y axis label (default: 'Y') - :param str axis: The Y axis for which to set the label (left or right) - """ - assert axis in ('left', 'right') - - if axis == 'left': - self._defaultLabels['y'] = label - self._currentLabels['y'] = label - else: - self._defaultLabels['yright'] = label - self._currentLabels['yright'] = label - - self._backend.setGraphYLabel(label, axis=axis) - self._setDirtyPlot() - - # Axes - - def setYAxisInverted(self, flag=True): - """Set the Y axis orientation. - - :param bool flag: True for Y axis going from top to bottom, - False for Y axis going from bottom to top - """ - flag = bool(flag) - self._backend.setYAxisInverted(flag) - self._setDirtyPlot() - self.notify('setYAxisInverted', state=flag) - - def isYAxisInverted(self): - """Return True if Y axis goes from top to bottom, False otherwise.""" - return self._backend.isYAxisInverted() - - def isXAxisLogarithmic(self): - """Return True if X axis scale is logarithmic, False if linear.""" - return self._logX - - def setXAxisLogarithmic(self, flag): - """Set the bottom X axis scale (either linear or logarithmic). - - :param bool flag: True to use a logarithmic scale, False for linear. - """ - if bool(flag) == self._logX: - return - self._logX = bool(flag) - - self._backend.setXAxisLogarithmic(self._logX) - - # TODO hackish way of forcing update of curves and images - for curve in self.getAllCurves(): - curve._updated() - for image in self.getAllImages(): - image._updated() - self._invalidateDataRange() - - self.resetZoom() - self.notify('setXAxisLogarithmic', state=self._logX) - - def isYAxisLogarithmic(self): - """Return True if Y axis scale is logarithmic, False if linear.""" - return self._logY - - def setYAxisLogarithmic(self, flag): - """Set the Y axes scale (either linear or logarithmic). - - :param bool flag: True to use a logarithmic scale, False for linear. - """ - if bool(flag) == self._logY: - return - self._logY = bool(flag) - - self._backend.setYAxisLogarithmic(self._logY) - - # TODO hackish way of forcing update of curves and images - for curve in self.getAllCurves(): - curve._updated() - for image in self.getAllImages(): - image._updated() - self._invalidateDataRange() - - self.resetZoom() - self.notify('setYAxisLogarithmic', state=self._logY) - - def isXAxisAutoScale(self): - """Return True if X axis is automatically adjusting its limits.""" - return self._xAutoScale - - def setXAxisAutoScale(self, flag=True): - """Set the X axis limits adjusting behavior of :meth:`resetZoom`. - - :param bool flag: True to resize limits automatically, - False to disable it. - """ - self._xAutoScale = bool(flag) - self.notify('setXAxisAutoScale', state=self._xAutoScale) - - def isYAxisAutoScale(self): - """Return True if Y axes are automatically adjusting its limits.""" - return self._yAutoScale - - def setYAxisAutoScale(self, flag=True): - """Set the Y axis limits adjusting behavior of :meth:`resetZoom`. - - :param bool flag: True to resize limits automatically, - False to disable it. - """ - self._yAutoScale = bool(flag) - self.notify('setYAxisAutoScale', state=self._yAutoScale) - - def isKeepDataAspectRatio(self): - """Returns whether the plot is keeping data aspect ratio or not.""" - return self._backend.isKeepDataAspectRatio() - - def setKeepDataAspectRatio(self, flag=True): - """Set whether the plot keeps data aspect ratio or not. - - :param bool flag: True to respect data aspect ratio - """ - flag = bool(flag) - self._backend.setKeepDataAspectRatio(flag=flag) - self._setDirtyPlot() - self.resetZoom() - self.notify('setKeepDataAspectRatio', state=flag) - - def getGraphGrid(self): - """Return the current grid mode, either None, 'major' or 'both'. - - See :meth:`setGraphGrid`. - """ - return self._grid - - def setGraphGrid(self, which=True): - """Set the type of grid to display. - - :param which: None or False to disable the grid, - 'major' or True for grid on major ticks (the default), - 'both' for grid on both major and minor ticks. - :type which: str of bool - """ - assert which in (None, True, False, 'both', 'major') - if not which: - which = None - elif which is True: - which = 'major' - self._grid = which - self._backend.setGraphGrid(which) - self._setDirtyPlot() - self.notify('setGraphGrid', which=str(which)) - - # Defaults - - def isDefaultPlotPoints(self): - """Return True if default Curve symbol is 'o', False for no symbol.""" - return self._defaultPlotPoints == 'o' - - def setDefaultPlotPoints(self, flag): - """Set the default symbol of all curves. - - When called, this reset the symbol of all existing curves. - - :param bool flag: True to use 'o' as the default curve symbol, - False to use no symbol. - """ - self._defaultPlotPoints = 'o' if flag else '' - - # Reset symbol of all curves - curves = self.getAllCurves(just_legend=False, withhidden=True) - - if curves: - for curve in curves: - curve.setSymbol(self._defaultPlotPoints) - - def isDefaultPlotLines(self): - """Return True for line as default line style, False for no line.""" - return self._plotLines - - def setDefaultPlotLines(self, flag): - """Toggle the use of lines as the default curve line style. - - :param bool flag: True to use a line as the default line style, - False to use no line as the default line style. - """ - self._plotLines = bool(flag) - - linestyle = '-' if self._plotLines else ' ' - - # Reset linestyle of all curves - curves = self.getAllCurves(withhidden=True) - - if curves: - for curve in curves: - curve.setLineStyle(linestyle) - - def getDefaultColormap(self): - """Return the default colormap used by :meth:`addImage` as a dict. - - See :mod:`Plot` for the documentation of the colormap dict. - """ - return self._defaultColormap.copy() - - def setDefaultColormap(self, colormap=None): - """Set the default colormap used by :meth:`addImage`. - - Setting the default colormap do not change any currently displayed - image. - It only affects future calls to :meth:`addImage` without the colormap - parameter. - - :param dict colormap: The description of the default colormap, or - None to set the colormap to a linear autoscale - gray colormap. - See :mod:`Plot` for the documentation - of the colormap dict. - """ - if colormap is None: - colormap = {'name': 'gray', 'normalization': 'linear', - 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0} - self._defaultColormap = colormap.copy() - - def getSupportedColormaps(self): - """Get the supported colormap names as a tuple of str. - - The list should at least contain and start by: - ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue') - """ - default = ('gray', 'reversed gray', - 'temperature', - 'red', 'green', 'blue') - if matplotlib_cm is None: - return default - else: - maps = [m for m in matplotlib_cm.datad] - maps.sort() - return default + tuple(maps) - - def _getColorAndStyle(self): - color = self.colorList[self._colorIndex] - style = self._styleList[self._styleIndex] - - # Loop over color and then styles - self._colorIndex += 1 - if self._colorIndex >= len(self.colorList): - self._colorIndex = 0 - self._styleIndex = (self._styleIndex + 1) % len(self._styleList) - - # If color is the one of active curve, take the next one - if color == self.getActiveCurveColor(): - color, style = self._getColorAndStyle() - - if not self._plotLines: - style = ' ' - - return color, style - - # Misc. - - def getWidgetHandle(self): - """Return the widget the plot is displayed in. - - This widget is owned by the backend. - """ - return self._backend.getWidgetHandle() - - def notify(self, event, **kwargs): - """Send an event to the listeners. - - Event are passed to the registered callback as a dict with an 'event' - key for backward compatibility with PyMca. - - :param str event: The type of event - :param kwargs: The information of the event. - """ - eventDict = kwargs.copy() - eventDict['event'] = event - self._callback(eventDict) - - def setCallback(self, callbackFunction=None): - """Attach a listener to the backend. - - Limitation: Only one listener at a time. - - :param callbackFunction: function accepting a dictionary as input - to handle the graph events - If None (default), use a default listener. - """ - # TODO allow multiple listeners, keep a weakref on it - # allow register listener by event type - if callbackFunction is None: - callbackFunction = self.graphCallback - self._callback = callbackFunction - - def graphCallback(self, ddict=None): - """This callback is going to receive all the events from the plot. - - Those events will consist on a dictionary and among the dictionary - keys the key 'event' is mandatory to describe the type of event. - This default implementation only handles setting the active curve. - """ - - if ddict is None: - ddict = {} - _logger.debug("Received dict keys = %s", str(ddict.keys())) - _logger.debug(str(ddict)) - if ddict['event'] in ["legendClicked", "curveClicked"]: - if ddict['button'] == "left": - self.setActiveCurve(ddict['label']) - - def saveGraph(self, filename, fileFormat=None, dpi=None, **kw): - """Save a snapshot of the plot. - - Supported file formats: "png", "svg", "pdf", "ps", "eps", - "tif", "tiff", "jpeg", "jpg". - - :param filename: Destination - :type filename: str, StringIO or BytesIO - :param str fileFormat: String specifying the format - :return: False if cannot save the plot, True otherwise - """ - if kw: - _logger.warning('Extra parameters ignored: %s', str(kw)) - - if fileFormat is None: - if not hasattr(filename, 'lower'): - _logger.warning( - 'saveGraph cancelled, cannot define file format.') - return False - else: - fileFormat = (filename.split(".")[-1]).lower() - - supportedFormats = ("png", "svg", "pdf", "ps", "eps", - "tif", "tiff", "jpeg", "jpg") - - if fileFormat not in supportedFormats: - _logger.warning('Unsupported format %s', fileFormat) - return False - else: - self._backend.saveGraph(filename, - fileFormat=fileFormat, - dpi=dpi) - return True - - def getDataMargins(self): - """Get the default data margin ratios, see :meth:`setDataMargins`. - - :return: The margin ratios for each side (xMin, xMax, yMin, yMax). - :rtype: A 4-tuple of floats. - """ - return self._defaultDataMargins - - def setDataMargins(self, xMinMargin=0., xMaxMargin=0., - yMinMargin=0., yMaxMargin=0.): - """Set the default data margins to use in :meth:`resetZoom`. - - Set the default ratios of margins (as floats) to add around the data - inside the plot area for each side. - """ - self._defaultDataMargins = (xMinMargin, xMaxMargin, - yMinMargin, yMaxMargin) - - def getAutoReplot(self): - """Return True if replot is automatically handled, False otherwise. - - See :meth`setAutoReplot`. - """ - return self._autoreplot - - def setAutoReplot(self, autoreplot=True): - """Set automatic replot mode. - - When enabled, the plot is redrawn automatically when changed. - When disabled, the plot is not redrawn when its content change. - Instead, it :meth:`replot` must be called. - - :param bool autoreplot: True to enable it (default), - False to disable it. - """ - self._autoreplot = bool(autoreplot) - - # If the plot is dirty before enabling autoreplot, - # then _backend.postRedisplay will never be called from _setDirtyPlot - if self._autoreplot and self._getDirtyPlot(): - self._backend.postRedisplay() - - def replot(self): - """Redraw the plot immediately.""" - for item in self._contentToUpdate: - item._update(self._backend) - self._contentToUpdate.clear() - self._backend.replot() - self._dirty = False # reset dirty flag - - def resetZoom(self, dataMargins=None): - """Reset the plot limits to the bounds of the data and redraw the plot. - - It automatically scale limits of axes that are in autoscale mode - (See :meth:`setXAxisAutoScale`, :meth:`setYAxisAutoScale`). - It keeps current limits on axes that are not in autoscale mode. - - Extra margins can be added around the data inside the plot area. - Margins are given as one ratio of the data range per limit of the - data (xMin, xMax, yMin and yMax limits). - For log scale, extra margins are applied in log10 of the data. - - :param dataMargins: Ratios of margins to add around the data inside - the plot area for each side (Default: no margins). - :type dataMargins: A 4-tuple of float as (xMin, xMax, yMin, yMax). - """ - if dataMargins is None: - dataMargins = self._defaultDataMargins - - xLimits = self.getGraphXLimits() - yLimits = self.getGraphYLimits(axis='left') - y2Limits = self.getGraphYLimits(axis='right') - - xAuto = self.isXAxisAutoScale() - yAuto = self.isYAxisAutoScale() - - if not xAuto and not yAuto: - _logger.debug("Nothing to autoscale") - else: # Some axes to autoscale - - # Get data range - ranges = self.getDataRange() - xmin, xmax = (1., 100.) if ranges.x is None else ranges.x - ymin, ymax = (1., 100.) if ranges.y is None else ranges.y - if ranges.yright is None: - ymin2, ymax2 = None, None - else: - ymin2, ymax2 = ranges.yright - - # Add margins around data inside the plot area - newLimits = list(_utils.addMarginsToLimits( - dataMargins, - self.isXAxisLogarithmic(), - self.isYAxisLogarithmic(), - xmin, xmax, ymin, ymax, ymin2, ymax2)) - - if self.isKeepDataAspectRatio(): - # Use limits with margins to keep ratio - xmin, xmax, ymin, ymax = newLimits[:4] - - # Compute bbox wth figure aspect ratio - plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:] - plotRatio = plotHeight / plotWidth - - if plotRatio > 0.: - dataRatio = (ymax - ymin) / (xmax - xmin) - if dataRatio < plotRatio: - # Increase y range - ycenter = 0.5 * (ymax + ymin) - yrange = (xmax - xmin) * plotRatio - newLimits[2] = ycenter - 0.5 * yrange - newLimits[3] = ycenter + 0.5 * yrange - - elif dataRatio > plotRatio: - # Increase x range - xcenter = 0.5 * (xmax + xmin) - xrange_ = (ymax - ymin) / plotRatio - newLimits[0] = xcenter - 0.5 * xrange_ - newLimits[1] = xcenter + 0.5 * xrange_ - - self.setLimits(*newLimits) - - if not xAuto and yAuto: - self.setGraphXLimits(*xLimits) - elif xAuto and not yAuto: - if y2Limits is not None: - self.setGraphYLimits( - y2Limits[0], y2Limits[1], axis='right') - if yLimits is not None: - self.setGraphYLimits(yLimits[0], yLimits[1], axis='left') - - self._setDirtyPlot() - - if (xLimits != self.getGraphXLimits() or - yLimits != self.getGraphYLimits(axis='left') or - y2Limits != self.getGraphYLimits(axis='right')): - self._notifyLimitsChanged() - - # Coord conversion - - def dataToPixel(self, x=None, y=None, axis="left", check=True): - """Convert a position in data coordinates to a position in pixels. - - :param float x: The X coordinate in data space. If None (default) - the middle position of the displayed data is used. - :param float y: The Y coordinate in data space. If None (default) - the middle position of the displayed data is used. - :param str axis: The Y axis to use for the conversion - ('left' or 'right'). - :param bool check: True to return None if outside displayed area, - False to convert to pixels anyway - :returns: The corresponding position in pixels or - None if the data position is not in the displayed area and - check is True. - :rtype: A tuple of 2 floats: (xPixel, yPixel) or None. - """ - assert axis in ("left", "right") - - xmin, xmax = self.getGraphXLimits() - ymin, ymax = self.getGraphYLimits(axis=axis) - - if x is None: - x = 0.5 * (xmax + xmin) - if y is None: - y = 0.5 * (ymax + ymin) - - if check: - if x > xmax or x < xmin: - return None - - if y > ymax or y < ymin: - return None - - return self._backend.dataToPixel(x, y, axis=axis) - - def pixelToData(self, x, y, axis="left", check=False): - """Convert a position in pixels to a position in data coordinates. - - :param float x: The X coordinate in pixels. If None (default) - the center of the widget is used. - :param float y: The Y coordinate in pixels. If None (default) - the center of the widget is used. - :param str axis: The Y axis to use for the conversion - ('left' or 'right'). - :param bool check: Toggle checking if pixel is in plot area. - If False, this method never returns None. - :returns: The corresponding position in data space or - None if the pixel position is not in the plot area. - :rtype: A tuple of 2 floats: (xData, yData) or None. - """ - assert axis in ("left", "right") - return self._backend.pixelToData(x, y, axis=axis, check=check) - - def getPlotBoundsInPixels(self): - """Plot area bounds in widget coordinates in pixels. - - :return: bounds as a 4-tuple of int: (left, top, width, height) - """ - return self._backend.getPlotBoundsInPixels() - - # Interaction support - - def setGraphCursorShape(self, cursor=None): - """Set the cursor shape. - - :param str cursor: Name of the cursor shape - """ - self._backend.setGraphCursorShape(cursor) - - def _pickMarker(self, x, y, test=None): - """Pick a marker at the given position. - - To use for interaction implementation. - - :param float x: X position in pixels. - :param float y: Y position in pixels. - :param test: A callable to call for each picked marker to filter - picked markers. If None (default), do not filter markers. - """ - if test is None: - def test(mark): - return True - - markers = self._backend.pickItems(x, y) - legends = [m['legend'] for m in markers if m['kind'] == 'marker'] - - for legend in reversed(legends): - marker = self._getMarker(legend) - if marker is not None and test(marker): - return marker - return None - - def _getAllMarkers(self, just_legend=False): - """Returns all markers' legend or objects - - :param bool just_legend: True to get the legend of the markers, - False (the default) to get marker objects. - :return: list of legend of list of marker objects - :rtype: list of str or list of marker objects - """ - return self._getItems( - kind='marker', just_legend=just_legend, withhidden=True) - - def _getMarker(self, legend=None): - """Get the object describing a specific marker. - - It returns None in case no matching marker is found - - :param str legend: The legend of the marker to retrieve - :rtype: None of marker object - """ - return self._getItem(kind='marker', legend=legend) - - def _pickImageOrCurve(self, x, y, test=None): - """Pick an image or a curve at the given position. - - To use for interaction implementation. - - :param float x: X position in pixelsparam float y: Y position in pixels - :param test: A callable to call for each picked item to filter - picked items. If None (default), do not filter items. - """ - if test is None: - def test(i): - return True - - allItems = self._backend.pickItems(x, y) - allItems = [item for item in allItems - if item['kind'] in ['curve', 'image']] - - for item in reversed(allItems): - kind, legend = item['kind'], item['legend'] - if kind == 'curve': - curve = self.getCurve(legend) - if curve is not None and test(curve): - return kind, curve, item['xdata'], item['ydata'] - - elif kind == 'image': - image = self.getImage(legend) - if image is not None and test(image): - return kind, image, None - - else: - _logger.warning('Unsupported kind: %s', kind) - - return None - - # User event handling # - - def _isPositionInPlotArea(self, x, y): - """Project position in pixel to the closest point in the plot area - - :param float x: X coordinate in widget coordinate (in pixel) - :param float y: Y coordinate in widget coordinate (in pixel) - :return: (x, y) in widget coord (in pixel) in the plot area - """ - left, top, width, height = self.getPlotBoundsInPixels() - xPlot = numpy.clip(x, left, left + width) - yPlot = numpy.clip(y, top, top + height) - return xPlot, yPlot - - def onMousePress(self, xPixel, yPixel, btn): - """Handle mouse press event. - - :param float xPixel: X mouse position in pixels - :param float yPixel: Y mouse position in pixels - :param str btn: Mouse button in 'left', 'middle', 'right' - """ - if self._isPositionInPlotArea(xPixel, yPixel) == (xPixel, yPixel): - self._pressedButtons.append(btn) - self._eventHandler.handleEvent('press', xPixel, yPixel, btn) - - def onMouseMove(self, xPixel, yPixel): - """Handle mouse move event. - - :param float xPixel: X mouse position in pixels - :param float yPixel: Y mouse position in pixels - """ - inXPixel, inYPixel = self._isPositionInPlotArea(xPixel, yPixel) - isCursorInPlot = inXPixel == xPixel and inYPixel == yPixel - - if self._cursorInPlot != isCursorInPlot: - self._cursorInPlot = isCursorInPlot - self._eventHandler.handleEvent( - 'enter' if self._cursorInPlot else 'leave') - - if isCursorInPlot: - # Signal mouse move event - dataPos = self.pixelToData(inXPixel, inYPixel) - assert dataPos is not None - - btn = self._pressedButtons[-1] if self._pressedButtons else None - event = PlotEvents.prepareMouseSignal( - 'mouseMoved', btn, dataPos[0], dataPos[1], xPixel, yPixel) - self.notify(**event) - - # Either button was pressed in the plot or cursor is in the plot - if isCursorInPlot or self._pressedButtons: - self._eventHandler.handleEvent('move', inXPixel, inYPixel) - - def onMouseRelease(self, xPixel, yPixel, btn): - """Handle mouse release event. - - :param float xPixel: X mouse position in pixels - :param float yPixel: Y mouse position in pixels - :param str btn: Mouse button in 'left', 'middle', 'right' - """ - try: - self._pressedButtons.remove(btn) - except ValueError: - pass - else: - xPixel, yPixel = self._isPositionInPlotArea(xPixel, yPixel) - self._eventHandler.handleEvent('release', xPixel, yPixel, btn) - - def onMouseWheel(self, xPixel, yPixel, angleInDegrees): - """Handle mouse wheel event. - - :param float xPixel: X mouse position in pixels - :param float yPixel: Y mouse position in pixels - :param float angleInDegrees: Angle corresponding to wheel motion. - Positive for movement away from the user, - negative for movement toward the user. - """ - if self._isPositionInPlotArea(xPixel, yPixel) == (xPixel, yPixel): - self._eventHandler.handleEvent( - 'wheel', xPixel, yPixel, angleInDegrees) - - def onMouseLeaveWidget(self): - """Handle mouse leave widget event.""" - if self._cursorInPlot: - self._cursorInPlot = False - self._eventHandler.handleEvent('leave') - - # Interaction modes # - - def getInteractiveMode(self): - """Returns the current interactive mode as a dict. - - The returned dict contains at least the key 'mode'. - Mode can be: 'draw', 'pan', 'select', 'zoom'. - It can also contains extra keys (e.g., 'color') specific to a mode - as provided to :meth:`setInteractiveMode`. - """ - return self._eventHandler.getInteractiveMode() - - def setInteractiveMode(self, mode, color='black', - shape='polygon', label=None, - zoomOnWheel=True, source=None, width=None): - """Switch the interactive mode. - - :param str mode: The name of the interactive mode. - In 'draw', 'pan', 'select', 'zoom'. - :param color: Only for 'draw' and 'zoom' modes. - Color to use for drawing selection area. Default black. - :type color: Color description: The name as a str or - a tuple of 4 floats. - :param str shape: Only for 'draw' mode. The kind of shape to draw. - In 'polygon', 'rectangle', 'line', 'vline', 'hline', - 'freeline'. - Default is 'polygon'. - :param str label: Only for 'draw' mode, sent in drawing events. - :param bool zoomOnWheel: Toggle zoom on wheel support - :param source: A user-defined object (typically the caller object) - that will be send in the interactiveModeChanged event, - to identify which object required a mode change. - Default: None - :param float width: Width of the pencil. Only for draw pencil mode. - """ - self._eventHandler.setInteractiveMode(mode, color, shape, label, width) - self._eventHandler.zoomOnWheel = zoomOnWheel - - self.notify( - 'interactiveModeChanged', source=source) - - # Deprecated # - - def isDrawModeEnabled(self): - """Deprecated, use :meth:`getInteractiveMode` instead. - - Return True if the current interactive state is drawing.""" - _logger.warning( - 'isDrawModeEnabled deprecated, use getInteractiveMode instead') - return self.getInteractiveMode()['mode'] == 'draw' - - def setDrawModeEnabled(self, flag=True, shape='polygon', label=None, - color=None, **kwargs): - """Deprecated, use :meth:`setInteractiveMode` instead. - - Set the drawing mode if flag is True and its parameters. - - If flag is False, only item selection is enabled. - - Warning: Zoom and drawing are not compatible and cannot be enabled - simultaneously. - - :param bool flag: True to enable drawing and disable zoom and select. - :param str shape: Type of item to be drawn in: - hline, vline, rectangle, polygon (default) - :param str label: Associated text for identifying draw signals - :param color: The color to use to draw the selection area - :type color: string ("#RRGGBB") or 4 column unsigned byte array or - one of the predefined color names defined in Colors.py - """ - _logger.warning( - 'setDrawModeEnabled deprecated, use setInteractiveMode instead') - - if kwargs: - _logger.warning('setDrawModeEnabled ignores additional parameters') - - if color is None: - color = 'black' - - if flag: - self.setInteractiveMode('draw', shape=shape, - label=label, color=color) - elif self.getInteractiveMode()['mode'] == 'draw': - self.setInteractiveMode('select') - - def getDrawMode(self): - """Deprecated, use :meth:`getInteractiveMode` instead. - - Return the draw mode parameters as a dict of None. - - It returns None if the interactive mode is not a drawing mode, - otherwise, it returns a dict containing the drawing mode parameters - as provided to :meth:`setDrawModeEnabled`. - """ - _logger.warning( - 'getDrawMode deprecated, use getInteractiveMode instead') - mode = self.getInteractiveMode() - return mode if mode['mode'] == 'draw' else None - - def isZoomModeEnabled(self): - """Deprecated, use :meth:`getInteractiveMode` instead. - - Return True if the current interactive state is zooming.""" - _logger.warning( - 'isZoomModeEnabled deprecated, use getInteractiveMode instead') - return self.getInteractiveMode()['mode'] == 'zoom' - - def setZoomModeEnabled(self, flag=True, color=None): - """Deprecated, use :meth:`setInteractiveMode` instead. - - Set the zoom mode if flag is True, else item selection is enabled. - - Warning: Zoom and drawing are not compatible and cannot be enabled - simultaneously - - :param bool flag: If True, enable zoom and select mode. - :param color: The color to use to draw the selection area. - (Default: 'black') - :param color: The color to use to draw the selection area - :type color: string ("#RRGGBB") or 4 column unsigned byte array or - one of the predefined color names defined in Colors.py - """ - _logger.warning( - 'setZoomModeEnabled deprecated, use setInteractiveMode instead') - if color is None: - color = 'black' - - if flag: - self.setInteractiveMode('zoom', color=color) - elif self.getInteractiveMode()['mode'] == 'zoom': - self.setInteractiveMode('select') - - def insertMarker(self, *args, **kwargs): - """Deprecated, use :meth:`addMarker` instead.""" - _logger.warning( - 'insertMarker deprecated, use addMarker instead.') - return self.addMarker(*args, **kwargs) - - def insertXMarker(self, *args, **kwargs): - """Deprecated, use :meth:`addXMarker` instead.""" - _logger.warning( - 'insertXMarker deprecated, use addXMarker instead.') - return self.addXMarker(*args, **kwargs) - - def insertYMarker(self, *args, **kwargs): - """Deprecated, use :meth:`addYMarker` instead.""" - _logger.warning( - 'insertYMarker deprecated, use addYMarker instead.') - return self.addYMarker(*args, **kwargs) - - def isActiveCurveHandlingEnabled(self): - """Deprecated, use :meth:`isActiveCurveHandling` instead.""" - _logger.warning( - 'isActiveCurveHandlingEnabled deprecated, ' - 'use isActiveCurveHandling instead.') - return self.isActiveCurveHandling() - - def enableActiveCurveHandling(self, *args, **kwargs): - """Deprecated, use :meth:`setActiveCurveHandling` instead.""" - _logger.warning( - 'enableActiveCurveHandling deprecated, ' - 'use setActiveCurveHandling instead.') - return self.setActiveCurveHandling(*args, **kwargs) - - def invertYAxis(self, *args, **kwargs): - """Deprecated, use :meth:`setYAxisInverted` instead.""" - _logger.warning('invertYAxis deprecated, ' - 'use setYAxisInverted instead.') - return self.setYAxisInverted(*args, **kwargs) - - def showGrid(self, flag=True): - """Deprecated, use :meth:`setGraphGrid` instead.""" - _logger.warning("showGrid deprecated, use setGraphGrid instead") - if flag in (0, False): - flag = None - elif flag in (1, True): - flag = 'major' - else: - flag = 'both' - return self.setGraphGrid(flag) - - def keepDataAspectRatio(self, *args, **kwargs): - """Deprecated, use :meth:`setKeepDataAspectRatio`.""" - _logger.warning('keepDataAspectRatio deprecated,' - 'use setKeepDataAspectRatio instead') - return self.setKeepDataAspectRatio(*args, **kwargs) diff --git a/silx/gui/plot/PlotActions.py b/silx/gui/plot/PlotActions.py index aad27d2..dd16221 100644 --- a/silx/gui/plot/PlotActions.py +++ b/silx/gui/plot/PlotActions.py @@ -22,1365 +22,46 @@ # THE SOFTWARE. # # ###########################################################################*/ -"""This module provides a set of QAction to use with :class:`.PlotWidget`. +"""Depracted module linking old PlotAction with the actions.xxx""" -The following QAction are available: -- :class:`ColormapAction` -- :class:`CopyAction` -- :class:`CrosshairAction` -- :class:`CurveStyleAction` -- :class:`FitAction` -- :class:`GridAction` -- :class:`KeepAspectRatioAction` -- :class:`PanWithArrowKeysAction` -- :class:`PrintAction` -- :class:`ResetZoomAction` -- :class:`SaveAction` -- :class:`XAxisLogarithmicAction` -- :class:`XAxisAutoScaleAction` -- :class:`YAxisInvertedAction` -- :class:`YAxisLogarithmicAction` -- :class:`YAxisAutoScaleAction` -- :class:`ZoomInAction` -- :class:`ZoomOutAction` -""" - -from __future__ import division - - -__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] +__author__ = ["V.A. Sole", "T. Vincent"] __license__ = "MIT" -__date__ = "20/04/2017" - - -from collections import OrderedDict -import logging -import sys -import traceback -import weakref - -if sys.version_info[0] == 3: - from io import BytesIO -else: - import cStringIO as _StringIO - BytesIO = _StringIO.StringIO - -import numpy - -from .. import icons -from .. import qt -from .._utils import convertArrayToQImage -from . import Colors, items -from .ColormapDialog import ColormapDialog -from ._utils import applyZoomToPlot as _applyZoomToPlot -from silx.third_party.EdfFile import EdfFile -from silx.third_party.TiffIO import TiffIO -from silx.math.histogram import Histogramnd -from silx.math.medianfilter import medfilt2d -from silx.gui.widgets.MedianFilterDialog import MedianFilterDialog - -from silx.io.utils import save1D, savespec - - -_logger = logging.getLogger(__name__) - - -class PlotAction(qt.QAction): - """Base class for QAction that operates on a PlotWidget. - - :param plot: :class:`.PlotWidget` instance on which to operate. - :param icon: QIcon or str name of icon to use - :param str text: The name of this action to be used for menu label - :param str tooltip: The text of the tooltip - :param triggered: The callback to connect to the action's triggered - signal or None for no callback. - :param bool checkable: True for checkable action, False otherwise (default) - :param parent: See :class:`QAction`. - """ - - def __init__(self, plot, icon, text, tooltip=None, - triggered=None, checkable=False, parent=None): - assert plot is not None - self._plotRef = weakref.ref(plot) - - if not isinstance(icon, qt.QIcon): - # Try with icon as a string and load corresponding icon - icon = icons.getQIcon(icon) - - super(PlotAction, self).__init__(icon, text, parent) - - if tooltip is not None: - self.setToolTip(tooltip) - - self.setCheckable(checkable) - - if triggered is not None: - self.triggered[bool].connect(triggered) - - @property - def plot(self): - """The :class:`.PlotWidget` this action group is controlling.""" - return self._plotRef() - - -class ResetZoomAction(PlotAction): - """QAction controlling reset zoom on a :class:`.PlotWidget`. - - :param plot: :class:`.PlotWidget` instance on which to operate - :param parent: See :class:`QAction` - """ - - def __init__(self, plot, parent=None): - super(ResetZoomAction, self).__init__( - plot, icon='zoom-original', text='Reset Zoom', - tooltip='Auto-scale the graph', - triggered=self._actionTriggered, - checkable=False, parent=parent) - self._autoscaleChanged(True) - plot.sigSetXAxisAutoScale.connect(self._autoscaleChanged) - plot.sigSetYAxisAutoScale.connect(self._autoscaleChanged) - - def _autoscaleChanged(self, enabled): - self.setEnabled( - self.plot.isXAxisAutoScale() or self.plot.isYAxisAutoScale()) - - if self.plot.isXAxisAutoScale() and self.plot.isYAxisAutoScale(): - tooltip = 'Auto-scale the graph' - elif self.plot.isXAxisAutoScale(): # And not Y axis - tooltip = 'Auto-scale the x-axis of the graph only' - elif self.plot.isYAxisAutoScale(): # And not X axis - tooltip = 'Auto-scale the y-axis of the graph only' - else: # no axis in autoscale - tooltip = 'Auto-scale the graph' - self.setToolTip(tooltip) - - def _actionTriggered(self, checked=False): - self.plot.resetZoom() - - -class ZoomInAction(PlotAction): - """QAction performing a zoom-in on a :class:`.PlotWidget`. - - :param plot: :class:`.PlotWidget` instance on which to operate - :param parent: See :class:`QAction` - """ - - def __init__(self, plot, parent=None): - super(ZoomInAction, self).__init__( - plot, icon='zoom-in', text='Zoom In', - tooltip='Zoom in the plot', - triggered=self._actionTriggered, - checkable=False, parent=parent) - self.setShortcut(qt.QKeySequence.ZoomIn) - self.setShortcutContext(qt.Qt.WidgetShortcut) - - def _actionTriggered(self, checked=False): - _applyZoomToPlot(self.plot, 1.1) - - -class ZoomOutAction(PlotAction): - """QAction performing a zoom-out on a :class:`.PlotWidget`. - - :param plot: :class:`.PlotWidget` instance on which to operate - :param parent: See :class:`QAction` - """ - - def __init__(self, plot, parent=None): - super(ZoomOutAction, self).__init__( - plot, icon='zoom-out', text='Zoom Out', - tooltip='Zoom out the plot', - triggered=self._actionTriggered, - checkable=False, parent=parent) - self.setShortcut(qt.QKeySequence.ZoomOut) - self.setShortcutContext(qt.Qt.WidgetShortcut) - - def _actionTriggered(self, checked=False): - _applyZoomToPlot(self.plot, 1. / 1.1) - - -class XAxisAutoScaleAction(PlotAction): - """QAction controlling X axis autoscale on a :class:`.PlotWidget`. - - :param plot: :class:`.PlotWidget` instance on which to operate - :param parent: See :class:`QAction` - """ - - def __init__(self, plot, parent=None): - super(XAxisAutoScaleAction, self).__init__( - plot, icon='plot-xauto', text='X Autoscale', - tooltip='Enable x-axis auto-scale when checked.\n' - 'If unchecked, x-axis does not change when reseting zoom.', - triggered=self._actionTriggered, - checkable=True, parent=parent) - self.setChecked(plot.isXAxisAutoScale()) - plot.sigSetXAxisAutoScale.connect(self.setChecked) - - def _actionTriggered(self, checked=False): - self.plot.setXAxisAutoScale(checked) - if checked: - self.plot.resetZoom() - - -class YAxisAutoScaleAction(PlotAction): - """QAction controlling Y axis autoscale on a :class:`.PlotWidget`. - - :param plot: :class:`.PlotWidget` instance on which to operate - :param parent: See :class:`QAction` - """ - - def __init__(self, plot, parent=None): - super(YAxisAutoScaleAction, self).__init__( - plot, icon='plot-yauto', text='Y Autoscale', - tooltip='Enable y-axis auto-scale when checked.\n' - 'If unchecked, y-axis does not change when reseting zoom.', - triggered=self._actionTriggered, - checkable=True, parent=parent) - self.setChecked(plot.isXAxisAutoScale()) - plot.sigSetYAxisAutoScale.connect(self.setChecked) - - def _actionTriggered(self, checked=False): - self.plot.setYAxisAutoScale(checked) - if checked: - self.plot.resetZoom() - - -class XAxisLogarithmicAction(PlotAction): - """QAction controlling X axis log scale on a :class:`.PlotWidget`. - - :param plot: :class:`.PlotWidget` instance on which to operate - :param parent: See :class:`QAction` - """ - - def __init__(self, plot, parent=None): - super(XAxisLogarithmicAction, self).__init__( - plot, icon='plot-xlog', text='X Log. scale', - tooltip='Logarithmic x-axis when checked', - triggered=self._actionTriggered, - checkable=True, parent=parent) - self.setChecked(plot.isXAxisLogarithmic()) - plot.sigSetXAxisLogarithmic.connect(self.setChecked) - - def _actionTriggered(self, checked=False): - self.plot.setXAxisLogarithmic(checked) - - -class YAxisLogarithmicAction(PlotAction): - """QAction controlling Y axis log scale on a :class:`.PlotWidget`. - - :param plot: :class:`.PlotWidget` instance on which to operate - :param parent: See :class:`QAction` - """ - - def __init__(self, plot, parent=None): - super(YAxisLogarithmicAction, self).__init__( - plot, icon='plot-ylog', text='Y Log. scale', - tooltip='Logarithmic y-axis when checked', - triggered=self._actionTriggered, - checkable=True, parent=parent) - self.setChecked(plot.isYAxisLogarithmic()) - plot.sigSetYAxisLogarithmic.connect(self.setChecked) - - def _actionTriggered(self, checked=False): - self.plot.setYAxisLogarithmic(checked) - - -class GridAction(PlotAction): - """QAction controlling grid mode on a :class:`.PlotWidget`. - - :param plot: :class:`.PlotWidget` instance on which to operate - :param str gridMode: The grid mode to use in 'both', 'major'. - See :meth:`.PlotWidget.setGraphGrid` - :param parent: See :class:`QAction` - """ - - def __init__(self, plot, gridMode='both', parent=None): - assert gridMode in ('both', 'major') - self._gridMode = gridMode - - super(GridAction, self).__init__( - plot, icon='plot-grid', text='Grid', - tooltip='Toggle grid (on/off)', - triggered=self._actionTriggered, - checkable=True, parent=parent) - self.setChecked(plot.getGraphGrid() is not None) - plot.sigSetGraphGrid.connect(self._gridChanged) - - def _gridChanged(self, which): - """Slot listening for PlotWidget grid mode change.""" - self.setChecked(which != 'None') - - def _actionTriggered(self, checked=False): - self.plot.setGraphGrid(self._gridMode if checked else None) - - -class CurveStyleAction(PlotAction): - """QAction controlling curve style on a :class:`.PlotWidget`. - - It changes the default line and markers style which updates all - curves on the plot. - - :param plot: :class:`.PlotWidget` instance on which to operate - :param parent: See :class:`QAction` - """ - - def __init__(self, plot, parent=None): - super(CurveStyleAction, self).__init__( - plot, icon='plot-toggle-points', text='Curve style', - tooltip='Change curve line and markers style', - triggered=self._actionTriggered, - checkable=False, parent=parent) - - def _actionTriggered(self, checked=False): - currentState = (self.plot.isDefaultPlotLines(), - self.plot.isDefaultPlotPoints()) - - # line only, line and symbol, symbol only - states = (True, False), (True, True), (False, True) - newState = states[(states.index(currentState) + 1) % 3] - - self.plot.setDefaultPlotLines(newState[0]) - self.plot.setDefaultPlotPoints(newState[1]) - - -class ColormapAction(PlotAction): - """QAction opening a ColormapDialog to update the colormap. - - Both the active image colormap and the default colormap are updated. - - :param plot: :class:`.PlotWidget` instance on which to operate - :param parent: See :class:`QAction` - """ - def __init__(self, plot, parent=None): - self._dialog = None # To store an instance of ColormapDialog - super(ColormapAction, self).__init__( - plot, icon='colormap', text='Colormap', - tooltip="Change colormap", - triggered=self._actionTriggered, - checkable=False, parent=parent) - - def _actionTriggered(self, checked=False): - """Create a cmap dialog and update active image and default cmap.""" - # Create the dialog if not already existing - if self._dialog is None: - self._dialog = ColormapDialog() - - image = self.plot.getActiveImage() - if not isinstance(image, items.ColormapMixIn): - # No active image or active image is RGBA, - # set dialog from default info - colormap = self.plot.getDefaultColormap() - - self._dialog.setHistogram() # Reset histogram and range if any - - else: - # Set dialog from active image - colormap = image.getColormap() - - data = image.getData(copy=False) - - goodData = data[numpy.isfinite(data)] - if goodData.size > 0: - dataMin = goodData.min() - dataMax = goodData.max() - else: - qt.QMessageBox.warning( - self, "No Data", - "Image data does not contain any real value") - dataMin, dataMax = 1., 10. - - self._dialog.setHistogram() # Reset histogram if any - self._dialog.setDataRange(dataMin, dataMax) - # The histogram should be done in a worker thread - # hist, bin_edges = numpy.histogram(goodData, bins=256) - # self._dialog.setHistogram(hist, bin_edges) - - self._dialog.setColormap(**colormap) - - # Run the dialog listening to colormap change - self._dialog.sigColormapChanged.connect(self._colormapChanged) - result = self._dialog.exec_() - self._dialog.sigColormapChanged.disconnect(self._colormapChanged) - - if not result: # Restore the previous colormap - self._colormapChanged(colormap) - - def _colormapChanged(self, colormap): - # Update default colormap - self.plot.setDefaultColormap(colormap) - - # Update active image colormap - activeImage = self.plot.getActiveImage() - if isinstance(activeImage, items.ColormapMixIn): - activeImage.setColormap(colormap) - - -class KeepAspectRatioAction(PlotAction): - """QAction controlling aspect ratio on a :class:`.PlotWidget`. - :param plot: :class:`.PlotWidget` instance on which to operate - :param parent: See :class:`QAction` - """ - - def __init__(self, plot, parent=None): - # Uses two images for checked/unchecked states - self._states = { - False: (icons.getQIcon('shape-circle-solid'), - "Keep data aspect ratio"), - True: (icons.getQIcon('shape-ellipse-solid'), - "Do no keep data aspect ratio") - } - - icon, tooltip = self._states[plot.isKeepDataAspectRatio()] - super(KeepAspectRatioAction, self).__init__( - plot, - icon=icon, - text='Toggle keep aspect ratio', - tooltip=tooltip, - triggered=self._actionTriggered, - checkable=False, - parent=parent) - plot.sigSetKeepDataAspectRatio.connect( - self._keepDataAspectRatioChanged) - - def _keepDataAspectRatioChanged(self, aspectRatio): - """Handle Plot set keep aspect ratio signal""" - icon, tooltip = self._states[aspectRatio] - self.setIcon(icon) - self.setToolTip(tooltip) - - def _actionTriggered(self, checked=False): - # This will trigger _keepDataAspectRatioChanged - self.plot.setKeepDataAspectRatio(not self.plot.isKeepDataAspectRatio()) - - -class YAxisInvertedAction(PlotAction): - """QAction controlling Y orientation on a :class:`.PlotWidget`. - - :param plot: :class:`.PlotWidget` instance on which to operate - :param parent: See :class:`QAction` - """ - - def __init__(self, plot, parent=None): - # Uses two images for checked/unchecked states - self._states = { - False: (icons.getQIcon('plot-ydown'), - "Orient Y axis downward"), - True: (icons.getQIcon('plot-yup'), - "Orient Y axis upward"), - } - - icon, tooltip = self._states[plot.isYAxisInverted()] - super(YAxisInvertedAction, self).__init__( - plot, - icon=icon, - text='Invert Y Axis', - tooltip=tooltip, - triggered=self._actionTriggered, - checkable=False, - parent=parent) - plot.sigSetYAxisInverted.connect(self._yAxisInvertedChanged) - - def _yAxisInvertedChanged(self, inverted): - """Handle Plot set y axis inverted signal""" - icon, tooltip = self._states[inverted] - self.setIcon(icon) - self.setToolTip(tooltip) - - def _actionTriggered(self, checked=False): - # This will trigger _yAxisInvertedChanged - self.plot.setYAxisInverted(not self.plot.isYAxisInverted()) - - -class SaveAction(PlotAction): - """QAction for saving Plot content. - - It opens a Save as... dialog. - - :param plot: :class:`.PlotWidget` instance on which to operate. - :param parent: See :class:`QAction`. - """ - # TODO find a way to make the filter list selectable and extensible - - SNAPSHOT_FILTER_SVG = 'Plot Snapshot as SVG (*.svg)' - - SNAPSHOT_FILTERS = ('Plot Snapshot as PNG (*.png)', - 'Plot Snapshot as JPEG (*.jpg)', - SNAPSHOT_FILTER_SVG) - - # Dict of curve filters with CSV-like format - # Using ordered dict to guarantee filters order - # Note: '%.18e' is numpy.savetxt default format - CURVE_FILTERS_TXT = OrderedDict(( - ('Curve as Raw ASCII (*.txt)', - {'fmt': '%.18e', 'delimiter': ' ', 'header': False}), - ('Curve as ";"-separated CSV (*.csv)', - {'fmt': '%.18e', 'delimiter': ';', 'header': True}), - ('Curve as ","-separated CSV (*.csv)', - {'fmt': '%.18e', 'delimiter': ',', 'header': True}), - ('Curve as tab-separated CSV (*.csv)', - {'fmt': '%.18e', 'delimiter': '\t', 'header': True}), - ('Curve as OMNIC CSV (*.csv)', - {'fmt': '%.7E', 'delimiter': ',', 'header': False}), - ('Curve as SpecFile (*.dat)', - {'fmt': '%.7g', 'delimiter': '', 'header': False}) - )) - - CURVE_FILTER_NPY = 'Curve as NumPy binary file (*.npy)' - - CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [CURVE_FILTER_NPY] |