diff options
author | Picca Frédéric-Emmanuel <picca@debian.org> | 2018-03-04 10:20:27 +0100 |
---|---|---|
committer | Picca Frédéric-Emmanuel <picca@debian.org> | 2018-03-04 10:20:27 +0100 |
commit | 270d5ddc31c26b62379e3caa9044dd75ccc71847 (patch) | |
tree | 55c5bfc851dfce7172d335cd2405b214323e3caf /silx | |
parent | e19c96eff0c310c06c4f268c8b80cb33bd08996f (diff) |
New upstream version 0.7.0+dfsg
Diffstat (limited to 'silx')
230 files changed, 29788 insertions, 13499 deletions
diff --git a/silx/app/convert.py b/silx/app/convert.py index a092ec1..cd48deb 100644 --- a/silx/app/convert.py +++ b/silx/app/convert.py @@ -1,6 +1,6 @@ # coding: utf-8 # /*########################################################################## -# Copyright (C) 2017 European Synchrotron Radiation Facility +# Copyright (C) 2017-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -24,13 +24,22 @@ """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 +import re +import time + +import silx.io +from silx.io.specfile import is_specfile +from silx.third_party import six + +try: + from silx.io import fabioh5 +except ImportError: + fabioh5 = None __authors__ = ["P. Knobel"] @@ -42,6 +51,129 @@ _logger = logging.getLogger(__name__) """Module logger""" +def c_format_string_to_re(pattern_string): + """ + + :param pattern_string: C style format string with integer patterns + (e.g. "%d", "%04d"). + Not supported: fixed length padded with whitespaces (e.g "%4d", "%-4d") + :return: Equivalent regular expression (e.g. "\d+", "\d{4}") + """ + # escape dots and backslashes + pattern_string = pattern_string.replace("\\", "\\\\") + pattern_string = pattern_string.replace(".", "\.") + + # %d + pattern_string = pattern_string.replace("%d", "([-+]?\d+)") + + # %0nd + for sub_pattern in re.findall("%0\d+d", pattern_string): + n = int(re.search("%0(\d+)d", sub_pattern).group(1)) + if n == 1: + re_sub_pattern = "([+-]?\d)" + else: + re_sub_pattern = "([\d+-]\d{%d})" % (n - 1) + pattern_string = pattern_string.replace(sub_pattern, re_sub_pattern, 1) + + return pattern_string + + +def drop_indices_before_begin(filenames, regex, begin): + """ + + :param List[str] filenames: list of filenames + :param str regex: Regexp used to find indices in a filename + :param str begin: Comma separated list of begin indices + :return: List of filenames with only indices >= begin + """ + begin_indices = list(map(int, begin.split(","))) + output_filenames = [] + for fname in filenames: + m = re.match(regex, fname) + file_indices = list(map(int, m.groups())) + if len(file_indices) != len(begin_indices): + raise IOError("Number of indices found in filename " + "does not match number of parsed end indices.") + good_indices = True + for i, fidx in enumerate(file_indices): + if fidx < begin_indices[i]: + good_indices = False + if good_indices: + output_filenames.append(fname) + return output_filenames + + +def drop_indices_after_end(filenames, regex, end): + """ + + :param List[str] filenames: list of filenames + :param str regex: Regexp used to find indices in a filename + :param str end: Comma separated list of end indices + :return: List of filenames with only indices <= end + """ + end_indices = list(map(int, end.split(","))) + output_filenames = [] + for fname in filenames: + m = re.match(regex, fname) + file_indices = list(map(int, m.groups())) + if len(file_indices) != len(end_indices): + raise IOError("Number of indices found in filename " + "does not match number of parsed end indices.") + good_indices = True + for i, fidx in enumerate(file_indices): + if fidx > end_indices[i]: + good_indices = False + if good_indices: + output_filenames.append(fname) + return output_filenames + + +def are_files_missing_in_series(filenames, regex): + """Return True if any file is missing in a list of filenames + that are supposed to follow a pattern. + + :param List[str] filenames: list of filenames + :param str regex: Regexp used to find indices in a filename + :return: boolean + :raises AssertionError: if a filename does not match the regexp + """ + previous_indices = None + for fname in filenames: + m = re.match(regex, fname) + assert m is not None, \ + "regex %s does not match filename %s" % (fname, regex) + new_indices = list(map(int, m.groups())) + if previous_indices is not None: + for old_idx, new_idx in zip(previous_indices, new_indices): + if (new_idx - old_idx) > 1: + _logger.error("Index increment > 1 in file series: " + "previous idx %d, next idx %d", + old_idx, new_idx) + return True + previous_indices = new_indices + return False + + +def are_all_specfile(filenames): + """Return True if all files in a list are SPEC files. + :param List[str] filenames: list of filenames + """ + for fname in filenames: + if not is_specfile(fname): + return False + return True + + +def contains_specfile(filenames): + """Return True if any file in a list are SPEC files. + :param List[str] filenames: list of filenames + """ + for fname in filenames: + if is_specfile(fname): + return True + return False + + def main(argv): """ Main function to launch the converter as an application @@ -52,15 +184,29 @@ def main(argv): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( 'input_files', - nargs="+", - help='Input files (EDF, SPEC)') + nargs="*", + help='Input files (EDF, TIFF, SPEC...). When specifying multiple ' + 'files, you cannot specify both fabio images and SPEC files. ' + 'Multiple SPEC files will simply be concatenated, with one ' + 'entry per scan. Multiple image files will be merged into ' + 'a single entry with a stack of images.') + # input_files and --filepattern are mutually exclusive + parser.add_argument( + '--file-pattern', + help='File name pattern for loading a series of indexed image files ' + '(toto_%%04d.edf). This argument is incompatible with argument ' + 'input_files. If an output URI with a HDF5 path is provided, ' + 'only the content of the NXdetector group will be copied there. ' + 'If no HDF5 path, or just "/", is given, a complete NXdata ' + 'structure will be created.') 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') + default=time.strftime("%Y%m%d-%H%M%S") + '.h5', + help='Output file name (HDF5). An URI can be provided to write' + ' the data into a specific group in the output file: ' + '/path/to/file::/path/to/group. ' + 'If not provided, the filename defaults to a timestamp:' + ' YYYYmmdd-HHMMSS.h5') parser.add_argument( '-m', '--mode', default="w-", @@ -69,12 +215,26 @@ def main(argv): '"w-" (write, fail if file exists) or ' '"a" (read/write if exists, create otherwise)') parser.add_argument( - '--no-root-group', + '--begin', + help='First file index, or first file indices to be considered. ' + 'This argument only makes sense when used together with ' + '--file-pattern. Provide as many start indices as there ' + 'are indices in the file pattern, separated by commas. ' + 'Examples: "--filepattern toto_%%d.edf --begin 100", ' + ' "--filepattern toto_%%d_%%04d_%%02d.edf --begin 100,2000,5".') + parser.add_argument( + '--end', + help='Last file index, or last file indices to be considered. ' + 'The same rules as with argument --begin apply. ' + 'Example: "--filepattern toto_%%d_%%d.edf --end 199,1999"') + parser.add_argument( + '--add-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).') + help='This option causes each input file to be written to a ' + 'specific root group with the same name as the file. When ' + 'merging multiple input files, this can help preventing conflicts' + ' when datasets have the same name (see --overwrite-data). ' + 'This option is ignored when using --file-pattern.') parser.add_argument( '--overwrite-data', action="store_true", @@ -121,7 +281,7 @@ def main(argv): parser.add_argument( '--shuffle', action="store_true", - help='Enables the byte shuffle filter, may improve the compression ' + help='Enables the byte shuffle filter. This may improve the compression ' 'ratio for block oriented compressors like GZIP or LZF.') parser.add_argument( '--fletcher32', @@ -135,22 +295,10 @@ def main(argv): 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 + # Import after parsing --debug try: # it should be loaded before h5py import hdf5plugin # noqa @@ -177,22 +325,78 @@ def main(argv): + " compressions. You can install it using \"pip install hdf5plugin\"." _logger.debug(message) + # Process input arguments (mutually exclusive arguments) + if bool(options.input_files) == bool(options.file_pattern is not None): + if not options.input_files: + message = "You must specify either input files (at least one), " + message += "or a file pattern." + else: + message = "You cannot specify input files and a file pattern" + message += " at the same time." + _logger.error(message) + return -1 + elif options.input_files: + # 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: + # glob does not sort files, but the bash shell does + options.input_files += sorted(globbed_files) + else: + # File series + dirname = os.path.dirname(options.file_pattern) + file_pattern_re = c_format_string_to_re(options.file_pattern) + "$" + files_in_dir = glob(os.path.join(dirname, "*")) + _logger.debug(""" + Processing file_pattern + dirname: %s + file_pattern_re: %s + files_in_dir: %s + """, dirname, file_pattern_re, files_in_dir) + + options.input_files = sorted(list(filter(lambda name: re.match(file_pattern_re, name), + files_in_dir))) + _logger.debug("options.input_files: %s", options.input_files) + + if options.begin is not None: + options.input_files = drop_indices_before_begin(options.input_files, + file_pattern_re, + options.begin) + _logger.debug("options.input_files after applying --begin: %s", + options.input_files) + + if options.end is not None: + options.input_files = drop_indices_after_end(options.input_files, + file_pattern_re, + options.end) + _logger.debug("options.input_files after applying --end: %s", + options.input_files) + + if are_files_missing_in_series(options.input_files, + file_pattern_re): + _logger.error("File missing in the file series. Aborting.") + return -1 + + if not options.input_files: + _logger.error("No file matching --file-pattern found.") + return -1 + # 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 = "/" + if "::" in options.output_uri: + output_name, hdf5_path = options.output_uri.split("::") else: - if "::" in options.output_uri: - output_name, hdf5_path = options.output_uri.split("::") - else: - output_name, hdf5_path = options.output_uri, "/" + 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.", + _logger.error("Output file %s exists and mode is 'w-' (default)." + " Aborting. To append data to an existing file, " + "use 'a' or 'r+'.", output_name) return -1 elif not os.access(output_name, os.W_OK): @@ -262,22 +466,80 @@ def main(argv): 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, + if (len(options.input_files) > 1 and + not contains_specfile(options.input_files) and + not options.add_root_group) or options.file_pattern is not None: + # File series -> stack of images + if fabioh5 is None: + # return a helpful error message if fabio is missing + try: + import fabio + except ImportError: + _logger.error("The fabio library is required to convert" + " edf files. Please install it with 'pip " + "install fabio` and try again.") + else: + # unexpected problem in silx.io.fabioh5 + raise + return -1 + input_group = fabioh5.File(file_series=options.input_files) + if hdf5_path != "/": + # we want to append only data and headers to an existing file + input_group = input_group["/scan_0/instrument/detector_0"] + with h5py.File(output_name, mode=options.mode) as h5f: + write_to_h5(input_group, h5f, + h5path=hdf5_path, 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)) + elif len(options.input_files) == 1 or \ + are_all_specfile(options.input_files) or\ + options.add_root_group: + # single file, or spec files + h5paths_and_groups = [] + for input_name in options.input_files: + hdf5_path_for_file = hdf5_path + if options.add_root_group: + hdf5_path_for_file = hdf5_path.rstrip("/") + "/" + os.path.basename(input_name) + try: + h5paths_and_groups.append((hdf5_path_for_file, + silx.io.open(input_name))) + except IOError: + _logger.error("Cannot read file %s. If this is a file format " + "supported by the fabio library, you can try to" + " install fabio (`pip install fabio`)." + " Aborting conversion.", + input_name) + return -1 + + with h5py.File(output_name, mode=options.mode) as h5f: + for hdf5_path_for_file, input_group in h5paths_and_groups: + write_to_h5(input_group, h5f, + h5path=hdf5_path_for_file, + overwrite_data=options.overwrite_data, + create_dataset_args=create_dataset_args, + min_size=options.min_size) + + else: + # multiple file, SPEC and fabio images mixed + _logger.error("Multiple files with incompatible formats specified. " + "You can provide multiple SPEC files or multiple image " + "files, but not both.") + return -1 + + with h5py.File(output_name, mode="r+") as h5f: + # append "silx convert" to the creator attribute, for NeXus files + previous_creator = h5f.attrs.get("creator", u"") + creator = "silx convert (v%s)" % silx.version + # only if it not already there + if creator not in previous_creator: + if not previous_creator: + new_creator = creator + else: + new_creator = previous_creator + "; " + creator + h5f.attrs["creator"] = numpy.array( + new_creator, + dtype=h5py.special_dtype(vlen=six.text_type)) return 0 diff --git a/silx/app/test/test_convert.py b/silx/app/test/test_convert.py index 3215460..97be3fd 100644 --- a/silx/app/test/test_convert.py +++ b/silx/app/test/test_convert.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# Copyright (c) 2016-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -26,7 +26,7 @@ __authors__ = ["P. Knobel"] __license__ = "MIT" -__date__ = "12/09/2017" +__date__ = "17/01/2018" import os @@ -43,7 +43,7 @@ except ImportError: import silx from .. import convert -from silx.test import utils +from silx.utils import testutils @@ -103,13 +103,12 @@ class TestConvertCommand(unittest.TestCase): 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) + @testutils.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) + with testutils.EnsureImportError("h5py"): + 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): @@ -122,7 +121,7 @@ class TestConvertCommand(unittest.TestCase): self.assertNotEqual(result, 0) @unittest.skipIf(h5py is None, "h5py is required to test convert") - @utils.test_logging(convert._logger.name, error=3) + @testutils.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"]) @@ -136,15 +135,16 @@ class TestConvertCommand(unittest.TestCase): # write a temporary SPEC file specname = os.path.join(tempdir, "input.dat") with io.open(specname, "wb") as fd: - if sys.version < '3.0': + if sys.version_info < (3, ): fd.write(sftext) else: fd.write(bytes(sftext, 'ascii')) # convert it h5name = os.path.join(tempdir, "output.h5") + assert not os.path.isfile(h5name) command_list = ["convert", "-m", "w", - "--no-root-group", specname, "-o", h5name] + specname, "-o", h5name] result = convert.main(command_list) self.assertEqual(result, 0) @@ -152,17 +152,16 @@ class TestConvertCommand(unittest.TestCase): with h5py.File(h5name, "r") as h5f: title12 = h5f["/1.2/title"][()] - if sys.version > '3.0': - title12 = title12.decode() + if sys.version_info < (3, ): + title12 = title12.encode("utf-8") self.assertEqual(title12, - "1 aaaaaa") + "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)) + if sys.version_info < (3, ): + creator = creator.encode("utf-8") + self.assertIn("silx convert (v%s)" % silx.version, creator) # delete input file gc.collect() # necessary to free spec file on Windows diff --git a/silx/app/test/test_view.py b/silx/app/test/test_view.py index e55e4f3..aeba0cc 100644 --- a/silx/app/test/test_view.py +++ b/silx/app/test/test_view.py @@ -26,29 +26,20 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "29/09/2017" +__date__ = "09/11/2017" import unittest import sys -import os +from silx.test.utils import test_options -# 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)" +if not test_options.WITH_QT_TEST: view = None TestCaseQt = unittest.TestCase else: from silx.gui.test.utils import TestCaseQt from .. import view - with_qt = True - reason = "" class QApplicationMock(object): @@ -73,6 +64,9 @@ class ViewerMock(object): def appendFile(self, filename): self.appendFileCalls.append(filename) + def setAttribute(self, attr, value): + pass + def resize(self, size): pass @@ -80,7 +74,7 @@ class ViewerMock(object): pass -@unittest.skipUnless(with_qt, "Qt binding required for TestLauncher") +@unittest.skipUnless(test_options.WITH_QT_TEST, test_options.WITH_QT_TEST_REASON) class TestLauncher(unittest.TestCase): """Test command line parsing""" @@ -133,7 +127,7 @@ class TestLauncher(unittest.TestCase): class TestViewer(TestCaseQt): """Test for Viewer class""" - @unittest.skipUnless(with_qt, reason) + @unittest.skipUnless(test_options.WITH_QT_TEST, test_options.WITH_QT_TEST_REASON) def testConstruct(self): if view is not None: widget = view.Viewer() diff --git a/silx/app/test_.py b/silx/app/test_.py index 7f95085..2623c04 100644 --- a/silx/app/test_.py +++ b/silx/app/test_.py @@ -25,10 +25,9 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "04/08/2017" +__date__ = "12/01/2018" import sys -import os import argparse import logging import unittest @@ -91,26 +90,17 @@ def main(argv): :param argv: Command line arguments :returns: exit status """ + from silx.test import utils + 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'") + utils.test_options.add_parser_argument(parser) options = parser.parse_args(argv[1:]) @@ -127,18 +117,6 @@ def main(argv): 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": @@ -153,6 +131,9 @@ def main(argv): else: raise ValueError("Qt binding '%s' is unknown" % options.qt_binding) + # Configure test options + utils.test_options.configure(options) + # Run the tests runnerArgs = {} runnerArgs["verbosity"] = test_verbosity diff --git a/silx/app/view.py b/silx/app/view.py index e8507f4..bc4e30c 100644 --- a/silx/app/view.py +++ b/silx/app/view.py @@ -1,6 +1,6 @@ # coding: utf-8 # /*########################################################################## -# Copyright (C) 2016-2017 European Synchrotron Radiation Facility +# Copyright (C) 2016-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "02/10/2017" +__date__ = "28/02/2018" import sys import os @@ -36,6 +36,17 @@ import collections _logger = logging.getLogger(__name__) """Module logger""" +if "silx.gui.qt" not in sys.modules: + # Try first PyQt5 and not the priority imposed by silx.gui.qt. + # To avoid problem with unittests we only do it if silx.gui.qt is not + # yet loaded. + # TODO: Can be removed for silx 0.8, as it should be the default binding + # of the silx library. + try: + import PyQt5.QtCore + except ImportError: + pass + from silx.gui import qt @@ -142,22 +153,28 @@ class Viewer(qt.QMainWindow): dialog.setWindowTitle("Open") dialog.setModal(True) + # NOTE: hdf5plugin have to be loaded before + import silx.io extensions = collections.OrderedDict() - # expect h5py - extensions["HDF5 files"] = "*.h5 *.hdf" - extensions["NeXus files"] = "*.nx *.nxs *.h5 *.hdf" - # no dependancy - extensions["NeXus layout from spec files"] = "*.dat *.spec *.mca" - extensions["Numpy binary files"] = "*.npz *.npy" - # expect fabio - 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" + for description, ext in silx.io.supported_extensions().items(): + extensions[description] = " ".join(sorted(list(ext))) + + # NOTE: hdf5plugin have to be loaded before + import fabio + if fabio is not None: + extensions["NeXus layout from EDF files"] = "*.edf" + extensions["NeXus layout from TIFF image files"] = "*.tif *.tiff" + extensions["NeXus layout from CBF files"] = "*.cbf" + extensions["NeXus layout from MarCCD image files"] = "*.mccd" + + all_supported_extensions = set() + for name, exts in extensions.items(): + exts = exts.split(" ") + all_supported_extensions.update(exts) + all_supported_extensions = sorted(list(all_supported_extensions)) filters = [] - filters.append("All supported files (%s)" % " ".join(extensions.values())) + filters.append("All supported files (%s)" % " ".join(all_supported_extensions)) for name, extension in extensions.items(): filters.append("%s (%s)" % (name, extension)) filters.append("All files (*)") @@ -194,7 +211,7 @@ class Viewer(qt.QMainWindow): selectedObjects = event.source().selectedH5Nodes(ignoreBrokenLinks=False) menu = event.menu() - if len(menu.children()): + if not menu.isEmpty(): menu.addSeparator() # Import it here to be sure to use the right logging level @@ -280,6 +297,7 @@ def main(argv): sys.excepthook = qt.exceptionHandler window = Viewer() + window.setAttribute(qt.Qt.WA_DeleteOnClose, True) window.resize(qt.QSize(640, 480)) for filename in options.files: diff --git a/silx/gui/_glutils/OpenGLWidget.py b/silx/gui/_glutils/OpenGLWidget.py index 6cbf8f0..7f600a0 100644 --- a/silx/gui/_glutils/OpenGLWidget.py +++ b/silx/gui/_glutils/OpenGLWidget.py @@ -116,6 +116,9 @@ else: format_.setSwapBehavior(qt.QSurfaceFormat.DoubleBuffer) self.setFormat(format_) + # Enable receiving mouse move events when no buttons are pressed + self.setMouseTracking(True) + def getDevicePixelRatio(self): """Returns the ratio device-independent / device pixel size @@ -217,7 +220,7 @@ else: _logger.error('_OpenGLWidget has no parent') return - if qt.BINDING == 'PyQt5': + if qt.BINDING in ('PyQt5', 'PySide2'): devicePixelRatio = self.window().windowHandle().devicePixelRatio() if devicePixelRatio != self.getDevicePixelRatio(): diff --git a/silx/gui/_glutils/Texture.py b/silx/gui/_glutils/Texture.py index 9f09a86..0875ebe 100644 --- a/silx/gui/_glutils/Texture.py +++ b/silx/gui/_glutils/Texture.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# Copyright (c) 2014-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -49,7 +49,8 @@ class Texture(object): :type data: numpy.ndarray or None :param format_: Input data format if different from internalFormat :param shape: If data is None, shape of the texture - :type shape: 2 or 3-tuple of int (height, width) or (depth, height, width) + (height, width) or (depth, height, width) + :type shape: List[int] :param int texUnit: The texture unit to use :param minFilter: OpenGL texture minimization filter (default: GL_NEAREST) :param magFilter: OpenGL texture magnification filter (default: GL_LINEAR) @@ -258,7 +259,7 @@ class Texture(object): :param format_: The OpenGL format of the data :param data: The data to use to update the texture :param offset: The offset in the texture where to copy the data - :type offset: 2 or 3-tuple of int + :type offset: List[int] :param int texUnit: The texture unit to use (default: the one provided at init) """ diff --git a/silx/gui/_glutils/gl.py b/silx/gui/_glutils/gl.py index 4b9a7bb..608d9ce 100644 --- a/silx/gui/_glutils/gl.py +++ b/silx/gui/_glutils/gl.py @@ -101,7 +101,10 @@ def enabled(capacity, enable=True): :param bool enable: True (default) to enable during context, False to disable """ - if enable: + if bool(enable) == glGetBoolean(capacity): + # Already in the right state: noop + yield + elif enable: glEnable(capacity) yield glDisable(capacity) diff --git a/silx/gui/_utils.py b/silx/gui/_utils.py index e29141f..d91a572 100644 --- a/silx/gui/_utils.py +++ b/silx/gui/_utils.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -24,7 +24,10 @@ # ###########################################################################*/ """This module provides convenient functions to use with Qt objects. -It provides conversion between numpy and QImage. +It provides: +- conversion between numpy and QImage: + :func:`convertArrayToQImage`, :func:`convertQImageToArray` +- Execution of function in Qt main thread: :func:`submitToQtMainThread` """ from __future__ import division @@ -38,6 +41,8 @@ __date__ = "16/01/2017" import sys import numpy +from silx.third_party.concurrent_futures import Future + from . import qt @@ -87,7 +92,7 @@ def convertQImageToArray(image): image = image.convertToFormat(qt.QImage.Format_RGB888) ptr = image.bits() - if qt.BINDING != 'PySide': + if qt.BINDING not in ('PySide', 'PySide2'): ptr.setsize(image.byteCount()) if qt.BINDING == 'PyQt4' and sys.version_info[0] == 2: ptr = ptr.asstring() @@ -100,3 +105,71 @@ def convertQImageToArray(image): array = array.reshape(image.height(), -1)[:, :image.width() * 3] array.shape = image.height(), image.width(), 3 return array + + +class _QtExecutor(qt.QObject): + """Executor of tasks in Qt main thread""" + + __sigSubmit = qt.Signal(Future, object, tuple, dict) + """Signal used to run tasks.""" + + def __init__(self): + super(_QtExecutor, self).__init__(parent=None) + + # Makes sure the executor lives in the main thread + app = qt.QApplication.instance() + assert app is not None + mainThread = app.thread() + if self.thread() != mainThread: + self.moveToThread(mainThread) + + self.__sigSubmit.connect(self.__run) + + def submit(self, fn, *args, **kwargs): + """Submit fn(*args, **kwargs) to Qt main thread + + :param callable fn: Function to call in main thread + :return: Future object to retrieve result + :rtype: concurrent.future.Future + """ + future = Future() + self.__sigSubmit.emit(future, fn, args, kwargs) + return future + + def __run(self, future, fn, args, kwargs): + """Run task in Qt main thread + + :param concurrent.future.Future future: + :param callable fn: Function to run + :param tuple args: Arguments + :param dict kwargs: Keyword arguments + """ + if not future.set_running_or_notify_cancel(): + return + + try: + result = fn(*args, **kwargs) + except BaseException as e: + future.set_exception(e) + else: + future.set_result(result) + + +_executor = None +"""QObject running the tasks in main thread""" + + +def submitToQtMainThread(fn, *args, **kwargs): + """Run fn(*args, **kwargs) in Qt's main thread. + + If not called from the main thread, this is run asynchronously. + + :param callable fn: Function to call in main thread. + :return: A future object to retrieve the result + :rtype: concurrent.future.Future + """ + global _executor + if _executor is None: # Lazy-loading + _executor = _QtExecutor() + + return _executor.submit(fn, *args, **kwargs) diff --git a/silx/gui/console.py b/silx/gui/console.py index 7812e2d..3c69419 100644 --- a/silx/gui/console.py +++ b/silx/gui/console.py @@ -129,7 +129,10 @@ if qtconsole is None: IPython.external.qt_loaders.has_binding = has_binding - from IPython.qt.console.rich_ipython_widget import RichIPythonWidget + try: + from IPython.qtconsole.rich_ipython_widget import RichIPythonWidget + except ImportError: + from IPython.qt.console.rich_ipython_widget import RichIPythonWidget from IPython.qt.inprocess import QtInProcessKernelManager diff --git a/silx/gui/data/DataViewer.py b/silx/gui/data/DataViewer.py index 750c654..5e0b25e 100644 --- a/silx/gui/data/DataViewer.py +++ b/silx/gui/data/DataViewer.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# Copyright (c) 2016-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -32,10 +32,12 @@ from silx.gui.data.DataViews import _normalizeData import logging from silx.gui import qt from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector +from silx.utils import deprecation +from silx.utils.property import classproperty __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "03/10/2017" +__date__ = "26/02/2018" _logger = logging.getLogger(__name__) @@ -68,16 +70,65 @@ class DataViewer(qt.QFrame): viewer.setVisible(True) """ - EMPTY_MODE = 0 - PLOT1D_MODE = 10 - PLOT2D_MODE = 20 - PLOT3D_MODE = 30 - RAW_MODE = 40 - RAW_ARRAY_MODE = 41 - RAW_RECORD_MODE = 42 - RAW_SCALAR_MODE = 43 - STACK_MODE = 50 - HDF5_MODE = 60 + # TODO: Can be removed for silx 0.8 + @classproperty + @deprecation.deprecated(replacement="DataViews.EMPTY_MODE", since_version="0.7", skip_backtrace_count=2) + def EMPTY_MODE(self): + return DataViews.EMPTY_MODE + + # TODO: Can be removed for silx 0.8 + @classproperty + @deprecation.deprecated(replacement="DataViews.PLOT1D_MODE", since_version="0.7", skip_backtrace_count=2) + def PLOT1D_MODE(self): + return DataViews.PLOT1D_MODE + + # TODO: Can be removed for silx 0.8 + @classproperty + @deprecation.deprecated(replacement="DataViews.PLOT2D_MODE", since_version="0.7", skip_backtrace_count=2) + def PLOT2D_MODE(self): + return DataViews.PLOT2D_MODE + + # TODO: Can be removed for silx 0.8 + @classproperty + @deprecation.deprecated(replacement="DataViews.PLOT3D_MODE", since_version="0.7", skip_backtrace_count=2) + def PLOT3D_MODE(self): + return DataViews.PLOT3D_MODE + + # TODO: Can be removed for silx 0.8 + @classproperty + @deprecation.deprecated(replacement="DataViews.RAW_MODE", since_version="0.7", skip_backtrace_count=2) + def RAW_MODE(self): + return DataViews.RAW_MODE + + # TODO: Can be removed for silx 0.8 + @classproperty + @deprecation.deprecated(replacement="DataViews.RAW_ARRAY_MODE", since_version="0.7", skip_backtrace_count=2) + def RAW_ARRAY_MODE(self): + return DataViews.RAW_ARRAY_MODE + + # TODO: Can be removed for silx 0.8 + @classproperty + @deprecation.deprecated(replacement="DataViews.RAW_RECORD_MODE", since_version="0.7", skip_backtrace_count=2) + def RAW_RECORD_MODE(self): + return DataViews.RAW_RECORD_MODE + + # TODO: Can be removed for silx 0.8 + @classproperty + @deprecation.deprecated(replacement="DataViews.RAW_SCALAR_MODE", since_version="0.7", skip_backtrace_count=2) + def RAW_SCALAR_MODE(self): + return DataViews.RAW_SCALAR_MODE + + # TODO: Can be removed for silx 0.8 + @classproperty + @deprecation.deprecated(replacement="DataViews.STACK_MODE", since_version="0.7", skip_backtrace_count=2) + def STACK_MODE(self): + return DataViews.STACK_MODE + + # TODO: Can be removed for silx 0.8 + @classproperty + @deprecation.deprecated(replacement="DataViews.HDF5_MODE", since_version="0.7", skip_backtrace_count=2) + def HDF5_MODE(self): + return DataViews.HDF5_MODE displayedViewChanged = qt.Signal(object) """Emitted when the displayed view changes""" @@ -129,7 +180,7 @@ class DataViewer(qt.QFrame): """Inisialize the available views""" views = self.createDefaultViews(self.__stack) self.__views = list(views) - self.setDisplayMode(self.EMPTY_MODE) + self.setDisplayMode(DataViews.EMPTY_MODE) def createDefaultViews(self, parent=None): """Create and returns available views which can be displayed by default @@ -137,7 +188,7 @@ class DataViewer(qt.QFrame): overwriten to provide a different set of viewers. :param QWidget parent: QWidget parent of the views - :rtype: list[silx.gui.data.DataViews.DataView] + :rtype: List[silx.gui.data.DataViews.DataView] """ viewClasses = [ DataViews._EmptyView, @@ -262,6 +313,7 @@ class DataViewer(qt.QFrame): def getViewFromModeId(self, modeId): """Returns the first available view which have the requested modeId. + Return None if modeId does not correspond to an existing view. :param int modeId: Requested mode id :rtype: silx.gui.data.DataViews.DataView @@ -269,7 +321,7 @@ class DataViewer(qt.QFrame): for view in self.__views: if view.modeId() == modeId: return view - return view + return None def setDisplayMode(self, modeId): """Set the displayed view using display mode. @@ -278,13 +330,14 @@ class DataViewer(qt.QFrame): :param int modeId: Display mode, one of - - `EMPTY_MODE`: display nothing - - `PLOT1D_MODE`: display the data as a curve - - `PLOT2D_MODE`: display the data as an image - - `PLOT3D_MODE`: display the data as an isosurface - - `RAW_MODE`: display the data as a table - - `STACK_MODE`: display the data as a stack of images - - `HDF5_MODE`: display the data as a table + - `DataViews.EMPTY_MODE`: display nothing + - `DataViews.PLOT1D_MODE`: display the data as a curve + - `DataViews.IMAGE_MODE`: display the data as an image + - `DataViews.PLOT3D_MODE`: display the data as an isosurface + - `DataViews.RAW_MODE`: display the data as a table + - `DataViews.STACK_MODE`: display the data as a stack of images + - `DataViews.HDF5_MODE`: display the data as a table of HDF5 info + - `DataViews.NXDATA_MODE`: display the data as NXdata """ try: view = self.getViewFromModeId(modeId) @@ -377,21 +430,21 @@ class DataViewer(qt.QFrame): on rendering. :param object data: data which will be displayed - :param list[view] available: List of available views, from highest + :param List[view] available: List of available views, from highest priority to lowest. :rtype: DataView """ hdf5View = self.getViewFromModeId(DataViewer.HDF5_MODE) if hdf5View in available: return hdf5View - return self.getViewFromModeId(DataViewer.EMPTY_MODE) + return self.getViewFromModeId(DataViews.EMPTY_MODE) def getDefaultViewFromAvailableViews(self, data, available): """Returns the default view which will be used according to available views. :param object data: data which will be displayed - :param list[view] available: List of available views, from highest + :param List[view] available: List of available views, from highest priority to lowest. :rtype: DataView """ @@ -403,7 +456,7 @@ class DataViewer(qt.QFrame): view = available[0] else: # else returns the empty view - view = self.getViewFromModeId(DataViewer.EMPTY_MODE) + view = self.getViewFromModeId(DataViews.EMPTY_MODE) return view def __setCurrentAvailableViews(self, availableViews): @@ -462,3 +515,51 @@ class DataViewer(qt.QFrame): def displayMode(self): """Returns the current display mode""" return self.__currentView.modeId() + + def replaceView(self, modeId, newView): + """Replace one of the builtin data views with a custom view. + Return True in case of success, False in case of failure. + + .. note:: + + This method must be called just after instantiation, before + the viewer is used. + + :param int modeId: Unique mode ID identifying the DataView to + be replaced. One of: + + - `DataViews.EMPTY_MODE` + - `DataViews.PLOT1D_MODE` + - `DataViews.IMAGE_MODE` + - `DataViews.PLOT2D_MODE` + - `DataViews.COMPLEX_IMAGE_MODE` + - `DataViews.PLOT3D_MODE` + - `DataViews.RAW_MODE` + - `DataViews.STACK_MODE` + - `DataViews.HDF5_MODE` + - `DataViews.NXDATA_MODE` + - `DataViews.NXDATA_INVALID_MODE` + - `DataViews.NXDATA_SCALAR_MODE` + - `DataViews.NXDATA_CURVE_MODE` + - `DataViews.NXDATA_XYVSCATTER_MODE` + - `DataViews.NXDATA_IMAGE_MODE` + - `DataViews.NXDATA_STACK_MODE` + + :param DataViews.DataView newView: New data view + :return: True if replacement was successful, else False + """ + assert isinstance(newView, DataViews.DataView) + isReplaced = False + for idx, view in enumerate(self.__views): + if view.modeId() == modeId: + self.__views[idx] = newView + isReplaced = True + break + elif isinstance(view, DataViews.CompositeDataView): + isReplaced = view.replaceView(modeId, newView) + if isReplaced: + break + + if isReplaced: + self.__updateAvailableViews() + return isReplaced diff --git a/silx/gui/data/DataViewerFrame.py b/silx/gui/data/DataViewerFrame.py index e050d4a..89a9992 100644 --- a/silx/gui/data/DataViewerFrame.py +++ b/silx/gui/data/DataViewerFrame.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# Copyright (c) 2016-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -133,7 +133,7 @@ class DataViewerFrame(qt.QWidget): overwriten to provide a different set of viewers. :param QWidget parent: QWidget parent of the views - :rtype: list[silx.gui.data.DataViews.DataView] + :rtype: List[silx.gui.data.DataViews.DataView] """ return self.__dataViewer._createDefaultViews(parent) @@ -192,3 +192,16 @@ class DataViewerFrame(qt.QWidget): - `ARRAY_MODE`: display the data as a table """ return self.__dataViewer.setDisplayMode(modeId) + + def getViewFromModeId(self, modeId): + """See :meth:`DataViewer.getViewFromModeId`""" + return self.__dataViewer.getViewFromModeId(modeId) + + def replaceView(self, modeId, newView): + """Replace one of the builtin data views with a custom view. + See :meth:`DataViewer.replaceView` for more documentation. + + :param DataViews.DataView newView: New data view + :return: True if replacement was successful, else False + """ + return self.__dataViewer.replaceView(modeId, newView) diff --git a/silx/gui/data/DataViewerSelector.py b/silx/gui/data/DataViewerSelector.py index 32cc636..35bbe99 100644 --- a/silx/gui/data/DataViewerSelector.py +++ b/silx/gui/data/DataViewerSelector.py @@ -29,12 +29,11 @@ from __future__ import division __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "26/01/2017" +__date__ = "23/01/2018" import weakref import functools from silx.gui import qt -from silx.gui.data.DataViewer import DataViewer import silx.utils.weakref @@ -51,21 +50,36 @@ class DataViewerSelector(qt.QWidget): self.__group = None self.__buttons = {} + self.__buttonLayout = None self.__buttonDummy = None self.__dataViewer = None + # Create the fixed layout + self.setLayout(qt.QHBoxLayout()) + layout = self.layout() + layout.setContentsMargins(0, 0, 0, 0) + self.__buttonLayout = qt.QHBoxLayout() + self.__buttonLayout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(self.__buttonLayout) + layout.addStretch(1) + if dataViewer is not None: self.setDataViewer(dataViewer) def __updateButtons(self): if self.__group is not None: self.__group.deleteLater() + + # Clean up + for _, b in self.__buttons.items(): + b.deleteLater() + if self.__buttonDummy is not None: + self.__buttonDummy.deleteLater() + self.__buttonDummy = None self.__buttons = {} self.__buttonDummy = None self.__group = qt.QButtonGroup(self) - self.setLayout(qt.QHBoxLayout()) - self.layout().setContentsMargins(0, 0, 0, 0) if self.__dataViewer is None: return @@ -83,19 +97,17 @@ class DataViewerSelector(qt.QWidget): weakMethod = silx.utils.weakref.WeakMethodProxy(self.__setDisplayedView) callback = functools.partial(weakMethod, weakView) button.clicked.connect(callback) - self.layout().addWidget(button) + self.__buttonLayout.addWidget(button) self.__group.addButton(button) self.__buttons[view] = button button = qt.QPushButton("Dummy") button.setCheckable(True) button.setVisible(False) - self.layout().addWidget(button) + self.__buttonLayout.addWidget(button) self.__group.addButton(button) self.__buttonDummy = button - self.layout().addStretch(1) - self.__updateButtonsVisibility() self.__displayedViewChanged(self.__dataViewer.displayedView()) @@ -125,7 +137,7 @@ class DataViewerSelector(qt.QWidget): self.__buttonDummy.setFlat(isFlat) def __displayedViewChanged(self, view): - """Called on displayed view changeS""" + """Called on displayed view changes""" selectedButton = self.__buttons.get(view, self.__buttonDummy) selectedButton.setChecked(True) @@ -142,12 +154,22 @@ class DataViewerSelector(qt.QWidget): return self.__dataViewer.setDisplayedView(view) + def __checkAvailableButtons(self): + views = set(self.__dataViewer.availableViews()) + if views == set(self.__buttons.keys()): + return + # Recreate all the buttons + # TODO: We dont have to create everything again + # We expect the views stay quite stable + self.__updateButtons() + def __updateButtonsVisibility(self): """Called on data changed""" if self.__dataViewer is None: for b in self.__buttons.values(): b.setVisible(False) else: + self.__checkAvailableButtons() availableViews = set(self.__dataViewer.currentAvailableViews()) for view, button in self.__buttons.items(): button.setVisible(view in availableViews) diff --git a/silx/gui/data/DataViews.py b/silx/gui/data/DataViews.py index 1ad997b..ef69441 100644 --- a/silx/gui/data/DataViews.py +++ b/silx/gui/data/DataViews.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# Copyright (c) 2016-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -35,11 +35,13 @@ from silx.gui import qt, icons from silx.gui.data.TextFormatter import TextFormatter from silx.io import nxdata from silx.gui.hdf5 import H5Node -from silx.io.nxdata import NXdata, get_attr_as_string +from silx.io.nxdata import get_attr_as_string +from silx.gui.plot.Colormap import Colormap +from silx.gui.plot.actions.control import ColormapAction __authors__ = ["V. Valls", "P. Knobel"] __license__ = "MIT" -__date__ = "03/10/2017" +__date__ = "23/01/2018" _logger = logging.getLogger(__name__) @@ -47,7 +49,9 @@ _logger = logging.getLogger(__name__) # DataViewer modes EMPTY_MODE = 0 PLOT1D_MODE = 10 -PLOT2D_MODE = 20 +IMAGE_MODE = 20 +PLOT2D_MODE = 21 +COMPLEX_IMAGE_MODE = 22 PLOT3D_MODE = 30 RAW_MODE = 40 RAW_ARRAY_MODE = 41 @@ -56,6 +60,13 @@ RAW_SCALAR_MODE = 43 RAW_HEXA_MODE = 44 STACK_MODE = 50 HDF5_MODE = 60 +NXDATA_MODE = 70 +NXDATA_INVALID_MODE = 71 +NXDATA_SCALAR_MODE = 72 +NXDATA_CURVE_MODE = 73 +NXDATA_XYVSCATTER_MODE = 74 +NXDATA_IMAGE_MODE = 75 +NXDATA_STACK_MODE = 76 def _normalizeData(data): @@ -77,7 +88,7 @@ def _normalizeComplex(data): absolute value. Else returns the input data.""" if hasattr(data, "dtype"): - isComplex = numpy.issubdtype(data.dtype, numpy.complex) + isComplex = numpy.issubdtype(data.dtype, numpy.complexfloating) else: isComplex = isinstance(data, numbers.Complex) if isComplex: @@ -97,7 +108,7 @@ class DataInfo(object): self.isComplex = False self.isBoolean = False self.isRecord = False - self.isNXdata = False + self.hasNXdata = False self.shape = tuple() self.dim = 0 self.size = 0 @@ -105,9 +116,10 @@ class DataInfo(object): if data is None: return - if silx.io.is_group(data) and nxdata.is_valid_nxdata(data): - self.isNXdata = True - nxd = nxdata.NXdata(data) + if silx.io.is_group(data): + nxd = nxdata.get_default(data) + if nxd is not None: + self.hasNXdata = True if isinstance(data, numpy.ndarray): self.isArray = True @@ -121,7 +133,7 @@ class DataInfo(object): self.interpretation = get_attr_as_string(data, "interpretation") else: self.interpretation = None - elif self.isNXdata: + elif self.hasNXdata: self.interpretation = nxd.interpretation else: self.interpretation = None @@ -132,12 +144,12 @@ class DataInfo(object): 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.isComplex = numpy.issubdtype(data.dtype, numpy.complexfloating) self.isBoolean = numpy.issubdtype(data.dtype, numpy.bool_) - elif self.isNXdata: + elif self.hasNXdata: self.isNumeric = numpy.issubdtype(nxd.signal.dtype, numpy.number) - self.isComplex = numpy.issubdtype(nxd.signal.dtype, numpy.complex) + self.isComplex = numpy.issubdtype(nxd.signal.dtype, numpy.complexfloating) self.isBoolean = numpy.issubdtype(nxd.signal.dtype, numpy.bool_) else: self.isNumeric = isinstance(data, numbers.Number) @@ -147,7 +159,7 @@ class DataInfo(object): if hasattr(data, "shape"): self.shape = data.shape - elif self.isNXdata: + elif self.hasNXdata: self.shape = nxd.signal.shape else: self.shape = tuple() @@ -172,6 +184,12 @@ class DataView(object): """Priority returned when the requested data can't be displayed by the view.""" + _defaultColormap = None + """Store a default colormap shared with all the views""" + + _defaultColorDialog = None + """Store a default color dialog shared with all the views""" + def __init__(self, parent, modeId=None, icon=None, label=None): """Constructor @@ -187,6 +205,32 @@ class DataView(object): icon = qt.QIcon() self.__icon = icon + @staticmethod + def defaultColormap(): + """Returns a shared colormap as default for all the views. + + :rtype: Colormap + """ + if DataView._defaultColormap is None: + DataView._defaultColormap = Colormap(name="viridis") + return DataView._defaultColormap + + @staticmethod + def defaultColorDialog(): + """Returns a shared color dialog as default for all the views. + + :rtype: ColorDialog + """ + if DataView._defaultColorDialog is None: + DataView._defaultColorDialog = ColormapAction._createDialog(qt.QApplication.instance().activeWindow()) + return DataView._defaultColorDialog + + @staticmethod + def _cleanUpCache(): + """Clean up the cache. Needed for tests""" + DataView._defaultColormap = None + DataView._defaultColorDialog = None + def icon(self): """Returns the default icon""" return self.__icon @@ -305,6 +349,13 @@ class CompositeDataView(DataView): """Add a new dataview to the available list.""" self.__views[dataView] = None + def availableViews(self): + """Returns the list of registered views + + :rtype: List[DataView] + """ + return list(self.__views.keys()) + def getBestView(self, data, info): """Returns the best view according to priorities.""" views = [(v.getDataPriority(data, info), v) for v in self.__views.keys()] @@ -374,6 +425,38 @@ class CompositeDataView(DataView): else: return view.getDataPriority(data, info) + def replaceView(self, modeId, newView): + """Replace a data view with a custom view. + Return True in case of success, False in case of failure. + + .. note:: + + This method must be called just after instantiation, before + the viewer is used. + + :param int modeId: Unique mode ID identifying the DataView to + be replaced. + :param DataViews.DataView newView: New data view + :return: True if replacement was successful, else False + """ + oldView = None + for view in self.__views: + if view.modeId() == modeId: + oldView = view + break + elif isinstance(view, CompositeDataView): + # recurse + if view.replaceView(modeId, newView): + return True + if oldView is None: + return False + + # replace oldView with new view in dict + self.__views = OrderedDict( + (newView, None) if view is oldView else (view, idx) for + view, idx in self.__views.items()) + return True + class _EmptyView(DataView): """Dummy view to display nothing""" @@ -457,6 +540,8 @@ class _Plot2dView(DataView): def createWidget(self, parent): from silx.gui import plot widget = plot.Plot2D(parent=parent) + widget.setDefaultColormap(self.defaultColormap()) + widget.getColormapAction().setColorDialog(self.defaultColorDialog()) widget.getIntensityHistogramAction().setVisible(True) widget.setKeepDataAspectRatio(True) widget.getXAxis().setLabel('X') @@ -582,13 +667,18 @@ class _ComplexImageView(DataView): def __init__(self, parent): super(_ComplexImageView, self).__init__( parent=parent, - modeId=PLOT2D_MODE, + modeId=COMPLEX_IMAGE_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.setColormap(self.defaultColormap(), mode=ComplexImageView.Mode.ABSOLUTE) + widget.setColormap(self.defaultColormap(), mode=ComplexImageView.Mode.SQUARE_AMPLITUDE) + widget.setColormap(self.defaultColormap(), mode=ComplexImageView.Mode.REAL) + widget.setColormap(self.defaultColormap(), mode=ComplexImageView.Mode.IMAGINARY) + widget.getPlot().getColormapAction().setColorDialog(self.defaultColorDialog()) widget.getPlot().getIntensityHistogramAction().setVisible(True) widget.getPlot().setKeepDataAspectRatio(True) widget.getXAxis().setLabel('X') @@ -681,6 +771,8 @@ class _StackView(DataView): def createWidget(self, parent): from silx.gui import plot widget = plot.StackView(parent=parent) + widget.setColormap(self.defaultColormap()) + widget.getPlot().getColormapAction().setColorDialog(self.defaultColorDialog()) widget.setKeepDataAspectRatio(True) widget.setLabels(self.axesNames(None, None)) # hide default option panel @@ -699,6 +791,8 @@ class _StackView(DataView): def setData(self, data): data = self.normalizeData(data) self.getWidget().setStack(stack=data, reset=self.__resetZoomNextTime) + # Override the colormap, while setStack overwrite it + self.getWidget().setColormap(self.defaultColormap()) self.__resetZoomNextTime = False def axesNames(self, data, info): @@ -736,7 +830,11 @@ class _ScalarView(DataView): d = self.normalizeData(data) if silx.io.is_dataset(d): d = d[()] - text = self.__formatter.toString(d, data.dtype) + dtype = None + if data is not None: + if hasattr(data, "dtype"): + dtype = data.dtype + text = self.__formatter.toString(d, dtype) self.getWidget().setText(text) def axesNames(self, data, info): @@ -891,18 +989,111 @@ class _ImageView(CompositeDataView): def __init__(self, parent): super(_ImageView, self).__init__( parent=parent, - modeId=PLOT2D_MODE, + modeId=IMAGE_MODE, label="Image", icon=icons.getQIcon("view-2d")) self.addView(_ComplexImageView(parent)) self.addView(_Plot2dView(parent)) +class _InvalidNXdataView(DataView): + """DataView showing a simple label with an error message + to inform that a group with @NX_class=NXdata cannot be + interpreted by any NXDataview.""" + def __init__(self, parent): + DataView.__init__(self, parent, + modeId=NXDATA_INVALID_MODE) + self._msg = "" + + def createWidget(self, parent): + widget = qt.QLabel(parent) + widget.setWordWrap(True) + widget.setStyleSheet("QLabel { color : red; }") + return widget + + def axesNames(self, data, info): + return [] + + def clear(self): + self.getWidget().setText("") + + def setData(self, data): + self.getWidget().setText(self._msg) + + def getDataPriority(self, data, info): + data = self.normalizeData(data) + if silx.io.is_group(data): + nxd = nxdata.get_default(data) + nx_class = get_attr_as_string(data, "NX_class") + + if nxd is None: + if nx_class == "NXdata": + # invalid: could not even be parsed by NXdata + self._msg = "Group has @NX_class = NXdata, but could not be interpreted" + self._msg += " as valid NXdata." + return 100 + elif nx_class == "NXentry": + if "default" not in data.attrs: + # no link to NXdata, no problem + return DataView.UNSUPPORTED + self._msg = "NXentry group provides a @default attribute," + default_nxdata_name = data.attrs["default"] + if default_nxdata_name not in data: + self._msg += " but no corresponding NXdata group exists." + elif get_attr_as_string(data[default_nxdata_name], "NX_class") != "NXdata": + self._msg += " but the corresponding item is not a " + self._msg += "NXdata group." + else: + self._msg += " but the corresponding NXdata seems to be" + self._msg += " malformed." + return 100 + elif nx_class == "NXroot" or silx.io.is_file(data): + if "default" not in data.attrs: + # no link to NXentry, no problem + return DataView.UNSUPPORTED + default_entry_name = data.attrs["default"] + if default_entry_name not in data: + # this is a problem, but not NXdata related + return DataView.UNSUPPORTED + default_entry = data[default_entry_name] + if "default" not in default_entry.attrs: + # no NXdata specified, no problemo + return DataView.UNSUPPORTED + default_nxdata_name = default_entry.attrs["default"] + self._msg = "NXroot group provides a @default attribute " + self._msg += "pointing to a NXentry which defines its own " + self._msg += "@default attribute, " + if default_nxdata_name not in default_entry: + self._msg += " but no corresponding NXdata group exists." + elif get_attr_as_string(default_entry[default_nxdata_name], + "NX_class") != "NXdata": + self._msg += " but the corresponding item is not a " + self._msg += "NXdata group." + else: + self._msg += " but the corresponding NXdata seems to be" + self._msg += " malformed." + return 100 + else: + # Not pretending to be NXdata, no problem + return DataView.UNSUPPORTED + + is_scalar = nxd.signal_is_0d or nxd.interpretation in ["scalar", "scaler"] + if not (is_scalar or nxd.is_curve or nxd.is_x_y_value_scatter or + nxd.is_image or nxd.is_stack): + # invalid: cannot be plotted by any widget (I cannot imagine a case) + self._msg = "NXdata seems valid, but cannot be displayed " + self._msg += "by any existing plot widget." + return 100 + + return DataView.UNSUPPORTED + + class _NXdataScalarView(DataView): """DataView using a table view for displaying NXdata scalars: 0-D signal or n-D signal with *@interpretation=scalar*""" def __init__(self, parent): - DataView.__init__(self, parent) + DataView.__init__(self, parent, + modeId=NXDATA_SCALAR_MODE) def createWidget(self, parent): from silx.gui.data.ArrayTableWidget import ArrayTableWidget @@ -919,14 +1110,17 @@ class _NXdataScalarView(DataView): def setData(self, data): data = self.normalizeData(data) - signal = NXdata(data).signal + # data could be a NXdata or an NXentry + nxd = nxdata.get_default(data) + signal = nxd.signal self.getWidget().setArrayData(signal, labels=True) def getDataPriority(self, data, info): data = self.normalizeData(data) - if info.isNXdata: - nxd = NXdata(data) + + if info.hasNXdata: + nxd = nxdata.get_default(data) if nxd.signal_is_0d or nxd.interpretation in ["scalar", "scaler"]: return 100 return DataView.UNSUPPORTED @@ -940,7 +1134,8 @@ class _NXdataCurveView(DataView): a 1-D signal with one axis whose values are not monotonically increasing. """ def __init__(self, parent): - DataView.__init__(self, parent) + DataView.__init__(self, parent, + modeId=NXDATA_CURVE_MODE) def createWidget(self, parent): from silx.gui.data.NXdataWidgets import ArrayCurvePlot @@ -956,29 +1151,34 @@ class _NXdataCurveView(DataView): def setData(self, data): data = self.normalizeData(data) - nxd = NXdata(data) - signal_name = get_attr_as_string(data, "signal") - group_name = data.name + nxd = nxdata.get_default(data) + signals_names = [nxd.signal_name] + nxd.auxiliary_signals_names if nxd.axes_dataset_names[-1] is not None: x_errors = nxd.get_axis_errors(nxd.axes_dataset_names[-1]) else: x_errors = None - self.getWidget().setCurveData(nxd.signal, nxd.axes[-1], - yerror=nxd.errors, xerror=x_errors, - ylabel=signal_name, xlabel=nxd.axes_names[-1], - title="NXdata group " + group_name) + # this fix is necessary until the next release of PyMca (5.2.3 or 5.3.0) + # see https://github.com/vasole/pymca/issues/144 and https://github.com/vasole/pymca/pull/145 + if not hasattr(self.getWidget(), "setCurvesData") and \ + hasattr(self.getWidget(), "setCurveData"): + _logger.warning("Using deprecated ArrayCurvePlot API, " + "without support of auxiliary signals") + self.getWidget().setCurveData(nxd.signal, nxd.axes[-1], + yerror=nxd.errors, xerror=x_errors, + ylabel=nxd.signal_name, xlabel=nxd.axes_names[-1], + title=nxd.title or nxd.signal_name) + return + + self.getWidget().setCurvesData([nxd.signal] + nxd.auxiliary_signals, nxd.axes[-1], + yerror=nxd.errors, xerror=x_errors, + ylabels=signals_names, xlabel=nxd.axes_names[-1], + title=nxd.title or signals_names[0]) def getDataPriority(self, data, info): data = self.normalizeData(data) - if info.isNXdata: - nxd = NXdata(data) - if nxd.is_x_y_value_scatter or nxd.is_unsupported_scatter: - return DataView.UNSUPPORTED - if nxd.signal_is_1d and \ - not nxd.interpretation in ["scalar", "scaler"]: - return 100 - if nxd.interpretation == "spectrum": + if info.hasNXdata: + if nxdata.get_default(data).is_curve: return 100 return DataView.UNSUPPORTED @@ -987,11 +1187,12 @@ class _NXdataXYVScatterView(DataView): """DataView using a Plot1D for displaying NXdata 3D scatters as a scatter of coloured points (1-D signal with 2 axes)""" def __init__(self, parent): - DataView.__init__(self, parent) + DataView.__init__(self, parent, + modeId=NXDATA_XYVSCATTER_MODE) def createWidget(self, parent): - from silx.gui.data.NXdataWidgets import ArrayCurvePlot - widget = ArrayCurvePlot(parent) + from silx.gui.data.NXdataWidgets import XYVScatterPlot + widget = XYVScatterPlot(parent) return widget def axesNames(self, data, info): @@ -1003,10 +1204,7 @@ class _NXdataXYVScatterView(DataView): def setData(self, data): data = self.normalizeData(data) - nxd = NXdata(data) - signal_name = get_attr_as_string(data, "signal") - # signal_errors = nx.errors # not supported - group_name = data.name + nxd = nxdata.get_default(data) x_axis, y_axis = nxd.axes[-2:] x_label, y_label = nxd.axes_names[-2:] @@ -1020,16 +1218,18 @@ class _NXdataXYVScatterView(DataView): else: y_errors = None - self.getWidget().setCurveData(y_axis, x_axis, values=nxd.signal, - yerror=y_errors, xerror=x_errors, - ylabel=signal_name, xlabel=x_label, - title="NXdata group " + group_name) + self.getWidget().setScattersData(y_axis, x_axis, values=[nxd.signal] + nxd.auxiliary_signals, + yerror=y_errors, xerror=x_errors, + ylabel=y_label, xlabel=x_label, + title=nxd.title, + scatter_titles=[nxd.signal_name] + nxd.auxiliary_signals_names) def getDataPriority(self, data, info): data = self.normalizeData(data) - if info.isNXdata: - if NXdata(data).is_x_y_value_scatter: + if info.hasNXdata: + if nxdata.get_default(data).is_x_y_value_scatter: return 100 + return DataView.UNSUPPORTED @@ -1037,11 +1237,14 @@ class _NXdataImageView(DataView): """DataView using a Plot2D for displaying NXdata images: 2-D signal or n-D signals with *@interpretation=spectrum*.""" def __init__(self, parent): - DataView.__init__(self, parent) + DataView.__init__(self, parent, + modeId=NXDATA_IMAGE_MODE) def createWidget(self, parent): from silx.gui.data.NXdataWidgets import ArrayImagePlot widget = ArrayImagePlot(parent) + widget.getPlot().setDefaultColormap(self.defaultColormap()) + widget.getPlot().getColormapAction().setColorDialog(self.defaultColorDialog()) return widget def axesNames(self, data, info): @@ -1053,36 +1256,41 @@ class _NXdataImageView(DataView): def setData(self, data): data = self.normalizeData(data) - nxd = NXdata(data) - 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:] + nxd = nxdata.get_default(data) + isRgba = nxd.interpretation == "rgba-image" + + # last two axes are Y & X + img_slicing = slice(-2, None) if not isRgba else slice(-3, -1) + y_axis, x_axis = nxd.axes[img_slicing] + y_label, x_label = nxd.axes_names[img_slicing] self.getWidget().setImageData( - nxd.signal, x_axis=x_axis, y_axis=y_axis, - signal_name=signal_name, xlabel=x_label, ylabel=y_label, - title="NXdata group %s: %s" % (group_name, signal_name)) + [nxd.signal] + nxd.auxiliary_signals, + x_axis=x_axis, y_axis=y_axis, + signals_names=[nxd.signal_name] + nxd.auxiliary_signals_names, + xlabel=x_label, ylabel=y_label, + title=nxd.title, isRgba=isRgba) def getDataPriority(self, data, info): data = self.normalizeData(data) - if info.isNXdata: - nxd = NXdata(data) - if nxd.signal_is_2d: - if nxd.interpretation not in ["scalar", "spectrum", "scaler"]: - return 100 - if nxd.interpretation == "image": + + if info.hasNXdata: + if nxdata.get_default(data).is_image: return 100 + return DataView.UNSUPPORTED class _NXdataStackView(DataView): def __init__(self, parent): - DataView.__init__(self, parent) + DataView.__init__(self, parent, + modeId=NXDATA_STACK_MODE) def createWidget(self, parent): from silx.gui.data.NXdataWidgets import ArrayStackPlot widget = ArrayStackPlot(parent) + widget.getStackView().setColormap(self.defaultColormap()) + widget.getStackView().getPlot().getColormapAction().setColorDialog(self.defaultColorDialog()) return widget def axesNames(self, data, info): @@ -1094,26 +1302,27 @@ class _NXdataStackView(DataView): def setData(self, data): data = self.normalizeData(data) - nxd = NXdata(data) - signal_name = get_attr_as_string(data, "signal") - group_name = data.name + nxd = nxdata.get_default(data) + signal_name = nxd.signal_name z_axis, y_axis, x_axis = nxd.axes[-3:] z_label, y_label, x_label = nxd.axes_names[-3:] + title = nxd.title or signal_name - self.getWidget().setStackData( + widget = self.getWidget() + widget.setStackData( nxd.signal, x_axis=x_axis, y_axis=y_axis, z_axis=z_axis, signal_name=signal_name, xlabel=x_label, ylabel=y_label, zlabel=z_label, - title="NXdata group %s: %s" % (group_name, signal_name)) + title=title) + # Override the colormap, while setStack overwrite it + widget.getStackView().setColormap(self.defaultColormap()) def getDataPriority(self, data, info): data = self.normalizeData(data) - if info.isNXdata: - nxd = NXdata(data) - if nxd.signal_ndim >= 3: - if nxd.interpretation not in ["scalar", "scaler", - "spectrum", "image"]: - return 100 + if info.hasNXdata: + if nxdata.get_default(data).is_stack: + return 100 + return DataView.UNSUPPORTED @@ -1124,8 +1333,10 @@ class _NXdataView(CompositeDataView): super(_NXdataView, self).__init__( parent=parent, label="NXdata", + modeId=NXDATA_MODE, icon=icons.getQIcon("view-nexus")) + self.addView(_InvalidNXdataView(parent)) self.addView(_NXdataScalarView(parent)) self.addView(_NXdataCurveView(parent)) self.addView(_NXdataXYVScatterView(parent)) diff --git a/silx/gui/data/Hdf5TableView.py b/silx/gui/data/Hdf5TableView.py index ba737e3..e4a0747 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__ = "29/09/2017" +__date__ = "10/10/2017" import functools import os.path @@ -330,7 +330,7 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): self.__data.addHeaderRow(headerLabel="Data info") - if h5py is not None and hasattr(obj, "id"): + if h5py is not None and hasattr(obj, "id") and hasattr(obj.id, "get_type"): # display the HDF5 type self.__data.addHeaderValueRow("HDF5 type", self.__formatHdf5Type) self.__data.addHeaderValueRow("dtype", self.__formatDType) @@ -345,21 +345,22 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel): # h5py also expose fletcher32 and shuffle attributes, but it is also # part of the filters if hasattr(obj, "shape") and hasattr(obj, "id"): - dcpl = obj.id.get_create_plist() - if dcpl.get_nfilters() > 0: - self.__data.addHeaderRow(headerLabel="Compression info") - pos = _CellData(value="Position", isHeader=True) - hdf5id = _CellData(value="HDF5 ID", isHeader=True) - name = _CellData(value="Name", isHeader=True) - options = _CellData(value="Options", isHeader=True) - self.__data.addRow(pos, hdf5id, name, options) - for index in range(dcpl.get_nfilters()): - callback = lambda index, dataIndex, x: self.__get_filter_info(x, index)[dataIndex] - pos = _CellData(value=functools.partial(callback, index, 0)) - hdf5id = _CellData(value=functools.partial(callback, index, 1)) - name = _CellData(value=functools.partial(callback, index, 2)) - options = _CellData(value=functools.partial(callback, index, 3)) - self.__data.addRow(pos, hdf5id, name, options) + if hasattr(obj.id, "get_create_plist"): + dcpl = obj.id.get_create_plist() + if dcpl.get_nfilters() > 0: + self.__data.addHeaderRow(headerLabel="Compression info") + pos = _CellData(value="Position", isHeader=True) + hdf5id = _CellData(value="HDF5 ID", isHeader=True) + name = _CellData(value="Name", isHeader=True) + options = _CellData(value="Options", isHeader=True) + self.__data.addRow(pos, hdf5id, name, options) + for index in range(dcpl.get_nfilters()): + callback = lambda index, dataIndex, x: self.__get_filter_info(x, index)[dataIndex] + pos = _CellData(value=functools.partial(callback, index, 0)) + hdf5id = _CellData(value=functools.partial(callback, index, 1)) + name = _CellData(value=functools.partial(callback, index, 2)) + options = _CellData(value=functools.partial(callback, index, 3)) + self.__data.addRow(pos, hdf5id, name, options) if hasattr(obj, "attrs"): if len(obj.attrs) > 0: diff --git a/silx/gui/data/NXdataWidgets.py b/silx/gui/data/NXdataWidgets.py index 7aaf3ad..ae2911d 100644 --- a/silx/gui/data/NXdataWidgets.py +++ b/silx/gui/data/NXdataWidgets.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -26,13 +26,15 @@ """ __authors__ = ["P. Knobel"] __license__ = "MIT" -__date__ = "27/06/2017" +__date__ = "20/12/2017" import numpy from silx.gui import qt from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector from silx.gui.plot import Plot1D, Plot2D, StackView +from silx.gui.plot.Colormap import Colormap +from silx.gui.widgets.FrameBrowser import HorizontalSliderWithBrowser from silx.math.calibration import ArrayCalibration, NoCalibration, LinearCalibration @@ -60,83 +62,79 @@ class ArrayCurvePlot(qt.QWidget): """ super(ArrayCurvePlot, self).__init__(parent) - self.__signal = None - self.__signal_name = None + self.__signals = None + self.__signals_names = None self.__signal_errors = None self.__axis = None self.__axis_name = None - self.__axis_errors = None + self.__x_axis_errors = None self.__values = None - self.__first_curve_added = False - self._plot = Plot1D(self) - self._plot.setDefaultColormap( # for scatters - {"name": "viridis", - "vmin": 0., "vmax": 1., # ignored (autoscale) but mandatory - "normalization": "linear", - "autoscale": True}) self.selectorDock = qt.QDockWidget("Data selector", self._plot) # not closable self.selectorDock.setFeatures(qt.QDockWidget.DockWidgetMovable | - qt.QDockWidget.DockWidgetFloatable) + qt.QDockWidget.DockWidgetFloatable) self._selector = NumpyAxesSelector(self.selectorDock) self._selector.setNamedAxesSelectorVisibility(False) self.__selector_is_connected = False self.selectorDock.setWidget(self._selector) self._plot.addTabbedDockWidget(self.selectorDock) + self._plot.sigActiveCurveChanged.connect(self._setYLabelFromActiveLegend) + layout = qt.QGridLayout() layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self._plot, 0, 0) + layout.addWidget(self._plot, 0, 0) self.setLayout(layout) - def setCurveData(self, y, x=None, values=None, - yerror=None, xerror=None, - ylabel=None, xlabel=None, title=None): + def getPlot(self): + """Returns the plot used for the display + + :rtype: Plot1D + """ + return self._plot + + def setCurvesData(self, ys, x=None, + yerror=None, xerror=None, + ylabels=None, xlabel=None, title=None): """ - :param y: dataset to be represented by the y (vertical) axis. - For a scatter, this must be a 1D array and x and values must be - 1-D arrays of the same size. - In other cases, it can be a n-D array whose last dimension must + :param List[ndarray] ys: List of arrays to be represented by the y (vertical) axis. + It can be multiple n-D array whose last dimension must have the same length as x (and values must be None) - :param x: 1-D dataset used as the curve's x values. If provided, + :param ndarray x: 1-D dataset used as the curve's x values. If provided, its lengths must be equal to the length of the last dimension of ``y`` (and equal to the length of ``value``, for a scatter plot). - :param values: Values, to be provided for a x-y-value scatter plot. - This will be used to compute the color map and assign colors - to the points. - :param yerror: 1-D dataset of errors for y, or None - :param xerror: 1-D dataset of errors for x, or None - :param ylabel: Label for Y axis - :param xlabel: Label for X axis - :param title: Graph title + :param ndarray yerror: Single array of errors for y (same shape), or None. + There can only be one array, and it applies to the first/main y + (no y errors for auxiliary_signals curves). + :param ndarray xerror: 1-D dataset of errors for x, or None + :param str ylabels: Labels for each curve's Y axis + :param str xlabel: Label for X axis + :param str title: Graph title """ - self.__signal = y - self.__signal_name = ylabel or "Y" + self.__signals = ys + self.__signals_names = ylabels or (["Y"] * len(ys)) self.__signal_errors = yerror self.__axis = x self.__axis_name = xlabel - self.__axis_errors = xerror - self.__values = values + self.__x_axis_errors = xerror if self.__selector_is_connected: self._selector.selectionChanged.disconnect(self._updateCurve) self.__selector_is_connected = False - self._selector.setData(y) - self._selector.setAxisNames([ylabel or "Y"]) + self._selector.setData(ys[0]) + self._selector.setAxisNames(["Y"]) - if len(y.shape) < 2: + if len(ys[0].shape) < 2: self.selectorDock.hide() else: self.selectorDock.show() self._plot.setGraphTitle(title or "") - self._plot.getXAxis().setLabel(self.__axis_name or "X") - self._plot.getYAxis().setLabel(self.__signal_name) self._updateCurve() if not self.__selector_is_connected: @@ -144,52 +142,165 @@ class ArrayCurvePlot(qt.QWidget): self.__selector_is_connected = True def _updateCurve(self): - y = self._selector.selectedData() + selection = self._selector.selection() + ys = [sig[selection] for sig in self.__signals] + y0 = ys[0] + len_y = len(y0) x = self.__axis if x is None: - x = numpy.arange(len(y)) + x = numpy.arange(len_y) elif numpy.isscalar(x) or len(x) == 1: # constant axis - x = x * numpy.ones_like(y) - elif len(x) == 2 and len(y) != 2: + x = x * numpy.ones_like(y0) + elif len(x) == 2 and len_y != 2: # linear calibration a + b * x - x = x[0] + x[1] * numpy.arange(len(y)) - legend = self.__signal_name + "[" - for sl in self._selector.selection(): - if sl == slice(None): - legend += ":, " - else: - legend += str(sl) + ", " - legend = legend[:-2] + "]" - if self.__signal_errors is not None: - y_errors = self.__signal_errors[self._selector.selection()] - else: - y_errors = None + x = x[0] + x[1] * numpy.arange(len_y) - self._plot.remove(kind=("curve", "scatter")) + self._plot.remove(kind=("curve",)) - # values: x-y-v scatter - if self.__values is not None: - self._plot.addScatter(x, y, self.__values, - legend=legend, - xerror=self.__axis_errors, - yerror=y_errors) + for i in range(len(self.__signals)): + legend = self.__signals_names[i] - # x monotonically increasing or decreasiing: curve - elif numpy.all(numpy.diff(x) > 0) or numpy.all(numpy.diff(x) < 0): - self._plot.addCurve(x, y, legend=legend, - xerror=self.__axis_errors, + # errors only supported for primary signal in NXdata + y_errors = None + if i == 0 and self.__signal_errors is not None: + y_errors = self.__signal_errors[self._selector.selection()] + self._plot.addCurve(x, ys[i], legend=legend, + xerror=self.__x_axis_errors, yerror=y_errors) + if i == 0: + self._plot.setActiveCurve(legend) - # scatter - else: - self._plot.addScatter(x, y, value=numpy.ones_like(y), - legend=legend, - xerror=self.__axis_errors, - yerror=y_errors) self._plot.resetZoom() self._plot.getXAxis().setLabel(self.__axis_name) - self._plot.getYAxis().setLabel(self.__signal_name) + self._plot.getYAxis().setLabel(self.__signals_names[0]) + + def _setYLabelFromActiveLegend(self, previous_legend, new_legend): + for ylabel in self.__signals_names: + if new_legend is not None and new_legend == ylabel: + self._plot.getYAxis().setLabel(ylabel) + break + + def clear(self): + self._plot.clear() + + +class XYVScatterPlot(qt.QWidget): + """ + Widget for plotting one or more scatters + (with identical x, y coordinates). + """ + def __init__(self, parent=None): + """ + + :param parent: Parent QWidget + """ + super(XYVScatterPlot, self).__init__(parent) + + self.__y_axis = None + """1D array""" + self.__y_axis_name = None + self.__values = None + """List of 1D arrays (for multiple scatters with identical + x, y coordinates)""" + + self.__x_axis = None + self.__x_axis_name = None + self.__x_axis_errors = None + self.__y_axis = None + self.__y_axis_name = None + self.__y_axis_errors = None + + self._plot = Plot1D(self) + self._plot.setDefaultColormap(Colormap(name="viridis", + vmin=None, vmax=None, + normalization=Colormap.LINEAR)) + + self._slider = HorizontalSliderWithBrowser(parent=self) + self._slider.setMinimum(0) + self._slider.setValue(0) + self._slider.valueChanged[int].connect(self._sliderIdxChanged) + self._slider.setToolTip("Select auxiliary signals") + + layout = qt.QGridLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._plot, 0, 0) + layout.addWidget(self._slider, 1, 0) + + self.setLayout(layout) + + def _sliderIdxChanged(self, value): + self._updateScatter() + + def getPlot(self): + """Returns the plot used for the display + + :rtype: Plot1D + """ + return self._plot + + def setScattersData(self, y, x, values, + yerror=None, xerror=None, + ylabel=None, xlabel=None, + title="", scatter_titles=None): + """ + + :param ndarray y: 1D array for y (vertical) coordinates. + :param ndarray x: 1D array for x coordinates. + :param List[ndarray] values: List of 1D arrays of values. + This will be used to compute the color map and assign colors + to the points. There should be as many arrays in the list as + scatters to be represented. + :param ndarray yerror: 1D array of errors for y (same shape), or None. + :param ndarray xerror: 1D array of errors for x, or None + :param str ylabel: Label for Y axis + :param str xlabel: Label for X axis + :param str title: Main graph title + :param List[str] scatter_titles: Subtitles (one per scatter) + """ + self.__y_axis = y + self.__x_axis = x + self.__x_axis_name = xlabel or "X" + self.__y_axis_name = ylabel or "Y" + self.__x_axis_errors = xerror + self.__y_axis_errors = yerror + self.__values = values + + self.__graph_title = title or "" + self.__scatter_titles = scatter_titles + + self._slider.valueChanged[int].disconnect(self._sliderIdxChanged) + self._slider.setMaximum(len(values) - 1) + if len(values) > 1: + self._slider.show() + else: + self._slider.hide() + self._slider.setValue(0) + self._slider.valueChanged[int].connect(self._sliderIdxChanged) + + self._updateScatter() + + def _updateScatter(self): + x = self.__x_axis + y = self.__y_axis + + self._plot.remove(kind=("scatter", )) + + idx = self._slider.value() + + title = "" + if self.__graph_title: + title += self.__graph_title + "\n" # main NXdata @title + title += self.__scatter_titles[idx] # scatter dataset name + + self._plot.setGraphTitle(title) + self._plot.addScatter(x, y, self.__values[idx], + legend="scatter%d" % idx, + xerror=self.__x_axis_errors, + yerror=self.__y_axis_errors) + self._plot.resetZoom() + self._plot.getXAxis().setLabel(self.__x_axis_name) + self._plot.getYAxis().setLabel(self.__y_axis_name) def clear(self): self._plot.clear() @@ -218,97 +329,117 @@ class ArrayImagePlot(qt.QWidget): """ super(ArrayImagePlot, self).__init__(parent) - self.__signal = None - self.__signal_name = None + self.__signals = None + self.__signals_names = None self.__x_axis = None self.__x_axis_name = None self.__y_axis = None self.__y_axis_name = None self._plot = Plot2D(self) - self._plot.setDefaultColormap( - {"name": "viridis", - "vmin": 0., "vmax": 1., # ignored (autoscale) but mandatory - "normalization": "linear", - "autoscale": True}) + self._plot.setDefaultColormap(Colormap(name="viridis", + vmin=None, vmax=None, + normalization=Colormap.LINEAR)) self.selectorDock = qt.QDockWidget("Data selector", self._plot) # not closable self.selectorDock.setFeatures(qt.QDockWidget.DockWidgetMovable | qt.QDockWidget.DockWidgetFloatable) - self._legend = qt.QLabel(self) self._selector = NumpyAxesSelector(self.selectorDock) self._selector.setNamedAxesSelectorVisibility(False) - self.__selector_is_connected = False + self._selector.selectionChanged.connect(self._updateImage) + + self._auxSigSlider = HorizontalSliderWithBrowser(parent=self) + self._auxSigSlider.setMinimum(0) + self._auxSigSlider.setValue(0) + self._auxSigSlider.valueChanged[int].connect(self._sliderIdxChanged) + self._auxSigSlider.setToolTip("Select auxiliary signals") layout = qt.QVBoxLayout() layout.addWidget(self._plot) - layout.addWidget(self._legend) + layout.addWidget(self._auxSigSlider) self.selectorDock.setWidget(self._selector) self._plot.addTabbedDockWidget(self.selectorDock) self.setLayout(layout) - def setImageData(self, signal, + def _sliderIdxChanged(self, value): + self._updateImage() + + def getPlot(self): + """Returns the plot used for the display + + :rtype: Plot2D + """ + return self._plot + + def setImageData(self, signals, x_axis=None, y_axis=None, - signal_name=None, + signals_names=None, xlabel=None, ylabel=None, - title=None): + title=None, isRgba=False): """ - :param signal: n-D dataset, whose last 2 dimensions are used as the - image's values. + :param signals: list of n-D datasets, whose last 2 dimensions are used as the + image's values, or list of 3D datasets interpreted as RGBA image. :param x_axis: 1-D dataset used as the image's x coordinates. If provided, its lengths must be equal to the length of the last dimension of ``signal``. :param y_axis: 1-D dataset used as the image's y. If provided, its lengths must be equal to the length of the 2nd to last dimension of ``signal``. - :param signal_name: Label used in the legend + :param signals_names: Names for each image, used as subtitle and legend. :param xlabel: Label for X axis :param ylabel: Label for Y axis :param title: Graph title + :param isRgba: True if data is a 3D RGBA image """ - if self.__selector_is_connected: - self._selector.selectionChanged.disconnect(self._updateImage) - self.__selector_is_connected = False + self._selector.selectionChanged.disconnect(self._updateImage) + self._auxSigSlider.valueChanged.disconnect(self._sliderIdxChanged) - self.__signal = signal - self.__signal_name = signal_name or "" + self.__signals = signals + self.__signals_names = signals_names self.__x_axis = x_axis self.__x_axis_name = xlabel self.__y_axis = y_axis self.__y_axis_name = ylabel + self.__title = title - self._selector.setData(signal) - self._selector.setAxisNames([ylabel or "Y", xlabel or "X"]) + self._selector.clear() + if not isRgba: + self._selector.setAxisNames(["Y", "X"]) + img_ndim = 2 + else: + self._selector.setAxisNames(["Y", "X", "RGB(A) channel"]) + img_ndim = 3 + self._selector.setData(signals[0]) - if len(signal.shape) < 3: + if len(signals[0].shape) <= img_ndim: self.selectorDock.hide() else: self.selectorDock.show() - self._plot.setGraphTitle(title or "") - self._plot.getXAxis().setLabel(self.__x_axis_name or "X") - self._plot.getYAxis().setLabel(self.__y_axis_name or "Y") + self._auxSigSlider.setMaximum(len(signals) - 1) + if len(signals) > 1: + self._auxSigSlider.show() + else: + self._auxSigSlider.hide() + self._auxSigSlider.setValue(0) self._updateImage() - if not self.__selector_is_connected: - self._selector.selectionChanged.connect(self._updateImage) - self.__selector_is_connected = True + self._selector.selectionChanged.connect(self._updateImage) + self._auxSigSlider.valueChanged.connect(self._sliderIdxChanged) def _updateImage(self): - legend = self.__signal_name + "[" - for sl in self._selector.selection(): - if sl == slice(None): - legend += ":, " - else: - legend += str(sl) + ", " - legend = legend[:-2] + "]" - self._legend.setText("Displayed data: " + legend) + selection = self._selector.selection() + auxSigIdx = self._auxSigSlider.value() + + legend = self.__signals_names[auxSigIdx] + + images = [img[selection] for img in self.__signals] + image = images[auxSigIdx] - img = self._selector.selectedData() x_axis = self.__x_axis y_axis = self.__y_axis @@ -318,25 +449,25 @@ class ArrayImagePlot(qt.QWidget): else: if x_axis is None: # no calibration - x_axis = numpy.arange(img.shape[-1]) + x_axis = numpy.arange(image.shape[1]) elif numpy.isscalar(x_axis) or len(x_axis) == 1: # constant axis - x_axis = x_axis * numpy.ones((img.shape[-1], )) + x_axis = x_axis * numpy.ones((image.shape[1], )) elif len(x_axis) == 2: # linear calibration - x_axis = x_axis[0] * numpy.arange(img.shape[-1]) + x_axis[1] + x_axis = x_axis[0] * numpy.arange(image.shape[1]) + x_axis[1] if y_axis is None: - y_axis = numpy.arange(img.shape[-2]) + y_axis = numpy.arange(image.shape[0]) elif numpy.isscalar(y_axis) or len(y_axis) == 1: - y_axis = y_axis * numpy.ones((img.shape[-2], )) + y_axis = y_axis * numpy.ones((image.shape[0], )) elif len(y_axis) == 2: - y_axis = y_axis[0] * numpy.arange(img.shape[-2]) + y_axis[1] + y_axis = y_axis[0] * numpy.arange(image.shape[0]) + y_axis[1] xcalib = ArrayCalibration(x_axis) ycalib = ArrayCalibration(y_axis) - self._plot.remove(kind=("scatter", "image")) + self._plot.remove(kind=("scatter", "image",)) if xcalib.is_affine() and ycalib.is_affine(): # regular image xorigin, xscale = xcalib(0), xcalib.get_slope() @@ -344,14 +475,22 @@ class ArrayImagePlot(qt.QWidget): origin = (xorigin, yorigin) scale = (xscale, yscale) - self._plot.addImage(img, legend=legend, + self._plot.addImage(image, legend=legend, origin=origin, scale=scale) else: scatterx, scattery = numpy.meshgrid(x_axis, y_axis) + # fixme: i don't think this can handle "irregular" RGBA images self._plot.addScatter(numpy.ravel(scatterx), numpy.ravel(scattery), - numpy.ravel(img), + numpy.ravel(image), legend=legend) + + title = "" + if self.__title: + title += self.__title + if not title.strip().endswith(self.__signals_names[auxSigIdx]): + title += "\n" + self.__signals_names[auxSigIdx] + self._plot.setGraphTitle(title) self._plot.getXAxis().setLabel(self.__x_axis_name) self._plot.getYAxis().setLabel(self.__y_axis_name) self._plot.resetZoom() @@ -408,6 +547,13 @@ class ArrayStackPlot(qt.QWidget): self.setLayout(layout) + def getStackView(self): + """Returns the plot used for the display + + :rtype: StackView + """ + return self._stack_view + def setStackData(self, signal, x_axis=None, y_axis=None, z_axis=None, signal_name=None, @@ -446,7 +592,7 @@ class ArrayStackPlot(qt.QWidget): self.__z_axis_name = zlabel self._selector.setData(signal) - self._selector.setAxisNames([ylabel or "Y", xlabel or "X", zlabel or "Z"]) + self._selector.setAxisNames(["Y", "X", "Z"]) self._stack_view.setGraphTitle(title or "") # by default, the z axis is the image position (dimension not plotted) diff --git a/silx/gui/data/NumpyAxesSelector.py b/silx/gui/data/NumpyAxesSelector.py index f4641da..4530aa9 100644 --- a/silx/gui/data/NumpyAxesSelector.py +++ b/silx/gui/data/NumpyAxesSelector.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# Copyright (c) 2016-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -29,7 +29,7 @@ from __future__ import division __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "16/01/2017" +__date__ = "29/01/2018" import numpy import functools @@ -133,7 +133,7 @@ class _Axis(qt.QWidget): def setAxisNames(self, axesNames): """Set the available list of names for the axis. - :param list[str] axesNames: List of available names + :param List[str] axesNames: List of available names """ self.__axes.clear() previous = self.__axes.blockSignals(True) @@ -146,7 +146,7 @@ class _Axis(qt.QWidget): def setCustomAxis(self, axesNames): """Set the available list of named axis which can be set to a value. - :param list[str] axesNames: List of customable axis names + :param List[str] axesNames: List of customable axis names """ self.__customAxisNames = set(axesNames) self.__updateSliderVisibility() @@ -258,9 +258,12 @@ class NumpyAxesSelector(qt.QWidget): The size of the list will constrain the dimension of the resulting array. - :param list[str] axesNames: List of string identifying axis names + :param List[str] axesNames: List of distinct strings identifying axis names """ self.__axisNames = list(axesNames) + assert len(set(self.__axisNames)) == len(self.__axisNames),\ + "Non-unique axes names: %s" % self.__axisNames + delta = len(self.__axis) - len(self.__axisNames) if delta < 0: delta = 0 @@ -277,7 +280,7 @@ class NumpyAxesSelector(qt.QWidget): def setCustomAxis(self, axesNames): """Set the available list of named axis which can be set to a value. - :param list[str] axesNames: List of customable axis names + :param List[str] axesNames: List of customable axis names """ self.__customAxisNames = set(axesNames) for axis in self.__axis: @@ -415,13 +418,20 @@ class NumpyAxesSelector(qt.QWidget): else: selection.append(slice(None)) axisNames.append(name) - self.__selection = tuple(selection) # get a view with few fixed dimensions # with a h5py dataset, it create a copy # TODO we can reuse the same memory in case of a copy view = self.__data[self.__selection] + if set(self.__axisNames) - set(axisNames) != set([]): + # Not all the expected axis are there + if self.__selectedData is not None: + self.__selectedData = None + self.__selection = tuple() + self.selectionChanged.emit() + return + # order axis as expected source = [] destination = [] diff --git a/silx/gui/data/TextFormatter.py b/silx/gui/data/TextFormatter.py index 37e1f48..332625c 100644 --- a/silx/gui/data/TextFormatter.py +++ b/silx/gui/data/TextFormatter.py @@ -27,12 +27,13 @@ data module to format data as text in the same way.""" __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "27/09/2017" +__date__ = "13/12/2017" import numpy import numbers from silx.third_party import six from silx.gui import qt +import logging try: import h5py @@ -40,6 +41,9 @@ except ImportError: h5py = None +_logger = logging.getLogger(__name__) + + class TextFormatter(qt.QObject): """Formatter to convert data to string. @@ -203,8 +207,9 @@ class TextFormatter(qt.QObject): data = [ord(d) for d in data.item()] else: data = data.item().astype(numpy.uint8) - else: + elif six.PY2: data = [ord(d) for d in data] + # In python3 data is already a bytes array data = ["\\x%02X" % d for d in data] if self.__useQuoteForText: return "b\"%s\"" % "".join(data) @@ -221,6 +226,30 @@ class TextFormatter(qt.QObject): else: return "".join(data) + def __formatCharString(self, data): + """Format text of char. + + From the specifications we expect to have ASCII, but we also allow + CP1252 in some ceases as fallback. + + If no encoding fits, it will display a readable ASCII chars, with + escaped chars (using the python syntax) for non decoded characters. + + :param data: A binary string of char expected in ASCII + :rtype: str + """ + try: + text = "%s" % data.decode("ascii") + return self.__formatText(text) + except UnicodeDecodeError: + # Here we can spam errors, this is definitly a badly + # generated file + _logger.error("Invalid ASCII string %s.", data) + if data == b"\xB0": + _logger.error("Fallback using cp1252 encoding") + return self.__formatText(u"\u00B0") + return self.__formatSafeAscii(data) + def __formatH5pyObject(self, data, dtype): # That's an HDF5 object ref = h5py.check_dtype(ref=dtype) @@ -236,11 +265,7 @@ class TextFormatter(qt.QObject): 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 self.__formatCharString(data) return None def toString(self, data, dtype=None): @@ -276,14 +301,12 @@ class TextFormatter(qt.QObject): elif isinstance(data, (numpy.unicode_, six.text_type)): return self.__formatText(data) elif isinstance(data, (numpy.string_, six.binary_type)): + if dtype is None and hasattr(data, "dtype"): + dtype = data.dtype 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) + return self.__formatCharString(data) elif dtype.kind == 'O': if h5py is not None: text = self.__formatH5pyObject(data, dtype) diff --git a/silx/gui/data/test/test_dataviewer.py b/silx/gui/data/test/test_dataviewer.py index dd3114a..274df92 100644 --- a/silx/gui/data/test/test_dataviewer.py +++ b/silx/gui/data/test/test_dataviewer.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# Copyright (c) 2016-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -24,7 +24,7 @@ # ###########################################################################*/ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "22/08/2017" +__date__ = "22/02/2018" import os import tempfile @@ -67,7 +67,8 @@ class _DataViewMock(DataView): class AbstractDataViewerTests(TestCaseQt): def create_widget(self): - raise NotImplementedError() + # Avoid to raise an error when testing the full module + self.skipTest("Not implemented") @contextmanager def h5_temporary_file(self): @@ -89,7 +90,7 @@ class AbstractDataViewerTests(TestCaseQt): widget = self.create_widget() for data in data_list: widget.setData(data) - self.assertEqual(DataViewer.RAW_MODE, widget.displayMode()) + self.assertEqual(DataViews.RAW_MODE, widget.displayMode()) def test_plot_1d_data(self): data = numpy.arange(3 ** 1) @@ -97,35 +98,35 @@ class AbstractDataViewerTests(TestCaseQt): widget = self.create_widget() widget.setData(data) availableModes = set([v.modeId() for v in widget.currentAvailableViews()]) - self.assertEqual(DataViewer.RAW_MODE, widget.displayMode()) - self.assertIn(DataViewer.PLOT1D_MODE, availableModes) + self.assertEqual(DataViews.RAW_MODE, widget.displayMode()) + self.assertIn(DataViews.PLOT1D_MODE, availableModes) - def test_plot_2d_data(self): + def test_image_data(self): data = numpy.arange(3 ** 2) data.shape = [3] * 2 widget = self.create_widget() widget.setData(data) availableModes = set([v.modeId() for v in widget.currentAvailableViews()]) - self.assertEqual(DataViewer.RAW_MODE, widget.displayMode()) - self.assertIn(DataViewer.PLOT2D_MODE, availableModes) + self.assertEqual(DataViews.RAW_MODE, widget.displayMode()) + self.assertIn(DataViews.IMAGE_MODE, availableModes) - def test_plot_2d_bool(self): + def test_image_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) + self.assertEqual(DataViews.RAW_MODE, widget.displayMode()) + self.assertIn(DataViews.IMAGE_MODE, availableModes) - def test_plot_2d_complex_data(self): + def test_image_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) + self.assertEqual(DataViews.RAW_MODE, widget.displayMode()) + self.assertIn(DataViews.IMAGE_MODE, availableModes) def test_plot_3d_data(self): data = numpy.arange(3 ** 3) @@ -135,38 +136,38 @@ class AbstractDataViewerTests(TestCaseQt): availableModes = set([v.modeId() for v in widget.currentAvailableViews()]) try: import silx.gui.plot3d # noqa - self.assertIn(DataViewer.PLOT3D_MODE, availableModes) + self.assertIn(DataViews.PLOT3D_MODE, availableModes) except ImportError: - self.assertIn(DataViewer.STACK_MODE, availableModes) - self.assertEqual(DataViewer.RAW_MODE, widget.displayMode()) + self.assertIn(DataViews.STACK_MODE, availableModes) + self.assertEqual(DataViews.RAW_MODE, widget.displayMode()) def test_array_1d_data(self): data = numpy.array(["aaa"] * (3 ** 1)) data.shape = [3] * 1 widget = self.create_widget() widget.setData(data) - self.assertEqual(DataViewer.RAW_MODE, widget.displayedView().modeId()) + self.assertEqual(DataViews.RAW_MODE, widget.displayedView().modeId()) def test_array_2d_data(self): data = numpy.array(["aaa"] * (3 ** 2)) data.shape = [3] * 2 widget = self.create_widget() widget.setData(data) - self.assertEqual(DataViewer.RAW_MODE, widget.displayedView().modeId()) + self.assertEqual(DataViews.RAW_MODE, widget.displayedView().modeId()) def test_array_4d_data(self): data = numpy.array(["aaa"] * (3 ** 4)) data.shape = [3] * 4 widget = self.create_widget() widget.setData(data) - self.assertEqual(DataViewer.RAW_MODE, widget.displayedView().modeId()) + self.assertEqual(DataViews.RAW_MODE, widget.displayedView().modeId()) def test_record_4d_data(self): data = numpy.zeros(3 ** 4, dtype='3int8, float32, (2,3)float64') data.shape = [3] * 4 widget = self.create_widget() widget.setData(data) - self.assertEqual(DataViewer.RAW_MODE, widget.displayedView().modeId()) + self.assertEqual(DataViews.RAW_MODE, widget.displayedView().modeId()) def test_3d_h5_dataset(self): if h5py is None: @@ -191,7 +192,7 @@ class AbstractDataViewerTests(TestCaseQt): widget.setData(10) widget.setData(None) modes = [v.modeId() for v in listener.arguments(argumentIndex=0)] - self.assertEquals(modes, [DataViewer.RAW_MODE, DataViewer.EMPTY_MODE]) + self.assertEquals(modes, [DataViews.RAW_MODE, DataViews.EMPTY_MODE]) listener.clear() def test_change_display_mode(self): @@ -199,14 +200,15 @@ class AbstractDataViewerTests(TestCaseQt): data.shape = [10] * 4 widget = self.create_widget() widget.setData(data) - widget.setDisplayMode(DataViewer.PLOT1D_MODE) - self.assertEquals(widget.displayedView().modeId(), DataViewer.PLOT1D_MODE) - widget.setDisplayMode(DataViewer.PLOT2D_MODE) - self.assertEquals(widget.displayedView().modeId(), DataViewer.PLOT2D_MODE) - widget.setDisplayMode(DataViewer.RAW_MODE) - self.assertEquals(widget.displayedView().modeId(), DataViewer.RAW_MODE) - widget.setDisplayMode(DataViewer.EMPTY_MODE) - self.assertEquals(widget.displayedView().modeId(), DataViewer.EMPTY_MODE) + widget.setDisplayMode(DataViews.PLOT1D_MODE) + self.assertEquals(widget.displayedView().modeId(), DataViews.PLOT1D_MODE) + widget.setDisplayMode(DataViews.IMAGE_MODE) + self.assertEquals(widget.displayedView().modeId(), DataViews.IMAGE_MODE) + widget.setDisplayMode(DataViews.RAW_MODE) + self.assertEquals(widget.displayedView().modeId(), DataViews.RAW_MODE) + widget.setDisplayMode(DataViews.EMPTY_MODE) + self.assertEquals(widget.displayedView().modeId(), DataViews.EMPTY_MODE) + DataView._cleanUpCache() def test_create_default_views(self): widget = self.create_widget() @@ -228,6 +230,26 @@ class AbstractDataViewerTests(TestCaseQt): self.assertTrue(view not in widget.availableViews()) self.assertTrue(view not in widget.currentAvailableViews()) + def test_replace_view(self): + widget = self.create_widget() + view = _DataViewMock(widget) + widget.replaceView(DataViews.RAW_MODE, + view) + self.assertIsNone(widget.getViewFromModeId(DataViews.RAW_MODE)) + self.assertTrue(view in widget.availableViews()) + self.assertTrue(view in widget.currentAvailableViews()) + + def test_replace_view_in_composite(self): + # replace a view that is a child of a composite view + widget = self.create_widget() + view = _DataViewMock(widget) + widget.replaceView(DataViews.NXDATA_INVALID_MODE, + view) + nxdata_view = widget.getViewFromModeId(DataViews.NXDATA_MODE) + self.assertNotIn(DataViews.NXDATA_INVALID_MODE, + [v.modeId() for v in nxdata_view.availableViews()]) + self.assertTrue(view in nxdata_view.availableViews()) + class TestDataViewer(AbstractDataViewerTests): def create_widget(self): @@ -265,6 +287,7 @@ class TestDataView(TestCaseQt): dataViewClass = DataViews._Plot2dView widget = self.createDataViewWithData(dataViewClass, data[0]) self.qWaitForWindowExposed(widget) + DataView._cleanUpCache() def testCubeWithComplex(self): self.skipTest("OpenGL widget not yet tested") @@ -276,12 +299,14 @@ class TestDataView(TestCaseQt): dataViewClass = DataViews._Plot3dView widget = self.createDataViewWithData(dataViewClass, data) self.qWaitForWindowExposed(widget) + DataView._cleanUpCache() def testImageStackWithComplex(self): data = self.createComplexData() dataViewClass = DataViews._StackView widget = self.createDataViewWithData(dataViewClass, data) self.qWaitForWindowExposed(widget) + DataView._cleanUpCache() def suite(): diff --git a/silx/gui/data/test/test_numpyaxesselector.py b/silx/gui/data/test/test_numpyaxesselector.py index cc15f83..6ce5119 100644 --- a/silx/gui/data/test/test_numpyaxesselector.py +++ b/silx/gui/data/test/test_numpyaxesselector.py @@ -24,7 +24,7 @@ # ###########################################################################*/ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "15/12/2016" +__date__ = "29/01/2018" import os import tempfile @@ -70,6 +70,20 @@ class TestNumpyAxesSelector(TestCaseQt): result = widget.selectedData() self.assertTrue(numpy.array_equal(result, expectedResult)) + def test_output_moredim(self): + data = numpy.arange(3 * 3 * 3 * 3) + data.shape = 3, 3, 3, 3 + expectedResult = data + + widget = NumpyAxesSelector() + widget.setAxisNames(["x", "y", "z", "boum"]) + widget.setData(data[0]) + result = widget.selectedData() + self.assertEqual(result, None) + widget.setData(data) + result = widget.selectedData() + self.assertTrue(numpy.array_equal(result, expectedResult)) + def test_output_lessdim(self): data = numpy.arange(3 * 3 * 3) data.shape = 3, 3, 3 diff --git a/silx/gui/data/test/test_textformatter.py b/silx/gui/data/test/test_textformatter.py index 2a7a66b..06a29ba 100644 --- a/silx/gui/data/test/test_textformatter.py +++ b/silx/gui/data/test/test_textformatter.py @@ -24,7 +24,7 @@ # ###########################################################################*/ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "27/09/2017" +__date__ = "12/12/2017" import unittest import shutil @@ -91,6 +91,17 @@ class TestTextFormatter(TestCaseQt): result = formatter.toString("toto") self.assertEquals(result, '"toto"') + def test_numpy_void(self): + formatter = TextFormatter() + result = formatter.toString(numpy.void(b"\xFF")) + self.assertEquals(result, 'b"\\xFF"') + + def test_char_cp1252(self): + # degree character in cp1252 + formatter = TextFormatter() + result = formatter.toString(numpy.bytes_(b"\xB0")) + self.assertEquals(result, u'"\u00B0"') + class TestTextFormatterWithH5py(TestCaseQt): diff --git a/silx/gui/dialog/AbstractDataFileDialog.py b/silx/gui/dialog/AbstractDataFileDialog.py new file mode 100644 index 0000000..1bd52bb --- /dev/null +++ b/silx/gui/dialog/AbstractDataFileDialog.py @@ -0,0 +1,1718 @@ +# 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. +# +# ###########################################################################*/ +""" +This module contains an :class:`AbstractDataFileDialog`. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "12/02/2018" + + +import sys +import os +import logging +import numpy +import functools +import silx.io.url +from silx.gui import qt +from silx.gui.hdf5.Hdf5TreeModel import Hdf5TreeModel +from . import utils +from silx.third_party import six +from .FileTypeComboBox import FileTypeComboBox +try: + import fabio +except ImportError: + fabio = None + + +_logger = logging.getLogger(__name__) + + +class _IconProvider(object): + + FileDialogToParentDir = qt.QStyle.SP_CustomBase + 1 + + FileDialogToParentFile = qt.QStyle.SP_CustomBase + 2 + + def __init__(self): + self.__iconFileDialogToParentDir = None + self.__iconFileDialogToParentFile = None + + def _createIconToParent(self, standardPixmap): + """ + + FIXME: It have to be tested for some OS (arrow icon do not have always + the same direction) + """ + style = qt.QApplication.style() + baseIcon = style.standardIcon(qt.QStyle.SP_FileDialogToParent) + backgroundIcon = style.standardIcon(standardPixmap) + icon = qt.QIcon() + + sizes = baseIcon.availableSizes() + sizes = sorted(sizes, key=lambda s: s.height()) + sizes = filter(lambda s: s.height() < 100, sizes) + sizes = list(sizes) + if len(sizes) > 0: + baseSize = sizes[-1] + else: + baseSize = baseIcon.availableSizes()[0] + size = qt.QSize(baseSize.width(), baseSize.height() * 3 // 2) + + modes = [qt.QIcon.Normal, qt.QIcon.Disabled] + for mode in modes: + pixmap = qt.QPixmap(size) + pixmap.fill(qt.Qt.transparent) + painter = qt.QPainter(pixmap) + painter.drawPixmap(0, 0, backgroundIcon.pixmap(baseSize, mode=mode)) + painter.drawPixmap(0, size.height() // 3, baseIcon.pixmap(baseSize, mode=mode)) + painter.end() + icon.addPixmap(pixmap, mode=mode) + + return icon + + def getFileDialogToParentDir(self): + if self.__iconFileDialogToParentDir is None: + self.__iconFileDialogToParentDir = self._createIconToParent(qt.QStyle.SP_DirIcon) + return self.__iconFileDialogToParentDir + + def getFileDialogToParentFile(self): + if self.__iconFileDialogToParentFile is None: + self.__iconFileDialogToParentFile = self._createIconToParent(qt.QStyle.SP_FileIcon) + return self.__iconFileDialogToParentFile + + def icon(self, kind): + if kind == self.FileDialogToParentDir: + return self.getFileDialogToParentDir() + elif kind == self.FileDialogToParentFile: + return self.getFileDialogToParentFile() + else: + style = qt.QApplication.style() + icon = style.standardIcon(kind) + return icon + + +class _SideBar(qt.QListView): + """Sidebar containing shortcuts for common directories""" + + def __init__(self, parent=None): + super(_SideBar, self).__init__(parent) + self.__iconProvider = qt.QFileIconProvider() + self.setUniformItemSizes(True) + model = qt.QStandardItemModel(self) + self.setModel(model) + self._initModel() + self.setEditTriggers(qt.QAbstractItemView.NoEditTriggers) + + def iconProvider(self): + return self.__iconProvider + + def _initModel(self): + urls = self._getDefaultUrls() + self.setUrls(urls) + + def _getDefaultUrls(self): + """Returns the default shortcuts. + + It uses the default QFileDialog shortcuts if it is possible, else + provides a link to the computer's root and the user's home. + + :rtype: List[str] + """ + urls = [] + if qt.qVersion().startswith("5.") and sys.platform in ["linux", "linux2"]: + # Avoid segfault on PyQt5 + gtk + _logger.debug("Skip default sidebar URLs (avoid PyQt5 segfault)") + pass + elif qt.qVersion().startswith("4.") and sys.platform in ["win32"]: + # Avoid 5min of locked GUI relative to network driver + _logger.debug("Skip default sidebar URLs (avoid lock when using network drivers)") + else: + # Get default shortcut + # There is no other way + d = qt.QFileDialog(self) + # Needed to be able to reach the sidebar urls + d.setOption(qt.QFileDialog.DontUseNativeDialog, True) + urls = d.sidebarUrls() + d.deleteLater() + d = None + + if len(urls) == 0: + urls.append(qt.QUrl("file://")) + urls.append(qt.QUrl.fromLocalFile(qt.QDir.homePath())) + + return urls + + def setSelectedPath(self, path): + selected = None + model = self.model() + for i in range(model.rowCount()): + index = model.index(i, 0) + url = model.data(index, qt.Qt.UserRole) + urlPath = url.toLocalFile() + if path == urlPath: + selected = index + + selectionModel = self.selectionModel() + if selected is not None: + selectionModel.setCurrentIndex(selected, qt.QItemSelectionModel.ClearAndSelect) + else: + selectionModel.clear() + + def setUrls(self, urls): + model = self.model() + model.clear() + + names = {} + names[qt.QDir.rootPath()] = "Computer" + names[qt.QDir.homePath()] = "Home" + + style = qt.QApplication.style() + iconProvider = self.iconProvider() + for url in urls: + path = url.toLocalFile() + if path == "": + if sys.platform != "win32": + url = qt.QUrl(qt.QDir.rootPath()) + name = "Computer" + icon = style.standardIcon(qt.QStyle.SP_ComputerIcon) + else: + fileInfo = qt.QFileInfo(path) + name = names.get(path, fileInfo.fileName()) + icon = iconProvider.icon(fileInfo) + + if icon.isNull(): + icon = style.standardIcon(qt.QStyle.SP_MessageBoxCritical) + + item = qt.QStandardItem() + item.setText(name) + item.setIcon(icon) + item.setData(url, role=qt.Qt.UserRole) + model.appendRow(item) + + def urls(self): + result = [] + model = self.model() + for i in range(model.rowCount()): + index = model.index(i, 0) + url = model.data(index, qt.Qt.UserRole) + result.append(url) + return result + + def sizeHint(self): + index = self.model().index(0, 0) + return self.sizeHintForIndex(index) + qt.QSize(2 * self.frameWidth(), 2 * self.frameWidth()) + + +class _Browser(qt.QStackedWidget): + + activated = qt.Signal(qt.QModelIndex) + selected = qt.Signal(qt.QModelIndex) + rootIndexChanged = qt.Signal(qt.QModelIndex) + + def __init__(self, parent, listView, detailView): + qt.QStackedWidget.__init__(self, parent) + self.__listView = listView + self.__detailView = detailView + self.insertWidget(0, self.__listView) + self.insertWidget(1, self.__detailView) + + self.__listView.activated.connect(self.__emitActivated) + self.__detailView.activated.connect(self.__emitActivated) + + def __emitActivated(self, index): + self.activated.emit(index) + + def __emitSelected(self, selected, deselected): + index = self.selectedIndex() + if index is not None: + self.selected.emit(index) + + def selectedIndex(self): + if self.currentIndex() == 0: + selectionModel = self.__listView.selectionModel() + else: + selectionModel = self.__detailView.selectionModel() + + if selectionModel is None: + return None + + indexes = selectionModel.selectedIndexes() + # Filter non-main columns + indexes = [i for i in indexes if i.column() == 0] + if len(indexes) == 1: + index = indexes[0] + return index + return None + + def model(self): + """Returns the current model.""" + if self.currentIndex() == 0: + return self.__listView.model() + else: + return self.__detailView.model() + + def selectIndex(self, index): + if self.currentIndex() == 0: + selectionModel = self.__listView.selectionModel() + else: + selectionModel = self.__detailView.selectionModel() + if selectionModel is None: + return + selectionModel.setCurrentIndex(index, qt.QItemSelectionModel.ClearAndSelect) + + def viewMode(self): + """Returns the current view mode. + + :rtype: qt.QFileDialog.ViewMode + """ + if self.currentIndex() == 0: + return qt.QFileDialog.List + elif self.currentIndex() == 1: + return qt.QFileDialog.Detail + else: + assert(False) + + def setViewMode(self, mode): + """Set the current view mode. + + :param qt.QFileDialog.ViewMode mode: The new view mode + """ + if mode == qt.QFileDialog.Detail: + self.showDetails() + elif mode == qt.QFileDialog.List: + self.showList() + else: + assert(False) + + def showList(self): + self.__listView.show() + self.__detailView.hide() + self.setCurrentIndex(0) + + def showDetails(self): + self.__listView.hide() + self.__detailView.show() + self.setCurrentIndex(1) + self.__detailView.updateGeometry() + + def clear(self): + self.__listView.setRootIndex(qt.QModelIndex()) + self.__detailView.setRootIndex(qt.QModelIndex()) + selectionModel = self.__listView.selectionModel() + if selectionModel is not None: + selectionModel.selectionChanged.disconnect() + selectionModel.clear() + selectionModel = self.__detailView.selectionModel() + if selectionModel is not None: + selectionModel.selectionChanged.disconnect() + selectionModel.clear() + self.__listView.setModel(None) + self.__detailView.setModel(None) + + def setRootIndex(self, index, model=None): + """Sets the root item to the item at the given index. + """ + rootIndex = self.__listView.rootIndex() + newModel = model or index.model() + assert(newModel is not None) + + if rootIndex is None or rootIndex.model() is not newModel: + # update the model + selectionModel = self.__listView.selectionModel() + if selectionModel is not None: + selectionModel.selectionChanged.disconnect() + selectionModel.clear() + selectionModel = self.__detailView.selectionModel() + if selectionModel is not None: + selectionModel.selectionChanged.disconnect() + selectionModel.clear() + pIndex = qt.QPersistentModelIndex(index) + self.__listView.setModel(newModel) + # changing the model of the tree view change the index mapping + # that is why we are using a persistance model index + self.__detailView.setModel(newModel) + index = newModel.index(pIndex.row(), pIndex.column(), pIndex.parent()) + selectionModel = self.__listView.selectionModel() + selectionModel.selectionChanged.connect(self.__emitSelected) + selectionModel = self.__detailView.selectionModel() + selectionModel.selectionChanged.connect(self.__emitSelected) + + self.__listView.setRootIndex(index) + self.__detailView.setRootIndex(index) + self.rootIndexChanged.emit(index) + + def rootIndex(self): + """Returns the model index of the model's root item. The root item is + the parent item to the view's toplevel items. The root can be invalid. + """ + return self.__listView.rootIndex() + + __serialVersion = 1 + """Store the current version of the serialized data""" + + def visualRect(self, index): + """Returns the rectangle on the viewport occupied by the item at index. + + :param qt.QModelIndex index: An index + :rtype: QRect + """ + if self.currentIndex() == 0: + return self.__listView.visualRect(index) + else: + return self.__detailView.visualRect(index) + + def viewport(self): + """Returns the viewport widget. + + :param qt.QModelIndex index: An index + :rtype: QRect + """ + if self.currentIndex() == 0: + return self.__listView.viewport() + else: + return self.__detailView.viewport() + + def restoreState(self, state): + """Restores the dialogs's layout, history and current directory to the + state specified. + + :param qt.QByeArray state: Stream containing the new state + :rtype: bool + """ + stream = qt.QDataStream(state, qt.QIODevice.ReadOnly) + + nameId = stream.readQString() + if nameId != "Browser": + _logger.warning("Stored state contains an invalid name id. Browser restoration cancelled.") + return False + + version = stream.readInt32() + if version != self.__serialVersion: + _logger.warning("Stored state contains an invalid version. Browser restoration cancelled.") + return False + + headerData = stream.readQVariant() + self.__detailView.header().restoreState(headerData) + + viewMode = stream.readInt32() + self.setViewMode(viewMode) + return True + + def saveState(self): + """Saves the state of the dialog's layout. + + :rtype: qt.QByteArray + """ + data = qt.QByteArray() + stream = qt.QDataStream(data, qt.QIODevice.WriteOnly) + + nameId = u"Browser" + stream.writeQString(nameId) + stream.writeInt32(self.__serialVersion) + stream.writeQVariant(self.__detailView.header().saveState()) + stream.writeInt32(self.viewMode()) + + return data + + +class _FabioData(object): + + def __init__(self, fabioFile): + self.__fabioFile = fabioFile + + @property + def dtype(self): + # Let say it is a valid type + return numpy.dtype("float") + + @property + def shape(self): + if self.__fabioFile.nframes == 0: + return None + return [self.__fabioFile.nframes, slice(None), slice(None)] + + def __getitem__(self, selector): + if isinstance(selector, tuple) and len(selector) == 1: + selector = selector[0] + + if isinstance(selector, six.integer_types): + if 0 <= selector < self.__fabioFile.nframes: + if self.__fabioFile.nframes == 1: + return self.__fabioFile.data + else: + frame = self.__fabioFile.getframe(selector) + return frame.data + else: + raise ValueError("Invalid selector %s" % selector) + else: + raise TypeError("Unsupported selector type %s" % type(selector)) + + +class _PathEdit(qt.QLineEdit): + pass + + +class _CatchResizeEvent(qt.QObject): + + resized = qt.Signal(qt.QResizeEvent) + + def __init__(self, parent, target): + super(_CatchResizeEvent, self).__init__(parent) + self.__target = target + self.__target_oldResizeEvent = self.__target.resizeEvent + self.__target.resizeEvent = self.__resizeEvent + + def __resizeEvent(self, event): + result = self.__target_oldResizeEvent(event) + self.resized.emit(event) + return result + + +class AbstractDataFileDialog(qt.QDialog): + """The `AbstractFileDialog` provides a generic GUI to create a custom dialog + allowing to access to file resources like HDF5 files or HDF5 datasets + + The dialog contains: + + - Shortcuts: It provides few links to have a fast access of browsing + locations. + - Browser: It provides a display to browse throw the file system and inside + HDF5 files or fabio files. A file format selector is provided. + - URL: Display the URL available to reach the data using + :meth:`silx.io.get_data`, :meth:`silx.io.open`. + - Data selector: A widget to apply a sub selection of the browsed dataset. + This widget can be provided, else nothing will be used. + - Data preview: A widget to preview the selected data, which is the result + of the filter from the data selector. + This widget can be provided, else nothing will be used. + - Preview's toolbar: Provides tools used to custom data preview or data + selector. + This widget can be provided, else nothing will be used. + - Buttons to validate the dialog + """ + + _defaultIconProvider = None + """Lazy loaded default icon provider""" + + def __init__(self, parent=None): + super(AbstractDataFileDialog, self).__init__(parent) + self._init() + + def _init(self): + self.setWindowTitle("Open") + + self.__directory = None + self.__directoryLoadedFilter = None + self.__errorWhileLoadingFile = None + self.__selectedFile = None + self.__selectedData = None + self.__currentHistory = [] + """Store history of URLs, last index one is the latest one""" + self.__currentHistoryLocation = -1 + """Store the location in the history. Bigger is older""" + + self.__processing = 0 + """Number of asynchronous processing tasks""" + self.__h5 = None + self.__fabio = None + + if qt.qVersion() < "5.0": + # On Qt4 it is needed to provide a safe file system model + _logger.debug("Uses SafeFileSystemModel") + from .SafeFileSystemModel import SafeFileSystemModel + self.__fileModel = SafeFileSystemModel(self) + else: + # On Qt5 a safe icon provider is still needed to avoid freeze + _logger.debug("Uses default QFileSystemModel with a SafeFileIconProvider") + self.__fileModel = qt.QFileSystemModel(self) + from .SafeFileIconProvider import SafeFileIconProvider + iconProvider = SafeFileIconProvider() + self.__fileModel.setIconProvider(iconProvider) + + # The common file dialog filter only on Mac OS X + self.__fileModel.setNameFilterDisables(sys.platform == "darwin") + self.__fileModel.setReadOnly(True) + self.__fileModel.directoryLoaded.connect(self.__directoryLoaded) + + self.__dataModel = Hdf5TreeModel(self) + + self.__createWidgets() + self.__initLayout() + self.__showAsListView() + + path = os.getcwd() + self.__fileModel_setRootPath(path) + + self.__clearData() + self.__updatePath() + + # Update the file model filter + self.__fileTypeCombo.setCurrentIndex(0) + self.__filterSelected(0) + + self.__openedFiles = [] + """Store the list of files opened by the model itself.""" + # FIXME: It should be managed one by one by Hdf5Item itself + + # It is not possible to override the QObject destructor nor + # to access to the content of the Python object with the `destroyed` + # signal cause the Python method was already removed with the QWidget, + # while the QObject still exists. + # We use a static method plus explicit references to objects to + # release. The callback do not use any ref to self. + onDestroy = functools.partial(self._closeFileList, self.__openedFiles) + self.destroyed.connect(onDestroy) + + @staticmethod + def _closeFileList(fileList): + """Static method to close explicit references to internal objects.""" + _logger.debug("Clear AbstractDataFileDialog") + for obj in fileList: + _logger.debug("Close file %s", obj.filename) + obj.close() + fileList[:] = [] + + def done(self, result): + self._clear() + super(AbstractDataFileDialog, self).done(result) + + def _clear(self): + """Explicit method to clear data stored in the dialog. + After this call it is not anymore possible to use the widget. + + This method is triggered by the destruction of the object and the + QDialog :meth:`done`. Then it can be triggered more than once. + """ + _logger.debug("Clear dialog") + self.__errorWhileLoadingFile = None + self.__clearData() + if self.__fileModel is not None: + # Cache the directory before cleaning the model + self.__directory = self.directory() + self.__browser.clear() + self.__closeFile() + self.__fileModel = None + self.__dataModel = None + + def hasPendingEvents(self): + """Returns true if the dialog have asynchronous tasks working on the + background.""" + return self.__processing > 0 + + # User interface + + def __createWidgets(self): + self.__sidebar = self._createSideBar() + if self.__sidebar is not None: + sideBarModel = self.__sidebar.selectionModel() + sideBarModel.selectionChanged.connect(self.__shortcutSelected) + self.__sidebar.setSelectionMode(qt.QAbstractItemView.SingleSelection) + + listView = qt.QListView(self) + listView.setSelectionBehavior(qt.QAbstractItemView.SelectRows) + listView.setSelectionMode(qt.QAbstractItemView.SingleSelection) + listView.setResizeMode(qt.QListView.Adjust) + listView.setWrapping(True) + listView.setEditTriggers(qt.QAbstractItemView.NoEditTriggers) + listView.setContextMenuPolicy(qt.Qt.CustomContextMenu) + utils.patchToConsumeReturnKey(listView) + + treeView = qt.QTreeView(self) + treeView.setSelectionBehavior(qt.QAbstractItemView.SelectRows) + treeView.setSelectionMode(qt.QAbstractItemView.SingleSelection) + treeView.setRootIsDecorated(False) + treeView.setItemsExpandable(False) + treeView.setSortingEnabled(True) + treeView.header().setSortIndicator(0, qt.Qt.AscendingOrder) + treeView.header().setStretchLastSection(False) + treeView.setTextElideMode(qt.Qt.ElideMiddle) + treeView.setEditTriggers(qt.QAbstractItemView.NoEditTriggers) + treeView.setContextMenuPolicy(qt.Qt.CustomContextMenu) + treeView.setDragDropMode(qt.QAbstractItemView.InternalMove) + utils.patchToConsumeReturnKey(treeView) + + self.__browser = _Browser(self, listView, treeView) + self.__browser.activated.connect(self.__browsedItemActivated) + self.__browser.selected.connect(self.__browsedItemSelected) + self.__browser.rootIndexChanged.connect(self.__rootIndexChanged) + self.__browser.setObjectName("browser") + + self.__previewWidget = self._createPreviewWidget(self) + + self.__fileTypeCombo = FileTypeComboBox(self) + self.__fileTypeCombo.setObjectName("fileTypeCombo") + self.__fileTypeCombo.setDuplicatesEnabled(False) + self.__fileTypeCombo.setSizeAdjustPolicy(qt.QComboBox.AdjustToMinimumContentsLength) + self.__fileTypeCombo.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed) + self.__fileTypeCombo.activated[int].connect(self.__filterSelected) + self.__fileTypeCombo.setFabioUrlSupproted(self._isFabioFilesSupported()) + + self.__pathEdit = _PathEdit(self) + self.__pathEdit.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed) + self.__pathEdit.textChanged.connect(self.__textChanged) + self.__pathEdit.setObjectName("url") + utils.patchToConsumeReturnKey(self.__pathEdit) + + self.__buttons = qt.QDialogButtonBox(self) + self.__buttons.setSizePolicy(qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed) + types = qt.QDialogButtonBox.Open | qt.QDialogButtonBox.Cancel + self.__buttons.setStandardButtons(types) + self.__buttons.button(qt.QDialogButtonBox.Cancel).setObjectName("cancel") + self.__buttons.button(qt.QDialogButtonBox.Open).setObjectName("open") + + self.__buttons.accepted.connect(self.accept) + self.__buttons.rejected.connect(self.reject) + + self.__browseToolBar = self._createBrowseToolBar() + self.__backwardAction.setEnabled(False) + self.__forwardAction.setEnabled(False) + self.__fileDirectoryAction.setEnabled(False) + self.__parentFileDirectoryAction.setEnabled(False) + + self.__selectorWidget = self._createSelectorWidget(self) + if self.__selectorWidget is not None: + self.__selectorWidget.selectionChanged.connect(self.__selectorWidgetChanged) + + self.__previewToolBar = self._createPreviewToolbar(self, self.__previewWidget, self.__selectorWidget) + + self.__dataIcon = qt.QLabel(self) + self.__dataIcon.setSizePolicy(qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed) + self.__dataIcon.setScaledContents(True) + self.__dataIcon.setMargin(2) + self.__dataIcon.setAlignment(qt.Qt.AlignCenter) + + self.__dataInfo = qt.QLabel(self) + self.__dataInfo.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed) + + def _createSideBar(self): + sidebar = _SideBar(self) + sidebar.setObjectName("sidebar") + return sidebar + + def iconProvider(self): + iconProvider = self.__class__._defaultIconProvider + if iconProvider is None: + iconProvider = _IconProvider() + self.__class__._defaultIconProvider = iconProvider + return iconProvider + + def _createBrowseToolBar(self): + toolbar = qt.QToolBar(self) + toolbar.setIconSize(qt.QSize(16, 16)) + iconProvider = self.iconProvider() + + backward = qt.QAction(toolbar) + backward.setText("Back") + backward.setObjectName("backwardAction") + backward.setIcon(iconProvider.icon(qt.QStyle.SP_ArrowBack)) + backward.triggered.connect(self.__navigateBackward) + self.__backwardAction = backward + + forward = qt.QAction(toolbar) + forward.setText("Forward") + forward.setObjectName("forwardAction") + forward.setIcon(iconProvider.icon(qt.QStyle.SP_ArrowForward)) + forward.triggered.connect(self.__navigateForward) + self.__forwardAction = forward + + parentDirectory = qt.QAction(toolbar) + parentDirectory.setText("Go to parent") + parentDirectory.setObjectName("toParentAction") + parentDirectory.setIcon(iconProvider.icon(qt.QStyle.SP_FileDialogToParent)) + parentDirectory.triggered.connect(self.__navigateToParent) + self.__toParentAction = parentDirectory + + fileDirectory = qt.QAction(toolbar) + fileDirectory.setText("Root of the file") + fileDirectory.setObjectName("toRootFileAction") + fileDirectory.setIcon(iconProvider.icon(iconProvider.FileDialogToParentFile)) + fileDirectory.triggered.connect(self.__navigateToParentFile) + self.__fileDirectoryAction = fileDirectory + + parentFileDirectory = qt.QAction(toolbar) + parentFileDirectory.setText("Parent directory of the file") + parentFileDirectory.setObjectName("toDirectoryAction") + parentFileDirectory.setIcon(iconProvider.icon(iconProvider.FileDialogToParentDir)) + parentFileDirectory.triggered.connect(self.__navigateToParentDir) + self.__parentFileDirectoryAction = parentFileDirectory + + listView = qt.QAction(toolbar) + listView.setText("List view") + listView.setObjectName("listModeAction") + listView.setIcon(iconProvider.icon(qt.QStyle.SP_FileDialogListView)) + listView.triggered.connect(self.__showAsListView) + listView.setCheckable(True) + + detailView = qt.QAction(toolbar) + detailView.setText("Detail view") + detailView.setObjectName("detailModeAction") + detailView.setIcon(iconProvider.icon(qt.QStyle.SP_FileDialogDetailedView)) + detailView.triggered.connect(self.__showAsDetailedView) + detailView.setCheckable(True) + + self.__listViewAction = listView + self.__detailViewAction = detailView + + toolbar.addAction(backward) + toolbar.addAction(forward) + toolbar.addSeparator() + toolbar.addAction(parentDirectory) + toolbar.addAction(fileDirectory) + toolbar.addAction(parentFileDirectory) + toolbar.addSeparator() + toolbar.addAction(listView) + toolbar.addAction(detailView) + + toolbar.setStyleSheet("QToolBar { border: 0px }") + + return toolbar + + def __initLayout(self): + sideBarLayout = qt.QVBoxLayout() + sideBarLayout.setContentsMargins(0, 0, 0, 0) + dummyToolBar = qt.QWidget(self) + dummyToolBar.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed) + dummyCombo = qt.QWidget(self) + dummyCombo.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed) + sideBarLayout.addWidget(dummyToolBar) + if self.__sidebar is not None: + sideBarLayout.addWidget(self.__sidebar) + sideBarLayout.addWidget(dummyCombo) + sideBarWidget = qt.QWidget(self) + sideBarWidget.setLayout(sideBarLayout) + + dummyCombo.setFixedHeight(self.__fileTypeCombo.height()) + self.__resizeCombo = _CatchResizeEvent(self, self.__fileTypeCombo) + self.__resizeCombo.resized.connect(lambda e: dummyCombo.setFixedHeight(e.size().height())) + + dummyToolBar.setFixedHeight(self.__browseToolBar.height()) + self.__resizeToolbar = _CatchResizeEvent(self, self.__browseToolBar) + self.__resizeToolbar.resized.connect(lambda e: dummyToolBar.setFixedHeight(e.size().height())) + + datasetSelection = qt.QWidget(self) + layoutLeft = qt.QVBoxLayout() + layoutLeft.setContentsMargins(0, 0, 0, 0) + layoutLeft.addWidget(self.__browseToolBar) + layoutLeft.addWidget(self.__browser) + layoutLeft.addWidget(self.__fileTypeCombo) + datasetSelection.setLayout(layoutLeft) + datasetSelection.setSizePolicy(qt.QSizePolicy.MinimumExpanding, qt.QSizePolicy.Expanding) + + infoLayout = qt.QHBoxLayout() + infoLayout.setContentsMargins(0, 0, 0, 0) + infoLayout.addWidget(self.__dataIcon) + infoLayout.addWidget(self.__dataInfo) + + dataFrame = qt.QFrame(self) + dataFrame.setFrameShape(qt.QFrame.StyledPanel) + layout = qt.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self.__previewWidget) + layout.addLayout(infoLayout) + dataFrame.setLayout(layout) + + dataSelection = qt.QWidget(self) + dataLayout = qt.QVBoxLayout() + dataLayout.setContentsMargins(0, 0, 0, 0) + if self.__previewToolBar is not None: + dataLayout.addWidget(self.__previewToolBar) + else: + # Add dummy space + dummyToolbar2 = qt.QWidget(self) + dummyToolbar2.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed) + dummyToolbar2.setFixedHeight(self.__browseToolBar.height()) + self.__resizeToolbar = _CatchResizeEvent(self, self.__browseToolBar) + self.__resizeToolbar.resized.connect(lambda e: dummyToolbar2.setFixedHeight(e.size().height())) + dataLayout.addWidget(dummyToolbar2) + + dataLayout.addWidget(dataFrame) + if self.__selectorWidget is not None: + dataLayout.addWidget(self.__selectorWidget) + else: + # Add dummy space + dummyCombo2 = qt.QWidget(self) + dummyCombo2.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed) + dummyCombo2.setFixedHeight(self.__fileTypeCombo.height()) + self.__resizeToolbar = _CatchResizeEvent(self, self.__fileTypeCombo) + self.__resizeToolbar.resized.connect(lambda e: dummyCombo2.setFixedHeight(e.size().height())) + dataLayout.addWidget(dummyCombo2) + dataSelection.setLayout(dataLayout) + + self.__splitter = qt.QSplitter(self) + self.__splitter.setContentsMargins(0, 0, 0, 0) + self.__splitter.addWidget(sideBarWidget) + self.__splitter.addWidget(datasetSelection) + self.__splitter.addWidget(dataSelection) + self.__splitter.setStretchFactor(1, 10) + + bottomLayout = qt.QHBoxLayout() + bottomLayout.setContentsMargins(0, 0, 0, 0) + bottomLayout.addWidget(self.__pathEdit) + bottomLayout.addWidget(self.__buttons) + + layout = qt.QVBoxLayout(self) + layout.addWidget(self.__splitter) + layout.addLayout(bottomLayout) + + self.setLayout(layout) + self.updateGeometry() + + # Logic + + def __navigateBackward(self): + """Navigate through the history one step backward.""" + if len(self.__currentHistory) > 0 and self.__currentHistoryLocation > 0: + self.__currentHistoryLocation -= 1 + url = self.__currentHistory[self.__currentHistoryLocation] + self.selectUrl(url) + + def __navigateForward(self): + """Navigate through the history one step forward.""" + if len(self.__currentHistory) > 0 and self.__currentHistoryLocation < len(self.__currentHistory) - 1: + self.__currentHistoryLocation += 1 + url = self.__currentHistory[self.__currentHistoryLocation] + self.selectUrl(url) + + def __navigateToParent(self): + index = self.__browser.rootIndex() + if index.model() is self.__fileModel: + # browse throw the file system + index = index.parent() + path = self.__fileModel.filePath(index) + self.__fileModel_setRootPath(path) + self.__browser.selectIndex(qt.QModelIndex()) + self.__updatePath() + elif index.model() is self.__dataModel: + index = index.parent() + if index.isValid(): + # browse throw the hdf5 + self.__browser.setRootIndex(index) + self.__browser.selectIndex(qt.QModelIndex()) + self.__updatePath() + else: + # go back to the file system + self.__navigateToParentDir() + else: + # Root of the file system (my computer) + pass + + def __navigateToParentFile(self): + index = self.__browser.rootIndex() + if index.model() is self.__dataModel: + index = self.__dataModel.indexFromH5Object(self.__h5) + self.__browser.setRootIndex(index) + self.__browser.selectIndex(qt.QModelIndex()) + self.__updatePath() + + def __navigateToParentDir(self): + index = self.__browser.rootIndex() + if index.model() is self.__dataModel: + path = os.path.dirname(self.__h5.file.filename) + index = self.__fileModel.index(path) + self.__browser.setRootIndex(index) + self.__browser.selectIndex(qt.QModelIndex()) + self.__closeFile() + self.__updatePath() + + def viewMode(self): + """Returns the current view mode. + + :rtype: qt.QFileDialog.ViewMode + """ + return self.__browser.viewMode() + + def setViewMode(self, mode): + """Set the current view mode. + + :param qt.QFileDialog.ViewMode mode: The new view mode + """ + if mode == qt.QFileDialog.Detail: + self.__browser.showDetails() + self.__listViewAction.setChecked(False) + self.__detailViewAction.setChecked(True) + elif mode == qt.QFileDialog.List: + self.__browser.showList() + self.__listViewAction.setChecked(True) + self.__detailViewAction.setChecked(False) + else: + assert(False) + + def __showAsListView(self): + self.setViewMode(qt.QFileDialog.List) + + def __showAsDetailedView(self): + self.setViewMode(qt.QFileDialog.Detail) + + def __shortcutSelected(self): + self.__browser.selectIndex(qt.QModelIndex()) + self.__clearData() + self.__updatePath() + selectionModel = self.__sidebar.selectionModel() + indexes = selectionModel.selectedIndexes() + if len(indexes) == 1: + index = indexes[0] + url = self.__sidebar.model().data(index, role=qt.Qt.UserRole) + path = url.toLocalFile() + self.__fileModel_setRootPath(path) + + def __browsedItemActivated(self, index): + if not index.isValid(): + return + if index.model() is self.__fileModel: + path = self.__fileModel.filePath(index) + if self.__fileModel.isDir(index): + self.__fileModel_setRootPath(path) + if os.path.isfile(path): + self.__fileActivated(index) + elif index.model() is self.__dataModel: + obj = self.__dataModel.data(index, role=Hdf5TreeModel.H5PY_OBJECT_ROLE) + if silx.io.is_group(obj): + self.__browser.setRootIndex(index) + else: + assert(False) + + def __browsedItemSelected(self, index): + self.__dataSelected(index) + self.__updatePath() + + def __fileModel_setRootPath(self, path): + """Set the root path of the fileModel with a filter on the + directoryLoaded event. + + Without this filter an extra event is received (at least with PyQt4) + when we use for the first time the sidebar. + + :param str path: Path to load + """ + assert(path is not None) + if path != "" and not os.path.exists(path): + return + if self.hasPendingEvents(): + # Make sure the asynchronous fileModel setRootPath is finished + qt.QApplication.instance().processEvents() + + if self.__directoryLoadedFilter is not None: + if utils.samefile(self.__directoryLoadedFilter, path): + return + self.__directoryLoadedFilter = path + self.__processing += 1 + index = self.__fileModel.setRootPath(path) + if not index.isValid(): + self.__processing -= 1 + self.__browser.setRootIndex(index, model=self.__fileModel) + self.__clearData() + self.__updatePath() + else: + # asynchronous process + pass + + def __directoryLoaded(self, path): + if self.__directoryLoadedFilter is not None: + if not utils.samefile(self.__directoryLoadedFilter, path): + # Filter event which should not arrive in PyQt4 + # The first click on the sidebar sent 2 events + self.__processing -= 1 + return + index = self.__fileModel.index(path) + self.__browser.setRootIndex(index, model=self.__fileModel) + self.__updatePath() + self.__processing -= 1 + + def __closeFile(self): + self.__openedFiles[:] = [] + self.__fileDirectoryAction.setEnabled(False) + self.__parentFileDirectoryAction.setEnabled(False) + if self.__h5 is not None: + self.__dataModel.removeH5pyObject(self.__h5) + self.__h5.close() + self.__h5 = None + if self.__fabio is not None: + if hasattr(self.__fabio, "close"): + self.__fabio.close() + self.__fabio = None + + def __openFabioFile(self, filename): + self.__closeFile() + try: + if fabio is None: + raise ImportError("Fabio module is not available") + self.__fabio = fabio.open(filename) + self.__openedFiles.append(self.__fabio) + self.__selectedFile = filename + except Exception as e: + _logger.error("Error while loading file %s: %s", filename, e.args[0]) + _logger.debug("Backtrace", exc_info=True) + self.__errorWhileLoadingFile = filename, e.args[0] + return False + else: + return True + + def __openSilxFile(self, filename): + self.__closeFile() + try: + self.__h5 = silx.io.open(filename) + self.__openedFiles.append(self.__h5) + self.__selectedFile = filename + except IOError as e: + _logger.error("Error while loading file %s: %s", filename, e.args[0]) + _logger.debug("Backtrace", exc_info=True) + self.__errorWhileLoadingFile = filename, e.args[0] + return False + else: + self.__fileDirectoryAction.setEnabled(True) + self.__parentFileDirectoryAction.setEnabled(True) + self.__dataModel.insertH5pyObject(self.__h5) + return True + + def __isSilxHavePriority(self, filename): + """Silx have priority when there is a specific decoder + """ + _, ext = os.path.splitext(filename) + ext = "*%s" % ext + formats = silx.io.supported_extensions(flat_formats=False) + for extensions in formats.values(): + if ext in extensions: + return True + return False + + def __openFile(self, filename): + codec = self.__fileTypeCombo.currentCodec() + openners = [] + if codec.is_autodetect(): + if self.__isSilxHavePriority(filename): + openners.append(self.__openSilxFile) + if fabio is not None and self._isFabioFilesSupported(): + openners.append(self.__openFabioFile) + else: + if fabio is not None and self._isFabioFilesSupported(): + openners.append(self.__openFabioFile) + openners.append(self.__openSilxFile) + elif codec.is_silx_codec(): + openners.append(self.__openSilxFile) + elif self._isFabioFilesSupported() and codec.is_fabio_codec(): + # It is requested to use fabio, anyway fabio is here or not + openners.append(self.__openFabioFile) + + for openner in openners: + ref = openner(filename) + if ref is not None: + return True + return False + + def __fileActivated(self, index): + self.__selectedFile = None + path = self.__fileModel.filePath(index) + if os.path.isfile(path): + loaded = self.__openFile(path) + if loaded: + if self.__h5 is not None: + index = self.__dataModel.indexFromH5Object(self.__h5) + self.__browser.setRootIndex(index) + elif self.__fabio is not None: + data = _FabioData(self.__fabio) + self.__setData(data) + self.__updatePath() + else: + self.__clearData() + + def __dataSelected(self, index): + selectedData = None + if index is not None: + if index.model() is self.__dataModel: + obj = self.__dataModel.data(index, self.__dataModel.H5PY_OBJECT_ROLE) + if self._isDataSupportable(obj): + selectedData = obj + elif index.model() is self.__fileModel: + self.__closeFile() + if self._isFabioFilesSupported(): + path = self.__fileModel.filePath(index) + if os.path.isfile(path): + codec = self.__fileTypeCombo.currentCodec() + is_fabio_decoder = codec.is_fabio_codec() + is_fabio_have_priority = not codec.is_silx_codec() and not self.__isSilxHavePriority(path) + if is_fabio_decoder or is_fabio_have_priority: + # Then it's flat frame container + if fabio is not None: + self.__openFabioFile(path) + if self.__fabio is not None: + selectedData = _FabioData(self.__fabio) + else: + assert(False) + + self.__setData(selectedData) + + def __filterSelected(self, index): + filters = self.__fileTypeCombo.itemExtensions(index) + self.__fileModel.setNameFilters(filters) + + def __setData(self, data): + self.__data = data + + if data is not None and self._isDataSupportable(data): + if self.__selectorWidget is not None: + self.__selectorWidget.setData(data) + if not self.__selectorWidget.isUsed(): + # Needed to fake the fact we have to reset the zoom in preview + self.__selectedData = None + self.__setSelectedData(data) + self.__selectorWidget.hide() + else: + self.__selectorWidget.setVisible(self.__selectorWidget.hasVisibleSelectors()) + # Needed to fake the fact we have to reset the zoom in preview + self.__selectedData = None + self.__selectorWidget.selectionChanged.emit() + else: + # Needed to fake the fact we have to reset the zoom in preview + self.__selectedData = None + self.__setSelectedData(data) + else: + self.__clearData() + self.__updatePath() + + def _isDataSupported(self, data): + """Check if the data can be returned by the dialog. + + If true, this data can be returned by the dialog and the open button + while be enabled. If false the button will be disabled. + + :rtype: bool + """ + raise NotImplementedError() + + def _isDataSupportable(self, data): + """Check if the selected data can be supported at one point. + + If true, the data selector will be checked and it will update the data + preview. Else the selecting is disabled. + + :rtype: bool + """ + raise NotImplementedError() + + def __clearData(self): + """Clear the data part of the GUI""" + if self.__previewWidget is not None: + self.__previewWidget.setData(None) + if self.__selectorWidget is not None: + self.__selectorWidget.hide() + self.__selectedData = None + self.__data = None + self.__updateDataInfo() + button = self.__buttons.button(qt.QDialogButtonBox.Open) + button.setEnabled(False) + + def __selectorWidgetChanged(self): + data = self.__selectorWidget.getSelectedData(self.__data) + self.__setSelectedData(data) + + def __setSelectedData(self, data): + """Set the data selected by the dialog. + + If :meth:`_isDataSupported` returns false, this function will be + inhibited and no data will be selected. + """ + if self.__previewWidget is not None: + fromDataSelector = self.__selectedData is not None + self.__previewWidget.setData(data, fromDataSelector=fromDataSelector) + if self._isDataSupported(data): + self.__selectedData = data + else: + self.__clearData() + return + self.__updateDataInfo() + self.__updatePath() + button = self.__buttons.button(qt.QDialogButtonBox.Open) + button.setEnabled(True) + + def __updateDataInfo(self): + if self.__errorWhileLoadingFile is not None: + filename, message = self.__errorWhileLoadingFile + message = "<b>Error while loading file '%s'</b><hr/>%s" % (filename, message) + size = self.__dataInfo.height() + icon = self.style().standardIcon(qt.QStyle.SP_MessageBoxCritical) + pixmap = icon.pixmap(size, size) + + self.__dataInfo.setText("Error while loading file") + self.__dataInfo.setToolTip(message) + self.__dataIcon.setToolTip(message) + self.__dataIcon.setVisible(True) + self.__dataIcon.setPixmap(pixmap) + + self.__errorWhileLoadingFile = None + return + + self.__dataIcon.setVisible(False) + self.__dataInfo.setToolTip("") + if self.__selectedData is None: + self.__dataInfo.setText("No data selected") + else: + text = self._displayedDataInfo(self.__data, self.__selectedData) + self.__dataInfo.setVisible(text is not None) + if text is not None: + self.__dataInfo.setText(text) + + def _displayedDataInfo(self, dataBeforeSelection, dataAfterSelection): + """Returns the text displayed under the data preview. + + This zone is used to display error in case or problem of data selection + or problems with IO. + + :param numpy.ndarray dataAfterSelection: Data as it is after the + selection widget (basically the data from the preview widget) + :param numpy.ndarray dataAfterSelection: Data as it is before the + selection widget (basically the data from the browsing widget) + :rtype: bool + """ + return None + + def __createUrlFromIndex(self, index, useSelectorWidget=True): + if index.model() is self.__fileModel: + filename = self.__fileModel.filePath(index) + dataPath = None + elif index.model() is self.__dataModel: + obj = self.__dataModel.data(index, role=Hdf5TreeModel.H5PY_OBJECT_ROLE) + filename = obj.file.filename + dataPath = obj.name + else: + # root of the computer + filename = "" + dataPath = None + + if useSelectorWidget and self.__selectorWidget is not None and self.__selectorWidget.isVisible(): + slicing = self.__selectorWidget.slicing() + else: + slicing = None + + if self.__fabio is not None: + scheme = "fabio" + elif self.__h5 is not None: + scheme = "silx" + else: + if os.path.isfile(filename): + codec = self.__fileTypeCombo.currentCodec() + if codec.is_fabio_codec(): + scheme = "fabio" + elif codec.is_silx_codec(): + scheme = "silx" + else: + scheme = None + else: + scheme = None + + url = silx.io.url.DataUrl(file_path=filename, data_path=dataPath, data_slice=slicing, scheme=scheme) + return url + + def __updatePath(self): + index = self.__browser.selectedIndex() + if index is None: + index = self.__browser.rootIndex() + url = self.__createUrlFromIndex(index) + if url.path() != self.__pathEdit.text(): + old = self.__pathEdit.blockSignals(True) + self.__pathEdit.setText(url.path()) + self.__pathEdit.blockSignals(old) + + def __rootIndexChanged(self, index): + url = self.__createUrlFromIndex(index, useSelectorWidget=False) + + currentUrl = None + if 0 <= self.__currentHistoryLocation < len(self.__currentHistory): + currentUrl = self.__currentHistory[self.__currentHistoryLocation] + + if currentUrl is None or currentUrl != url.path(): + # clean up the forward history + self.__currentHistory = self.__currentHistory[0:self.__currentHistoryLocation + 1] + self.__currentHistory.append(url.path()) + self.__currentHistoryLocation += 1 + + if index.model() != self.__dataModel: + if sys.platform == "win32": + # path == "" + isRoot = not index.isValid() + else: + # path in ["", "/"] + isRoot = not index.isValid() or not index.parent().isValid() + else: + isRoot = False + + if index.isValid(): + self.__dataSelected(index) + self.__toParentAction.setEnabled(not isRoot) + self.__updateActionHistory() + self.__updateSidebar() + + def __updateSidebar(self): + """Called when the current directory location change""" + if self.__sidebar is None: + return + selectionModel = self.__sidebar.selectionModel() + selectionModel.selectionChanged.disconnect(self.__shortcutSelected) + index = self.__browser.rootIndex() + if index.model() == self.__fileModel: + path = self.__fileModel.filePath(index) + self.__sidebar.setSelectedPath(path) + elif index.model() is None: + path = "" + self.__sidebar.setSelectedPath(path) + else: + selectionModel.clear() + selectionModel.selectionChanged.connect(self.__shortcutSelected) + + def __updateActionHistory(self): + self.__forwardAction.setEnabled(len(self.__currentHistory) - 1 > self.__currentHistoryLocation) + self.__backwardAction.setEnabled(self.__currentHistoryLocation > 0) + + def __textChanged(self, text): + self.__pathChanged() + + def _isFabioFilesSupported(self): + """Returns true fabio files can be loaded. + """ + return True + + def _isLoadableUrl(self, url): + """Returns true if the URL is loadable by this dialog. + + :param DataUrl url: The requested URL + """ + return True + + def __pathChanged(self): + url = silx.io.url.DataUrl(path=self.__pathEdit.text()) + if url.is_valid() or url.path() == "": + if url.path() in ["", "/"] or url.file_path() in ["", "/"]: + self.__fileModel_setRootPath(qt.QDir.rootPath()) + elif os.path.exists(url.file_path()): + rootIndex = None + if os.path.isdir(url.file_path()): + self.__fileModel_setRootPath(url.file_path()) + index = self.__fileModel.index(url.file_path()) + elif os.path.isfile(url.file_path()): + if self._isLoadableUrl(url): + if url.scheme() == "silx": + loaded = self.__openSilxFile(url.file_path()) + elif url.scheme() == "fabio" and self._isFabioFilesSupported(): + loaded = self.__openFabioFile(url.file_path()) + else: + loaded = self.__openFile(url.file_path()) + else: + loaded = False + if loaded: + if self.__h5 is not None: + rootIndex = self.__dataModel.indexFromH5Object(self.__h5) + elif self.__fabio is not None: + index = self.__fileModel.index(url.file_path()) + rootIndex = index + if rootIndex is None: + index = self.__fileModel.index(url.file_path()) + index = index.parent() + + if rootIndex is not None: + if rootIndex.model() == self.__dataModel: + if url.data_path() is not None: + dataPath = url.data_path() + if dataPath in self.__h5: + obj = self.__h5[dataPath] + else: + path = utils.findClosestSubPath(self.__h5, dataPath) + if path is None: + path = "/" + obj = self.__h5[path] + + if silx.io.is_file(obj): + self.__browser.setRootIndex(rootIndex) + elif silx.io.is_group(obj): + index = self.__dataModel.indexFromH5Object(obj) + self.__browser.setRootIndex(index) + else: + index = self.__dataModel.indexFromH5Object(obj) + self.__browser.setRootIndex(index.parent()) + self.__browser.selectIndex(index) + else: + self.__browser.setRootIndex(rootIndex) + self.__clearData() + elif rootIndex.model() == self.__fileModel: + # that's a fabio file + self.__browser.setRootIndex(rootIndex.parent()) + self.__browser.selectIndex(rootIndex) + # data = _FabioData(self.__fabio) + # self.__setData(data) + else: + assert(False) + else: + self.__browser.setRootIndex(index, model=self.__fileModel) + self.__clearData() + + if self.__selectorWidget is not None: + self.__selectorWidget.setVisible(url.data_slice() is not None) + if url.data_slice() is not None: + self.__selectorWidget.setSlicing(url.data_slice()) + else: + self.__errorWhileLoadingFile = (url.file_path(), "File not found") + self.__clearData() + else: + self.__errorWhileLoadingFile = (url.file_path(), "Path invalid") + self.__clearData() + + def previewToolbar(self): + return self.__previewToolbar + + def previewWidget(self): + return self.__previewWidget + + def selectorWidget(self): + return self.__selectorWidget + + def _createPreviewToolbar(self, parent, dataPreviewWidget, dataSelectorWidget): + return None + + def _createPreviewWidget(self, parent): + return None + + def _createSelectorWidget(self, parent): + return None + + # Selected file + + def setDirectory(self, path): + """Sets the data dialog's current directory.""" + self.__fileModel_setRootPath(path) + + def selectedFile(self): + """Returns the file path containing the selected data. + + :rtype: str + """ + return self.__selectedFile + + def selectFile(self, filename): + """Sets the data dialog's current file.""" + self.__directoryLoadedFilter = "" + old = self.__pathEdit.blockSignals(True) + try: + self.__pathEdit.setText(filename) + finally: + self.__pathEdit.blockSignals(old) + self.__pathChanged() + + # Selected data + + def selectUrl(self, url): + """Sets the data dialog's current data url. + + :param Union[str,DataUrl] url: URL identifying a data (it can be a + `DataUrl` object) + """ + if isinstance(url, silx.io.url.DataUrl): + url = url.path() + self.__directoryLoadedFilter = "" + old = self.__pathEdit.blockSignals(True) + try: + self.__pathEdit.setText(url) + finally: + self.__pathEdit.blockSignals(old) + self.__pathChanged() + + def selectedUrl(self): + """Returns the URL from the file system to the data. + + If the dialog is not validated, the path can be an intermediat + selected path, or an invalid path. + + :rtype: str + """ + return self.__pathEdit.text() + + def selectedDataUrl(self): + """Returns the URL as a :class:`DataUrl` from the file system to the + data. + + If the dialog is not validated, the path can be an intermediat + selected path, or an invalid path. + + :rtype: DataUrl + """ + url = self.selectedUrl() + return silx.io.url.DataUrl(url) + + def directory(self): + """Returns the path from the current browsed directory. + + :rtype: str + """ + if self.__directory is not None: + # At post execution, returns the cache + return self.__directory + + index = self.__browser.rootIndex() + if index.model() is self.__fileModel: + path = self.__fileModel.filePath(index) + return path + elif index.model() is self.__dataModel: + path = os.path.dirname(self.__h5.file.filename) + return path + else: + return "" + + def _selectedData(self): + """Returns the internal selected data + + :rtype: numpy.ndarray + """ + return self.__selectedData + + # Filters + + def selectedNameFilter(self): + """Returns the filter that the user selected in the file dialog.""" + return self.__fileTypeCombo.currentText() + + # History + + def history(self): + """Returns the browsing history of the filedialog as a list of paths. + + :rtype: List<str> + """ + if len(self.__currentHistory) <= 1: + return [] + history = self.__currentHistory[0:self.__currentHistoryLocation] + return list(history) + + def setHistory(self, history): + self.__currentHistory = [] + self.__currentHistory.extend(history) + self.__currentHistoryLocation = len(self.__currentHistory) - 1 + self.__updateActionHistory() + + # Colormap + + def colormap(self): + if self.__previewWidget is None: + return None + return self.__previewWidget.colormap() + + def setColormap(self, colormap): + if self.__previewWidget is None: + raise RuntimeError("No preview widget defined") + self.__previewWidget.setColormap(colormap) + + # Sidebar + + def setSidebarUrls(self, urls): + """Sets the urls that are located in the sidebar.""" + if self.__sidebar is None: + return + self.__sidebar.setUrls(urls) + + def sidebarUrls(self): + """Returns a list of urls that are currently in the sidebar.""" + if self.__sidebar is None: + return [] + return self.__sidebar.urls() + + # State + + __serialVersion = 1 + """Store the current version of the serialized data""" + + @classmethod + def qualifiedName(cls): + return "%s.%s" % (cls.__module__, cls.__name__) + + def restoreState(self, state): + """Restores the dialogs's layout, history and current directory to the + state specified. + + :param qt.QByteArray state: Stream containing the new state + :rtype: bool + """ + stream = qt.QDataStream(state, qt.QIODevice.ReadOnly) + + qualifiedName = stream.readQString() + if qualifiedName != self.qualifiedName(): + _logger.warning("Stored state contains an invalid qualified name. %s restoration cancelled.", self.__class__.__name__) + return False + + version = stream.readInt32() + if version != self.__serialVersion: + _logger.warning("Stored state contains an invalid version. %s restoration cancelled.", self.__class__.__name__) + return False + + result = True + + splitterData = stream.readQVariant() + sidebarUrls = stream.readQStringList() + history = stream.readQStringList() + workingDirectory = stream.readQString() + browserData = stream.readQVariant() + viewMode = stream.readInt32() + colormapData = stream.readQVariant() + + result &= self.__splitter.restoreState(splitterData) + sidebarUrls = [qt.QUrl(s) for s in sidebarUrls] + self.setSidebarUrls(list(sidebarUrls)) + history = [s for s in history] + self.setHistory(list(history)) + if workingDirectory is not None: + self.setDirectory(workingDirectory) + result &= self.__browser.restoreState(browserData) + self.setViewMode(viewMode) + colormap = self.colormap() + if colormap is not None: + result &= self.colormap().restoreState(colormapData) + + return result + + def saveState(self): + """Saves the state of the dialog's layout, history and current + directory. + + :rtype: qt.QByteArray + """ + data = qt.QByteArray() + stream = qt.QDataStream(data, qt.QIODevice.WriteOnly) + + s = self.qualifiedName() + stream.writeQString(u"%s" % s) + stream.writeInt32(self.__serialVersion) + stream.writeQVariant(self.__splitter.saveState()) + strings = [u"%s" % s.toString() for s in self.sidebarUrls()] + stream.writeQStringList(strings) + strings = [u"%s" % s for s in self.history()] + stream.writeQStringList(strings) + stream.writeQString(u"%s" % self.directory()) + stream.writeQVariant(self.__browser.saveState()) + stream.writeInt32(self.viewMode()) + colormap = self.colormap() + if colormap is not None: + stream.writeQVariant(self.colormap().saveState()) + else: + stream.writeQVariant(None) + + return data diff --git a/silx/gui/dialog/DataFileDialog.py b/silx/gui/dialog/DataFileDialog.py new file mode 100644 index 0000000..7ff1258 --- /dev/null +++ b/silx/gui/dialog/DataFileDialog.py @@ -0,0 +1,342 @@ +# 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. +# +# ###########################################################################*/ +""" +This module contains an :class:`DataFileDialog`. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "14/02/2018" + +import logging +from silx.gui import qt +from silx.gui.hdf5.Hdf5Formatter import Hdf5Formatter +import silx.io +from .AbstractDataFileDialog import AbstractDataFileDialog +from silx.third_party import enum +try: + import fabio +except ImportError: + fabio = None + + +_logger = logging.getLogger(__name__) + + +class _DataPreview(qt.QWidget): + """Provide a preview of the selected image""" + + def __init__(self, parent=None): + super(_DataPreview, self).__init__(parent) + + self.__formatter = Hdf5Formatter(self) + self.__data = None + self.__info = qt.QTableView(self) + self.__model = qt.QStandardItemModel(self) + self.__info.setModel(self.__model) + self.__info.horizontalHeader().hide() + self.__info.horizontalHeader().setStretchLastSection(True) + layout = qt.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.__info) + self.setLayout(layout) + + def colormap(self): + return None + + def setColormap(self, colormap): + # Ignored + pass + + def sizeHint(self): + return qt.QSize(200, 200) + + def setData(self, data, fromDataSelector=False): + self.__info.setEnabled(data is not None) + if data is None: + self.__model.clear() + else: + self.__model.clear() + + if silx.io.is_dataset(data): + kind = "Dataset" + elif silx.io.is_group(data): + kind = "Group" + elif silx.io.is_file(data): + kind = "File" + else: + kind = "Unknown" + + headers = [] + + basename = data.name.split("/")[-1] + if basename == "": + basename = "/" + headers.append("Basename") + self.__model.appendRow([qt.QStandardItem(basename)]) + headers.append("Kind") + self.__model.appendRow([qt.QStandardItem(kind)]) + if hasattr(data, "dtype"): + headers.append("Type") + text = self.__formatter.humanReadableType(data) + self.__model.appendRow([qt.QStandardItem(text)]) + if hasattr(data, "shape"): + headers.append("Shape") + text = self.__formatter.humanReadableShape(data) + self.__model.appendRow([qt.QStandardItem(text)]) + if hasattr(data, "attrs") and "NX_class" in data.attrs: + headers.append("NX_class") + value = data.attrs["NX_class"] + formatter = self.__formatter.textFormatter() + old = formatter.useQuoteForText() + formatter.setUseQuoteForText(False) + text = self.__formatter.textFormatter().toString(value) + formatter.setUseQuoteForText(old) + self.__model.appendRow([qt.QStandardItem(text)]) + self.__model.setVerticalHeaderLabels(headers) + self.__data = data + + def __imageItem(self): + image = self.__plot.getImage("data") + return image + + def data(self): + if self.__data is not None: + if hasattr(self.__data, "name"): + # in case of HDF5 + if self.__data.name is None: + # The dataset was closed + self.__data = None + return self.__data + + def clear(self): + self.__data = None + self.__info.setText("") + + +class DataFileDialog(AbstractDataFileDialog): + """The `DataFileDialog` class provides a dialog that allow users to select + any datasets or groups from an HDF5-like file. + + The `DataFileDialog` class enables a user to traverse the file system in + order to select an HDF5-like file. Then to traverse the file to select an + HDF5 node. + + .. image:: img/datafiledialog.png + + The selected data is any kind of group or dataset. It can be restricted + to only existing datasets or only existing groups using + :meth:`setFilterMode`. A callback can be defining using + :meth:`setFilterCallback` to filter even more data which can be returned. + + Filtering data which can be returned by a `DataFileDialog` can be done like + that: + + .. code-block:: python + + # Force to return only a dataset + dialog = DataFileDialog() + dialog.setFilterMode(DataFileDialog.FilterMode.ExistingDataset) + + .. code-block:: python + + def customFilter(obj): + if "NX_class" in obj.attrs: + return obj.attrs["NX_class"] in [b"NXentry", u"NXentry"] + return False + + # Force to return an NX entry + dialog = DataFileDialog() + # 1st, filter out everything which is not a group + dialog.setFilterMode(DataFileDialog.FilterMode.ExistingGroup) + # 2nd, check what NX_class is an NXentry + dialog.setFilterCallback(customFilter) + + Executing a `DataFileDialog` can be done like that: + + .. code-block:: python + + dialog = DataFileDialog() + result = dialog.exec_() + if result: + print("Selection:") + print(dialog.selectedFile()) + print(dialog.selectedUrl()) + else: + print("Nothing selected") + + If the selection is a dataset you can access to the data using + :meth:`selectedData`. + + If the selection is a group or if you want to read the selected object on + your own you can use the `silx.io` API. + + .. code-block:: python + + url = dialog.selectedUrl() + with silx.io.open(url) as data: + pass + + Or by loading the file first + + .. code-block:: python + + url = dialog.selectedDataUrl() + with silx.io.open(url.file_path()) as h5: + data = h5[url.data_path()] + + Or by using `h5py` library + + .. code-block:: python + + url = dialog.selectedDataUrl() + with h5py.File(url.file_path()) as h5: + data = h5[url.data_path()] + """ + + class FilterMode(enum.Enum): + """This enum is used to indicate what the user may select in the + dialog; i.e. what the dialog will return if the user clicks OK.""" + + AnyNode = 0 + """Any existing node from an HDF5-like file.""" + ExistingDataset = 1 + """An existing HDF5-like dataset.""" + ExistingGroup = 2 + """An existing HDF5-like group. A file root is a group.""" + + def __init__(self, parent=None): + AbstractDataFileDialog.__init__(self, parent=parent) + self.__filter = DataFileDialog.FilterMode.AnyNode + self.__filterCallback = None + + def selectedData(self): + """Returns the selected data by using the :meth:`silx.io.get_data` + API with the selected URL provided by the dialog. + + If the URL identify a group of a file it will raise an exception. For + group or file you have to use on your own the API :meth:`silx.io.open`. + + :rtype: numpy.ndarray + :raise ValueError: If the URL do not link to a dataset + """ + url = self.selectedUrl() + return silx.io.get_data(url) + + def _createPreviewWidget(self, parent): + previewWidget = _DataPreview(parent) + previewWidget.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Expanding) + return previewWidget + + def _createSelectorWidget(self, parent): + # There is no selector + return None + + def _createPreviewToolbar(self, parent, dataPreviewWidget, dataSelectorWidget): + # There is no toolbar + return None + + def _isDataSupportable(self, data): + """Check if the selected data can be supported at one point. + + If true, the data selector will be checked and it will update the data + preview. Else the selecting is disabled. + + :rtype: bool + """ + # Everything is supported + return True + + def _isFabioFilesSupported(self): + # Everything is supported + return False + + def _isDataSupported(self, data): + """Check if the data can be returned by the dialog. + + If true, this data can be returned by the dialog and the open button + will be enabled. If false the button will be disabled. + + :rtype: bool + """ + if self.__filter == DataFileDialog.FilterMode.AnyNode: + accepted = True + elif self.__filter == DataFileDialog.FilterMode.ExistingDataset: + accepted = silx.io.is_dataset(data) + elif self.__filter == DataFileDialog.FilterMode.ExistingGroup: + accepted = silx.io.is_group(data) + else: + raise ValueError("Filter %s is not supported" % self.__filter) + if not accepted: + return False + if self.__filterCallback is not None: + try: + return self.__filterCallback(data) + except Exception: + _logger.error("Error while executing custom callback", exc_info=True) + return False + return True + + def setFilterCallback(self, callback): + """Set the filter callback. This filter is applied only if the filter + mode (:meth:`filterMode`) first accepts the selected data. + + It is not supposed to be set while the dialog is being used. + + :param callable callback: Define a custom function returning a boolean + and taking as argument an h5-like node. If the function returns true + the dialog can return the associated URL. + """ + self.__filterCallback = callback + + def setFilterMode(self, mode): + """Set the filter mode. + + It is not supposed to be set while the dialog is being used. + + :param DataFileDialog.FilterMode mode: The new filter. + """ + self.__filter = mode + + def fileMode(self): + """Returns the filter mode. + + :rtype: DataFileDialog.FilterMode + """ + return self.__filter + + def _displayedDataInfo(self, dataBeforeSelection, dataAfterSelection): + """Returns the text displayed under the data preview. + + This zone is used to display error in case or problem of data selection + or problems with IO. + + :param numpy.ndarray dataAfterSelection: Data as it is after the + selection widget (basically the data from the preview widget) + :param numpy.ndarray dataAfterSelection: Data as it is before the + selection widget (basically the data from the browsing widget) + :rtype: bool + """ + return u"" diff --git a/silx/gui/dialog/FileTypeComboBox.py b/silx/gui/dialog/FileTypeComboBox.py new file mode 100644 index 0000000..07b11cf --- /dev/null +++ b/silx/gui/dialog/FileTypeComboBox.py @@ -0,0 +1,213 @@ +# 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. +# +# ###########################################################################*/ +""" +This module contains utilitaries used by other dialog modules. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "06/02/2018" + +try: + import fabio +except ImportError: + fabio = None +import silx.io +from silx.gui import qt + + +class Codec(object): + + def __init__(self, any_fabio=False, any_silx=False, fabio_codec=None, auto=False): + self.__any_fabio = any_fabio + self.__any_silx = any_silx + self.fabio_codec = fabio_codec + self.__auto = auto + + def is_autodetect(self): + return self.__auto + + def is_fabio_codec(self): + return self.__any_fabio or self.fabio_codec is not None + + def is_silx_codec(self): + return self.__any_silx + + +class FileTypeComboBox(qt.QComboBox): + """ + A combobox providing all image file formats supported by fabio and silx. + + It provides access for each fabio codecs individually. + """ + + EXTENSIONS_ROLE = qt.Qt.UserRole + 1 + + CODEC_ROLE = qt.Qt.UserRole + 2 + + INDENTATION = u"\u2022 " + + def __init__(self, parent=None): + qt.QComboBox.__init__(self, parent) + self.__fabioUrlSupported = True + self.__initItems() + + def setFabioUrlSupproted(self, isSupported): + if self.__fabioUrlSupported == isSupported: + return + self.__fabioUrlSupported = isSupported + self.__initItems() + + def __initItems(self): + self.clear() + if fabio is not None and self.__fabioUrlSupported: + self.__insertFabioFormats() + self.__insertSilxFormats() + self.__insertAllSupported() + self.__insertAnyFiles() + + def __insertAnyFiles(self): + index = self.count() + self.addItem("All files (*)") + self.setItemData(index, ["*"], role=self.EXTENSIONS_ROLE) + self.setItemData(index, Codec(auto=True), role=self.CODEC_ROLE) + + def __insertAllSupported(self): + allExtensions = set([]) + for index in range(self.count()): + ext = self.itemExtensions(index) + allExtensions.update(ext) + allExtensions = allExtensions - set("*") + list(sorted(list(allExtensions))) + index = 0 + self.insertItem(index, "All supported files") + self.setItemData(index, allExtensions, role=self.EXTENSIONS_ROLE) + self.setItemData(index, Codec(auto=True), role=self.CODEC_ROLE) + + def __insertSilxFormats(self): + formats = silx.io.supported_extensions() + + extensions = [] + allExtensions = set([]) + + for description, ext in formats.items(): + allExtensions.update(ext) + if ext == []: + ext = ["*"] + extensions.append((description, ext, "silx")) + extensions = list(sorted(extensions)) + + allExtensions = list(sorted(list(allExtensions))) + index = self.count() + self.addItem("All supported files, using Silx") + self.setItemData(index, allExtensions, role=self.EXTENSIONS_ROLE) + self.setItemData(index, Codec(any_silx=True), role=self.CODEC_ROLE) + + for e in extensions: + index = self.count() + if len(e[1]) < 10: + self.addItem("%s%s (%s)" % (self.INDENTATION, e[0], " ".join(e[1]))) + else: + self.addItem("%s%s" % (self.INDENTATION, e[0])) + codec = Codec(any_silx=True) + self.setItemData(index, e[1], role=self.EXTENSIONS_ROLE) + self.setItemData(index, codec, role=self.CODEC_ROLE) + + def __insertFabioFormats(self): + formats = fabio.fabioformats.get_classes(reader=True) + + extensions = [] + allExtensions = set([]) + + for reader in formats: + if not hasattr(reader, "DESCRIPTION"): + continue + if not hasattr(reader, "DEFAULT_EXTENSIONS"): + continue + + ext = reader.DEFAULT_EXTENSIONS + ext = ["*.%s" % e for e in ext] + allExtensions.update(ext) + if ext == []: + ext = ["*"] + extensions.append((reader.DESCRIPTION, ext, reader.codec_name())) + extensions = list(sorted(extensions)) + + allExtensions = list(sorted(list(allExtensions))) + index = self.count() + self.addItem("All supported files, using Fabio") + self.setItemData(index, allExtensions, role=self.EXTENSIONS_ROLE) + self.setItemData(index, Codec(any_fabio=True), role=self.CODEC_ROLE) + + for e in extensions: + index = self.count() + if len(e[1]) < 10: + self.addItem("%s%s (%s)" % (self.INDENTATION, e[0], " ".join(e[1]))) + else: + self.addItem(e[0]) + codec = Codec(fabio_codec=e[2]) + self.setItemData(index, e[1], role=self.EXTENSIONS_ROLE) + self.setItemData(index, codec, role=self.CODEC_ROLE) + + def itemExtensions(self, index): + """Returns the extensions associated to an index.""" + result = self.itemData(index, self.EXTENSIONS_ROLE) + if result is None: + result = None + return result + + def currentExtensions(self): + """Returns the current selected extensions.""" + index = self.currentIndex() + return self.itemExtensions(index) + + def indexFromCodec(self, codecName): + for i in range(self.count()): + codec = self.itemCodec(i) + if codecName == "auto": + if codec.is_autodetect(): + return i + elif codecName == "silx": + if codec.is_silx_codec(): + return i + elif codecName == "fabio": + if codec.is_fabio_codec() and codec.fabio_codec is None: + return i + elif codecName == codec.fabio_codec: + return i + return -1 + + def itemCodec(self, index): + """Returns the codec associated to an index.""" + result = self.itemData(index, self.CODEC_ROLE) + if result is None: + result = None + return result + + def currentCodec(self): + """Returns the current selected codec. None if nothing selected + or if the item is not a codec""" + index = self.currentIndex() + return self.itemCodec(index) diff --git a/silx/gui/dialog/ImageFileDialog.py b/silx/gui/dialog/ImageFileDialog.py new file mode 100644 index 0000000..c324071 --- /dev/null +++ b/silx/gui/dialog/ImageFileDialog.py @@ -0,0 +1,338 @@ +# 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. +# +# ###########################################################################*/ +""" +This module contains an :class:`ImageFileDialog`. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "12/02/2018" + +import logging +from silx.gui.plot import actions +from silx.gui import qt +from silx.gui.plot.PlotWidget import PlotWidget +from .AbstractDataFileDialog import AbstractDataFileDialog +import silx.io +try: + import fabio +except ImportError: + fabio = None + + +_logger = logging.getLogger(__name__) + + +class _ImageSelection(qt.QWidget): + """Provide a widget allowing to select an image from an hypercube by + selecting a slice.""" + + selectionChanged = qt.Signal() + """Emitted when the selection change.""" + + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent) + self.__shape = None + self.__axis = [] + layout = qt.QVBoxLayout() + self.setLayout(layout) + + def hasVisibleSelectors(self): + return self.__visibleSliders > 0 + + def isUsed(self): + if self.__shape is None: + return None + return len(self.__shape) > 2 + + def getSelectedData(self, data): + slicing = self.slicing() + image = data[slicing] + return image + + def setData(self, data): + shape = data.shape + if self.__shape is not None: + # clean up + for widget in self.__axis: + self.layout().removeWidget(widget) + widget.deleteLater() + self.__axis = [] + + self.__shape = shape + self.__visibleSliders = 0 + + if shape is not None: + # create expected axes + for index in range(len(shape) - 2): + axis = qt.QSlider(self) + axis.setMinimum(0) + axis.setMaximum(shape[index] - 1) + axis.setOrientation(qt.Qt.Horizontal) + if shape[index] == 1: + axis.setVisible(False) + else: + self.__visibleSliders += 1 + + axis.valueChanged.connect(self.__axisValueChanged) + self.layout().addWidget(axis) + self.__axis.append(axis) + + self.selectionChanged.emit() + + def __axisValueChanged(self): + self.selectionChanged.emit() + + def slicing(self): + slicing = [] + for axes in self.__axis: + slicing.append(axes.value()) + return tuple(slicing) + + def setSlicing(self, slicing): + for i, value in enumerate(slicing): + if i > len(self.__axis): + break + self.__axis[i].setValue(value) + + +class _ImagePreview(qt.QWidget): + """Provide a preview of the selected image""" + + def __init__(self, parent=None): + super(_ImagePreview, self).__init__(parent) + + self.__data = None + self.__plot = PlotWidget(self) + self.__plot.setAxesDisplayed(False) + self.__plot.setKeepDataAspectRatio(True) + layout = qt.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.__plot) + self.setLayout(layout) + + def resizeEvent(self, event): + self.__updateConstraints() + return qt.QWidget.resizeEvent(self, event) + + def sizeHint(self): + return qt.QSize(200, 200) + + def plot(self): + return self.__plot + + def setData(self, data, fromDataSelector=False): + if data is None: + self.clear() + return + + resetzoom = not fromDataSelector + previousImage = self.data() + if previousImage is not None and data.shape != previousImage.shape: + resetzoom = True + + self.__plot.addImage(legend="data", data=data, resetzoom=resetzoom) + self.__data = data + self.__updateConstraints() + + def __updateConstraints(self): + """ + Update the constraints depending on the size of the widget + """ + image = self.data() + if image is None: + return + size = self.size() + if size.width() == 0 or size.height() == 0: + return + + heightData, widthData = image.shape + + widthContraint = heightData * size.width() / size.height() + if widthContraint > widthData: + heightContraint = heightData + else: + heightContraint = heightData * size.height() / size.width() + widthContraint = widthData + + midWidth, midHeight = widthData * 0.5, heightData * 0.5 + heightContraint, widthContraint = heightContraint * 0.5, widthContraint * 0.5 + + axis = self.__plot.getXAxis() + axis.setLimitsConstraints(midWidth - widthContraint, midWidth + widthContraint) + axis = self.__plot.getYAxis() + axis.setLimitsConstraints(midHeight - heightContraint, midHeight + heightContraint) + + def __imageItem(self): + image = self.__plot.getImage("data") + return image + + def data(self): + if self.__data is not None: + if hasattr(self.__data, "name"): + # in case of HDF5 + if self.__data.name is None: + # The dataset was closed + self.__data = None + return self.__data + + def colormap(self): + image = self.__imageItem() + if image is not None: + return image.getColormap() + return self.__plot.getDefaultColormap() + + def setColormap(self, colormap): + self.__plot.setDefaultColormap(colormap) + + def clear(self): + self.__data = None + image = self.__imageItem() + if image is not None: + self.__plot.removeImage(legend="data") + + +class ImageFileDialog(AbstractDataFileDialog): + """The `ImageFileDialog` class provides a dialog that allow users to select + an image from a file. + + The `ImageFileDialog` class enables a user to traverse the file system in + order to select one file. Then to traverse the file to select a frame or + a slice of a dataset. + + .. image:: img/imagefiledialog_h5.png + + It supports fast access to image files using `FabIO`. Which is not the case + of the default silx API. Image files still also can be available using the + NeXus layout, by editing the file type combo box. + + .. image:: img/imagefiledialog_edf.png + + The selected data is an numpy array with 2 dimension. + + Using an `ImageFileDialog` can be done like that. + + .. code-block:: python + + dialog = ImageFileDialog() + result = dialog.exec_() + if result: + print("Selection:") + print(dialog.selectedFile()) + print(dialog.selectedUrl()) + print(dialog.selectedImage()) + else: + print("Nothing selected") + """ + + def selectedImage(self): + """Returns the selected image data as numpy + + :rtype: numpy.ndarray + """ + url = self.selectedUrl() + return silx.io.get_data(url) + + def _createPreviewWidget(self, parent): + previewWidget = _ImagePreview(parent) + previewWidget.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Expanding) + return previewWidget + + def _createSelectorWidget(self, parent): + return _ImageSelection(parent) + + def _createPreviewToolbar(self, parent, dataPreviewWidget, dataSelectorWidget): + plot = dataPreviewWidget.plot() + toolbar = qt.QToolBar(parent) + toolbar.setIconSize(qt.QSize(16, 16)) + toolbar.setStyleSheet("QToolBar { border: 0px }") + toolbar.addAction(actions.mode.ZoomModeAction(plot, parent)) + toolbar.addAction(actions.mode.PanModeAction(plot, parent)) + toolbar.addSeparator() + toolbar.addAction(actions.control.ResetZoomAction(plot, parent)) + toolbar.addSeparator() + toolbar.addAction(actions.control.ColormapAction(plot, parent)) + return toolbar + + def _isDataSupportable(self, data): + """Check if the selected data can be supported at one point. + + If true, the data selector will be checked and it will update the data + preview. Else the selecting is disabled. + + :rtype: bool + """ + if not hasattr(data, "dtype"): + # It is not an HDF5 dataset nor a fabio image wrapper + return False + + if data is None or data.shape is None: + return False + + if data.dtype.kind not in set(["f", "u", "i", "b"]): + return False + + dim = len(data.shape) + return dim >= 2 + + def _isFabioFilesSupported(self): + return True + + def _isDataSupported(self, data): + """Check if the data can be returned by the dialog. + + If true, this data can be returned by the dialog and the open button + while be enabled. If false the button will be disabled. + + :rtype: bool + """ + dim = len(data.shape) + return dim == 2 + + def _displayedDataInfo(self, dataBeforeSelection, dataAfterSelection): + """Returns the text displayed under the data preview. + + This zone is used to display error in case or problem of data selection + or problems with IO. + + :param numpy.ndarray dataAfterSelection: Data as it is after the + selection widget (basically the data from the preview widget) + :param numpy.ndarray dataAfterSelection: Data as it is before the + selection widget (basically the data from the browsing widget) + :rtype: bool + """ + destination = self.__formatShape(dataAfterSelection.shape) + source = self.__formatShape(dataBeforeSelection.shape) + return u"%s \u2192 %s" % (source, destination) + + def __formatShape(self, shape): + result = [] + for s in shape: + if isinstance(s, slice): + v = u"\u2026" + else: + v = str(s) + result.append(v) + return u" \u00D7 ".join(result) diff --git a/silx/gui/dialog/SafeFileIconProvider.py b/silx/gui/dialog/SafeFileIconProvider.py new file mode 100644 index 0000000..7fac7c0 --- /dev/null +++ b/silx/gui/dialog/SafeFileIconProvider.py @@ -0,0 +1,150 @@ +# 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. +# +# ###########################################################################*/ +""" +This module contains :class:`SafeIconProvider`. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "31/10/2017" + +import sys +import logging +from silx.gui import qt + + +_logger = logging.getLogger(__name__) + + +class SafeFileIconProvider(qt.QFileIconProvider): + """ + This class reimplement :class:`qt.QFileIconProvider` to avoid blocking + access to the file system. + + It avoid to use `qt.QFileInfo.absoluteFilePath` or + `qt.QFileInfo.canonicalPath` to reach drive icons which are known to + freeze the file system using network drives. + + Computer root, and drive root paths are filtered. Other paths are not + filtered while it is anyway needed to synchronoze a drive to accesss to it. + """ + + WIN32_DRIVE_UNKNOWN = 0 + """The drive type cannot be determined.""" + WIN32_DRIVE_NO_ROOT_DIR = 1 + """The root path is invalid; for example, there is no volume mounted at the + specified path.""" + WIN32_DRIVE_REMOVABLE = 2 + """The drive has removable media; for example, a floppy drive, thumb drive, + or flash card reader.""" + WIN32_DRIVE_FIXED = 3 + """The drive has fixed media; for example, a hard disk drive or flash + drive.""" + WIN32_DRIVE_REMOTE = 4 + """The drive is a remote (network) drive.""" + WIN32_DRIVE_CDROM = 5 + """The drive is a CD-ROM drive.""" + WIN32_DRIVE_RAMDISK = 6 + """The drive is a RAM disk.""" + + def __init__(self): + qt.QFileIconProvider.__init__(self) + self.__filterDirAndFiles = False + if sys.platform == "win32": + self._windowsTypes = {} + item = "Drive", qt.QStyle.SP_DriveHDIcon + self._windowsTypes[self.WIN32_DRIVE_UNKNOWN] = item + item = "Invalid root", qt.QStyle.SP_DriveHDIcon + self._windowsTypes[self.WIN32_DRIVE_NO_ROOT_DIR] = item + item = "Removable", qt.QStyle.SP_DriveNetIcon + self._windowsTypes[self.WIN32_DRIVE_REMOVABLE] = item + item = "Drive", qt.QStyle.SP_DriveHDIcon + self._windowsTypes[self.WIN32_DRIVE_FIXED] = item + item = "Remote", qt.QStyle.SP_DriveNetIcon + self._windowsTypes[self.WIN32_DRIVE_REMOTE] = item + item = "CD-ROM", qt.QStyle.SP_DriveCDIcon + self._windowsTypes[self.WIN32_DRIVE_CDROM] = item + item = "RAM disk", qt.QStyle.SP_DriveHDIcon + self._windowsTypes[self.WIN32_DRIVE_RAMDISK] = item + + def __windowsDriveTypeId(self, info): + try: + import ctypes + path = info.filePath() + dtype = ctypes.cdll.kernel32.GetDriveTypeW(path) + except Exception: + _logger.warning("Impossible to identify drive %s" % path) + _logger.debug("Backtrace", exc_info=True) + return self.WIN32_DRIVE_UNKNOWN + return dtype + + def __windowsDriveIcon(self, info): + dtype = self.__windowsDriveTypeId(info) + default = self._windowsTypes[self.WIN32_DRIVE_UNKNOWN] + driveInfo = self._windowsTypes.get(dtype, default) + style = qt.QApplication.instance().style() + icon = style.standardIcon(driveInfo[1]) + return icon + + def __windowsDriveType(self, info): + dtype = self.__windowsDriveTypeId(info) + default = self._windowsTypes[self.WIN32_DRIVE_UNKNOWN] + driveInfo = self._windowsTypes.get(dtype, default) + return driveInfo[0] + + def icon(self, info): + style = qt.QApplication.instance().style() + path = info.filePath() + if path in ["", "/"]: + # That's the computer root on Windows or Linux + result = style.standardIcon(qt.QStyle.SP_ComputerIcon) + elif sys.platform == "win32" and path[-2] == ":": + # That's a drive on Windows + result = self.__windowsDriveIcon(info) + elif self.__filterDirAndFiles: + if info.isDir(): + result = style.standardIcon(qt.QStyle.SP_DirIcon) + else: + result = style.standardIcon(qt.QStyle.SP_FileIcon) + else: + result = qt.QFileIconProvider.icon(self, info) + return result + + def type(self, info): + path = info.filePath() + if path in ["", "/"]: + # That's the computer root on Windows or Linux + result = "Computer" + elif sys.platform == "win32" and path[-2] == ":": + # That's a drive on Windows + result = self.__windowsDriveType(info) + elif self.__filterDirAndFiles: + if info.isDir(): + result = "Directory" + else: + result = info.suffix() + else: + result = qt.QFileIconProvider.type(self, info) + return result diff --git a/silx/gui/dialog/SafeFileSystemModel.py b/silx/gui/dialog/SafeFileSystemModel.py new file mode 100644 index 0000000..8a97974 --- /dev/null +++ b/silx/gui/dialog/SafeFileSystemModel.py @@ -0,0 +1,802 @@ +# 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. +# +# ###########################################################################*/ +""" +This module contains an :class:`SafeFileSystemModel`. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "22/11/2017" + +import sys +import os.path +import logging +import weakref +from silx.gui import qt +from silx.third_party import six +from .SafeFileIconProvider import SafeFileIconProvider + +_logger = logging.getLogger(__name__) + + +class _Item(object): + + def __init__(self, fileInfo): + self.__fileInfo = fileInfo + self.__parent = None + self.__children = None + self.__absolutePath = None + + def isDrive(self): + if sys.platform == "win32": + return self.parent().parent() is None + else: + return False + + def isRoot(self): + return self.parent() is None + + def isFile(self): + """ + Returns true if the path is a file. + + It avoid to access to the `Qt.QFileInfo` in case the file is a drive. + """ + if self.isDrive(): + return False + return self.__fileInfo.isFile() + + def isDir(self): + """ + Returns true if the path is a directory. + + The default `qt.QFileInfo.isDir` can freeze the file system with + network drives. This function avoid the freeze in case of browsing + the root. + """ + if self.isDrive(): + # A drive is a directory, we don't have to synchronize the + # drive to know that + return True + return self.__fileInfo.isDir() + + def absoluteFilePath(self): + """ + Returns an absolute path including the file name. + + This function uses in most cases the default + `qt.QFileInfo.absoluteFilePath`. But it is known to freeze the file + system with network drives. + + This function uses `qt.QFileInfo.filePath` in case of root drives, to + avoid this kind of issues. In case of drive, the result is the same, + while the file path is already absolute. + + :rtype: str + """ + if self.__absolutePath is None: + if self.isRoot(): + path = "" + elif self.isDrive(): + path = self.__fileInfo.filePath() + else: + path = os.path.join(self.parent().absoluteFilePath(), self.__fileInfo.fileName()) + if path == "": + return "/" + self.__absolutePath = path + return self.__absolutePath + + def child(self): + self.populate() + return self.__children + + def childAt(self, position): + self.populate() + return self.__children[position] + + def childCount(self): + self.populate() + return len(self.__children) + + def indexOf(self, item): + self.populate() + return self.__children.index(item) + + def parent(self): + parent = self.__parent + if parent is None: + return None + return parent() + + def filePath(self): + return self.__fileInfo.filePath() + + def fileName(self): + if self.isDrive(): + name = self.absoluteFilePath() + if name[-1] == "/": + name = name[:-1] + return name + return os.path.basename(self.absoluteFilePath()) + + def fileInfo(self): + """ + Returns the Qt file info. + + :rtype: Qt.QFileInfo + """ + return self.__fileInfo + + def _setParent(self, parent): + self.__parent = weakref.ref(parent) + + def findChildrenByPath(self, path): + if path == "": + return self + path = path.replace("\\", "/") + if path[-1] == "/": + path = path[:-1] + names = path.split("/") + caseSensitive = qt.QFSFileEngine(path).caseSensitive() + count = len(names) + cursor = self + for name in names: + for item in cursor.child(): + if caseSensitive: + same = item.fileName() == name + else: + same = item.fileName().lower() == name.lower() + if same: + cursor = item + count -= 1 + break + else: + return None + if count == 0: + break + else: + return None + return cursor + + def populate(self): + if self.__children is not None: + return + self.__children = [] + if self.isRoot(): + items = qt.QDir.drives() + else: + directory = qt.QDir(self.absoluteFilePath()) + filters = qt.QDir.AllEntries | qt.QDir.Hidden | qt.QDir.System + items = directory.entryInfoList(filters) + for fileInfo in items: + i = _Item(fileInfo) + self.__children.append(i) + i._setParent(self) + + +class _RawFileSystemModel(qt.QAbstractItemModel): + """ + This class implement a file system model and try to avoid freeze. On Qt4, + :class:`qt.QFileSystemModel` is known to freeze the file system when + network drives are available. + + To avoid this behaviour, this class does not use + `qt.QFileInfo.absoluteFilePath` nor `qt.QFileInfo.canonicalPath` to reach + information on drives. + + This model do not take care of sorting and filtering. This features are + managed by another model, by composition. + + And because it is the end of life of Qt4, we do not implement asynchronous + loading of files as it is done by :class:`qt.QFileSystemModel`, nor some + useful features. + """ + + __directoryLoadedSync = qt.Signal(str) + """This signal is connected asynchronously to a slot. It allows to + emit directoryLoaded as an asynchronous signal.""" + + directoryLoaded = qt.Signal(str) + """This signal is emitted when the gatherer thread has finished to load the + path.""" + + rootPathChanged = qt.Signal(str) + """This signal is emitted whenever the root path has been changed to a + newPath.""" + + NAME_COLUMN = 0 + SIZE_COLUMN = 1 + TYPE_COLUMN = 2 + LAST_MODIFIED_COLUMN = 3 + + def __init__(self, parent=None): + qt.QAbstractItemModel.__init__(self, parent) + self.__computer = _Item(qt.QFileInfo()) + self.__header = "Name", "Size", "Type", "Last modification" + self.__currentPath = "" + self.__iconProvider = SafeFileIconProvider() + self.__directoryLoadedSync.connect(self.__emitDirectoryLoaded, qt.Qt.QueuedConnection) + + def headerData(self, section, orientation, role=qt.Qt.DisplayRole): + if orientation == qt.Qt.Horizontal: + if role == qt.Qt.DisplayRole: + return self.__header[section] + if role == qt.Qt.TextAlignmentRole: + return qt.Qt.AlignRight if section == 1 else qt.Qt.AlignLeft + return None + + def flags(self, index): + if not index.isValid(): + return 0 + return qt.Qt.ItemIsEnabled | qt.Qt.ItemIsSelectable + + def columnCount(self, parent=qt.QModelIndex()): + return len(self.__header) + + def rowCount(self, parent=qt.QModelIndex()): + item = self.__getItem(parent) + return item.childCount() + + def data(self, index, role=qt.Qt.DisplayRole): + if not index.isValid(): + return None + + column = index.column() + if role in [qt.Qt.DisplayRole, qt.Qt.EditRole]: + if column == self.NAME_COLUMN: + return self.__displayName(index) + elif column == self.SIZE_COLUMN: + return self.size(index) + elif column == self.TYPE_COLUMN: + return self.type(index) + elif column == self.LAST_MODIFIED_COLUMN: + return self.lastModified(index) + else: + _logger.warning("data: invalid display value column %d", index.column()) + elif role == qt.QFileSystemModel.FilePathRole: + return self.filePath(index) + elif role == qt.QFileSystemModel.FileNameRole: + return self.fileName(index) + elif role == qt.Qt.DecorationRole: + if column == self.NAME_COLUMN: + icon = self.fileIcon(index) + if icon is None or icon.isNull(): + if self.isDir(index): + self.__iconProvider.icon(qt.QFileIconProvider.Folder) + else: + self.__iconProvider.icon(qt.QFileIconProvider.File) + return icon + elif role == qt.Qt.TextAlignmentRole: + if column == self.SIZE_COLUMN: + return qt.Qt.AlignRight + elif role == qt.QFileSystemModel.FilePermissions: + return self.permissions(index) + + return None + + def index(self, *args, **kwargs): + path_api = False + path_api |= len(args) >= 1 and isinstance(args[0], six.string_types) + path_api |= "path" in kwargs + + if path_api: + return self.__indexFromPath(*args, **kwargs) + else: + return self.__index(*args, **kwargs) + + def __index(self, row, column, parent=qt.QModelIndex()): + if parent.isValid() and parent.column() != 0: + return None + + parentItem = self.__getItem(parent) + item = parentItem.childAt(row) + return self.createIndex(row, column, item) + + def __indexFromPath(self, path, column=0): + """ + Uses the index(str) C++ API + + :rtype: qt.QModelIndex + """ + if path == "": + return qt.QModelIndex() + + item = self.__computer.findChildrenByPath(path) + if item is None: + return qt.QModelIndex() + + return self.createIndex(item.parent().indexOf(item), column, item) + + def parent(self, index): + if not index.isValid(): + return qt.QModelIndex() + + item = self.__getItem(index) + if index is None: + return qt.QModelIndex() + + parent = item.parent() + if parent is None or parent is self.__computer: + return qt.QModelIndex() + + return self.createIndex(parent.parent().indexOf(parent), 0, parent) + + def __emitDirectoryLoaded(self, path): + self.directoryLoaded.emit(path) + + def __emitRootPathChanged(self, path): + self.rootPathChanged.emit(path) + + def __getItem(self, index): + if not index.isValid(): + return self.__computer + item = index.internalPointer() + return item + + def fileIcon(self, index): + item = self.__getItem(index) + if self.__iconProvider is not None: + fileInfo = item.fileInfo() + result = self.__iconProvider.icon(fileInfo) + else: + style = qt.QApplication.instance().style() + if item.isRoot(): + result = style.standardIcon(qt.QStyle.SP_ComputerIcon) + elif item.isDrive(): + result = style.standardIcon(qt.QStyle.SP_DriveHDIcon) + elif item.isDir(): + result = style.standardIcon(qt.QStyle.SP_DirIcon) + else: + result = style.standardIcon(qt.QStyle.SP_FileIcon) + return result + + def _item(self, index): + item = self.__getItem(index) + return item + + def fileInfo(self, index): + item = self.__getItem(index) + result = item.fileInfo() + return result + + def __fileIcon(self, index): + item = self.__getItem(index) + result = item.fileName() + return result + + def __displayName(self, index): + item = self.__getItem(index) + result = item.fileName() + return result + + def fileName(self, index): + item = self.__getItem(index) + result = item.fileName() + return result + + def filePath(self, index): + item = self.__getItem(index) + result = item.fileInfo().filePath() + return result + + def isDir(self, index): + item = self.__getItem(index) + result = item.isDir() + return result + + def lastModified(self, index): + item = self.__getItem(index) + result = item.fileInfo().lastModified() + return result + + def permissions(self, index): + item = self.__getItem(index) + result = item.fileInfo().permissions() + return result + + def size(self, index): + item = self.__getItem(index) + result = item.fileInfo().size() + return result + + def type(self, index): + item = self.__getItem(index) + if self.__iconProvider is not None: + fileInfo = item.fileInfo() + result = self.__iconProvider.type(fileInfo) + else: + if item.isRoot(): + result = "Computer" + elif item.isDrive(): + result = "Drive" + elif item.isDir(): + result = "Directory" + else: + fileInfo = item.fileInfo() + result = fileInfo.suffix() + return result + + # File manipulation + + # bool remove(const QModelIndex & index) const + # bool rmdir(const QModelIndex & index) const + # QModelIndex mkdir(const QModelIndex & parent, const QString & name) + + # Configuration + + def rootDirectory(self): + return qt.QDir(self.rootPath()) + + def rootPath(self): + return self.__currentPath + + def setRootPath(self, path): + if self.__currentPath == path: + return + self.__currentPath = path + item = self.__computer.findChildrenByPath(path) + self.__emitRootPathChanged(path) + if item is None or item.parent() is None: + return qt.QModelIndex() + index = self.createIndex(item.parent().indexOf(item), 0, item) + self.__directoryLoadedSync.emit(path) + return index + + def iconProvider(self): + # FIXME: invalidate the model + return self.__iconProvider + + def setIconProvider(self, provider): + # FIXME: invalidate the model + self.__iconProvider = provider + + # bool resolveSymlinks() const + # void setResolveSymlinks(bool enable) + + def setNameFilterDisables(self, enable): + return None + + def nameFilterDisables(self): + return None + + def myComputer(self, role=qt.Qt.DisplayRole): + return None + + def setNameFilters(self, filters): + return + + def nameFilters(self): + return None + + def filter(self): + return self.__filters + + def setFilter(self, filters): + return + + def setReadOnly(self, enable): + assert(enable is True) + + def isReadOnly(self): + return False + + +class SafeFileSystemModel(qt.QSortFilterProxyModel): + """ + This class implement a file system model and try to avoid freeze. On Qt4, + :class:`qt.QFileSystemModel` is known to freeze the file system when + network drives are available. + + To avoid this behaviour, this class does not use + `qt.QFileInfo.absoluteFilePath` nor `qt.QFileInfo.canonicalPath` to reach + information on drives. + + And because it is the end of life of Qt4, we do not implement asynchronous + loading of files as it is done by :class:`qt.QFileSystemModel`, nor some + useful features. + """ + + def __init__(self, parent=None): + qt.QSortFilterProxyModel.__init__(self, parent=parent) + self.__nameFilterDisables = sys.platform == "darwin" + self.__nameFilters = [] + self.__filters = qt.QDir.AllEntries | qt.QDir.NoDotAndDotDot | qt.QDir.AllDirs + sourceModel = _RawFileSystemModel(self) + self.setSourceModel(sourceModel) + + @property + def directoryLoaded(self): + return self.sourceModel().directoryLoaded + + @property + def rootPathChanged(self): + return self.sourceModel().rootPathChanged + + def index(self, *args, **kwargs): + path_api = False + path_api |= len(args) >= 1 and isinstance(args[0], six.string_types) + path_api |= "path" in kwargs + + if path_api: + return self.__indexFromPath(*args, **kwargs) + else: + return self.__index(*args, **kwargs) + + def __index(self, row, column, parent=qt.QModelIndex()): + return qt.QSortFilterProxyModel.index(self, row, column, parent) + + def __indexFromPath(self, path, column=0): + """ + Uses the index(str) C++ API + + :rtype: qt.QModelIndex + """ + if path == "": + return qt.QModelIndex() + + index = self.sourceModel().index(path, column) + index = self.mapFromSource(index) + return index + + def lessThan(self, leftSourceIndex, rightSourceIndex): + sourceModel = self.sourceModel() + sortColumn = self.sortColumn() + if sortColumn == _RawFileSystemModel.NAME_COLUMN: + leftItem = sourceModel._item(leftSourceIndex) + rightItem = sourceModel._item(rightSourceIndex) + if sys.platform != "darwin": + # Sort directories before files + leftIsDir = leftItem.isDir() + rightIsDir = rightItem.isDir() + if leftIsDir ^ rightIsDir: + return leftIsDir + return leftItem.fileName().lower() < rightItem.fileName().lower() + elif sortColumn == _RawFileSystemModel.SIZE_COLUMN: + left = sourceModel.fileInfo(leftSourceIndex) + right = sourceModel.fileInfo(rightSourceIndex) + return left.size() < right.size() + elif sortColumn == _RawFileSystemModel.TYPE_COLUMN: + left = sourceModel.type(leftSourceIndex) + right = sourceModel.type(rightSourceIndex) + return left < right + elif sortColumn == _RawFileSystemModel.LAST_MODIFIED_COLUMN: + left = sourceModel.fileInfo(leftSourceIndex) + right = sourceModel.fileInfo(rightSourceIndex) + return left.lastModified() < right.lastModified() + else: + _logger.warning("Unsupported sorted column %d", sortColumn) + + return False + + def __filtersAccepted(self, item, filters): + """ + Check individual flag filters. + """ + if not (filters & (qt.QDir.Dirs | qt.QDir.AllDirs)): + # Hide dirs + if item.isDir(): + return False + if not (filters & qt.QDir.Files): + # Hide files + if item.isFile(): + return False + if not (filters & qt.QDir.Drives): + # Hide drives + if item.isDrive(): + return False + + fileInfo = item.fileInfo() + if fileInfo is None: + return False + + filterPermissions = (filters & qt.QDir.PermissionMask) != 0 + if filterPermissions and (filters & (qt.QDir.Dirs | qt.QDir.Files)): + if (filters & qt.QDir.Readable): + # Hide unreadable + if not fileInfo.isReadable(): + return False + if (filters & qt.QDir.Writable): + # Hide unwritable + if not fileInfo.isWritable(): + return False + if (filters & qt.QDir.Executable): + # Hide unexecutable + if not fileInfo.isExecutable(): + return False + + if (filters & qt.QDir.NoSymLinks): + # Hide sym links + if fileInfo.isSymLink(): + return False + + if not (filters & qt.QDir.System): + # Hide system + if not item.isDir() and not item.isFile(): + return False + + fileName = item.fileName() + isDot = fileName == "." + isDotDot = fileName == ".." + + if not (filters & qt.QDir.Hidden): + # Hide hidden + if not (isDot or isDotDot) and fileInfo.isHidden(): + return False + + if filters & (qt.QDir.NoDot | qt.QDir.NoDotDot | qt.QDir.NoDotAndDotDot): + # Hide parent/self references + if filters & qt.QDir.NoDot: + if isDot: + return False + if filters & qt.QDir.NoDotDot: + if isDotDot: + return False + if filters & qt.QDir.NoDotAndDotDot: + if isDot or isDotDot: + return False + + return True + + def filterAcceptsRow(self, sourceRow, sourceParent): + if not sourceParent.isValid(): + return True + + sourceModel = self.sourceModel() + index = sourceModel.index(sourceRow, 0, sourceParent) + if not index.isValid(): + return True + item = sourceModel._item(index) + + filters = self.__filters + + if item.isDrive(): + # Let say a user always have access to a drive + # It avoid to access to fileInfo then avoid to freeze the file + # system + return True + + if not self.__filtersAccepted(item, filters): + return False + + if self.__nameFilterDisables: + return True + + if item.isDir() and (filters & qt.QDir.AllDirs): + # dont apply the filters to directory names + return True + + return self.__nameFiltersAccepted(item) + + def __nameFiltersAccepted(self, item): + if len(self.__nameFilters) == 0: + return True + + fileName = item.fileName() + for reg in self.__nameFilters: + if reg.exactMatch(fileName): + return True + return False + + def setNameFilterDisables(self, enable): + self.__nameFilterDisables = enable + self.invalidate() + + def nameFilterDisables(self): + return self.__nameFilterDisables + + def myComputer(self, role=qt.Qt.DisplayRole): + return self.sourceModel().myComputer(role) + + def setNameFilters(self, filters): + self.__nameFilters = [] + isCaseSensitive = self.__filters & qt.QDir.CaseSensitive + caseSensitive = qt.Qt.CaseSensitive if isCaseSensitive else qt.Qt.CaseInsensitive + for f in filters: + reg = qt.QRegExp(f, caseSensitive, qt.QRegExp.Wildcard) + self.__nameFilters.append(reg) + self.invalidate() + + def nameFilters(self): + return [f.pattern() for f in self.__nameFilters] + + def filter(self): + return self.__filters + + def setFilter(self, filters): + self.__filters = filters + # In case of change of case sensitivity + self.setNameFilters(self.nameFilters()) + self.invalidate() + + def setReadOnly(self, enable): + assert(enable is True) + + def isReadOnly(self): + return False + + def rootPath(self): + return self.sourceModel().rootPath() + + def setRootPath(self, path): + index = self.sourceModel().setRootPath(path) + index = self.mapFromSource(index) + return index + + def flags(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + filters = sourceModel.flags(index) + + if self.__nameFilterDisables: + item = sourceModel._item(index) + if not self.__nameFiltersAccepted(item): + filters &= ~qt.Qt.ItemIsEnabled + + return filters + + def fileIcon(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.fileIcon(index) + + def fileInfo(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.fileInfo(index) + + def fileName(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.fileName(index) + + def filePath(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.filePath(index) + + def isDir(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.isDir(index) + + def lastModified(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.lastModified(index) + + def permissions(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.permissions(index) + + def size(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.size(index) + + def type(self, index): + sourceModel = self.sourceModel() + index = self.mapToSource(index) + return sourceModel.type(index) diff --git a/silx/gui/dialog/__init__.py b/silx/gui/dialog/__init__.py new file mode 100644 index 0000000..77c5949 --- /dev/null +++ b/silx/gui/dialog/__init__.py @@ -0,0 +1,29 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Qt dialogs""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "11/10/2017" diff --git a/silx/gui/dialog/setup.py b/silx/gui/dialog/setup.py new file mode 100644 index 0000000..48ab8d8 --- /dev/null +++ b/silx/gui/dialog/setup.py @@ -0,0 +1,40 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "23/10/2017" + +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('dialog', parent_package, top_path) + config.add_subpackage('test') + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + setup(configuration=configuration) diff --git a/silx/gui/dialog/test/__init__.py b/silx/gui/dialog/test/__init__.py new file mode 100644 index 0000000..eee8aea --- /dev/null +++ b/silx/gui/dialog/test/__init__.py @@ -0,0 +1,47 @@ +# 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. +# +# ###########################################################################*/ +"""Tests for Qt dialogs""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/02/2018" + + +import logging +import os +import sys +import unittest + + +_logger = logging.getLogger(__name__) + + +def suite(): + test_suite = unittest.TestSuite() + from . import test_imagefiledialog + from . import test_datafiledialog + test_suite.addTest(test_imagefiledialog.suite()) + test_suite.addTest(test_datafiledialog.suite()) + return test_suite diff --git a/silx/gui/dialog/test/test_datafiledialog.py b/silx/gui/dialog/test/test_datafiledialog.py new file mode 100644 index 0000000..bdda810 --- /dev/null +++ b/silx/gui/dialog/test/test_datafiledialog.py @@ -0,0 +1,981 @@ +# 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. +# +# ###########################################################################*/ +"""Test for silx.gui.hdf5 module""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "14/02/2018" + + +import unittest +import tempfile +import numpy +import shutil +import os +import io +import weakref + +try: + import fabio +except ImportError: + fabio = None +try: + import h5py +except ImportError: + h5py = None + +import silx.io.url +from silx.gui import qt +from silx.gui.test import utils +from ..DataFileDialog import DataFileDialog +from silx.gui.hdf5 import Hdf5TreeModel + +_tmpDirectory = None + + +def setUpModule(): + global _tmpDirectory + _tmpDirectory = tempfile.mkdtemp(prefix=__name__) + + data = numpy.arange(100 * 100) + data.shape = 100, 100 + + if fabio is not None: + filename = _tmpDirectory + "/singleimage.edf" + image = fabio.edfimage.EdfImage(data=data) + image.write(filename) + + if h5py is not None: + filename = _tmpDirectory + "/data.h5" + f = h5py.File(filename, "w") + f["scalar"] = 10 + f["image"] = data + f["cube"] = [data, data + 1, data + 2] + f["complex_image"] = data * 1j + f["group/image"] = data + f["nxdata/foo"] = 10 + f["nxdata"].attrs["NX_class"] = u"NXdata" + f.close() + + filename = _tmpDirectory + "/badformat.h5" + with io.open(filename, "wb") as f: + f.write(b"{\nHello Nurse!") + + +def tearDownModule(): + global _tmpDirectory + shutil.rmtree(_tmpDirectory) + _tmpDirectory = None + + +class _UtilsMixin(object): + + def createDialog(self): + self._deleteDialog() + self._dialog = self._createDialog() + return self._dialog + + def _createDialog(self): + return DataFileDialog() + + def _deleteDialog(self): + if not hasattr(self, "_dialog"): + return + if self._dialog is not None: + ref = weakref.ref(self._dialog) + self._dialog = None + self.qWaitForDestroy(ref) + + def qWaitForPendingActions(self, dialog): + for _ in range(20): + if not dialog.hasPendingEvents(): + return + self.qWait(10) + raise RuntimeError("Still have pending actions") + + def assertSamePath(self, path1, path2): + path1_ = os.path.normcase(path1) + path2_ = os.path.normcase(path2) + if path1_ != path2_: + # Use the unittest API to log and display error + self.assertEquals(path1, path2) + + def assertNotSamePath(self, path1, path2): + path1_ = os.path.normcase(path1) + path2_ = os.path.normcase(path2) + if path1_ == path2_: + # Use the unittest API to log and display error + self.assertNotEquals(path1, path2) + + +class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): + + def tearDown(self): + self._deleteDialog() + utils.TestCaseQt.tearDown(self) + + def testDisplayAndKeyEscape(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + + self.keyClick(dialog, qt.Qt.Key_Escape) + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Rejected) + + def testDisplayAndClickCancel(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + + button = utils.findChildren(dialog, qt.QPushButton, name="cancel")[0] + self.mouseClick(button, qt.Qt.LeftButton) + self.assertFalse(dialog.isVisible()) + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Rejected) + + def testDisplayAndClickLockedOpen(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.mouseClick(button, qt.Qt.LeftButton) + # open button locked, dialog is not closed + self.assertTrue(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Rejected) + + def testSelectRoot_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertTrue(button.isEnabled()) + self.mouseClick(button, qt.Qt.LeftButton) + url = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertTrue(url.data_path() is not None) + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Accepted) + + def testSelectGroup_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/group"]) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertTrue(button.isEnabled()) + self.mouseClick(button, qt.Qt.LeftButton) + url = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertEqual(url.data_path(), "/group") + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Accepted) + + def testSelectDataset_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/scalar"]) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertTrue(button.isEnabled()) + self.mouseClick(button, qt.Qt.LeftButton) + url = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertEqual(url.data_path(), "/scalar") + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Accepted) + + def testClickOnBackToParentTool(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = utils.findChildren(dialog, qt.QAction, name="toParentAction")[0] + toParentButton = utils.getQToolButtonFromAction(action) + filename = _tmpDirectory + "/data.h5" + + # init state + path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path() + dialog.selectUrl(path) + self.qWaitForPendingActions(dialog) + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path() + self.assertSamePath(url.text(), path) + # test + self.mouseClick(toParentButton, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + self.assertSamePath(url.text(), path) + + self.mouseClick(toParentButton, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertSamePath(url.text(), _tmpDirectory) + + self.mouseClick(toParentButton, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertSamePath(url.text(), os.path.dirname(_tmpDirectory)) + + def testClickOnBackToRootTool(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = utils.findChildren(dialog, qt.QAction, name="toRootFileAction")[0] + button = utils.getQToolButtonFromAction(action) + filename = _tmpDirectory + "/data.h5" + + # init state + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path() + dialog.selectUrl(path) + self.qWaitForPendingActions(dialog) + self.assertSamePath(url.text(), path) + self.assertTrue(button.isEnabled()) + # test + self.mouseClick(button, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + self.assertSamePath(url.text(), path) + # self.assertFalse(button.isEnabled()) + + def testClickOnBackToDirectoryTool(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = utils.findChildren(dialog, qt.QAction, name="toDirectoryAction")[0] + button = utils.getQToolButtonFromAction(action) + filename = _tmpDirectory + "/data.h5" + + # init state + path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path() + dialog.selectUrl(path) + self.qWaitForPendingActions(dialog) + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path() + self.assertSamePath(url.text(), path) + self.assertTrue(button.isEnabled()) + # test + self.mouseClick(button, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertSamePath(url.text(), _tmpDirectory) + self.assertFalse(button.isEnabled()) + + # FIXME: There is an unreleased qt.QWidget without nameObject + # No idea where it come from. + self.allowedLeakingWidgets = 1 + + def testClickOnHistoryTools(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + forwardAction = utils.findChildren(dialog, qt.QAction, name="forwardAction")[0] + backwardAction = utils.findChildren(dialog, qt.QAction, name="backwardAction")[0] + filename = _tmpDirectory + "/data.h5" + + dialog.setDirectory(_tmpDirectory) + self.qWaitForPendingActions(dialog) + # No way to use QTest.mouseDClick with QListView, QListWidget + # Then we feed the history using selectPath + dialog.selectUrl(filename) + self.qWaitForPendingActions(dialog) + path2 = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + dialog.selectUrl(path2) + self.qWaitForPendingActions(dialog) + path3 = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group").path() + dialog.selectUrl(path3) + self.qWaitForPendingActions(dialog) + self.assertFalse(forwardAction.isEnabled()) + self.assertTrue(backwardAction.isEnabled()) + + button = utils.getQToolButtonFromAction(backwardAction) + self.mouseClick(button, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertTrue(forwardAction.isEnabled()) + self.assertTrue(backwardAction.isEnabled()) + self.assertSamePath(url.text(), path2) + + button = utils.getQToolButtonFromAction(forwardAction) + self.mouseClick(button, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertFalse(forwardAction.isEnabled()) + self.assertTrue(backwardAction.isEnabled()) + self.assertSamePath(url.text(), path3) + + def testSelectImageFromEdf(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/singleimage.edf" + url = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/scan_0/instrument/detector_0/data") + dialog.selectUrl(url.path()) + self.assertTrue(dialog._selectedData().shape, (100, 100)) + self.assertSamePath(dialog.selectedFile(), filename) + self.assertSamePath(dialog.selectedUrl(), url.path()) + + def testSelectImage(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/data.h5" + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/image").path() + dialog.selectUrl(path) + # test + self.assertTrue(dialog._selectedData().shape, (100, 100)) + self.assertSamePath(dialog.selectedFile(), filename) + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectScalar(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/data.h5" + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/scalar").path() + dialog.selectUrl(path) + # test + self.assertEqual(dialog._selectedData()[()], 10) + self.assertSamePath(dialog.selectedFile(), filename) + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectGroup(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/data.h5" + uri = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group") + dialog.selectUrl(uri.path()) + self.qWaitForPendingActions(dialog) + # test + self.assertTrue(silx.io.is_group(dialog._selectedData())) + self.assertSamePath(dialog.selectedFile(), filename) + uri = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertSamePath(uri.data_path(), "/group") + + def testSelectRoot(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/data.h5" + uri = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/") + dialog.selectUrl(uri.path()) + self.qWaitForPendingActions(dialog) + # test + self.assertTrue(silx.io.is_file(dialog._selectedData())) + self.assertSamePath(dialog.selectedFile(), filename) + uri = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertSamePath(uri.data_path(), "/") + + def testSelectH5_Activate(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + filename = _tmpDirectory + "/data.h5" + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + index = browser.rootIndex().model().index(filename) + # click + browser.selectIndex(index) + # double click + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + # test + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectBadFileFormat_Activate(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + filename = _tmpDirectory + "/badformat.h5" + index = browser.rootIndex().model().index(filename) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + # test + self.assertTrue(dialog.selectedUrl(), filename) + + def _countSelectableItems(self, model, rootIndex): + selectable = 0 + for i in range(model.rowCount(rootIndex)): + index = model.index(i, 0, rootIndex) + flags = model.flags(index) + isEnabled = (int(flags) & qt.Qt.ItemIsEnabled) != 0 + if isEnabled: + selectable += 1 + return selectable + + def testFilterExtensions(self): + if h5py is None: + self.skipTest("h5py is missing") + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 3) + + +class TestDataFileDialog_FilterDataset(utils.TestCaseQt, _UtilsMixin): + + def tearDown(self): + self._deleteDialog() + utils.TestCaseQt.tearDown(self) + + def _createDialog(self): + dialog = DataFileDialog() + dialog.setFilterMode(DataFileDialog.FilterMode.ExistingDataset) + return dialog + + def testSelectGroup_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/group"]) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertFalse(button.isEnabled()) + + def testSelectDataset_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/scalar"]) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertTrue(button.isEnabled()) + self.mouseClick(button, qt.Qt.LeftButton) + url = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertEqual(url.data_path(), "/scalar") + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Accepted) + + data = dialog.selectedData() + self.assertEqual(data, 10) + + +class TestDataFileDialog_FilterGroup(utils.TestCaseQt, _UtilsMixin): + + def tearDown(self): + self._deleteDialog() + utils.TestCaseQt.tearDown(self) + + def _createDialog(self): + dialog = DataFileDialog() + dialog.setFilterMode(DataFileDialog.FilterMode.ExistingGroup) + return dialog + + def testSelectGroup_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/group"]) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertTrue(button.isEnabled()) + self.mouseClick(button, qt.Qt.LeftButton) + url = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertEqual(url.data_path(), "/group") + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Accepted) + + self.assertRaises(Exception, dialog.selectedData) + + def testSelectDataset_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/scalar"]) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertFalse(button.isEnabled()) + + +class TestDataFileDialog_FilterNXdata(utils.TestCaseQt, _UtilsMixin): + + def tearDown(self): + self._deleteDialog() + utils.TestCaseQt.tearDown(self) + + def _createDialog(self): + def customFilter(obj): + if "NX_class" in obj.attrs: + return obj.attrs["NX_class"] == u"NXdata" + return False + + dialog = DataFileDialog() + dialog.setFilterMode(DataFileDialog.FilterMode.ExistingGroup) + dialog.setFilterCallback(customFilter) + return dialog + + def testSelectGroupRefused_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/group"]) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertFalse(button.isEnabled()) + + self.assertRaises(Exception, dialog.selectedData) + + def testSelectNXdataAccepted_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/data.h5" + dialog.selectFile(os.path.dirname(filename)) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().index(filename) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + # select, then double click on the file + index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/nxdata"]) + browser.selectIndex(index) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertTrue(button.isEnabled()) + self.mouseClick(button, qt.Qt.LeftButton) + url = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertEqual(url.data_path(), "/nxdata") + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Accepted) + + +class TestDataFileDialogApi(utils.TestCaseQt, _UtilsMixin): + + def tearDown(self): + self._deleteDialog() + utils.TestCaseQt.tearDown(self) + + def _createDialog(self): + dialog = DataFileDialog() + return dialog + + def testSaveRestoreState(self): + dialog = self.createDialog() + dialog.setDirectory(_tmpDirectory) + self.qWaitForPendingActions(dialog) + state = dialog.saveState() + dialog = None + + dialog2 = self.createDialog() + result = dialog2.restoreState(state) + self.assertTrue(result) + dialog2 = None + + def printState(self): + """ + Print state of the ImageFileDialog. + + Can be used to add or regenerate `STATE_VERSION1_QT4` or + `STATE_VERSION1_QT5`. + + >>> ./run_tests.py -v silx.gui.dialog.test.test_datafiledialog.TestDataFileDialogApi.printState + """ + dialog = self.createDialog() + dialog.setDirectory("") + dialog.setHistory([]) + dialog.setSidebarUrls([]) + state = dialog.saveState() + string = "" + strings = [] + for i in range(state.size()): + d = state.data()[i] + if not isinstance(d, int): + d = ord(d) + if d > 0x20 and d < 0x7F: + string += chr(d) + else: + string += "\\x%02X" % d + if len(string) > 60: + strings.append(string) + string = "" + strings.append(string) + strings = ["b'%s'" % s for s in strings] + print() + print("\\\n".join(strings)) + + STATE_VERSION1_QT4 = b''\ + b'\x00\x00\x00Z\x00s\x00i\x00l\x00x\x00.\x00g\x00u\x00i\x00.\x00'\ + b'd\x00i\x00a\x00l\x00o\x00g\x00.\x00D\x00a\x00t\x00a\x00F\x00i'\ + b'\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00.\x00D\x00a\x00t\x00'\ + b'a\x00F\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00\x00\x00'\ + b'\x01\x00\x00\x00\x0C\x00\x00\x00\x00"\x00\x00\x00\xFF\x00\x00'\ + b'\x00\x00\x00\x00\x00\x03\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF'\ + b'\xFF\xFF\x01\x00\x00\x00\x06\x01\x00\x00\x00\x01\x00\x00\x00\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C\x00\x00\x00\x00'\ + b'}\x00\x00\x00\x0E\x00B\x00r\x00o\x00w\x00s\x00e\x00r\x00\x00\x00'\ + b'\x01\x00\x00\x00\x0C\x00\x00\x00\x00Z\x00\x00\x00\xFF\x00\x00'\ + b'\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\ + b'\x00\x01\x90\x00\x00\x00\x04\x01\x01\x00\x00\x00\x00\x00\x00\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00d\xFF\xFF\xFF\xFF\x00\x00\x00\x81'\ + b'\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x01\x90\x00\x00\x00\x04'\ + b'\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00'\ + b'\x01\xFF\xFF\xFF\xFF' + """Serialized state on Qt4. Generated using :meth:`printState`""" + + STATE_VERSION1_QT5 = b''\ + b'\x00\x00\x00Z\x00s\x00i\x00l\x00x\x00.\x00g\x00u\x00i\x00.\x00'\ + b'd\x00i\x00a\x00l\x00o\x00g\x00.\x00D\x00a\x00t\x00a\x00F\x00i'\ + b'\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00.\x00D\x00a\x00t\x00'\ + b'a\x00F\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00\x00\x00'\ + b'\x01\x00\x00\x00\x0C\x00\x00\x00\x00#\x00\x00\x00\xFF\x00\x00'\ + b'\x00\x01\x00\x00\x00\x03\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF'\ + b'\xFF\xFF\x01\xFF\xFF\xFF\xFF\x01\x00\x00\x00\x01\x00\x00\x00\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C\x00\x00\x00'\ + b'\x00\xAA\x00\x00\x00\x0E\x00B\x00r\x00o\x00w\x00s\x00e\x00r\x00'\ + b'\x00\x00\x01\x00\x00\x00\x0C\x00\x00\x00\x00\x87\x00\x00\x00\xFF'\ + b'\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00'\ + b'\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\ + b'\x00\x00\x00\x01\x90\x00\x00\x00\x04\x01\x01\x00\x00\x00\x00\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00d\xFF\xFF\xFF\xFF\x00\x00'\ + b'\x00\x81\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00d\x00\x00'\ + b'\x00\x01\x00\x00\x00\x00\x00\x00\x00d\x00\x00\x00\x01\x00\x00'\ + b'\x00\x00\x00\x00\x00d\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00'\ + b'\x00d\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x03\xE8\x00\xFF'\ + b'\xFF\xFF\xFF\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01' + """Serialized state on Qt5. Generated using :meth:`printState`""" + + def testAvoidRestoreRegression_Version1(self): + version = qt.qVersion().split(".")[0] + if version == "4": + state = self.STATE_VERSION1_QT4 + elif version == "5": + state = self.STATE_VERSION1_QT5 + else: + self.skipTest("Resource not available") + + state = qt.QByteArray(state) + dialog = self.createDialog() + result = dialog.restoreState(state) + self.assertTrue(result) + + def testRestoreRobusness(self): + """What's happen if you try to open a config file with a different + binding.""" + state = qt.QByteArray(self.STATE_VERSION1_QT4) + dialog = self.createDialog() + dialog.restoreState(state) + state = qt.QByteArray(self.STATE_VERSION1_QT5) + dialog = None + dialog = self.createDialog() + dialog.restoreState(state) + + def testRestoreNonExistingDirectory(self): + directory = os.path.join(_tmpDirectory, "dir") + os.mkdir(directory) + dialog = self.createDialog() + dialog.setDirectory(directory) + self.qWaitForPendingActions(dialog) + state = dialog.saveState() + os.rmdir(directory) + dialog = None + + dialog2 = self.createDialog() + result = dialog2.restoreState(state) + self.assertTrue(result) + self.assertNotEquals(dialog2.directory(), directory) + + def testHistory(self): + dialog = self.createDialog() + history = dialog.history() + dialog.setHistory([]) + self.assertEqual(dialog.history(), []) + dialog.setHistory(history) + self.assertEqual(dialog.history(), history) + + def testSidebarUrls(self): + dialog = self.createDialog() + urls = dialog.sidebarUrls() + dialog.setSidebarUrls([]) + self.assertEqual(dialog.sidebarUrls(), []) + dialog.setSidebarUrls(urls) + self.assertEqual(dialog.sidebarUrls(), urls) + + def testDirectory(self): + dialog = self.createDialog() + self.qWaitForPendingActions(dialog) + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + self.assertSamePath(dialog.directory(), _tmpDirectory) + + def testBadFileFormat(self): + dialog = self.createDialog() + dialog.selectUrl(_tmpDirectory + "/badformat.h5") + self.qWaitForPendingActions(dialog) + self.assertIsNone(dialog._selectedData()) + + def testBadPath(self): + dialog = self.createDialog() + dialog.selectUrl("#$%/#$%") + self.qWaitForPendingActions(dialog) + self.assertIsNone(dialog._selectedData()) + + def testBadSubpath(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + self.qWaitForPendingActions(dialog) + + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + + filename = _tmpDirectory + "/data.h5" + url = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/foobar") + dialog.selectUrl(url.path()) + self.qWaitForPendingActions(dialog) + self.assertIsNotNone(dialog._selectedData()) + + # an existing node is browsed, but the wrong path is selected + index = browser.rootIndex() + obj = index.model().data(index, role=Hdf5TreeModel.H5PY_OBJECT_ROLE) + self.assertEqual(obj.name, "/group") + url = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertEqual(url.data_path(), "/group") + + def testUnsupportedSlicingPath(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + self.qWaitForPendingActions(dialog) + dialog.selectUrl(_tmpDirectory + "/data.h5?path=/cube&slice=0") + self.qWaitForPendingActions(dialog) + data = dialog._selectedData() + if data is None: + # Maybe nothing is selected + self.assertTrue(True) + else: + # Maybe the cube is selected but not sliced + self.assertEqual(len(data.shape), 3) + + +def suite(): + test_suite = unittest.TestSuite() + loadTests = unittest.defaultTestLoader.loadTestsFromTestCase + test_suite.addTest(loadTests(TestDataFileDialogInteraction)) + test_suite.addTest(loadTests(TestDataFileDialogApi)) + test_suite.addTest(loadTests(TestDataFileDialog_FilterDataset)) + test_suite.addTest(loadTests(TestDataFileDialog_FilterGroup)) + test_suite.addTest(loadTests(TestDataFileDialog_FilterNXdata)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/dialog/test/test_imagefiledialog.py b/silx/gui/dialog/test/test_imagefiledialog.py new file mode 100644 index 0000000..7909f10 --- /dev/null +++ b/silx/gui/dialog/test/test_imagefiledialog.py @@ -0,0 +1,803 @@ +# 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. +# +# ###########################################################################*/ +"""Test for silx.gui.hdf5 module""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "12/02/2018" + + +import unittest +import tempfile +import numpy +import shutil +import os +import io +import weakref + +try: + import fabio +except ImportError: + fabio = None +try: + import h5py +except ImportError: + h5py = None + +import silx.io.url +from silx.gui import qt +from silx.gui.test import utils +from ..ImageFileDialog import ImageFileDialog +from silx.gui.plot.Colormap import Colormap +from silx.gui.hdf5 import Hdf5TreeModel + +_tmpDirectory = None + + +def setUpModule(): + global _tmpDirectory + _tmpDirectory = tempfile.mkdtemp(prefix=__name__) + + data = numpy.arange(100 * 100) + data.shape = 100, 100 + + if fabio is not None: + filename = _tmpDirectory + "/singleimage.edf" + image = fabio.edfimage.EdfImage(data=data) + image.write(filename) + + filename = _tmpDirectory + "/multiframe.edf" + image = fabio.edfimage.EdfImage(data=data) + image.appendFrame(data=data + 1) + image.appendFrame(data=data + 2) + image.write(filename) + + filename = _tmpDirectory + "/singleimage.msk" + image = fabio.fit2dmaskimage.Fit2dMaskImage(data=data % 2 == 1) + image.write(filename) + + if h5py is not None: + filename = _tmpDirectory + "/data.h5" + f = h5py.File(filename, "w") + f["scalar"] = 10 + f["image"] = data + f["cube"] = [data, data + 1, data + 2] + f["complex_image"] = data * 1j + f["group/image"] = data + f.close() + + filename = _tmpDirectory + "/badformat.edf" + with io.open(filename, "wb") as f: + f.write(b"{\nHello Nurse!") + + +def tearDownModule(): + global _tmpDirectory + shutil.rmtree(_tmpDirectory) + _tmpDirectory = None + + +class _UtilsMixin(object): + + def createDialog(self): + self._deleteDialog() + self._dialog = self._createDialog() + return self._dialog + + def _createDialog(self): + return ImageFileDialog() + + def _deleteDialog(self): + if not hasattr(self, "_dialog"): + return + if self._dialog is not None: + ref = weakref.ref(self._dialog) + self._dialog = None + self.qWaitForDestroy(ref) + + def qWaitForPendingActions(self, dialog): + for _ in range(20): + if not dialog.hasPendingEvents(): + return + self.qWait(10) + raise RuntimeError("Still have pending actions") + + def assertSamePath(self, path1, path2): + path1_ = os.path.normcase(path1) + path2_ = os.path.normcase(path2) + if path1_ != path2_: + # Use the unittest API to log and display error + self.assertEquals(path1, path2) + + def assertNotSamePath(self, path1, path2): + path1_ = os.path.normcase(path1) + path2_ = os.path.normcase(path2) + if path1_ == path2_: + # Use the unittest API to log and display error + self.assertNotEquals(path1, path2) + + +class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin): + + def tearDown(self): + self._deleteDialog() + utils.TestCaseQt.tearDown(self) + + def testDisplayAndKeyEscape(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + + self.keyClick(dialog, qt.Qt.Key_Escape) + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Rejected) + + def testDisplayAndClickCancel(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + + button = utils.findChildren(dialog, qt.QPushButton, name="cancel")[0] + self.mouseClick(button, qt.Qt.LeftButton) + self.assertFalse(dialog.isVisible()) + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Rejected) + + def testDisplayAndClickLockedOpen(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.mouseClick(button, qt.Qt.LeftButton) + # open button locked, dialog is not closed + self.assertTrue(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Rejected) + + def testDisplayAndClickOpen(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + self.assertTrue(dialog.isVisible()) + filename = _tmpDirectory + "/singleimage.edf" + dialog.selectFile(filename) + self.qWaitForPendingActions(dialog) + + button = utils.findChildren(dialog, qt.QPushButton, name="open")[0] + self.assertTrue(button.isEnabled()) + self.mouseClick(button, qt.Qt.LeftButton) + self.assertFalse(dialog.isVisible()) + self.assertEquals(dialog.result(), qt.QDialog.Accepted) + + def testClickOnShortcut(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + sidebar = utils.findChildren(dialog, qt.QListView, name="sidebar")[0] + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + dialog.setDirectory(_tmpDirectory) + self.qWaitForPendingActions(dialog) + + self.assertSamePath(url.text(), _tmpDirectory) + + urls = sidebar.urls() + if len(urls) == 0: + self.skipTest("No sidebar path") + path = urls[0].path() + if path != "" and not os.path.exists(path): + self.skipTest("Sidebar path do not exists") + + index = sidebar.model().index(0, 0) + # rect = sidebar.visualRect(index) + # self.mouseClick(sidebar, qt.Qt.LeftButton, pos=rect.center()) + # Using mouse click is not working, let's use the selection API + sidebar.selectionModel().select(index, qt.QItemSelectionModel.ClearAndSelect) + self.qWaitForPendingActions(dialog) + + index = browser.rootIndex() + if not index.isValid(): + path = "" + else: + path = index.model().filePath(index) + self.assertNotSamePath(_tmpDirectory, path) + self.assertNotSamePath(url.text(), _tmpDirectory) + + def testClickOnDetailView(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + action = utils.findChildren(dialog, qt.QAction, name="detailModeAction")[0] + detailModeButton = utils.getQToolButtonFromAction(action) + self.mouseClick(detailModeButton, qt.Qt.LeftButton) + self.assertEqual(dialog.viewMode(), qt.QFileDialog.Detail) + + action = utils.findChildren(dialog, qt.QAction, name="listModeAction")[0] + listModeButton = utils.getQToolButtonFromAction(action) + self.mouseClick(listModeButton, qt.Qt.LeftButton) + self.assertEqual(dialog.viewMode(), qt.QFileDialog.List) + + def testClickOnBackToParentTool(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = utils.findChildren(dialog, qt.QAction, name="toParentAction")[0] + toParentButton = utils.getQToolButtonFromAction(action) + filename = _tmpDirectory + "/data.h5" + + # init state + path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path() + dialog.selectUrl(path) + self.qWaitForPendingActions(dialog) + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path() + self.assertSamePath(url.text(), path) + # test + self.mouseClick(toParentButton, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + self.assertSamePath(url.text(), path) + + self.mouseClick(toParentButton, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertSamePath(url.text(), _tmpDirectory) + + self.mouseClick(toParentButton, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertSamePath(url.text(), os.path.dirname(_tmpDirectory)) + + def testClickOnBackToRootTool(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = utils.findChildren(dialog, qt.QAction, name="toRootFileAction")[0] + button = utils.getQToolButtonFromAction(action) + filename = _tmpDirectory + "/data.h5" + + # init state + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path() + dialog.selectUrl(path) + self.qWaitForPendingActions(dialog) + self.assertSamePath(url.text(), path) + self.assertTrue(button.isEnabled()) + # test + self.mouseClick(button, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + self.assertSamePath(url.text(), path) + # self.assertFalse(button.isEnabled()) + + def testClickOnBackToDirectoryTool(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + action = utils.findChildren(dialog, qt.QAction, name="toDirectoryAction")[0] + button = utils.getQToolButtonFromAction(action) + filename = _tmpDirectory + "/data.h5" + + # init state + path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path() + dialog.selectUrl(path) + self.qWaitForPendingActions(dialog) + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path() + self.assertSamePath(url.text(), path) + self.assertTrue(button.isEnabled()) + # test + self.mouseClick(button, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertSamePath(url.text(), _tmpDirectory) + self.assertFalse(button.isEnabled()) + + # FIXME: There is an unreleased qt.QWidget without nameObject + # No idea where it come from. + self.allowedLeakingWidgets = 1 + + def testClickOnHistoryTools(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0] + forwardAction = utils.findChildren(dialog, qt.QAction, name="forwardAction")[0] + backwardAction = utils.findChildren(dialog, qt.QAction, name="backwardAction")[0] + filename = _tmpDirectory + "/data.h5" + + dialog.setDirectory(_tmpDirectory) + self.qWaitForPendingActions(dialog) + # No way to use QTest.mouseDClick with QListView, QListWidget + # Then we feed the history using selectPath + dialog.selectUrl(filename) + self.qWaitForPendingActions(dialog) + path2 = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + dialog.selectUrl(path2) + self.qWaitForPendingActions(dialog) + path3 = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group").path() + dialog.selectUrl(path3) + self.qWaitForPendingActions(dialog) + self.assertFalse(forwardAction.isEnabled()) + self.assertTrue(backwardAction.isEnabled()) + + button = utils.getQToolButtonFromAction(backwardAction) + self.mouseClick(button, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertTrue(forwardAction.isEnabled()) + self.assertTrue(backwardAction.isEnabled()) + self.assertSamePath(url.text(), path2) + + button = utils.getQToolButtonFromAction(forwardAction) + self.mouseClick(button, qt.Qt.LeftButton) + self.qWaitForPendingActions(dialog) + self.assertFalse(forwardAction.isEnabled()) + self.assertTrue(backwardAction.isEnabled()) + self.assertSamePath(url.text(), path3) + + def testSelectImageFromEdf(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/singleimage.edf" + path = filename + dialog.selectUrl(path) + self.assertTrue(dialog.selectedImage().shape, (100, 100)) + self.assertSamePath(dialog.selectedFile(), filename) + path = silx.io.url.DataUrl(scheme="fabio", file_path=filename).path() + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectImageFromEdf_Activate(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + filename = _tmpDirectory + "/singleimage.edf" + path = silx.io.url.DataUrl(scheme="fabio", file_path=filename).path() + index = browser.rootIndex().model().index(filename) + # click + browser.selectIndex(index) + # double click + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + # test + self.assertTrue(dialog.selectedImage().shape, (100, 100)) + self.assertSamePath(dialog.selectedFile(), filename) + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectFrameFromEdf(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/multiframe.edf" + path = silx.io.url.DataUrl(scheme="fabio", file_path=filename, data_slice=(1,)).path() + dialog.selectUrl(path) + # test + image = dialog.selectedImage() + self.assertTrue(image.shape, (100, 100)) + self.assertTrue(image[0, 0], 1) + self.assertSamePath(dialog.selectedFile(), filename) + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectImageFromMsk(self): + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/singleimage.msk" + path = silx.io.url.DataUrl(scheme="fabio", file_path=filename).path() + dialog.selectUrl(path) + # test + self.assertTrue(dialog.selectedImage().shape, (100, 100)) + self.assertSamePath(dialog.selectedFile(), filename) + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectImageFromH5(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/data.h5" + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/image").path() + dialog.selectUrl(path) + # test + self.assertTrue(dialog.selectedImage().shape, (100, 100)) + self.assertSamePath(dialog.selectedFile(), filename) + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectH5_Activate(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + filename = _tmpDirectory + "/data.h5" + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path() + index = browser.rootIndex().model().index(filename) + # click + browser.selectIndex(index) + # double click + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + # test + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectFrameFromH5(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + filename = _tmpDirectory + "/data.h5" + path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/cube", data_slice=(1, )).path() + dialog.selectUrl(path) + # test + self.assertTrue(dialog.selectedImage().shape, (100, 100)) + self.assertTrue(dialog.selectedImage()[0, 0], 1) + self.assertSamePath(dialog.selectedFile(), filename) + self.assertSamePath(dialog.selectedUrl(), path) + + def testSelectBadFileFormat_Activate(self): + dialog = self.createDialog() + dialog.show() + self.qWaitForWindowExposed(dialog) + + # init state + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + filename = _tmpDirectory + "/badformat.edf" + index = browser.rootIndex().model().index(filename) + browser.activated.emit(index) + self.qWaitForPendingActions(dialog) + # test + self.assertTrue(dialog.selectedUrl(), filename) + + def _countSelectableItems(self, model, rootIndex): + selectable = 0 + for i in range(model.rowCount(rootIndex)): + index = model.index(i, 0, rootIndex) + flags = model.flags(index) + isEnabled = (int(flags) & qt.Qt.ItemIsEnabled) != 0 + if isEnabled: + selectable += 1 + return selectable + + def testFilterExtensions(self): + if h5py is None: + self.skipTest("h5py is missing") + if fabio is None: + self.skipTest("fabio is missing") + dialog = self.createDialog() + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + filters = utils.findChildren(dialog, qt.QWidget, name="fileTypeCombo")[0] + dialog.show() + self.qWaitForWindowExposed(dialog) + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 5) + + codecName = fabio.edfimage.EdfImage.codec_name() + index = filters.indexFromCodec(codecName) + filters.setCurrentIndex(index) + filters.activated[int].emit(index) + self.qWait(50) + self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 3) + + codecName = fabio.fit2dmaskimage.Fit2dMaskImage.codec_name() + index = filters.indexFromCodec(codecName) + filters.setCurrentIndex(index) + filters.activated[int].emit(index) + self.qWait(50) + self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 1) + + +class TestImageFileDialogApi(utils.TestCaseQt, _UtilsMixin): + + def tearDown(self): + self._deleteDialog() + utils.TestCaseQt.tearDown(self) + + def testSaveRestoreState(self): + dialog = self.createDialog() + dialog.setDirectory(_tmpDirectory) + colormap = Colormap(normalization=Colormap.LOGARITHM) + dialog.setColormap(colormap) + self.qWaitForPendingActions(dialog) + state = dialog.saveState() + dialog = None + + dialog2 = self.createDialog() + result = dialog2.restoreState(state) + self.qWaitForPendingActions(dialog2) + self.assertTrue(result) + self.assertTrue(dialog2.colormap().getNormalization(), "log") + + def printState(self): + """ + Print state of the ImageFileDialog. + + Can be used to add or regenerate `STATE_VERSION1_QT4` or + `STATE_VERSION1_QT5`. + + >>> ./run_tests.py -v silx.gui.dialog.test.test_imagefiledialog.TestImageFileDialogApi.printState + """ + dialog = self.createDialog() + colormap = Colormap(normalization=Colormap.LOGARITHM) + dialog.setDirectory("") + dialog.setHistory([]) + dialog.setColormap(colormap) + dialog.setSidebarUrls([]) + state = dialog.saveState() + string = "" + strings = [] + for i in range(state.size()): + d = state.data()[i] + if not isinstance(d, int): + d = ord(d) + if d > 0x20 and d < 0x7F: + string += chr(d) + else: + string += "\\x%02X" % d + if len(string) > 60: + strings.append(string) + string = "" + strings.append(string) + strings = ["b'%s'" % s for s in strings] + print() + print("\\\n".join(strings)) + + STATE_VERSION1_QT4 = b''\ + b'\x00\x00\x00^\x00s\x00i\x00l\x00x\x00.\x00g\x00u\x00i\x00.\x00'\ + b'd\x00i\x00a\x00l\x00o\x00g\x00.\x00I\x00m\x00a\x00g\x00e\x00F'\ + b'\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00.\x00I\x00m\x00'\ + b'a\x00g\x00e\x00F\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g'\ + b'\x00\x00\x00\x01\x00\x00\x00\x0C\x00\x00\x00\x00"\x00\x00\x00'\ + b'\xFF\x00\x00\x00\x00\x00\x00\x00\x03\xFF\xFF\xFF\xFF\xFF\xFF\xFF'\ + b'\xFF\xFF\xFF\xFF\xFF\x01\x00\x00\x00\x06\x01\x00\x00\x00\x01\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C\x00'\ + b'\x00\x00\x00}\x00\x00\x00\x0E\x00B\x00r\x00o\x00w\x00s\x00e\x00'\ + b'r\x00\x00\x00\x01\x00\x00\x00\x0C\x00\x00\x00\x00Z\x00\x00\x00'\ + b'\xFF\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00'\ + b'\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\ + b'\x00\x00\x00\x00\x01\x90\x00\x00\x00\x04\x01\x01\x00\x00\x00\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00d\xFF\xFF\xFF\xFF\x00'\ + b'\x00\x00\x81\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x01\x90\x00'\ + b'\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00'\ + b'\x00\x00\x0C\x00\x00\x00\x000\x00\x00\x00\x10\x00C\x00o\x00l\x00'\ + b'o\x00r\x00m\x00a\x00p\x00\x00\x00\x01\x00\x00\x00\x08\x00g\x00'\ + b'r\x00a\x00y\x01\x01\x00\x00\x00\x06\x00l\x00o\x00g' + """Serialized state on Qt4. Generated using :meth:`printState`""" + + STATE_VERSION1_QT5 = b''\ + b'\x00\x00\x00^\x00s\x00i\x00l\x00x\x00.\x00g\x00u\x00i\x00.\x00'\ + b'd\x00i\x00a\x00l\x00o\x00g\x00.\x00I\x00m\x00a\x00g\x00e\x00F'\ + b'\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00.\x00I\x00m\x00'\ + b'a\x00g\x00e\x00F\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g'\ + b'\x00\x00\x00\x01\x00\x00\x00\x0C\x00\x00\x00\x00#\x00\x00\x00'\ + b'\xFF\x00\x00\x00\x01\x00\x00\x00\x03\xFF\xFF\xFF\xFF\xFF\xFF\xFF'\ + b'\xFF\xFF\xFF\xFF\xFF\x01\xFF\xFF\xFF\xFF\x01\x00\x00\x00\x01\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C'\ + b'\x00\x00\x00\x00\xAA\x00\x00\x00\x0E\x00B\x00r\x00o\x00w\x00s'\ + b'\x00e\x00r\x00\x00\x00\x01\x00\x00\x00\x0C\x00\x00\x00\x00\x87'\ + b'\x00\x00\x00\xFF\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00'\ + b'\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x01\x90\x00\x00\x00\x04\x01\x01\x00'\ + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00d\xFF\xFF'\ + b'\xFF\xFF\x00\x00\x00\x81\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00'\ + b'\x00d\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00d\x00\x00\x00'\ + b'\x01\x00\x00\x00\x00\x00\x00\x00d\x00\x00\x00\x01\x00\x00\x00'\ + b'\x00\x00\x00\x00d\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x03'\ + b'\xE8\x00\xFF\xFF\xFF\xFF\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00'\ + b'\x00\x0C\x00\x00\x00\x000\x00\x00\x00\x10\x00C\x00o\x00l\x00o'\ + b'\x00r\x00m\x00a\x00p\x00\x00\x00\x01\x00\x00\x00\x08\x00g\x00'\ + b'r\x00a\x00y\x01\x01\x00\x00\x00\x06\x00l\x00o\x00g' + """Serialized state on Qt5. Generated using :meth:`printState`""" + + def testAvoidRestoreRegression_Version1(self): + version = qt.qVersion().split(".")[0] + if version == "4": + state = self.STATE_VERSION1_QT4 + elif version == "5": + state = self.STATE_VERSION1_QT5 + else: + self.skipTest("Resource not available") + + state = qt.QByteArray(state) + dialog = self.createDialog() + result = dialog.restoreState(state) + self.assertTrue(result) + colormap = dialog.colormap() + self.assertTrue(colormap.getNormalization(), "log") + + def testRestoreRobusness(self): + """What's happen if you try to open a config file with a different + binding.""" + state = qt.QByteArray(self.STATE_VERSION1_QT4) + dialog = self.createDialog() + dialog.restoreState(state) + state = qt.QByteArray(self.STATE_VERSION1_QT5) + dialog = None + dialog = self.createDialog() + dialog.restoreState(state) + + def testRestoreNonExistingDirectory(self): + directory = os.path.join(_tmpDirectory, "dir") + os.mkdir(directory) + dialog = self.createDialog() + dialog.setDirectory(directory) + self.qWaitForPendingActions(dialog) + state = dialog.saveState() + os.rmdir(directory) + dialog = None + + dialog2 = self.createDialog() + result = dialog2.restoreState(state) + self.assertTrue(result) + self.assertNotEquals(dialog2.directory(), directory) + + def testHistory(self): + dialog = self.createDialog() + history = dialog.history() + dialog.setHistory([]) + self.assertEqual(dialog.history(), []) + dialog.setHistory(history) + self.assertEqual(dialog.history(), history) + + def testSidebarUrls(self): + dialog = self.createDialog() + urls = dialog.sidebarUrls() + dialog.setSidebarUrls([]) + self.assertEqual(dialog.sidebarUrls(), []) + dialog.setSidebarUrls(urls) + self.assertEqual(dialog.sidebarUrls(), urls) + + def testColomap(self): + dialog = self.createDialog() + colormap = dialog.colormap() + self.assertEqual(colormap.getNormalization(), "linear") + colormap = Colormap(normalization=Colormap.LOGARITHM) + dialog.setColormap(colormap) + self.assertEqual(colormap.getNormalization(), "log") + + def testDirectory(self): + dialog = self.createDialog() + self.qWaitForPendingActions(dialog) + dialog.selectUrl(_tmpDirectory) + self.qWaitForPendingActions(dialog) + self.assertSamePath(dialog.directory(), _tmpDirectory) + + def testBadDataType(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.selectUrl(_tmpDirectory + "/data.h5::/complex_image") + self.qWaitForPendingActions(dialog) + self.assertIsNone(dialog._selectedData()) + + def testBadDataShape(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + dialog.selectUrl(_tmpDirectory + "/data.h5::/unknown") + self.qWaitForPendingActions(dialog) + self.assertIsNone(dialog._selectedData()) + + def testBadDataFormat(self): + dialog = self.createDialog() + dialog.selectUrl(_tmpDirectory + "/badformat.edf") + self.qWaitForPendingActions(dialog) + self.assertIsNone(dialog._selectedData()) + + def testBadPath(self): + dialog = self.createDialog() + dialog.selectUrl("#$%/#$%") + self.qWaitForPendingActions(dialog) + self.assertIsNone(dialog._selectedData()) + + def testBadSubpath(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + self.qWaitForPendingActions(dialog) + + browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0] + + filename = _tmpDirectory + "/data.h5" + url = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/foobar") + dialog.selectUrl(url.path()) + self.qWaitForPendingActions(dialog) + self.assertIsNone(dialog._selectedData()) + + # an existing node is browsed, but the wrong path is selected + index = browser.rootIndex() + obj = index.model().data(index, role=Hdf5TreeModel.H5PY_OBJECT_ROLE) + self.assertEqual(obj.name, "/group") + url = silx.io.url.DataUrl(dialog.selectedUrl()) + self.assertEqual(url.data_path(), "/group") + + def testBadSlicingPath(self): + if h5py is None: + self.skipTest("h5py is missing") + dialog = self.createDialog() + self.qWaitForPendingActions(dialog) + dialog.selectUrl(_tmpDirectory + "/data.h5::/cube[a;45,-90]") + self.qWaitForPendingActions(dialog) + self.assertIsNone(dialog._selectedData()) + + +def suite(): + test_suite = unittest.TestSuite() + loadTests = unittest.defaultTestLoader.loadTestsFromTestCase + test_suite.addTest(loadTests(TestImageFileDialogInteraction)) + test_suite.addTest(loadTests(TestImageFileDialogApi)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/dialog/utils.py b/silx/gui/dialog/utils.py new file mode 100644 index 0000000..1c16b44 --- /dev/null +++ b/silx/gui/dialog/utils.py @@ -0,0 +1,104 @@ +# 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. +# +# ###########################################################################*/ +""" +This module contains utilitaries used by other dialog modules. +""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "25/10/2017" + +import os +import sys +import types +from silx.gui import qt +from silx.third_party import six + + +def samefile(path1, path2): + """Portable :func:`os.path.samepath` function. + + :param str path1: A path to a file + :param str path2: Another path to a file + :rtype: bool + """ + if six.PY2 and sys.platform == "win32": + path1 = os.path.normcase(path1) + path2 = os.path.normcase(path2) + return path1 == path2 + if path1 == path2: + return True + if path1 == "": + return False + if path2 == "": + return False + return os.path.samefile(path1, path2) + + +def findClosestSubPath(hdf5Object, path): + """Find the closest existing path from the hdf5Object using a subset of the + provided path. + + Returns None if no path found. It is possible if the path is a relative + path. + + :param h5py.Node hdf5Object: An HDF5 node + :param str path: A path + :rtype: str + """ + if path in ["", "/"]: + return "/" + names = path.split("/") + if path[0] == "/": + names.pop(0) + for i in range(len(names)): + n = len(names) - i + path2 = "/".join(names[0:n]) + if path2 == "": + return "" + if path2 in hdf5Object: + return path2 + + if path[0] == "/": + return "/" + return None + + +def patchToConsumeReturnKey(widget): + """ + Monkey-patch a widget to consume the return key instead of propagating it + to the dialog. + """ + assert(not hasattr(widget, "_oldKeyPressEvent")) + + def keyPressEvent(self, event): + k = event.key() + result = self._oldKeyPressEvent(event) + if k in [qt.Qt.Key_Return, qt.Qt.Key_Enter]: + event.accept() + return result + + widget._oldKeyPressEvent = widget.keyPressEvent + widget.keyPressEvent = types.MethodType(keyPressEvent, widget) diff --git a/silx/gui/hdf5/Hdf5Formatter.py b/silx/gui/hdf5/Hdf5Formatter.py index 3a4c1c1..0e3697f 100644 --- a/silx/gui/hdf5/Hdf5Formatter.py +++ b/silx/gui/hdf5/Hdf5Formatter.py @@ -27,7 +27,7 @@ text.""" __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "27/09/2017" +__date__ = "23/01/2018" import numpy from silx.third_party import six @@ -153,7 +153,8 @@ class Hdf5Formatter(qt.QObject): if not full: return "compound" else: - compound = [d[0] for d in dtype.fields.values()] + fields = sorted(dtype.fields.items(), key=lambda e: e[1][1]) + compound = [d[1][0] for d in fields] compound = [self.humanReadableDType(d) for d in compound] return "compound(%s)" % ", ".join(compound) elif numpy.issubdtype(dtype, numpy.integer): diff --git a/silx/gui/hdf5/Hdf5Item.py b/silx/gui/hdf5/Hdf5Item.py index f131f61..9804907 100644 --- a/silx/gui/hdf5/Hdf5Item.py +++ b/silx/gui/hdf5/Hdf5Item.py @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "26/09/2017" +__date__ = "10/10/2017" import logging @@ -40,12 +40,6 @@ from ..hdf5.Hdf5Formatter import Hdf5Formatter _logger = logging.getLogger(__name__) -try: - import h5py -except ImportError as e: - _logger.error("Module %s requires h5py", __name__) - raise e - _formatter = TextFormatter() _hdf5Formatter = Hdf5Formatter(textFormatter=_formatter) # FIXME: The formatter should be an attribute of the Hdf5Model @@ -57,15 +51,15 @@ class Hdf5Item(Hdf5Node): tree structure. """ - def __init__(self, text, obj, parent, key=None, h5pyClass=None, linkClass=None, populateAll=False): + def __init__(self, text, obj, parent, key=None, h5Class=None, linkClass=None, populateAll=False): """ :param str text: text displayed - :param object obj: Pointer to h5py data. See the `obj` attribute. + :param object obj: Pointer to a h5py-link object. See the `obj` attribute. """ self.__obj = obj self.__key = key - self.__h5pyClass = h5pyClass - self.__isBroken = obj is None and h5pyClass is None + self.__h5Class = h5Class + self.__isBroken = obj is None and h5Class is None self.__error = None self.__text = text self.__linkClass = linkClass @@ -74,7 +68,7 @@ class Hdf5Item(Hdf5Node): @property def obj(self): if self.__key: - self.__initH5pyObject() + self.__initH5Object() return self.__obj @property @@ -82,6 +76,20 @@ class Hdf5Item(Hdf5Node): return self.__text @property + def h5Class(self): + """Returns the class of the stored object. + + When the object is in lazy loading, this method should be able to + return the type of the futrue loaded object. It allows to delay the + real load of the object. + + :rtype: silx.io.utils.H5Type + """ + if self.__h5Class is None and self.obj is not None: + self.__h5Class = silx.io.utils.get_h5_class(self.obj) + return self.__h5Class + + @property def h5pyClass(self): """Returns the class of the stored object. @@ -91,15 +99,14 @@ class Hdf5Item(Hdf5Node): :rtype: h5py.File or h5py.Dataset or h5py.Group """ - if self.__h5pyClass is None and self.obj is not None: - self.__h5pyClass = silx.io.utils.get_h5py_class(self.obj) - return self.__h5pyClass + type_ = self.h5Class + return silx.io.utils.h5type_to_h5py_class(type_) @property def linkClass(self): """Returns the link class object of this node - :type: h5py.SoftLink or h5py.HardLink or h5py.ExternalLink or None + :rtype: H5Type """ return self.__linkClass @@ -109,16 +116,16 @@ class Hdf5Item(Hdf5Node): :rtype: bool """ - if self.h5pyClass is None: + if self.h5Class is None: return False - return issubclass(self.h5pyClass, h5py.Group) + return self.h5Class in [silx.io.utils.H5Type.GROUP, silx.io.utils.H5Type.FILE] def isBrokenObj(self): """Returns true if the stored HDF5 object is broken. - The stored object is then an h5py link (external or not) which point - to nowhere (tbhe external file is not here, the expected dataset is - still not on the file...) + The stored object is then an h5py-like link (external or not) which + point to nowhere (tbhe external file is not here, the expected + dataset is still not on the file...) :rtype: bool """ @@ -137,7 +144,7 @@ class Hdf5Item(Hdf5Node): return len(self.obj) return 0 - def __initH5pyObject(self): + def __initH5Object(self): """Lazy load of the HDF5 node. It is reached from the parent node with the key of the node.""" parent_obj = self.parent.obj @@ -145,7 +152,9 @@ class Hdf5Item(Hdf5Node): try: obj = parent_obj.get(self.__key) except Exception as e: - _logger.debug("Internal h5py error", exc_info=True) + lib_name = self.obj.__class__.__module__.split(".")[0] + _logger.debug("Internal %s error", lib_name, exc_info=True) + _logger.debug("Backtrace", exc_info=True) try: self.__obj = parent_obj.get(self.__key, getlink=True) except Exception: @@ -168,9 +177,11 @@ class Hdf5Item(Hdf5Node): if not hasattr(self.__obj, "file"): self.__obj.file = parent_obj.file - if isinstance(self.__obj, h5py.ExternalLink): + class_ = silx.io.utils.get_h5_class(self.__obj) + + if class_ == silx.io.utils.H5Type.EXTERNAL_LINK: message = "External link broken. Path %s::%s does not exist" % (self.__obj.filename, self.__obj.path) - elif isinstance(self.__obj, h5py.SoftLink): + elif class_ == silx.io.utils.H5Type.SOFT_LINK: message = "Soft link broken. Path %s does not exist" % (self.__obj.path) else: name = self.obj.__class__.__name__.split(".")[-1].capitalize() @@ -204,14 +215,25 @@ class Hdf5Item(Hdf5Node): try: class_ = self.obj.get(name, getclass=True) link = self.obj.get(name, getclass=True, getlink=True) - except Exception as e: - _logger.warn("Internal h5py error", exc_info=True) + link = silx.io.utils.get_h5_class(class_=link) + except Exception: + lib_name = self.obj.__class__.__module__.split(".")[0] + _logger.warning("Internal %s error", lib_name, exc_info=True) + _logger.debug("Backtrace", exc_info=True) class_ = None try: link = self.obj.get(name, getclass=True, getlink=True) - except Exception as e: - link = h5py.HardLink - item = Hdf5Item(text=name, obj=None, parent=self, key=name, h5pyClass=class_, linkClass=link) + link = silx.io.utils.get_h5_class(class_=link) + except Exception: + _logger.debug("Backtrace", exc_info=True) + link = silx.io.utils.H5Type.HARD_LINK + + h5class = None + if class_ is not None: + h5class = silx.io.utils.get_h5_class(class_=class_) + if h5class is None: + _logger.error("Class %s unsupported", class_) + item = Hdf5Item(text=name, obj=None, parent=self, key=name, h5Class=h5class, linkClass=link) self.appendChild(item) def hasChildren(self): @@ -234,16 +256,16 @@ class Hdf5Item(Hdf5Node): if self.__isBroken: icon = style.standardIcon(qt.QStyle.SP_MessageBoxCritical) return icon - class_ = self.h5pyClass - if issubclass(class_, h5py.File): + class_ = self.h5Class + if class_ == silx.io.utils.H5Type.FILE: return style.standardIcon(qt.QStyle.SP_FileIcon) - elif issubclass(class_, h5py.Group): + elif class_ == silx.io.utils.H5Type.GROUP: return style.standardIcon(qt.QStyle.SP_DirIcon) - elif issubclass(class_, h5py.SoftLink): + elif class_ == silx.io.utils.H5Type.SOFT_LINK: return style.standardIcon(qt.QStyle.SP_DirLinkIcon) - elif issubclass(class_, h5py.ExternalLink): + elif class_ == silx.io.utils.H5Type.EXTERNAL_LINK: return style.standardIcon(qt.QStyle.SP_FileLinkIcon) - elif issubclass(class_, h5py.Dataset): + elif class_ == silx.io.utils.H5Type.DATASET: if obj.shape is None: name = "item-none" elif len(obj.shape) < 4: @@ -262,28 +284,28 @@ class Hdf5Item(Hdf5Node): """ attributeDict = collections.OrderedDict() - if issubclass(self.h5pyClass, h5py.Dataset): + if self.h5Class == silx.io.utils.H5Type.DATASET: attributeDict["#Title"] = "HDF5 Dataset" attributeDict["Name"] = self.basename attributeDict["Path"] = self.obj.name 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): + elif self.h5Class == silx.io.utils.H5Type.GROUP: attributeDict["#Title"] = "HDF5 Group" attributeDict["Name"] = self.basename attributeDict["Path"] = self.obj.name - elif issubclass(self.h5pyClass, h5py.File): + elif self.h5Class == silx.io.utils.H5Type.FILE: attributeDict["#Title"] = "HDF5 File" attributeDict["Name"] = self.basename attributeDict["Path"] = "/" - elif isinstance(self.obj, h5py.ExternalLink): + elif self.h5Class == silx.io.utils.H5Type.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): + elif self.h5Class == silx.io.utils.H5Type.SOFT_LINK: attributeDict["#Title"] = "HDF5 Soft Link" attributeDict["Name"] = self.basename attributeDict["Path"] = self.obj.name @@ -331,8 +353,8 @@ class Hdf5Item(Hdf5Node): if role == qt.Qt.DisplayRole: if self.__error is not None: return "" - class_ = self.h5pyClass - if issubclass(class_, h5py.Dataset): + class_ = self.h5Class + if class_ == silx.io.utils.H5Type.DATASET: text = self._getFormatter().humanReadableType(self.obj) else: text = "" @@ -349,8 +371,8 @@ class Hdf5Item(Hdf5Node): if role == qt.Qt.DisplayRole: if self.__error is not None: return "" - class_ = self.h5pyClass - if not issubclass(class_, h5py.Dataset): + class_ = self.h5Class + if class_ != silx.io.utils.H5Type.DATASET: return "" return self._getFormatter().humanReadableShape(self.obj) return None @@ -364,7 +386,7 @@ class Hdf5Item(Hdf5Node): if role == qt.Qt.DisplayRole: if self.__error is not None: return "" - if not issubclass(self.h5pyClass, h5py.Dataset): + if self.h5Class != silx.io.utils.H5Type.DATASET: return "" return self._getFormatter().humanReadableValue(self.obj) return None @@ -387,7 +409,7 @@ class Hdf5Item(Hdf5Node): if role == qt.Qt.ToolTipRole: if self.__error is not None: self.obj # lazy loading of the object - self.__initH5pyObject() + self.__initH5Object() return self.__error if "desc" in self.obj.attrs: text = self.obj.attrs["desc"] @@ -405,11 +427,11 @@ class Hdf5Item(Hdf5Node): if role == qt.Qt.DisplayRole: if self.isBrokenObj(): return "" - class_ = self.h5pyClass + class_ = self.obj.__class__ text = class_.__name__.split(".")[-1] return text if role == qt.Qt.ToolTipRole: - class_ = self.h5pyClass + class_ = self.obj.__class__ if class_ is None: return "" return "Class name: %s" % self.__class__ @@ -430,11 +452,11 @@ class Hdf5Item(Hdf5Node): link = self.linkClass if link is None: return "" - elif link is h5py.ExternalLink: + elif link == silx.io.utils.H5Type.EXTERNAL_LINK: return "External" - elif link is h5py.SoftLink: + elif link == silx.io.utils.H5Type.SOFT_LINK: return "Soft" - elif link is h5py.HardLink: + elif link == silx.io.utils.H5Type.HARD_LINK: return "" else: return link.__name__ diff --git a/silx/gui/hdf5/Hdf5TreeModel.py b/silx/gui/hdf5/Hdf5TreeModel.py index 41fa91c..2d62429 100644 --- a/silx/gui/hdf5/Hdf5TreeModel.py +++ b/silx/gui/hdf5/Hdf5TreeModel.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# Copyright (c) 2016-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -25,11 +25,12 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "22/09/2017" +__date__ = "29/11/2017" import os import logging +import functools from .. import qt from .. import icons from .Hdf5Node import Hdf5Node @@ -130,7 +131,6 @@ class LoadingItemRunnable(qt.QRunnable): item = Hdf5Item(text=text, obj=h5obj, parent=oldItem.parent, populateAll=True) return item - @qt.Slot() def run(self): """Process the file loading. The worker is used as holder of the data and the signal. The result is sent as a signal. @@ -237,25 +237,32 @@ class Hdf5TreeModel(qt.QAbstractItemModel): 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__() + # FIXME: It should be managed one by one by Hdf5Item itself + + # It is not possible to override the QObject destructor nor + # to access to the content of the Python object with the `destroyed` + # signal cause the Python method was already removed with the QWidget, + # while the QObject still exists. + # We use a static method plus explicit references to objects to + # release. The callback do not use any ref to self. + onDestroy = functools.partial(self._closeFileList, self.__openedFiles) + self.destroyed.connect(onDestroy) + + @staticmethod + def _closeFileList(fileList): + """Static method to close explicit references to internal objects.""" + _logger.debug("Clear Hdf5TreeModel") + for obj in fileList: + _logger.debug("Close file %s", obj.filename) + obj.close() + fileList[:] = [] 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 = [] + self._closeFileList(self.__openedFiles) def __updateLoadingItems(self, icon): for i in range(self.__root.childCount()): @@ -283,6 +290,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): self.__root.removeChildAtIndex(row) self.endRemoveRows() if newItem is not None: + rootIndex = qt.QModelIndex() self.__openedFiles.append(newItem.obj) self.beginInsertRows(rootIndex, row, row) self.__root.insertChild(row, newItem) @@ -325,7 +333,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): Returns an object that contains serialized items of data corresponding to the list of indexes specified. - :param list(qt.QModelIndex) indexes: List of indexes + :param List[qt.QModelIndex] indexes: List of indexes :rtype: qt.QMimeData """ if not self.__fileMoveEnabled or len(indexes) == 0: @@ -512,6 +520,16 @@ class Hdf5TreeModel(qt.QAbstractItemModel): def nodeFromIndex(self, index): return index.internalPointer() if index.isValid() else self.__root + def _closeFileIfOwned(self, node): + """"Close the file if it was loaded from a filename or a + drag-and-drop""" + obj = node.obj + for f in self.__openedFiles: + if f in obj: + _logger.debug("Close file %s", obj.filename) + obj.close() + self.__openedFiles.remove(obj) + def synchronizeIndex(self, index): """ Synchronize a file a given its index. @@ -524,9 +542,8 @@ class Hdf5TreeModel(qt.QAbstractItemModel): if node.parent is not self.__root: return - self.removeIndex(index) filename = node.obj.filename - node.obj.close() + self.removeIndex(index) self.insertFileAsync(filename, index.row()) def synchronizeH5pyObject(self, h5pyObject): @@ -555,6 +572,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel): node = self.nodeFromIndex(index) if node.parent is not self.__root: return + self._closeFileIfOwned(node) self.beginRemoveRows(qt.QModelIndex(), index.row(), index.row()) self.__root.removeChildAtIndex(index.row()) self.endRemoveRows() @@ -587,6 +605,9 @@ class Hdf5TreeModel(qt.QAbstractItemModel): row = self.__root.childCount() self.insertNode(row, Hdf5Item(text=text, obj=h5pyObject, parent=self.__root)) + def hasPendingOperations(self): + return len(self.__runnerSet) > 0 + def insertFileAsync(self, filename, row=-1): if not os.path.isfile(filename): raise IOError("Filename '%s' must be a file path" % filename) @@ -599,9 +620,9 @@ class Hdf5TreeModel(qt.QAbstractItemModel): # start loading the real one runnable = LoadingItemRunnable(filename, item) runnable.itemReady.connect(self.__itemReady) - self.__runnerSet.add(runnable) runnable.runnerFinished.connect(self.__releaseRunner) - qt.QThreadPool.globalInstance().start(runnable) + self.__runnerSet.add(runnable) + qt.silxGlobalThreadPool().start(runnable) def __releaseRunner(self, runner): self.__runnerSet.remove(runner) @@ -621,3 +642,75 @@ class Hdf5TreeModel(qt.QAbstractItemModel): def appendFile(self, filename): self.insertFile(filename, -1) + + def indexFromH5Object(self, h5Object): + """Returns a model index from an h5py-like object. + + :param object h5Object: An h5py-like object + :rtype: qt.QModelIndex + """ + if h5Object is None: + return qt.QModelIndex() + + filename = h5Object.file.filename + + # Seach for the right roots + rootIndices = [] + for index in range(self.rowCount(qt.QModelIndex())): + index = self.index(index, 0, qt.QModelIndex()) + obj = self.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 qt.QModelIndex() + + 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 = self.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(self.rowCount(parentIndex)): + index = self.index(index, 0, parentIndex) + obj = self.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: + return foundIndices[-1] + return qt.QModelIndex() diff --git a/silx/gui/hdf5/Hdf5TreeView.py b/silx/gui/hdf5/Hdf5TreeView.py index 0a4198e..78b5c19 100644 --- a/silx/gui/hdf5/Hdf5TreeView.py +++ b/silx/gui/hdf5/Hdf5TreeView.py @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "20/09/2017" +__date__ = "20/02/2018" import logging @@ -114,12 +114,12 @@ class Hdf5TreeView(qt.QTreeView): callback(event) except KeyboardInterrupt: raise - except: + except Exception: # make sure no user callback crash the application _logger.error("Error while calling callback", exc_info=True) pass - if len(menu.children()) > 0: + if not menu.isEmpty(): for action in actions: menu.addAction(action) menu.popup(self.viewport().mapToGlobal(pos)) @@ -194,6 +194,38 @@ class Hdf5TreeView(qt.QTreeView): continue yield _utils.H5Node(item) + def __intermediateModels(self, index): + """Returns intermediate models from the view model to the + model of the index.""" + models = [] + targetModel = index.model() + model = self.model() + while model is not None: + if model is targetModel: + # found + return models + models.append(model) + if isinstance(model, qt.QAbstractProxyModel): + model = model.sourceModel() + else: + break + raise RuntimeError("Model from the requested index is not reachable from this view") + + def mapToModel(self, index): + """Map an index from any model reachable by the view to an index from + the very first model connected to the view. + + :param qt.QModelIndex index: Index from the Hdf5Tree model + :rtype: qt.QModelIndex + :return: Index from the model connected to the view + """ + if not index.isValid(): + return index + models = self.__intermediateModels(index) + for model in reversed(models): + index = model.mapFromSource(index) + return index + def setSelectedH5Node(self, h5Object): """ Select the specified node of the tree using an h5py node. @@ -203,77 +235,22 @@ class Hdf5TreeView(qt.QTreeView): - 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 + :param h5py.Node 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: + model = self.findHdf5TreeModel() + index = model.indexFromH5Object(h5Object) + index = self.mapToModel(index) + if index.isValid(): # Update the GUI - for index in foundIndices[:-1]: - self.expand(index) - self.setCurrentIndex(foundIndices[-1]) + i = index + while i.isValid(): + self.expand(i) + i = i.parent() + self.setCurrentIndex(index) def mousePressEvent(self, event): """Override mousePressEvent to provide a consistante compatible API diff --git a/silx/gui/hdf5/NexusSortFilterProxyModel.py b/silx/gui/hdf5/NexusSortFilterProxyModel.py index 49a22d3..9a27968 100644 --- a/silx/gui/hdf5/NexusSortFilterProxyModel.py +++ b/silx/gui/hdf5/NexusSortFilterProxyModel.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# Copyright (c) 2016-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -25,7 +25,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "16/06/2017" +__date__ = "10/10/2017" import logging @@ -33,14 +33,8 @@ import re import numpy from .. import qt from .Hdf5TreeModel import Hdf5TreeModel +import silx.io.utils -_logger = logging.getLogger(__name__) - -try: - import h5py -except ImportError as e: - _logger.error("Module %s requires h5py", __name__) - raise e _logger = logging.getLogger(__name__) @@ -86,8 +80,8 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel): def __isNXentry(self, node): """Returns true if the node is an NXentry""" - class_ = node.h5pyClass - if class_ is None or not issubclass(node.h5pyClass, h5py.Group): + class_ = node.h5Class + if class_ is None or class_ != silx.io.utils.H5Type.GROUP: return False nxClass = node.obj.attrs.get("NX_class", None) return nxClass == "NXentry" @@ -100,7 +94,7 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel): `["aaa", 10, "bbb", 50, ".", 30]`. :param str name: A name - :rtype: list + :rtype: List """ words = self.__split.findall(name) result = [] @@ -148,6 +142,6 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel): return left_time < right_time except KeyboardInterrupt: raise - except Exception as e: + except Exception: _logger.debug("Exception occurred", exc_info=True) return None diff --git a/silx/gui/hdf5/_utils.py b/silx/gui/hdf5/_utils.py index 048aa20..ddf4db5 100644 --- a/silx/gui/hdf5/_utils.py +++ b/silx/gui/hdf5/_utils.py @@ -28,7 +28,7 @@ package `silx.gui.hdf5` package. __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "29/09/2017" +__date__ = "20/12/2017" import logging @@ -38,12 +38,6 @@ from silx.utils.html import escape _logger = logging.getLogger(__name__) -try: - import h5py -except ImportError as e: - _logger.error("Module %s requires h5py", __name__) - raise e - class Hdf5ContextMenuEvent(object): """Hold information provided to context menu callbacks.""" @@ -168,12 +162,13 @@ class H5Node(object): e = elements.pop(0) path = path + "/" + e link = obj.parent.get(path, getlink=True) + classlink = silx.io.utils.get_h5_class(link) - if isinstance(link, h5py.ExternalLink): + if classlink == silx.io.utils.H5Type.EXTERNAL_LINK: subpath = "/".join(elements) external_obj = obj.parent.get(self.basename + "/" + subpath) return self.__get_target(external_obj) - elif silx.io.utils.is_softlink(link): + elif classlink == silx.io.utils.H5Type.SOFT_LINK: # Restart from this stat path = "" root_elements = link.path.split("/") @@ -202,13 +197,22 @@ class H5Node(object): return self.__h5py_object @property + def h5type(self): + """Returns the node type, as an H5Type. + + :rtype: H5Node + """ + return silx.io.utils.get_h5_class(self.__h5py_object) + + @property def ntype(self): """Returns the node type, as an h5py class. :rtype: :class:`h5py.File`, :class:`h5py.Group` or :class:`h5py.Dataset` """ - return silx.io.utils.get_h5py_class(self.__h5py_object) + type_ = self.h5type + return silx.io.utils.h5type_to_h5py_class(type_) @property def basename(self): @@ -269,13 +273,13 @@ class H5Node(object): """ item = self.__h5py_item while item.parent.parent is not None: - class_ = item.h5pyClass - if class_ is not None and issubclass(class_, h5py.File): + class_ = silx.io.utils.get_h5_class(class_=item.h5pyClass) + if class_ == silx.io.utils.H5Type.FILE: break item = item.parent - class_ = item.h5pyClass - if class_ is not None and issubclass(class_, h5py.File): + class_ = silx.io.utils.get_h5_class(class_=item.h5pyClass) + if class_ == silx.io.utils.H5Type.FILE: return item.obj else: return item.obj.file @@ -313,8 +317,8 @@ class H5Node(object): :rtype: str """ - class_ = self.__h5py_item.h5pyClass - if class_ is not None and issubclass(class_, h5py.File): + class_ = self.__h5py_item.h5Class + if class_ is not None and class_ == silx.io.utils.H5Type.FILE: return "" return self.__h5py_item.basename @@ -327,10 +331,11 @@ class H5Node(object): :rtype: h5py.File :raises RuntimeError: If no file are found """ - if isinstance(self.__h5py_object, h5py.ExternalLink): + class_ = silx.io.utils.get_h5_class(self.__h5py_object) + if class_ == silx.io.utils.H5Type.EXTERNAL_LINK: # It means the link is broken raise RuntimeError("No file node found") - if isinstance(self.__h5py_object, h5py.SoftLink): + if class_ == silx.io.utils.H5Type.SOFT_LINK: # It means the link is broken return self.local_file @@ -347,10 +352,11 @@ class H5Node(object): :rtype: str """ - if isinstance(self.__h5py_object, h5py.ExternalLink): + class_ = silx.io.utils.get_h5_class(self.__h5py_object) + if class_ == silx.io.utils.H5Type.EXTERNAL_LINK: # It means the link is broken return self.__h5py_object.path - if isinstance(self.__h5py_object, h5py.SoftLink): + if class_ == silx.io.utils.H5Type.SOFT_LINK: # It means the link is broken return self.__h5py_object.path @@ -367,10 +373,11 @@ class H5Node(object): :rtype: str """ - if isinstance(self.__h5py_object, h5py.ExternalLink): + class_ = silx.io.utils.get_h5_class(self.__h5py_object) + if class_ == silx.io.utils.H5Type.EXTERNAL_LINK: # It means the link is broken return self.__h5py_object.filename - if isinstance(self.__h5py_object, h5py.SoftLink): + if class_ == silx.io.utils.H5Type.SOFT_LINK: # It means the link is broken return self.local_file.filename diff --git a/silx/gui/hdf5/test/test_hdf5.py b/silx/gui/hdf5/test/test_hdf5.py index 8e375f2..44c4456 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__ = "22/09/2017" +__date__ = "20/02/2018" import time @@ -40,6 +40,7 @@ from silx.gui import qt from silx.gui.test.utils import TestCaseQt from silx.gui import hdf5 from silx.io import commonh5 +import weakref try: import h5py @@ -69,6 +70,14 @@ class TestHdf5TreeModel(TestCaseQt): if h5py is None: self.skipTest("h5py is not available") + def waitForPendingOperations(self, model): + for i in range(10): + if not model.hasPendingOperations(): + break + self.qWait(10) + else: + raise RuntimeError("Still waiting for a pending operation") + @contextmanager def h5TempFile(self): # create tmp file @@ -96,7 +105,9 @@ class TestHdf5TreeModel(TestCaseQt): # clean up index = model.index(0, 0, qt.QModelIndex()) h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) - h5File.close() + ref = weakref.ref(model) + model = None + self.qWaitForDestroy(ref) def testAppendBadFilename(self): model = hdf5.Hdf5TreeModel() @@ -104,32 +115,35 @@ class TestHdf5TreeModel(TestCaseQt): def testInsertFilename(self): with self.h5TempFile() as filename: - model = hdf5.Hdf5TreeModel() - self.assertEquals(model.rowCount(qt.QModelIndex()), 0) - model.insertFile(filename) - self.assertEquals(model.rowCount(qt.QModelIndex()), 1) - # clean up - index = model.index(0, 0, qt.QModelIndex()) - h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) - h5File.close() + try: + model = hdf5.Hdf5TreeModel() + self.assertEquals(model.rowCount(qt.QModelIndex()), 0) + model.insertFile(filename) + self.assertEquals(model.rowCount(qt.QModelIndex()), 1) + # clean up + index = model.index(0, 0, qt.QModelIndex()) + h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) + self.assertIsNotNone(h5File) + finally: + ref = weakref.ref(model) + model = None + self.qWaitForDestroy(ref) def testInsertFilenameAsync(self): with self.h5TempFile() as filename: - model = hdf5.Hdf5TreeModel() - self.assertEquals(model.rowCount(qt.QModelIndex()), 0) - model.insertFileAsync(filename) - index = model.index(0, 0, qt.QModelIndex()) - self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5LoadingItem.Hdf5LoadingItem) - time.sleep(0.1) - self.qapp.processEvents() - time.sleep(0.1) - index = model.index(0, 0, qt.QModelIndex()) - self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item) - self.assertEquals(model.rowCount(qt.QModelIndex()), 1) - # clean up - index = model.index(0, 0, qt.QModelIndex()) - h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) - h5File.close() + try: + model = hdf5.Hdf5TreeModel() + self.assertEquals(model.rowCount(qt.QModelIndex()), 0) + model.insertFileAsync(filename) + index = model.index(0, 0, qt.QModelIndex()) + self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5LoadingItem.Hdf5LoadingItem) + self.waitForPendingOperations(model) + index = model.index(0, 0, qt.QModelIndex()) + self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item) + finally: + ref = weakref.ref(model) + model = None + self.qWaitForDestroy(ref) def testInsertObject(self): h5 = commonh5.File("/foo/bar/1.mock", "w") @@ -156,6 +170,10 @@ class TestHdf5TreeModel(TestCaseQt): index = model.index(0, 0, qt.QModelIndex()) node1 = model.nodeFromIndex(index) model.synchronizeH5pyObject(h5) + # Now h5 was loaded from it's filename + # Another ref is owned by the model + h5.close() + index = model.index(0, 0, qt.QModelIndex()) node2 = model.nodeFromIndex(index) self.assertIsNot(node1, node2) @@ -168,7 +186,12 @@ class TestHdf5TreeModel(TestCaseQt): # clean up index = model.index(0, 0, qt.QModelIndex()) h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) - h5File.close() + self.assertIsNotNone(h5File) + h5File = None + # delete the model + ref = weakref.ref(model) + model = None + self.qWaitForDestroy(ref) def testFileMoveState(self): model = hdf5.Hdf5TreeModel() @@ -206,15 +229,17 @@ class TestHdf5TreeModel(TestCaseQt): model.dropMimeData(mimeData, qt.Qt.CopyAction, 0, 0, qt.QModelIndex()) self.assertEquals(model.rowCount(qt.QModelIndex()), 1) # after sync - time.sleep(0.1) - self.qapp.processEvents() - time.sleep(0.1) + self.waitForPendingOperations(model) index = model.index(0, 0, qt.QModelIndex()) self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item) # clean up index = model.index(0, 0, qt.QModelIndex()) h5File = model.data(index, role=hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) - h5File.close() + self.assertIsNotNone(h5File) + h5File = None + ref = weakref.ref(model) + model = None + self.qWaitForDestroy(ref) def getRowDataAsDict(self, model, row): displayed = {} @@ -503,7 +528,9 @@ class TestH5Node(TestCaseQt): @classmethod def tearDownClass(cls): + ref = weakref.ref(cls.model) cls.model = None + cls.qWaitForDestroy(ref) cls.h5File.close() shutil.rmtree(cls.tmpDirectory) super(TestH5Node, cls).tearDownClass() @@ -696,6 +723,18 @@ class TestHdf5TreeView(TestCaseQt): view = hdf5.Hdf5TreeView() view._createContextMenu(qt.QPoint(0, 0)) + def testSelection_OriginalModel(self): + tree = commonh5.File("/foo/bar/1.mock", "w") + item = tree.create_group("a/b/c/d") + item.create_group("e").create_group("f") + + view = hdf5.Hdf5TreeView() + view.findHdf5TreeModel().insertH5pyObject(tree) + view.setSelectedH5Node(item) + + selected = list(view.selectedH5Nodes())[0] + self.assertIs(item, selected.h5py_object) + def testSelection_Simple(self): tree = commonh5.File("/foo/bar/1.mock", "w") item = tree.create_group("a/b/c/d") diff --git a/silx/gui/icons.py b/silx/gui/icons.py index 07654c1..0108b3a 100644 --- a/silx/gui/icons.py +++ b/silx/gui/icons.py @@ -328,7 +328,8 @@ def getQIcon(name): """ if name not in _cached_icons: qfile = getQFile(name) - icon = qt.QIcon(qfile.fileName()) + pixmap = qt.QPixmap(qfile.fileName()) + icon = qt.QIcon(pixmap) _cached_icons[name] = icon else: icon = _cached_icons[name] diff --git a/silx/gui/plot/ColorBar.py b/silx/gui/plot/ColorBar.py index 8f4bde2..2db7b79 100644 --- a/silx/gui/plot/ColorBar.py +++ b/silx/gui/plot/ColorBar.py @@ -27,7 +27,7 @@ __authors__ = ["H. Payno", "T. Vincent"] __license__ = "MIT" -__date__ = "11/04/2017" +__date__ = "15/02/2018" import logging @@ -65,11 +65,12 @@ class ColorBarWidget(qt.QWidget): :param plot: PlotWidget the colorbar is attached to (optional) :param str legend: the label to set to the colorbar """ + sigVisibleChanged = qt.Signal(bool) + """Emitted when the property `visible` have changed.""" def __init__(self, parent=None, plot=None, legend=None): self._isConnected = False self._plot = None - self._viewAction = None self._colormap = None self._data = None @@ -127,15 +128,18 @@ class ColorBarWidget(qt.QWidget): self._plot.sigPlotSignal.connect(self._defaultColormapChanged) self._isConnected = True + def setVisible(self, isVisible): + # isHidden looks to be always synchronized, while isVisible is not + wasHidden = self.isHidden() + qt.QWidget.setVisible(self, isVisible) + if wasHidden != self.isHidden(): + self.sigVisibleChanged.emit(not self.isHidden()) + 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): """ @@ -230,21 +234,6 @@ 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 @@ -405,8 +394,8 @@ class ColorScaleBar(qt.QWidget): :param val: if True, set the labels visible, otherwise set it not visible """ - self._maxLabel.show() if val is True else self._maxLabel.hide() - self._minLabel.show() if val is True else self._minLabel.hide() + self._minLabel.setVisible(val) + self._maxLabel.setVisible(val) def _updateMinMax(self): """Update the min and max label if we are in the case of the @@ -533,12 +522,7 @@ class _ColorScale(qt.QWidget): return 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) + colors = colormap.getNColors(nbColors=self._NB_CONTROL_POINTS) self._gradient = qt.QLinearGradient(0, 1, 0, 0) self._gradient.setCoordinateMode(qt.QGradient.StretchToDeviceMode) self._gradient.setStops( @@ -784,7 +768,7 @@ class _TickBar(qt.QWidget): if self._norm == Colormap.Colormap.LINEAR: return 1 - (val - self._vmin) / (self._vmax - self._vmin) elif self._norm == Colormap.Colormap.LOGARITHM: - return 1 - (numpy.log10(val) - numpy.log10(self._vmin))/(numpy.log10(self._vmax) - numpy.log(self._vmin)) + return 1 - (numpy.log10(val) - numpy.log10(self._vmin)) / (numpy.log10(self._vmax) - numpy.log(self._vmin)) else: raise ValueError('Norm is not recognized') diff --git a/silx/gui/plot/Colormap.py b/silx/gui/plot/Colormap.py index abe8546..9adf0d4 100644 --- a/silx/gui/plot/Colormap.py +++ b/silx/gui/plot/Colormap.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2015-2017 European Synchrotron Radiation Facility +# Copyright (c) 2015-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -29,7 +29,7 @@ from __future__ import absolute_import __authors__ = ["T. Vincent", "H.Payno"] __license__ = "MIT" -__date__ = "05/12/2016" +__date__ = "08/01/2018" from silx.gui import qt import copy as copy_mdl @@ -37,6 +37,7 @@ import numpy from .matplotlib import Colormap as MPLColormap import logging from silx.math.combo import min_max +from silx.utils.exceptions import NotEditableError _logger = logging.getLogger(__file__) @@ -62,7 +63,7 @@ class Colormap(qt.QObject): 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 str normalization: Normalization: 'linear' (default) or 'log' :param float vmin: Lower bound of the colormap or None for autoscale (default) :param float vmax: @@ -79,6 +80,7 @@ class Colormap(qt.QObject): """Tuple of managed normalizations""" sigChanged = qt.Signal() + """Signal emitted when the colormap has changed.""" def __init__(self, name='gray', colors=None, normalization=LINEAR, vmin=None, vmax=None): qt.QObject.__init__(self) @@ -98,10 +100,11 @@ class Colormap(qt.QObject): self._normalization = str(normalization) self._vmin = float(vmin) if vmin is not None else None self._vmax = float(vmax) if vmax is not None else None + self._editable = True def isAutoscale(self): """Return True if both min and max are in autoscale mode""" - return self._vmin is None or self._vmax is None + return self._vmin is None and self._vmax is None def getName(self): """Return the name of the colormap @@ -115,35 +118,69 @@ class Colormap(qt.QObject): else: self._colors = numpy.array(colors, copy=True) + def getNColors(self, nbColors=None): + """Returns N colors computed by sampling the colormap regularly. + + :param nbColors: + The number of colors in the returned array or None for the default value. + The default value is 256 for colormap with a name (see :meth:`setName`) and + it is the size of the LUT for colormap defined with :meth:`setColormapLUT`. + :type nbColors: int or None + :return: 2D array of uint8 of shape (nbColors, 4) + :rtype: numpy.ndarray + """ + # Handle default value for nbColors + if nbColors is None: + lut = self.getColormapLUT() + if lut is not None: # In this case uses LUT length + nbColors = len(lut) + else: # Default to 256 + nbColors = 256 + + nbColors = int(nbColors) + + colormap = self.copy() + colormap.setNormalization(Colormap.LINEAR) + colormap.setVRange(vmin=None, vmax=None) + colors = colormap.applyToData( + numpy.arange(nbColors, dtype=numpy.int)) + return colors + def setName(self, name): - """Set the name of the colormap and load the colors corresponding to - the name + """Set the name of the colormap to use. - :param str name: the name of the colormap (should be in ['gray', + :param str name: The name of the colormap. + At least the following names are supported: 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue', 'jet', - 'viridis', 'magma', 'inferno', 'plasma'] + 'viridis', 'magma', 'inferno', 'plasma'. """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') assert name in self.getSupportedColormaps() self._name = str(name) self._colors = None self.sigChanged.emit() def getColormapLUT(self): - """Return the list of colors for the colormap. None if not setted - - :return: the list of colors for the colormap. None if not setted - :rtype: numpy.ndarray + """Return the list of colors for the colormap or None if not set + + :return: the list of colors for the colormap or None if not set + :rtype: numpy.ndarray or None """ - return self._colors + if self._colors is None: + return None + else: + return numpy.array(self._colors, copy=True) def setColormapLUT(self, colors): - """ - Set the colors of the colormap. + """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 + .. warning: this will set the value of name to None """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') self._setColors(colors) if len(colors) is 0: self._colors = None @@ -153,7 +190,7 @@ class Colormap(qt.QObject): def getNormalization(self): """Return the normalization of the colormap ('log' or 'linear') - + :return: the normalization of the colormap :rtype: str """ @@ -164,12 +201,14 @@ class Colormap(qt.QObject): :param str norm: the norm to set """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') self._normalization = str(norm) self.sigChanged.emit() def getVMin(self): """Return the lower bound of the colormap - + :return: the lower bound of the colormap :rtype: float or None """ @@ -182,10 +221,12 @@ class Colormap(qt.QObject): (default) value) """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') if vmin is not None: - if self._vmax is not None and vmin >= self._vmax: - err = "Can't set vmin because vmin >= vmax." - err += "vmin = %s, vmax = %s" %(vmin, self._vmax) + if self._vmax is not None and vmin > self._vmax: + err = "Can't set vmin because vmin >= vmax. " \ + "vmin = %s, vmax = %s" % (vmin, self._vmax) raise ValueError(err) self._vmin = vmin @@ -193,7 +234,7 @@ class Colormap(qt.QObject): 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 """ @@ -205,15 +246,35 @@ class Colormap(qt.QObject): :param float vmax: Upper bounds of the colormap or None for autoscale (default) """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') if vmax is not None: - if self._vmin is not None and vmax <= self._vmin: - err = "Can't set vmax because vmax <= vmin." - err += "vmin = %s, vmax = %s" %(self._vmin, vmax) + if self._vmin is not None and vmax < self._vmin: + err = "Can't set vmax because vmax <= vmin. " \ + "vmin = %s, vmax = %s" % (self._vmin, vmax) raise ValueError(err) self._vmax = vmax self.sigChanged.emit() + def isEditable(self): + """ Return if the colormap is editable or not + + :return: editable state of the colormap + :rtype: bool + """ + return self._editable + + def setEditable(self, editable): + """ + Set the editable state of the colormap + + :param bool editable: is the colormap editable + """ + assert type(editable) is bool + self._editable = editable + self.sigChanged.emit() + def getColormapRange(self, data=None): """Return (vmin, vmax) @@ -267,20 +328,24 @@ class Colormap(qt.QObject): return vmin, vmax def setVRange(self, vmin, vmax): - """ - Set bounds to the colormap + """Set the bounds of the colormap :param vmin: Lower bound of the colormap or None for autoscale (default) :param vmax: Upper bounds of the colormap or None for autoscale (default) """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') if vmin is not None and vmax is not None: - if vmin >= vmax: - err = "Can't set vmin and vmax because vmin >= vmax" - err += "vmin = %s, vmax = %s" %(vmin, self._vmax) + if vmin > vmax: + err = "Can't set vmin and vmax because vmin >= vmax " \ + "vmin = %s, vmax = %s" % (vmin, vmax) raise ValueError(err) + if self._vmin == vmin and self._vmax == vmax: + return + self._vmin = vmin self._vmax = vmax self.sigChanged.emit() @@ -322,6 +387,8 @@ class Colormap(qt.QObject): :param dict dic: the colormap as a dictionary """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') name = dic['name'] if 'name' in dic else None colors = dic['colors'] if 'colors' in dic else None vmin = dic['vmin'] if 'vmin' in dic else None @@ -361,9 +428,9 @@ class Colormap(qt.QObject): return colormap def copy(self): - """ + """Return a copy of the Colormap. - :return: a copy of the Colormap object + :rtype: silx.gui.plot.Colormap.Colormap """ return Colormap(name=self._name, colors=copy_mdl.copy(self._colors), @@ -408,3 +475,115 @@ class Colormap(qt.QObject): numpy.array_equal(self.getColormapLUT(), other.getColormapLUT()) ) + _SERIAL_VERSION = 1 + + def restoreState(self, byteArray): + """ + Read the colormap state from a QByteArray. + + :param qt.QByteArray byteArray: Stream containing the state + :return: True if the restoration sussseed + :rtype: bool + """ + if self.isEditable() is False: + raise NotEditableError('Colormap is not editable') + stream = qt.QDataStream(byteArray, qt.QIODevice.ReadOnly) + + className = stream.readQString() + if className != self.__class__.__name__: + _logger.warning("Classname mismatch. Found %s." % className) + return False + + version = stream.readUInt32() + if version != self._SERIAL_VERSION: + _logger.warning("Serial version mismatch. Found %d." % version) + return False + + name = stream.readQString() + isNull = stream.readBool() + if not isNull: + vmin = stream.readQVariant() + else: + vmin = None + isNull = stream.readBool() + if not isNull: + vmax = stream.readQVariant() + else: + vmax = None + normalization = stream.readQString() + + # emit change event only once + old = self.blockSignals(True) + try: + self.setName(name) + self.setNormalization(normalization) + self.setVRange(vmin, vmax) + finally: + self.blockSignals(old) + self.sigChanged.emit() + return True + + def saveState(self): + """ + Save state of the colomap into a QDataStream. + + :rtype: qt.QByteArray + """ + data = qt.QByteArray() + stream = qt.QDataStream(data, qt.QIODevice.WriteOnly) + + stream.writeQString(self.__class__.__name__) + stream.writeUInt32(self._SERIAL_VERSION) + stream.writeQString(self.getName()) + stream.writeBool(self.getVMin() is None) + if self.getVMin() is not None: + stream.writeQVariant(self.getVMin()) + stream.writeBool(self.getVMax() is None) + if self.getVMax() is not None: + stream.writeQVariant(self.getVMax()) + stream.writeQString(self.getNormalization()) + return data + + +_PREFERRED_COLORMAPS = DEFAULT_COLORMAPS +""" +Tuple of preferred colormap names accessed with :meth:`preferredColormaps`. +""" + + +def preferredColormaps(): + """Returns the name of the preferred colormaps. + + This list is used by widgets allowing to change the colormap + like the :class:`ColormapDialog` as a subset of colormap choices. + + :rtype: tuple of str + """ + return _PREFERRED_COLORMAPS + + +def setPreferredColormaps(colormaps): + """Set the list of preferred colormap names. + + Warning: If a colormap name is not available + it will be removed from the list. + + :param colormaps: Not empty list of colormap names + :type colormaps: iterable of str + :raise ValueError: if the list of available preferred colormaps is empty. + """ + supportedColormaps = Colormap.getSupportedColormaps() + colormaps = tuple( + cmap for cmap in colormaps if cmap in supportedColormaps) + if len(colormaps) == 0: + raise ValueError("Cannot set preferred colormaps to an empty list") + + global _PREFERRED_COLORMAPS + _PREFERRED_COLORMAPS = colormaps + + +# Initialize preferred colormaps +setPreferredColormaps(('gray', 'reversed gray', + 'temperature', 'red', 'green', 'blue', 'jet', + 'viridis', 'magma', 'inferno', 'plasma', + 'hsv')) diff --git a/silx/gui/plot/ColormapDialog.py b/silx/gui/plot/ColormapDialog.py index 748dd72..4aefab6 100644 --- a/silx/gui/plot/ColormapDialog.py +++ b/silx/gui/plot/ColormapDialog.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# Copyright (c) 2004-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -31,12 +31,14 @@ To run the following sample code, a QApplication must be initialized. Create the colormap dialog and set the colormap description and data range: >>> from silx.gui.plot.ColormapDialog import ColormapDialog +>>> from silx.gui.plot.Colormap import Colormap >>> dialog = ColormapDialog() +>>> colormap = Colormap(name='red', normalization='log', +... vmin=1., vmax=2.) ->>> dialog.setColormap(name='red', normalization='log', -... autoscale=False, vmin=1., vmax=2.) ->>> dialog.setDataRange(1., 100.) # This scale the width of the plot area +>>> dialog.setColormap(colormap) +>>> colormap.setVRange(1., 100.) # This scale the width of the plot area >>> dialog.show() Get the colormap description (compatible with :class:`Plot`) from the dialog: @@ -59,9 +61,9 @@ The updates of the colormap description are also available through the signal: from __future__ import division -__authors__ = ["V.A. Sole", "T. Vincent"] +__authors__ = ["V.A. Sole", "T. Vincent", "H. Payno"] __license__ = "MIT" -__date__ = "02/10/2017" +__date__ = "09/02/2018" import logging @@ -69,13 +71,162 @@ import logging import numpy from .. import qt -from .Colormap import Colormap +from .Colormap import Colormap, preferredColormaps from . import PlotWidget from silx.gui.widgets.FloatEdit import FloatEdit +import weakref +from silx.math.combo import min_max +from silx.third_party import enum +from silx.gui import icons +from silx.math.histogram import Histogramnd _logger = logging.getLogger(__name__) +_colormapIconPreview = {} + + +class _BoundaryWidget(qt.QWidget): + """Widget to edit a boundary of the colormap (vmin, vmax)""" + sigValueChanged = qt.Signal(object) + """Signal emitted when value is changed""" + + def __init__(self, parent=None, value=0.0): + qt.QWidget.__init__(self, parent=None) + self.setLayout(qt.QHBoxLayout()) + self.layout().setContentsMargins(0, 0, 0, 0) + self._numVal = FloatEdit(parent=self, value=value) + self.layout().addWidget(self._numVal) + self._autoCB = qt.QCheckBox('auto', parent=self) + self.layout().addWidget(self._autoCB) + self._autoCB.setChecked(False) + + self._autoCB.toggled.connect(self._autoToggled) + self.sigValueChanged = self._autoCB.toggled + self.textEdited = self._numVal.textEdited + self.editingFinished = self._numVal.editingFinished + self._dataValue = None + + def isAutoChecked(self): + return self._autoCB.isChecked() + + def getValue(self): + return None if self._autoCB.isChecked() else self._numVal.value() + + def getFiniteValue(self): + if not self._autoCB.isChecked(): + return self._numVal.value() + elif self._dataValue is None: + return self._numVal.value() + else: + return self._dataValue + + def _autoToggled(self, enabled): + self._numVal.setEnabled(not enabled) + self._updateDisplayedText() + + def _updateDisplayedText(self): + # if dataValue is finite + if self._autoCB.isChecked() and self._dataValue is not None: + old = self._numVal.blockSignals(True) + self._numVal.setValue(self._dataValue) + self._numVal.blockSignals(old) + + def setDataValue(self, dataValue): + self._dataValue = dataValue + self._updateDisplayedText() + + def setFiniteValue(self, value): + assert(value is not None) + old = self._numVal.blockSignals(True) + self._numVal.setValue(value) + self._numVal.blockSignals(old) + + def setValue(self, value, isAuto=False): + self._autoCB.setChecked(isAuto or value is None) + if value is not None: + self._numVal.setValue(value) + self._updateDisplayedText() + + +class _ColormapNameCombox(qt.QComboBox): + def __init__(self, parent=None): + qt.QComboBox.__init__(self, parent) + self.__initItems() + + ORIGINAL_NAME = qt.Qt.UserRole + 1 + + def __initItems(self): + for colormapName in preferredColormaps(): + index = self.count() + self.addItem(str.title(colormapName)) + self.setItemIcon(index, self.getIconPreview(colormapName)) + self.setItemData(index, colormapName, role=self.ORIGINAL_NAME) + + def getIconPreview(self, colormapName): + """Return an icon preview from a LUT name. + + This icons are cached into a global structure. + + :param str colormapName: str + :rtype: qt.QIcon + """ + if colormapName not in _colormapIconPreview: + icon = self.createIconPreview(colormapName) + _colormapIconPreview[colormapName] = icon + return _colormapIconPreview[colormapName] + + def createIconPreview(self, colormapName): + """Create and return an icon preview from a LUT name. + + This icons are cached into a global structure. + + :param str colormapName: Name of the LUT + :rtype: qt.QIcon + """ + colormap = Colormap(colormapName) + size = 32 + lut = colormap.getNColors(size) + if lut is None or len(lut) == 0: + return qt.QIcon() + + pixmap = qt.QPixmap(size, size) + painter = qt.QPainter(pixmap) + for i in range(size): + rgb = lut[i] + r, g, b = rgb[0], rgb[1], rgb[2] + painter.setPen(qt.QColor(r, g, b)) + painter.drawPoint(qt.QPoint(i, 0)) + + painter.drawPixmap(0, 1, size, size - 1, pixmap, 0, 0, size, 1) + painter.end() + + return qt.QIcon(pixmap) + + def getCurrentName(self): + return self.itemData(self.currentIndex(), self.ORIGINAL_NAME) + + def findColormap(self, name): + return self.findData(name, role=self.ORIGINAL_NAME) + + def setCurrentName(self, name): + index = self.findColormap(name) + if index < 0: + index = self.count() + self.addItem(str.title(name)) + self.setItemIcon(index, self.getIconPreview(name)) + self.setItemData(index, name, role=self.ORIGINAL_NAME) + self.setCurrentIndex(index) + + +@enum.unique +class _DataInPlotMode(enum.Enum): + """Enum for each mode of display of the data in the plot.""" + NONE = 'none' + RANGE = 'range' + HISTOGRAM = 'histogram' + + class ColormapDialog(qt.QDialog): """A QDialog widget to set the colormap. @@ -83,57 +234,62 @@ class ColormapDialog(qt.QDialog): :param str title: The QDialog title """ - sigColormapChanged = qt.Signal(Colormap) - """Signal triggered when the colormap is changed. - - It provides a dict describing the colormap to the slot. - This dict can be used with :class:`Plot`. - """ + visibleChanged = qt.Signal(bool) + """This event is sent when the dialog visibility change""" def __init__(self, parent=None, title="Colormap Dialog"): qt.QDialog.__init__(self, parent) self.setWindowTitle(title) + self._colormap = None + self._data = None + self._dataInPlotMode = _DataInPlotMode.RANGE + + self._ignoreColormapChange = False + """Used as a semaphore to avoid editing the colormap object when we are + only attempt to display it. + Used instead of n connect and disconnect of the sigChanged. The + disconnection to sigChanged was also limiting when this colormapdialog + is used in the colormapaction and associated to the activeImageChanged. + (because the activeImageChanged is send when the colormap changed and + the self.setcolormap is a callback) + """ + self._histogramData = None - self._dataRange = None self._minMaxWasEdited = False + self._initialRange = None + + self._dataRange = None + """If defined 3-tuple containing information from a data: + minimum, positive minimum, maximum""" - colormaps = [ - 'gray', 'reversed gray', - 'temperature', 'red', 'green', 'blue', 'jet', - 'viridis', 'magma', 'inferno', 'plasma'] - if 'hsv' in Colormap.getSupportedColormaps(): - colormaps.append('hsv') - self._colormapList = tuple(colormaps) + self._colormapStoredState = None # Make the GUI vLayout = qt.QVBoxLayout(self) - formWidget = qt.QWidget() + formWidget = qt.QWidget(parent=self) vLayout.addWidget(formWidget) formLayout = qt.QFormLayout(formWidget) formLayout.setContentsMargins(10, 10, 10, 10) formLayout.setSpacing(0) # Colormap row - self._comboBoxColormap = qt.QComboBox() - for cmap in self._colormapList: - # Capitalize first letters - cmap = ' '.join(w[0].upper() + w[1:] for w in cmap.split()) - self._comboBoxColormap.addItem(cmap) - self._comboBoxColormap.activated[int].connect(self._notify) + self._comboBoxColormap = _ColormapNameCombox(parent=formWidget) + self._comboBoxColormap.currentIndexChanged[int].connect(self._updateName) formLayout.addRow('Colormap:', self._comboBoxColormap) # Normalization row self._normButtonLinear = qt.QRadioButton('Linear') self._normButtonLinear.setChecked(True) self._normButtonLog = qt.QRadioButton('Log') + self._normButtonLog.toggled.connect(self._activeLogNorm) normButtonGroup = qt.QButtonGroup(self) normButtonGroup.setExclusive(True) normButtonGroup.addButton(self._normButtonLinear) normButtonGroup.addButton(self._normButtonLog) - normButtonGroup.buttonClicked[int].connect(self._notify) + self._normButtonLinear.toggled[bool].connect(self._updateLinearNorm) normLayout = qt.QHBoxLayout() normLayout.setContentsMargins(0, 0, 0, 0) @@ -143,51 +299,124 @@ class ColormapDialog(qt.QDialog): formLayout.addRow('Normalization:', normLayout) - # Range row - self._rangeAutoscaleButton = qt.QCheckBox('Autoscale') - self._rangeAutoscaleButton.setChecked(True) - self._rangeAutoscaleButton.toggled.connect(self._autoscaleToggled) - self._rangeAutoscaleButton.clicked.connect(self._notify) - formLayout.addRow('Range:', self._rangeAutoscaleButton) - # Min row - self._minValue = FloatEdit(parent=self, value=1.) - self._minValue.setEnabled(False) + self._minValue = _BoundaryWidget(parent=self, value=1.0) self._minValue.textEdited.connect(self._minMaxTextEdited) self._minValue.editingFinished.connect(self._minEditingFinished) + self._minValue.sigValueChanged.connect(self._updateMinMax) formLayout.addRow('\tMin:', self._minValue) # Max row - self._maxValue = FloatEdit(parent=self, value=10.) - self._maxValue.setEnabled(False) + self._maxValue = _BoundaryWidget(parent=self, value=10.0) self._maxValue.textEdited.connect(self._minMaxTextEdited) + self._maxValue.sigValueChanged.connect(self._updateMinMax) self._maxValue.editingFinished.connect(self._maxEditingFinished) formLayout.addRow('\tMax:', self._maxValue) # Add plot for histogram + self._plotToolbar = qt.QToolBar(self) + self._plotToolbar.setFloatable(False) + self._plotToolbar.setMovable(False) + self._plotToolbar.setIconSize(qt.QSize(8, 8)) + self._plotToolbar.setStyleSheet("QToolBar { border: 0px }") + self._plotToolbar.setOrientation(qt.Qt.Vertical) + + group = qt.QActionGroup(self._plotToolbar) + group.setExclusive(True) + + action = qt.QAction("Nothing", self) + action.setToolTip("No range nor histogram are displayed. No extra computation have to be done.") + action.setIcon(icons.getQIcon('colormap-none')) + action.setCheckable(True) + action.setData(_DataInPlotMode.NONE) + action.setChecked(action.data() == self._dataInPlotMode) + self._plotToolbar.addAction(action) + group.addAction(action) + action = qt.QAction("Data range", self) + action.setToolTip("Display the data range within the colormap range. A fast data processing have to be done.") + action.setIcon(icons.getQIcon('colormap-range')) + action.setCheckable(True) + action.setData(_DataInPlotMode.RANGE) + action.setChecked(action.data() == self._dataInPlotMode) + self._plotToolbar.addAction(action) + group.addAction(action) + action = qt.QAction("Histogram", self) + action.setToolTip("Display the data histogram within the colormap range. A slow data processing have to be done. ") + action.setIcon(icons.getQIcon('colormap-histogram')) + action.setCheckable(True) + action.setData(_DataInPlotMode.HISTOGRAM) + action.setChecked(action.data() == self._dataInPlotMode) + self._plotToolbar.addAction(action) + group.addAction(action) + group.triggered.connect(self._displayDataInPlotModeChanged) + + self._plotBox = qt.QWidget(self) self._plotInit() - vLayout.addWidget(self._plot) - # Close button - buttonsWidget = qt.QWidget() - vLayout.addWidget(buttonsWidget) + plotBoxLayout = qt.QHBoxLayout() + plotBoxLayout.setContentsMargins(0, 0, 0, 0) + plotBoxLayout.setSpacing(2) + plotBoxLayout.addWidget(self._plotToolbar) + plotBoxLayout.addWidget(self._plot) + plotBoxLayout.setSizeConstraint(qt.QLayout.SetMinimumSize) + self._plotBox.setLayout(plotBoxLayout) + vLayout.addWidget(self._plotBox) + + # define modal buttons + types = qt.QDialogButtonBox.Ok | qt.QDialogButtonBox.Cancel + self._buttonsModal = qt.QDialogButtonBox(parent=self) + self._buttonsModal.setStandardButtons(types) + self.layout().addWidget(self._buttonsModal) + self._buttonsModal.accepted.connect(self.accept) + self._buttonsModal.rejected.connect(self.reject) + + # define non modal buttons + types = qt.QDialogButtonBox.Close | qt.QDialogButtonBox.Reset + self._buttonsNonModal = qt.QDialogButtonBox(parent=self) + self._buttonsNonModal.setStandardButtons(types) + self.layout().addWidget(self._buttonsNonModal) + self._buttonsNonModal.button(qt.QDialogButtonBox.Close).clicked.connect(self.accept) + self._buttonsNonModal.button(qt.QDialogButtonBox.Reset).clicked.connect(self.resetColormap) + + # Set the colormap to default values + self.setColormap(Colormap(name='gray', normalization='linear', + vmin=None, vmax=None)) - buttonsLayout = qt.QHBoxLayout(buttonsWidget) + self.setModal(self.isModal()) - okButton = qt.QPushButton('OK') - okButton.clicked.connect(self.accept) - buttonsLayout.addWidget(okButton) + vLayout.setSizeConstraint(qt.QLayout.SetMinimumSize) + self.setFixedSize(self.sizeHint()) + self._applyColormap() - cancelButton = qt.QPushButton('Cancel') - cancelButton.clicked.connect(self.reject) - buttonsLayout.addWidget(cancelButton) + def showEvent(self, event): + self.visibleChanged.emit(True) + super(ColormapDialog, self).showEvent(event) - # colormap window can not be resized - self.setFixedSize(vLayout.minimumSize()) + def closeEvent(self, event): + if not self.isModal(): + self.accept() + super(ColormapDialog, self).closeEvent(event) - # Set the colormap to default values - self.setColormap(name='gray', normalization='linear', - autoscale=True, vmin=1., vmax=10.) + def hideEvent(self, event): + self.visibleChanged.emit(False) + super(ColormapDialog, self).hideEvent(event) + + def close(self): + self.accept() + qt.QDialog.close(self) + + def setModal(self, modal): + assert type(modal) is bool + self._buttonsNonModal.setVisible(not modal) + self._buttonsModal.setVisible(modal) + qt.QDialog.setModal(self, modal) + + def exec_(self): + wasModal = self.isModal() + self.setModal(True) + result = super(ColormapDialog, self).exec_() + self.setModal(wasModal) + return result def _plotInit(self): """Init the plot to display the range and the values""" @@ -199,51 +428,63 @@ class ColormapDialog(qt.QDialog): self._plot.setActiveCurveHandling(False) self._plot.setMinimumSize(qt.QSize(250, 200)) self._plot.sigPlotSignal.connect(self._plotSlot) - self._plot.hide() self._plotUpdate() + def sizeHint(self): + return self.layout().minimumSize() + def _plotUpdate(self, updateMarkers=True): """Update the plot content :param bool updateMarkers: True to update markers, False otherwith """ - dataRange = self.getDataRange() - - if dataRange is None: - if self._plot.isVisibleTo(self): - self._plot.setVisible(False) - self.setFixedSize(self.layout().minimumSize()) + colormap = self.getColormap() + if colormap is None: + if self._plotBox.isVisibleTo(self): + self._plotBox.setVisible(False) + self.setFixedSize(self.sizeHint()) return - if not self._plot.isVisibleTo(self): - self._plot.setVisible(True) - self.setFixedSize(self.layout().minimumSize()) + if not self._plotBox.isVisibleTo(self): + self._plotBox.setVisible(True) + self.setFixedSize(self.sizeHint()) - dataMin, dataMax = dataRange - marge = (abs(dataMax) + abs(dataMin)) / 6.0 - minmd = dataMin - marge - maxpd = dataMax + marge + minData, maxData = self._minValue.getFiniteValue(), self._maxValue.getFiniteValue() + if minData > maxData: + # avoid a full collapse + minData, maxData = maxData, minData + minimum = minData + maximum = maxData - start, end = self._minValue.value(), self._maxValue.value() + if self._dataRange is not None: + minRange = self._dataRange[0] + maxRange = self._dataRange[2] + minimum = min(minimum, minRange) + maximum = max(maximum, maxRange) - if start <= end: - x = [minmd, start, end, maxpd] - y = [0, 0, 1, 1] + if self._histogramData is not None: + minHisto = self._histogramData[1][0] + maxHisto = self._histogramData[1][-1] + minimum = min(minimum, minHisto) + maximum = max(maximum, maxHisto) - else: - x = [minmd, end, start, maxpd] - y = [1, 1, 0, 0] - - # Display the colormap on the side - # colormap = {'name': self.getColormap()['name'], - # 'normalization': self.getColormap()['normalization'], - # 'autoscale': True, 'vmin': 1., 'vmax': 256.} - # self._plot.addImage((1 + numpy.arange(256)).reshape(256, -1), - # xScale=(minmd - marge, marge), - # yScale=(1., 2./256.), - # legend='colormap', - # colormap=colormap) + marge = abs(maximum - minimum) / 6.0 + if marge < 0.0001: + # Smaller that the QLineEdit precision + marge = 0.0001 + + minView, maxView = minimum - marge, maximum + marge + + if updateMarkers: + # Save the state in we are not moving the markers + self._initialRange = minView, maxView + elif self._initialRange is not None: + minView = min(minView, self._initialRange[0]) + maxView = max(maxView, self._initialRange[1]) + + x = [minView, minData, maxData, maxView] + y = [0, 0, 1, 1] self._plot.addCurve(x, y, legend="ConstrainedCurve", @@ -252,22 +493,24 @@ class ColormapDialog(qt.QDialog): linestyle='-', resetzoom=False) - draggable = not self._rangeAutoscaleButton.isChecked() - if updateMarkers: + minDraggable = (self._colormap().isEditable() and + not self._minValue.isAutoChecked()) self._plot.addXMarker( - self._minValue.value(), + self._minValue.getFiniteValue(), legend='Min', text='Min', - draggable=draggable, + draggable=minDraggable, color='blue', constraint=self._plotMinMarkerConstraint) + maxDraggable = (self._colormap().isEditable() and + not self._maxValue.isAutoChecked()) self._plot.addXMarker( - self._maxValue.value(), + self._maxValue.getFiniteValue(), legend='Max', text='Max', - draggable=draggable, + draggable=maxDraggable, color='blue', constraint=self._plotMaxMarkerConstraint) @@ -275,11 +518,11 @@ class ColormapDialog(qt.QDialog): def _plotMinMarkerConstraint(self, x, y): """Constraint of the min marker""" - return min(x, self._maxValue.value()), y + return min(x, self._maxValue.getFiniteValue()), y def _plotMaxMarkerConstraint(self, x, y): """Constraint of the max marker""" - return max(x, self._minValue.value()), y + return max(x, self._minValue.getFiniteValue()), y def _plotSlot(self, event): """Handle events from the plot""" @@ -293,10 +536,139 @@ class ColormapDialog(qt.QDialog): # This will recreate the markers while interacting... # It might break if marker interaction is changed if event['event'] == 'markerMoved': - self._notify() + self._initialRange = None + self._updateMinMax() else: self._plotUpdate(updateMarkers=False) + @staticmethod + def computeDataRange(data): + """Compute the data range as used by :meth:`setDataRange`. + + :param data: The data to process + :rtype: Tuple(float, float, float) + """ + if data is None or len(data) == 0: + return None, None, None + + dataRange = min_max(data, min_positive=True, finite=True) + if dataRange.minimum is None: + # Only non-finite data + dataRange = None + + if dataRange is not None: + min_positive = dataRange.min_positive + if min_positive is None: + min_positive = float('nan') + dataRange = dataRange.minimum, min_positive, dataRange.maximum + + if dataRange is None or len(dataRange) != 3: + qt.QMessageBox.warning( + None, "No Data", + "Image data does not contain any real value") + dataRange = 1., 1., 10. + + return dataRange + + @staticmethod + def computeHistogram(data): + """Compute the data histogram as used by :meth:`setHistogram`. + + :param data: The data to process + :rtype: Tuple(List(float),List(float) + """ + _data = data + if _data.ndim == 3: # RGB(A) images + _logger.info('Converting current image from RGB(A) to grayscale\ + in order to compute the intensity distribution') + _data = (_data[:, :, 0] * 0.299 + + _data[:, :, 1] * 0.587 + + _data[:, :, 2] * 0.114) + + if len(_data) == 0: + return None, None + + xmin, xmax = min_max(_data, min_positive=False, finite=True) + nbins = min(256, int(numpy.sqrt(_data.size))) + data_range = xmin, xmax + + # bad hack: get 256 bins in the case we have a B&W + if numpy.issubdtype(_data.dtype, numpy.integer): + if nbins > xmax - xmin: + nbins = xmax - xmin + + nbins = max(2, nbins) + _data = _data.ravel().astype(numpy.float32) + + histogram = Histogramnd(_data, n_bins=nbins, histo_range=data_range) + return histogram.histo, histogram.edges[0] + + def _getData(self): + if self._data is None: + return None + return self._data() + + def setData(self, data): + """Store the data as a weakref. + + According to the state of the dialog, the data will be used to display + the data range or the histogram of the data using :meth:`setDataRange` + and :meth:`setHistogram` + """ + oldData = self._getData() + if oldData is data: + return + + if data is None: + self.setDataRange() + self.setHistogram() + self._data = None + return + + self._data = weakref.ref(data, self._dataAboutToFinalize) + + self._updateDataInPlot() + + def _setDataInPlotMode(self, mode): + if self._dataInPlotMode == mode: + return + self._dataInPlotMode = mode + self._updateDataInPlot() + + def _displayDataInPlotModeChanged(self, action): + mode = action.data() + self._setDataInPlotMode(mode) + + def _updateDataInPlot(self): + data = self._getData() + if data is None: + return + + mode = self._dataInPlotMode + + if mode == _DataInPlotMode.NONE: + self.setHistogram() + self.setDataRange() + elif mode == _DataInPlotMode.RANGE: + result = self.computeDataRange(data) + self.setHistogram() + self.setDataRange(*result) + elif mode == _DataInPlotMode.HISTOGRAM: + # The histogram should be done in a worker thread + result = self.computeHistogram(data) + self.setHistogram(*result) + self.setDataRange() + + def _colormapAboutToFinalize(self, weakrefColormap): + """Callback when the data weakref is about to be finalized.""" + if self._colormap is weakrefColormap: + self.setColormap(None) + + def _dataAboutToFinalize(self, weakrefData): + """Callback when the data weakref is about to be finalized.""" + if self._data is weakrefData: + self.setData(None) + def getHistogram(self): """Returns the counts and bin edges of the displayed histogram. @@ -312,136 +684,243 @@ class ColormapDialog(qt.QDialog): """Set the histogram to display. This update the data range with the bounds of the bins. - See :meth:`setDataRange`. :param hist: array-like of counts or None to hide histogram :param bin_edges: array-like of bins edges or None to hide histogram """ if hist is None or bin_edges is None: self._histogramData = None - self._plot.remove(legend='Histogram', kind='curve') - self.setDataRange() # Remove data range - + self._plot.remove(legend='Histogram', kind='histogram') else: hist = numpy.array(hist, copy=True) bin_edges = numpy.array(bin_edges, copy=True) self._histogramData = hist, bin_edges - - # For now, draw the histogram as a curve - # using bin centers and normalised counts - bins_center = 0.5 * (bin_edges[:-1] + bin_edges[1:]) norm_hist = hist / max(hist) - self._plot.addCurve(bins_center, norm_hist, - legend="Histogram", - color='gray', - symbol='', - linestyle='-', - fill=True) + self._plot.addHistogram(norm_hist, + bin_edges, + legend="Histogram", + color='gray', + align='center', + fill=True) + self._updateMinMaxData() - # Update the data range - self.setDataRange(bin_edges[0], bin_edges[-1]) + def getColormap(self): + """Return the colormap description as a :class:`.Colormap`. - def getDataRange(self): - """Returns the data range used for the histogram area. + """ + if self._colormap is None: + return None + return self._colormap() - :return: (dataMin, dataMax) or None if no data range is set - :rtype: 2-tuple of float + def resetColormap(self): """ - return self._dataRange + Reset the colormap state before modification. - def setDataRange(self, min_=None, max_=None): + ..note :: the colormap reference state is the state when set or the + state when validated + """ + colormap = self.getColormap() + if colormap is not None and self._colormapStoredState is not None: + if self._colormap()._toDict() != self._colormapStoredState: + self._ignoreColormapChange = True + colormap._setFromDict(self._colormapStoredState) + self._ignoreColormapChange = False + self._applyColormap() + + def setDataRange(self, minimum=None, positiveMin=None, maximum=None): """Set the range of data to use for the range of the histogram area. - :param float min_: The min of the data or None to disable range. - :param float max_: The max of the data or None to disable range. + :param float minimum: The minimum of the data + :param float positiveMin: The positive minimum of the data + :param float maximum: The maximum of the data """ - if min_ is None or max_ is None: + if minimum is None or positiveMin is None or maximum is None: self._dataRange = None - self._plotUpdate() - + self._plot.remove(legend='Range', kind='histogram') else: - min_, max_ = float(min_), float(max_) - assert min_ <= max_ - self._dataRange = min_, max_ - if self._rangeAutoscaleButton.isChecked(): - self._minValue.setValue(min_) - self._maxValue.setValue(max_) - self._notify() - else: - self._plotUpdate() + hist = numpy.array([1]) + bin_edges = numpy.array([minimum, maximum]) + self._plot.addHistogram(hist, + bin_edges, + legend="Range", + color='gray', + align='center', + fill=True) + self._dataRange = minimum, positiveMin, maximum + self._updateMinMaxData() + + def _updateMinMaxData(self): + """Update the min and max of the data according to the data range and + the histogram preset.""" + colormap = self.getColormap() + + minimum = float("+inf") + maximum = float("-inf") + + if colormap is not None and colormap.getNormalization() == colormap.LOGARITHM: + # find a range in the positive part of the data + if self._dataRange is not None: + minimum = min(minimum, self._dataRange[1]) + maximum = max(maximum, self._dataRange[2]) + if self._histogramData is not None: + positives = list(filter(lambda x: x > 0, self._histogramData[1])) + if len(positives) > 0: + minimum = min(minimum, positives[0]) + maximum = max(maximum, positives[-1]) + else: + if self._dataRange is not None: + minimum = min(minimum, self._dataRange[0]) + maximum = max(maximum, self._dataRange[2]) + if self._histogramData is not None: + minimum = min(minimum, self._histogramData[1][0]) + maximum = max(maximum, self._histogramData[1][-1]) + + if not numpy.isfinite(minimum): + minimum = None + if not numpy.isfinite(maximum): + maximum = None + + self._minValue.setDataValue(minimum) + self._maxValue.setDataValue(maximum) + self._plotUpdate() - def getColormap(self): - """Return the colormap description as a :class:`.Colormap`. + def accept(self): + self.storeCurrentState() + qt.QDialog.accept(self) + def storeCurrentState(self): + """ + save the current value sof the colormap if the user want to undo is + modifications """ - isNormLinear = self._normButtonLinear.isChecked() - if self._rangeAutoscaleButton.isChecked(): - vmin = None - vmax = None + colormap = self.getColormap() + if colormap is not None: + self._colormapStoredState = colormap._toDict() 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, - autoscale=None, vmin=None, vmax=None, colors=None): - """Set the colormap description + self._colormapStoredState = None - If some arguments are not provided, the current values are used. + def reject(self): + self.resetColormap() + qt.QDialog.reject(self) - :param str name: The name of the colormap - :param str normalization: 'linear' or 'log' - :param bool autoscale: Toggle colormap range autoscale - :param float vmin: The min value, ignored if autoscale is True - :param float vmax: The max value, ignored if autoscale is True + def setColormap(self, colormap): + """Set the colormap description + + :param :class:`Colormap` colormap: the colormap to edit """ - if name is not None: - assert name in self._colormapList - index = self._colormapList.index(name) - self._comboBoxColormap.setCurrentIndex(index) - - if normalization is not None: - 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) - - if vmax is not None: - self._maxValue.setValue(vmax) - - if autoscale is not None: - self._rangeAutoscaleButton.setChecked(autoscale) - if autoscale: - dataRange = self.getDataRange() - if dataRange is not None: - self._minValue.setValue(dataRange[0]) - self._maxValue.setValue(dataRange[1]) - - # Do it once for all the changes - self._notify() - - def _notify(self, *args, **kwargs): - """Emit the signal for colormap change""" + assert colormap is None or isinstance(colormap, Colormap) + if self._ignoreColormapChange is True: + return + + oldColormap = self.getColormap() + if oldColormap is colormap: + return + if oldColormap is not None: + oldColormap.sigChanged.disconnect(self._applyColormap) + + if colormap is not None: + colormap.sigChanged.connect(self._applyColormap) + colormap = weakref.ref(colormap, self._colormapAboutToFinalize) + + self._colormap = colormap + self.storeCurrentState() + self._updateResetButton() + self._applyColormap() + + def _updateResetButton(self): + resetButton = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset) + rStateEnabled = False + colormap = self.getColormap() + if colormap is not None and colormap.isEditable(): + # can reset only in the case the colormap changed + rStateEnabled = colormap._toDict() != self._colormapStoredState + resetButton.setEnabled(rStateEnabled) + + def _applyColormap(self): + self._updateResetButton() + if self._ignoreColormapChange is True: + return + + colormap = self.getColormap() + if colormap is None: + self._comboBoxColormap.setEnabled(False) + self._normButtonLinear.setEnabled(False) + self._normButtonLog.setEnabled(False) + self._minValue.setEnabled(False) + self._maxValue.setEnabled(False) + else: + self._ignoreColormapChange = True + + if colormap.getName() is not None: + name = colormap.getName() + self._comboBoxColormap.setCurrentName(name) + self._comboBoxColormap.setEnabled(self._colormap().isEditable()) + + assert colormap.getNormalization() in Colormap.NORMALIZATIONS + self._normButtonLinear.setChecked( + colormap.getNormalization() == Colormap.LINEAR) + self._normButtonLog.setChecked( + colormap.getNormalization() == Colormap.LOGARITHM) + vmin = colormap.getVMin() + vmax = colormap.getVMax() + dataRange = colormap.getColormapRange() + self._normButtonLinear.setEnabled(self._colormap().isEditable()) + self._normButtonLog.setEnabled(self._colormap().isEditable()) + self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None) + self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None) + self._minValue.setEnabled(self._colormap().isEditable()) + self._maxValue.setEnabled(self._colormap().isEditable()) + self._ignoreColormapChange = False + self._plotUpdate() - self.sigColormapChanged.emit(self.getColormap()) - - def _autoscaleToggled(self, checked): - """Handle autoscale changes by enabling/disabling min/max fields""" - self._minValue.setEnabled(not checked) - self._maxValue.setEnabled(not checked) - if checked: - dataRange = self.getDataRange() - if dataRange is not None: - self._minValue.setValue(dataRange[0]) - self._maxValue.setValue(dataRange[1]) + + def _updateMinMax(self): + if self._ignoreColormapChange is True: + return + + vmin = self._minValue.getFiniteValue() + vmax = self._maxValue.getFiniteValue() + if vmax is not None and vmin is not None and vmax < vmin: + # If only one autoscale is checked constraints are too strong + # We have to edit a user value anyway it is not requested + # TODO: It would be better IMO to disable the auto checkbox before + # this case occur (valls) + cmin = self._minValue.isAutoChecked() + cmax = self._maxValue.isAutoChecked() + if cmin is False: + self._minValue.setFiniteValue(vmax) + if cmax is False: + self._maxValue.setFiniteValue(vmin) + + vmin = self._minValue.getValue() + vmax = self._maxValue.getValue() + self._ignoreColormapChange = True + colormap = self._colormap() + if colormap is not None: + colormap.setVRange(vmin, vmax) + self._ignoreColormapChange = False + self._plotUpdate() + self._updateResetButton() + + def _updateName(self): + if self._ignoreColormapChange is True: + return + + if self._colormap(): + self._ignoreColormapChange = True + self._colormap().setName( + self._comboBoxColormap.getCurrentName()) + self._ignoreColormapChange = False + + def _updateLinearNorm(self, isNormLinear): + if self._ignoreColormapChange is True: + return + + if self._colormap(): + self._ignoreColormapChange = True + norm = Colormap.LINEAR if isNormLinear else Colormap.LOGARITHM + self._colormap().setNormalization(norm) + self._ignoreColormapChange = False def _minMaxTextEdited(self, text): """Handle _minValue and _maxValue textEdited signal""" @@ -457,9 +936,10 @@ class ColormapDialog(qt.QDialog): self._minMaxWasEdited = False # Fix start value - if self._minValue.value() > self._maxValue.value(): - self._minValue.setValue(self._maxValue.value()) - self._notify() + if (self._maxValue.getValue() is not None and + self._minValue.getValue() > self._maxValue.getValue()): + self._minValue.setValue(self._maxValue.getValue()) + self._updateMinMax() def _maxEditingFinished(self): """Handle _maxValue editingFinished signal @@ -471,9 +951,10 @@ class ColormapDialog(qt.QDialog): self._minMaxWasEdited = False # Fix end value - if self._minValue.value() > self._maxValue.value(): - self._maxValue.setValue(self._minValue.value()) - self._notify() + if (self._minValue.getValue() is not None and + self._minValue.getValue() > self._maxValue.getValue()): + self._maxValue.setValue(self._minValue.getValue()) + self._updateMinMax() def keyPressEvent(self, event): """Override key handling. @@ -488,3 +969,13 @@ class ColormapDialog(qt.QDialog): else: # Use QDialog keyPressEvent super(ColormapDialog, self).keyPressEvent(event) + + def _activeLogNorm(self, isLog): + if self._ignoreColormapChange is True: + return + if self._colormap(): + self._ignoreColormapChange = True + norm = Colormap.LOGARITHM if isLog is True else Colormap.LINEAR + self._colormap().setNormalization(norm) + self._ignoreColormapChange = False + self._updateMinMaxData() diff --git a/silx/gui/plot/ComplexImageView.py b/silx/gui/plot/ComplexImageView.py index 1463293..ebff175 100644 --- a/silx/gui/plot/ComplexImageView.py +++ b/silx/gui/plot/ComplexImageView.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -32,144 +32,22 @@ from __future__ import absolute_import __authors__ = ["Vincent Favre-Nicolin", "T. Vincent"] __license__ = "MIT" -__date__ = "02/10/2017" +__date__ = "19/01/2018" import logging +import collections import numpy from .. import qt, icons from .PlotWindow import Plot2D -from .Colormap import Colormap from . import items +from .items import ImageComplexData 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): @@ -291,13 +169,19 @@ class _ComplexDataToolButton(qt.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')] + _MODES = collections.OrderedDict([ + (ImageComplexData.Mode.ABSOLUTE, ('math-amplitude', 'Amplitude')), + (ImageComplexData.Mode.SQUARE_AMPLITUDE, + ('math-square-amplitude', 'Square amplitude')), + (ImageComplexData.Mode.PHASE, ('math-phase', 'Phase')), + (ImageComplexData.Mode.REAL, ('math-real', 'Real part')), + (ImageComplexData.Mode.IMAGINARY, + ('math-imaginary', 'Imaginary part')), + (ImageComplexData.Mode.AMPLITUDE_PHASE, + ('math-phase-color', 'Amplitude and Phase')), + (ImageComplexData.Mode.LOG10_AMPLITUDE_PHASE, + ('math-phase-color-log', 'Log10(Amp.) and Phase')) + ]) _RANGE_DIALOG_TEXT = 'Set Amplitude Range...' @@ -311,8 +195,10 @@ class _ComplexDataToolButton(qt.QToolButton): menu.triggered.connect(self._triggered) self.setMenu(menu) - for _, icon, text in self._MODES: + for mode, info in self._MODES.items(): + icon, text = info action = qt.QAction(icons.getQIcon(icon), text, self) + action.setData(mode) action.setIconVisibleInMenu(True) menu.addAction(action) @@ -328,13 +214,10 @@ class _ComplexDataToolButton(qt.QToolButton): 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') + icon, text = self._MODES[mode] + self.setIcon(icons.getQIcon(icon)) + self.setToolTip('Display the ' + text.lower()) + self._rangeDialogAction.setEnabled(mode == ImageComplexData.Mode.LOG10_AMPLITUDE_PHASE) def _triggered(self, action): """Handle triggering of menu actions""" @@ -360,9 +243,9 @@ class _ComplexDataToolButton(qt.QToolButton): dialog.sigRangeChanged.disconnect(self._rangeChanged) else: # update mode - for mode, _, text in self._MODES: - if actionText == text: - self._plot2DComplex.setVisualizationMode(mode) + mode = action.data() + if isinstance(mode, ImageComplexData.Mode): + self._plot2DComplex.setVisualizationMode(mode) def _rangeChanged(self, range_): """Handle updates of range in the dialog""" @@ -375,10 +258,13 @@ class ComplexImageView(qt.QWidget): :param parent: See :class:`QMainWindow` """ + Mode = ImageComplexData.Mode + """Also expose the modes inside the class""" + sigDataChanged = qt.Signal() """Signal emitted when data has changed.""" - sigVisualizationModeChanged = qt.Signal(str) + sigVisualizationModeChanged = qt.Signal(object) """Signal emitted when the visualization mode has changed. It provides the new visualization mode. @@ -389,11 +275,6 @@ class ComplexImageView(qt.QWidget): 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) @@ -403,10 +284,9 @@ class ComplexImageView(qt.QWidget): self.setLayout(layout) # Create and add image to the plot - self._plotImage = _ImageComplexData() + self._plotImage = ImageComplexData() self._plotImage._setLegend('__ComplexImageView__complex_image__') - self._plotImage.setData(self._displayedData) - self._plotImage.setVisualizationMode(self._mode) + self._plotImage.sigItemChanged.connect(self._itemChanged) self._plot2D._add(self._plotImage) self._plot2D.setActiveImage(self._plotImage.getLegend()) @@ -416,57 +296,18 @@ class ComplexImageView(qt.QWidget): self._plot2D.insertToolBar(self._plot2D.getProfileToolbar(), toolBar) + def _itemChanged(self, event): + """Handle item changed signal""" + if event is items.ItemChangedType.DATA: + self.sigDataChanged.emit() + elif event is items.ItemChangedType.VISUALIZATION_MODE: + mode = self.getVisualizationMode() + self.sigVisualizationModeChanged.emit(mode) + 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. @@ -476,22 +317,13 @@ class ComplexImageView(qt.QWidget): """ 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() + previousData = self._plotImage.getComplexData(copy=False) + + self._plotImage.setData(data, copy=copy) + + if previousData.shape != data.shape: + self.getPlot().resetZoom() def getData(self, copy=True): """Get the currently displayed complex data. @@ -501,7 +333,7 @@ class ComplexImageView(qt.QWidget): :return: The complex data array. :rtype: numpy.ndarray of complex with 2 dimensions """ - return numpy.array(self._data, copy=copy) + return self._plotImage.getComplexData(copy=copy) def getDisplayedData(self, copy=True): """Returns the displayed data depending on the visualization mode @@ -512,7 +344,12 @@ class ComplexImageView(qt.QWidget): 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) + mode = self.getVisualizationMode() + if mode in (self.Mode.AMPLITUDE_PHASE, + self.Mode.LOG10_AMPLITUDE_PHASE): + return self._plotImage.getRgbaImageData(copy=copy) + else: + return self._plotImage.getData(copy=copy) @staticmethod def getSupportedVisualizationModes(): @@ -530,12 +367,7 @@ class ComplexImageView(qt.QWidget): :rtype: tuple of str """ - return ('absolute', - 'phase', - 'real', - 'imaginary', - 'amplitude_phase', - 'log10_amplitude_phase') + return tuple(ImageComplexData.Mode) def setVisualizationMode(self, mode): """Set the mode of visualization of the complex data. @@ -545,20 +377,14 @@ class ComplexImageView(qt.QWidget): :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) + self._plotImage.setVisualizationMode(mode) def getVisualizationMode(self): """Get the current visualization mode of the complex data. - :rtype: str + :rtype: Mode """ - return self._mode + return self._plotImage.getVisualizationMode() def _setAmplitudeRangeInfo(self, max_=None, delta=2): """Set the amplitude range to display for 'log10_amplitude_phase' mode. @@ -567,39 +393,35 @@ class ComplexImageView(qt.QWidget): 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() + self._plotImage._setAmplitudeRangeInfo(max_, delta) 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 + return self._plotImage._getAmplitudeRangeInfo() # Image item proxy - def setColormap(self, colormap): + def setColormap(self, colormap, mode=None): """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 + :param ~silx.gui.plot.Colormap.Colormap colormap: The colormap + :param Mode mode: If specified, set the colormap of this specific mode """ - self._plotImage.setColormap(colormap) + self._plotImage.setColormap(colormap, mode) - def getColormap(self): + def getColormap(self, mode=None): """Returns the colormap used to display the data. - :rtype: Colormap + :param Mode mode: If specified, set the colormap of this specific mode + :rtype: ~silx.gui.plot.Colormap.Colormap """ - # Returns internal colormap and bypass forcing colormap - return items.ImageData.getColormap(self._plotImage) + return self._plotImage.getColormap(mode=mode) def getOrigin(self): """Returns the offset from origin at which to display the image. diff --git a/silx/gui/plot/CurvesROIWidget.py b/silx/gui/plot/CurvesROIWidget.py index 4b10cd6..ccb6866 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__ = "27/06/2017" +__date__ = "13/11/2017" from collections import OrderedDict @@ -57,6 +57,8 @@ import sys import numpy from silx.io import dictdump +from silx.utils import deprecation + from .. import icons, qt @@ -84,10 +86,14 @@ class CurvesROIWidget(qt.QWidget): 'rowheader' """ - def __init__(self, parent=None, name=None): + sigROISignal = qt.Signal(object) + + def __init__(self, parent=None, name=None, plot=None): super(CurvesROIWidget, self).__init__(parent) if name is not None: self.setWindowTitle(name) + assert plot is not None + self.plot = plot layout = qt.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) @@ -151,6 +157,19 @@ class CurvesROIWidget(qt.QWidget): self.saveButton.clicked.connect(self._save) self.roiTable.sigROITableSignal.connect(self._forward) + self.currentROI = None + self._middleROIMarkerFlag = False + self._isConnected = False # True if connected to plot signals + self._isInit = False + + def showEvent(self, event): + self._visibilityChangedHandler(visible=True) + qt.QWidget.showEvent(self, event) + + def hideEvent(self, event): + self._visibilityChangedHandler(visible=False) + qt.QWidget.hideEvent(self, event) + @property def roiFileDir(self): """The directory from which to load/save ROI from/to files.""" @@ -214,6 +233,19 @@ class CurvesROIWidget(qt.QWidget): return OrderedDict([(name, roidict[name]) for name in ordered_roilist]) + def setMiddleROIMarkerFlag(self, flag=True): + """Activate or deactivate middle marker. + + This allows shifting both min and max limits at once, by dragging + a marker located in the middle. + + :param bool flag: True to activate middle ROI marker + """ + if flag: + self._middleROIMarkerFlag = True + else: + self._middleROIMarkerFlag = False + def _add(self): """Add button clicked handler""" ddict = {} @@ -365,6 +397,322 @@ class CurvesROIWidget(qt.QWidget): """Set the header text of this widget""" self.headerLabel.setText("<b>%s<\b>" % text) + def _roiSignal(self, ddict): + """Handle ROI widget signal""" + _logger.debug("CurvesROIWidget._roiSignal %s", str(ddict)) + if ddict['event'] == "AddROI": + 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') + self.plot.remove('ROI max', kind='marker') + if self._middleROIMarkerFlag: + self.plot.remove('ROI middle', kind='marker') + roiList, roiDict = self.roiTable.getROIListAndDict() + nrois = len(roiList) + if nrois == 0: + newroi = "ICR" + fromdata, dummy0, todata, dummy1 = self._getAllLimits() + draggable = False + color = 'black' + else: + for i in range(nrois): + i += 1 + newroi = "newroi %d" % i + if newroi not in roiList: + break + color = 'blue' + draggable = True + self.plot.addXMarker(fromdata, + legend='ROI min', + text='ROI min', + color=color, + draggable=draggable) + self.plot.addXMarker(todata, + legend='ROI max', + text='ROI max', + color=color, + draggable=draggable) + if draggable and self._middleROIMarkerFlag: + pos = 0.5 * (fromdata + todata) + self.plot.addXMarker(pos, + legend='ROI middle', + text="", + color='yellow', + draggable=draggable) + roiList.append(newroi) + roiDict[newroi] = {} + if newroi == "ICR": + roiDict[newroi]['type'] = "Default" + else: + roiDict[newroi]['type'] = self.plot.getXAxis().getLabel() + roiDict[newroi]['from'] = fromdata + roiDict[newroi]['to'] = todata + self.roiTable.fillFromROIDict(roilist=roiList, + roidict=roiDict, + currentroi=newroi) + self.currentROI = newroi + self.calculateRois() + elif ddict['event'] in ['DelROI', "ResetROI"]: + self.plot.remove('ROI min', kind='marker') + self.plot.remove('ROI max', kind='marker') + if self._middleROIMarkerFlag: + self.plot.remove('ROI middle', kind='marker') + roiList, roiDict = self.roiTable.getROIListAndDict() + roiDictKeys = list(roiDict.keys()) + if len(roiDictKeys): + currentroi = roiDictKeys[0] + else: + # create again the ICR + ddict = {"event": "AddROI"} + return self._roiSignal(ddict) + + self.roiTable.fillFromROIDict(roilist=roiList, + roidict=roiDict, + currentroi=currentroi) + self.currentROI = currentroi + + elif ddict['event'] == 'LoadROI': + self.calculateRois() + + elif ddict['event'] == 'selectionChanged': + _logger.debug("Selection changed") + self.roilist, self.roidict = self.roiTable.getROIListAndDict() + fromdata = ddict['roi']['from'] + todata = ddict['roi']['to'] + self.plot.remove('ROI min', kind='marker') + self.plot.remove('ROI max', kind='marker') + if ddict['key'] == 'ICR': + draggable = False + color = 'black' + else: + draggable = True + color = 'blue' + self.plot.addXMarker(fromdata, + legend='ROI min', + text='ROI min', + color=color, + draggable=draggable) + self.plot.addXMarker(todata, + legend='ROI max', + text='ROI max', + color=color, + draggable=draggable) + if draggable and self._middleROIMarkerFlag: + pos = 0.5 * (fromdata + todata) + self.plot.addXMarker(pos, + legend='ROI middle', + text="", + color='yellow', + draggable=True) + self.currentROI = ddict['key'] + if ddict['colheader'] in ['From', 'To']: + dict0 = {} + dict0['event'] = "SetActiveCurveEvent" + dict0['legend'] = self.plot.getActiveCurve(just_legend=1) + self.plot.setActiveCurve(dict0['legend']) + elif ddict['colheader'] == 'Raw Counts': + pass + elif ddict['colheader'] == 'Net Counts': + pass + else: + self._emitCurrentROISignal() + + else: + _logger.debug("Unknown or ignored event %s", ddict['event']) + + def _getAllLimits(self): + """Retrieve the limits based on the curves.""" + curves = self.plot.getAllCurves() + if not curves: + return 1.0, 1.0, 100., 100. + + xmin, ymin = None, None + xmax, ymax = None, None + + for curve in curves: + x = curve.getXData(copy=False) + y = curve.getYData(copy=False) + if xmin is None: + xmin = x.min() + else: + xmin = min(xmin, x.min()) + if xmax is None: + xmax = x.max() + else: + xmax = max(xmax, x.max()) + if ymin is None: + ymin = y.min() + else: + ymin = min(ymin, y.min()) + if ymax is None: + ymax = y.max() + else: + ymax = max(ymax, y.max()) + + return xmin, ymin, xmax, ymax + + @deprecation.deprecated(replacement="calculateRois", + reason="CamelCase convention") + def calculateROIs(self, *args, **kw): + self.calculateRois(*args, **kw) + + def calculateRois(self, roiList=None, roiDict=None): + """Compute ROI information""" + if roiList is None or roiDict is None: + roiList, roiDict = self.roiTable.getROIListAndDict() + + activeCurve = self.plot.getActiveCurve(just_legend=False) + if activeCurve is None: + xproc = None + yproc = None + self.setHeader() + else: + x = activeCurve.getXData(copy=False) + y = activeCurve.getYData(copy=False) + legend = activeCurve.getLegend() + idx = numpy.argsort(x, kind='mergesort') + xproc = numpy.take(x, idx) + yproc = numpy.take(y, idx) + self.setHeader('ROIs of %s' % legend) + + for key in roiList: + if key == 'ICR': + if xproc is not None: + roiDict[key]['from'] = xproc.min() + roiDict[key]['to'] = xproc.max() + else: + roiDict[key]['from'] = 0 + roiDict[key]['to'] = -1 + fromData = roiDict[key]['from'] + toData = roiDict[key]['to'] + if xproc is not None: + idx = numpy.nonzero((fromData <= xproc) & + (xproc <= toData))[0] + if len(idx): + xw = xproc[idx] + yw = yproc[idx] + rawCounts = yw.sum(dtype=numpy.float) + deltaX = xw[-1] - xw[0] + deltaY = yw[-1] - yw[0] + if deltaX > 0.0: + slope = (deltaY / deltaX) + background = yw[0] + slope * (xw - xw[0]) + netCounts = (rawCounts - + background.sum(dtype=numpy.float)) + else: + netCounts = 0.0 + else: + rawCounts = 0.0 + netCounts = 0.0 + roiDict[key]['rawcounts'] = rawCounts + roiDict[key]['netcounts'] = netCounts + else: + roiDict[key].pop('rawcounts', None) + roiDict[key].pop('netcounts', None) + + self.roiTable.fillFromROIDict( + roilist=roiList, + roidict=roiDict, + currentroi=self.currentROI if self.currentROI in roiList else None) + + def _emitCurrentROISignal(self): + ddict = {} + ddict['event'] = "currentROISignal" + _roiList, roiDict = self.roiTable.getROIListAndDict() + if self.currentROI in roiDict: + ddict['ROI'] = roiDict[self.currentROI] + else: + self.currentROI = None + ddict['current'] = self.currentROI + self.sigROISignal.emit(ddict) + + def _handleROIMarkerEvent(self, ddict): + """Handle plot signals related to marker events.""" + if ddict['event'] == 'markerMoved': + + label = ddict['label'] + if label not in ['ROI min', 'ROI max', 'ROI middle']: + return + + roiList, roiDict = self.roiTable.getROIListAndDict() + if self.currentROI is None: + return + if self.currentROI not in roiDict: + return + x = ddict['x'] + + if label == 'ROI min': + roiDict[self.currentROI]['from'] = x + if self._middleROIMarkerFlag: + pos = 0.5 * (roiDict[self.currentROI]['to'] + + roiDict[self.currentROI]['from']) + self.plot.addXMarker(pos, + legend='ROI middle', + text='', + color='yellow', + draggable=True) + elif label == 'ROI max': + roiDict[self.currentROI]['to'] = x + if self._middleROIMarkerFlag: + pos = 0.5 * (roiDict[self.currentROI]['to'] + + roiDict[self.currentROI]['from']) + self.plot.addXMarker(pos, + legend='ROI middle', + text='', + color='yellow', + draggable=True) + elif label == 'ROI middle': + delta = x - 0.5 * (roiDict[self.currentROI]['from'] + + roiDict[self.currentROI]['to']) + roiDict[self.currentROI]['from'] += delta + roiDict[self.currentROI]['to'] += delta + self.plot.addXMarker(roiDict[self.currentROI]['from'], + legend='ROI min', + text='ROI min', + color='blue', + draggable=True) + self.plot.addXMarker(roiDict[self.currentROI]['to'], + legend='ROI max', + text='ROI max', + color='blue', + draggable=True) + else: + return + self.calculateRois(roiList, roiDict) + self._emitCurrentROISignal() + + def _visibilityChangedHandler(self, visible): + """Handle widget's visibility updates. + + It is connected to plot signals only when visible. + """ + if visible: + if not self._isInit: + # Deferred ROI widget init finalization + self._isInit = True + self.sigROIWidgetSignal.connect(self._roiSignal) + # initialize with the ICR + self._roiSignal({'event': "AddROI"}) + + if not self._isConnected: + self.plot.sigPlotSignal.connect(self._handleROIMarkerEvent) + self.plot.sigActiveCurveChanged.connect( + self._activeCurveChanged) + self._isConnected = True + + self.calculateRois() + else: + if self._isConnected: + self.plot.sigPlotSignal.disconnect(self._handleROIMarkerEvent) + self.plot.sigActiveCurveChanged.disconnect( + self._activeCurveChanged) + self._isConnected = False + + def _activeCurveChanged(self, *args): + """Recompute ROIs when active curve changed.""" + self.calculateRois() + class ROITable(qt.QTableWidget): """Table widget displaying ROI information. @@ -622,6 +970,9 @@ class CurvesROIDockWidget(qt.QDockWidget): :param name: See :class:`QDockWidget` """ sigROISignal = qt.Signal(object) + """Deprecated signal for backward compatibility with silx < 0.7. + Prefer connecting directly to :attr:`CurvesRoiWidget.sigRoiSignal` + """ def __init__(self, parent=None, plot=None, name=None): super(CurvesROIDockWidget, self).__init__(name, parent) @@ -629,25 +980,24 @@ class CurvesROIDockWidget(qt.QDockWidget): assert plot is not None self.plot = plot - self.currentROI = None - self._middleROIMarkerFlag = False - - self._isConnected = False # True if connected to plot signals - self._isInit = False - - self.roiWidget = CurvesROIWidget(self, name) + self.roiWidget = CurvesROIWidget(self, name, plot=plot) """Main widget of type :class:`CurvesROIWidget`""" # convenience methods to offer a simpler API allowing to ignore # the details of the underlying implementation - self.calculateROIs = self.calculateRois + # (ALL DEPRECATED) + self.calculateROIs = self.calculateRois = self.roiWidget.calculateRois self.setRois = self.roiWidget.setRois self.getRois = self.roiWidget.getRois + self.roiWidget.sigROISignal.connect(self._forwardSigROISignal) + self.currentROI = self.roiWidget.currentROI self.layout().setContentsMargins(0, 0, 0, 0) self.setWidget(self.roiWidget) - self.visibilityChanged.connect(self._visibilityChangedHandler) + def _forwardSigROISignal(self, ddict): + # emit deprecated signal for backward compatibility (silx < 0.7) + self.sigROISignal.emit(ddict) def toggleViewAction(self): """Returns a checkable action that shows or closes this widget. @@ -658,320 +1008,10 @@ class CurvesROIDockWidget(qt.QDockWidget): action.setIcon(icons.getQIcon('plot-roi')) return action - def _visibilityChangedHandler(self, visible): - """Handle widget's visibilty updates. - - It is connected to plot signals only when visible. - """ - if visible: - if not self._isInit: - # Deferred ROI widget init finalization - self._isInit = True - self.roiWidget.sigROIWidgetSignal.connect(self._roiSignal) - # initialize with the ICR - self._roiSignal({'event': "AddROI"}) - - if not self._isConnected: - self.plot.sigPlotSignal.connect(self._handleROIMarkerEvent) - self.plot.sigActiveCurveChanged.connect( - self._activeCurveChanged) - self._isConnected = True - - self.calculateROIs() - else: - if self._isConnected: - self.plot.sigPlotSignal.disconnect(self._handleROIMarkerEvent) - self.plot.sigActiveCurveChanged.disconnect( - self._activeCurveChanged) - self._isConnected = False - - def _handleROIMarkerEvent(self, ddict): - """Handle plot signals related to marker events.""" - if ddict['event'] == 'markerMoved': - - label = ddict['label'] - if label not in ['ROI min', 'ROI max', 'ROI middle']: - return - - roiList, roiDict = self.roiWidget.getROIListAndDict() - if self.currentROI is None: - return - if self.currentROI not in roiDict: - return - x = ddict['x'] - - if label == 'ROI min': - roiDict[self.currentROI]['from'] = x - if self._middleROIMarkerFlag: - pos = 0.5 * (roiDict[self.currentROI]['to'] + - roiDict[self.currentROI]['from']) - self.plot.addXMarker(pos, - legend='ROI middle', - text='', - color='yellow', - draggable=True) - elif label == 'ROI max': - roiDict[self.currentROI]['to'] = x - if self._middleROIMarkerFlag: - pos = 0.5 * (roiDict[self.currentROI]['to'] + - roiDict[self.currentROI]['from']) - self.plot.addXMarker(pos, - legend='ROI middle', - text='', - color='yellow', - draggable=True) - elif label == 'ROI middle': - delta = x - 0.5 * (roiDict[self.currentROI]['from'] + - roiDict[self.currentROI]['to']) - roiDict[self.currentROI]['from'] += delta - roiDict[self.currentROI]['to'] += delta - self.plot.addXMarker(roiDict[self.currentROI]['from'], - legend='ROI min', - text='ROI min', - color='blue', - draggable=True) - self.plot.addXMarker(roiDict[self.currentROI]['to'], - legend='ROI max', - text='ROI max', - color='blue', - draggable=True) - else: - return - self.calculateROIs(roiList, roiDict) - self._emitCurrentROISignal() - - def _roiSignal(self, ddict): - """Handle ROI widget signal""" - _logger.debug("PlotWindow._roiSignal %s", str(ddict)) - if ddict['event'] == "AddROI": - 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') - self.plot.remove('ROI max', kind='marker') - if self._middleROIMarkerFlag: - self.remove('ROI middle', kind='marker') - roiList, roiDict = self.roiWidget.getROIListAndDict() - nrois = len(roiList) - if nrois == 0: - newroi = "ICR" - fromdata, dummy0, todata, dummy1 = self._getAllLimits() - draggable = False - color = 'black' - else: - for i in range(nrois): - i += 1 - newroi = "newroi %d" % i - if newroi not in roiList: - break - color = 'blue' - draggable = True - self.plot.addXMarker(fromdata, - legend='ROI min', - text='ROI min', - color=color, - draggable=draggable) - self.plot.addXMarker(todata, - legend='ROI max', - text='ROI max', - color=color, - draggable=draggable) - if draggable and self._middleROIMarkerFlag: - pos = 0.5 * (fromdata + todata) - self.plot.addXMarker(pos, - legend='ROI middle', - text="", - color='yellow', - draggable=draggable) - roiList.append(newroi) - roiDict[newroi] = {} - if newroi == "ICR": - roiDict[newroi]['type'] = "Default" - else: - roiDict[newroi]['type'] = self.plot.getXAxis().getLabel() - roiDict[newroi]['from'] = fromdata - roiDict[newroi]['to'] = todata - self.roiWidget.fillFromROIDict(roilist=roiList, - roidict=roiDict, - currentroi=newroi) - self.currentROI = newroi - self.calculateROIs() - elif ddict['event'] in ['DelROI', "ResetROI"]: - self.plot.remove('ROI min', kind='marker') - self.plot.remove('ROI max', kind='marker') - if self._middleROIMarkerFlag: - self.plot.remove('ROI middle', kind='marker') - roiList, roiDict = self.roiWidget.getROIListAndDict() - roiDictKeys = list(roiDict.keys()) - if len(roiDictKeys): - currentroi = roiDictKeys[0] - else: - # create again the ICR - ddict = {"event": "AddROI"} - return self._roiSignal(ddict) - - self.roiWidget.fillFromROIDict(roilist=roiList, - roidict=roiDict, - currentroi=currentroi) - self.currentROI = currentroi - - elif ddict['event'] == 'LoadROI': - self.calculateROIs() - - elif ddict['event'] == 'selectionChanged': - _logger.debug("Selection changed") - self.roilist, self.roidict = self.roiWidget.getROIListAndDict() - fromdata = ddict['roi']['from'] - todata = ddict['roi']['to'] - self.plot.remove('ROI min', kind='marker') - self.plot.remove('ROI max', kind='marker') - if ddict['key'] == 'ICR': - draggable = False - color = 'black' - else: - draggable = True - color = 'blue' - self.plot.addXMarker(fromdata, - legend='ROI min', - text='ROI min', - color=color, - draggable=draggable) - self.plot.addXMarker(todata, - legend='ROI max', - text='ROI max', - color=color, - draggable=draggable) - if draggable and self._middleROIMarkerFlag: - pos = 0.5 * (fromdata + todata) - self.plot.addXMarker(pos, - legend='ROI middle', - text="", - color='yellow', - draggable=True) - self.currentROI = ddict['key'] - if ddict['colheader'] in ['From', 'To']: - dict0 = {} - dict0['event'] = "SetActiveCurveEvent" - dict0['legend'] = self.plot.getActiveCurve(just_legend=1) - self.plot.setActiveCurve(dict0['legend']) - elif ddict['colheader'] == 'Raw Counts': - pass - elif ddict['colheader'] == 'Net Counts': - pass - else: - self._emitCurrentROISignal() - - else: - _logger.debug("Unknown or ignored event %s", ddict['event']) - - def _activeCurveChanged(self, *args): - """Recompute ROIs when active curve changed.""" - self.calculateROIs() - - def calculateRois(self, roiList=None, roiDict=None): - """Compute ROI information""" - if roiList is None or roiDict is None: - roiList, roiDict = self.roiWidget.getROIListAndDict() - - activeCurve = self.plot.getActiveCurve(just_legend=False) - if activeCurve is None: - xproc = None - yproc = None - self.roiWidget.setHeader() - else: - x = activeCurve.getXData(copy=False) - y = activeCurve.getYData(copy=False) - legend = activeCurve.getLegend() - idx = numpy.argsort(x, kind='mergesort') - xproc = numpy.take(x, idx) - yproc = numpy.take(y, idx) - self.roiWidget.setHeader('ROIs of %s' % legend) - - for key in roiList: - if key == 'ICR': - if xproc is not None: - roiDict[key]['from'] = xproc.min() - roiDict[key]['to'] = xproc.max() - else: - roiDict[key]['from'] = 0 - roiDict[key]['to'] = -1 - fromData = roiDict[key]['from'] - toData = roiDict[key]['to'] - if xproc is not None: - idx = numpy.nonzero((fromData <= xproc) & - (xproc <= toData))[0] - if len(idx): - xw = xproc[idx] - yw = yproc[idx] - rawCounts = yw.sum(dtype=numpy.float) - deltaX = xw[-1] - xw[0] - deltaY = yw[-1] - yw[0] - if deltaX > 0.0: - slope = (deltaY / deltaX) - background = yw[0] + slope * (xw - xw[0]) - netCounts = (rawCounts - - background.sum(dtype=numpy.float)) - else: - netCounts = 0.0 - else: - rawCounts = 0.0 - netCounts = 0.0 - roiDict[key]['rawcounts'] = rawCounts - roiDict[key]['netcounts'] = netCounts - else: - roiDict[key].pop('rawcounts', None) - roiDict[key].pop('netcounts', None) - - self.roiWidget.fillFromROIDict( - roilist=roiList, - roidict=roiDict, - currentroi=self.currentROI if self.currentROI in roiList else None) - - def _emitCurrentROISignal(self): - ddict = {} - ddict['event'] = "currentROISignal" - _roiList, roiDict = self.roiWidget.getROIListAndDict() - if self.currentROI in roiDict: - ddict['ROI'] = roiDict[self.currentROI] - else: - self.currentROI = None - ddict['current'] = self.currentROI - self.sigROISignal.emit(ddict) - - def _getAllLimits(self): - """Retrieve the limits based on the curves.""" - curves = self.plot.getAllCurves() - if not curves: - return 1.0, 1.0, 100., 100. - - xmin, ymin = None, None - xmax, ymax = None, None - - for curve in curves: - x = curve.getXData(copy=False) - y = curve.getYData(copy=False) - if xmin is None: - xmin = x.min() - else: - xmin = min(xmin, x.min()) - if xmax is None: - xmax = x.max() - else: - xmax = max(xmax, x.max()) - if ymin is None: - ymin = y.min() - else: - ymin = min(ymin, y.min()) - if ymax is None: - ymax = y.max() - else: - ymax = max(ymax, y.max()) - - return xmin, ymin, xmax, ymax - def showEvent(self, event): """Make sure this widget is raised when it is shown (when it is first created as a tab in PlotWindow or when it is shown again after hiding). """ self.raise_() + qt.QDockWidget.showEvent(self, event) diff --git a/silx/gui/plot/Interaction.py b/silx/gui/plot/Interaction.py index f09b9bc..358af74 100644 --- a/silx/gui/plot/Interaction.py +++ b/silx/gui/plot/Interaction.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2016 European Synchrotron Radiation Facility +# Copyright (c) 2014-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal diff --git a/silx/gui/plot/PlotToolButtons.py b/silx/gui/plot/PlotToolButtons.py index 430489d..fc5fcf4 100644 --- a/silx/gui/plot/PlotToolButtons.py +++ b/silx/gui/plot/PlotToolButtons.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# Copyright (c) 2004-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -22,13 +22,14 @@ # THE SOFTWARE. # # ###########################################################################*/ -"""This module provides a set of QToolButton to use with :class:`.PlotWidget`. +"""This module provides a set of QToolButton to use with +:class:`~silx.gui.plot.PlotWidget`. The following QToolButton are available: -- :class:`AspectToolButton` -- :class:`YAxisOriginToolButton` -- :class:`ProfileToolButton` +- :class:`.AspectToolButton` +- :class:`.YAxisOriginToolButton` +- :class:`.ProfileToolButton` """ @@ -46,7 +47,7 @@ _logger = logging.getLogger(__name__) class PlotToolButton(qt.QToolButton): - """A QToolButton connected to a :class:`.PlotWidget`. + """A QToolButton connected to a :class:`~silx.gui.plot.PlotWidget`. """ def __init__(self, parent=None, plot=None): @@ -93,6 +94,7 @@ class PlotToolButton(qt.QToolButton): class AspectToolButton(PlotToolButton): + """Tool button to switch keep aspect ratio of a plot""" STATE = None """Lazy loaded states used to feed AspectToolButton""" @@ -159,6 +161,7 @@ class AspectToolButton(PlotToolButton): class YAxisOriginToolButton(PlotToolButton): + """Tool button to switch the Y axis orientation of a plot.""" STATE = None """Lazy loaded states used to feed YAxisOriginToolButton""" diff --git a/silx/gui/plot/PlotTools.py b/silx/gui/plot/PlotTools.py index ed62d48..7fadfd2 100644 --- a/silx/gui/plot/PlotTools.py +++ b/silx/gui/plot/PlotTools.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# Copyright (c) 2016-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -83,10 +83,10 @@ class PositionInfo(qt.QWidget): >>> plot.show() # To display the PlotWindow with the position widget :param plot: The PlotWidget this widget is displaying data coords from. - :param converters: List of name to display and conversion function from - (x, y) in data coords to displayed value. - If None, the default, it displays X and Y. - :type converters: Iterable of 2-tuple (str, function) + :param converters: + List of 2-tuple: name to display and conversion function from (x, y) + in data coords to displayed value. + If None, the default, it displays X and Y. :param parent: Parent widget """ diff --git a/silx/gui/plot/PlotWidget.py b/silx/gui/plot/PlotWidget.py index 5bf2b59..3641b8c 100644 --- a/silx/gui/plot/PlotWidget.py +++ b/silx/gui/plot/PlotWidget.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# Copyright (c) 2004-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -23,151 +23,7 @@ # ###########################################################################*/ """Qt widget providing plot API for 1D and 2D data. -Widget with plot API for 1D and 2D data. - The :class:`PlotWidget` implements the plot API initially provided in PyMca. - -Plot Events ------------ - -The :class:`PlotWidget` sends some event to the registered callback -(See :meth:`PlotWidget.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:`PlotWidget.setInteractiveMode`). - -- 'event': 'drawingProgress' or 'drawingFinished' -- 'parameters': dict of parameters used by the drawing mode. - It has the following keys: 'shape', 'label', 'color'. - See :meth:`PlotWidget.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) - -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. - -'defaultColormapChanged' event is triggered when the default colormap of -the plot is updated. """ from __future__ import division @@ -264,7 +120,8 @@ class PlotWidget(qt.QMainWindow): """Signal for all events of the plot. The signal information is provided as a dict. - See :class:`PlotWidget` for documentation of the content of the dict. + See the :ref:`plot signal documentation page <plot_signal>` for + information about the content of the dict """ sigSetKeepDataAspectRatio = qt.Signal(bool) @@ -574,7 +431,7 @@ class PlotWidget(qt.QMainWindow): item._setPlot(self) if item.isVisible(): self._itemRequiresUpdate(item) - if isinstance(item, (items.Curve, items.ImageBase)): + if isinstance(item, items.DATA_ITEMS): self._invalidateDataRange() # TODO handle this automatically self._notifyContentChanged(item) @@ -964,7 +821,7 @@ class PlotWidget(qt.QMainWindow): :param colormap: Description of the :class:`.Colormap` to use (or None). This is ignored if data is a RGB(A) image. - :type colormap: Colormap or dict (old API ) + :type colormap: Union[silx.gui.plot.Colormap.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, @@ -1107,8 +964,8 @@ class PlotWidget(qt.QMainWindow): :param numpy.ndarray y: The data corresponding to the y coordinates :param numpy.ndarray value: The data value associated with each point :param str legend: The legend to be associated to the scatter (or None) - :param Colormap colormap: The :class:`.Colormap`. to be used for the - scatter (or None) + :param silx.gui.plot.Colormap.Colormap colormap: + The :class:`.Colormap`. to be used for the scatter (or None) :param info: User-defined information associated to the curve :param str symbol: Symbol to be drawn at each (x, y) position:: @@ -2407,9 +2264,10 @@ class PlotWidget(qt.QMainWindow): It only affects future calls to :meth:`addImage` without the colormap parameter. - :param Colormap colormap: The description of the default colormap, or - None to set the :class:`.Colormap` to a linear - autoscale gray colormap. + :param silx.gui.plot.Colormap.Colormap colormap: + The description of the default colormap, or + None to set the :class:`.Colormap` to a linear + autoscale gray colormap. """ if colormap is None: colormap = Colormap(name='gray', @@ -2533,6 +2391,7 @@ class PlotWidget(qt.QMainWindow): if ddict['event'] in ["legendClicked", "curveClicked"]: if ddict['button'] == "left": self.setActiveCurve(ddict['label']) + qt.QToolTip.showText(self.cursor().pos(), ddict['label']) def saveGraph(self, filename, fileFormat=None, dpi=None, **kw): """Save a snapshot of the plot. @@ -2817,7 +2676,7 @@ class PlotWidget(qt.QMainWindow): def test(mark): return True - markers = self._backend.pickItems(x, y) + markers = self._backend.pickItems(x, y, kinds=('marker',)) legends = [m['legend'] for m in markers if m['kind'] == 'marker'] for legend in reversed(legends): @@ -2852,7 +2711,8 @@ class PlotWidget(qt.QMainWindow): To use for interaction implementation. - :param float x: X position in pixelsparam float y: Y position in pixels + :param float x: X position in pixels + :param 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. """ @@ -2860,7 +2720,7 @@ class PlotWidget(qt.QMainWindow): def test(i): return True - allItems = self._backend.pickItems(x, y) + allItems = self._backend.pickItems(x, y, kinds=('curve', 'image')) allItems = [item for item in allItems if item['kind'] in ['curve', 'image']] diff --git a/silx/gui/plot/PlotWindow.py b/silx/gui/plot/PlotWindow.py index a23db04..5c7e661 100644 --- a/silx/gui/plot/PlotWindow.py +++ b/silx/gui/plot/PlotWindow.py @@ -29,7 +29,7 @@ The :class:`PlotWindow` is a subclass of :class:`.PlotWidget`. __authors__ = ["V.A. Sole", "T. Vincent"] __license__ = "MIT" -__date__ = "17/08/2017" +__date__ = "15/02/2018" import collections import logging @@ -41,6 +41,7 @@ from . import actions from . import items from .actions import medfilt as actions_medfilt from .actions import fit as actions_fit +from .actions import control as actions_control from .actions import histogram as actions_histogram from . import PlotToolButtons from .PlotTools import PositionInfo @@ -112,6 +113,10 @@ class PlotWindow(PlotWidget): self._legendsDockWidget = None self._curvesROIDockWidget = None self._maskToolsDockWidget = None + self._consoleDockWidget = None + + # Create color bar, hidden by default for backward compatibility + self._colorbar = ColorBarWidget(parent=self, plot=self) # Init actions self.group = qt.QActionGroup(self) @@ -168,6 +173,12 @@ class PlotWindow(PlotWidget): self.colormapAction.setVisible(colormap) self.addAction(self.colormapAction) + self.colorbarAction = self.group.addAction( + actions_control.ColorBarAction(self, self)) + self.colorbarAction.setVisible(False) + self.addAction(self.colorbarAction) + self._colorbar.setVisible(False) + self.keepDataAspectRatioButton = PlotToolButtons.AspectToolButton( parent=self, plot=self) self.keepDataAspectRatioButton.setVisible(aspectRatio) @@ -219,10 +230,6 @@ class PlotWindow(PlotWidget): self._panWithArrowKeysAction = None self._crosshairAction = None - # Create color bar, hidden by default for backward compatibility - self._colorbar = ColorBarWidget(parent=self, plot=self) - self._colorbar.setVisible(False) - # Make colorbar background white self._colorbar.setAutoFillBackground(True) palette = self._colorbar.palette() @@ -301,11 +308,11 @@ class PlotWindow(PlotWidget): """ return bool(self.getMaskToolsDockWidget().setSelectionMask(mask)) - def _toggleConsoleVisibility(self, is_checked=False): + def _toggleConsoleVisibility(self, isChecked=False): """Create IPythonDockWidget if needed, show it or hide it.""" # create widget if needed (first call) - if not hasattr(self, '_consoleDockWidget'): + if self._consoleDockWidget is None: available_vars = {"plt": self} banner = "The variable 'plt' is available. Use the 'whos' " banner += "and 'help(plt)' commands for more information.\n\n" @@ -314,10 +321,11 @@ class PlotWindow(PlotWidget): custom_banner=banner, parent=self) self.addTabbedDockWidget(self._consoleDockWidget) - self._consoleDockWidget.visibilityChanged.connect( + # self._consoleDockWidget.setVisible(True) + self._consoleDockWidget.toggleViewAction().toggled.connect( self.getConsoleAction().setChecked) - self._consoleDockWidget.setVisible(is_checked) + self._consoleDockWidget.setVisible(isChecked) def _createToolBar(self, title, parent): """Create a QToolBar from the QAction of the PlotWindow. @@ -427,16 +435,22 @@ class PlotWindow(PlotWidget): return self._legendsDockWidget @property - @deprecated(replacement="getCurvesRoiDockWidget()", since_version="0.4.0") + @deprecated(replacement="getCurvesRoiWidget()", since_version="0.4.0") def curvesROIDockWidget(self): return self.getCurvesRoiDockWidget() def getCurvesRoiDockWidget(self): - """DockWidget with curves' ROI panel (lazy-loaded). + # Undocumented for a "soft deprecation" in version 0.7.0 + # (still used internally for lazy loading) + if self._curvesROIDockWidget is None: + self._curvesROIDockWidget = CurvesROIDockWidget( + plot=self, name='Regions Of Interest') + self._curvesROIDockWidget.hide() + self.addTabbedDockWidget(self._curvesROIDockWidget) + return self._curvesROIDockWidget - The widget returned is a :class:`CurvesROIDockWidget`. - Its central widget is a :class:`CurvesROIWidget` - accessible as :attr:`CurvesROIDockWidget.roiWidget`. + def getCurvesRoiWidget(self): + """Return the :class:`CurvesROIWidget`. :class:`silx.gui.plot.CurvesROIWidget.CurvesROIWidget` offers a getter and a setter for the ROI data: @@ -444,12 +458,7 @@ class PlotWindow(PlotWidget): - :meth:`CurvesROIWidget.getRois` - :meth:`CurvesROIWidget.setRois` """ - if self._curvesROIDockWidget is None: - self._curvesROIDockWidget = CurvesROIDockWidget( - plot=self, name='Regions Of Interest') - self._curvesROIDockWidget.hide() - self.addTabbedDockWidget(self._curvesROIDockWidget) - return self._curvesROIDockWidget + return self.getCurvesRoiDockWidget().roiWidget @property @deprecated(replacement="getMaskToolsDockWidget()", since_version="0.4.0") @@ -695,6 +704,16 @@ class PlotWindow(PlotWidget): """ return self._medianFilter2DAction + def getColorBarAction(self): + """Action toggling the colorbar show/hide action + + .. warning:: to show/hide the plot colorbar call directly the ColorBar + widget using getColorBarWidget() + + :rtype: actions.PlotAction + """ + return self.colorbarAction + class Plot1D(PlotWindow): """PlotWindow with tools specific for curves. @@ -756,6 +775,7 @@ class Plot2D(PlotWindow): self.profile = ProfileToolBar(plot=self) self.addToolBar(self.profile) + self.colorbarAction.setVisible(True) self.getColorBarWidget().setVisible(True) # Put colorbar action after colormap action @@ -763,9 +783,6 @@ class Plot2D(PlotWindow): for index, action in enumerate(actions): if action is self.getColormapAction(): break - self.toolBar().insertAction( - actions[index + 1], - self.getColorBarWidget().getToggleViewAction()) def _getImageValue(self, x, y): """Get status bar value of top most image at position (x, y) diff --git a/silx/gui/plot/Profile.py b/silx/gui/plot/Profile.py index 4a74fa7..f61412d 100644 --- a/silx/gui/plot/Profile.py +++ b/silx/gui/plot/Profile.py @@ -660,23 +660,23 @@ class ProfileToolBar(qt.QToolBar): winGeom = self.window().frameGeometry() qapp = qt.QApplication.instance() screenGeom = qapp.desktop().availableGeometry(self) - spaceOnLeftSide = winGeom.left() spaceOnRightSide = screenGeom.width() - winGeom.right() profileWindowWidth = profileMainWindow.frameGeometry().width() - if (profileWindowWidth < spaceOnRightSide or - spaceOnRightSide > spaceOnLeftSide): + if (profileWindowWidth < spaceOnRightSide): # Place profile on the right profileMainWindow.move(winGeom.right(), winGeom.top()) - else: - # Not enough place on the right, place profile on the left + elif(profileWindowWidth < spaceOnLeftSide): + # Place profile on the left profileMainWindow.move( - max(0, winGeom.left() - profileWindowWidth), winGeom.top()) + max(0, winGeom.left() - profileWindowWidth), winGeom.top()) profileMainWindow.show() + profileMainWindow.raise_() else: self.getProfilePlot().show() + self.getProfilePlot().raise_() def hideProfileWindow(self): """Hide profile window. diff --git a/silx/gui/plot/StackView.py b/silx/gui/plot/StackView.py index 938447b..1fb188c 100644 --- a/silx/gui/plot/StackView.py +++ b/silx/gui/plot/StackView.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# Copyright (c) 2016-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -69,7 +69,7 @@ Example:: __authors__ = ["P. Knobel", "H. Payno"] __license__ = "MIT" -__date__ = "11/09/2017" +__date__ = "15/02/2018" import numpy @@ -82,6 +82,7 @@ from .PlotTools import LimitsToolBar from .Profile import Profile3DToolBar from ..widgets.FrameBrowser import HorizontalSliderWithBrowser +from silx.gui.plot.actions import control as actions_control from silx.utils.array_like import DatasetView, ListOfImages from silx.math import calibration from silx.utils.deprecation import deprecated_warning @@ -245,9 +246,8 @@ class StackView(qt.QMainWindow): for index, action in enumerate(actions): if action is self._plot.getColormapAction(): break - self._plot.toolBar().insertAction( - actions[index + 1], - self._plot.getColorBarWidget().getToggleViewAction()) + self._colorbarAction = actions_control.ColorBarAction(self._plot, self._plot) + self._plot.toolBar().insertAction(actions[index + 1], self._colorbarAction) def _plotCallback(self, eventDict): """Callback for plot events. @@ -652,7 +652,7 @@ class StackView(qt.QMainWindow): when the volume is rotated (when different axes are selected as the X and Y axes). - :param list(str) labels: 3 labels corresponding to the 3 dimensions + :param List[str] labels: 3 labels corresponding to the 3 dimensions of the data volumes. """ @@ -972,6 +972,16 @@ class StackView(qt.QMainWindow): """ return self._plot.getActiveImage(just_legend=just_legend) + def getColorBarAction(self): + """Returns the action managing the visibility of the colorbar. + + .. warning:: to show/hide the plot colorbar call directly the ColorBar + widget using getColorBarWidget() + + :rtype: QAction + """ + return self._colorbarAction + def remove(self, legend=None, kind=('curve', 'image', 'item', 'marker')): """See :meth:`Plot.Plot.remove`""" @@ -1102,7 +1112,7 @@ class StackViewMainWindow(StackView): menu.addSeparator() menu.addAction(self._plot.resetZoomAction) menu.addAction(self._plot.colormapAction) - menu.addAction(self._plot.getColorBarWidget().getToggleViewAction()) + menu.addAction(self.getColorBarAction()) menu.addAction(actions.control.KeepAspectRatioAction(self._plot, self)) menu.addAction(actions.control.YAxisInvertedAction(self._plot, self)) diff --git a/silx/gui/plot/_utils/test/test_ticklayout.py b/silx/gui/plot/_utils/test/test_ticklayout.py index 8c67620..927ffb6 100644 --- a/silx/gui/plot/_utils/test/test_ticklayout.py +++ b/silx/gui/plot/_utils/test/test_ticklayout.py @@ -27,12 +27,13 @@ from __future__ import absolute_import, division, unicode_literals __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "18/10/2016" +__date__ = "17/01/2018" import unittest +import numpy -from silx.test.utils import ParametricTestCase +from silx.utils.testutils import ParametricTestCase from silx.gui.plot._utils import ticklayout @@ -40,6 +41,19 @@ from silx.gui.plot._utils import ticklayout class TestTickLayout(ParametricTestCase): """Test ticks layout algorithms""" + def testTicks(self): + """Test of :func:`ticks`""" + tests = { # (vmin, vmax): ref_ticks + (1., 1.): (1.,), + (0.5, 10.5): (2.0, 4.0, 6.0, 8.0, 10.0), + (0.001, 0.005): (0.001, 0.002, 0.003, 0.004, 0.005) + } + + for (vmin, vmax), ref_ticks in tests.items(): + with self.subTest(vmin=vmin, vmax=vmax): + ticks, labels = ticklayout.ticks(vmin, vmax) + self.assertTrue(numpy.allclose(ticks, ref_ticks)) + def testNiceNumbers(self): """Minimalistic tests of :func:`niceNumbers`""" tests = { # (vmin, vmax): ref_ticks diff --git a/silx/gui/plot/_utils/ticklayout.py b/silx/gui/plot/_utils/ticklayout.py index 5f4b636..6e9f654 100644 --- a/silx/gui/plot/_utils/ticklayout.py +++ b/silx/gui/plot/_utils/ticklayout.py @@ -109,7 +109,7 @@ def ticks(vMin, vMax, nbTicks=5): """Returns tick positions and labels using nice numbers algorithm. This enforces ticks to be within [vMin, vMax] range. - It returns at least 2 ticks. + It returns at least 1 tick (when vMin == vMax). :param float vMin: The min value on the axis :param float vMax: The max value on the axis @@ -117,13 +117,19 @@ def ticks(vMin, vMax, nbTicks=5): :returns: tick positions and corresponding text labels :rtype: 2-tuple: list of float, list of string """ - start, end, step, nfrac = niceNumbers(vMin, vMax, nbTicks) - positions = [t for t in _frange(start, end, step) if vMin <= t <= vMax] + assert vMin <= vMax + if vMin == vMax: + positions = [vMin] + nfrac = 0 + + else: + start, end, step, nfrac = niceNumbers(vMin, vMax, nbTicks) + positions = [t for t in _frange(start, end, step) if vMin <= t <= vMax] - # Makes sure there is at least 2 ticks - if len(positions) < 2: - positions = [vMin, vMax] - nfrac = numberOfDigits(vMax - vMin) + # Makes sure there is at least 2 ticks + if len(positions) < 2: + positions = [vMin, vMax] + nfrac = numberOfDigits(vMax - vMin) # Generate labels format_ = '%g' if nfrac == 0 else '%.{}f'.format(nfrac) diff --git a/silx/gui/plot/actions/PlotAction.py b/silx/gui/plot/actions/PlotAction.py index 6eb9ba3..2983775 100644 --- a/silx/gui/plot/actions/PlotAction.py +++ b/silx/gui/plot/actions/PlotAction.py @@ -32,10 +32,9 @@ from __future__ import division __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] __license__ = "MIT" -__date__ = "20/04/2017" +__date__ = "03/01/2018" -from collections import OrderedDict import weakref from silx.gui import icons from silx.gui import qt diff --git a/silx/gui/plot/actions/__init__.py b/silx/gui/plot/actions/__init__.py index 73829cd..930c728 100644 --- a/silx/gui/plot/actions/__init__.py +++ b/silx/gui/plot/actions/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -22,10 +22,14 @@ # THE SOFTWARE. # # ###########################################################################*/ -"""This package provides a set of QActions to use with :class:`PlotWidget` +"""This package provides a set of QAction to use with +:class:`~silx.gui.plot.PlotWidget` -It also contains the :class:'.PlotAction' (Base class for QAction that operates -on a PlotWidget) +Those actions are useful to add menu items or toolbar items +that interact with a :class:`~silx.gui.plot.PlotWidget`. + +It provides a base class used to define new plot actions: +:class:`~silx.gui.plot.actions.PlotAction`. """ __authors__ = ["H. Payno"] diff --git a/silx/gui/plot/actions/control.py b/silx/gui/plot/actions/control.py index 23e710e..ac6dc2f 100644 --- a/silx/gui/plot/actions/control.py +++ b/silx/gui/plot/actions/control.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# Copyright (c) 2004-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -50,11 +50,10 @@ from __future__ import division __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] __license__ = "MIT" -__date__ = "27/06/2017" +__date__ = "15/02/2018" from . import PlotAction import logging -import numpy from silx.gui.plot import items from silx.gui.plot.ColormapDialog import ColormapDialog from silx.gui.plot._utils import applyZoomToPlot as _applyZoomToPlot @@ -327,67 +326,112 @@ class ColormapAction(PlotAction): plot, icon='colormap', text='Colormap', tooltip="Change colormap", triggered=self._actionTriggered, - checkable=False, parent=parent) + checkable=True, parent=parent) + self.plot.sigActiveImageChanged.connect(self._updateColormap) + + def setColorDialog(self, colorDialog): + """Set a specific color dialog instead of using the default dialog.""" + assert(colorDialog is not None) + assert(self._dialog is None) + self._dialog = colorDialog + self._dialog.visibleChanged.connect(self._dialogVisibleChanged) + self.setChecked(self._dialog.isVisible()) + + @staticmethod + def _createDialog(parent): + """Create the dialog if not already existing + + :parent QWidget parent: Parent of the new colormap + :rtype: ColormapDialog + """ + dialog = ColormapDialog(parent=parent) + dialog.setModal(False) + return dialog 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() + self._dialog = self._createDialog(self.plot) + self._dialog.visibleChanged.connect(self._dialogVisibleChanged) + + # Run the dialog listening to colormap change + if checked is True: + self._dialog.show() + self._updateColormap() + else: + self._dialog.hide() + + def _dialogVisibleChanged(self, isVisible): + self.setChecked(isVisible) + def _updateColormap(self): + if self._dialog is None: + return 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 + if isinstance(image, items.ImageComplexData): + # Specific init for complex images + colormap = image.getColormap() - else: + mode = image.getVisualizationMode() + if mode in (items.ImageComplexData.Mode.AMPLITUDE_PHASE, + items.ImageComplexData.Mode.LOG10_AMPLITUDE_PHASE): + data = image.getData( + copy=False, mode=items.ImageComplexData.Mode.PHASE) + else: + data = image.getData(copy=False) + + # Set histogram and range if any + self._dialog.setData(data) + + elif isinstance(image, items.ColormapMixIn): # Set dialog from active image colormap = image.getColormap() - data = image.getData(copy=False) + # Set histogram and range if any + self._dialog.setData(data) - goodData = data[numpy.isfinite(data)] - if goodData.size > 0: - dataMin = goodData.min() - dataMax = goodData.max() - else: - qt.QMessageBox.warning( - None, "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(name=colormap.getName(), - normalization=colormap.getNormalization(), - autoscale=colormap.isAutoscale(), - vmin=colormap.getVMin(), - vmax=colormap.getVMax(), - colors=colormap.getColormapLUT()) + else: + # No active image or active image is RGBA, + # set dialog from default info + colormap = self.plot.getDefaultColormap() + # Reset histogram and range if any + self._dialog.setData(None) - # Run the dialog listening to colormap change - self._dialog.sigColormapChanged.connect(self._colormapChanged) - result = self._dialog.exec_() - self._dialog.sigColormapChanged.disconnect(self._colormapChanged) + self._dialog.setColormap(colormap) - if not result: # Restore the previous colormap - self._colormapChanged(colormap) - def _colormapChanged(self, colormap): - # Update default colormap - self.plot.setDefaultColormap(colormap) +class ColorBarAction(PlotAction): + """QAction opening the ColorBarWidget of the specified plot. + + :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(ColorBarAction, self).__init__( + plot, icon='colorbar', text='Colorbar', + tooltip="Show/Hide the colorbar", + triggered=self._actionTriggered, + checkable=True, parent=parent) + colorBarWidget = self.plot.getColorBarWidget() + old = self.blockSignals(True) + self.setChecked(colorBarWidget.isVisibleTo(self.plot)) + self.blockSignals(old) + colorBarWidget.sigVisibleChanged.connect(self._widgetVisibleChanged) + + def _widgetVisibleChanged(self, isVisible): + """Callback when the colorbar `visible` property change.""" + if self.isChecked() == isVisible: + return + self.setChecked(isVisible) - # Update active image colormap - activeImage = self.plot.getActiveImage() - if isinstance(activeImage, items.ColormapMixIn): - activeImage.setColormap(colormap) + def _actionTriggered(self, checked=False): + """Create a cmap dialog and update active image and default cmap.""" + colorBarWidget = self.plot.getColorBarWidget() + if not colorBarWidget.isHidden() == checked: + return + self.plot.getColorBarWidget().setVisible(checked) class KeepAspectRatioAction(PlotAction): diff --git a/silx/gui/plot/actions/fit.py b/silx/gui/plot/actions/fit.py index d7256ab..5ca649c 100644 --- a/silx/gui/plot/actions/fit.py +++ b/silx/gui/plot/actions/fit.py @@ -36,7 +36,7 @@ from __future__ import division __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] __license__ = "MIT" -__date__ = "28/06/2017" +__date__ = "03/01/2018" from . import PlotAction import logging @@ -111,7 +111,7 @@ class FitAction(PlotAction): if histo is None and curve is None: # ambiguous case, we need to ask which plot item to fit - isd = ItemsSelectionDialog(plot=self.plot) + isd = ItemsSelectionDialog(parent=self.plot, plot=self.plot) isd.setWindowTitle("Select item to be fitted") isd.setItemsSelectionMode(qt.QTableWidget.SingleSelection) isd.setAvailableKinds(["curve", "histogram"]) diff --git a/silx/gui/plot/actions/histogram.py b/silx/gui/plot/actions/histogram.py index a4a91e9..40ef873 100644 --- a/silx/gui/plot/actions/histogram.py +++ b/silx/gui/plot/actions/histogram.py @@ -39,6 +39,7 @@ __license__ = "MIT" from . import PlotAction from silx.math.histogram import Histogramnd +from silx.math.combo import min_max import numpy import logging from silx.gui import qt @@ -107,8 +108,7 @@ class PixelIntensitiesHistoAction(PlotAction): image[:, :, 1] * 0.587 + image[:, :, 2] * 0.114) - xmin = numpy.nanmin(image) - xmax = numpy.nanmax(image) + xmin, xmax = min_max(image, min_positive=False, finite=True) nbins = min(1024, int(numpy.sqrt(image.size))) data_range = xmin, xmax diff --git a/silx/gui/plot/actions/io.py b/silx/gui/plot/actions/io.py index 50410e3..d6d5909 100644 --- a/silx/gui/plot/actions/io.py +++ b/silx/gui/plot/actions/io.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# Copyright (c) 2004-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -37,10 +37,11 @@ from __future__ import division __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] __license__ = "MIT" -__date__ = "27/06/2017" +__date__ = "02/02/2018" from . import PlotAction from silx.io.utils import save1D, savespec +from silx.io.nxdata import save_NXdata import logging import sys from collections import OrderedDict @@ -59,6 +60,10 @@ else: _logger = logging.getLogger(__name__) +_NEXUS_HDF5_EXT = [".nx5", ".nxs", ".hdf", ".hdf5", ".cxi", ".h5"] +_NEXUS_HDF5_EXT_STR = ' '.join(['*' + ext for ext in _NEXUS_HDF5_EXT]) + + class SaveAction(PlotAction): """QAction for saving Plot content. @@ -89,12 +94,15 @@ class SaveAction(PlotAction): ('Curve as OMNIC CSV (*.csv)', {'fmt': '%.7E', 'delimiter': ',', 'header': False}), ('Curve as SpecFile (*.dat)', - {'fmt': '%.7g', 'delimiter': '', 'header': False}) + {'fmt': '%.10g', 'delimiter': '', 'header': False}) )) CURVE_FILTER_NPY = 'Curve as NumPy binary file (*.npy)' - CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [CURVE_FILTER_NPY] + CURVE_FILTER_NXDATA = 'Curve as NXdata (%s)' % _NEXUS_HDF5_EXT_STR + + CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [CURVE_FILTER_NPY, + CURVE_FILTER_NXDATA] ALL_CURVES_FILTERS = ("All curves as SpecFile (*.dat)", ) @@ -107,6 +115,7 @@ class SaveAction(PlotAction): IMAGE_FILTER_CSV_TAB = 'Image data as tab-separated CSV (*.csv)' IMAGE_FILTER_RGB_PNG = 'Image as PNG (*.png)' IMAGE_FILTER_RGB_TIFF = 'Image as TIFF (*.tif)' + IMAGE_FILTER_NXDATA = 'Image as NXdata (%s)' % _NEXUS_HDF5_EXT_STR IMAGE_FILTERS = (IMAGE_FILTER_EDF, IMAGE_FILTER_TIFF, IMAGE_FILTER_NUMPY, @@ -115,7 +124,11 @@ class SaveAction(PlotAction): IMAGE_FILTER_CSV_SEMICOLON, IMAGE_FILTER_CSV_TAB, IMAGE_FILTER_RGB_PNG, - IMAGE_FILTER_RGB_TIFF) + IMAGE_FILTER_RGB_TIFF, + IMAGE_FILTER_NXDATA) + + SCATTER_FILTER_NXDATA = 'Scatter as NXdata (%s)' % _NEXUS_HDF5_EXT_STR + SCATTER_FILTERS = (SCATTER_FILTER_NXDATA, ) def __init__(self, plot, parent=None): super(SaveAction, self).__init__( @@ -183,7 +196,7 @@ class SaveAction(PlotAction): csvdelim = filter_['delimiter'] autoheader = filter_['header'] else: - # .npy + # .npy or nxdata fmt, csvdelim, autoheader = ("", "", False) # If curve has no associated label, get the default from the plot @@ -194,6 +207,19 @@ class SaveAction(PlotAction): if ylabel is None: ylabel = self.plot.getYAxis().getLabel() + if nameFilter == self.CURVE_FILTER_NXDATA: + return save_NXdata( + filename, + signal=curve.getYData(copy=False), + axes=[curve.getXData(copy=False)], + signal_name="y", + axes_names=["x"], + signal_long_name=ylabel, + axes_long_names=[xlabel], + signal_errors=curve.getYErrorData(copy=False), + axes_errors=[curve.getXErrorData(copy=True)], + title=self.plot.getGraphTitle()) + try: save1D(filename, curve.getXData(copy=False), @@ -226,11 +252,13 @@ class SaveAction(PlotAction): curve = curves[0] scanno = 1 try: + xlabel = curve.getXLabel() or self.plot.getGraphXLabel() + ylabel = curve.getYLabel() or self.plot.getGraphYLabel(curve.getYAxis()) specfile = savespec(filename, curve.getXData(copy=False), curve.getYData(copy=False), - curve.getXLabel(), - curve.getYLabel(), + xlabel, + ylabel, fmt="%.7g", scan_number=1, mode="w", write_file_header=True, close_file=False) @@ -241,12 +269,14 @@ class SaveAction(PlotAction): for curve in curves[1:]: try: scanno += 1 + xlabel = curve.getXLabel() or self.plot.getGraphXLabel() + ylabel = curve.getYLabel() or self.plot.getGraphYLabel(curve.getYAxis()) specfile = savespec(specfile, curve.getXData(copy=False), curve.getYData(copy=False), - curve.getXLabel(), - curve.getYLabel(), - fmt="%.7g", scan_number=scanno, mode="w", + xlabel, + ylabel, + fmt="%.7g", scan_number=scanno, write_file_header=False, close_file=False) except IOError: @@ -294,6 +324,24 @@ class SaveAction(PlotAction): return False return True + elif nameFilter == self.IMAGE_FILTER_NXDATA: + xorigin, yorigin = image.getOrigin() + xscale, yscale = image.getScale() + xaxis = xorigin + xscale * numpy.arange(data.shape[1]) + yaxis = yorigin + yscale * numpy.arange(data.shape[0]) + xlabel = image.getXLabel() or self.plot.getGraphXLabel() + ylabel = image.getYLabel() or self.plot.getGraphYLabel() + interpretation = "image" if len(data.shape) == 2 else "rgba-image" + + return save_NXdata(filename, + signal=data, + axes=[yaxis, xaxis], + signal_name="image", + axes_names=["y", "x"], + axes_long_names=[ylabel, xlabel], + title=self.plot.getGraphTitle(), + interpretation=interpretation) + elif nameFilter in (self.IMAGE_FILTER_ASCII, self.IMAGE_FILTER_CSV_COMMA, self.IMAGE_FILTER_CSV_SEMICOLON, @@ -343,6 +391,45 @@ class SaveAction(PlotAction): return False + def _saveScatter(self, filename, nameFilter): + """Save an image from the plot. + + :param str filename: The name of the file to write + :param str nameFilter: The selected name filter + :return: False if format is not supported or save failed, + True otherwise. + """ + if nameFilter not in self.SCATTER_FILTERS: + return False + + if nameFilter == self.SCATTER_FILTER_NXDATA: + scatter = self.plot.getScatter() + # TODO: we could get all scatters on this plot and concatenate their (x, y, values) + x = scatter.getXData(copy=False) + y = scatter.getYData(copy=False) + z = scatter.getValueData(copy=False) + + xerror = scatter.getXErrorData(copy=False) + if isinstance(xerror, float): + xerror = xerror * numpy.ones(x.shape, dtype=numpy.float32) + + yerror = scatter.getYErrorData(copy=False) + if isinstance(yerror, float): + yerror = yerror * numpy.ones(x.shape, dtype=numpy.float32) + + xlabel = self.plot.getGraphXLabel() + ylabel = self.plot.getGraphYLabel() + + return save_NXdata( + filename, + signal=z, + axes=[x, y], + signal_name="values", + axes_names=["x", "y"], + axes_long_names=[xlabel, ylabel], + axes_errors=[xerror, yerror], + title=self.plot.getGraphTitle()) + def _actionTriggered(self, checked=False): """Handle save action.""" # Set-up filters @@ -359,6 +446,11 @@ class SaveAction(PlotAction): if len(self.plot.getAllCurves()) > 1: filters.extend(self.ALL_CURVES_FILTERS) + # Add scatter filters if there is a scatter + # todo: CSV + if self.plot.getScatter() is not None: + filters.extend(self.SCATTER_FILTERS) + filters.extend(self.SNAPSHOT_FILTERS) # Create and run File dialog @@ -378,10 +470,19 @@ class SaveAction(PlotAction): dialog.close() # Forces the filename extension to match the chosen filter - extension = nameFilter.split()[-1][2:-1] - if (len(filename) <= len(extension) or - filename[-len(extension):].lower() != extension.lower()): - filename += extension + if "NXdata" in nameFilter: + has_allowed_ext = False + for ext in _NEXUS_HDF5_EXT: + if (len(filename) > len(ext) and + filename[-len(ext):].lower() == ext.lower()): + has_allowed_ext = True + if not has_allowed_ext: + filename += ".h5" + else: + default_extension = nameFilter.split()[-1][2:-1] + if (len(filename) <= len(default_extension) or + filename[-len(default_extension):].lower() != default_extension.lower()): + filename += default_extension # Handle save if nameFilter in self.SNAPSHOT_FILTERS: @@ -392,6 +493,8 @@ class SaveAction(PlotAction): return self._saveCurves(filename, nameFilter) elif nameFilter in self.IMAGE_FILTERS: return self._saveImage(filename, nameFilter) + elif nameFilter in self.SCATTER_FILTERS: + return self._saveScatter(filename, nameFilter) else: _logger.warning('Unsupported file filter: %s', nameFilter) return False diff --git a/silx/gui/plot/actions/medfilt.py b/silx/gui/plot/actions/medfilt.py index 3305d1b..4284a8b 100644 --- a/silx/gui/plot/actions/medfilt.py +++ b/silx/gui/plot/actions/medfilt.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# Copyright (c) 2004-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -39,7 +39,7 @@ from __future__ import division __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"] __license__ = "MIT" -__date__ = "24/05/2017" +__date__ = "03/01/2018" from . import PlotAction from silx.gui.widgets.MedianFilterDialog import MedianFilterDialog @@ -67,7 +67,7 @@ class MedianFilterAction(PlotAction): self._originalImage = None self._legend = None self._filteredImage = None - self._popup = MedianFilterDialog(parent=None) + self._popup = MedianFilterDialog(parent=plot) self._popup.sigFilterOptChanged.connect(self._updateFilter) self.plot.sigActiveImageChanged.connect(self._updateActiveImage) self._updateActiveImage() @@ -101,7 +101,7 @@ class MedianFilterAction(PlotAction): self.plot.sigActiveImageChanged.connect(self._updateActiveImage) def _computeFilteredImage(self, kernelWidth, conditional): - raise NotImplemented('MedianFilterAction is a an abstract class') + raise NotImplementedError('MedianFilterAction is a an abstract class') def getFilteredImage(self): """ diff --git a/silx/gui/plot/backends/BackendBase.py b/silx/gui/plot/backends/BackendBase.py index 12561b2..45bf785 100644 --- a/silx/gui/plot/backends/BackendBase.py +++ b/silx/gui/plot/backends/BackendBase.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# Copyright (c) 2004-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -189,7 +189,7 @@ class BackendBase(object): def addMarker(self, x, y, legend, text, color, selectable, draggable, - symbol, constraint, overlay): + symbol, constraint): """Add a point, vertical line or horizontal line marker to the plot. :param float x: Horizontal position of the marker in graph coordinates. @@ -221,9 +221,6 @@ class BackendBase(object): :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. - :param bool overlay: True if marker is an overlay (Default: False). - This allows for rendering optimization if this - marker is changed often. :return: Handle used by the backend to univocally access the marker """ return legend @@ -270,11 +267,13 @@ class BackendBase(object): """ pass - def pickItems(self, x, y): + def pickItems(self, x, y, kinds): """Get a list of items at a pixel position. :param float x: The x pixel coord where to pick. :param float y: The y pixel coord where to pick. + :param List[str] kind: List of item kinds to pick. + Supported kinds: 'marker', 'curve', 'image'. :return: All picked items from back to front. One dict per item, with 'kind' key in 'curve', 'marker', 'image'; diff --git a/silx/gui/plot/backends/BackendMatplotlib.py b/silx/gui/plot/backends/BackendMatplotlib.py index b41f20e..f9a1fe5 100644 --- a/silx/gui/plot/backends/BackendMatplotlib.py +++ b/silx/gui/plot/backends/BackendMatplotlib.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# Copyright (c) 2004-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -58,6 +58,59 @@ from . import BackendBase from .._utils import FLOAT32_MINPOS +class _MarkerContainer(Container): + """Marker artists container supporting draw/remove and text position update + + :param artists: + Iterable with either one Line2D or a Line2D and a Text. + The use of an iterable if enforced by Container being + a subclass of tuple that defines a specific __new__. + :param x: X coordinate of the marker (None for horizontal lines) + :param y: Y coordinate of the marker (None for vertical lines) + """ + + def __init__(self, artists, x, y): + self.line = artists[0] + self.text = artists[1] if len(artists) > 1 else None + self.x = x + self.y = y + + Container.__init__(self, artists) + + def draw(self, *args, **kwargs): + """artist-like draw to broadcast draw to line and text""" + self.line.draw(*args, **kwargs) + if self.text is not None: + self.text.draw(*args, **kwargs) + + def updateMarkerText(self, xmin, xmax, ymin, ymax): + """Update marker text position and visibility according to plot limits + + :param xmin: X axis lower limit + :param xmax: X axis upper limit + :param ymin: Y axis lower limit + :param ymax: Y axis upprt limit + """ + if self.text is not None: + visible = ((self.x is None or xmin <= self.x <= xmax) and + (self.y is None or ymin <= self.y <= ymax)) + self.text.set_visible(visible) + + if self.x is not None and self.y is None: # vertical line + delta = abs(ymax - ymin) + if ymin > ymax: + ymax = ymin + ymax -= 0.005 * delta + self.text.set_y(ymax) + + if self.x is None and self.y is not None: # Horizontal line + delta = abs(xmax - xmin) + if xmin > xmax: + xmax = xmin + xmax -= 0.005 * delta + self.text.set_x(xmax) + + class BackendMatplotlib(BackendBase.BackendBase): """Base class for Matplotlib backend without a FigureCanvas. @@ -356,10 +409,13 @@ class BackendMatplotlib(BackendBase.BackendBase): self.ax.add_patch(item) elif shape in ('polygon', 'polylines'): - xView = xView.reshape(1, -1) - yView = yView.reshape(1, -1) - item = Polygon(numpy.vstack((xView, yView)).T, - closed=(shape == 'polygon'), + points = numpy.array((xView, yView)).T + if shape == 'polygon': + closed = True + else: # shape == 'polylines' + closed = numpy.all(numpy.equal(points[0], points[-1])) + item = Polygon(points, + closed=closed, fill=False, label=legend, color=color) @@ -381,9 +437,14 @@ class BackendMatplotlib(BackendBase.BackendBase): def addMarker(self, x, y, legend, text, color, selectable, draggable, - symbol, constraint, overlay): + symbol, constraint): legend = "__MARKER__" + legend + textArtist = None + + xmin, xmax = self.getGraphXLimits() + ymin, ymax = self.getGraphYLimits(axis='left') + if x is not None and y is not None: line = self.ax.plot(x, y, label=legend, linestyle=" ", @@ -392,49 +453,35 @@ class BackendMatplotlib(BackendBase.BackendBase): markersize=10.)[-1] if text is not None: - xtmp, ytmp = self.ax.transData.transform_point((x, y)) - inv = self.ax.transData.inverted() - xtmp, ytmp = inv.transform_point((xtmp, ytmp)) - if symbol is None: valign = 'baseline' else: valign = 'top' text = " " + text - line._infoText = self.ax.text(x, ytmp, text, - color=color, - horizontalalignment='left', - verticalalignment=valign) + textArtist = self.ax.text(x, y, text, + color=color, + horizontalalignment='left', + verticalalignment=valign) elif x is not None: line = self.ax.axvline(x, label=legend, color=color) if text is not None: - text = " " + text - ymin, ymax = self.getGraphYLimits(axis='left') - delta = abs(ymax - ymin) - if ymin > ymax: - ymax = ymin - ymax -= 0.005 * delta - line._infoText = self.ax.text(x, ymax, text, - color=color, - horizontalalignment='left', - verticalalignment='top') + # Y position will be updated in updateMarkerText call + textArtist = self.ax.text(x, 1., " " + text, + color=color, + horizontalalignment='left', + verticalalignment='top') elif y is not None: line = self.ax.axhline(y, label=legend, color=color) if text is not None: - text = " " + text - xmin, xmax = self.getGraphXLimits() - delta = abs(xmax - xmin) - if xmin > xmax: - xmax = xmin - xmax -= 0.005 * delta - line._infoText = self.ax.text(xmax, y, text, - color=color, - horizontalalignment='right', - verticalalignment='top') + # X position will be updated in updateMarkerText call + textArtist = self.ax.text(1., y, " " + text, + color=color, + horizontalalignment='right', + verticalalignment='top') else: raise RuntimeError('A marker must at least have one coordinate') @@ -442,19 +489,29 @@ class BackendMatplotlib(BackendBase.BackendBase): if selectable or draggable: line.set_picker(5) - if overlay: - line.set_animated(True) - self._overlays.add(line) + # All markers are overlays + line.set_animated(True) + if textArtist is not None: + textArtist.set_animated(True) + + artists = [line] if textArtist is None else [line, textArtist] + container = _MarkerContainer(artists, x, y) + container.updateMarkerText(xmin, xmax, ymin, ymax) + self._overlays.add(container) - return line + return container + + def _updateMarkers(self): + xmin, xmax = self.ax.get_xbound() + ymin, ymax = self.ax.get_ybound() + for item in self._overlays: + if isinstance(item, _MarkerContainer): + item.updateMarkerText(xmin, xmax, ymin, ymax) # Remove methods def remove(self, item): # Warning: It also needs to remove extra stuff if added as for markers - if hasattr(item, "_infoText"): # For markers text - item._infoText.remove() - item._infoText = None self._overlays.discard(item) try: item.remove() @@ -562,6 +619,8 @@ class BackendMatplotlib(BackendBase.BackendBase): else: self.ax.set_ylim(max(ymin, ymax), min(ymin, ymax)) + self._updateMarkers() + def getGraphXLimits(self): if self._dirtyLimits and self.isKeepDataAspectRatio(): self.replot() # makes sure we get the right limits @@ -570,6 +629,7 @@ class BackendMatplotlib(BackendBase.BackendBase): def setGraphXLimits(self, xmin, xmax): self._dirtyLimits = True self.ax.set_xlim(min(xmin, xmax), max(xmin, xmax)) + self._updateMarkers() def getGraphYLimits(self, axis): assert axis in ('left', 'right') @@ -607,6 +667,8 @@ class BackendMatplotlib(BackendBase.BackendBase): else: ax.set_ylim(ymax, ymin) + self._updateMarkers() + # Graph axes def setXAxisLogarithmic(self, flag): @@ -814,7 +876,7 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): self._picked.append({'kind': 'curve', 'legend': label, 'indices': event.ind}) - def pickItems(self, x, y): + def pickItems(self, x, y, kinds): self._picked = [] # Weird way to do an explicit picking: Simulate a button press event @@ -822,7 +884,8 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): cid = self.mpl_connect('pick_event', self._onPick) self.fig.pick(mouseEvent) self.mpl_disconnect(cid) - picked = self._picked + + picked = [p for p in self._picked if p['kind'] in kinds] self._picked = None return picked @@ -882,6 +945,10 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): xLimits, yLimits, yRightLimits = self._limitsBeforeResize self._limitsBeforeResize = None + if (xLimits != self.ax.get_xbound() or + yLimits != self.ax.get_ybound()): + self._updateMarkers() + if xLimits != self.ax.get_xbound(): self._plot.getXAxis()._emitLimitsChanged() if yLimits != self.ax.get_ybound(): @@ -889,6 +956,7 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib): if yRightLimits != self.ax2.get_ybound(): self._plot.getYAxis(axis='right')._emitLimitsChanged() + self._drawOverlays() def replot(self): diff --git a/silx/gui/plot/backends/BackendOpenGL.py b/silx/gui/plot/backends/BackendOpenGL.py index c70b03a..3c18f4f 100644 --- a/silx/gui/plot/backends/BackendOpenGL.py +++ b/silx/gui/plot/backends/BackendOpenGL.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2014-2017 European Synchrotron Radiation Facility +# Copyright (c) 2014-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -892,11 +892,13 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): for item in self._items.values(): shape2D = item.get('_shape2D') if shape2D is None: + closed = item['shape'] != 'polylines' shape2D = Shape2D(tuple(zip(item['x'], item['y'])), fill=item['fill'], fillColor=item['color'], stroke=True, - strokeColor=item['color']) + strokeColor=item['color'], + strokeClosed=closed) item['_shape2D'] = shape2D if ((isXLog and shape2D.xMin < FLOAT32_MINPOS) or @@ -1032,17 +1034,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): data = numpy.array(data, dtype=numpy.float32, order='C') colormapIsLog = colormap.getNormalization() == 'log' - cmapRange = colormap.getColormapRange(data=data) - - # Retrieve colormap LUT from name and color array - colormapDisp = Colormap(name=colormap.getName(), - normalization=Colormap.LINEAR, - vmin=0, - vmax=255, - colors=colormap.getColormapLUT()) - colormapLut = colormapDisp.applyToData( - numpy.arange(256, dtype=numpy.uint8)) + colormapLut = colormap.getNColors(nbColors=256) image = GLPlotColormap(data, origin, @@ -1087,7 +1080,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): def addItem(self, x, y, legend, shape, color, fill, overlay, z): # TODO handle overlay - if shape not in ('polygon', 'rectangle', 'line', 'vline', 'hline'): + if shape not in ('polygon', 'rectangle', 'line', + 'vline', 'hline', 'polylines'): raise NotImplementedError("Unsupported shape {0}".format(shape)) x = numpy.array(x, copy=False) @@ -1107,6 +1101,9 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): raise RuntimeError( 'Cannot add item with Y <= 0 with Y axis log scale') + # Ignore fill for polylines to mimic matplotlib + fill = fill if shape != 'polylines' else False + self._items[legend] = { 'shape': shape, 'color': Colors.rgba(color), @@ -1119,8 +1116,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): def addMarker(self, x, y, legend, text, color, selectable, draggable, - symbol, constraint, overlay): - # TODO handle overlay + symbol, constraint): if symbol is None: symbol = '+' @@ -1227,90 +1223,93 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget): self._plotFrame.size[1] - self._plotFrame.margins.bottom - 1) return xPlot, yPlot - def pickItems(self, x, y): + def pickItems(self, x, y, kinds): picked = [] dataPos = self.pixelToData(x, y, axis='left', check=True) if dataPos is not None: # Pick markers - for marker in reversed(list(self._markers.values())): - pixelPos = self.dataToPixel( - marker['x'], marker['y'], axis='left', check=False) - if pixelPos is None: # negative coord on a log axis - continue - - if marker['x'] is None: # Horizontal line - pt1 = self.pixelToData( - x, y - self._PICK_OFFSET, axis='left', check=False) - pt2 = self.pixelToData( - x, y + self._PICK_OFFSET, axis='left', check=False) - isPicked = (min(pt1[1], pt2[1]) <= marker['y'] <= - max(pt1[1], pt2[1])) - - elif marker['y'] is None: # Vertical line - pt1 = self.pixelToData( - x - self._PICK_OFFSET, y, axis='left', check=False) - pt2 = self.pixelToData( - x + self._PICK_OFFSET, y, axis='left', check=False) - isPicked = (min(pt1[0], pt2[0]) <= marker['x'] <= - max(pt1[0], pt2[0])) - - else: - isPicked = ( - numpy.fabs(x - pixelPos[0]) <= self._PICK_OFFSET and - numpy.fabs(y - pixelPos[1]) <= self._PICK_OFFSET) - - if isPicked: - picked.append(dict(kind='marker', - legend=marker['legend'])) - - # Pick image and curves - for item in self._plotContent.zOrderedPrimitives(reverse=True): - if isinstance(item, (GLPlotColormap, GLPlotRGBAImage)): - pickedPos = item.pick(*dataPos) - if pickedPos is not None: - picked.append(dict(kind='image', - legend=item.info['legend'])) - - elif isinstance(item, GLPlotCurve2D): - offset = self._PICK_OFFSET - if item.marker is not None: - offset = max(item.markerSize / 2., offset) - if item.lineStyle is not None: - offset = max(item.lineWidth / 2., offset) - - yAxis = item.info['yAxis'] - - inAreaPos = self._mouseInPlotArea(x - offset, y - offset) - dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1], - axis=yAxis, check=True) - if dataPos is None: + if 'marker' in kinds: + for marker in reversed(list(self._markers.values())): + pixelPos = self.dataToPixel( + marker['x'], marker['y'], axis='left', check=False) + if pixelPos is None: # negative coord on a log axis continue - xPick0, yPick0 = dataPos - inAreaPos = self._mouseInPlotArea(x + offset, y + offset) - dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1], - axis=yAxis, check=True) - if dataPos is None: - continue - xPick1, yPick1 = dataPos + if marker['x'] is None: # Horizontal line + pt1 = self.pixelToData( + x, y - self._PICK_OFFSET, axis='left', check=False) + pt2 = self.pixelToData( + x, y + self._PICK_OFFSET, axis='left', check=False) + isPicked = (min(pt1[1], pt2[1]) <= marker['y'] <= + max(pt1[1], pt2[1])) + + elif marker['y'] is None: # Vertical line + pt1 = self.pixelToData( + x - self._PICK_OFFSET, y, axis='left', check=False) + pt2 = self.pixelToData( + x + self._PICK_OFFSET, y, axis='left', check=False) + isPicked = (min(pt1[0], pt2[0]) <= marker['x'] <= + max(pt1[0], pt2[0])) - if xPick0 < xPick1: - xPickMin, xPickMax = xPick0, xPick1 else: - xPickMin, xPickMax = xPick1, xPick0 + isPicked = ( + numpy.fabs(x - pixelPos[0]) <= self._PICK_OFFSET and + numpy.fabs(y - pixelPos[1]) <= self._PICK_OFFSET) - if yPick0 < yPick1: - yPickMin, yPickMax = yPick0, yPick1 - else: - yPickMin, yPickMax = yPick1, yPick0 - - pickedIndices = item.pick(xPickMin, yPickMin, - xPickMax, yPickMax) - if pickedIndices: - picked.append(dict(kind='curve', - legend=item.info['legend'], - indices=pickedIndices)) + if isPicked: + picked.append(dict(kind='marker', + legend=marker['legend'])) + + # Pick image and curves + if 'image' in kinds or 'curve' in kinds: + for item in self._plotContent.zOrderedPrimitives(reverse=True): + if ('image' in kinds and + isinstance(item, (GLPlotColormap, GLPlotRGBAImage))): + pickedPos = item.pick(*dataPos) + if pickedPos is not None: + picked.append(dict(kind='image', + legend=item.info['legend'])) + + elif 'curve' in kinds and isinstance(item, GLPlotCurve2D): + offset = self._PICK_OFFSET + if item.marker is not None: + offset = max(item.markerSize / 2., offset) + if item.lineStyle is not None: + offset = max(item.lineWidth / 2., offset) + + yAxis = item.info['yAxis'] + + inAreaPos = self._mouseInPlotArea(x - offset, y - offset) + dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1], + axis=yAxis, check=True) + if dataPos is None: + continue + xPick0, yPick0 = dataPos + + inAreaPos = self._mouseInPlotArea(x + offset, y + offset) + dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1], + axis=yAxis, check=True) + if dataPos is None: + continue + xPick1, yPick1 = dataPos + + if xPick0 < xPick1: + xPickMin, xPickMax = xPick0, xPick1 + else: + xPickMin, xPickMax = xPick1, xPick0 + + if yPick0 < yPick1: + yPickMin, yPickMax = yPick0, yPick1 + else: + yPickMin, yPickMax = yPick1, yPick0 + + pickedIndices = item.pick(xPickMin, yPickMin, + xPickMax, yPickMax) + if pickedIndices: + picked.append(dict(kind='curve', + legend=item.info['legend'], + indices=pickedIndices)) return picked diff --git a/silx/gui/plot/backends/glutils/GLPlotCurve.py b/silx/gui/plot/backends/glutils/GLPlotCurve.py index 4433613..124a3da 100644 --- a/silx/gui/plot/backends/glutils/GLPlotCurve.py +++ b/silx/gui/plot/backends/glutils/GLPlotCurve.py @@ -606,7 +606,7 @@ class _Points2D(object): """, ASTERISK: """ float alphaSymbol(vec2 coord, float size) { - /* Combining +, x and cirle */ + /* Combining +, x and circle */ vec2 d_plus = abs(size * (coord - vec2(0.5, 0.5))); vec2 pos = floor(size * coord) + 0.5; vec2 d_x = abs(pos.x + vec2(- pos.y, pos.y - size)); diff --git a/silx/gui/plot/backends/glutils/PlotImageFile.py b/silx/gui/plot/backends/glutils/PlotImageFile.py index f028ee8..83c7ae0 100644 --- a/silx/gui/plot/backends/glutils/PlotImageFile.py +++ b/silx/gui/plot/backends/glutils/PlotImageFile.py @@ -93,7 +93,7 @@ def saveImageToFile(data, fileNameOrObj, fileFormat): assert fileFormat in ('png', 'ppm', 'svg', 'tiff') if not hasattr(fileNameOrObj, 'write'): - if sys.version < "3.0": + if sys.version_info < (3, ): fileObj = open(fileNameOrObj, "wb") else: if fileFormat in ('png', 'ppm', 'tiff'): diff --git a/silx/gui/plot/items/__init__.py b/silx/gui/plot/items/__init__.py index bf39c87..e7957ac 100644 --- a/silx/gui/plot/items/__init__.py +++ b/silx/gui/plot/items/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -35,6 +35,7 @@ __date__ = "22/06/2017" from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, # noqa SymbolMixIn, ColorMixIn, YAxisMixIn, FillMixIn, # noqa AlphaMixIn, LineMixIn, ItemChangedType) # noqa +from .complex import ImageComplexData # noqa from .curve import Curve # noqa from .histogram import Histogram # noqa from .image import ImageBase, ImageData, ImageRgba, MaskImageData # noqa @@ -42,3 +43,7 @@ from .shape import Shape # noqa from .scatter import Scatter # noqa from .marker import Marker, XMarker, YMarker # noqa from .axis import Axis, XAxis, YAxis, YRightAxis + +DATA_ITEMS = ImageComplexData, Curve, Histogram, ImageBase, Scatter +"""Classes of items representing data and to consider to compute data bounds. +""" diff --git a/silx/gui/plot/items/axis.py b/silx/gui/plot/items/axis.py index ff36512..d7e6eff 100644 --- a/silx/gui/plot/items/axis.py +++ b/silx/gui/plot/items/axis.py @@ -27,7 +27,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "30/08/2017" +__date__ = "06/12/2017" import logging from ... import qt @@ -66,7 +66,7 @@ class Axis(qt.QObject): """Signal emitted when axis autoscale has changed""" sigLimitsChanged = qt.Signal(float, float) - """Signal emitted when axis autoscale has changed""" + """Signal emitted when axis limits have changed""" def __init__(self, plot): """Constructor @@ -262,7 +262,7 @@ class Axis(qt.QObject): def setLimitsConstraints(self, minPos=None, maxPos=None): """ - Set a constaints on the position of the axes. + Set a constraint on the position of the axes. :param float minPos: Minimum allowed axis value. :param float maxPos: Maximum allowed axis value. @@ -283,7 +283,7 @@ class Axis(qt.QObject): def setRangeConstraints(self, minRange=None, maxRange=None): """ - Set a constaints on the position of the axes. + Set a constraint on the position of the axes. :param float minRange: Minimum allowed left-to-right span across the view diff --git a/silx/gui/plot/items/complex.py b/silx/gui/plot/items/complex.py new file mode 100644 index 0000000..ba57e85 --- /dev/null +++ b/silx/gui/plot/items/complex.py @@ -0,0 +1,356 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides the :class:`ImageComplexData` of the :class:`Plot`. +""" + +from __future__ import absolute_import + +__authors__ = ["Vincent Favre-Nicolin", "T. Vincent"] +__license__ = "MIT" +__date__ = "19/01/2018" + + +import logging +import numpy + +from silx.third_party import enum + +from ..Colormap import Colormap +from .core import ColormapMixIn, ItemChangedType +from .image import ImageBase + + +_logger = logging.getLogger(__name__) + + +# Complex colormap functions + +def _phase2rgb(colormap, data): + """Creates RGBA image with colour-coded phase. + + :param Colormap colormap: The colormap to use + :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 colormap.applyToData(phase) + + +def _complex2rgbalog(phaseColormap, data, amin=0., dlogs=2, smax=None): + """Returns RGBA colors: colour-coded phases and log10(amplitude) in alpha. + + :param Colormap phaseColormap: Colormap to use for the phase + :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(phaseColormap, 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(phaseColormap, data, gamma=1.0, smax=None): + """Returns RGBA colors: colour-coded phase and linear amplitude in alpha. + + :param Colormap phaseColormap: Colormap to use for the phase + :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(phaseColormap, data) + a = numpy.absolute(data) + if smax is not None: + a[a > smax] = smax + a /= a.max() + rgba[..., 3] = 255 * a**gamma + return rgba + + +class ImageComplexData(ImageBase, ColormapMixIn): + """Specific plot item to force colormap when using complex colormap. + + This is returning the specific colormap when displaying + colored phase + amplitude. + """ + + class Mode(enum.Enum): + """Identify available display mode for complex""" + ABSOLUTE = 'absolute' + PHASE = 'phase' + REAL = 'real' + IMAGINARY = 'imaginary' + AMPLITUDE_PHASE = 'amplitude_phase' + LOG10_AMPLITUDE_PHASE = 'log10_amplitude_phase' + SQUARE_AMPLITUDE = 'square_amplitude' + + def __init__(self): + ImageBase.__init__(self) + ColormapMixIn.__init__(self) + self._data = numpy.zeros((0, 0), dtype=numpy.complex64) + self._dataByModesCache = {} + self._mode = self.Mode.ABSOLUTE + self._amplitudeRangeInfo = None, 2 + + # Use default from ColormapMixIn + colormap = super(ImageComplexData, self).getColormap() + + phaseColormap = Colormap( + name='hsv', + vmin=-numpy.pi, + vmax=numpy.pi) + phaseColormap.setEditable(False) + + self._colormaps = { # Default colormaps for all modes + self.Mode.ABSOLUTE: colormap, + self.Mode.PHASE: phaseColormap, + self.Mode.REAL: colormap, + self.Mode.IMAGINARY: colormap, + self.Mode.AMPLITUDE_PHASE: phaseColormap, + self.Mode.LOG10_AMPLITUDE_PHASE: phaseColormap, + self.Mode.SQUARE_AMPLITUDE: colormap, + } + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + plot = self.getPlot() + assert plot is not None + if not self._isPlotLinear(plot): + # Do not render with non linear scales + return None + + mode = self.getVisualizationMode() + if mode in (self.Mode.AMPLITUDE_PHASE, + self.Mode.LOG10_AMPLITUDE_PHASE): + # For those modes, compute RGBA image here + colormap = None + data = self.getRgbaImageData(copy=False) + else: + colormap = self.getColormap() + data = self.getData(copy=False) + + if data.size == 0: + return None # No data to display + + return backend.addImage(data, + legend=self.getLegend(), + origin=self.getOrigin(), + scale=self.getScale(), + z=self.getZValue(), + selectable=self.isSelectable(), + draggable=self.isDraggable(), + colormap=colormap, + alpha=self.getAlpha()) + + + def setVisualizationMode(self, mode): + """Set the visualization mode to use. + + :param Mode mode: + """ + assert isinstance(mode, self.Mode) + assert mode in self._colormaps + + if mode != self._mode: + self._mode = mode + + self._updated(ItemChangedType.VISUALIZATION_MODE) + + # Send data updated as value returned by getData has changed + self._updated(ItemChangedType.DATA) + + # Update ColormapMixIn colormap + colormap = self._colormaps[self._mode] + if colormap is not super(ImageComplexData, self).getColormap(): + super(ImageComplexData, self).setColormap(colormap) + + def getVisualizationMode(self): + """Returns the visualization mode in use. + + :rtype: Mode + """ + 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) + self._updated(ItemChangedType.VISUALIZATION_MODE) + + 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 + + def setColormap(self, colormap, mode=None): + """Set the colormap for this specific mode. + + :param ~silx.gui.plot.Colormap.Colormap colormap: The colormap + :param Mode mode: + If specified, set the colormap of this specific mode. + Default: current mode. + """ + if mode is None: + mode = self.getVisualizationMode() + + self._colormaps[mode] = colormap + if mode is self.getVisualizationMode(): + super(ImageComplexData, self).setColormap(colormap) + else: + self._updated(ItemChangedType.COLORMAP) + + def getColormap(self, mode=None): + """Get the colormap for the (current) mode. + + :param Mode mode: + If specified, get the colormap of this specific mode. + Default: current mode. + :rtype: ~silx.gui.plot.Colormap.Colormap + """ + if mode is None: + mode = self.getVisualizationMode() + + return self._colormaps[mode] + + def setData(self, data, copy=True): + """"Set the image complex data + + :param numpy.ndarray data: 2D array of complex with 2 dimensions (h, w) + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + """ + data = numpy.array(data, copy=copy) + assert data.ndim == 2 + if not numpy.issubdtype(data.dtype, numpy.complexfloating): + _logger.warning( + 'Image is not complex, converting it to complex to plot it.') + data = numpy.array(data, dtype=numpy.complex64) + + self._data = data + self._dataByModesCache = {} + + # TODO hackish data range implementation + if self.isVisible(): + plot = self.getPlot() + if plot is not None: + plot._invalidateDataRange() + + self._updated(ItemChangedType.DATA) + + def getComplexData(self, copy=True): + """Returns the image complex data + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: numpy.ndarray of complex + """ + return numpy.array(self._data, copy=copy) + + def getData(self, copy=True, mode=None): + """Returns the image data corresponding to (current) mode. + + The returned data is always floats, to get the complex data, use + :meth:`getComplexData`. + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :param Mode mode: + If specified, get data corresponding to the mode. + Default: Current mode. + :rtype: numpy.ndarray of float + """ + if mode is None: + mode = self.getVisualizationMode() + + if mode not in self._dataByModesCache: + # Compute data for mode and store it in cache + complexData = self.getComplexData(copy=False) + if mode is self.Mode.PHASE: + data = numpy.angle(complexData) + elif mode is self.Mode.REAL: + data = numpy.real(complexData) + elif mode is self.Mode.IMAGINARY: + data = numpy.imag(complexData) + elif mode in (self.Mode.ABSOLUTE, + self.Mode.LOG10_AMPLITUDE_PHASE, + self.Mode.AMPLITUDE_PHASE): + data = numpy.absolute(complexData) + elif mode is self.Mode.SQUARE_AMPLITUDE: + data = numpy.absolute(complexData) ** 2 + else: + _logger.error( + 'Unsupported conversion mode: %s, fallback to absolute', + str(mode)) + data = numpy.absolute(complexData) + + self._dataByModesCache[mode] = data + + return numpy.array(self._dataByModesCache[mode], copy=copy) + + def getRgbaImageData(self, copy=True, mode=None): + """Get the displayed RGB(A) image for (current) mode + + :param bool copy: Ignored for this class + :param Mode mode: + If specified, get data corresponding to the mode. + Default: Current mode. + :rtype: numpy.ndarray of uint8 of shape (height, width, 4) + """ + if mode is None: + mode = self.getVisualizationMode() + + colormap = self.getColormap(mode=mode) + if mode is self.Mode.AMPLITUDE_PHASE: + data = self.getComplexData(copy=False) + return _complex2rgbalin(colormap, data) + elif mode is self.Mode.LOG10_AMPLITUDE_PHASE: + data = self.getComplexData(copy=False) + max_, delta = self._getAmplitudeRangeInfo() + return _complex2rgbalog(colormap, data, dlogs=delta, smax=max_) + else: + data = self.getData(copy=False, mode=mode) + return colormap.applyToData(data) diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py index 34ac700..bcb6dd1 100644 --- a/silx/gui/plot/items/core.py +++ b/silx/gui/plot/items/core.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -115,6 +115,9 @@ class ItemChangedType(enum.Enum): OVERLAY = 'overlayChanged' """Item's overlay state changed flag.""" + VISUALIZATION_MODE = 'visualizationModeChanged' + """Item's visualization mode changed flag.""" + class Item(qt.QObject): """Description of an item of the plot""" @@ -136,7 +139,7 @@ class Item(qt.QObject): """ def __init__(self): - super(Item, self).__init__() + qt.QObject.__init__(self) self._dirty = True self._plotRef = None self._visible = True @@ -312,7 +315,24 @@ class Item(qt.QObject): # Mix-in classes ############################################################## -class LabelsMixIn(object): +class ItemMixInBase(qt.QObject): + """Base class for Item mix-in""" + + def _updated(se |