summaryrefslogtreecommitdiff
path: root/silx
diff options
context:
space:
mode:
authorPicca Frédéric-Emmanuel <picca@debian.org>2018-03-04 10:20:27 +0100
committerPicca Frédéric-Emmanuel <picca@debian.org>2018-03-04 10:20:27 +0100
commit270d5ddc31c26b62379e3caa9044dd75ccc71847 (patch)
tree55c5bfc851dfce7172d335cd2405b214323e3caf /silx
parente19c96eff0c310c06c4f268c8b80cb33bd08996f (diff)
New upstream version 0.7.0+dfsg
Diffstat (limited to 'silx')
-rw-r--r--silx/app/convert.py368
-rw-r--r--silx/app/test/test_convert.py37
-rw-r--r--silx/app/test/test_view.py22
-rw-r--r--silx/app/test_.py33
-rw-r--r--silx/app/view.py50
-rw-r--r--silx/gui/_glutils/OpenGLWidget.py5
-rw-r--r--silx/gui/_glutils/Texture.py7
-rw-r--r--silx/gui/_glutils/gl.py5
-rw-r--r--silx/gui/_utils.py79
-rw-r--r--silx/gui/console.py5
-rw-r--r--silx/gui/data/DataViewer.py153
-rw-r--r--silx/gui/data/DataViewerFrame.py17
-rw-r--r--silx/gui/data/DataViewerSelector.py40
-rw-r--r--silx/gui/data/DataViews.py365
-rw-r--r--silx/gui/data/Hdf5TableView.py35
-rw-r--r--silx/gui/data/NXdataWidgets.py390
-rw-r--r--silx/gui/data/NumpyAxesSelector.py24
-rw-r--r--silx/gui/data/TextFormatter.py47
-rw-r--r--silx/gui/data/test/test_dataviewer.py87
-rw-r--r--silx/gui/data/test/test_numpyaxesselector.py16
-rw-r--r--silx/gui/data/test/test_textformatter.py13
-rw-r--r--silx/gui/dialog/AbstractDataFileDialog.py1718
-rw-r--r--silx/gui/dialog/DataFileDialog.py342
-rw-r--r--silx/gui/dialog/FileTypeComboBox.py213
-rw-r--r--silx/gui/dialog/ImageFileDialog.py338
-rw-r--r--silx/gui/dialog/SafeFileIconProvider.py150
-rw-r--r--silx/gui/dialog/SafeFileSystemModel.py802
-rw-r--r--silx/gui/dialog/__init__.py29
-rw-r--r--silx/gui/dialog/setup.py40
-rw-r--r--silx/gui/dialog/test/__init__.py47
-rw-r--r--silx/gui/dialog/test/test_datafiledialog.py981
-rw-r--r--silx/gui/dialog/test/test_imagefiledialog.py803
-rw-r--r--silx/gui/dialog/utils.py104
-rw-r--r--silx/gui/hdf5/Hdf5Formatter.py5
-rw-r--r--silx/gui/hdf5/Hdf5Item.py126
-rw-r--r--silx/gui/hdf5/Hdf5TreeModel.py135
-rw-r--r--silx/gui/hdf5/Hdf5TreeView.py113
-rw-r--r--silx/gui/hdf5/NexusSortFilterProxyModel.py20
-rw-r--r--silx/gui/hdf5/_utils.py51
-rw-r--r--silx/gui/hdf5/test/test_hdf5.py99
-rw-r--r--silx/gui/icons.py3
-rw-r--r--silx/gui/plot/ColorBar.py44
-rw-r--r--silx/gui/plot/Colormap.py243
-rw-r--r--silx/gui/plot/ColormapDialog.py897
-rw-r--r--silx/gui/plot/ComplexImageView.py314
-rw-r--r--silx/gui/plot/CurvesROIWidget.py684
-rw-r--r--silx/gui/plot/Interaction.py2
-rw-r--r--silx/gui/plot/PlotToolButtons.py15
-rw-r--r--silx/gui/plot/PlotTools.py10
-rw-r--r--silx/gui/plot/PlotWidget.py172
-rw-r--r--silx/gui/plot/PlotWindow.py63
-rw-r--r--silx/gui/plot/Profile.py12
-rw-r--r--silx/gui/plot/StackView.py24
-rw-r--r--silx/gui/plot/_utils/test/test_ticklayout.py18
-rw-r--r--silx/gui/plot/_utils/ticklayout.py20
-rw-r--r--silx/gui/plot/actions/PlotAction.py3
-rw-r--r--silx/gui/plot/actions/__init__.py12
-rw-r--r--silx/gui/plot/actions/control.py140
-rw-r--r--silx/gui/plot/actions/fit.py4
-rw-r--r--silx/gui/plot/actions/histogram.py4
-rw-r--r--silx/gui/plot/actions/io.py133
-rw-r--r--silx/gui/plot/actions/medfilt.py8
-rw-r--r--silx/gui/plot/backends/BackendBase.py11
-rw-r--r--silx/gui/plot/backends/BackendMatplotlib.py154
-rw-r--r--silx/gui/plot/backends/BackendOpenGL.py177
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotCurve.py2
-rw-r--r--silx/gui/plot/backends/glutils/PlotImageFile.py2
-rw-r--r--silx/gui/plot/items/__init__.py7
-rw-r--r--silx/gui/plot/items/axis.py8
-rw-r--r--silx/gui/plot/items/complex.py356
-rw-r--r--silx/gui/plot/items/core.py95
-rw-r--r--silx/gui/plot/items/image.py7
-rw-r--r--silx/gui/plot/items/marker.py3
-rw-r--r--silx/gui/plot/matplotlib/Colormap.py58
-rw-r--r--silx/gui/plot/matplotlib/__init__.py5
-rw-r--r--silx/gui/plot/test/__init__.py6
-rw-r--r--silx/gui/plot/test/testColormap.py76
-rw-r--r--silx/gui/plot/test/testColormapDialog.py321
-rw-r--r--silx/gui/plot/test/testColors.py4
-rw-r--r--silx/gui/plot/test/testComplexImageView.py4
-rw-r--r--silx/gui/plot/test/testCurvesROIWidget.py19
-rw-r--r--silx/gui/plot/test/testItem.py20
-rw-r--r--silx/gui/plot/test/testMaskToolsWidget.py9
-rw-r--r--silx/gui/plot/test/testPlotTools.py4
-rw-r--r--silx/gui/plot/test/testPlotWidget.py22
-rw-r--r--silx/gui/plot/test/testPlotWidgetNoBackend.py4
-rw-r--r--silx/gui/plot/test/testProfile.py4
-rw-r--r--silx/gui/plot/test/testSaveAction.py97
-rw-r--r--silx/gui/plot/test/testScatterMaskToolsWidget.py9
-rw-r--r--silx/gui/plot/test/testUtilsAxis.py23
-rw-r--r--silx/gui/plot/test/utils.py127
-rw-r--r--silx/gui/plot/utils/axis.py78
-rw-r--r--silx/gui/plot3d/ParamTreeView.py541
-rw-r--r--silx/gui/plot3d/Plot3DWidget.py60
-rw-r--r--silx/gui/plot3d/Plot3DWindow.py23
-rw-r--r--silx/gui/plot3d/SFViewParamTree.py270
-rw-r--r--silx/gui/plot3d/ScalarFieldView.py430
-rw-r--r--silx/gui/plot3d/SceneWidget.py646
-rw-r--r--silx/gui/plot3d/SceneWindow.py192
-rw-r--r--silx/gui/plot3d/_model/__init__.py (renamed from silx/third_party/_local/__init__.py)17
-rw-r--r--silx/gui/plot3d/_model/core.py372
-rw-r--r--silx/gui/plot3d/_model/items.py1388
-rw-r--r--silx/gui/plot3d/_model/model.py184
-rw-r--r--silx/gui/plot3d/actions/Plot3DAction.py10
-rw-r--r--silx/gui/plot3d/actions/io.py15
-rw-r--r--silx/gui/plot3d/actions/mode.py15
-rw-r--r--silx/gui/plot3d/actions/viewpoint.py141
-rw-r--r--silx/gui/plot3d/items/__init__.py43
-rw-r--r--silx/gui/plot3d/items/clipplane.py50
-rw-r--r--silx/gui/plot3d/items/core.py622
-rw-r--r--silx/gui/plot3d/items/image.py126
-rw-r--r--silx/gui/plot3d/items/mesh.py145
-rw-r--r--silx/gui/plot3d/items/mixins.py302
-rw-r--r--silx/gui/plot3d/items/scatter.py474
-rw-r--r--silx/gui/plot3d/items/volume.py463
-rw-r--r--silx/gui/plot3d/scene/__init__.py2
-rw-r--r--silx/gui/plot3d/scene/axes.py19
-rw-r--r--silx/gui/plot3d/scene/camera.py7
-rw-r--r--silx/gui/plot3d/scene/cutplane.py46
-rw-r--r--silx/gui/plot3d/scene/function.py36
-rw-r--r--silx/gui/plot3d/scene/interaction.py58
-rw-r--r--silx/gui/plot3d/scene/primitives.py1241
-rw-r--r--silx/gui/plot3d/scene/test/test_utils.py4
-rw-r--r--silx/gui/plot3d/scene/transform.py24
-rw-r--r--silx/gui/plot3d/scene/utils.py42
-rw-r--r--silx/gui/plot3d/scene/viewport.py59
-rw-r--r--silx/gui/plot3d/scene/window.py27
-rw-r--r--silx/gui/plot3d/setup.py2
-rw-r--r--silx/gui/plot3d/test/__init__.py12
-rw-r--r--silx/gui/plot3d/test/testScalarFieldView.py4
-rw-r--r--silx/gui/plot3d/tools/GroupPropertiesWidget.py200
-rw-r--r--silx/gui/plot3d/tools/ViewpointTools.py119
-rw-r--r--silx/gui/plot3d/tools/__init__.py6
-rw-r--r--silx/gui/plot3d/tools/toolbars.py111
-rw-r--r--silx/gui/qt/_qt.py64
-rw-r--r--silx/gui/qt/_utils.py25
-rw-r--r--silx/gui/setup.py3
-rw-r--r--silx/gui/test/__init__.py15
-rw-r--r--silx/gui/test/test_utils.py91
-rw-r--r--silx/gui/test/utils.py88
-rw-r--r--silx/gui/widgets/FrameBrowser.py2
-rw-r--r--silx/gui/widgets/MedianFilterDialog.py10
-rw-r--r--silx/gui/widgets/PeriodicTable.py8
-rw-r--r--silx/gui/widgets/PrintPreview.py4
-rw-r--r--silx/gui/widgets/ThreadPoolPushButton.py11
-rw-r--r--silx/gui/widgets/test/test_threadpoolpushbutton.py24
-rw-r--r--silx/image/test/test_shapes.py4
-rw-r--r--silx/io/__init__.py9
-rw-r--r--silx/io/commonh5.py53
-rw-r--r--silx/io/configdict.py8
-rw-r--r--silx/io/convert.py81
-rw-r--r--silx/io/dictdump.py27
-rw-r--r--silx/io/fabioh5.py336
-rw-r--r--silx/io/nxdata.py669
-rw-r--r--silx/io/specfile.c12792
-rw-r--r--silx/io/specfile.pyx18
-rw-r--r--silx/io/specfile/src/locale_management.c24
-rw-r--r--silx/io/specfilewrapper.py4
-rw-r--r--silx/io/spech5.py155
-rw-r--r--silx/io/test/__init__.py4
-rw-r--r--silx/io/test/test_dictdump.py25
-rw-r--r--silx/io/test/test_fabioh5.py181
-rw-r--r--silx/io/test/test_nxdata.py271
-rw-r--r--silx/io/test/test_specfile.py32
-rw-r--r--silx/io/test/test_specfilewrapper.py17
-rw-r--r--silx/io/test/test_spech5.py72
-rw-r--r--silx/io/test/test_spectoh5.py23
-rw-r--r--silx/io/test/test_url.py209
-rw-r--r--silx/io/test/test_utils.py221
-rw-r--r--silx/io/url.py366
-rw-r--r--silx/io/utils.py419
-rw-r--r--silx/math/fit/fitmanager.py12
-rw-r--r--silx/math/fit/test/test_fit.py6
-rw-r--r--silx/math/medianfilter/test/test_medianfilter.py4
-rw-r--r--silx/math/test/benchmark_combo.py5
-rw-r--r--silx/math/test/test_combo.py4
-rw-r--r--silx/math/test/test_marchingcubes.py4
-rw-r--r--silx/opencl/backprojection.py4
-rw-r--r--silx/opencl/codec/__init__.py0
-rw-r--r--silx/opencl/codec/byte_offset.py439
-rw-r--r--silx/opencl/codec/setup.py43
-rw-r--r--silx/opencl/codec/test/__init__.py37
-rw-r--r--silx/opencl/codec/test/test_byte_offset.py317
-rw-r--r--silx/opencl/common.py5
-rw-r--r--silx/opencl/image.py387
-rw-r--r--silx/opencl/processing.py59
-rw-r--r--silx/opencl/projection.py89
-rw-r--r--silx/opencl/setup.py3
-rw-r--r--silx/opencl/test/__init__.py7
-rw-r--r--silx/opencl/test/test_addition.py6
-rw-r--r--silx/opencl/test/test_backprojection.py4
-rw-r--r--silx/opencl/test/test_image.py137
-rw-r--r--silx/opencl/test/test_projection.py16
-rw-r--r--silx/resources/__init__.py45
-rw-r--r--silx/resources/gui/icons/colormap-histogram.pngbin0 -> 641 bytes
-rw-r--r--silx/resources/gui/icons/colormap-histogram.svg37
-rw-r--r--silx/resources/gui/icons/colormap-none.pngbin0 -> 232 bytes
-rw-r--r--silx/resources/gui/icons/colormap-none.svg33
-rw-r--r--silx/resources/gui/icons/colormap-range.pngbin0 -> 284 bytes
-rw-r--r--silx/resources/gui/icons/colormap-range.svg37
-rw-r--r--silx/resources/gui/icons/math-phase.pngbin515 -> 1868 bytes
-rw-r--r--silx/resources/gui/icons/math-phase.svg4
-rw-r--r--silx/resources/gui/icons/math-square-amplitude.pngbin0 -> 592 bytes
-rw-r--r--silx/resources/gui/icons/math-square-amplitude.svg3
-rw-r--r--silx/resources/opencl/codec/byte_offset.cl235
-rw-r--r--silx/resources/opencl/image/cast.cl181
-rw-r--r--silx/resources/opencl/image/histogram.cl178
-rw-r--r--silx/resources/opencl/image/map.cl85
-rw-r--r--silx/resources/opencl/image/max_min.cl207
-rw-r--r--silx/sx/__init__.py38
-rw-r--r--silx/sx/_plot.py338
-rw-r--r--silx/sx/_plot3d.py246
-rw-r--r--silx/test/__init__.py4
-rw-r--r--silx/test/test_resources.py15
-rw-r--r--silx/test/test_sx.py121
-rw-r--r--silx/test/utils.py243
-rw-r--r--silx/third_party/EdfFile.py4
-rw-r--r--silx/third_party/_local/enum.py877
-rw-r--r--silx/third_party/_local/six.py868
-rw-r--r--silx/third_party/concurrent_futures.py59
-rw-r--r--silx/third_party/scipy_spatial.py51
-rw-r--r--silx/third_party/setup.py6
-rw-r--r--silx/utils/array_like.py6
-rw-r--r--silx/utils/deprecation.py8
-rw-r--r--silx/utils/exceptions.py33
-rw-r--r--silx/utils/launcher.py18
-rw-r--r--silx/utils/property.py52
-rw-r--r--silx/utils/test/test_deprecation.py18
-rw-r--r--silx/utils/test/test_launcher.py4
-rw-r--r--silx/utils/testutils.py281
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