summaryrefslogtreecommitdiff
path: root/silx
diff options
context:
space:
mode:
authorPicca Frédéric-Emmanuel <picca@synchrotron-soleil.fr>2018-07-31 16:22:25 +0200
committerPicca Frédéric-Emmanuel <picca@synchrotron-soleil.fr>2018-07-31 16:22:25 +0200
commit159ef14fb9e198bb0066ea14e6b980f065de63dd (patch)
treebc37c7d4ba09ee59deb708897fa0571709aec293 /silx
parent270d5ddc31c26b62379e3caa9044dd75ccc71847 (diff)
New upstream version 0.8.0+dfsg
Diffstat (limited to 'silx')
-rw-r--r--silx/__init__.py19
-rw-r--r--silx/__main__.py4
-rw-r--r--silx/_config.py83
-rw-r--r--silx/app/__init__.py4
-rw-r--r--silx/app/setup.py3
-rw-r--r--silx/app/test/__init__.py4
-rw-r--r--silx/app/test/test_view.py146
-rw-r--r--silx/app/view.py314
-rw-r--r--silx/app/view/About.py (renamed from silx/app/qtutils.py)35
-rw-r--r--silx/app/view/ApplicationContext.py194
-rw-r--r--silx/app/view/CustomNxdataWidget.py1008
-rw-r--r--silx/app/view/DataPanel.py171
-rw-r--r--silx/app/view/Viewer.py686
-rw-r--r--silx/app/view/__init__.py28
-rw-r--r--silx/app/view/main.py168
-rw-r--r--silx/app/view/setup.py40
-rw-r--r--silx/app/view/test/__init__.py41
-rw-r--r--silx/app/view/test/test_launcher.py145
-rw-r--r--silx/app/view/test/test_view.py402
-rw-r--r--silx/app/view/utils.py45
-rw-r--r--silx/gui/__init__.py24
-rw-r--r--silx/gui/_glutils/font.py7
-rw-r--r--silx/gui/colors.py732
-rw-r--r--silx/gui/console.py63
-rw-r--r--silx/gui/data/DataViewer.py37
-rw-r--r--silx/gui/data/DataViewerFrame.py9
-rw-r--r--silx/gui/data/DataViews.py260
-rw-r--r--silx/gui/data/Hdf5TableView.py62
-rw-r--r--silx/gui/data/HexaTableView.py10
-rw-r--r--silx/gui/data/NXdataWidgets.py32
-rw-r--r--silx/gui/data/TextFormatter.py18
-rw-r--r--silx/gui/data/test/test_dataviewer.py6
-rw-r--r--silx/gui/dialog/AbstractDataFileDialog.py6
-rw-r--r--silx/gui/dialog/ColormapDialog.py986
-rw-r--r--silx/gui/dialog/GroupDialog.py177
-rw-r--r--silx/gui/dialog/test/__init__.py4
-rw-r--r--silx/gui/dialog/test/test_colormapdialog.py (renamed from silx/gui/plot/test/testColormapDialog.py)29
-rw-r--r--silx/gui/dialog/test/test_datafiledialog.py24
-rw-r--r--silx/gui/dialog/test/test_imagefiledialog.py32
-rw-r--r--silx/gui/hdf5/Hdf5Formatter.py18
-rw-r--r--silx/gui/hdf5/Hdf5TreeModel.py90
-rw-r--r--silx/gui/hdf5/Hdf5TreeView.py17
-rw-r--r--silx/gui/hdf5/NexusSortFilterProxyModel.py59
-rw-r--r--silx/gui/hdf5/_utils.py22
-rw-r--r--silx/gui/hdf5/test/test_hdf5.py256
-rw-r--r--silx/gui/icons.py14
-rw-r--r--silx/gui/plot/ColorBar.py117
-rw-r--r--silx/gui/plot/Colormap.py567
-rw-r--r--silx/gui/plot/ColormapDialog.py964
-rw-r--r--silx/gui/plot/Colors.py122
-rw-r--r--silx/gui/plot/ComplexImageView.py6
-rw-r--r--silx/gui/plot/CurvesROIWidget.py195
-rw-r--r--silx/gui/plot/ImageView.py28
-rw-r--r--silx/gui/plot/MaskToolsWidget.py35
-rw-r--r--silx/gui/plot/PlotInteraction.py251
-rw-r--r--silx/gui/plot/PlotToolButtons.py95
-rw-r--r--silx/gui/plot/PlotTools.py289
-rw-r--r--silx/gui/plot/PlotWidget.py110
-rw-r--r--silx/gui/plot/PlotWindow.py181
-rw-r--r--silx/gui/plot/Profile.py12
-rw-r--r--silx/gui/plot/ProfileMainWindow.py4
-rw-r--r--silx/gui/plot/ScatterMaskToolsWidget.py68
-rw-r--r--silx/gui/plot/ScatterView.py353
-rw-r--r--silx/gui/plot/StackView.py72
-rw-r--r--silx/gui/plot/StatsWidget.py572
-rw-r--r--silx/gui/plot/_BaseMaskToolsWidget.py49
-rw-r--r--silx/gui/plot/__init__.py7
-rw-r--r--silx/gui/plot/_utils/dtime_ticklayout.py438
-rw-r--r--silx/gui/plot/_utils/test/__init__.py4
-rw-r--r--silx/gui/plot/_utils/test/testColormap.py648
-rw-r--r--silx/gui/plot/_utils/test/test_dtime_ticklayout.py93
-rw-r--r--silx/gui/plot/_utils/ticklayout.py85
-rw-r--r--silx/gui/plot/actions/control.py24
-rw-r--r--silx/gui/plot/actions/histogram.py4
-rw-r--r--silx/gui/plot/actions/io.py344
-rw-r--r--silx/gui/plot/actions/mode.py10
-rw-r--r--silx/gui/plot/backends/BackendBase.py43
-rw-r--r--silx/gui/plot/backends/BackendMatplotlib.py238
-rw-r--r--silx/gui/plot/backends/BackendOpenGL.py254
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotCurve.py1070
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotFrame.py97
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotImage.py31
-rw-r--r--silx/gui/plot/backends/glutils/GLSupport.py37
-rw-r--r--silx/gui/plot/backends/glutils/GLText.py5
-rw-r--r--silx/gui/plot/items/axis.py154
-rw-r--r--silx/gui/plot/items/complex.py8
-rw-r--r--silx/gui/plot/items/core.py38
-rw-r--r--silx/gui/plot/items/curve.py8
-rw-r--r--silx/gui/plot/items/histogram.py37
-rw-r--r--silx/gui/plot/items/roi.py1416
-rw-r--r--silx/gui/plot/items/scatter.py6
-rw-r--r--silx/gui/plot/matplotlib/Colormap.py6
-rw-r--r--silx/gui/plot/matplotlib/__init__.py50
-rw-r--r--silx/gui/plot/setup.py6
-rw-r--r--silx/gui/plot/stats/__init__.py (renamed from silx/math/include/isnan.h)22
-rw-r--r--silx/gui/plot/stats/stats.py491
-rw-r--r--silx/gui/plot/stats/statshandler.py190
-rw-r--r--silx/gui/plot/test/__init__.py25
-rw-r--r--silx/gui/plot/test/testColorBar.py12
-rw-r--r--silx/gui/plot/test/testColors.py94
-rw-r--r--silx/gui/plot/test/testCurvesROIWidget.py24
-rw-r--r--silx/gui/plot/test/testImageView.py4
-rw-r--r--silx/gui/plot/test/testLimitConstraints.py6
-rw-r--r--silx/gui/plot/test/testPixelIntensityHistoAction.py104
-rw-r--r--silx/gui/plot/test/testPlotWidget.py71
-rw-r--r--silx/gui/plot/test/testPlotWidgetNoBackend.py12
-rw-r--r--silx/gui/plot/test/testSaveAction.py40
-rw-r--r--silx/gui/plot/test/testScatterView.py115
-rw-r--r--silx/gui/plot/test/testStackView.py15
-rw-r--r--silx/gui/plot/test/testStats.py561
-rw-r--r--silx/gui/plot/test/utils.py7
-rw-r--r--silx/gui/plot/tools/LimitsToolBar.py131
-rw-r--r--silx/gui/plot/tools/PositionInfo.py347
-rw-r--r--silx/gui/plot/tools/__init__.py50
-rw-r--r--silx/gui/plot/tools/profile/ImageProfileToolBar.py271
-rw-r--r--silx/gui/plot/tools/profile/ScatterProfileToolBar.py431
-rw-r--r--silx/gui/plot/tools/profile/_BaseProfileToolBar.py430
-rw-r--r--silx/gui/plot/tools/profile/__init__.py38
-rw-r--r--silx/gui/plot/tools/roi.py934
-rw-r--r--silx/gui/plot/tools/test/__init__.py48
-rw-r--r--silx/gui/plot/tools/test/testROI.py456
-rw-r--r--silx/gui/plot/tools/test/testScatterProfileToolBar.py216
-rw-r--r--silx/gui/plot/tools/test/testTools.py (renamed from silx/gui/plot/test/testPlotTools.py)103
-rw-r--r--silx/gui/plot/tools/toolbars.py356
-rw-r--r--silx/gui/plot/utils/axis.py50
-rw-r--r--silx/gui/plot3d/Plot3DWidget.py6
-rw-r--r--silx/gui/plot3d/SFViewParamTree.py8
-rw-r--r--silx/gui/plot3d/ScalarFieldView.py10
-rw-r--r--silx/gui/plot3d/SceneWidget.py6
-rw-r--r--silx/gui/plot3d/SceneWindow.py13
-rw-r--r--silx/gui/plot3d/_model/items.py20
-rw-r--r--silx/gui/plot3d/actions/io.py11
-rw-r--r--silx/gui/plot3d/items/__init__.py2
-rw-r--r--silx/gui/plot3d/items/mesh.py396
-rw-r--r--silx/gui/plot3d/items/mixins.py4
-rw-r--r--silx/gui/plot3d/items/volume.py4
-rw-r--r--silx/gui/plot3d/scene/primitives.py10
-rw-r--r--silx/gui/plot3d/scene/text.py4
-rw-r--r--silx/gui/plot3d/scene/utils.py9
-rw-r--r--silx/gui/plot3d/scene/viewport.py4
-rw-r--r--silx/gui/plot3d/tools/GroupPropertiesWidget.py6
-rw-r--r--silx/gui/printer.py62
-rw-r--r--silx/gui/qt/__init__.py12
-rw-r--r--silx/gui/qt/_qt.py23
-rw-r--r--silx/gui/setup.py4
-rw-r--r--silx/gui/test/__init__.py11
-rw-r--r--silx/gui/test/test_colors.py (renamed from silx/gui/plot/test/testColormap.py)68
-rw-r--r--silx/gui/utils/__init__.py29
-rw-r--r--silx/gui/utils/_image.py (renamed from silx/gui/_utils.py)73
-rw-r--r--silx/gui/utils/concurrent.py103
-rw-r--r--silx/gui/utils/test/__init__.py48
-rw-r--r--silx/gui/utils/test/test_async.py (renamed from silx/gui/test/test_utils.py)48
-rw-r--r--silx/gui/utils/test/test_image.py74
-rw-r--r--silx/gui/widgets/BoxLayoutDockWidget.py90
-rw-r--r--silx/gui/widgets/FrameBrowser.py75
-rw-r--r--silx/gui/widgets/PrintGeometryDialog.py2
-rw-r--r--silx/gui/widgets/PrintPreview.py4
-rw-r--r--silx/gui/widgets/test/__init__.py6
-rw-r--r--silx/gui/widgets/test/test_boxlayoutdockwidget.py83
-rw-r--r--silx/gui/widgets/test/test_framebrowser.py73
-rw-r--r--silx/image/__init__.py8
-rw-r--r--silx/image/bilinear.c8339
-rw-r--r--silx/image/marchingsquares/__init__.py117
-rw-r--r--silx/image/marchingsquares/_mergeimpl.cpp33655
-rw-r--r--silx/image/marchingsquares/_mergeimpl.pyx1319
-rw-r--r--silx/image/marchingsquares/_skimage.py139
-rw-r--r--silx/image/marchingsquares/include/patterns.h89
-rw-r--r--silx/image/marchingsquares/setup.py51
-rw-r--r--silx/image/marchingsquares/test/__init__.py40
-rw-r--r--silx/image/marchingsquares/test/test_funcapi.py99
-rw-r--r--silx/image/marchingsquares/test/test_mergeimpl.py272
-rw-r--r--silx/image/setup.py5
-rw-r--r--silx/image/shapes.c10120
-rw-r--r--silx/image/test/__init__.py6
-rw-r--r--silx/io/__init__.py9
-rw-r--r--silx/io/commonh5.py103
-rw-r--r--silx/io/convert.py27
-rw-r--r--silx/io/fabioh5.py81
-rw-r--r--silx/io/nxdata/__init__.py64
-rw-r--r--silx/io/nxdata/_utils.py183
-rw-r--r--silx/io/nxdata/parse.py (renamed from silx/io/nxdata.py)795
-rw-r--r--silx/io/nxdata/write.py202
-rw-r--r--silx/io/setup.py7
-rw-r--r--silx/io/specfile.c32121
-rw-r--r--silx/io/specfile.pyx50
-rw-r--r--silx/io/spech5.py2
-rw-r--r--silx/io/test/test_fabioh5.py26
-rw-r--r--silx/io/test/test_nxdata.py16
-rw-r--r--silx/io/utils.py6
-rw-r--r--silx/math/__init__.py10
-rw-r--r--silx/math/calibration.py16
-rw-r--r--silx/math/chistogramnd.c11927
-rw-r--r--silx/math/chistogramnd.pyx88
-rw-r--r--silx/math/chistogramnd_lut.c19582
-rw-r--r--silx/math/chistogramnd_lut.pyx38
-rw-r--r--silx/math/colormap.c50168
-rw-r--r--silx/math/colormap.pyx389
-rw-r--r--silx/math/combo.c28407
-rw-r--r--silx/math/combo.pyx22
-rw-r--r--silx/math/fit/filters.c9455
-rw-r--r--silx/math/fit/functions.c10754
-rw-r--r--silx/math/fit/peaks.c8176
-rw-r--r--silx/math/fit/setup.py13
-rw-r--r--silx/math/histogramnd_c.pxd88
-rw-r--r--silx/math/include/math_compatibility.h53
-rw-r--r--silx/math/marchingcubes.cpp10037
-rw-r--r--silx/math/marchingcubes.pyx17
-rw-r--r--silx/math/math_compatibility.pxd35
-rw-r--r--silx/math/medianfilter/medianfilter.cpp10080
-rw-r--r--silx/math/medianfilter/medianfilter.pyx8
-rw-r--r--silx/math/setup.py9
-rw-r--r--silx/math/test/__init__.py7
-rw-r--r--silx/math/test/test_calibration.py158
-rw-r--r--silx/math/test/test_colormap.py190
-rw-r--r--silx/opencl/__init__.py8
-rw-r--r--silx/resources/gui/icons/add-shape-arc.pngbin0 -> 1164 bytes
-rw-r--r--silx/resources/gui/icons/add-shape-arc.svg2
-rw-r--r--silx/resources/gui/icons/add-shape-diagonal.pngbin0 -> 626 bytes
-rw-r--r--silx/resources/gui/icons/add-shape-diagonal.svg2
-rw-r--r--silx/resources/gui/icons/add-shape-horizontal.pngbin0 -> 408 bytes
-rw-r--r--silx/resources/gui/icons/add-shape-horizontal.svg2
-rw-r--r--silx/resources/gui/icons/add-shape-point.pngbin0 -> 494 bytes
-rw-r--r--silx/resources/gui/icons/add-shape-point.svg2
-rw-r--r--silx/resources/gui/icons/add-shape-polygon.pngbin0 -> 1217 bytes
-rw-r--r--silx/resources/gui/icons/add-shape-polygon.svg2
-rw-r--r--silx/resources/gui/icons/add-shape-rectangle.pngbin0 -> 463 bytes
-rw-r--r--silx/resources/gui/icons/add-shape-rectangle.svg2
-rw-r--r--silx/resources/gui/icons/add-shape-unknown.pngbin0 -> 1506 bytes
-rw-r--r--silx/resources/gui/icons/add-shape-unknown.svg2
-rw-r--r--silx/resources/gui/icons/add-shape-vertical.pngbin0 -> 422 bytes
-rw-r--r--silx/resources/gui/icons/add-shape-vertical.svg2
-rwxr-xr-xsilx/resources/gui/icons/document-print.pngbin1427 -> 702 bytes
-rw-r--r--silx/resources/gui/icons/document-print.svg54
-rw-r--r--silx/resources/gui/icons/layer-nx.pngbin0 -> 459 bytes
-rw-r--r--silx/resources/gui/icons/layer-nx.svg3
-rw-r--r--silx/resources/gui/icons/nxdata-axis-add.pngbin0 -> 686 bytes
-rw-r--r--silx/resources/gui/icons/nxdata-axis-add.svg2
-rw-r--r--silx/resources/gui/icons/nxdata-axis-remove.pngbin0 -> 967 bytes
-rw-r--r--silx/resources/gui/icons/nxdata-axis-remove.svg2
-rw-r--r--silx/resources/gui/icons/nxdata-create.pngbin0 -> 867 bytes
-rw-r--r--silx/resources/gui/icons/nxdata-create.svg2
-rw-r--r--silx/resources/gui/icons/nxdata-remove.pngbin0 -> 1265 bytes
-rw-r--r--silx/resources/gui/icons/nxdata-remove.svg2
-rw-r--r--silx/resources/gui/icons/pixel-intensities.pngbin1145 -> 654 bytes
-rw-r--r--silx/resources/gui/icons/pixel-intensities.svg19
-rw-r--r--silx/resources/gui/icons/plot-symbols.pngbin0 -> 672 bytes
-rw-r--r--silx/resources/gui/icons/plot-symbols.svg2
-rw-r--r--silx/resources/gui/icons/stats-active-items.pngbin0 -> 1521 bytes
-rw-r--r--silx/resources/gui/icons/stats-active-items.svg2
-rw-r--r--silx/resources/gui/icons/stats-visible-data.pngbin0 -> 662 bytes
-rw-r--r--silx/resources/gui/icons/stats-visible-data.svg2
-rw-r--r--silx/resources/gui/icons/stats-whole-data.pngbin0 -> 923 bytes
-rw-r--r--silx/resources/gui/icons/stats-whole-data.svg2
-rw-r--r--silx/resources/gui/icons/stats-whole-items.pngbin0 -> 1333 bytes
-rw-r--r--silx/resources/gui/icons/stats-whole-items.svg2
-rw-r--r--silx/resources/gui/icons/tree-collapse-all.pngbin0 -> 508 bytes
-rw-r--r--silx/resources/gui/icons/tree-collapse-all.svg2
-rw-r--r--silx/resources/gui/icons/tree-expand-all.pngbin0 -> 602 bytes
-rw-r--r--silx/resources/gui/icons/tree-expand-all.svg2
-rw-r--r--silx/setup.py3
-rw-r--r--silx/sx/__init__.py70
-rw-r--r--silx/sx/_plot.py373
-rw-r--r--silx/sx/_plot3d.py8
-rw-r--r--silx/sx/test/__init__.py38
-rw-r--r--silx/sx/test/test_sx.py288
-rw-r--r--silx/test/__init__.py30
-rw-r--r--silx/test/test_sx.py279
-rw-r--r--silx/third_party/EdfFile.py2
-rw-r--r--silx/third_party/__init__.py7
-rw-r--r--silx/third_party/setup.py4
-rw-r--r--silx/utils/__init__.py28
-rw-r--r--silx/utils/_have_openmp.pxi49
-rw-r--r--silx/utils/debug.py102
-rw-r--r--silx/utils/include/silx_store_openmp.h10
-rw-r--r--silx/utils/number.py143
-rw-r--r--silx/utils/test/__init__.py6
-rw-r--r--silx/utils/test/test_debug.py99
-rw-r--r--silx/utils/test/test_number.py181
-rw-r--r--silx/utils/weakref.py4
279 files changed, 210406 insertions, 61958 deletions
diff --git a/silx/__init__.py b/silx/__init__.py
index 8dab7e1..2892572 100644
--- a/silx/__init__.py
+++ b/silx/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -22,16 +22,31 @@
# THE SOFTWARE.
#
# ###########################################################################*/
+"""The silx package contains the following main sub-packages:
+
+- silx.gui: Qt widgets for data visualization and data file browsing
+- silx.image: Some processing functions for 2D images
+- silx.io: Reading and writing data files (HDF5/NeXus, SPEC, ...)
+- silx.math: Some processing functions for 1D, 2D, 3D, nD arrays
+- silx.opencl: OpenCL-based data processing
+- silx.sx: High-level silx functions suited for (I)Python console.
+- silx.utils: Miscellaneous convenient functions
+
+See silx documentation: http://www.silx.org/doc/silx/latest/
+"""
from __future__ import absolute_import, print_function, division
__authors__ = ["Jérôme Kieffer"]
__license__ = "MIT"
-__date__ = "23/05/2016"
+__date__ = "26/04/2018"
import os as _os
import logging as _logging
+from ._config import Config as _Config
+config = _Config()
+"""Global configuration shared with the whole library"""
# Attach a do nothing logging handler for silx
_logging.getLogger(__name__).addHandler(_logging.NullHandler())
diff --git a/silx/__main__.py b/silx/__main__.py
index 8323b03..a971390 100644
--- a/silx/__main__.py
+++ b/silx/__main__.py
@@ -32,7 +32,7 @@ Your environment should provide a command `silx`. You can reach help with
__authors__ = ["V. Valls", "P. Knobel"]
__license__ = "MIT"
-__date__ = "29/06/2017"
+__date__ = "07/06/2018"
import logging
@@ -54,7 +54,7 @@ def main():
"""
launcher = Launcher(prog="silx", version=silx._version.version)
launcher.add_command("view",
- module_name="silx.app.view",
+ module_name="silx.app.view.main",
description="Browse a data file with a GUI")
launcher.add_command("convert",
module_name="silx.app.convert",
diff --git a/silx/_config.py b/silx/_config.py
new file mode 100644
index 0000000..932aec1
--- /dev/null
+++ b/silx/_config.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module contains library wide configuration.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "26/04/2018"
+
+
+class Config(object):
+ """
+ Class containing shared global configuration for the silx library.
+
+ .. versionadded:: 0.8
+ """
+
+ DEFAULT_PLOT_BACKEND = "matplotlib"
+ """Default plot backend.
+
+ It will be used as default backend for all the next created PlotWidget.
+
+ This attribute can be set with:
+
+ - 'matplotlib' (default) or 'mpl'
+ - 'opengl', 'gl'
+ - 'none'
+ - A :class:`silx.gui.plot.backend.BackendBase.BackendBase` class
+ - A callable returning backend class or binding name
+
+ .. versionadded:: 0.8
+ """
+
+ DEFAULT_COLORMAP_NAME = 'gray'
+ """Default LUT for the plot widgets.
+
+ The available list of names are availaible in the module
+ :module:`silx.gui.colors`.
+
+ .. versionadded:: 0.8
+ """
+
+ DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = 'upward'
+ """Default Y-axis orientation for plot widget displaying images.
+
+ This attribute can be set with:
+
+ - 'upward' (default), which set the origin to the bottom with an upward
+ orientation.
+ - 'downward', which set the origin to the top with a backward orientation.
+
+ It will have an influence on:
+
+ - :class:`silx.gui.plot.StackWidget`
+ - :class:`silx.gui.plot.ComplexImageView`
+ - :class:`silx.gui.plot.Plot2D`
+ - :class:`silx.gui.plot.ImageView`
+
+ .. versionadded:: 0.8
+ """
diff --git a/silx/app/__init__.py b/silx/app/__init__.py
index 9cbb8bb..3af680c 100644
--- a/silx/app/__init__.py
+++ b/silx/app/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -22,7 +22,7 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""Application provided by the launcher"""
+"""This package contains the application provided by the launcher"""
__authors__ = ["V. Valls"]
__license__ = "MIT"
diff --git a/silx/app/setup.py b/silx/app/setup.py
index bf6f3af..85c3662 100644
--- a/silx/app/setup.py
+++ b/silx/app/setup.py
@@ -24,7 +24,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "30/03/2017"
+__date__ = "23/04/2018"
from numpy.distutils.misc_util import Configuration
@@ -32,6 +32,7 @@ from numpy.distutils.misc_util import Configuration
def configuration(parent_package='', top_path=None):
config = Configuration('app', parent_package, top_path)
config.add_subpackage('test')
+ config.add_subpackage('view')
return config
diff --git a/silx/app/test/__init__.py b/silx/app/test/__init__.py
index 0c22386..7c91134 100644
--- a/silx/app/test/__init__.py
+++ b/silx/app/test/__init__.py
@@ -24,11 +24,11 @@
# ###########################################################################*/
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "30/03/2017"
+__date__ = "06/06/2018"
import unittest
-from . import test_view
+from ..view import test as test_view
from . import test_convert
diff --git a/silx/app/test/test_view.py b/silx/app/test/test_view.py
deleted file mode 100644
index aeba0cc..0000000
--- a/silx/app/test/test_view.py
+++ /dev/null
@@ -1,146 +0,0 @@
-# coding: utf-8
-# /*##########################################################################
-#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-#
-# ###########################################################################*/
-"""Module testing silx.app.view"""
-
-__authors__ = ["V. Valls"]
-__license__ = "MIT"
-__date__ = "09/11/2017"
-
-
-import unittest
-import sys
-from silx.test.utils import test_options
-
-
-if not test_options.WITH_QT_TEST:
- view = None
- TestCaseQt = unittest.TestCase
-else:
- from silx.gui.test.utils import TestCaseQt
- from .. import view
-
-
-class QApplicationMock(object):
-
- def __init__(self, args):
- pass
-
- def exec_(self):
- return 0
-
- def deleteLater(self):
- pass
-
-
-class ViewerMock(object):
-
- def __init__(self):
- super(ViewerMock, self).__init__()
- self.__class__._instance = self
- self.appendFileCalls = []
-
- def appendFile(self, filename):
- self.appendFileCalls.append(filename)
-
- def setAttribute(self, attr, value):
- pass
-
- def resize(self, size):
- pass
-
- def show(self):
- pass
-
-
-@unittest.skipUnless(test_options.WITH_QT_TEST, test_options.WITH_QT_TEST_REASON)
-class TestLauncher(unittest.TestCase):
- """Test command line parsing"""
-
- @classmethod
- def setUpClass(cls):
- super(TestLauncher, cls).setUpClass()
- cls._Viewer = view.Viewer
- view.Viewer = ViewerMock
- cls._QApplication = view.qt.QApplication
- view.qt.QApplication = QApplicationMock
-
- @classmethod
- def tearDownClass(cls):
- view.Viewer = cls._Viewer
- view.qt.QApplication = cls._QApplication
- cls._Viewer = None
- super(TestLauncher, cls).tearDownClass()
-
- def testHelp(self):
- # option -h must cause a raise SystemExit or a return 0
- try:
- result = view.main(["view", "--help"])
- except SystemExit as e:
- result = e.args[0]
- self.assertEqual(result, 0)
-
- def testWrongOption(self):
- try:
- result = view.main(["view", "--foo"])
- except SystemExit as e:
- result = e.args[0]
- self.assertNotEqual(result, 0)
-
- def testWrongFile(self):
- try:
- result = view.main(["view", "__file.not.found__"])
- except SystemExit as e:
- result = e.args[0]
- self.assertEqual(result, 0)
-
- def testFile(self):
- # sys.executable is an existing readable file
- result = view.main(["view", sys.executable])
- self.assertEqual(result, 0)
- viewer = ViewerMock._instance
- self.assertEqual(viewer.appendFileCalls, [sys.executable])
- ViewerMock._instance = None
-
-
-class TestViewer(TestCaseQt):
- """Test for Viewer class"""
-
- @unittest.skipUnless(test_options.WITH_QT_TEST, test_options.WITH_QT_TEST_REASON)
- def testConstruct(self):
- if view is not None:
- widget = view.Viewer()
- self.qWaitForWindowExposed(widget)
-
-
-def suite():
- test_suite = unittest.TestSuite()
- loader = unittest.defaultTestLoader.loadTestsFromTestCase
- test_suite.addTest(loader(TestViewer))
- test_suite.addTest(loader(TestLauncher))
- return test_suite
-
-
-if __name__ == '__main__':
- unittest.main(defaultTest='suite')
diff --git a/silx/app/view.py b/silx/app/view.py
deleted file mode 100644
index bc4e30c..0000000
--- a/silx/app/view.py
+++ /dev/null
@@ -1,314 +0,0 @@
-# coding: utf-8
-# /*##########################################################################
-# Copyright (C) 2016-2018 European Synchrotron Radiation Facility
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-#
-# ############################################################################*/
-"""Browse a data file with a GUI"""
-
-__authors__ = ["V. Valls"]
-__license__ = "MIT"
-__date__ = "28/02/2018"
-
-import sys
-import os
-import argparse
-import logging
-import collections
-
-_logger = logging.getLogger(__name__)
-"""Module logger"""
-
-if "silx.gui.qt" not in sys.modules:
- # Try first PyQt5 and not the priority imposed by silx.gui.qt.
- # To avoid problem with unittests we only do it if silx.gui.qt is not
- # yet loaded.
- # TODO: Can be removed for silx 0.8, as it should be the default binding
- # of the silx library.
- try:
- import PyQt5.QtCore
- except ImportError:
- pass
-
-from silx.gui import qt
-
-
-class Viewer(qt.QMainWindow):
- """
- This window allows to browse a data file like images or HDF5 and it's
- content.
- """
-
- def __init__(self):
- """
- :param files_: List of HDF5 or Spec files (pathes or
- :class:`silx.io.spech5.SpecH5` or :class:`h5py.File`
- instances)
- """
- # Import it here to be sure to use the right logging level
- import silx.gui.hdf5
- from silx.gui.data.DataViewerFrame import DataViewerFrame
-
- qt.QMainWindow.__init__(self)
- self.setWindowTitle("Silx viewer")
-
- self.__asyncload = False
- self.__dialogState = None
- self.__treeview = silx.gui.hdf5.Hdf5TreeView(self)
- """Silx HDF5 TreeView"""
-
- self.__dataViewer = DataViewerFrame(self)
- vSpliter = qt.QSplitter(qt.Qt.Vertical)
- vSpliter.addWidget(self.__dataViewer)
- vSpliter.setSizes([10, 0])
-
- spliter = qt.QSplitter(self)
- spliter.addWidget(self.__treeview)
- spliter.addWidget(vSpliter)
- spliter.setStretchFactor(1, 1)
-
- main_panel = qt.QWidget(self)
- layout = qt.QVBoxLayout()
- layout.addWidget(spliter)
- layout.setStretchFactor(spliter, 1)
- main_panel.setLayout(layout)
-
- self.setCentralWidget(main_panel)
-
- model = self.__treeview.selectionModel()
- model.selectionChanged.connect(self.displayData)
- self.__treeview.addContextMenuCallback(self.closeAndSyncCustomContextMenu)
-
- treeModel = self.__treeview.findHdf5TreeModel()
- columns = list(treeModel.COLUMN_IDS)
- columns.remove(treeModel.DESCRIPTION_COLUMN)
- columns.remove(treeModel.NODE_COLUMN)
- self.__treeview.header().setSections(columns)
-
- self.createActions()
- self.createMenus()
-
- def createActions(self):
- action = qt.QAction("E&xit", self)
- action.setShortcuts(qt.QKeySequence.Quit)
- action.setStatusTip("Exit the application")
- action.triggered.connect(self.close)
- self._exitAction = action
-
- action = qt.QAction("&Open", self)
- action.setStatusTip("Open a file")
- action.triggered.connect(self.open)
- self._openAction = action
-
- action = qt.QAction("&About", self)
- action.setStatusTip("Show the application's About box")
- action.triggered.connect(self.about)
- self._aboutAction = action
-
- def createMenus(self):
- fileMenu = self.menuBar().addMenu("&File")
- fileMenu.addAction(self._openAction)
- fileMenu.addSeparator()
- fileMenu.addAction(self._exitAction)
- helpMenu = self.menuBar().addMenu("&Help")
- helpMenu.addAction(self._aboutAction)
-
- def open(self):
- dialog = self.createFileDialog()
- if self.__dialogState is None:
- currentDirectory = os.getcwd()
- dialog.setDirectory(currentDirectory)
- else:
- dialog.restoreState(self.__dialogState)
-
- result = dialog.exec_()
- if not result:
- return
-
- self.__dialogState = dialog.saveState()
-
- filenames = dialog.selectedFiles()
- for filename in filenames:
- self.appendFile(filename)
-
- def createFileDialog(self):
- dialog = qt.QFileDialog(self)
- dialog.setWindowTitle("Open")
- dialog.setModal(True)
-
- # NOTE: hdf5plugin have to be loaded before
- import silx.io
- extensions = collections.OrderedDict()
- for description, ext in silx.io.supported_extensions().items():
- extensions[description] = " ".join(sorted(list(ext)))
-
- # NOTE: hdf5plugin have to be loaded before
- import fabio
- if fabio is not None:
- extensions["NeXus layout from EDF files"] = "*.edf"
- extensions["NeXus layout from TIFF image files"] = "*.tif *.tiff"
- extensions["NeXus layout from CBF files"] = "*.cbf"
- extensions["NeXus layout from MarCCD image files"] = "*.mccd"
-
- all_supported_extensions = set()
- for name, exts in extensions.items():
- exts = exts.split(" ")
- all_supported_extensions.update(exts)
- all_supported_extensions = sorted(list(all_supported_extensions))
-
- filters = []
- filters.append("All supported files (%s)" % " ".join(all_supported_extensions))
- for name, extension in extensions.items():
- filters.append("%s (%s)" % (name, extension))
- filters.append("All files (*)")
-
- dialog.setNameFilters(filters)
- dialog.setFileMode(qt.QFileDialog.ExistingFiles)
- return dialog
-
- def about(self):
- from . import qtutils
- qtutils.About.about(self, "Silx viewer")
-
- def appendFile(self, filename):
- self.__treeview.findHdf5TreeModel().appendFile(filename)
-
- def displayData(self):
- """Called to update the dataviewer with the selected data.
- """
- selected = list(self.__treeview.selectedH5Nodes(ignoreBrokenLinks=False))
- if len(selected) == 1:
- # Update the viewer for a single selection
- data = selected[0]
- self.__dataViewer.setData(data)
-
- def useAsyncLoad(self, useAsync):
- self.__asyncload = useAsync
-
- def closeAndSyncCustomContextMenu(self, event):
- """Called to populate the context menu
-
- :param silx.gui.hdf5.Hdf5ContextMenuEvent event: Event
- containing expected information to populate the context menu
- """
- selectedObjects = event.source().selectedH5Nodes(ignoreBrokenLinks=False)
- menu = event.menu()
-
- if not menu.isEmpty():
- menu.addSeparator()
-
- # Import it here to be sure to use the right logging level
- import h5py
- for obj in selectedObjects:
- if obj.ntype is h5py.File:
- action = qt.QAction("Remove %s" % obj.local_filename, event.source())
- action.triggered.connect(lambda: self.__treeview.findHdf5TreeModel().removeH5pyObject(obj.h5py_object))
- menu.addAction(action)
- action = qt.QAction("Synchronize %s" % obj.local_filename, event.source())
- action.triggered.connect(lambda: self.__treeview.findHdf5TreeModel().synchronizeH5pyObject(obj.h5py_object))
- menu.addAction(action)
-
-
-def main(argv):
- """
- Main function to launch the viewer as an application
-
- :param argv: Command line arguments
- :returns: exit status
- """
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument(
- 'files',
- nargs=argparse.ZERO_OR_MORE,
- help='Data file to show (h5 file, edf files, spec files)')
- parser.add_argument(
- '--debug',
- dest="debug",
- action="store_true",
- default=False,
- help='Set logging system in debug mode')
- parser.add_argument(
- '--use-opengl-plot',
- dest="use_opengl_plot",
- action="store_true",
- default=False,
- help='Use OpenGL for plots (instead of matplotlib)')
-
- options = parser.parse_args(argv[1:])
-
- if options.debug:
- logging.root.setLevel(logging.DEBUG)
-
- #
- # Import most of the things here to be sure to use the right logging level
- #
-
- try:
- # it should be loaded before h5py
- import hdf5plugin # noqa
- except ImportError:
- _logger.debug("Backtrace", exc_info=True)
- hdf5plugin = None
-
- try:
- import h5py
- except ImportError:
- _logger.debug("Backtrace", exc_info=True)
- h5py = None
-
- if h5py is None:
- message = "Module 'h5py' is not installed but is mandatory."\
- + " You can install it using \"pip install h5py\"."
- _logger.error(message)
- return -1
-
- if hdf5plugin is None:
- message = "Module 'hdf5plugin' is not installed. It supports some hdf5"\
- + " compressions. You can install it using \"pip install hdf5plugin\"."
- _logger.warning(message)
-
- #
- # Run the application
- #
-
- if options.use_opengl_plot:
- from silx.gui.plot import PlotWidget
- PlotWidget.setDefaultBackend("opengl")
-
- app = qt.QApplication([])
- qt.QLocale.setDefault(qt.QLocale.c())
-
- sys.excepthook = qt.exceptionHandler
- window = Viewer()
- window.setAttribute(qt.Qt.WA_DeleteOnClose, True)
- window.resize(qt.QSize(640, 480))
-
- for filename in options.files:
- try:
- window.appendFile(filename)
- except IOError as e:
- _logger.error(e.args[0])
- _logger.debug("Backtrace", exc_info=True)
-
- window.show()
- result = app.exec_()
- # remove ending warnings relative to QTimer
- app.deleteLater()
- return result
diff --git a/silx/app/qtutils.py b/silx/app/view/About.py
index 4c29c84..07306ef 100644
--- a/silx/app/qtutils.py
+++ b/silx/app/view/About.py
@@ -21,30 +21,14 @@
# THE SOFTWARE.
#
# ############################################################################*/
-"""Qt utils for Silx applications"""
+"""About box for Silx viewer"""
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "22/09/2017"
+__date__ = "05/06/2018"
import sys
-try:
- # it should be loaded before h5py
- import hdf5plugin # noqa
-except ImportError:
- hdf5plugin = None
-
-try:
- import h5py
-except ImportError:
- h5py = None
-
-try:
- import fabio
-except ImportError:
- fabio = None
-
from silx.gui import qt
from silx.gui import icons
@@ -158,9 +142,9 @@ class About(qt.QDialog):
def __formatOptionalLibraries(name, isAvailable):
"""Utils to format availability of features"""
if isAvailable:
- template = '<b>%s</b> is <font color="green">installed</font>'
+ template = '<b>%s</b> is <font color="green">loaded</font>'
else:
- template = '<b>%s</b> is <font color="red">not installed</font>'
+ template = '<b>%s</b> is <font color="red">not loaded</font>'
return template % name
def __updateText(self):
@@ -189,10 +173,15 @@ class About(qt.QDialog):
Copyright (C) <a href="{esrf_url}">European Synchrotron Radiation Facility</a>
</p>
"""
+
+ hdf5pluginLoaded = "hdf5plugin" in sys.modules
+ fabioLoaded = "fabio" in sys.modules
+ h5pyLoaded = "h5py" in sys.modules
+
optional_lib = []
- optional_lib.append(self.__formatOptionalLibraries("FabIO", fabio is not None))
- optional_lib.append(self.__formatOptionalLibraries("H5py", h5py is not None))
- optional_lib.append(self.__formatOptionalLibraries("hdf5plugin", hdf5plugin is not None))
+ optional_lib.append(self.__formatOptionalLibraries("FabIO", fabioLoaded))
+ optional_lib.append(self.__formatOptionalLibraries("H5py", h5pyLoaded))
+ optional_lib.append(self.__formatOptionalLibraries("hdf5plugin", hdf5pluginLoaded))
# Access to the logo in SVG or PNG
logo = icons.getQFile("../logo/silx")
diff --git a/silx/app/view/ApplicationContext.py b/silx/app/view/ApplicationContext.py
new file mode 100644
index 0000000..8693848
--- /dev/null
+++ b/silx/app/view/ApplicationContext.py
@@ -0,0 +1,194 @@
+# coding: utf-8
+# /*##########################################################################
+# Copyright (C) 2016-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+"""Browse a data file with a GUI"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "23/05/2018"
+
+import weakref
+import logging
+
+import silx
+from silx.gui.data.DataViews import DataViewHooks
+from silx.gui.colors import Colormap
+from silx.gui.dialog.ColormapDialog import ColormapDialog
+
+
+_logger = logging.getLogger(__name__)
+
+
+class ApplicationContext(DataViewHooks):
+ """
+ Store the conmtext of the application
+
+ It overwrites the DataViewHooks to custom the use of the DataViewer for
+ the silx view application.
+
+ - Create a single colormap shared with all the views
+ - Create a single colormap dialog shared with all the views
+ """
+
+ def __init__(self, parent, settings=None):
+ self.__parent = weakref.ref(parent)
+ self.__defaultColormap = None
+ self.__defaultColormapDialog = None
+ self.__settings = settings
+ self.__recentFiles = []
+
+ def getSettings(self):
+ """Returns actual application settings.
+
+ :rtype: qt.QSettings
+ """
+ return self.__settings
+
+ def restoreLibrarySettings(self):
+ """Restore the library settings, which must be done early"""
+ settings = self.__settings
+ if settings is None:
+ return
+ settings.beginGroup("library")
+ plotBackend = settings.value("plot.backend", "")
+ plotImageYAxisOrientation = settings.value("plot-image.y-axis-orientation", "")
+ settings.endGroup()
+
+ if plotBackend != "":
+ silx.config.DEFAULT_PLOT_BACKEND = plotBackend
+ if plotImageYAxisOrientation != "":
+ silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = plotImageYAxisOrientation
+
+ def restoreSettings(self):
+ """Restore the settings of all the application"""
+ settings = self.__settings
+ if settings is None:
+ return
+ parent = self.__parent()
+ parent.restoreSettings(settings)
+
+ settings.beginGroup("colormap")
+ byteArray = settings.value("default", None)
+ if byteArray is not None:
+ try:
+ colormap = Colormap()
+ colormap.restoreState(byteArray)
+ self.__defaultColormap = colormap
+ except Exception:
+ _logger.debug("Backtrace", exc_info=True)
+ settings.endGroup()
+
+ self.__recentFiles = []
+ settings.beginGroup("recent-files")
+ for index in range(1, 10 + 1):
+ if not settings.contains("path%d" % index):
+ break
+ filePath = settings.value("path%d" % index)
+ self.__recentFiles.append(filePath)
+ settings.endGroup()
+
+ def saveSettings(self):
+ """Save the settings of all the application"""
+ settings = self.__settings
+ if settings is None:
+ return
+ parent = self.__parent()
+ parent.saveSettings(settings)
+
+ if self.__defaultColormap is not None:
+ settings.beginGroup("colormap")
+ settings.setValue("default", self.__defaultColormap.saveState())
+ settings.endGroup()
+
+ settings.beginGroup("library")
+ settings.setValue("plot.backend", silx.config.DEFAULT_PLOT_BACKEND)
+ settings.setValue("plot-image.y-axis-orientation", silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION)
+ settings.endGroup()
+
+ settings.beginGroup("recent-files")
+ for index in range(0, 11):
+ key = "path%d" % (index + 1)
+ if index < len(self.__recentFiles):
+ filePath = self.__recentFiles[index]
+ settings.setValue(key, filePath)
+ else:
+ settings.remove(key)
+ settings.endGroup()
+
+ def getRecentFiles(self):
+ """Returns the list of recently opened files.
+
+ The list is limited to the last 10 entries. The newest file path is
+ in first.
+
+ :rtype: List[str]
+ """
+ return self.__recentFiles
+
+ def pushRecentFile(self, filePath):
+ """Push a new recent file to the list.
+
+ If the file is duplicated in the list, all duplications are removed
+ before inserting the new filePath.
+
+ If the list becan bigger than 10 items, oldest paths are removed.
+
+ :param filePath: File path to push
+ """
+ # Remove old occurencies
+ self.__recentFiles[:] = (f for f in self.__recentFiles if f != filePath)
+ self.__recentFiles.insert(0, filePath)
+ while len(self.__recentFiles) > 10:
+ self.__recentFiles.pop()
+
+ def clearRencentFiles(self):
+ """Clear the history of the rencent files.
+ """
+ self.__recentFiles[:] = []
+
+ def getColormap(self, view):
+ """Returns a default colormap.
+
+ Override from DataViewHooks
+
+ :rtype: Colormap
+ """
+ if self.__defaultColormap is None:
+ self.__defaultColormap = Colormap(name="viridis")
+ return self.__defaultColormap
+
+ def getColormapDialog(self, view):
+ """Returns a shared color dialog as default for all the views.
+
+ Override from DataViewHooks
+
+ :rtype: ColorDialog
+ """
+ if self.__defaultColormapDialog is None:
+ parent = self.__parent()
+ if parent is None:
+ return None
+ dialog = ColormapDialog(parent=parent)
+ dialog.setModal(False)
+ self.__defaultColormapDialog = dialog
+ return self.__defaultColormapDialog
diff --git a/silx/app/view/CustomNxdataWidget.py b/silx/app/view/CustomNxdataWidget.py
new file mode 100644
index 0000000..02ae6c0
--- /dev/null
+++ b/silx/app/view/CustomNxdataWidget.py
@@ -0,0 +1,1008 @@
+# coding: utf-8
+# /*##########################################################################
+# Copyright (C) 2016-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+
+"""Widget to custom NXdata groups"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "15/06/2018"
+
+import logging
+import numpy
+import weakref
+
+from silx.gui import qt
+from silx.io import commonh5
+import silx.io.nxdata
+from silx.gui.hdf5._utils import Hdf5DatasetMimeData
+from silx.gui.data.TextFormatter import TextFormatter
+from silx.gui.hdf5.Hdf5Formatter import Hdf5Formatter
+from silx.gui import icons
+
+
+_logger = logging.getLogger(__name__)
+_formatter = TextFormatter()
+_hdf5Formatter = Hdf5Formatter(textFormatter=_formatter)
+
+
+class _RowItems(qt.QStandardItem):
+ """Define the list of items used for a specific row."""
+
+ def type(self):
+ return qt.QStandardItem.UserType + 1
+
+ def getRowItems(self):
+ """Returns the list of items used for a specific row.
+
+ The first item should be this class.
+
+ :rtype: List[qt.QStandardItem]
+ """
+ raise NotImplementedError()
+
+
+class _DatasetItemRow(_RowItems):
+ """Define a row which can contain a dataset."""
+
+ def __init__(self, label="", dataset=None):
+ """Constructor"""
+ super(_DatasetItemRow, self).__init__(label)
+ self.setEditable(False)
+ self.setDropEnabled(False)
+ self.setDragEnabled(False)
+
+ self.__name = qt.QStandardItem()
+ self.__name.setEditable(False)
+ self.__name.setDropEnabled(True)
+
+ self.__type = qt.QStandardItem()
+ self.__type.setEditable(False)
+ self.__type.setDropEnabled(False)
+ self.__type.setDragEnabled(False)
+
+ self.__shape = qt.QStandardItem()
+ self.__shape.setEditable(False)
+ self.__shape.setDropEnabled(False)
+ self.__shape.setDragEnabled(False)
+
+ self.setDataset(dataset)
+
+ def getDefaultFormatter(self):
+ """Get the formatter used to display dataset informations.
+
+ :rtype: Hdf5Formatter
+ """
+ return _hdf5Formatter
+
+ def setDataset(self, dataset):
+ """Set the dataset stored in this item.
+
+ :param Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset] dataset:
+ The dataset to store.
+ """
+ self.__dataset = dataset
+ if self.__dataset is not None:
+ name = self.__dataset.name
+
+ if silx.io.is_dataset(dataset):
+ type_ = self.getDefaultFormatter().humanReadableType(dataset)
+ shape = self.getDefaultFormatter().humanReadableShape(dataset)
+
+ if dataset.shape is None:
+ icon_name = "item-none"
+ elif len(dataset.shape) < 4:
+ icon_name = "item-%ddim" % len(dataset.shape)
+ else:
+ icon_name = "item-ndim"
+ icon = icons.getQIcon(icon_name)
+ else:
+ type_ = ""
+ shape = ""
+ icon = qt.QIcon()
+ else:
+ name = ""
+ type_ = ""
+ shape = ""
+ icon = qt.QIcon()
+
+ self.__icon = icon
+ self.__name.setText(name)
+ self.__name.setDragEnabled(self.__dataset is not None)
+ self.__name.setIcon(self.__icon)
+ self.__type.setText(type_)
+ self.__shape.setText(shape)
+
+ parent = self.parent()
+ if parent is not None:
+ self.parent()._datasetUpdated()
+
+ def getDataset(self):
+ """Returns the dataset stored within the item."""
+ return self.__dataset
+
+ def getRowItems(self):
+ """Returns the list of items used for a specific row.
+
+ The first item should be this class.
+
+ :rtype: List[qt.QStandardItem]
+ """
+ return [self, self.__name, self.__type, self.__shape]
+
+
+class _DatasetAxisItemRow(_DatasetItemRow):
+ """Define a row describing an axis."""
+
+ def __init__(self):
+ """Constructor"""
+ super(_DatasetAxisItemRow, self).__init__()
+
+ def setAxisId(self, axisId):
+ """Set the id of the axis (the first axis is 0)
+
+ :param int axisId: Identifier of this axis.
+ """
+ self.__axisId = axisId
+ label = "Axis %d" % (axisId + 1)
+ self.setText(label)
+
+ def getAxisId(self):
+ """Returns the identifier of this axis.
+
+ :rtype: int
+ """
+ return self.__axisId
+
+
+class _NxDataItem(qt.QStandardItem):
+ """
+ Define a custom NXdata.
+ """
+
+ def __init__(self):
+ """Constructor"""
+ qt.QStandardItem.__init__(self)
+ self.__error = None
+ self.__title = None
+ self.__axes = []
+ self.__virtual = None
+
+ item = _DatasetItemRow("Signal", None)
+ self.appendRow(item.getRowItems())
+ self.__signal = item
+
+ self.setEditable(False)
+ self.setDragEnabled(False)
+ self.setDropEnabled(False)
+ self.__setError(None)
+
+ def getRowItems(self):
+ """Returns the list of items used for a specific row.
+
+ The first item should be this class.
+
+ :rtype: List[qt.QStandardItem]
+ """
+ row = [self]
+ for _ in range(3):
+ item = qt.QStandardItem("")
+ item.setEditable(False)
+ item.setDragEnabled(False)
+ item.setDropEnabled(False)
+ row.append(item)
+ return row
+
+ def _datasetUpdated(self):
+ """Called when the NXdata contained of the item have changed.
+
+ It invalidates the NXdata stored and send an event `sigNxdataUpdated`.
+ """
+ self.__virtual = None
+ self.__setError(None)
+ model = self.model()
+ if model is not None:
+ model.sigNxdataUpdated.emit(self.index())
+
+ def createVirtualGroup(self):
+ """Returns a new virtual Group using a NeXus NXdata structure to store
+ data
+
+ :rtype: silx.io.commonh5.Group
+ """
+ name = ""
+ if self.__title is not None:
+ name = self.__title
+ virtual = commonh5.Group(name)
+ virtual.attrs["NX_class"] = "NXdata"
+
+ if self.__title is not None:
+ virtual.attrs["title"] = self.__title
+
+ if self.__signal is not None:
+ signal = self.__signal.getDataset()
+ if signal is not None:
+ # Could be done using a link instead of a copy
+ node = commonh5.DatasetProxy("signal", target=signal)
+ virtual.attrs["signal"] = "signal"
+ virtual.add_node(node)
+
+ axesAttr = []
+ for i, axis in enumerate(self.__axes):
+ if axis is None:
+ name = "."
+ else:
+ axis = axis.getDataset()
+ if axis is None:
+ name = "."
+ else:
+ name = "axis%d" % i
+ node = commonh5.DatasetProxy(name, target=axis)
+ virtual.add_node(node)
+ axesAttr.append(name)
+
+ if axesAttr != []:
+ virtual.attrs["axes"] = numpy.array(axesAttr)
+
+ validator = silx.io.nxdata.NXdata(virtual)
+ if not validator.is_valid:
+ message = "<html>"
+ message += "This NXdata is not consistant"
+ message += "<ul>"
+ for issue in validator.issues:
+ message += "<li>%s</li>" % issue
+ message += "</ul>"
+ message += "</html>"
+ self.__setError(message)
+ else:
+ self.__setError(None)
+ return virtual
+
+ def isValid(self):
+ """Returns true if the stored NXdata is valid
+
+ :rtype: bool
+ """
+ return self.__error is None
+
+ def getVirtualGroup(self):
+ """Returns a cached virtual Group using a NeXus NXdata structure to
+ store data.
+
+ If the stored NXdata was invalidated, :meth:`createVirtualGroup` is
+ internally called to update the cache.
+
+ :rtype: silx.io.commonh5.Group
+ """
+ if self.__virtual is None:
+ self.__virtual = self.createVirtualGroup()
+ return self.__virtual
+
+ def getTitle(self):
+ """Returns the title of the NXdata
+
+ :rtype: str
+ """
+ return self.text()
+
+ def setTitle(self, title):
+ """Set the title of the NXdata
+
+ :param str title: The title of this NXdata
+ """
+ self.setText(title)
+
+ def __setError(self, error):
+ """Set the error message in case of the current state of the stored
+ NXdata is not valid.
+
+ :param str error: Message to display
+ """
+ self.__error = error
+ style = qt.QApplication.style()
+ if error is None:
+ message = ""
+ icon = style.standardIcon(qt.QStyle.SP_DirLinkIcon)
+ else:
+ message = error
+ icon = style.standardIcon(qt.QStyle.SP_MessageBoxCritical)
+ self.setIcon(icon)
+ self.setToolTip(message)
+
+ def getError(self):
+ """Returns the error message in case the NXdata is not valid.
+
+ :rtype: str"""
+ return self.__error
+
+ def setSignalDataset(self, dataset):
+ """Set the dataset to use as signal with this NXdata.
+
+ :param Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset] dataset:
+ The dataset to use as signal.
+ """
+
+ self.__signal.setDataset(dataset)
+ self._datasetUpdated()
+
+ def getSignalDataset(self):
+ """Returns the dataset used as signal.
+
+ :rtype: Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset]
+ """
+ return self.__signal.getDataset()
+
+ def setAxesDatasets(self, datasets):
+ """Set all the available dataset used as axes.
+
+ Axes will be created or removed from the GUI in order to provide the
+ same amount of requested axes.
+
+ A `None` element is an axes with no dataset.
+
+ :param List[Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset,None]] datasets:
+ List of dataset to use as axes.
+ """
+ for i, dataset in enumerate(datasets):
+ if i < len(self.__axes):
+ mustAppend = False
+ item = self.__axes[i]
+ else:
+ mustAppend = True
+ item = _DatasetAxisItemRow()
+ item.setAxisId(i)
+ item.setDataset(dataset)
+ if mustAppend:
+ self.__axes.append(item)
+ self.appendRow(item.getRowItems())
+
+ # Clean up extra axis
+ for i in range(len(datasets), len(self.__axes)):
+ item = self.__axes.pop(len(datasets))
+ self.removeRow(item.row())
+
+ self._datasetUpdated()
+
+ def getAxesDatasets(self):
+ """Returns available axes as dataset.
+
+ A `None` element is an axes with no dataset.
+
+ :rtype: List[Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset,None]]
+ """
+ datasets = []
+ for axis in self.__axes:
+ datasets.append(axis.getDataset())
+ return datasets
+
+
+class _Model(qt.QStandardItemModel):
+ """Model storing a list of custom NXdata items.
+
+ Supports drag and drop of datasets.
+ """
+
+ sigNxdataUpdated = qt.Signal(qt.QModelIndex)
+ """Emitted when stored NXdata was edited"""
+
+ def __init__(self, parent=None):
+ """Constructor"""
+ qt.QStandardItemModel.__init__(self, parent)
+ root = self.invisibleRootItem()
+ root.setDropEnabled(True)
+ root.setDragEnabled(False)
+
+ def supportedDropActions(self):
+ """Inherited method to redefine supported drop actions."""
+ return qt.Qt.CopyAction | qt.Qt.MoveAction
+
+ def mimeTypes(self):
+ """Inherited method to redefine draggable mime types."""
+ return [Hdf5DatasetMimeData.MIME_TYPE]
+
+ def mimeData(self, indexes):
+ """
+ Returns an object that contains serialized items of data corresponding
+ to the list of indexes specified.
+
+ :param List[qt.QModelIndex] indexes: List of indexes
+ :rtype: qt.QMimeData
+ """
+ if len(indexes) > 1:
+ return None
+ if len(indexes) == 0:
+ return None
+
+ qindex = indexes[0]
+ qindex = self.index(qindex.row(), 0, parent=qindex.parent())
+ item = self.itemFromIndex(qindex)
+ if isinstance(item, _DatasetItemRow):
+ dataset = item.getDataset()
+ if dataset is None:
+ return None
+ else:
+ mimeData = Hdf5DatasetMimeData(dataset=item.getDataset())
+ else:
+ mimeData = None
+ return mimeData
+
+ def dropMimeData(self, mimedata, action, row, column, parentIndex):
+ """Inherited method to handle a drop operation to this model."""
+ if action == qt.Qt.IgnoreAction:
+ return True
+
+ if mimedata.hasFormat(Hdf5DatasetMimeData.MIME_TYPE):
+ if row != -1 or column != -1:
+ # It is not a drop on a specific item
+ return False
+ item = self.itemFromIndex(parentIndex)
+ if item is None or item is self.invisibleRootItem():
+ # Drop at the end
+ dataset = mimedata.dataset()
+ if silx.io.is_dataset(dataset):
+ self.createFromSignal(dataset)
+ elif silx.io.is_group(dataset):
+ nxdata = dataset
+ try:
+ self.createFromNxdata(nxdata)
+ except ValueError:
+ _logger.error("Error while dropping a group as an NXdata")
+ _logger.debug("Backtrace", exc_info=True)
+ return False
+ else:
+ _logger.error("Dropping a wrong object")
+ return False
+ else:
+ item = item.parent().child(item.row(), 0)
+ if not isinstance(item, _DatasetItemRow):
+ # Dropped at a bad place
+ return False
+ dataset = mimedata.dataset()
+ if silx.io.is_dataset(dataset):
+ item.setDataset(dataset)
+ else:
+ _logger.error("Dropping a wrong object")
+ return False
+ return True
+
+ return False
+
+ def __getNxdataByTitle(self, title):
+ """Returns an NXdata item by its title, else None.
+
+ :rtype: Union[_NxDataItem,None]
+ """
+ for row in range(self.rowCount()):
+ qindex = self.index(row, 0)
+ item = self.itemFromIndex(qindex)
+ if item.getTitle() == title:
+ return item
+ return None
+
+ def findFreeNxdataTitle(self):
+ """Returns an NXdata title which is not yet used.
+
+ :rtype: str
+ """
+ for i in range(self.rowCount() + 1):
+ name = "NXData #%d" % (i + 1)
+ group = self.__getNxdataByTitle(name)
+ if group is None:
+ break
+ return name
+
+ def createNewNxdata(self, name=None):
+ """Create a new NXdata item.
+
+ :param Union[str,None] name: A title for the new NXdata
+ """
+ item = _NxDataItem()
+ if name is None:
+ name = self.findFreeNxdataTitle()
+ item.setTitle(name)
+ self.appendRow(item.getRowItems())
+
+ def createFromSignal(self, dataset):
+ """Create a new NXdata item from a signal dataset.
+
+ This signal will also define an amount of axes according to its number
+ of dimensions.
+
+ :param Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset] dataset:
+ A dataset uses as signal.
+ """
+
+ item = _NxDataItem()
+ name = self.findFreeNxdataTitle()
+ item.setTitle(name)
+ item.setSignalDataset(dataset)
+ item.setAxesDatasets([None] * len(dataset.shape))
+ self.appendRow(item.getRowItems())
+
+ def createFromNxdata(self, nxdata):
+ """Create a new custom NXdata item from an existing NXdata group.
+
+ If the NXdata is not valid, nothing is created, and an exception is
+ returned.
+
+ :param Union[h5py.Group,silx.io.commonh5.Group] nxdata: An h5py group
+ following the NXData specification.
+ :raise ValueError:If `nxdata` is not valid.
+ """
+ validator = silx.io.nxdata.NXdata(nxdata)
+ if validator.is_valid:
+ item = _NxDataItem()
+ title = validator.title
+ if title in [None or ""]:
+ title = self.findFreeNxdataTitle()
+ item.setTitle(title)
+ item.setSignalDataset(validator.signal)
+ item.setAxesDatasets(validator.axes)
+ self.appendRow(item.getRowItems())
+ else:
+ raise ValueError("Not a valid NXdata")
+
+ def removeNxdataItem(self, item):
+ """Remove an NXdata item from this model.
+
+ :param _NxDataItem item: An item
+ """
+ if isinstance(item, _NxDataItem):
+ parent = item.parent()
+ assert(parent is None)
+ model = item.model()
+ model.removeRow(item.row())
+ else:
+ _logger.error("Unexpected item")
+
+ def appendAxisToNxdataItem(self, item):
+ """Append a new axes to this item (or the NXdata item own by this item).
+
+ :param Union[_NxDataItem,qt.QStandardItem] item: An item
+ """
+ if item is not None and not isinstance(item, _NxDataItem):
+ item = item.parent()
+ nxdataItem = item
+ if isinstance(item, _NxDataItem):
+ datasets = nxdataItem.getAxesDatasets()
+ datasets.append(None)
+ nxdataItem.setAxesDatasets(datasets)
+ else:
+ _logger.error("Unexpected item")
+
+ def removeAxisItem(self, item):
+ """Remove an axis item from this model.
+
+ :param _DatasetAxisItemRow item: An axis item
+ """
+ if isinstance(item, _DatasetAxisItemRow):
+ axisId = item.getAxisId()
+ nxdataItem = item.parent()
+ datasets = nxdataItem.getAxesDatasets()
+ del datasets[axisId]
+ nxdataItem.setAxesDatasets(datasets)
+ else:
+ _logger.error("Unexpected item")
+
+
+class CustomNxDataToolBar(qt.QToolBar):
+ """A specialised toolbar to manage custom NXdata model and items."""
+
+ def __init__(self, parent=None):
+ """Constructor"""
+ super(CustomNxDataToolBar, self).__init__(parent=parent)
+ self.__nxdataWidget = None
+ self.__initContent()
+ # Initialize action state
+ self.__currentSelectionChanged(qt.QModelIndex(), qt.QModelIndex())
+
+ def __initContent(self):
+ """Create all expected actions and set the content of this toolbar."""
+ action = qt.QAction("Create a new custom NXdata", self)
+ action.setIcon(icons.getQIcon("nxdata-create"))
+ action.triggered.connect(self.__createNewNxdata)
+ self.addAction(action)
+ self.__addNxDataAction = action
+
+ action = qt.QAction("Remove the selected NXdata", self)
+ action.setIcon(icons.getQIcon("nxdata-remove"))
+ action.triggered.connect(self.__removeSelectedNxdata)
+ self.addAction(action)
+ self.__removeNxDataAction = action
+
+ self.addSeparator()
+
+ action = qt.QAction("Create a new axis to the selected NXdata", self)
+ action.setIcon(icons.getQIcon("nxdata-axis-add"))
+ action.triggered.connect(self.__appendNewAxisToSelectedNxdata)
+ self.addAction(action)
+ self.__addNxDataAxisAction = action
+
+ action = qt.QAction("Remove the selected NXdata axis", self)
+ action.setIcon(icons.getQIcon("nxdata-axis-remove"))
+ action.triggered.connect(self.__removeSelectedAxis)
+ self.addAction(action)
+ self.__removeNxDataAxisAction = action
+
+ def __getSelectedItem(self):
+ """Get the selected item from the linked CustomNxdataWidget.
+
+ :rtype: qt.QStandardItem
+ """
+ selectionModel = self.__nxdataWidget.selectionModel()
+ index = selectionModel.currentIndex()
+ if not index.isValid():
+ return
+ model = self.__nxdataWidget.model()
+ index = model.index(index.row(), 0, index.parent())
+ item = model.itemFromIndex(index)
+ return item
+
+ def __createNewNxdata(self):
+ """Create a new NXdata item to the linked CustomNxdataWidget."""
+ if self.__nxdataWidget is None:
+ return
+ model = self.__nxdataWidget.model()
+ model.createNewNxdata()
+
+ def __removeSelectedNxdata(self):
+ """Remove the NXdata item currently selected in the linked
+ CustomNxdataWidget."""
+ if self.__nxdataWidget is None:
+ return
+ model = self.__nxdataWidget.model()
+ item = self.__getSelectedItem()
+ model.removeNxdataItem(item)
+
+ def __appendNewAxisToSelectedNxdata(self):
+ """Append a new axis to the NXdata item currently selected in the
+ linked CustomNxdataWidget."""
+ if self.__nxdataWidget is None:
+ return
+ model = self.__nxdataWidget.model()
+ item = self.__getSelectedItem()
+ model.appendAxisToNxdataItem(item)
+
+ def __removeSelectedAxis(self):
+ """Remove the axis item currently selected in the linked
+ CustomNxdataWidget."""
+ if self.__nxdataWidget is None:
+ return
+ model = self.__nxdataWidget.model()
+ item = self.__getSelectedItem()
+ model.removeAxisItem(item)
+
+ def setCustomNxDataWidget(self, widget):
+ """Set the linked CustomNxdataWidget to this toolbar."""
+ assert(isinstance(widget, CustomNxdataWidget))
+ if self.__nxdataWidget is not None:
+ selectionModel = self.__nxdataWidget.selectionModel()
+ selectionModel.currentChanged.disconnect(self.__currentSelectionChanged)
+ self.__nxdataWidget = widget
+ if self.__nxdataWidget is not None:
+ selectionModel = self.__nxdataWidget.selectionModel()
+ selectionModel.currentChanged.connect(self.__currentSelectionChanged)
+
+ def __currentSelectionChanged(self, current, previous):
+ """Update the actions according to the linked CustomNxdataWidget
+ item selection"""
+ if not current.isValid():
+ item = None
+ else:
+ model = self.__nxdataWidget.model()
+ index = model.index(current.row(), 0, current.parent())
+ item = model.itemFromIndex(index)
+ self.__removeNxDataAction.setEnabled(isinstance(item, _NxDataItem))
+ self.__removeNxDataAxisAction.setEnabled(isinstance(item, _DatasetAxisItemRow))
+ self.__addNxDataAxisAction.setEnabled(isinstance(item, _NxDataItem) or isinstance(item, _DatasetItemRow))
+
+
+class _HashDropZones(qt.QStyledItemDelegate):
+ """Delegate item displaying a drop zone when the item do not contains
+ dataset."""
+
+ def __init__(self, parent=None):
+ """Constructor"""
+ super(_HashDropZones, self).__init__(parent)
+ pen = qt.QPen()
+ pen.setColor(qt.QColor("#D0D0D0"))
+ pen.setStyle(qt.Qt.DotLine)
+ pen.setWidth(2)
+ self.__dropPen = pen
+
+ def paint(self, painter, option, index):
+ """
+ Paint the item
+
+ :param qt.QPainter painter: A painter
+ :param qt.QStyleOptionViewItem option: Options of the item to paint
+ :param qt.QModelIndex index: Index of the item to paint
+ """
+ displayDropZone = False
+ if index.isValid():
+ model = index.model()
+ rowIndex = model.index(index.row(), 0, index.parent())
+ rowItem = model.itemFromIndex(rowIndex)
+ if isinstance(rowItem, _DatasetItemRow):
+ displayDropZone = rowItem.getDataset() is None
+
+ if displayDropZone:
+ painter.save()
+
+ # Draw background if selected
+ if option.state & qt.QStyle.State_Selected:
+ colorGroup = qt.QPalette.Inactive
+ if option.state & qt.QStyle.State_Active:
+ colorGroup = qt.QPalette.Active
+ if not option.state & qt.QStyle.State_Enabled:
+ colorGroup = qt.QPalette.Disabled
+ brush = option.palette.brush(colorGroup, qt.QPalette.Highlight)
+ painter.fillRect(option.rect, brush)
+
+ painter.setPen(self.__dropPen)
+ painter.drawRect(option.rect.adjusted(3, 3, -3, -3))
+ painter.restore()
+ else:
+ qt.QStyledItemDelegate.paint(self, painter, option, index)
+
+
+class CustomNxdataWidget(qt.QTreeView):
+ """Widget providing a table displaying and allowing to custom virtual
+ NXdata."""
+
+ sigNxdataItemUpdated = qt.Signal(qt.QStandardItem)
+ """Emitted when the NXdata from an NXdata item was edited"""
+
+ sigNxdataItemRemoved = qt.Signal(qt.QStandardItem)
+ """Emitted when an NXdata item was removed"""
+
+ def __init__(self, parent=None):
+ """Constructor"""
+ qt.QTreeView.__init__(self, parent=None)
+ self.__model = _Model(self)
+ self.__model.setColumnCount(4)
+ self.__model.setHorizontalHeaderLabels(["Name", "Dataset", "Type", "Shape"])
+ self.setModel(self.__model)
+
+ self.setItemDelegateForColumn(1, _HashDropZones(self))
+
+ self.__model.sigNxdataUpdated.connect(self.__nxdataUpdate)
+ self.__model.rowsAboutToBeRemoved.connect(self.__rowsAboutToBeRemoved)
+ self.__model.rowsAboutToBeInserted.connect(self.__rowsAboutToBeInserted)
+
+ header = self.header()
+ if qt.qVersion() < "5.0":
+ setResizeMode = header.setResizeMode
+ else:
+ setResizeMode = header.setSectionResizeMode
+ setResizeMode(0, qt.QHeaderView.ResizeToContents)
+ setResizeMode(1, qt.QHeaderView.Stretch)
+ setResizeMode(2, qt.QHeaderView.ResizeToContents)
+ setResizeMode(3, qt.QHeaderView.ResizeToContents)
+
+ self.setSelectionMode(qt.QAbstractItemView.SingleSelection)
+ self.setDropIndicatorShown(True)
+ self.setDragDropOverwriteMode(True)
+ self.setDragEnabled(True)
+ self.viewport().setAcceptDrops(True)
+
+ self.setContextMenuPolicy(qt.Qt.CustomContextMenu)
+ self.customContextMenuRequested[qt.QPoint].connect(self.__executeContextMenu)
+
+ def __rowsAboutToBeInserted(self, parentIndex, start, end):
+ if qt.qVersion()[0:2] == "5.":
+ # FIXME: workaround for https://github.com/silx-kit/silx/issues/1919
+ # Uses of ResizeToContents looks to break nice update of cells with Qt5
+ # This patch make the view blinking
+ self.repaint()
+
+ def __rowsAboutToBeRemoved(self, parentIndex, start, end):
+ """Called when an item was removed from the model."""
+ items = []
+ model = self.model()
+ for index in range(start, end):
+ qindex = model.index(index, 0, parent=parentIndex)
+ item = self.__model.itemFromIndex(qindex)
+ if isinstance(item, _NxDataItem):
+ items.append(item)
+ for item in items:
+ self.sigNxdataItemRemoved.emit(item)
+
+ if qt.qVersion()[0:2] == "5.":
+ # FIXME: workaround for https://github.com/silx-kit/silx/issues/1919
+ # Uses of ResizeToContents looks to break nice update of cells with Qt5
+ # This patch make the view blinking
+ self.repaint()
+
+ def __nxdataUpdate(self, index):
+ """Called when a virtual NXdata was updated from the model."""
+ model = self.model()
+ item = model.itemFromIndex(index)
+ self.sigNxdataItemUpdated.emit(item)
+
+ def createDefaultContextMenu(self, index):
+ """Create a default context menu at this position.
+
+ :param qt.QModelIndex index: Index of the item
+ """
+ index = self.__model.index(index.row(), 0, parent=index.parent())
+ item = self.__model.itemFromIndex(index)
+
+ menu = qt.QMenu()
+
+ weakself = weakref.proxy(self)
+
+ if isinstance(item, _NxDataItem):
+ action = qt.QAction("Add a new axis", menu)
+ action.triggered.connect(lambda: weakself.model().appendAxisToNxdataItem(item))
+ action.setIcon(icons.getQIcon("nxdata-axis-add"))
+ action.setIconVisibleInMenu(True)
+ menu.addAction(action)
+ menu.addSeparator()
+ action = qt.QAction("Remove this NXdata", menu)
+ action.triggered.connect(lambda: weakself.model().removeNxdataItem(item))
+ action.setIcon(icons.getQIcon("remove"))
+ action.setIconVisibleInMenu(True)
+ menu.addAction(action)
+ else:
+ if isinstance(item, _DatasetItemRow):
+ if item.getDataset() is not None:
+ action = qt.QAction("Remove this dataset", menu)
+ action.triggered.connect(lambda: item.setDataset(None))
+ menu.addAction(action)
+
+ if isinstance(item, _DatasetAxisItemRow):
+ menu.addSeparator()
+ action = qt.QAction("Remove this axis", menu)
+ action.triggered.connect(lambda: weakself.model().removeAxisItem(item))
+ action.setIcon(icons.getQIcon("remove"))
+ action.setIconVisibleInMenu(True)
+ menu.addAction(action)
+
+ return menu
+
+ def __executeContextMenu(self, point):
+ """Execute the context menu at this position."""
+ index = self.indexAt(point)
+ menu = self.createDefaultContextMenu(index)
+ if menu is None or menu.isEmpty():
+ return
+ menu.exec_(qt.QCursor.pos())
+
+ def removeDatasetsFrom(self, root):
+ """
+ Remove all datasets provided by this root
+
+ :param root: The root file of datasets to remove
+ """
+ for row in range(self.__model.rowCount()):
+ qindex = self.__model.index(row, 0)
+ item = self.model().itemFromIndex(qindex)
+
+ edited = False
+ datasets = item.getAxesDatasets()
+ for i, dataset in enumerate(datasets):
+ if dataset is not None:
+ # That's an approximation, IS can't be used as h5py generates
+ # To objects for each requests to a node
+ if dataset.file.filename == root.file.filename:
+ datasets[i] = None
+ edited = True
+ if edited:
+ item.setAxesDatasets(datasets)
+
+ dataset = item.getSignalDataset()
+ if dataset is not None:
+ # That's an approximation, IS can't be used as h5py generates
+ # To objects for each requests to a node
+ if dataset.file.filename == root.file.filename:
+ item.setSignalDataset(None)
+
+ def replaceDatasetsFrom(self, removedRoot, loadedRoot):
+ """
+ Replace any dataset from any NXdata items using the same dataset name
+ from another root.
+
+ Usually used when a file was synchronized.
+
+ :param removedRoot: The h5py root file which is replaced
+ (which have to be removed)
+ :param loadedRoot: The new h5py root file which have to be used
+ instread.
+ """
+ for row in range(self.__model.rowCount()):
+ qindex = self.__model.index(row, 0)
+ item = self.model().itemFromIndex(qindex)
+
+ edited = False
+ datasets = item.getAxesDatasets()
+ for i, dataset in enumerate(datasets):
+ newDataset = self.__replaceDatasetRoot(dataset, removedRoot, loadedRoot)
+ if dataset is not newDataset:
+ datasets[i] = newDataset
+ edited = True
+ if edited:
+ item.setAxesDatasets(datasets)
+
+ dataset = item.getSignalDataset()
+ newDataset = self.__replaceDatasetRoot(dataset, removedRoot, loadedRoot)
+ if dataset is not newDataset:
+ item.setSignalDataset(newDataset)
+
+ def __replaceDatasetRoot(self, dataset, fromRoot, toRoot):
+ """
+ Replace the dataset by the same dataset name from another root.
+ """
+ if dataset is None:
+ return None
+
+ if dataset.file is None:
+ # Not from the expected root
+ return dataset
+
+ # That's an approximation, IS can't be used as h5py generates
+ # To objects for each requests to a node
+ if dataset.file.filename == fromRoot.file.filename:
+ # Try to find the same dataset name
+ try:
+ return toRoot[dataset.name]
+ except Exception:
+ _logger.debug("Backtrace", exc_info=True)
+ return None
+ else:
+ # Not from the expected root
+ return dataset
+
+ def selectedItems(self):
+ """Returns the list of selected items containing NXdata
+
+ :rtype: List[qt.QStandardItem]
+ """
+ result = []
+ for qindex in self.selectedIndexes():
+ if qindex.column() != 0:
+ continue
+ if not qindex.isValid():
+ continue
+ item = self.__model.itemFromIndex(qindex)
+ if not isinstance(item, _NxDataItem):
+ continue
+ result.append(item)
+ return result
+
+ def selectedNxdata(self):
+ """Returns the list of selected NXdata
+
+ :rtype: List[silx.io.commonh5.Group]
+ """
+ result = []
+ for qindex in self.selectedIndexes():
+ if qindex.column() != 0:
+ continue
+ if not qindex.isValid():
+ continue
+ item = self.__model.itemFromIndex(qindex)
+ if not isinstance(item, _NxDataItem):
+ continue
+ result.append(item.getVirtualGroup())
+ return result
diff --git a/silx/app/view/DataPanel.py b/silx/app/view/DataPanel.py
new file mode 100644
index 0000000..0653f74
--- /dev/null
+++ b/silx/app/view/DataPanel.py
@@ -0,0 +1,171 @@
+# coding: utf-8
+# /*##########################################################################
+# Copyright (C) 2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+"""Browse a data file with a GUI"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "06/06/2018"
+
+import logging
+
+from silx.gui import qt
+from silx.gui.data.DataViewerFrame import DataViewerFrame
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _HeaderLabel(qt.QLabel):
+
+ def __init__(self, parent=None):
+ qt.QLabel.__init__(self, parent=parent)
+ self.setFrameShape(qt.QFrame.StyledPanel)
+
+ def sizeHint(self):
+ return qt.QSize(10, 30)
+
+ def paintEvent(self, event):
+ painter = qt.QPainter(self)
+
+ opt = qt.QStyleOptionHeader()
+ opt.orientation = qt.Qt.Horizontal
+ opt.text = self.text()
+ opt.textAlignment = self.alignment()
+ opt.direction = self.layoutDirection()
+ opt.fontMetrics = self.fontMetrics()
+ opt.palette = self.palette()
+ opt.state = qt.QStyle.State_Active
+ opt.position = qt.QStyleOptionHeader.Beginning
+ style = self.style()
+
+ # Background
+ margin = -1
+ opt.rect = self.rect().adjusted(margin, margin, -margin, -margin)
+ style.drawControl(qt.QStyle.CE_HeaderSection, opt, painter, None)
+
+ # Frame border and text
+ super(_HeaderLabel, self).paintEvent(event)
+
+
+class DataPanel(qt.QWidget):
+
+ def __init__(self, parent=None, context=None):
+ qt.QWidget.__init__(self, parent=parent)
+
+ self.__customNxdataItem = None
+
+ self.__dataTitle = _HeaderLabel(self)
+ self.__dataTitle.setVisible(False)
+
+ self.__dataViewer = DataViewerFrame(self)
+ self.__dataViewer.setGlobalHooks(context)
+
+ layout = qt.QVBoxLayout(self)
+ layout.setSpacing(0)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self.__dataTitle)
+ layout.addWidget(self.__dataViewer)
+
+ def getData(self):
+ return self.__dataViewer.data()
+
+ def getCustomNxdataItem(self):
+ return self.__customNxdataItem
+
+ def setData(self, data):
+ self.__customNxdataItem = None
+ self.__dataViewer.setData(data)
+ self.__dataTitle.setVisible(data is not None)
+ if data is not None:
+ self.__dataTitle.setVisible(True)
+ if hasattr(data, "name"):
+ if hasattr(data, "file"):
+ label = str(data.file.filename)
+ label += "::"
+ else:
+ label = ""
+ label += data.name
+ else:
+ label = ""
+ self.__dataTitle.setText(label)
+
+ def setCustomDataItem(self, item):
+ self.__customNxdataItem = item
+ if item is not None:
+ data = item.getVirtualGroup()
+ else:
+ data = None
+ self.__dataViewer.setData(data)
+ self.__dataTitle.setVisible(item is not None)
+ if item is not None:
+ text = item.text()
+ self.__dataTitle.setText(text)
+
+ def removeDatasetsFrom(self, root):
+ """
+ Remove all datasets provided by this root
+
+ .. note:: This function do not update data stored inside
+ customNxdataItem cause in the silx-view context this item is
+ already updated on his own.
+
+ :param root: The root file of datasets to remove
+ """
+ data = self.__dataViewer.data()
+ if data is not None:
+ if data.file is not None:
+ # That's an approximation, IS can't be used as h5py generates
+ # To objects for each requests to a node
+ if data.file.filename == root.file.filename:
+ self.__dataViewer.setData(None)
+
+ def replaceDatasetsFrom(self, removedH5, loadedH5):
+ """
+ Replace any dataset from any NXdata items using the same dataset name
+ from another root.
+
+ Usually used when a file was synchronized.
+
+ .. note:: This function do not update data stored inside
+ customNxdataItem cause in the silx-view context this item is
+ already updated on his own.
+
+ :param removedRoot: The h5py root file which is replaced
+ (which have to be removed)
+ :param loadedRoot: The new h5py root file which have to be used
+ instread.
+ """
+
+ data = self.__dataViewer.data()
+ if data is not None:
+ if data.file is not None:
+ if data.file.filename == removedH5.file.filename:
+ # Try to synchonize the viewed data
+ try:
+ # TODO: It have to update the data without changing the
+ # view which is not so easy
+ newData = loadedH5[data.name]
+ self.__dataViewer.setData(newData)
+ except Exception:
+ _logger.debug("Backtrace", exc_info=True)
diff --git a/silx/app/view/Viewer.py b/silx/app/view/Viewer.py
new file mode 100644
index 0000000..8f5db60
--- /dev/null
+++ b/silx/app/view/Viewer.py
@@ -0,0 +1,686 @@
+# coding: utf-8
+# /*##########################################################################
+# Copyright (C) 2016-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+"""Browse a data file with a GUI"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "25/06/2018"
+
+
+import os
+import collections
+import logging
+import functools
+
+import silx.io.nxdata
+from silx.gui import qt
+from silx.gui import icons
+import silx.gui.hdf5
+from .ApplicationContext import ApplicationContext
+from .CustomNxdataWidget import CustomNxdataWidget
+from .CustomNxdataWidget import CustomNxDataToolBar
+from . import utils
+from .DataPanel import DataPanel
+
+
+_logger = logging.getLogger(__name__)
+
+
+class Viewer(qt.QMainWindow):
+ """
+ This window allows to browse a data file like images or HDF5 and it's
+ content.
+ """
+
+ def __init__(self, parent=None, settings=None):
+ """
+ Constructor
+ """
+
+ qt.QMainWindow.__init__(self, parent)
+ self.setWindowTitle("Silx viewer")
+
+ self.__context = ApplicationContext(self, settings)
+ self.__context.restoreLibrarySettings()
+
+ self.__dialogState = None
+ self.__customNxDataItem = None
+ self.__treeview = silx.gui.hdf5.Hdf5TreeView(self)
+ self.__treeview.setExpandsOnDoubleClick(False)
+ """Silx HDF5 TreeView"""
+
+ rightPanel = qt.QSplitter(self)
+ rightPanel.setOrientation(qt.Qt.Vertical)
+ self.__splitter2 = rightPanel
+
+ self.__treeWindow = self.__createTreeWindow(self.__treeview)
+
+ # Custom the model to be able to manage the life cycle of the files
+ treeModel = silx.gui.hdf5.Hdf5TreeModel(self.__treeview, ownFiles=False)
+ treeModel.sigH5pyObjectLoaded.connect(self.__h5FileLoaded)
+ treeModel.sigH5pyObjectRemoved.connect(self.__h5FileRemoved)
+ treeModel.sigH5pyObjectSynchronized.connect(self.__h5FileSynchonized)
+ treeModel.setDatasetDragEnabled(True)
+ treeModel2 = silx.gui.hdf5.NexusSortFilterProxyModel(self.__treeview)
+ treeModel2.setSourceModel(treeModel)
+ treeModel2.sort(0, qt.Qt.AscendingOrder)
+ treeModel2.setSortCaseSensitivity(qt.Qt.CaseInsensitive)
+
+ self.__treeview.setModel(treeModel2)
+ rightPanel.addWidget(self.__treeWindow)
+
+ self.__customNxdata = CustomNxdataWidget(self)
+ self.__customNxdata.setSelectionBehavior(qt.QAbstractItemView.SelectRows)
+ # optimise the rendering
+ self.__customNxdata.setUniformRowHeights(True)
+ self.__customNxdata.setIconSize(qt.QSize(16, 16))
+ self.__customNxdata.setExpandsOnDoubleClick(False)
+
+ self.__customNxdataWindow = self.__createCustomNxdataWindow(self.__customNxdata)
+ self.__customNxdataWindow.setVisible(False)
+ rightPanel.addWidget(self.__customNxdataWindow)
+
+ rightPanel.setStretchFactor(1, 1)
+ rightPanel.setCollapsible(0, False)
+ rightPanel.setCollapsible(1, False)
+
+ self.__dataPanel = DataPanel(self, self.__context)
+
+ spliter = qt.QSplitter(self)
+ spliter.addWidget(rightPanel)
+ spliter.addWidget(self.__dataPanel)
+ spliter.setStretchFactor(1, 1)
+ self.__splitter = spliter
+
+ main_panel = qt.QWidget(self)
+ layout = qt.QVBoxLayout()
+ layout.addWidget(spliter)
+ layout.setStretchFactor(spliter, 1)
+ main_panel.setLayout(layout)
+
+ self.setCentralWidget(main_panel)
+
+ self.__treeview.activated.connect(self.displaySelectedData)
+ self.__customNxdata.activated.connect(self.displaySelectedCustomData)
+ self.__customNxdata.sigNxdataItemRemoved.connect(self.__customNxdataRemoved)
+ self.__customNxdata.sigNxdataItemUpdated.connect(self.__customNxdataUpdated)
+ self.__treeview.addContextMenuCallback(self.customContextMenu)
+
+ treeModel = self.__treeview.findHdf5TreeModel()
+ columns = list(treeModel.COLUMN_IDS)
+ columns.remove(treeModel.DESCRIPTION_COLUMN)
+ columns.remove(treeModel.NODE_COLUMN)
+ self.__treeview.header().setSections(columns)
+
+ self._iconUpward = icons.getQIcon('plot-yup')
+ self._iconDownward = icons.getQIcon('plot-ydown')
+
+ self.createActions()
+ self.createMenus()
+ self.__context.restoreSettings()
+
+ def __createTreeWindow(self, treeView):
+ toolbar = qt.QToolBar(self)
+ toolbar.setIconSize(qt.QSize(16, 16))
+ toolbar.setStyleSheet("QToolBar { border: 0px }")
+
+ action = qt.QAction(toolbar)
+ action.setIcon(icons.getQIcon("tree-expand-all"))
+ action.setText("Expand all")
+ action.setToolTip("Expand all selected items")
+ action.triggered.connect(self.__expandAllSelected)
+ action.setShortcut(qt.QKeySequence(qt.Qt.ControlModifier + qt.Qt.Key_Plus))
+ toolbar.addAction(action)
+ treeView.addAction(action)
+ self.__expandAllAction = action
+
+ action = qt.QAction(toolbar)
+ action.setIcon(icons.getQIcon("tree-collapse-all"))
+ action.setText("Collapse all")
+ action.setToolTip("Collapse all selected items")
+ action.triggered.connect(self.__collapseAllSelected)
+ action.setShortcut(qt.QKeySequence(qt.Qt.ControlModifier + qt.Qt.Key_Minus))
+ toolbar.addAction(action)
+ treeView.addAction(action)
+ self.__collapseAllAction = action
+
+ widget = qt.QWidget(self)
+ layout = qt.QVBoxLayout(widget)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ layout.addWidget(toolbar)
+ layout.addWidget(treeView)
+ return widget
+
+ def __expandAllSelected(self):
+ """Expand all selected items of the tree.
+
+ The depth is fixed to avoid infinite loop with recurssive links.
+ """
+ qt.QApplication.setOverrideCursor(qt.Qt.WaitCursor)
+
+ selection = self.__treeview.selectionModel()
+ indexes = selection.selectedIndexes()
+ model = self.__treeview.model()
+ while len(indexes) > 0:
+ index = indexes.pop(0)
+ if isinstance(index, tuple):
+ index, depth = index
+ else:
+ depth = 0
+
+ if depth > 10:
+ # Avoid infinite loop with recursive links
+ break
+
+ if model.hasChildren(index):
+ self.__treeview.setExpanded(index, True)
+ for row in range(model.rowCount(index)):
+ childIndex = model.index(row, 0, index)
+ indexes.append((childIndex, depth + 1))
+ qt.QApplication.restoreOverrideCursor()
+
+ def __collapseAllSelected(self):
+ """Collapse all selected items of the tree.
+
+ The depth is fixed to avoid infinite loop with recurssive links.
+ """
+ selection = self.__treeview.selectionModel()
+ indexes = selection.selectedIndexes()
+ model = self.__treeview.model()
+ while len(indexes) > 0:
+ index = indexes.pop(0)
+ if isinstance(index, tuple):
+ index, depth = index
+ else:
+ depth = 0
+
+ if depth > 10:
+ # Avoid infinite loop with recursive links
+ break
+
+ if model.hasChildren(index):
+ self.__treeview.setExpanded(index, False)
+ for row in range(model.rowCount(index)):
+ childIndex = model.index(row, 0, index)
+ indexes.append((childIndex, depth + 1))
+
+ def __createCustomNxdataWindow(self, customNxdataWidget):
+ toolbar = CustomNxDataToolBar(self)
+ toolbar.setCustomNxDataWidget(customNxdataWidget)
+ toolbar.setIconSize(qt.QSize(16, 16))
+ toolbar.setStyleSheet("QToolBar { border: 0px }")
+
+ widget = qt.QWidget(self)
+ layout = qt.QVBoxLayout(widget)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ layout.addWidget(toolbar)
+ layout.addWidget(customNxdataWidget)
+ return widget
+
+ def __h5FileLoaded(self, loadedH5):
+ self.__context.pushRecentFile(loadedH5.file.filename)
+
+ def __h5FileRemoved(self, removedH5):
+ self.__dataPanel.removeDatasetsFrom(removedH5)
+ self.__customNxdata.removeDatasetsFrom(removedH5)
+ removedH5.close()
+
+ def __h5FileSynchonized(self, removedH5, loadedH5):
+ self.__dataPanel.replaceDatasetsFrom(removedH5, loadedH5)
+ self.__customNxdata.replaceDatasetsFrom(removedH5, loadedH5)
+ removedH5.close()
+
+ def closeEvent(self, event):
+ self.__context.saveSettings()
+
+ # Clean up as much as possible Python objects
+ model = self.__customNxdata.model()
+ model.clear()
+ model = self.__treeview.findHdf5TreeModel()
+ model.clear()
+
+ def saveSettings(self, settings):
+ """Save the window settings to this settings object
+
+ :param qt.QSettings settings: Initialized settings
+ """
+ isFullScreen = bool(self.windowState() & qt.Qt.WindowFullScreen)
+ if isFullScreen:
+ # show in normal to catch the normal geometry
+ self.showNormal()
+
+ settings.beginGroup("mainwindow")
+ settings.setValue("size", self.size())
+ settings.setValue("pos", self.pos())
+ settings.setValue("full-screen", isFullScreen)
+ settings.endGroup()
+
+ settings.beginGroup("mainlayout")
+ settings.setValue("spliter", self.__splitter.sizes())
+ settings.setValue("spliter2", self.__splitter2.sizes())
+ isVisible = self.__customNxdataWindow.isVisible()
+ settings.setValue("custom-nxdata-window-visible", isVisible)
+ settings.endGroup()
+
+ if isFullScreen:
+ self.showFullScreen()
+
+ def restoreSettings(self, settings):
+ """Restore the window settings using this settings object
+
+ :param qt.QSettings settings: Initialized settings
+ """
+ settings.beginGroup("mainwindow")
+ size = settings.value("size", qt.QSize(640, 480))
+ pos = settings.value("pos", qt.QPoint())
+ isFullScreen = settings.value("full-screen", False)
+ try:
+ if not isinstance(isFullScreen, bool):
+ isFullScreen = utils.stringToBool(isFullScreen)
+ except ValueError:
+ isFullScreen = False
+ settings.endGroup()
+
+ settings.beginGroup("mainlayout")
+ try:
+ data = settings.value("spliter")
+ data = [int(d) for d in data]
+ self.__splitter.setSizes(data)
+ except Exception:
+ _logger.debug("Backtrace", exc_info=True)
+ try:
+ data = settings.value("spliter2")
+ data = [int(d) for d in data]
+ self.__splitter2.setSizes(data)
+ except Exception:
+ _logger.debug("Backtrace", exc_info=True)
+ isVisible = settings.value("custom-nxdata-window-visible", False)
+ try:
+ if not isinstance(isVisible, bool):
+ isVisible = utils.stringToBool(isVisible)
+ except ValueError:
+ isVisible = False
+ self.__customNxdataWindow.setVisible(isVisible)
+ self._displayCustomNxdataWindow.setChecked(isVisible)
+
+ settings.endGroup()
+
+ if not pos.isNull():
+ self.move(pos)
+ if not size.isNull():
+ self.resize(size)
+ if isFullScreen:
+ self.showFullScreen()
+
+ def createActions(self):
+ action = qt.QAction("E&xit", self)
+ action.setShortcuts(qt.QKeySequence.Quit)
+ action.setStatusTip("Exit the application")
+ action.triggered.connect(self.close)
+ self._exitAction = action
+
+ action = qt.QAction("&Open...", self)
+ action.setStatusTip("Open a file")
+ action.triggered.connect(self.open)
+ self._openAction = action
+
+ action = qt.QAction("Open Recent", self)
+ action.setStatusTip("Open a recently openned file")
+ action.triggered.connect(self.open)
+ self._openRecentAction = action
+
+ action = qt.QAction("&About", self)
+ action.setStatusTip("Show the application's About box")
+ action.triggered.connect(self.about)
+ self._aboutAction = action
+
+ # Plot backend
+
+ action = qt.QAction("Plot rendering backend", self)
+ action.setStatusTip("Select plot rendering backend")
+ self._plotBackendSelection = action
+
+ menu = qt.QMenu()
+ action.setMenu(menu)
+ group = qt.QActionGroup(self)
+ group.setExclusive(True)
+
+ action = qt.QAction("matplotlib", self)
+ action.setStatusTip("Plot will be rendered using matplotlib")
+ action.setCheckable(True)
+ action.triggered.connect(self.__forceMatplotlibBackend)
+ group.addAction(action)
+ menu.addAction(action)
+ self._usePlotWithMatplotlib = action
+
+ action = qt.QAction("OpenGL", self)
+ action.setStatusTip("Plot will be rendered using OpenGL")
+ action.setCheckable(True)
+ action.triggered.connect(self.__forceOpenglBackend)
+ group.addAction(action)
+ menu.addAction(action)
+ self._usePlotWithOpengl = action
+
+ # Plot image orientation
+
+ action = qt.QAction("Default plot image y-axis orientation", self)
+ action.setStatusTip("Select the default y-axis orientation used by plot displaying images")
+ self._plotImageOrientation = action
+
+ menu = qt.QMenu()
+ action.setMenu(menu)
+ group = qt.QActionGroup(self)
+ group.setExclusive(True)
+
+ action = qt.QAction("Downward, origin on top", self)
+ action.setIcon(self._iconDownward)
+ action.setStatusTip("Plot images will use a downward Y-axis orientation")
+ action.setCheckable(True)
+ action.triggered.connect(self.__forcePlotImageDownward)
+ group.addAction(action)
+ menu.addAction(action)
+ self._useYAxisOrientationDownward = action
+
+ action = qt.QAction("Upward, origin on bottom", self)
+ action.setIcon(self._iconUpward)
+ action.setStatusTip("Plot images will use a upward Y-axis orientation")
+ action.setCheckable(True)
+ action.triggered.connect(self.__forcePlotImageUpward)
+ group.addAction(action)
+ menu.addAction(action)
+ self._useYAxisOrientationUpward = action
+
+ # Windows
+
+ action = qt.QAction("Show custom NXdata selector", self)
+ action.setStatusTip("Show a widget which allow to create plot by selecting data and axes")
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_F5))
+ action.toggled.connect(self.__toggleCustomNxdataWindow)
+ self._displayCustomNxdataWindow = action
+
+ def __toggleCustomNxdataWindow(self):
+ isVisible = self._displayCustomNxdataWindow.isChecked()
+ self.__customNxdataWindow.setVisible(isVisible)
+
+ def __updateFileMenu(self):
+ files = self.__context.getRecentFiles()
+ self._openRecentAction.setEnabled(len(files) != 0)
+ menu = None
+ if len(files) != 0:
+ menu = qt.QMenu()
+ for filePath in files:
+ baseName = os.path.basename(filePath)
+ action = qt.QAction(baseName, self)
+ action.setToolTip(filePath)
+ action.triggered.connect(functools.partial(self.__openRecentFile, filePath))
+ menu.addAction(action)
+ menu.addSeparator()
+ baseName = os.path.basename(filePath)
+ action = qt.QAction("Clear history", self)
+ action.setToolTip("Clear the history of the recent files")
+ action.triggered.connect(self.__clearRecentFile)
+ menu.addAction(action)
+ self._openRecentAction.setMenu(menu)
+
+ def __clearRecentFile(self):
+ self.__context.clearRencentFiles()
+
+ def __openRecentFile(self, filePath):
+ self.appendFile(filePath)
+
+ def __updateOptionMenu(self):
+ """Update the state of the checked options as it is based on global
+ environment values."""
+
+ # plot backend
+
+ action = self._plotBackendSelection
+ title = action.text().split(": ", 1)[0]
+ action.setText("%s: %s" % (title, silx.config.DEFAULT_PLOT_BACKEND))
+
+ action = self._usePlotWithMatplotlib
+ action.setChecked(silx.config.DEFAULT_PLOT_BACKEND in ["matplotlib", "mpl"])
+ title = action.text().split(" (", 1)[0]
+ if not action.isChecked():
+ title += " (applied after application restart)"
+ action.setText(title)
+
+ action = self._usePlotWithOpengl
+ action.setChecked(silx.config.DEFAULT_PLOT_BACKEND in ["opengl", "gl"])
+ title = action.text().split(" (", 1)[0]
+ if not action.isChecked():
+ title += " (applied after application restart)"
+ action.setText(title)
+
+ # plot orientation
+
+ action = self._plotImageOrientation
+ if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == "downward":
+ action.setIcon(self._iconDownward)
+ else:
+ action.setIcon(self._iconUpward)
+ action.setIconVisibleInMenu(True)
+
+ action = self._useYAxisOrientationDownward
+ action.setChecked(silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == "downward")
+ title = action.text().split(" (", 1)[0]
+ if not action.isChecked():
+ title += " (applied after application restart)"
+ action.setText(title)
+
+ action = self._useYAxisOrientationUpward
+ action.setChecked(silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION != "downward")
+ title = action.text().split(" (", 1)[0]
+ if not action.isChecked():
+ title += " (applied after application restart)"
+ action.setText(title)
+
+ def createMenus(self):
+ fileMenu = self.menuBar().addMenu("&File")
+ fileMenu.addAction(self._openAction)
+ fileMenu.addAction(self._openRecentAction)
+ fileMenu.addSeparator()
+ fileMenu.addAction(self._exitAction)
+ fileMenu.aboutToShow.connect(self.__updateFileMenu)
+
+ optionMenu = self.menuBar().addMenu("&Options")
+ optionMenu.addAction(self._plotImageOrientation)
+ optionMenu.addAction(self._plotBackendSelection)
+ optionMenu.aboutToShow.connect(self.__updateOptionMenu)
+
+ viewMenu = self.menuBar().addMenu("&Views")
+ viewMenu.addAction(self._displayCustomNxdataWindow)
+
+ helpMenu = self.menuBar().addMenu("&Help")
+ helpMenu.addAction(self._aboutAction)
+
+ def open(self):
+ dialog = self.createFileDialog()
+ if self.__dialogState is None:
+ currentDirectory = os.getcwd()
+ dialog.setDirectory(currentDirectory)
+ else:
+ dialog.restoreState(self.__dialogState)
+
+ result = dialog.exec_()
+ if not result:
+ return
+
+ self.__dialogState = dialog.saveState()
+
+ filenames = dialog.selectedFiles()
+ for filename in filenames:
+ self.appendFile(filename)
+
+ def createFileDialog(self):
+ dialog = qt.QFileDialog(self)
+ dialog.setWindowTitle("Open")
+ dialog.setModal(True)
+
+ # NOTE: hdf5plugin have to be loaded before
+ extensions = collections.OrderedDict()
+ for description, ext in silx.io.supported_extensions().items():
+ extensions[description] = " ".join(sorted(list(ext)))
+
+ try:
+ # NOTE: hdf5plugin have to be loaded before
+ import fabio
+ except Exception:
+ _logger.debug("Backtrace while loading fabio", exc_info=True)
+ fabio = None
+
+ if fabio is not None:
+ extensions["NeXus layout from EDF files"] = "*.edf"
+ extensions["NeXus layout from TIFF image files"] = "*.tif *.tiff"
+ extensions["NeXus layout from CBF files"] = "*.cbf"
+ extensions["NeXus layout from MarCCD image files"] = "*.mccd"
+
+ all_supported_extensions = set()
+ for name, exts in extensions.items():
+ exts = exts.split(" ")
+ all_supported_extensions.update(exts)
+ all_supported_extensions = sorted(list(all_supported_extensions))
+
+ filters = []
+ filters.append("All supported files (%s)" % " ".join(all_supported_extensions))
+ for name, extension in extensions.items():
+ filters.append("%s (%s)" % (name, extension))
+ filters.append("All files (*)")
+
+ dialog.setNameFilters(filters)
+ dialog.setFileMode(qt.QFileDialog.ExistingFiles)
+ return dialog
+
+ def about(self):
+ from .About import About
+ About.about(self, "Silx viewer")
+
+ def __forcePlotImageDownward(self):
+ silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = "downward"
+
+ def __forcePlotImageUpward(self):
+ silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = "upward"
+
+ def __forceMatplotlibBackend(self):
+ silx.config.DEFAULT_PLOT_BACKEND = "matplotlib"
+
+ def __forceOpenglBackend(self):
+ silx.config.DEFAULT_PLOT_BACKEND = "opengl"
+
+ def appendFile(self, filename):
+ self.__treeview.findHdf5TreeModel().appendFile(filename)
+
+ def displaySelectedData(self):
+ """Called to update the dataviewer with the selected data.
+ """
+ selected = list(self.__treeview.selectedH5Nodes(ignoreBrokenLinks=False))
+ if len(selected) == 1:
+ # Update the viewer for a single selection
+ data = selected[0]
+ self.__dataPanel.setData(data)
+ else:
+ _logger.debug("Too many data selected")
+
+ def displayData(self, data):
+ """Called to update the dataviewer with a secific data.
+ """
+ self.__dataPanel.setData(data)
+
+ def displaySelectedCustomData(self):
+ selected = list(self.__customNxdata.selectedItems())
+ if len(selected) == 1:
+ # Update the viewer for a single selection
+ item = selected[0]
+ self.__dataPanel.setCustomDataItem(item)
+ else:
+ _logger.debug("Too many items selected")
+
+ def __customNxdataRemoved(self, item):
+ if self.__dataPanel.getCustomNxdataItem() is item:
+ self.__dataPanel.setCustomDataItem(None)
+
+ def __customNxdataUpdated(self, item):
+ if self.__dataPanel.getCustomNxdataItem() is item:
+ self.__dataPanel.setCustomDataItem(item)
+
+ def __makeSureCustomNxDataWindowIsVisible(self):
+ if not self.__customNxdataWindow.isVisible():
+ self.__customNxdataWindow.setVisible(True)
+ self._displayCustomNxdataWindow.setChecked(True)
+
+ def useAsNewCustomSignal(self, h5dataset):
+ self.__makeSureCustomNxDataWindowIsVisible()
+ model = self.__customNxdata.model()
+ model.createFromSignal(h5dataset)
+
+ def useAsNewCustomNxdata(self, h5nxdata):
+ self.__makeSureCustomNxDataWindowIsVisible()
+ model = self.__customNxdata.model()
+ model.createFromNxdata(h5nxdata)
+
+ def customContextMenu(self, event):
+ """Called to populate the context menu
+
+ :param silx.gui.hdf5.Hdf5ContextMenuEvent event: Event
+ containing expected information to populate the context menu
+ """
+ selectedObjects = event.source().selectedH5Nodes(ignoreBrokenLinks=False)
+ menu = event.menu()
+
+ if not menu.isEmpty():
+ menu.addSeparator()
+
+ for obj in selectedObjects:
+ h5 = obj.h5py_object
+
+ name = obj.name
+ if name.startswith("/"):
+ name = name[1:]
+ if name == "":
+ name = "the root"
+
+ action = qt.QAction("Show %s" % name, event.source())
+ action.triggered.connect(lambda: self.displayData(h5))
+ menu.addAction(action)
+
+ if silx.io.is_dataset(h5):
+ action = qt.QAction("Use as a new custom signal", event.source())
+ action.triggered.connect(lambda: self.useAsNewCustomSignal(h5))
+ menu.addAction(action)
+
+ if silx.io.is_group(h5) and silx.io.nxdata.is_valid_nxdata(h5):
+ action = qt.QAction("Use as a new custom NXdata", event.source())
+ action.triggered.connect(lambda: self.useAsNewCustomNxdata(h5))
+ menu.addAction(action)
+
+ if silx.io.is_file(h5):
+ action = qt.QAction("Remove %s" % obj.local_filename, event.source())
+ action.triggered.connect(lambda: self.__treeview.findHdf5TreeModel().removeH5pyObject(h5))
+ menu.addAction(action)
+ action = qt.QAction("Synchronize %s" % obj.local_filename, event.source())
+ action.triggered.connect(lambda: self.__treeview.findHdf5TreeModel().synchronizeH5pyObject(h5))
+ menu.addAction(action)
diff --git a/silx/app/view/__init__.py b/silx/app/view/__init__.py
new file mode 100644
index 0000000..229c44e
--- /dev/null
+++ b/silx/app/view/__init__.py
@@ -0,0 +1,28 @@
+# coding: utf-8
+# /*##########################################################################
+# Copyright (C) 2016-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+"""Package containing source code of the `silx view` application"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "07/06/2018"
diff --git a/silx/app/view/main.py b/silx/app/view/main.py
new file mode 100644
index 0000000..fc89a22
--- /dev/null
+++ b/silx/app/view/main.py
@@ -0,0 +1,168 @@
+# coding: utf-8
+# /*##########################################################################
+# Copyright (C) 2016-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+"""Module containing launcher of the `silx view` application"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "07/06/2018"
+
+import sys
+import argparse
+import logging
+import signal
+
+
+_logger = logging.getLogger(__name__)
+"""Module logger"""
+
+if "silx.gui.qt" not in sys.modules:
+ # Try first PyQt5 and not the priority imposed by silx.gui.qt.
+ # To avoid problem with unittests we only do it if silx.gui.qt is not
+ # yet loaded.
+ # TODO: Can be removed for silx 0.8, as it should be the default binding
+ # of the silx library.
+ try:
+ import PyQt5.QtCore
+ except ImportError:
+ pass
+
+import silx
+from silx.gui import qt
+
+
+def sigintHandler(*args):
+ """Handler for the SIGINT signal."""
+ qt.QApplication.quit()
+
+
+def createParser():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ 'files',
+ nargs=argparse.ZERO_OR_MORE,
+ help='Data file to show (h5 file, edf files, spec files)')
+ parser.add_argument(
+ '--debug',
+ dest="debug",
+ action="store_true",
+ default=False,
+ help='Set logging system in debug mode')
+ parser.add_argument(
+ '--use-opengl-plot',
+ dest="use_opengl_plot",
+ action="store_true",
+ default=False,
+ help='Use OpenGL for plots (instead of matplotlib)')
+ parser.add_argument(
+ '--fresh',
+ dest="fresh_preferences",
+ action="store_true",
+ default=False,
+ help='Start the application using new fresh user preferences')
+ return parser
+
+
+def main(argv):
+ """
+ Main function to launch the viewer as an application
+
+ :param argv: Command line arguments
+ :returns: exit status
+ """
+ parser = createParser()
+ options = parser.parse_args(argv[1:])
+
+ if options.debug:
+ logging.root.setLevel(logging.DEBUG)
+
+ #
+ # Import most of the things here to be sure to use the right logging level
+ #
+
+ try:
+ # it should be loaded before h5py
+ import hdf5plugin # noqa
+ except ImportError:
+ _logger.debug("Backtrace", exc_info=True)
+
+ try:
+ import h5py
+ except ImportError:
+ _logger.debug("Backtrace", exc_info=True)
+ h5py = None
+
+ if h5py is None:
+ message = "Module 'h5py' is not installed but is mandatory."\
+ + " You can install it using \"pip install h5py\"."
+ _logger.error(message)
+ return -1
+
+ #
+ # Run the application
+ #
+
+ app = qt.QApplication([])
+ qt.QLocale.setDefault(qt.QLocale.c())
+
+ signal.signal(signal.SIGINT, sigintHandler)
+ sys.excepthook = qt.exceptionHandler
+
+ timer = qt.QTimer()
+ timer.start(500)
+ # Application have to wake up Python interpreter, else SIGINT is not
+ # catched
+ timer.timeout.connect(lambda: None)
+
+ settings = qt.QSettings(qt.QSettings.IniFormat,
+ qt.QSettings.UserScope,
+ "silx",
+ "silx-view",
+ None)
+ if options.fresh_preferences:
+ settings.clear()
+
+ from .Viewer import Viewer
+ window = Viewer(parent=None, settings=settings)
+ window.setAttribute(qt.Qt.WA_DeleteOnClose, True)
+
+ if options.use_opengl_plot:
+ # It have to be done after the settings (after the Viewer creation)
+ silx.config.DEFAULT_PLOT_BACKEND = "opengl"
+
+ for filename in options.files:
+ try:
+ window.appendFile(filename)
+ except IOError as e:
+ _logger.error(e.args[0])
+ _logger.debug("Backtrace", exc_info=True)
+
+ window.show()
+ result = app.exec_()
+ # remove ending warnings relative to QTimer
+ app.deleteLater()
+ return result
+
+
+if __name__ == '__main__':
+ main(sys.argv)
diff --git a/silx/app/view/setup.py b/silx/app/view/setup.py
new file mode 100644
index 0000000..fa076cb
--- /dev/null
+++ b/silx/app/view/setup.py
@@ -0,0 +1,40 @@
+# coding: utf-8
+# /*##########################################################################
+# Copyright (C) 2016 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "06/06/2018"
+
+from numpy.distutils.misc_util import Configuration
+
+
+def configuration(parent_package='', top_path=None):
+ config = Configuration('view', parent_package, top_path)
+ config.add_subpackage('test')
+ return config
+
+
+if __name__ == "__main__":
+ from numpy.distutils.core import setup
+ setup(configuration=configuration)
diff --git a/silx/app/view/test/__init__.py b/silx/app/view/test/__init__.py
new file mode 100644
index 0000000..8e64948
--- /dev/null
+++ b/silx/app/view/test/__init__.py
@@ -0,0 +1,41 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "07/06/2018"
+
+import unittest
+
+from silx.test.utils import test_options
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ if test_options.WITH_QT_TEST:
+ from . import test_launcher
+ from . import test_view
+ test_suite.addTest(test_view.suite())
+ test_suite.addTest(test_launcher.suite())
+ return test_suite
diff --git a/silx/app/view/test/test_launcher.py b/silx/app/view/test/test_launcher.py
new file mode 100644
index 0000000..aabccf0
--- /dev/null
+++ b/silx/app/view/test/test_launcher.py
@@ -0,0 +1,145 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Module testing silx.app.view"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "07/06/2018"
+
+
+import os
+import sys
+import unittest
+import logging
+import subprocess
+
+from silx.test.utils import test_options
+from .. import main
+from silx import __main__ as silx_main
+
+_logger = logging.getLogger(__name__)
+
+
+@unittest.skipUnless(test_options.WITH_QT_TEST, test_options.WITH_QT_TEST_REASON)
+class TestLauncher(unittest.TestCase):
+ """Test command line parsing"""
+
+ def testHelp(self):
+ # option -h must cause a raise SystemExit or a return 0
+ try:
+ parser = main.createParser()
+ parser.parse_args(["view", "--help"])
+ result = 0
+ except SystemExit as e:
+ result = e.args[0]
+ self.assertEqual(result, 0)
+
+ def testWrongOption(self):
+ try:
+ parser = main.createParser()
+ parser.parse_args(["view", "--foo"])
+ self.fail()
+ except SystemExit as e:
+ result = e.args[0]
+ self.assertNotEqual(result, 0)
+
+ def testWrongFile(self):
+ try:
+ parser = main.createParser()
+ result = parser.parse_args(["view", "__file.not.found__"])
+ result = 0
+ except SystemExit as e:
+ result = e.args[0]
+ self.assertEqual(result, 0)
+
+ def executeCommandLine(self, command_line, env):
+ """Execute a command line.
+
+ Log output as debug in case of bad return code.
+ """
+ _logger.info("Execute: %s", " ".join(command_line))
+ p = subprocess.Popen(command_line,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=env)
+ out, err = p.communicate()
+ _logger.info("Return code: %d", p.returncode)
+ try:
+ out = out.decode('utf-8')
+ except UnicodeError:
+ pass
+ try:
+ err = err.decode('utf-8')
+ except UnicodeError:
+ pass
+
+ if p.returncode != 0:
+ _logger.info("stdout:")
+ _logger.info("%s", out)
+ _logger.info("stderr:")
+ _logger.info("%s", err)
+ else:
+ _logger.debug("stdout:")
+ _logger.debug("%s", out)
+ _logger.debug("stderr:")
+ _logger.debug("%s", err)
+ self.assertEqual(p.returncode, 0)
+
+ def createTestEnv(self):
+ """
+ Returns an associated environment with a working project.
+ """
+ env = dict((str(k), str(v)) for k, v in os.environ.items())
+ env["PYTHONPATH"] = os.pathsep.join(sys.path)
+ return env
+
+ def testExecuteViewHelp(self):
+ """Test if the main module is well connected.
+
+ Uses subprocess to avoid to parasite the current environment.
+ """
+ env = self.createTestEnv()
+ commandLine = [sys.executable, main.__file__, "--help"]
+ self.executeCommandLine(commandLine, env)
+
+ def testExecuteSilxViewHelp(self):
+ """Test if the main module is well connected.
+
+ Uses subprocess to avoid to parasite the current environment.
+ """
+ env = self.createTestEnv()
+ commandLine = [sys.executable, silx_main.__file__, "view", "--help"]
+ self.executeCommandLine(commandLine, env)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ loader = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loader(TestLauncher))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/app/view/test/test_view.py b/silx/app/view/test/test_view.py
new file mode 100644
index 0000000..010cda5
--- /dev/null
+++ b/silx/app/view/test/test_view.py
@@ -0,0 +1,402 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Module testing silx.app.view"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "07/06/2018"
+
+
+import unittest
+import weakref
+import numpy
+import tempfile
+import shutil
+import os.path
+try:
+ import h5py
+except ImportError:
+ h5py = None
+
+from silx.gui import qt
+from silx.app.view.Viewer import Viewer
+from silx.app.view.About import About
+from silx.app.view.DataPanel import DataPanel
+from silx.app.view.CustomNxdataWidget import CustomNxdataWidget
+from silx.gui.hdf5._utils import Hdf5DatasetMimeData
+from silx.gui.test.utils import TestCaseQt
+from silx.io import commonh5
+
+_tmpDirectory = None
+
+
+def setUpModule():
+ global _tmpDirectory
+ _tmpDirectory = tempfile.mkdtemp(prefix=__name__)
+
+ if h5py is not None:
+ # create h5 data
+ filename = _tmpDirectory + "/data.h5"
+ f = h5py.File(filename, "w")
+ g = f.create_group("arrays")
+ g.create_dataset("scalar", data=10)
+ g.create_dataset("integers", data=numpy.array([10, 20, 30]))
+ f.close()
+
+ # create h5 data
+ filename = _tmpDirectory + "/data2.h5"
+ f = h5py.File(filename, "w")
+ g = f.create_group("arrays")
+ g.create_dataset("scalar", data=20)
+ g.create_dataset("integers", data=numpy.array([10, 20, 30]))
+ f.close()
+
+
+def tearDownModule():
+ global _tmpDirectory
+ shutil.rmtree(_tmpDirectory)
+ _tmpDirectory = None
+
+
+class TestViewer(TestCaseQt):
+ """Test for Viewer class"""
+
+ def testConstruct(self):
+ widget = Viewer()
+ self.qWaitForWindowExposed(widget)
+
+ def testDestroy(self):
+ widget = Viewer()
+ ref = weakref.ref(widget)
+ widget = None
+ self.qWaitForDestroy(ref)
+
+
+class TestAbout(TestCaseQt):
+ """Test for About box class"""
+
+ def testConstruct(self):
+ widget = About()
+ self.qWaitForWindowExposed(widget)
+
+ def testLicense(self):
+ widget = About()
+ widget.getHtmlLicense()
+ self.qWaitForWindowExposed(widget)
+
+ def testDestroy(self):
+ widget = About()
+ ref = weakref.ref(widget)
+ widget = None
+ self.qWaitForDestroy(ref)
+
+
+class TestDataPanel(TestCaseQt):
+
+ def testConstruct(self):
+ widget = DataPanel()
+ self.qWaitForWindowExposed(widget)
+
+ def testDestroy(self):
+ widget = DataPanel()
+ ref = weakref.ref(widget)
+ widget = None
+ self.qWaitForDestroy(ref)
+
+ def testHeaderLabelPaintEvent(self):
+ widget = DataPanel()
+ data = numpy.array([1, 2, 3, 4, 5])
+ widget.setData(data)
+ # Expected to execute HeaderLabel.paintEvent
+ widget.setVisible(True)
+ self.qWaitForWindowExposed(widget)
+
+ def testData(self):
+ widget = DataPanel()
+ data = numpy.array([1, 2, 3, 4, 5])
+ widget.setData(data)
+ self.assertIs(widget.getData(), data)
+ self.assertIs(widget.getCustomNxdataItem(), None)
+
+ def testDataNone(self):
+ widget = DataPanel()
+ widget.setData(None)
+ self.assertIs(widget.getData(), None)
+ self.assertIs(widget.getCustomNxdataItem(), None)
+
+ def testCustomDataItem(self):
+ class CustomDataItemMock(object):
+ def getVirtualGroup(self):
+ return None
+
+ def text(self):
+ return ""
+
+ data = CustomDataItemMock()
+ widget = DataPanel()
+ widget.setCustomDataItem(data)
+ self.assertIs(widget.getData(), None)
+ self.assertIs(widget.getCustomNxdataItem(), data)
+
+ def testCustomDataItemNone(self):
+ data = None
+ widget = DataPanel()
+ widget.setCustomDataItem(data)
+ self.assertIs(widget.getData(), None)
+ self.assertIs(widget.getCustomNxdataItem(), data)
+
+ @unittest.skipIf(h5py is None, "Could not import h5py")
+ def testRemoveDatasetsFrom(self):
+ f = h5py.File(os.path.join(_tmpDirectory, "data.h5"))
+ try:
+ widget = DataPanel()
+ widget.setData(f["arrays/scalar"])
+ widget.removeDatasetsFrom(f)
+ self.assertIs(widget.getData(), None)
+ finally:
+ widget.setData(None)
+ f.close()
+
+ @unittest.skipIf(h5py is None, "Could not import h5py")
+ def testReplaceDatasetsFrom(self):
+ f = h5py.File(os.path.join(_tmpDirectory, "data.h5"))
+ f2 = h5py.File(os.path.join(_tmpDirectory, "data2.h5"))
+ try:
+ widget = DataPanel()
+ widget.setData(f["arrays/scalar"])
+ self.assertEqual(widget.getData()[()], 10)
+ widget.replaceDatasetsFrom(f, f2)
+ self.assertEqual(widget.getData()[()], 20)
+ finally:
+ widget.setData(None)
+ f.close()
+ f2.close()
+
+
+class TestCustomNxdataWidget(TestCaseQt):
+
+ def testConstruct(self):
+ widget = CustomNxdataWidget()
+ self.qWaitForWindowExposed(widget)
+
+ def testDestroy(self):
+ widget = CustomNxdataWidget()
+ ref = weakref.ref(widget)
+ widget = None
+ self.qWaitForDestroy(ref)
+
+ def testCreateNxdata(self):
+ widget = CustomNxdataWidget()
+ model = widget.model()
+ model.createNewNxdata()
+ model.createNewNxdata("Foo")
+ widget.setVisible(True)
+ self.qWaitForWindowExposed(widget)
+
+ def testCreateNxdataFromDataset(self):
+ widget = CustomNxdataWidget()
+ model = widget.model()
+ signal = commonh5.Dataset("foo", data=numpy.array([[[5]]]))
+ model.createFromSignal(signal)
+ widget.setVisible(True)
+ self.qWaitForWindowExposed(widget)
+
+ def testCreateNxdataFromNxdata(self):
+ widget = CustomNxdataWidget()
+ model = widget.model()
+ data = numpy.array([[[5]]])
+ nxdata = commonh5.Group("foo")
+ nxdata.attrs["NX_class"] = "NXdata"
+ nxdata.attrs["signal"] = "signal"
+ nxdata.create_dataset("signal", data=data)
+ model.createFromNxdata(nxdata)
+ widget.setVisible(True)
+ self.qWaitForWindowExposed(widget)
+
+ def testCreateBadNxdata(self):
+ widget = CustomNxdataWidget()
+ model = widget.model()
+ signal = commonh5.Dataset("foo", data=numpy.array([[[5]]]))
+ model.createFromSignal(signal)
+ axis = commonh5.Dataset("foo", data=numpy.array([[[5]]]))
+ nxdataIndex = model.index(0, 0)
+ item = model.itemFromIndex(nxdataIndex)
+ item.setAxesDatasets([axis])
+ nxdata = item.getVirtualGroup()
+ self.assertIsNotNone(nxdata)
+ self.assertFalse(item.isValid())
+
+ @unittest.skipIf(h5py is None, "Could not import h5py")
+ def testRemoveDatasetsFrom(self):
+ f = h5py.File(os.path.join(_tmpDirectory, "data.h5"))
+ try:
+ widget = CustomNxdataWidget()
+ model = widget.model()
+ dataset = f["arrays/integers"]
+ model.createFromSignal(dataset)
+ widget.removeDatasetsFrom(f)
+ finally:
+ model.clear()
+ f.close()
+
+ @unittest.skipIf(h5py is None, "Could not import h5py")
+ def testReplaceDatasetsFrom(self):
+ f = h5py.File(os.path.join(_tmpDirectory, "data.h5"))
+ f2 = h5py.File(os.path.join(_tmpDirectory, "data2.h5"))
+ try:
+ widget = CustomNxdataWidget()
+ model = widget.model()
+ dataset = f["arrays/integers"]
+ model.createFromSignal(dataset)
+ widget.replaceDatasetsFrom(f, f2)
+ finally:
+ model.clear()
+ f.close()
+ f2.close()
+
+
+class TestCustomNxdataWidgetInteraction(TestCaseQt):
+ """Test CustomNxdataWidget with user interaction"""
+
+ def setUp(self):
+ TestCaseQt.setUp(self)
+
+ self.widget = CustomNxdataWidget()
+ self.model = self.widget.model()
+ data = numpy.array([[[5]]])
+ dataset = commonh5.Dataset("foo", data=data)
+ self.model.createFromSignal(dataset)
+ self.selectionModel = self.widget.selectionModel()
+
+ def tearDown(self):
+ self.selectionModel = None
+ self.model.clear()
+ self.model = None
+ self.widget = None
+ TestCaseQt.tearDown(self)
+
+ def testSelectedNxdata(self):
+ index = self.model.index(0, 0)
+ self.selectionModel.setCurrentIndex(index, qt.QItemSelectionModel.ClearAndSelect)
+ nxdata = self.widget.selectedNxdata()
+ self.assertEqual(len(nxdata), 1)
+ self.assertIsNot(nxdata[0], None)
+
+ def testSelectedItems(self):
+ index = self.model.index(0, 0)
+ self.selectionModel.setCurrentIndex(index, qt.QItemSelectionModel.ClearAndSelect)
+ items = self.widget.selectedItems()
+ self.assertEqual(len(items), 1)
+ self.assertIsNot(items[0], None)
+ self.assertIsInstance(items[0], qt.QStandardItem)
+
+ def testRowsAboutToBeRemoved(self):
+ self.model.removeRow(0)
+ self.qWaitForWindowExposed(self.widget)
+
+ def testPaintItems(self):
+ self.widget.expandAll()
+ self.widget.setVisible(True)
+ self.qWaitForWindowExposed(self.widget)
+
+ def testCreateDefaultContextMenu(self):
+ nxDataIndex = self.model.index(0, 0)
+ menu = self.widget.createDefaultContextMenu(nxDataIndex)
+ self.assertIsNot(menu, None)
+ self.assertIsInstance(menu, qt.QMenu)
+
+ signalIndex = self.model.index(0, 0, nxDataIndex)
+ menu = self.widget.createDefaultContextMenu(signalIndex)
+ self.assertIsNot(menu, None)
+ self.assertIsInstance(menu, qt.QMenu)
+
+ axesIndex = self.model.index(1, 0, nxDataIndex)
+ menu = self.widget.createDefaultContextMenu(axesIndex)
+ self.assertIsNot(menu, None)
+ self.assertIsInstance(menu, qt.QMenu)
+
+ def testDropNewDataset(self):
+ dataset = commonh5.Dataset("foo", numpy.array([1, 2, 3, 4]))
+ mimedata = Hdf5DatasetMimeData(dataset=dataset)
+ self.model.dropMimeData(mimedata, qt.Qt.CopyAction, -1, -1, qt.QModelIndex())
+ self.assertEqual(self.model.rowCount(qt.QModelIndex()), 2)
+
+ def testDropNewNxdata(self):
+ data = numpy.array([[[5]]])
+ nxdata = commonh5.Group("foo")
+ nxdata.attrs["NX_class"] = "NXdata"
+ nxdata.attrs["signal"] = "signal"
+ nxdata.create_dataset("signal", data=data)
+ mimedata = Hdf5DatasetMimeData(dataset=nxdata)
+ self.model.dropMimeData(mimedata, qt.Qt.CopyAction, -1, -1, qt.QModelIndex())
+ self.assertEqual(self.model.rowCount(qt.QModelIndex()), 2)
+
+ def testDropAxisDataset(self):
+ dataset = commonh5.Dataset("foo", numpy.array([1, 2, 3, 4]))
+ mimedata = Hdf5DatasetMimeData(dataset=dataset)
+ nxDataIndex = self.model.index(0, 0)
+ axesIndex = self.model.index(1, 0, nxDataIndex)
+ self.model.dropMimeData(mimedata, qt.Qt.CopyAction, -1, -1, axesIndex)
+ self.assertEqual(self.model.rowCount(qt.QModelIndex()), 1)
+ item = self.model.itemFromIndex(axesIndex)
+ self.assertIsNot(item.getDataset(), None)
+
+ def testMimeData(self):
+ nxDataIndex = self.model.index(0, 0)
+ signalIndex = self.model.index(0, 0, nxDataIndex)
+ mimeData = self.model.mimeData([signalIndex])
+ self.assertIsNot(mimeData, None)
+ self.assertIsInstance(mimeData, qt.QMimeData)
+
+ def testRemoveNxdataItem(self):
+ nxdataIndex = self.model.index(0, 0)
+ item = self.model.itemFromIndex(nxdataIndex)
+ self.model.removeNxdataItem(item)
+
+ def testAppendAxisToNxdataItem(self):
+ nxdataIndex = self.model.index(0, 0)
+ item = self.model.itemFromIndex(nxdataIndex)
+ self.model.appendAxisToNxdataItem(item)
+
+ def testRemoveAxisItem(self):
+ nxdataIndex = self.model.index(0, 0)
+ axesIndex = self.model.index(1, 0, nxdataIndex)
+ item = self.model.itemFromIndex(axesIndex)
+ self.model.removeAxisItem(item)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ loader = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loader(TestViewer))
+ test_suite.addTest(loader(TestAbout))
+ test_suite.addTest(loader(TestDataPanel))
+ test_suite.addTest(loader(TestCustomNxdataWidget))
+ test_suite.addTest(loader(TestCustomNxdataWidgetInteraction))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/app/view/utils.py b/silx/app/view/utils.py
new file mode 100644
index 0000000..80167c8
--- /dev/null
+++ b/silx/app/view/utils.py
@@ -0,0 +1,45 @@
+# coding: utf-8
+# /*##########################################################################
+# Copyright (C) 2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+"""Browse a data file with a GUI"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "28/05/2018"
+
+
+_trueStrings = set(["yes", "true", "1"])
+_falseStrings = set(["no", "false", "0"])
+
+
+def stringToBool(string):
+ """Returns a boolean from a string.
+
+ :raise ValueError: If the string do not contains a boolean information.
+ """
+ lower = string.lower()
+ if lower in _trueStrings:
+ return True
+ if lower in _falseStrings:
+ return False
+ raise ValueError("'%s' is not a valid boolean" % string)
diff --git a/silx/gui/__init__.py b/silx/gui/__init__.py
index 6baf238..b796e20 100644
--- a/silx/gui/__init__.py
+++ b/silx/gui/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -22,7 +22,27 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""Set of Qt widgets"""
+"""This package provides a set of Qt widgets.
+
+It contains the following sub-packages and modules:
+
+- silx.gui.colors: Functions to handle colors and colormap
+- silx.gui.console: IPython console widget
+- silx.gui.data:
+ Widgets for displaying data arrays using table views and plot widgets
+- silx.gui.dialog: Specific dialog widgets
+- silx.gui.fit: Widgets for controlling curve fitting
+- silx.gui.hdf5: Widgets for displaying content relative to HDF5 format
+- silx.gui.icons: Functions to access embedded icons
+- silx.gui.plot: Widgets for 1D and 2D plotting and related tools
+- silx.gui.plot3d: Widgets for visualizing data in 3D based on OpenGL
+- silx.gui.printer: Shared printer used by the library
+- silx.gui.qt: Common wrapper over different Python Qt binding
+- silx.gui.utils: Miscellaneous helpers for Qt
+- silx.gui.widgets: Miscellaneous standalone widgets
+
+See silx documentation: http://www.silx.org/doc/silx/latest/
+"""
__authors__ = ["T. Vincent"]
__license__ = "MIT"
diff --git a/silx/gui/_glutils/font.py b/silx/gui/_glutils/font.py
index 2be2c04..b5bd6b5 100644
--- a/silx/gui/_glutils/font.py
+++ b/silx/gui/_glutils/font.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -30,11 +30,10 @@ __date__ = "13/10/2016"
import logging
-import sys
import numpy
-from .. import qt
-from .._utils import convertQImageToArray
+from ..utils._image import convertQImageToArray
+from .. import qt
_logger = logging.getLogger(__name__)
diff --git a/silx/gui/colors.py b/silx/gui/colors.py
new file mode 100644
index 0000000..028609b
--- /dev/null
+++ b/silx/gui/colors.py
@@ -0,0 +1,732 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides API to manage colors.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent", "H.Payno"]
+__license__ = "MIT"
+__date__ = "14/06/2018"
+
+from silx.gui import qt
+import copy as copy_mdl
+import numpy
+import logging
+from silx.math.combo import min_max
+from silx.math.colormap import cmap as _cmap
+from silx.utils.exceptions import NotEditableError
+
+_logger = logging.getLogger(__file__)
+
+
+_COLORDICT = {}
+"""Dictionary of common colors."""
+
+_COLORDICT['b'] = _COLORDICT['blue'] = '#0000ff'
+_COLORDICT['r'] = _COLORDICT['red'] = '#ff0000'
+_COLORDICT['g'] = _COLORDICT['green'] = '#00ff00'
+_COLORDICT['k'] = _COLORDICT['black'] = '#000000'
+_COLORDICT['w'] = _COLORDICT['white'] = '#ffffff'
+_COLORDICT['pink'] = '#ff66ff'
+_COLORDICT['brown'] = '#a52a2a'
+_COLORDICT['orange'] = '#ff9900'
+_COLORDICT['violet'] = '#6600ff'
+_COLORDICT['gray'] = _COLORDICT['grey'] = '#a0a0a4'
+# _COLORDICT['darkGray'] = _COLORDICT['darkGrey'] = '#808080'
+# _COLORDICT['lightGray'] = _COLORDICT['lightGrey'] = '#c0c0c0'
+_COLORDICT['y'] = _COLORDICT['yellow'] = '#ffff00'
+_COLORDICT['m'] = _COLORDICT['magenta'] = '#ff00ff'
+_COLORDICT['c'] = _COLORDICT['cyan'] = '#00ffff'
+_COLORDICT['darkBlue'] = '#000080'
+_COLORDICT['darkRed'] = '#800000'
+_COLORDICT['darkGreen'] = '#008000'
+_COLORDICT['darkBrown'] = '#660000'
+_COLORDICT['darkCyan'] = '#008080'
+_COLORDICT['darkYellow'] = '#808000'
+_COLORDICT['darkMagenta'] = '#800080'
+
+
+# FIXME: It could be nice to expose a functional API instead of that attribute
+COLORDICT = _COLORDICT
+
+
+def rgba(color, colorDict=None):
+ """Convert color code '#RRGGBB' and '#RRGGBBAA' to (R, G, B, A)
+
+ It also convert RGB(A) values from uint8 to float in [0, 1] and
+ accept a QColor as color argument.
+
+ :param str color: The color to convert
+ :param dict colorDict: A dictionary of color name conversion to color code
+ :returns: RGBA colors as floats in [0., 1.]
+ :rtype: tuple
+ """
+ if colorDict is None:
+ colorDict = _COLORDICT
+
+ if hasattr(color, 'getRgbF'): # QColor support
+ color = color.getRgbF()
+
+ values = numpy.asarray(color).ravel()
+
+ if values.dtype.kind in 'iuf': # integer or float
+ # Color is an array
+ assert len(values) in (3, 4)
+
+ # Convert from integers in [0, 255] to float in [0, 1]
+ if values.dtype.kind in 'iu':
+ values = values / 255.
+
+ # Clip to [0, 1]
+ values[values < 0.] = 0.
+ values[values > 1.] = 1.
+
+ if len(values) == 3:
+ return values[0], values[1], values[2], 1.
+ else:
+ return tuple(values)
+
+ # We assume color is a string
+ if not color.startswith('#'):
+ color = colorDict[color]
+
+ assert len(color) in (7, 9) and color[0] == '#'
+ r = int(color[1:3], 16) / 255.
+ g = int(color[3:5], 16) / 255.
+ b = int(color[5:7], 16) / 255.
+ a = int(color[7:9], 16) / 255. if len(color) == 9 else 1.
+ return r, g, b, a
+
+
+_COLORMAP_CURSOR_COLORS = {
+ 'gray': 'pink',
+ 'reversed gray': 'pink',
+ 'temperature': 'pink',
+ 'red': 'green',
+ 'green': 'pink',
+ 'blue': 'yellow',
+ 'jet': 'pink',
+ 'viridis': 'pink',
+ 'magma': 'green',
+ 'inferno': 'green',
+ 'plasma': 'green',
+}
+
+
+def cursorColorForColormap(colormapName):
+ """Get a color suitable for overlay over a colormap.
+
+ :param str colormapName: The name of the colormap.
+ :return: Name of the color.
+ :rtype: str
+ """
+ return _COLORMAP_CURSOR_COLORS.get(colormapName, 'black')
+
+
+DEFAULT_COLORMAPS = (
+ 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue')
+"""Tuple of supported colormap names."""
+
+DEFAULT_MIN_LIN = 0
+"""Default min value if in linear normalization"""
+DEFAULT_MAX_LIN = 1
+"""Default max value if in linear normalization"""
+DEFAULT_MIN_LOG = 1
+"""Default min value if in log normalization"""
+DEFAULT_MAX_LOG = 10
+"""Default max value if in log normalization"""
+
+
+class Colormap(qt.QObject):
+ """Description of a colormap
+
+ :param str name: Name of the colormap
+ :param tuple colors: optional, custom colormap.
+ Nx3 or Nx4 numpy array of RGB(A) colors,
+ either uint8 or float in [0, 1].
+ If 'name' is None, then this array is used as the colormap.
+ :param str normalization: Normalization: 'linear' (default) or 'log'
+ :param float vmin:
+ Lower bound of the colormap or None for autoscale (default)
+ :param float vmax:
+ Upper bounds of the colormap or None for autoscale (default)
+ """
+
+ LINEAR = 'linear'
+ """constant for linear normalization"""
+
+ LOGARITHM = 'log'
+ """constant for logarithmic normalization"""
+
+ NORMALIZATIONS = (LINEAR, LOGARITHM)
+ """Tuple of managed normalizations"""
+
+ sigChanged = qt.Signal()
+ """Signal emitted when the colormap has changed."""
+
+ def __init__(self, name='gray', colors=None, normalization=LINEAR, vmin=None, vmax=None):
+ qt.QObject.__init__(self)
+ assert normalization in Colormap.NORMALIZATIONS
+ assert not (name is None and colors is None)
+ if normalization is Colormap.LOGARITHM:
+ if (vmin is not None and vmin < 0) or (vmax is not None and vmax < 0):
+ m = "Unsuported vmin (%s) and/or vmax (%s) given for a log scale."
+ m += ' Autoscale will be performed.'
+ m = m % (vmin, vmax)
+ _logger.warning(m)
+ vmin = None
+ vmax = None
+
+ self._name = str(name) if name is not None else None
+ self._setColors(colors)
+ self._normalization = str(normalization)
+ self._vmin = float(vmin) if vmin is not None else None
+ self._vmax = float(vmax) if vmax is not None else None
+ self._editable = True
+
+ def isAutoscale(self):
+ """Return True if both min and max are in autoscale mode"""
+ return self._vmin is None and self._vmax is None
+
+ def getName(self):
+ """Return the name of the colormap
+ :rtype: str
+ """
+ return self._name
+
+ @staticmethod
+ def _convertColorsFromFloatToUint8(colors):
+ """Convert colors from float in [0, 1] to uint8
+
+ :param numpy.ndarray colors: Array of float colors to convert
+ :return: colors as uint8
+ :rtype: numpy.ndarray
+ """
+ # Each bin is [N, N+1[ except the last one: [255, 256]
+ return numpy.clip(
+ colors.astype(numpy.float64) * 256, 0., 255.).astype(numpy.uint8)
+
+ def _setColors(self, colors):
+ if colors is None:
+ self._colors = None
+ else:
+ colors = numpy.array(colors, copy=False)
+ colors.shape = -1, colors.shape[-1]
+ if colors.dtype.kind == 'f':
+ colors = self._convertColorsFromFloatToUint8(colors)
+
+ # Makes sure it is RGBA8888
+ self._colors = numpy.zeros((len(colors), 4), dtype=numpy.uint8)
+ self._colors[:, 3] = 255 # Alpha channel
+ self._colors[:, :colors.shape[1]] = colors # Copy colors
+
+ def getNColors(self, nbColors=None):
+ """Returns N colors computed by sampling the colormap regularly.
+
+ :param nbColors:
+ The number of colors in the returned array or None for the default value.
+ The default value is 256 for colormap with a name (see :meth:`setName`) and
+ it is the size of the LUT for colormap defined with :meth:`setColormapLUT`.
+ :type nbColors: int or None
+ :return: 2D array of uint8 of shape (nbColors, 4)
+ :rtype: numpy.ndarray
+ """
+ # Handle default value for nbColors
+ if nbColors is None:
+ lut = self.getColormapLUT()
+ if lut is not None: # In this case uses LUT length
+ nbColors = len(lut)
+ else: # Default to 256
+ nbColors = 256
+
+ nbColors = int(nbColors)
+
+ colormap = self.copy()
+ colormap.setNormalization(Colormap.LINEAR)
+ colormap.setVRange(vmin=None, vmax=None)
+ colors = colormap.applyToData(
+ numpy.arange(nbColors, dtype=numpy.int))
+ return colors
+
+ def setName(self, name):
+ """Set the name of the colormap to use.
+
+ :param str name: The name of the colormap.
+ At least the following names are supported: 'gray',
+ 'reversed gray', 'temperature', 'red', 'green', 'blue', 'jet',
+ 'viridis', 'magma', 'inferno', 'plasma'.
+ """
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
+ assert name in self.getSupportedColormaps()
+ self._name = str(name)
+ self._colors = None
+ self.sigChanged.emit()
+
+ def getColormapLUT(self):
+ """Return the list of colors for the colormap or None if not set
+
+ :return: the list of colors for the colormap or None if not set
+ :rtype: numpy.ndarray or None
+ """
+ if self._colors is None:
+ return None
+ else:
+ return numpy.array(self._colors, copy=True)
+
+ def setColormapLUT(self, colors):
+ """Set the colors of the colormap.
+
+ :param numpy.ndarray colors: the colors of the LUT.
+ If float, it is converted from [0, 1] to uint8 range.
+ Otherwise it is casted to uint8.
+
+ .. warning: this will set the value of name to None
+ """
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
+ self._setColors(colors)
+ if len(colors) is 0:
+ self._colors = None
+
+ self._name = None
+ self.sigChanged.emit()
+
+ def getNormalization(self):
+ """Return the normalization of the colormap ('log' or 'linear')
+
+ :return: the normalization of the colormap
+ :rtype: str
+ """
+ return self._normalization
+
+ def setNormalization(self, norm):
+ """Set the norm ('log', 'linear')
+
+ :param str norm: the norm to set
+ """
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
+ self._normalization = str(norm)
+ self.sigChanged.emit()
+
+ def getVMin(self):
+ """Return the lower bound of the colormap
+
+ :return: the lower bound of the colormap
+ :rtype: float or None
+ """
+ return self._vmin
+
+ def setVMin(self, vmin):
+ """Set the minimal value of the colormap
+
+ :param float vmin: Lower bound of the colormap or None for autoscale
+ (default)
+ value)
+ """
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
+ if vmin is not None:
+ if self._vmax is not None and vmin > self._vmax:
+ err = "Can't set vmin because vmin >= vmax. " \
+ "vmin = %s, vmax = %s" % (vmin, self._vmax)
+ raise ValueError(err)
+
+ self._vmin = vmin
+ self.sigChanged.emit()
+
+ def getVMax(self):
+ """Return the upper bounds of the colormap or None
+
+ :return: the upper bounds of the colormap or None
+ :rtype: float or None
+ """
+ return self._vmax
+
+ def setVMax(self, vmax):
+ """Set the maximal value of the colormap
+
+ :param float vmax: Upper bounds of the colormap or None for autoscale
+ (default)
+ """
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
+ if vmax is not None:
+ if self._vmin is not None and vmax < self._vmin:
+ err = "Can't set vmax because vmax <= vmin. " \
+ "vmin = %s, vmax = %s" % (self._vmin, vmax)
+ raise ValueError(err)
+
+ self._vmax = vmax
+ self.sigChanged.emit()
+
+ def isEditable(self):
+ """ Return if the colormap is editable or not
+
+ :return: editable state of the colormap
+ :rtype: bool
+ """
+ return self._editable
+
+ def setEditable(self, editable):
+ """
+ Set the editable state of the colormap
+
+ :param bool editable: is the colormap editable
+ """
+ assert type(editable) is bool
+ self._editable = editable
+ self.sigChanged.emit()
+
+ def getColormapRange(self, data=None):
+ """Return (vmin, vmax)
+
+ :return: the tuple vmin, vmax fitting vmin, vmax, normalization and
+ data if any given
+ :rtype: tuple
+ """
+ vmin = self._vmin
+ vmax = self._vmax
+ assert vmin is None or vmax is None or vmin <= vmax # TODO handle this in setters
+
+ if self.getNormalization() == self.LOGARITHM:
+ # Handle negative bounds as autoscale
+ if vmin is not None and (vmin is not None and vmin <= 0.):
+ mess = 'negative vmin, moving to autoscale for lower bound'
+ _logger.warning(mess)
+ vmin = None
+ if vmax is not None and (vmax is not None and vmax <= 0.):
+ mess = 'negative vmax, moving to autoscale for upper bound'
+ _logger.warning(mess)
+ vmax = None
+
+ if vmin is None or vmax is None: # Handle autoscale
+ # Get min/max from data
+ if data is not None:
+ data = numpy.array(data, copy=False)
+ if data.size == 0: # Fallback an array but no data
+ min_, max_ = self._getDefaultMin(), self._getDefaultMax()
+ else:
+ if self.getNormalization() == self.LOGARITHM:
+ result = min_max(data, min_positive=True, finite=True)
+ min_ = result.min_positive # >0 or None
+ max_ = result.maximum # can be <= 0
+ else:
+ min_, max_ = min_max(data, min_positive=False, finite=True)
+
+ # Handle fallback
+ if min_ is None or not numpy.isfinite(min_):
+ min_ = self._getDefaultMin()
+ if max_ is None or not numpy.isfinite(max_):
+ max_ = self._getDefaultMax()
+ else: # Fallback if no data is provided
+ min_, max_ = self._getDefaultMin(), self._getDefaultMax()
+
+ if vmin is None: # Set vmin respecting provided vmax
+ vmin = min_ if vmax is None else min(min_, vmax)
+
+ if vmax is None:
+ vmax = max(max_, vmin) # Handle max_ <= 0 for log scale
+
+ return vmin, vmax
+
+ def setVRange(self, vmin, vmax):
+ """Set the bounds of the colormap
+
+ :param vmin: Lower bound of the colormap or None for autoscale
+ (default)
+ :param vmax: Upper bounds of the colormap or None for autoscale
+ (default)
+ """
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
+ if vmin is not None and vmax is not None:
+ if vmin > vmax:
+ err = "Can't set vmin and vmax because vmin >= vmax " \
+ "vmin = %s, vmax = %s" % (vmin, vmax)
+ raise ValueError(err)
+
+ if self._vmin == vmin and self._vmax == vmax:
+ return
+
+ self._vmin = vmin
+ self._vmax = vmax
+ self.sigChanged.emit()
+
+ def __getitem__(self, item):
+ if item == 'autoscale':
+ return self.isAutoscale()
+ elif item == 'name':
+ return self.getName()
+ elif item == 'normalization':
+ return self.getNormalization()
+ elif item == 'vmin':
+ return self.getVMin()
+ elif item == 'vmax':
+ return self.getVMax()
+ elif item == 'colors':
+ return self.getColormapLUT()
+ else:
+ raise KeyError(item)
+
+ def _toDict(self):
+ """Return the equivalent colormap as a dictionary
+ (old colormap representation)
+
+ :return: the representation of the Colormap as a dictionary
+ :rtype: dict
+ """
+ return {
+ 'name': self._name,
+ 'colors': copy_mdl.copy(self._colors),
+ 'vmin': self._vmin,
+ 'vmax': self._vmax,
+ 'autoscale': self.isAutoscale(),
+ 'normalization': self._normalization
+ }
+
+ def _setFromDict(self, dic):
+ """Set values to the colormap from a dictionary
+
+ :param dict dic: the colormap as a dictionary
+ """
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
+ name = dic['name'] if 'name' in dic else None
+ colors = dic['colors'] if 'colors' in dic else None
+ vmin = dic['vmin'] if 'vmin' in dic else None
+ vmax = dic['vmax'] if 'vmax' in dic else None
+ if 'normalization' in dic:
+ normalization = dic['normalization']
+ else:
+ warn = 'Normalization not given in the dictionary, '
+ warn += 'set by default to ' + Colormap.LINEAR
+ _logger.warning(warn)
+ normalization = Colormap.LINEAR
+
+ if name is None and colors is None:
+ err = 'The colormap should have a name defined or a tuple of colors'
+ raise ValueError(err)
+ if normalization not in Colormap.NORMALIZATIONS:
+ err = 'Given normalization is not recoginized (%s)' % normalization
+ raise ValueError(err)
+
+ # If autoscale, then set boundaries to None
+ if dic.get('autoscale', False):
+ vmin, vmax = None, None
+
+ self._name = name
+ self._colors = colors
+ self._vmin = vmin
+ self._vmax = vmax
+ self._autoscale = True if (vmin is None and vmax is None) else False
+ self._normalization = normalization
+
+ self.sigChanged.emit()
+
+ @staticmethod
+ def _fromDict(dic):
+ colormap = Colormap(name="")
+ colormap._setFromDict(dic)
+ return colormap
+
+ def copy(self):
+ """Return a copy of the Colormap.
+
+ :rtype: silx.gui.colors.Colormap
+ """
+ return Colormap(name=self._name,
+ colors=copy_mdl.copy(self._colors),
+ vmin=self._vmin,
+ vmax=self._vmax,
+ normalization=self._normalization)
+
+ def applyToData(self, data):
+ """Apply the colormap to the data
+
+ :param numpy.ndarray data: The data to convert.
+ """
+ name = self.getName()
+ if name is not None: # Get colormap definition from matplotlib
+ # FIXME: If possible remove dependency to the plot
+ from .plot.matplotlib import Colormap as MPLColormap
+ mplColormap = MPLColormap.getColormap(name)
+ colors = mplColormap(numpy.linspace(0, 1, 256, endpoint=True))
+ colors = self._convertColorsFromFloatToUint8(colors)
+
+ else: # Use user defined LUT
+ colors = self.getColormapLUT()
+
+ vmin, vmax = self.getColormapRange(data)
+ normalization = self.getNormalization()
+
+ return _cmap(data, colors, vmin, vmax, normalization)
+
+ @staticmethod
+ def getSupportedColormaps():
+ """Get the supported colormap names as a tuple of str.
+
+ The list should at least contain and start by:
+ ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue')
+ :rtype: tuple
+ """
+ # FIXME: If possible remove dependency to the plot
+ from .plot.matplotlib import Colormap as MPLColormap
+ maps = MPLColormap.getSupportedColormaps()
+ return DEFAULT_COLORMAPS + maps
+
+ def __str__(self):
+ return str(self._toDict())
+
+ def _getDefaultMin(self):
+ return DEFAULT_MIN_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MIN_LOG
+
+ def _getDefaultMax(self):
+ return DEFAULT_MAX_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MAX_LOG
+
+ def __eq__(self, other):
+ """Compare colormap values and not pointers"""
+ return (self.getName() == other.getName() and
+ self.getNormalization() == other.getNormalization() and
+ self.getVMin() == other.getVMin() and
+ self.getVMax() == other.getVMax() and
+ numpy.array_equal(self.getColormapLUT(), other.getColormapLUT())
+ )
+
+ _SERIAL_VERSION = 1
+
+ def restoreState(self, byteArray):
+ """
+ Read the colormap state from a QByteArray.
+
+ :param qt.QByteArray byteArray: Stream containing the state
+ :return: True if the restoration sussseed
+ :rtype: bool
+ """
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
+ stream = qt.QDataStream(byteArray, qt.QIODevice.ReadOnly)
+
+ className = stream.readQString()
+ if className != self.__class__.__name__:
+ _logger.warning("Classname mismatch. Found %s." % className)
+ return False
+
+ version = stream.readUInt32()
+ if version != self._SERIAL_VERSION:
+ _logger.warning("Serial version mismatch. Found %d." % version)
+ return False
+
+ name = stream.readQString()
+ isNull = stream.readBool()
+ if not isNull:
+ vmin = stream.readQVariant()
+ else:
+ vmin = None
+ isNull = stream.readBool()
+ if not isNull:
+ vmax = stream.readQVariant()
+ else:
+ vmax = None
+ normalization = stream.readQString()
+
+ # emit change event only once
+ old = self.blockSignals(True)
+ try:
+ self.setName(name)
+ self.setNormalization(normalization)
+ self.setVRange(vmin, vmax)
+ finally:
+ self.blockSignals(old)
+ self.sigChanged.emit()
+ return True
+
+ def saveState(self):
+ """
+ Save state of the colomap into a QDataStream.
+
+ :rtype: qt.QByteArray
+ """
+ data = qt.QByteArray()
+ stream = qt.QDataStream(data, qt.QIODevice.WriteOnly)
+
+ stream.writeQString(self.__class__.__name__)
+ stream.writeUInt32(self._SERIAL_VERSION)
+ stream.writeQString(self.getName())
+ stream.writeBool(self.getVMin() is None)
+ if self.getVMin() is not None:
+ stream.writeQVariant(self.getVMin())
+ stream.writeBool(self.getVMax() is None)
+ if self.getVMax() is not None:
+ stream.writeQVariant(self.getVMax())
+ stream.writeQString(self.getNormalization())
+ return data
+
+
+_PREFERRED_COLORMAPS = None
+"""
+Tuple of preferred colormap names accessed with :meth:`preferredColormaps`.
+"""
+
+
+def preferredColormaps():
+ """Returns the name of the preferred colormaps.
+
+ This list is used by widgets allowing to change the colormap
+ like the :class:`ColormapDialog` as a subset of colormap choices.
+
+ :rtype: tuple of str
+ """
+ global _PREFERRED_COLORMAPS
+ if _PREFERRED_COLORMAPS is None:
+ _PREFERRED_COLORMAPS = DEFAULT_COLORMAPS
+ # Initialize preferred colormaps
+ setPreferredColormaps(('gray', 'reversed gray',
+ 'temperature', 'red', 'green', 'blue', 'jet',
+ 'viridis', 'magma', 'inferno', 'plasma',
+ 'hsv'))
+ return _PREFERRED_COLORMAPS
+
+
+def setPreferredColormaps(colormaps):
+ """Set the list of preferred colormap names.
+
+ Warning: If a colormap name is not available
+ it will be removed from the list.
+
+ :param colormaps: Not empty list of colormap names
+ :type colormaps: iterable of str
+ :raise ValueError: if the list of available preferred colormaps is empty.
+ """
+ supportedColormaps = Colormap.getSupportedColormaps()
+ colormaps = tuple(
+ cmap for cmap in colormaps if cmap in supportedColormaps)
+ if len(colormaps) == 0:
+ raise ValueError("Cannot set preferred colormaps to an empty list")
+
+ global _PREFERRED_COLORMAPS
+ _PREFERRED_COLORMAPS = colormaps
diff --git a/silx/gui/console.py b/silx/gui/console.py
index 3c69419..b6341ef 100644
--- a/silx/gui/console.py
+++ b/silx/gui/console.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -34,9 +34,8 @@ the widgets' methods from the console.
.. note::
This module has a dependency on
- `IPython <https://pypi.python.org/pypi/ipython>`_ and
- `qtconsole <https://pypi.python.org/pypi/qtconsole>`_ (or *ipython.qt* for
- older versions of *IPython*). An ``ImportError`` will be raised if it is
+ `qtconsole <https://pypi.org/project/qtconsole/>`_.
+ An ``ImportError`` will be raised if it is
imported while the dependencies are not satisfied.
Basic usage example::
@@ -76,11 +75,7 @@ from . import qt
_logger = logging.getLogger(__name__)
-try:
- import IPython
-except ImportError as e:
- raise ImportError("Failed to import IPython, required by " + __name__)
-
+
# This widget cannot be used inside an interactive IPython shell.
# It would raise MultipleInstanceError("Multiple incompatible subclass
# instances of InProcessInteractiveShell are being created").
@@ -92,48 +87,14 @@ else:
msg = "Module " + __name__ + " cannot be used within an IPython shell"
raise ImportError(msg)
-# qtconsole is a separate module in recent versions of IPython/Jupyter
-# http://blog.jupyter.org/2015/04/15/the-big-split/
-if IPython.__version__.startswith("2"):
- qtconsole = None
-else:
- try:
- import qtconsole
- except ImportError:
- qtconsole = None
-
-if qtconsole is not None:
- try:
- from qtconsole.rich_ipython_widget import RichJupyterWidget as \
- RichIPythonWidget
- except ImportError:
- try:
- from qtconsole.rich_ipython_widget import RichIPythonWidget
- except ImportError as e:
- qtconsole = None
- else:
- from qtconsole.inprocess import QtInProcessKernelManager
- else:
- from qtconsole.inprocess import QtInProcessKernelManager
-
-
-if qtconsole is None:
- # Import the console machinery from ipython
-
- # The `has_binding` test of IPython does not find the Qt bindings
- # in case silx is used in a frozen binary
- import IPython.external.qt_loaders
-
- def has_binding(*var, **kw):
- return True
-
- IPython.external.qt_loaders.has_binding = has_binding
-
- try:
- from IPython.qtconsole.rich_ipython_widget import RichIPythonWidget
- except ImportError:
- from IPython.qt.console.rich_ipython_widget import RichIPythonWidget
- from IPython.qt.inprocess import QtInProcessKernelManager
+
+try:
+ from qtconsole.rich_ipython_widget import RichJupyterWidget as \
+ RichIPythonWidget
+except ImportError:
+ from qtconsole.rich_ipython_widget import RichIPythonWidget
+
+from qtconsole.inprocess import QtInProcessKernelManager
class IPythonWidget(RichIPythonWidget):
diff --git a/silx/gui/data/DataViewer.py b/silx/gui/data/DataViewer.py
index 5e0b25e..4db2863 100644
--- a/silx/gui/data/DataViewer.py
+++ b/silx/gui/data/DataViewer.py
@@ -37,7 +37,7 @@ from silx.utils.property import classproperty
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "26/02/2018"
+__date__ = "24/04/2018"
_logger = logging.getLogger(__name__)
@@ -167,8 +167,10 @@ class DataViewer(qt.QFrame):
self.__currentAvailableViews = []
self.__currentView = None
self.__data = None
+ self.__info = None
self.__useAxisSelection = False
self.__userSelectedView = None
+ self.__hooks = None
self.__views = []
self.__index = {}
@@ -182,6 +184,15 @@ class DataViewer(qt.QFrame):
self.__views = list(views)
self.setDisplayMode(DataViews.EMPTY_MODE)
+ def setGlobalHooks(self, hooks):
+ """Set a data view hooks for all the views
+
+ :param DataViewHooks context: The hooks to use
+ """
+ self.__hooks = hooks
+ for v in self.__views:
+ v.setHooks(hooks)
+
def createDefaultViews(self, parent=None):
"""Create and returns available views which can be displayed by default
by the data viewer. It is called internally by the widget. It can be
@@ -250,7 +261,7 @@ class DataViewer(qt.QFrame):
"""
previous = self.__numpySelection.blockSignals(True)
self.__numpySelection.clear()
- info = DataViews.DataInfo(self.__data)
+ info = self._getInfo()
axisNames = self.__currentView.axesNames(self.__data, info)
if info.isArray and info.size != 0 and self.__data is not None and axisNames is not None:
self.__useAxisSelection = True
@@ -359,6 +370,8 @@ class DataViewer(qt.QFrame):
:param DataView view: A dataview
"""
+ if self.__hooks is not None:
+ view.setHooks(self.__hooks)
self.__views.append(view)
# TODO It can be skipped if the view do not support the data
self.__updateAvailableViews()
@@ -390,8 +403,8 @@ class DataViewer(qt.QFrame):
Update available views from the current data.
"""
data = self.__data
+ info = self._getInfo()
# sort available views according to priority
- info = DataViews.DataInfo(data)
priorities = [v.getDataPriority(data, info) for v in self.__views]
views = zip(priorities, self.__views)
views = filter(lambda t: t[0] > DataViews.DataView.UNSUPPORTED, views)
@@ -490,6 +503,7 @@ class DataViewer(qt.QFrame):
:param numpy.ndarray data: The data.
"""
self.__data = data
+ self._invalidateInfo()
self.__displayedData = None
self.__updateView()
self.__updateNumpySelectionAxis()
@@ -512,6 +526,21 @@ class DataViewer(qt.QFrame):
"""Returns the data"""
return self.__data
+ def _invalidateInfo(self):
+ """Invalidate DataInfo cache."""
+ self.__info = None
+
+ def _getInfo(self):
+ """Returns the DataInfo of the current selected data.
+
+ This value is cached.
+
+ :rtype: DataInfo
+ """
+ if self.__info is None:
+ self.__info = DataViews.DataInfo(self.__data)
+ return self.__info
+
def displayMode(self):
"""Returns the current display mode"""
return self.__currentView.modeId()
@@ -552,6 +581,8 @@ class DataViewer(qt.QFrame):
isReplaced = False
for idx, view in enumerate(self.__views):
if view.modeId() == modeId:
+ if self.__hooks is not None:
+ newView.setHooks(self.__hooks)
self.__views[idx] = newView
isReplaced = True
break
diff --git a/silx/gui/data/DataViewerFrame.py b/silx/gui/data/DataViewerFrame.py
index 89a9992..4e6d2e8 100644
--- a/silx/gui/data/DataViewerFrame.py
+++ b/silx/gui/data/DataViewerFrame.py
@@ -27,7 +27,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "21/09/2017"
+__date__ = "24/04/2018"
from silx.gui import qt
from .DataViewer import DataViewer
@@ -113,6 +113,13 @@ class DataViewerFrame(qt.QWidget):
"""Called when the displayed view changes"""
self.displayedViewChanged.emit(view)
+ def setGlobalHooks(self, hooks):
+ """Set a data view hooks for all the views
+
+ :param DataViewHooks context: The hooks to use
+ """
+ self.__dataViewer.setGlobalHooks(hooks)
+
def availableViews(self):
"""Returns the list of registered views
diff --git a/silx/gui/data/DataViews.py b/silx/gui/data/DataViews.py
index ef69441..2291e87 100644
--- a/silx/gui/data/DataViews.py
+++ b/silx/gui/data/DataViews.py
@@ -35,13 +35,13 @@ from silx.gui import qt, icons
from silx.gui.data.TextFormatter import TextFormatter
from silx.io import nxdata
from silx.gui.hdf5 import H5Node
-from silx.io.nxdata import get_attr_as_string
-from silx.gui.plot.Colormap import Colormap
-from silx.gui.plot.actions.control import ColormapAction
+from silx.io.nxdata import get_attr_as_unicode
+from silx.gui.colors import Colormap
+from silx.gui.dialog.ColormapDialog import ColormapDialog
__authors__ = ["V. Valls", "P. Knobel"]
__license__ = "MIT"
-__date__ = "23/01/2018"
+__date__ = "23/05/2018"
_logger = logging.getLogger(__name__)
@@ -109,6 +109,7 @@ class DataInfo(object):
self.isBoolean = False
self.isRecord = False
self.hasNXdata = False
+ self.isInvalidNXdata = False
self.shape = tuple()
self.dim = 0
self.size = 0
@@ -118,8 +119,28 @@ class DataInfo(object):
if silx.io.is_group(data):
nxd = nxdata.get_default(data)
+ nx_class = get_attr_as_unicode(data, "NX_class")
if nxd is not None:
self.hasNXdata = True
+ # can we plot it?
+ is_scalar = nxd.signal_is_0d or nxd.interpretation in ["scalar", "scaler"]
+ if not (is_scalar or nxd.is_curve or nxd.is_x_y_value_scatter or
+ nxd.is_image or nxd.is_stack):
+ # invalid: cannot be plotted by any widget
+ self.isInvalidNXdata = True
+ elif nx_class == "NXdata":
+ # group claiming to be NXdata could not be parsed
+ self.isInvalidNXdata = True
+ elif nx_class == "NXentry" and "default" in data.attrs:
+ # entry claiming to have a default NXdata could not be parsed
+ self.isInvalidNXdata = True
+ elif nx_class == "NXroot" or silx.io.is_file(data):
+ # root claiming to have a default entry
+ if "default" in data.attrs:
+ def_entry = data.attrs["default"]
+ if def_entry in data and "default" in data[def_entry].attrs:
+ # and entry claims to have default NXdata
+ self.isInvalidNXdata = True
if isinstance(data, numpy.ndarray):
self.isArray = True
@@ -130,7 +151,7 @@ class DataInfo(object):
if silx.io.is_dataset(data):
if "interpretation" in data.attrs:
- self.interpretation = get_attr_as_string(data, "interpretation")
+ self.interpretation = get_attr_as_unicode(data, "interpretation")
else:
self.interpretation = None
elif self.hasNXdata:
@@ -166,7 +187,11 @@ class DataInfo(object):
if self.shape is not None:
self.dim = len(self.shape)
- if hasattr(data, "size"):
+ if hasattr(data, "shape") and data.shape is None:
+ # This test is expected to avoid to fall done on the h5py issue
+ # https://github.com/h5py/h5py/issues/1044
+ self.size = 0
+ elif hasattr(data, "size"):
self.size = int(data.size)
else:
self.size = 1
@@ -177,6 +202,18 @@ class DataInfo(object):
return _normalizeData(data)
+class DataViewHooks(object):
+ """A set of hooks defined to custom the behaviour of the data views."""
+
+ def getColormap(self, view):
+ """Returns a colormap for this view."""
+ return None
+
+ def getColormapDialog(self, view):
+ """Returns a color dialog for this view."""
+ return None
+
+
class DataView(object):
"""Holder for the data view."""
@@ -184,12 +221,6 @@ class DataView(object):
"""Priority returned when the requested data can't be displayed by the
view."""
- _defaultColormap = None
- """Store a default colormap shared with all the views"""
-
- _defaultColorDialog = None
- """Store a default color dialog shared with all the views"""
-
def __init__(self, parent, modeId=None, icon=None, label=None):
"""Constructor
@@ -204,32 +235,46 @@ class DataView(object):
if icon is None:
icon = qt.QIcon()
self.__icon = icon
+ self.__hooks = None
- @staticmethod
- def defaultColormap():
- """Returns a shared colormap as default for all the views.
+ def getHooks(self):
+ """Returns the data viewer hooks used by this view.
- :rtype: Colormap
+ :rtype: DataViewHooks
"""
- if DataView._defaultColormap is None:
- DataView._defaultColormap = Colormap(name="viridis")
- return DataView._defaultColormap
+ return self.__hooks
- @staticmethod
- def defaultColorDialog():
- """Returns a shared color dialog as default for all the views.
+ def setHooks(self, hooks):
+ """Set the data view hooks to use with this view.
- :rtype: ColorDialog
+ :param DataViewHooks hooks: The data view hooks to use
"""
- if DataView._defaultColorDialog is None:
- DataView._defaultColorDialog = ColormapAction._createDialog(qt.QApplication.instance().activeWindow())
- return DataView._defaultColorDialog
+ self.__hooks = hooks
- @staticmethod
- def _cleanUpCache():
- """Clean up the cache. Needed for tests"""
- DataView._defaultColormap = None
- DataView._defaultColorDialog = None
+ def defaultColormap(self):
+ """Returns a default colormap.
+
+ :rtype: Colormap
+ """
+ colormap = None
+ if self.__hooks is not None:
+ colormap = self.__hooks.getColormap(self)
+ if colormap is None:
+ colormap = Colormap(name="viridis")
+ return colormap
+
+ def defaultColorDialog(self):
+ """Returns a default color dialog.
+
+ :rtype: ColormapDialog
+ """
+ dialog = None
+ if self.__hooks is not None:
+ dialog = self.__hooks.getColormapDialog(self)
+ if dialog is None:
+ dialog = ColormapDialog()
+ dialog.setModal(False)
+ return dialog
def icon(self):
"""Returns the default icon"""
@@ -345,8 +390,21 @@ class CompositeDataView(DataView):
self.__views = OrderedDict()
self.__currentView = None
+ def setHooks(self, hooks):
+ """Set the data context to use with this view.
+
+ :param DataViewHooks hooks: The data view hooks to use
+ """
+ super(CompositeDataView, self).setHooks(hooks)
+ if hooks is not None:
+ for v in self.__views:
+ v.setHooks(hooks)
+
def addView(self, dataView):
"""Add a new dataview to the available list."""
+ hooks = self.getHooks()
+ if hooks is not None:
+ dataView.setHooks(hooks)
self.__views[dataView] = None
def availableViews(self):
@@ -446,6 +504,9 @@ class CompositeDataView(DataView):
break
elif isinstance(view, CompositeDataView):
# recurse
+ hooks = self.getHooks()
+ if hooks is not None:
+ newView.setHooks(hooks)
if view.replaceView(modeId, newView):
return True
if oldView is None:
@@ -1022,70 +1083,46 @@ class _InvalidNXdataView(DataView):
def getDataPriority(self, data, info):
data = self.normalizeData(data)
- if silx.io.is_group(data):
- nxd = nxdata.get_default(data)
- nx_class = get_attr_as_string(data, "NX_class")
-
- if nxd is None:
- if nx_class == "NXdata":
- # invalid: could not even be parsed by NXdata
- self._msg = "Group has @NX_class = NXdata, but could not be interpreted"
- self._msg += " as valid NXdata."
- return 100
- elif nx_class == "NXentry":
- if "default" not in data.attrs:
- # no link to NXdata, no problem
- return DataView.UNSUPPORTED
- self._msg = "NXentry group provides a @default attribute,"
- default_nxdata_name = data.attrs["default"]
- if default_nxdata_name not in data:
- self._msg += " but no corresponding NXdata group exists."
- elif get_attr_as_string(data[default_nxdata_name], "NX_class") != "NXdata":
- self._msg += " but the corresponding item is not a "
- self._msg += "NXdata group."
- else:
- self._msg += " but the corresponding NXdata seems to be"
- self._msg += " malformed."
- return 100
- elif nx_class == "NXroot" or silx.io.is_file(data):
- if "default" not in data.attrs:
- # no link to NXentry, no problem
- return DataView.UNSUPPORTED
- default_entry_name = data.attrs["default"]
- if default_entry_name not in data:
- # this is a problem, but not NXdata related
- return DataView.UNSUPPORTED
- default_entry = data[default_entry_name]
- if "default" not in default_entry.attrs:
- # no NXdata specified, no problemo
- return DataView.UNSUPPORTED
- default_nxdata_name = default_entry.attrs["default"]
- self._msg = "NXroot group provides a @default attribute "
- self._msg += "pointing to a NXentry which defines its own "
- self._msg += "@default attribute, "
- if default_nxdata_name not in default_entry:
- self._msg += " but no corresponding NXdata group exists."
- elif get_attr_as_string(default_entry[default_nxdata_name],
- "NX_class") != "NXdata":
- self._msg += " but the corresponding item is not a "
- self._msg += "NXdata group."
- else:
- self._msg += " but the corresponding NXdata seems to be"
- self._msg += " malformed."
- return 100
- else:
- # Not pretending to be NXdata, no problem
- return DataView.UNSUPPORTED
-
- is_scalar = nxd.signal_is_0d or nxd.interpretation in ["scalar", "scaler"]
- if not (is_scalar or nxd.is_curve or nxd.is_x_y_value_scatter or
- nxd.is_image or nxd.is_stack):
- # invalid: cannot be plotted by any widget (I cannot imagine a case)
- self._msg = "NXdata seems valid, but cannot be displayed "
- self._msg += "by any existing plot widget."
- return 100
- return DataView.UNSUPPORTED
+ if not info.isInvalidNXdata:
+ return DataView.UNSUPPORTED
+
+ if info.hasNXdata:
+ self._msg = "NXdata seems valid, but cannot be displayed "
+ self._msg += "by any existing plot widget."
+ else:
+ nx_class = get_attr_as_unicode(data, "NX_class")
+ if nx_class == "NXdata":
+ # invalid: could not even be parsed by NXdata
+ self._msg = "Group has @NX_class = NXdata, but could not be interpreted"
+ self._msg += " as valid NXdata."
+ elif nx_class == "NXentry":
+ self._msg = "NXentry group provides a @default attribute,"
+ default_nxdata_name = data.attrs["default"]
+ if default_nxdata_name not in data:
+ self._msg += " but no corresponding NXdata group exists."
+ elif get_attr_as_unicode(data[default_nxdata_name], "NX_class") != "NXdata":
+ self._msg += " but the corresponding item is not a "
+ self._msg += "NXdata group."
+ else:
+ self._msg += " but the corresponding NXdata seems to be"
+ self._msg += " malformed."
+ elif nx_class == "NXroot" or silx.io.is_file(data):
+ default_entry = data[data.attrs["default"]]
+ default_nxdata_name = default_entry.attrs["default"]
+ self._msg = "NXroot group provides a @default attribute "
+ self._msg += "pointing to a NXentry which defines its own "
+ self._msg += "@default attribute, "
+ if default_nxdata_name not in default_entry:
+ self._msg += " but no corresponding NXdata group exists."
+ elif get_attr_as_unicode(default_entry[default_nxdata_name],
+ "NX_class") != "NXdata":
+ self._msg += " but the corresponding item is not a "
+ self._msg += "NXdata group."
+ else:
+ self._msg += " but the corresponding NXdata seems to be"
+ self._msg += " malformed."
+ return 100
class _NXdataScalarView(DataView):
@@ -1111,7 +1148,7 @@ class _NXdataScalarView(DataView):
def setData(self, data):
data = self.normalizeData(data)
# data could be a NXdata or an NXentry
- nxd = nxdata.get_default(data)
+ nxd = nxdata.get_default(data, validate=False)
signal = nxd.signal
self.getWidget().setArrayData(signal,
labels=True)
@@ -1119,8 +1156,8 @@ class _NXdataScalarView(DataView):
def getDataPriority(self, data, info):
data = self.normalizeData(data)
- if info.hasNXdata:
- nxd = nxdata.get_default(data)
+ if info.hasNXdata and not info.isInvalidNXdata:
+ nxd = nxdata.get_default(data, validate=False)
if nxd.signal_is_0d or nxd.interpretation in ["scalar", "scaler"]:
return 100
return DataView.UNSUPPORTED
@@ -1151,7 +1188,7 @@ class _NXdataCurveView(DataView):
def setData(self, data):
data = self.normalizeData(data)
- nxd = nxdata.get_default(data)
+ nxd = nxdata.get_default(data, validate=False)
signals_names = [nxd.signal_name] + nxd.auxiliary_signals_names
if nxd.axes_dataset_names[-1] is not None:
x_errors = nxd.get_axis_errors(nxd.axes_dataset_names[-1])
@@ -1177,8 +1214,8 @@ class _NXdataCurveView(DataView):
def getDataPriority(self, data, info):
data = self.normalizeData(data)
- if info.hasNXdata:
- if nxdata.get_default(data).is_curve:
+ if info.hasNXdata and not info.isInvalidNXdata:
+ if nxdata.get_default(data, validate=False).is_curve:
return 100
return DataView.UNSUPPORTED
@@ -1204,8 +1241,13 @@ class _NXdataXYVScatterView(DataView):
def setData(self, data):
data = self.normalizeData(data)
- nxd = nxdata.get_default(data)
+ nxd = nxdata.get_default(data, validate=False)
+
x_axis, y_axis = nxd.axes[-2:]
+ if x_axis is None:
+ x_axis = numpy.arange(nxd.signal.size)
+ if y_axis is None:
+ y_axis = numpy.arange(nxd.signal.size)
x_label, y_label = nxd.axes_names[-2:]
if x_label is not None:
@@ -1226,8 +1268,8 @@ class _NXdataXYVScatterView(DataView):
def getDataPriority(self, data, info):
data = self.normalizeData(data)
- if info.hasNXdata:
- if nxdata.get_default(data).is_x_y_value_scatter:
+ if info.hasNXdata and not info.isInvalidNXdata:
+ if nxdata.get_default(data, validate=False).is_x_y_value_scatter:
return 100
return DataView.UNSUPPORTED
@@ -1256,7 +1298,7 @@ class _NXdataImageView(DataView):
def setData(self, data):
data = self.normalizeData(data)
- nxd = nxdata.get_default(data)
+ nxd = nxdata.get_default(data, validate=False)
isRgba = nxd.interpretation == "rgba-image"
# last two axes are Y & X
@@ -1274,8 +1316,8 @@ class _NXdataImageView(DataView):
def getDataPriority(self, data, info):
data = self.normalizeData(data)
- if info.hasNXdata:
- if nxdata.get_default(data).is_image:
+ if info.hasNXdata and not info.isInvalidNXdata:
+ if nxdata.get_default(data, validate=False).is_image:
return 100
return DataView.UNSUPPORTED
@@ -1302,7 +1344,7 @@ class _NXdataStackView(DataView):
def setData(self, data):
data = self.normalizeData(data)
- nxd = nxdata.get_default(data)
+ nxd = nxdata.get_default(data, validate=False)
signal_name = nxd.signal_name
z_axis, y_axis, x_axis = nxd.axes[-3:]
z_label, y_label, x_label = nxd.axes_names[-3:]
@@ -1319,8 +1361,8 @@ class _NXdataStackView(DataView):
def getDataPriority(self, data, info):
data = self.normalizeData(data)
- if info.hasNXdata:
- if nxdata.get_default(data).is_stack:
+ if info.hasNXdata and not info.isInvalidNXdata:
+ if nxdata.get_default(data, validate=False).is_stack:
return 100
return DataView.UNSUPPORTED
diff --git a/silx/gui/data/Hdf5TableView.py b/silx/gui/data/Hdf5TableView.py
index e4a0747..04199b2 100644
--- a/silx/gui/data/Hdf5TableView.py
+++ b/silx/gui/data/Hdf5TableView.py
@@ -30,8 +30,9 @@ from __future__ import division
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "10/10/2017"
+__date__ = "23/05/2018"
+import collections
import functools
import os.path
import logging
@@ -41,6 +42,7 @@ from .TextFormatter import TextFormatter
import silx.gui.hdf5
from silx.gui.widgets import HierarchicalTableView
from ..hdf5.Hdf5Formatter import Hdf5Formatter
+from ..hdf5._utils import htmlFromDict
try:
import h5py
@@ -54,7 +56,7 @@ _logger = logging.getLogger(__name__)
class _CellData(object):
"""Store a table item
"""
- def __init__(self, value=None, isHeader=False, span=None):
+ def __init__(self, value=None, isHeader=False, span=None, tooltip=None):
"""
Constructor
@@ -65,6 +67,7 @@ class _CellData(object):
self.__value = value
self.__isHeader = isHeader
self.__span = span
+ self.__tooltip = tooltip
def isHeader(self):
"""Returns true if the property is a sub-header title.
@@ -85,6 +88,19 @@ class _CellData(object):
"""
return self.__span
+ def tooltip(self):
+ """Returns the tooltip of the item.
+
+ :rtype: tuple
+ """
+ return self.__tooltip
+
+ def invalidateValue(self):
+ self.__value = None
+
+ def invalidateToolTip(self):
+ self.__tooltip = None
+
class _TableData(object):
"""Modelize a table with header, row and column span.
@@ -143,7 +159,7 @@ class _TableData(object):
item = _CellData(value=headerLabel, isHeader=True, span=(1, self.__colCount))
self.__data.append([item])
- def addHeaderValueRow(self, headerLabel, value):
+ def addHeaderValueRow(self, headerLabel, value, tooltip=None):
"""Append the table with a row using the first column as an header and
other cells as a single cell for the value.
@@ -151,7 +167,7 @@ class _TableData(object):
:param object value: value to store.
"""
header = _CellData(value=headerLabel, isHeader=True)
- value = _CellData(value=value, span=(1, self.__colCount))
+ value = _CellData(value=value, span=(1, self.__colCount), tooltip=tooltip)
self.__data.append([header, value])
def addRow(self, *args):
@@ -214,7 +230,20 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
elif role == qt.Qt.DisplayRole:
value = cell.value()
if callable(value):
- value = value(self.__obj)
+ try:
+ value = value(self.__obj)
+ except Exception:
+ cell.invalidateValue()
+ raise
+ return value
+ elif role == qt.Qt.ToolTipRole:
+ value = cell.tooltip()
+ if callable(value):
+ try:
+ value = value(self.__obj)
+ except Exception:
+ cell.invalidateToolTip()
+ raise
return value
return None
@@ -260,6 +289,14 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
"""Format the HDF5 type"""
return self.__hdf5Formatter.humanReadableHdf5Type(dataset)
+ def __attributeTooltip(self, attribute):
+ attributeDict = collections.OrderedDict()
+ if hasattr(attribute, "shape"):
+ attributeDict["Shape"] = self.__hdf5Formatter.humanReadableShape(attribute)
+ attributeDict["Data type"] = self.__hdf5Formatter.humanReadableType(attribute, full=True)
+ html = htmlFromDict(attributeDict, title="HDF5 Attribute")
+ return html
+
def __formatDType(self, dataset):
"""Format the numpy dtype"""
return self.__hdf5Formatter.humanReadableType(dataset, full=True)
@@ -310,7 +347,8 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
# it's a real H5py object
self.__data.addHeaderValueRow("Basename", lambda x: os.path.basename(x.name))
self.__data.addHeaderValueRow("Name", lambda x: x.name)
- self.__data.addHeaderValueRow("File", lambda x: x.file.filename)
+ if obj.file is not None:
+ self.__data.addHeaderValueRow("File", lambda x: x.file.filename)
if hasattr(obj, "path"):
# That's a link
@@ -322,8 +360,11 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
else:
if silx.io.is_file(obj):
physical = lambda x: x.filename + SEPARATOR + x.name
+ elif obj.file is not None:
+ physical = lambda x: x.file.filename + SEPARATOR + x.name
else:
- physical = lambda x: x.file.filename + SEPARATOR + x.name
+ # Guess it is a virtual node
+ physical = "No physical location"
self.__data.addHeaderValueRow("Physical", physical)
if hasattr(obj, "dtype"):
@@ -367,7 +408,10 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
self.__data.addHeaderRow(headerLabel="Attributes")
for key in sorted(obj.attrs.keys()):
callback = lambda key, x: self.__formatter.toString(x.attrs[key])
- self.__data.addHeaderValueRow(headerLabel=key, value=functools.partial(callback, key))
+ callbackTooltip = lambda key, x: self.__attributeTooltip(x.attrs[key])
+ self.__data.addHeaderValueRow(headerLabel=key,
+ value=functools.partial(callback, key),
+ tooltip=functools.partial(callbackTooltip, key))
def __get_filter_info(self, dataset, filterIndex):
"""Get a tuple of readable info from dataset filters
@@ -447,7 +491,7 @@ class Hdf5TableView(HierarchicalTableView.HierarchicalTableView):
def setData(self, data):
"""Set the h5py-like object exposed by the model
- :param h5pyObject: A h5py-like object. It can be a `h5py.Dataset`,
+ :param data: A h5py-like object. It can be a `h5py.Dataset`,
a `h5py.File`, a `h5py.Group`. It also can be a,
`silx.gui.hdf5.H5Node` which is needed to display some local path
information.
diff --git a/silx/gui/data/HexaTableView.py b/silx/gui/data/HexaTableView.py
index 1b2a7e9..c86c0af 100644
--- a/silx/gui/data/HexaTableView.py
+++ b/silx/gui/data/HexaTableView.py
@@ -37,7 +37,7 @@ from silx.gui.widgets.TableWidget import CopySelectedCellsAction
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "27/09/2017"
+__date__ = "23/05/2018"
class _VoidConnector(object):
@@ -54,7 +54,13 @@ class _VoidConnector(object):
def __getBuffer(self, bufferId):
if bufferId not in self.__cache:
pos = bufferId << 10
- data = self.__data.tobytes()[pos:pos + 1024]
+ data = self.__data
+ if hasattr(data, "tobytes"):
+ data = data.tobytes()[pos:pos + 1024]
+ else:
+ # Old fashion
+ data = data.data[pos:pos + 1024]
+
self.__cache[bufferId] = data
if len(self.__cache) > 32:
self.__cache.popitem()
diff --git a/silx/gui/data/NXdataWidgets.py b/silx/gui/data/NXdataWidgets.py
index ae2911d..1bf5425 100644
--- a/silx/gui/data/NXdataWidgets.py
+++ b/silx/gui/data/NXdataWidgets.py
@@ -26,14 +26,14 @@
"""
__authors__ = ["P. Knobel"]
__license__ = "MIT"
-__date__ = "20/12/2017"
+__date__ = "24/04/2018"
import numpy
from silx.gui import qt
from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector
-from silx.gui.plot import Plot1D, Plot2D, StackView
-from silx.gui.plot.Colormap import Colormap
+from silx.gui.plot import Plot1D, Plot2D, StackView, ScatterView
+from silx.gui.colors import Colormap
from silx.gui.widgets.FrameBrowser import HorizontalSliderWithBrowser
from silx.math.calibration import ArrayCalibration, NoCalibration, LinearCalibration
@@ -211,10 +211,10 @@ class XYVScatterPlot(qt.QWidget):
self.__y_axis_name = None
self.__y_axis_errors = None
- self._plot = Plot1D(self)
- self._plot.setDefaultColormap(Colormap(name="viridis",
- vmin=None, vmax=None,
- normalization=Colormap.LINEAR))
+ self._plot = ScatterView(self)
+ self._plot.setColormap(Colormap(name="viridis",
+ vmin=None, vmax=None,
+ normalization=Colormap.LINEAR))
self._slider = HorizontalSliderWithBrowser(parent=self)
self._slider.setMinimum(0)
@@ -235,9 +235,9 @@ class XYVScatterPlot(qt.QWidget):
def getPlot(self):
"""Returns the plot used for the display
- :rtype: Plot1D
+ :rtype: PlotWidget
"""
- return self._plot
+ return self._plot.getPlotWidget()
def setScattersData(self, y, x, values,
yerror=None, xerror=None,
@@ -284,8 +284,6 @@ class XYVScatterPlot(qt.QWidget):
x = self.__x_axis
y = self.__y_axis
- self._plot.remove(kind=("scatter", ))
-
idx = self._slider.value()
title = ""
@@ -294,16 +292,15 @@ class XYVScatterPlot(qt.QWidget):
title += self.__scatter_titles[idx] # scatter dataset name
self._plot.setGraphTitle(title)
- self._plot.addScatter(x, y, self.__values[idx],
- legend="scatter%d" % idx,
- xerror=self.__x_axis_errors,
- yerror=self.__y_axis_errors)
+ self._plot.setData(x, y, self.__values[idx],
+ xerror=self.__x_axis_errors,
+ yerror=self.__y_axis_errors)
self._plot.resetZoom()
self._plot.getXAxis().setLabel(self.__x_axis_name)
self._plot.getYAxis().setLabel(self.__y_axis_name)
def clear(self):
- self._plot.clear()
+ self._plot.getPlotWidget().clear()
class ArrayImagePlot(qt.QWidget):
@@ -476,7 +473,8 @@ class ArrayImagePlot(qt.QWidget):
scale = (xscale, yscale)
self._plot.addImage(image, legend=legend,
- origin=origin, scale=scale)
+ origin=origin, scale=scale,
+ replace=True)
else:
scatterx, scattery = numpy.meshgrid(x_axis, y_axis)
# fixme: i don't think this can handle "irregular" RGBA images
diff --git a/silx/gui/data/TextFormatter.py b/silx/gui/data/TextFormatter.py
index 332625c..8440509 100644
--- a/silx/gui/data/TextFormatter.py
+++ b/silx/gui/data/TextFormatter.py
@@ -27,7 +27,7 @@ data module to format data as text in the same way."""
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "13/12/2017"
+__date__ = "25/06/2018"
import numpy
import numbers
@@ -204,7 +204,7 @@ class TextFormatter(qt.QObject):
def __formatBinary(self, data):
if isinstance(data, numpy.void):
if six.PY2:
- data = [ord(d) for d in data.item()]
+ data = [ord(d) for d in data.data]
else:
data = data.item().astype(numpy.uint8)
elif six.PY2:
@@ -266,6 +266,8 @@ class TextFormatter(qt.QObject):
elif vlen == six.binary_type:
# HDF5 ASCII
return self.__formatCharString(data)
+ elif isinstance(vlen, numpy.dtype):
+ return self.toString(data, vlen)
return None
def toString(self, data, dtype=None):
@@ -291,11 +293,17 @@ class TextFormatter(qt.QObject):
else:
text = [self.toString(d, dtype) for d in data]
return "[" + " ".join(text) + "]"
+ if dtype is not None and dtype.kind == 'O':
+ text = self.__formatH5pyObject(data, dtype)
+ if text is not None:
+ return text
elif isinstance(data, numpy.void):
if dtype is None:
dtype = data.dtype
- if data.dtype.fields is not None:
- text = [self.toString(data[f], dtype) for f in dtype.fields]
+ if dtype.fields is not None:
+ text = []
+ for index, field in enumerate(dtype.fields.items()):
+ text.append(field[0] + ":" + self.toString(data[index], field[1][0]))
return "(" + " ".join(text) + ")"
return self.__formatBinary(data)
elif isinstance(data, (numpy.unicode_, six.text_type)):
@@ -340,7 +348,7 @@ class TextFormatter(qt.QObject):
elif isinstance(data, (numbers.Real, numpy.floating)):
# It have to be done before complex checking
return self.__floatFormat % data
- elif isinstance(data, (numpy.complex_, numbers.Complex)):
+ elif isinstance(data, (numpy.complexfloating, numbers.Complex)):
text = ""
if data.real != 0:
text += self.__floatFormat % data.real
diff --git a/silx/gui/data/test/test_dataviewer.py b/silx/gui/data/test/test_dataviewer.py
index 274df92..f3c2808 100644
--- a/silx/gui/data/test/test_dataviewer.py
+++ b/silx/gui/data/test/test_dataviewer.py
@@ -24,7 +24,7 @@
# ###########################################################################*/
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "22/02/2018"
+__date__ = "23/04/2018"
import os
import tempfile
@@ -208,7 +208,6 @@ class AbstractDataViewerTests(TestCaseQt):
self.assertEquals(widget.displayedView().modeId(), DataViews.RAW_MODE)
widget.setDisplayMode(DataViews.EMPTY_MODE)
self.assertEquals(widget.displayedView().modeId(), DataViews.EMPTY_MODE)
- DataView._cleanUpCache()
def test_create_default_views(self):
widget = self.create_widget()
@@ -287,7 +286,6 @@ class TestDataView(TestCaseQt):
dataViewClass = DataViews._Plot2dView
widget = self.createDataViewWithData(dataViewClass, data[0])
self.qWaitForWindowExposed(widget)
- DataView._cleanUpCache()
def testCubeWithComplex(self):
self.skipTest("OpenGL widget not yet tested")
@@ -299,14 +297,12 @@ class TestDataView(TestCaseQt):
dataViewClass = DataViews._Plot3dView
widget = self.createDataViewWithData(dataViewClass, data)
self.qWaitForWindowExposed(widget)
- DataView._cleanUpCache()
def testImageStackWithComplex(self):
data = self.createComplexData()
dataViewClass = DataViews._StackView
widget = self.createDataViewWithData(dataViewClass, data)
self.qWaitForWindowExposed(widget)
- DataView._cleanUpCache()
def suite():
diff --git a/silx/gui/dialog/AbstractDataFileDialog.py b/silx/gui/dialog/AbstractDataFileDialog.py
index 1bd52bb..cb6711c 100644
--- a/silx/gui/dialog/AbstractDataFileDialog.py
+++ b/silx/gui/dialog/AbstractDataFileDialog.py
@@ -28,7 +28,7 @@ This module contains an :class:`AbstractDataFileDialog`.
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "12/02/2018"
+__date__ = "05/03/2018"
import sys
@@ -494,7 +494,9 @@ class _CatchResizeEvent(qt.QObject):
class AbstractDataFileDialog(qt.QDialog):
"""The `AbstractFileDialog` provides a generic GUI to create a custom dialog
- allowing to access to file resources like HDF5 files or HDF5 datasets
+ allowing to access to file resources like HDF5 files or HDF5 datasets.
+
+ .. image:: img/abstractdatafiledialog.png
The dialog contains:
diff --git a/silx/gui/dialog/ColormapDialog.py b/silx/gui/dialog/ColormapDialog.py
new file mode 100644
index 0000000..ed10728
--- /dev/null
+++ b/silx/gui/dialog/ColormapDialog.py
@@ -0,0 +1,986 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""A QDialog widget to set-up the colormap.
+
+It uses a description of colormaps as dict compatible with :class:`Plot`.
+
+To run the following sample code, a QApplication must be initialized.
+
+Create the colormap dialog and set the colormap description and data range:
+
+>>> from silx.gui.dialog.ColormapDialog import ColormapDialog
+>>> from silx.gui.colors import Colormap
+
+>>> dialog = ColormapDialog()
+>>> colormap = Colormap(name='red', normalization='log',
+... vmin=1., vmax=2.)
+
+>>> dialog.setColormap(colormap)
+>>> colormap.setVRange(1., 100.) # This scale the width of the plot area
+>>> dialog.show()
+
+Get the colormap description (compatible with :class:`Plot`) from the dialog:
+
+>>> cmap = dialog.getColormap()
+>>> cmap.getName()
+'red'
+
+It is also possible to display an histogram of the image in the dialog.
+This updates the data range with the range of the bins.
+
+>>> import numpy
+>>> image = numpy.random.normal(size=512 * 512).reshape(512, -1)
+>>> hist, bin_edges = numpy.histogram(image, bins=10)
+>>> dialog.setHistogram(hist, bin_edges)
+
+The updates of the colormap description are also available through the signal:
+:attr:`ColormapDialog.sigColormapChanged`.
+""" # noqa
+
+from __future__ import division
+
+__authors__ = ["V.A. Sole", "T. Vincent", "H. Payno"]
+__license__ = "MIT"
+__date__ = "23/05/2018"
+
+
+import logging
+
+import numpy
+
+from .. import qt
+from ..colors import Colormap, preferredColormaps
+from ..plot import PlotWidget
+from silx.gui.widgets.FloatEdit import FloatEdit
+import weakref
+from silx.math.combo import min_max
+from silx.third_party import enum
+from silx.gui import icons
+from silx.math.histogram import Histogramnd
+
+_logger = logging.getLogger(__name__)
+
+
+_colormapIconPreview = {}
+
+
+class _BoundaryWidget(qt.QWidget):
+ """Widget to edit a boundary of the colormap (vmin, vmax)"""
+ sigValueChanged = qt.Signal(object)
+ """Signal emitted when value is changed"""
+
+ def __init__(self, parent=None, value=0.0):
+ qt.QWidget.__init__(self, parent=None)
+ self.setLayout(qt.QHBoxLayout())
+ self.layout().setContentsMargins(0, 0, 0, 0)
+ self._numVal = FloatEdit(parent=self, value=value)
+ self.layout().addWidget(self._numVal)
+ self._autoCB = qt.QCheckBox('auto', parent=self)
+ self.layout().addWidget(self._autoCB)
+ self._autoCB.setChecked(False)
+
+ self._autoCB.toggled.connect(self._autoToggled)
+ self.sigValueChanged = self._autoCB.toggled
+ self.textEdited = self._numVal.textEdited
+ self.editingFinished = self._numVal.editingFinished
+ self._dataValue = None
+
+ def isAutoChecked(self):
+ return self._autoCB.isChecked()
+
+ def getValue(self):
+ return None if self._autoCB.isChecked() else self._numVal.value()
+
+ def getFiniteValue(self):
+ if not self._autoCB.isChecked():
+ return self._numVal.value()
+ elif self._dataValue is None:
+ return self._numVal.value()
+ else:
+ return self._dataValue
+
+ def _autoToggled(self, enabled):
+ self._numVal.setEnabled(not enabled)
+ self._updateDisplayedText()
+
+ def _updateDisplayedText(self):
+ # if dataValue is finite
+ if self._autoCB.isChecked() and self._dataValue is not None:
+ old = self._numVal.blockSignals(True)
+ self._numVal.setValue(self._dataValue)
+ self._numVal.blockSignals(old)
+
+ def setDataValue(self, dataValue):
+ self._dataValue = dataValue
+ self._updateDisplayedText()
+
+ def setFiniteValue(self, value):
+ assert(value is not None)
+ old = self._numVal.blockSignals(True)
+ self._numVal.setValue(value)
+ self._numVal.blockSignals(old)
+
+ def setValue(self, value, isAuto=False):
+ self._autoCB.setChecked(isAuto or value is None)
+ if value is not None:
+ self._numVal.setValue(value)
+ self._updateDisplayedText()
+
+
+class _ColormapNameCombox(qt.QComboBox):
+ def __init__(self, parent=None):
+ qt.QComboBox.__init__(self, parent)
+ self.__initItems()
+
+ ORIGINAL_NAME = qt.Qt.UserRole + 1
+
+ def __initItems(self):
+ for colormapName in preferredColormaps():
+ index = self.count()
+ self.addItem(str.title(colormapName))
+ self.setItemIcon(index, self.getIconPreview(colormapName))
+ self.setItemData(index, colormapName, role=self.ORIGINAL_NAME)
+
+ def getIconPreview(self, colormapName):
+ """Return an icon preview from a LUT name.
+
+ This icons are cached into a global structure.
+
+ :param str colormapName: str
+ :rtype: qt.QIcon
+ """
+ if colormapName not in _colormapIconPreview:
+ icon = self.createIconPreview(colormapName)
+ _colormapIconPreview[colormapName] = icon
+ return _colormapIconPreview[colormapName]
+
+ def createIconPreview(self, colormapName):
+ """Create and return an icon preview from a LUT name.
+
+ This icons are cached into a global structure.
+
+ :param str colormapName: Name of the LUT
+ :rtype: qt.QIcon
+ """
+ colormap = Colormap(colormapName)
+ size = 32
+ lut = colormap.getNColors(size)
+ if lut is None or len(lut) == 0:
+ return qt.QIcon()
+
+ pixmap = qt.QPixmap(size, size)
+ painter = qt.QPainter(pixmap)
+ for i in range(size):
+ rgb = lut[i]
+ r, g, b = rgb[0], rgb[1], rgb[2]
+ painter.setPen(qt.QColor(r, g, b))
+ painter.drawPoint(qt.QPoint(i, 0))
+
+ painter.drawPixmap(0, 1, size, size - 1, pixmap, 0, 0, size, 1)
+ painter.end()
+
+ return qt.QIcon(pixmap)
+
+ def getCurrentName(self):
+ return self.itemData(self.currentIndex(), self.ORIGINAL_NAME)
+
+ def findColormap(self, name):
+ return self.findData(name, role=self.ORIGINAL_NAME)
+
+ def setCurrentName(self, name):
+ index = self.findColormap(name)
+ if index < 0:
+ index = self.count()
+ self.addItem(str.title(name))
+ self.setItemIcon(index, self.getIconPreview(name))
+ self.setItemData(index, name, role=self.ORIGINAL_NAME)
+ self.setCurrentIndex(index)
+
+
+@enum.unique
+class _DataInPlotMode(enum.Enum):
+ """Enum for each mode of display of the data in the plot."""
+ NONE = 'none'
+ RANGE = 'range'
+ HISTOGRAM = 'histogram'
+
+
+class ColormapDialog(qt.QDialog):
+ """A QDialog widget to set the colormap.
+
+ :param parent: See :class:`QDialog`
+ :param str title: The QDialog title
+ """
+
+ visibleChanged = qt.Signal(bool)
+ """This event is sent when the dialog visibility change"""
+
+ def __init__(self, parent=None, title="Colormap Dialog"):
+ qt.QDialog.__init__(self, parent)
+ self.setWindowTitle(title)
+
+ self._colormap = None
+ self._data = None
+ self._dataInPlotMode = _DataInPlotMode.RANGE
+
+ self._ignoreColormapChange = False
+ """Used as a semaphore to avoid editing the colormap object when we are
+ only attempt to display it.
+ Used instead of n connect and disconnect of the sigChanged. The
+ disconnection to sigChanged was also limiting when this colormapdialog
+ is used in the colormapaction and associated to the activeImageChanged.
+ (because the activeImageChanged is send when the colormap changed and
+ the self.setcolormap is a callback)
+ """
+
+ self._histogramData = None
+ self._minMaxWasEdited = False
+ self._initialRange = None
+
+ self._dataRange = None
+ """If defined 3-tuple containing information from a data:
+ minimum, positive minimum, maximum"""
+
+ self._colormapStoredState = None
+
+ # Make the GUI
+ vLayout = qt.QVBoxLayout(self)
+
+ formWidget = qt.QWidget(parent=self)
+ vLayout.addWidget(formWidget)
+ formLayout = qt.QFormLayout(formWidget)
+ formLayout.setContentsMargins(10, 10, 10, 10)
+ formLayout.setSpacing(0)
+
+ # Colormap row
+ self._comboBoxColormap = _ColormapNameCombox(parent=formWidget)
+ self._comboBoxColormap.currentIndexChanged[int].connect(self._updateName)
+ formLayout.addRow('Colormap:', self._comboBoxColormap)
+
+ # Normalization row
+ self._normButtonLinear = qt.QRadioButton('Linear')
+ self._normButtonLinear.setChecked(True)
+ self._normButtonLog = qt.QRadioButton('Log')
+ self._normButtonLog.toggled.connect(self._activeLogNorm)
+
+ normButtonGroup = qt.QButtonGroup(self)
+ normButtonGroup.setExclusive(True)
+ normButtonGroup.addButton(self._normButtonLinear)
+ normButtonGroup.addButton(self._normButtonLog)
+ self._normButtonLinear.toggled[bool].connect(self._updateLinearNorm)
+
+ normLayout = qt.QHBoxLayout()
+ normLayout.setContentsMargins(0, 0, 0, 0)
+ normLayout.setSpacing(10)
+ normLayout.addWidget(self._normButtonLinear)
+ normLayout.addWidget(self._normButtonLog)
+
+ formLayout.addRow('Normalization:', normLayout)
+
+ # Min row
+ self._minValue = _BoundaryWidget(parent=self, value=1.0)
+ self._minValue.textEdited.connect(self._minMaxTextEdited)
+ self._minValue.editingFinished.connect(self._minEditingFinished)
+ self._minValue.sigValueChanged.connect(self._updateMinMax)
+ formLayout.addRow('\tMin:', self._minValue)
+
+ # Max row
+ self._maxValue = _BoundaryWidget(parent=self, value=10.0)
+ self._maxValue.textEdited.connect(self._minMaxTextEdited)
+ self._maxValue.sigValueChanged.connect(self._updateMinMax)
+ self._maxValue.editingFinished.connect(self._maxEditingFinished)
+ formLayout.addRow('\tMax:', self._maxValue)
+
+ # Add plot for histogram
+ self._plotToolbar = qt.QToolBar(self)
+ self._plotToolbar.setFloatable(False)
+ self._plotToolbar.setMovable(False)
+ self._plotToolbar.setIconSize(qt.QSize(8, 8))
+ self._plotToolbar.setStyleSheet("QToolBar { border: 0px }")
+ self._plotToolbar.setOrientation(qt.Qt.Vertical)
+
+ group = qt.QActionGroup(self._plotToolbar)
+ group.setExclusive(True)
+
+ action = qt.QAction("Nothing", self)
+ action.setToolTip("No range nor histogram are displayed. No extra computation have to be done.")
+ action.setIcon(icons.getQIcon('colormap-none'))
+ action.setCheckable(True)
+ action.setData(_DataInPlotMode.NONE)
+ action.setChecked(action.data() == self._dataInPlotMode)
+ self._plotToolbar.addAction(action)
+ group.addAction(action)
+ action = qt.QAction("Data range", self)
+ action.setToolTip("Display the data range within the colormap range. A fast data processing have to be done.")
+ action.setIcon(icons.getQIcon('colormap-range'))
+ action.setCheckable(True)
+ action.setData(_DataInPlotMode.RANGE)
+ action.setChecked(action.data() == self._dataInPlotMode)
+ self._plotToolbar.addAction(action)
+ group.addAction(action)
+ action = qt.QAction("Histogram", self)
+ action.setToolTip("Display the data histogram within the colormap range. A slow data processing have to be done. ")
+ action.setIcon(icons.getQIcon('colormap-histogram'))
+ action.setCheckable(True)
+ action.setData(_DataInPlotMode.HISTOGRAM)
+ action.setChecked(action.data() == self._dataInPlotMode)
+ self._plotToolbar.addAction(action)
+ group.addAction(action)
+ group.triggered.connect(self._displayDataInPlotModeChanged)
+
+ self._plotBox = qt.QWidget(self)
+ self._plotInit()
+
+ plotBoxLayout = qt.QHBoxLayout()
+ plotBoxLayout.setContentsMargins(0, 0, 0, 0)
+ plotBoxLayout.setSpacing(2)
+ plotBoxLayout.addWidget(self._plotToolbar)
+ plotBoxLayout.addWidget(self._plot)
+ plotBoxLayout.setSizeConstraint(qt.QLayout.SetMinimumSize)
+ self._plotBox.setLayout(plotBoxLayout)
+ vLayout.addWidget(self._plotBox)
+
+ # define modal buttons
+ types = qt.QDialogButtonBox.Ok | qt.QDialogButtonBox.Cancel
+ self._buttonsModal = qt.QDialogButtonBox(parent=self)
+ self._buttonsModal.setStandardButtons(types)
+ self.layout().addWidget(self._buttonsModal)
+ self._buttonsModal.accepted.connect(self.accept)
+ self._buttonsModal.rejected.connect(self.reject)
+
+ # define non modal buttons
+ types = qt.QDialogButtonBox.Close | qt.QDialogButtonBox.Reset
+ self._buttonsNonModal = qt.QDialogButtonBox(parent=self)
+ self._buttonsNonModal.setStandardButtons(types)
+ self.layout().addWidget(self._buttonsNonModal)
+ self._buttonsNonModal.button(qt.QDialogButtonBox.Close).clicked.connect(self.accept)
+ self._buttonsNonModal.button(qt.QDialogButtonBox.Reset).clicked.connect(self.resetColormap)
+
+ # Set the colormap to default values
+ self.setColormap(Colormap(name='gray', normalization='linear',
+ vmin=None, vmax=None))
+
+ self.setModal(self.isModal())
+
+ vLayout.setSizeConstraint(qt.QLayout.SetMinimumSize)
+ self.setFixedSize(self.sizeHint())
+ self._applyColormap()
+
+ def showEvent(self, event):
+ self.visibleChanged.emit(True)
+ super(ColormapDialog, self).showEvent(event)
+
+ def closeEvent(self, event):
+ if not self.isModal():
+ self.accept()
+ super(ColormapDialog, self).closeEvent(event)
+
+ def hideEvent(self, event):
+ self.visibleChanged.emit(False)
+ super(ColormapDialog, self).hideEvent(event)
+
+ def close(self):
+ self.accept()
+ qt.QDialog.close(self)
+
+ def setModal(self, modal):
+ assert type(modal) is bool
+ self._buttonsNonModal.setVisible(not modal)
+ self._buttonsModal.setVisible(modal)
+ qt.QDialog.setModal(self, modal)
+
+ def exec_(self):
+ wasModal = self.isModal()
+ self.setModal(True)
+ result = super(ColormapDialog, self).exec_()
+ self.setModal(wasModal)
+ return result
+
+ def _plotInit(self):
+ """Init the plot to display the range and the values"""
+ self._plot = PlotWidget()
+ self._plot.setDataMargins(yMinMargin=0.125, yMaxMargin=0.125)
+ self._plot.getXAxis().setLabel("Data Values")
+ self._plot.getYAxis().setLabel("")
+ self._plot.setInteractiveMode('select', zoomOnWheel=False)
+ self._plot.setActiveCurveHandling(False)
+ self._plot.setMinimumSize(qt.QSize(250, 200))
+ self._plot.sigPlotSignal.connect(self._plotSlot)
+
+ self._plotUpdate()
+
+ def sizeHint(self):
+ return self.layout().minimumSize()
+
+ def _plotUpdate(self, updateMarkers=True):
+ """Update the plot content
+
+ :param bool updateMarkers: True to update markers, False otherwith
+ """
+ colormap = self.getColormap()
+ if colormap is None:
+ if self._plotBox.isVisibleTo(self):
+ self._plotBox.setVisible(False)
+ self.setFixedSize(self.sizeHint())
+ return
+
+ if not self._plotBox.isVisibleTo(self):
+ self._plotBox.setVisible(True)
+ self.setFixedSize(self.sizeHint())
+
+ minData, maxData = self._minValue.getFiniteValue(), self._maxValue.getFiniteValue()
+ if minData > maxData:
+ # avoid a full collapse
+ minData, maxData = maxData, minData
+ minimum = minData
+ maximum = maxData
+
+ if self._dataRange is not None:
+ minRange = self._dataRange[0]
+ maxRange = self._dataRange[2]
+ minimum = min(minimum, minRange)
+ maximum = max(maximum, maxRange)
+
+ if self._histogramData is not None:
+ minHisto = self._histogramData[1][0]
+ maxHisto = self._histogramData[1][-1]
+ minimum = min(minimum, minHisto)
+ maximum = max(maximum, maxHisto)
+
+ marge = abs(maximum - minimum) / 6.0
+ if marge < 0.0001:
+ # Smaller that the QLineEdit precision
+ marge = 0.0001
+
+ minView, maxView = minimum - marge, maximum + marge
+
+ if updateMarkers:
+ # Save the state in we are not moving the markers
+ self._initialRange = minView, maxView
+ elif self._initialRange is not None:
+ minView = min(minView, self._initialRange[0])
+ maxView = max(maxView, self._initialRange[1])
+
+ x = [minView, minData, maxData, maxView]
+ y = [0, 0, 1, 1]
+
+ self._plot.addCurve(x, y,
+ legend="ConstrainedCurve",
+ color='black',
+ symbol='o',
+ linestyle='-',
+ resetzoom=False)
+
+ if updateMarkers:
+ minDraggable = (self._colormap().isEditable() and
+ not self._minValue.isAutoChecked())
+ self._plot.addXMarker(
+ self._minValue.getFiniteValue(),
+ legend='Min',
+ text='Min',
+ draggable=minDraggable,
+ color='blue',
+ constraint=self._plotMinMarkerConstraint)
+
+ maxDraggable = (self._colormap().isEditable() and
+ not self._maxValue.isAutoChecked())
+ self._plot.addXMarker(
+ self._maxValue.getFiniteValue(),
+ legend='Max',
+ text='Max',
+ draggable=maxDraggable,
+ color='blue',
+ constraint=self._plotMaxMarkerConstraint)
+
+ self._plot.resetZoom()
+
+ def _plotMinMarkerConstraint(self, x, y):
+ """Constraint of the min marker"""
+ return min(x, self._maxValue.getFiniteValue()), y
+
+ def _plotMaxMarkerConstraint(self, x, y):
+ """Constraint of the max marker"""
+ return max(x, self._minValue.getFiniteValue()), y
+
+ def _plotSlot(self, event):
+ """Handle events from the plot"""
+ if event['event'] in ('markerMoving', 'markerMoved'):
+ value = float(str(event['xdata']))
+ if event['label'] == 'Min':
+ self._minValue.setValue(value)
+ elif event['label'] == 'Max':
+ self._maxValue.setValue(value)
+
+ # This will recreate the markers while interacting...
+ # It might break if marker interaction is changed
+ if event['event'] == 'markerMoved':
+ self._initialRange = None
+ self._updateMinMax()
+ else:
+ self._plotUpdate(updateMarkers=False)
+
+ @staticmethod
+ def computeDataRange(data):
+ """Compute the data range as used by :meth:`setDataRange`.
+
+ :param data: The data to process
+ :rtype: Tuple(float, float, float)
+ """
+ if data is None or len(data) == 0:
+ return None, None, None
+
+ dataRange = min_max(data, min_positive=True, finite=True)
+ if dataRange.minimum is None:
+ # Only non-finite data
+ dataRange = None
+
+ if dataRange is not None:
+ min_positive = dataRange.min_positive
+ if min_positive is None:
+ min_positive = float('nan')
+ dataRange = dataRange.minimum, min_positive, dataRange.maximum
+
+ if dataRange is None or len(dataRange) != 3:
+ qt.QMessageBox.warning(
+ None, "No Data",
+ "Image data does not contain any real value")
+ dataRange = 1., 1., 10.
+
+ return dataRange
+
+ @staticmethod
+ def computeHistogram(data):
+ """Compute the data histogram as used by :meth:`setHistogram`.
+
+ :param data: The data to process
+ :rtype: Tuple(List(float),List(float)
+ """
+ _data = data
+ if _data.ndim == 3: # RGB(A) images
+ _logger.info('Converting current image from RGB(A) to grayscale\
+ in order to compute the intensity distribution')
+ _data = (_data[:, :, 0] * 0.299 +
+ _data[:, :, 1] * 0.587 +
+ _data[:, :, 2] * 0.114)
+
+ if len(_data) == 0:
+ return None, None
+
+ xmin, xmax = min_max(_data, min_positive=False, finite=True)
+ nbins = min(256, int(numpy.sqrt(_data.size)))
+ data_range = xmin, xmax
+
+ # bad hack: get 256 bins in the case we have a B&W
+ if numpy.issubdtype(_data.dtype, numpy.integer):
+ if nbins > xmax - xmin:
+ nbins = xmax - xmin
+
+ nbins = max(2, nbins)
+ _data = _data.ravel().astype(numpy.float32)
+
+ histogram = Histogramnd(_data, n_bins=nbins, histo_range=data_range)
+ return histogram.histo, histogram.edges[0]
+
+ def _getData(self):
+ if self._data is None:
+ return None
+ return self._data()
+
+ def setData(self, data):
+ """Store the data as a weakref.
+
+ According to the state of the dialog, the data will be used to display
+ the data range or the histogram of the data using :meth:`setDataRange`
+ and :meth:`setHistogram`
+ """
+ oldData = self._getData()
+ if oldData is data:
+ return
+
+ if data is None:
+ self._data = None
+ else:
+ self._data = weakref.ref(data, self._dataAboutToFinalize)
+
+ self._updateDataInPlot()
+
+ def _setDataInPlotMode(self, mode):
+ if self._dataInPlotMode == mode:
+ return
+ self._dataInPlotMode = mode
+ self._updateDataInPlot()
+
+ def _displayDataInPlotModeChanged(self, action):
+ mode = action.data()
+ self._setDataInPlotMode(mode)
+
+ def _updateDataInPlot(self):
+ data = self._getData()
+ if data is None:
+ self.setDataRange()
+ self.setHistogram()
+ return
+
+ if data.size == 0:
+ # One or more dimensions are equal to 0
+ self.setHistogram()
+ self.setDataRange()
+ return
+
+ mode = self._dataInPlotMode
+
+ if mode == _DataInPlotMode.NONE:
+ self.setHistogram()
+ self.setDataRange()
+ elif mode == _DataInPlotMode.RANGE:
+ result = self.computeDataRange(data)
+ self.setHistogram()
+ self.setDataRange(*result)
+ elif mode == _DataInPlotMode.HISTOGRAM:
+ # The histogram should be done in a worker thread
+ result = self.computeHistogram(data)
+ self.setHistogram(*result)
+ self.setDataRange()
+
+ def _colormapAboutToFinalize(self, weakrefColormap):
+ """Callback when the data weakref is about to be finalized."""
+ if self._colormap is weakrefColormap:
+ self.setColormap(None)
+
+ def _dataAboutToFinalize(self, weakrefData):
+ """Callback when the data weakref is about to be finalized."""
+ if self._data is weakrefData:
+ self.setData(None)
+
+ def getHistogram(self):
+ """Returns the counts and bin edges of the displayed histogram.
+
+ :return: (hist, bin_edges)
+ :rtype: 2-tuple of numpy arrays"""
+ if self._histogramData is None:
+ return None
+ else:
+ bins, counts = self._histogramData
+ return numpy.array(bins, copy=True), numpy.array(counts, copy=True)
+
+ def setHistogram(self, hist=None, bin_edges=None):
+ """Set the histogram to display.
+
+ This update the data range with the bounds of the bins.
+
+ :param hist: array-like of counts or None to hide histogram
+ :param bin_edges: array-like of bins edges or None to hide histogram
+ """
+ if hist is None or bin_edges is None:
+ self._histogramData = None
+ self._plot.remove(legend='Histogram', kind='histogram')
+ else:
+ hist = numpy.array(hist, copy=True)
+ bin_edges = numpy.array(bin_edges, copy=True)
+ self._histogramData = hist, bin_edges
+ norm_hist = hist / max(hist)
+ self._plot.addHistogram(norm_hist,
+ bin_edges,
+ legend="Histogram",
+ color='gray',
+ align='center',
+ fill=True)
+ self._updateMinMaxData()
+
+ def getColormap(self):
+ """Return the colormap description as a :class:`.Colormap`.
+
+ """
+ if self._colormap is None:
+ return None
+ return self._colormap()
+
+ def resetColormap(self):
+ """
+ Reset the colormap state before modification.
+
+ ..note :: the colormap reference state is the state when set or the
+ state when validated
+ """
+ colormap = self.getColormap()
+ if colormap is not None and self._colormapStoredState is not None:
+ if self._colormap()._toDict() != self._colormapStoredState:
+ self._ignoreColormapChange = True
+ colormap._setFromDict(self._colormapStoredState)
+ self._ignoreColormapChange = False
+ self._applyColormap()
+
+ def setDataRange(self, minimum=None, positiveMin=None, maximum=None):
+ """Set the range of data to use for the range of the histogram area.
+
+ :param float minimum: The minimum of the data
+ :param float positiveMin: The positive minimum of the data
+ :param float maximum: The maximum of the data
+ """
+ if minimum is None or positiveMin is None or maximum is None:
+ self._dataRange = None
+ self._plot.remove(legend='Range', kind='histogram')
+ else:
+ hist = numpy.array([1])
+ bin_edges = numpy.array([minimum, maximum])
+ self._plot.addHistogram(hist,
+ bin_edges,
+ legend="Range",
+ color='gray',
+ align='center',
+ fill=True)
+ self._dataRange = minimum, positiveMin, maximum
+ self._updateMinMaxData()
+
+ def _updateMinMaxData(self):
+ """Update the min and max of the data according to the data range and
+ the histogram preset."""
+ colormap = self.getColormap()
+
+ minimum = float("+inf")
+ maximum = float("-inf")
+
+ if colormap is not None and colormap.getNormalization() == colormap.LOGARITHM:
+ # find a range in the positive part of the data
+ if self._dataRange is not None:
+ minimum = min(minimum, self._dataRange[1])
+ maximum = max(maximum, self._dataRange[2])
+ if self._histogramData is not None:
+ positives = list(filter(lambda x: x > 0, self._histogramData[1]))
+ if len(positives) > 0:
+ minimum = min(minimum, positives[0])
+ maximum = max(maximum, positives[-1])
+ else:
+ if self._dataRange is not None:
+ minimum = min(minimum, self._dataRange[0])
+ maximum = max(maximum, self._dataRange[2])
+ if self._histogramData is not None:
+ minimum = min(minimum, self._histogramData[1][0])
+ maximum = max(maximum, self._histogramData[1][-1])
+
+ if not numpy.isfinite(minimum):
+ minimum = None
+ if not numpy.isfinite(maximum):
+ maximum = None
+
+ self._minValue.setDataValue(minimum)
+ self._maxValue.setDataValue(maximum)
+ self._plotUpdate()
+
+ def accept(self):
+ self.storeCurrentState()
+ qt.QDialog.accept(self)
+
+ def storeCurrentState(self):
+ """
+ save the current value sof the colormap if the user want to undo is
+ modifications
+ """
+ colormap = self.getColormap()
+ if colormap is not None:
+ self._colormapStoredState = colormap._toDict()
+ else:
+ self._colormapStoredState = None
+
+ def reject(self):
+ self.resetColormap()
+ qt.QDialog.reject(self)
+
+ def setColormap(self, colormap):
+ """Set the colormap description
+
+ :param :class:`Colormap` colormap: the colormap to edit
+ """
+ assert colormap is None or isinstance(colormap, Colormap)
+ if self._ignoreColormapChange is True:
+ return
+
+ oldColormap = self.getColormap()
+ if oldColormap is colormap:
+ return
+ if oldColormap is not None:
+ oldColormap.sigChanged.disconnect(self._applyColormap)
+
+ if colormap is not None:
+ colormap.sigChanged.connect(self._applyColormap)
+ colormap = weakref.ref(colormap, self._colormapAboutToFinalize)
+
+ self._colormap = colormap
+ self.storeCurrentState()
+ self._updateResetButton()
+ self._applyColormap()
+
+ def _updateResetButton(self):
+ resetButton = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset)
+ rStateEnabled = False
+ colormap = self.getColormap()
+ if colormap is not None and colormap.isEditable():
+ # can reset only in the case the colormap changed
+ rStateEnabled = colormap._toDict() != self._colormapStoredState
+ resetButton.setEnabled(rStateEnabled)
+
+ def _applyColormap(self):
+ self._updateResetButton()
+ if self._ignoreColormapChange is True:
+ return
+
+ colormap = self.getColormap()
+ if colormap is None:
+ self._comboBoxColormap.setEnabled(False)
+ self._normButtonLinear.setEnabled(False)
+ self._normButtonLog.setEnabled(False)
+ self._minValue.setEnabled(False)
+ self._maxValue.setEnabled(False)
+ else:
+ self._ignoreColormapChange = True
+
+ if colormap.getName() is not None:
+ name = colormap.getName()
+ self._comboBoxColormap.setCurrentName(name)
+ self._comboBoxColormap.setEnabled(self._colormap().isEditable())
+
+ assert colormap.getNormalization() in Colormap.NORMALIZATIONS
+ self._normButtonLinear.setChecked(
+ colormap.getNormalization() == Colormap.LINEAR)
+ self._normButtonLog.setChecked(
+ colormap.getNormalization() == Colormap.LOGARITHM)
+ vmin = colormap.getVMin()
+ vmax = colormap.getVMax()
+ dataRange = colormap.getColormapRange()
+ self._normButtonLinear.setEnabled(self._colormap().isEditable())
+ self._normButtonLog.setEnabled(self._colormap().isEditable())
+ self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None)
+ self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None)
+ self._minValue.setEnabled(self._colormap().isEditable())
+ self._maxValue.setEnabled(self._colormap().isEditable())
+ self._ignoreColormapChange = False
+
+ self._plotUpdate()
+
+ def _updateMinMax(self):
+ if self._ignoreColormapChange is True:
+ return
+
+ vmin = self._minValue.getFiniteValue()
+ vmax = self._maxValue.getFiniteValue()
+ if vmax is not None and vmin is not None and vmax < vmin:
+ # If only one autoscale is checked constraints are too strong
+ # We have to edit a user value anyway it is not requested
+ # TODO: It would be better IMO to disable the auto checkbox before
+ # this case occur (valls)
+ cmin = self._minValue.isAutoChecked()
+ cmax = self._maxValue.isAutoChecked()
+ if cmin is False:
+ self._minValue.setFiniteValue(vmax)
+ if cmax is False:
+ self._maxValue.setFiniteValue(vmin)
+
+ vmin = self._minValue.getValue()
+ vmax = self._maxValue.getValue()
+ self._ignoreColormapChange = True
+ colormap = self._colormap()
+ if colormap is not None:
+ colormap.setVRange(vmin, vmax)
+ self._ignoreColormapChange = False
+ self._plotUpdate()
+ self._updateResetButton()
+
+ def _updateName(self):
+ if self._ignoreColormapChange is True:
+ return
+
+ if self._colormap():
+ self._ignoreColormapChange = True
+ self._colormap().setName(
+ self._comboBoxColormap.getCurrentName())
+ self._ignoreColormapChange = False
+
+ def _updateLinearNorm(self, isNormLinear):
+ if self._ignoreColormapChange is True:
+ return
+
+ if self._colormap():
+ self._ignoreColormapChange = True
+ norm = Colormap.LINEAR if isNormLinear else Colormap.LOGARITHM
+ self._colormap().setNormalization(norm)
+ self._ignoreColormapChange = False
+
+ def _minMaxTextEdited(self, text):
+ """Handle _minValue and _maxValue textEdited signal"""
+ self._minMaxWasEdited = True
+
+ def _minEditingFinished(self):
+ """Handle _minValue editingFinished signal
+
+ Together with :meth:`_minMaxTextEdited`, this avoids to notify
+ colormap change when the min and max value where not edited.
+ """
+ if self._minMaxWasEdited:
+ self._minMaxWasEdited = False
+
+ # Fix start value
+ if (self._maxValue.getValue() is not None and
+ self._minValue.getValue() > self._maxValue.getValue()):
+ self._minValue.setValue(self._maxValue.getValue())
+ self._updateMinMax()
+
+ def _maxEditingFinished(self):
+ """Handle _maxValue editingFinished signal
+
+ Together with :meth:`_minMaxTextEdited`, this avoids to notify
+ colormap change when the min and max value where not edited.
+ """
+ if self._minMaxWasEdited:
+ self._minMaxWasEdited = False
+
+ # Fix end value
+ if (self._minValue.getValue() is not None and
+ self._minValue.getValue() > self._maxValue.getValue()):
+ self._maxValue.setValue(self._minValue.getValue())
+ self._updateMinMax()
+
+ def keyPressEvent(self, event):
+ """Override key handling.
+
+ It disables leaving the dialog when editing a text field.
+ """
+ if event.key() == qt.Qt.Key_Enter and (self._minValue.hasFocus() or
+ self._maxValue.hasFocus()):
+ # Bypass QDialog keyPressEvent
+ # To avoid leaving the dialog when pressing enter on a text field
+ super(qt.QDialog, self).keyPressEvent(event)
+ else:
+ # Use QDialog keyPressEvent
+ super(ColormapDialog, self).keyPressEvent(event)
+
+ def _activeLogNorm(self, isLog):
+ if self._ignoreColormapChange is True:
+ return
+ if self._colormap():
+ self._ignoreColormapChange = True
+ norm = Colormap.LOGARITHM if isLog is True else Colormap.LINEAR
+ self._colormap().setNormalization(norm)
+ self._ignoreColormapChange = False
+ self._updateMinMaxData()
diff --git a/silx/gui/dialog/GroupDialog.py b/silx/gui/dialog/GroupDialog.py
new file mode 100644
index 0000000..71235d2
--- /dev/null
+++ b/silx/gui/dialog/GroupDialog.py
@@ -0,0 +1,177 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides a dialog widget to select a HDF5 group in a
+tree.
+
+.. autoclass:: GroupDialog
+ :show-inheritance:
+ :members:
+
+
+"""
+from silx.gui import qt
+from silx.gui.hdf5.Hdf5TreeView import Hdf5TreeView
+import silx.io
+from silx.io.url import DataUrl
+
+__authors__ = ["P. Knobel"]
+__license__ = "MIT"
+__date__ = "22/03/2018"
+
+
+class GroupDialog(qt.QDialog):
+ """This :class:`QDialog` uses a :class:`silx.gui.hdf5.Hdf5TreeView` to
+ provide a HDF5 group selection dialog.
+
+ The information identifying the selected node is provided as a
+ :class:`silx.io.url.DataUrl`.
+
+ Example:
+
+ .. code-block:: python
+
+ dialog = GroupDialog()
+ dialog.addFile(filepath1)
+ dialog.addFile(filepath2)
+
+ if dialog.exec_():
+ print("File path: %s" % dialog.getSelectedDataUrl().file_path())
+ print("HDF5 group path : %s " % dialog.getSelectedDataUrl().data_path())
+ else:
+ print("Operation cancelled :(")
+
+ """
+ def __init__(self, parent=None):
+ qt.QDialog.__init__(self, parent)
+ self.setWindowTitle("HDF5 group selection")
+
+ self._tree = Hdf5TreeView(self)
+ self._tree.setSelectionMode(qt.QAbstractItemView.SingleSelection)
+ self._tree.activated.connect(self._onActivation)
+ self._tree.selectionModel().selectionChanged.connect(
+ self._onSelectionChange)
+
+ self._model = self._tree.findHdf5TreeModel()
+
+ self._header = self._tree.header()
+ self._header.setSections([self._model.NAME_COLUMN,
+ self._model.NODE_COLUMN,
+ self._model.LINK_COLUMN])
+
+ _labelSubgroup = qt.QLabel(self)
+ _labelSubgroup.setText("Subgroup name (optional)")
+ self._lineEditSubgroup = qt.QLineEdit(self)
+ self._lineEditSubgroup.setToolTip(
+ "Specify the name of a new subgroup "
+ "to be created in the selected group.")
+ self._lineEditSubgroup.textChanged.connect(
+ self._onSubgroupNameChange)
+
+ _labelSelectionTitle = qt.QLabel(self)
+ _labelSelectionTitle.setText("Current selection")
+ self._labelSelection = qt.QLabel(self)
+ self._labelSelection.setStyleSheet("color: gray")
+ self._labelSelection.setWordWrap(True)
+ self._labelSelection.setText("Select a group")
+
+ buttonBox = qt.QDialogButtonBox()
+ self._okButton = buttonBox.addButton(qt.QDialogButtonBox.Ok)
+ self._okButton.setEnabled(False)
+ buttonBox.addButton(qt.QDialogButtonBox.Cancel)
+
+ buttonBox.accepted.connect(self.accept)
+ buttonBox.rejected.connect(self.reject)
+
+ vlayout = qt.QVBoxLayout(self)
+ vlayout.addWidget(self._tree)
+ vlayout.addWidget(_labelSubgroup)
+ vlayout.addWidget(self._lineEditSubgroup)
+ vlayout.addWidget(_labelSelectionTitle)
+ vlayout.addWidget(self._labelSelection)
+ vlayout.addWidget(buttonBox)
+ self.setLayout(vlayout)
+
+ self.setMinimumWidth(400)
+
+ self._selectedUrl = None
+
+ def addFile(self, path):
+ """Add a HDF5 file to the tree.
+ All groups it contains will be selectable in the dialog.
+
+ :param str path: File path
+ """
+ self._model.insertFile(path)
+
+ def addGroup(self, group):
+ """Add a HDF5 group to the tree. This group and all its subgroups
+ will be selectable in the dialog.
+
+ :param h5py.Group group: HDF5 group
+ """
+ self._model.insertH5pyObject(group)
+
+ def _onActivation(self, idx):
+ # double-click or enter press
+ nodes = list(self._tree.selectedH5Nodes())
+ node = nodes[0]
+ if silx.io.is_group(node.h5py_object):
+ self.accept()
+
+ def _onSelectionChange(self, old, new):
+ self._updateUrl()
+
+ def _onSubgroupNameChange(self, text):
+ self._updateUrl()
+
+ def _updateUrl(self):
+ nodes = list(self._tree.selectedH5Nodes())
+ subgroupName = self._lineEditSubgroup.text()
+ if nodes:
+ node = nodes[0]
+ if silx.io.is_group(node.h5py_object):
+ data_path = node.local_name
+ if subgroupName.lstrip("/"):
+ if not data_path.endswith("/"):
+ data_path += "/"
+ data_path += subgroupName.lstrip("/")
+ self._selectedUrl = DataUrl(file_path=node.local_filename,
+ data_path=data_path)
+ self._okButton.setEnabled(True)
+ self._labelSelection.setText(
+ self._selectedUrl.path())
+ else:
+ self._selectedUrl = None
+ self._okButton.setEnabled(False)
+ self._labelSelection.setText("Select a group")
+
+ def getSelectedDataUrl(self):
+ """Return a :class:`DataUrl` with a file path and a data path.
+ Return None if the dialog was cancelled.
+
+ :return: :class:`silx.io.url.DataUrl` object pointing to the
+ selected group.
+ """
+ return self._selectedUrl
diff --git a/silx/gui/dialog/test/__init__.py b/silx/gui/dialog/test/__init__.py
index eee8aea..f43a37a 100644
--- a/silx/gui/dialog/test/__init__.py
+++ b/silx/gui/dialog/test/__init__.py
@@ -26,7 +26,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "07/02/2018"
+__date__ = "24/04/2018"
import logging
@@ -42,6 +42,8 @@ def suite():
test_suite = unittest.TestSuite()
from . import test_imagefiledialog
from . import test_datafiledialog
+ from . import test_colormapdialog
test_suite.addTest(test_imagefiledialog.suite())
test_suite.addTest(test_datafiledialog.suite())
+ test_suite.addTest(test_colormapdialog.suite())
return test_suite
diff --git a/silx/gui/plot/test/testColormapDialog.py b/silx/gui/dialog/test/test_colormapdialog.py
index 8087369..6f0ceea 100644
--- a/silx/gui/plot/test/testColormapDialog.py
+++ b/silx/gui/dialog/test/test_colormapdialog.py
@@ -26,7 +26,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "17/01/2018"
+__date__ = "23/05/2018"
import doctest
@@ -34,9 +34,9 @@ import unittest
from silx.gui.test.utils import qWaitForWindowExposedAndActivate
from silx.gui import qt
-from silx.gui.plot import ColormapDialog
+from silx.gui.dialog import ColormapDialog
from silx.gui.test.utils import TestCaseQt
-from silx.gui.plot.Colormap import Colormap, preferredColormaps
+from silx.gui.colors import Colormap, preferredColormaps
from silx.utils.testutils import ParametricTestCase
from silx.gui.plot.PlotWindow import PlotWindow
@@ -119,7 +119,7 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
self.assertTrue(self.colormap.getVMin() is None)
self.assertTrue(self.colormap.getVMax() is None)
self.assertTrue(self.colormap.isAutoscale() is True)
-
+
def testGUIModalCancel(self):
"""Make sure the colormap is not modified if gone through reject"""
assert self.colormap.isAutoscale() is False
@@ -308,6 +308,19 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
colormap.setEditable(False)
self.assertFalse(resetButton.isEnabled())
+ def testImageData(self):
+ data = numpy.random.rand(5, 5)
+ self.colormapDiag.setData(data)
+
+ def testEmptyData(self):
+ data = numpy.empty((10, 0))
+ self.colormapDiag.setData(data)
+
+ def testNoneData(self):
+ data = numpy.random.rand(5, 5)
+ self.colormapDiag.setData(data)
+ self.colormapDiag.setData(None)
+
class TestColormapAction(TestCaseQt):
def setUp(self):
@@ -336,16 +349,16 @@ class TestColormapAction(TestCaseQt):
self.assertTrue(self.colormapDialog.getColormap() is self.defaultColormap)
self.plot.addImage(data=numpy.random.rand(10, 10), legend='img1',
- replace=False, origin=(0, 0),
+ origin=(0, 0),
colormap=self.colormap1)
self.plot.setActiveImage('img1')
self.assertTrue(self.colormapDialog.getColormap() is self.colormap1)
self.plot.addImage(data=numpy.random.rand(10, 10), legend='img2',
- replace=False, origin=(0, 0),
+ origin=(0, 0),
colormap=self.colormap2)
self.plot.addImage(data=numpy.random.rand(10, 10), legend='img3',
- replace=False, origin=(0, 0))
+ origin=(0, 0))
self.plot.setActiveImage('img3')
self.assertTrue(self.colormapDialog.getColormap() is self.defaultColormap)
@@ -363,7 +376,7 @@ class TestColormapAction(TestCaseQt):
self.plot.getColormapAction()._actionTriggered(checked=True)
self.assertTrue(self.plot.getColormapAction().isChecked())
self.plot.addImage(data=numpy.random.rand(10, 10), legend='img1',
- replace=False, origin=(0, 0),
+ origin=(0, 0),
colormap=self.colormap1)
self.colormap1.setName('red')
self.plot.getColormapAction()._actionTriggered()
diff --git a/silx/gui/dialog/test/test_datafiledialog.py b/silx/gui/dialog/test/test_datafiledialog.py
index bdda810..38fa03b 100644
--- a/silx/gui/dialog/test/test_datafiledialog.py
+++ b/silx/gui/dialog/test/test_datafiledialog.py
@@ -26,7 +26,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "14/02/2018"
+__date__ = "03/07/2018"
import unittest
@@ -79,6 +79,20 @@ def setUpModule():
f["nxdata"].attrs["NX_class"] = u"NXdata"
f.close()
+ if h5py is not None:
+ directory = os.path.join(_tmpDirectory, "data")
+ os.mkdir(directory)
+ filename = os.path.join(directory, "data.h5")
+ f = h5py.File(filename, "w")
+ f["scalar"] = 10
+ f["image"] = data
+ f["cube"] = [data, data + 1, data + 2]
+ f["complex_image"] = data * 1j
+ f["group/image"] = data
+ f["nxdata/foo"] = 10
+ f["nxdata"].attrs["NX_class"] = u"NXdata"
+ f.close()
+
filename = _tmpDirectory + "/badformat.h5"
with io.open(filename, "wb") as f:
f.write(b"{\nHello Nurse!")
@@ -270,7 +284,7 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
action = utils.findChildren(dialog, qt.QAction, name="toParentAction")[0]
toParentButton = utils.getQToolButtonFromAction(action)
- filename = _tmpDirectory + "/data.h5"
+ filename = _tmpDirectory + "/data/data.h5"
# init state
path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path()
@@ -286,11 +300,11 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
self.mouseClick(toParentButton, qt.Qt.LeftButton)
self.qWaitForPendingActions(dialog)
- self.assertSamePath(url.text(), _tmpDirectory)
+ self.assertSamePath(url.text(), _tmpDirectory + "/data")
self.mouseClick(toParentButton, qt.Qt.LeftButton)
self.qWaitForPendingActions(dialog)
- self.assertSamePath(url.text(), os.path.dirname(_tmpDirectory))
+ self.assertSamePath(url.text(), _tmpDirectory)
def testClickOnBackToRootTool(self):
if h5py is None:
@@ -529,7 +543,7 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
self.qWaitForWindowExposed(dialog)
dialog.selectUrl(_tmpDirectory)
self.qWaitForPendingActions(dialog)
- self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 3)
+ self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 4)
class TestDataFileDialog_FilterDataset(utils.TestCaseQt, _UtilsMixin):
diff --git a/silx/gui/dialog/test/test_imagefiledialog.py b/silx/gui/dialog/test/test_imagefiledialog.py
index 7909f10..8fef3c5 100644
--- a/silx/gui/dialog/test/test_imagefiledialog.py
+++ b/silx/gui/dialog/test/test_imagefiledialog.py
@@ -26,7 +26,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "12/02/2018"
+__date__ = "03/07/2018"
import unittest
@@ -50,7 +50,7 @@ import silx.io.url
from silx.gui import qt
from silx.gui.test import utils
from ..ImageFileDialog import ImageFileDialog
-from silx.gui.plot.Colormap import Colormap
+from silx.gui.colors import Colormap
from silx.gui.hdf5 import Hdf5TreeModel
_tmpDirectory = None
@@ -88,6 +88,18 @@ def setUpModule():
f["group/image"] = data
f.close()
+ if h5py is not None:
+ directory = os.path.join(_tmpDirectory, "data")
+ os.mkdir(directory)
+ filename = os.path.join(directory, "data.h5")
+ f = h5py.File(filename, "w")
+ f["scalar"] = 10
+ f["image"] = data
+ f["cube"] = [data, data + 1, data + 2]
+ f["complex_image"] = data * 1j
+ f["group/image"] = data
+ f.close()
+
filename = _tmpDirectory + "/badformat.edf"
with io.open(filename, "wb") as f:
f.write(b"{\nHello Nurse!")
@@ -256,27 +268,31 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
action = utils.findChildren(dialog, qt.QAction, name="toParentAction")[0]
toParentButton = utils.getQToolButtonFromAction(action)
- filename = _tmpDirectory + "/data.h5"
+ filename = _tmpDirectory + "/data/data.h5"
# init state
path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path()
dialog.selectUrl(path)
self.qWaitForPendingActions(dialog)
path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path()
+ print(url.text())
self.assertSamePath(url.text(), path)
# test
self.mouseClick(toParentButton, qt.Qt.LeftButton)
self.qWaitForPendingActions(dialog)
path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path()
+ print(url.text())
self.assertSamePath(url.text(), path)
self.mouseClick(toParentButton, qt.Qt.LeftButton)
self.qWaitForPendingActions(dialog)
- self.assertSamePath(url.text(), _tmpDirectory)
+ print(url.text())
+ self.assertSamePath(url.text(), _tmpDirectory + "/data")
self.mouseClick(toParentButton, qt.Qt.LeftButton)
self.qWaitForPendingActions(dialog)
- self.assertSamePath(url.text(), os.path.dirname(_tmpDirectory))
+ print(url.text())
+ self.assertSamePath(url.text(), _tmpDirectory)
def testClickOnBackToRootTool(self):
if h5py is None:
@@ -540,21 +556,21 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
self.qWaitForWindowExposed(dialog)
dialog.selectUrl(_tmpDirectory)
self.qWaitForPendingActions(dialog)
- self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 5)
+ self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 6)
codecName = fabio.edfimage.EdfImage.codec_name()
index = filters.indexFromCodec(codecName)
filters.setCurrentIndex(index)
filters.activated[int].emit(index)
self.qWait(50)
- self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 3)
+ self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 4)
codecName = fabio.fit2dmaskimage.Fit2dMaskImage.codec_name()
index = filters.indexFromCodec(codecName)
filters.setCurrentIndex(index)
filters.activated[int].emit(index)
self.qWait(50)
- self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 1)
+ self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 2)
class TestImageFileDialogApi(utils.TestCaseQt, _UtilsMixin):
diff --git a/silx/gui/hdf5/Hdf5Formatter.py b/silx/gui/hdf5/Hdf5Formatter.py
index 0e3697f..6802142 100644
--- a/silx/gui/hdf5/Hdf5Formatter.py
+++ b/silx/gui/hdf5/Hdf5Formatter.py
@@ -27,7 +27,7 @@ text."""
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "23/01/2018"
+__date__ = "06/06/2018"
import numpy
from silx.third_party import six
@@ -119,7 +119,11 @@ class Hdf5Formatter(qt.QObject):
return text
def humanReadableType(self, dataset, full=False):
- dtype = dataset.dtype
+ if hasattr(dataset, "dtype"):
+ dtype = dataset.dtype
+ else:
+ # Fallback...
+ dtype = type(dataset)
return self.humanReadableDType(dtype, full)
def humanReadableDType(self, dtype, full=False):
@@ -164,6 +168,16 @@ class Hdf5Formatter(qt.QObject):
return "enum"
text = str(dtype.newbyteorder('N'))
+ if numpy.issubdtype(dtype, numpy.floating):
+ if hasattr(numpy, "float128") and dtype == numpy.float128:
+ text = "float80"
+ if full:
+ text += " (padding 128bits)"
+ elif hasattr(numpy, "float96") and dtype == numpy.float96:
+ text = "float80"
+ if full:
+ text += " (padding 96bits)"
+
if full:
if dtype.byteorder == "<":
text = "Little-endian " + text
diff --git a/silx/gui/hdf5/Hdf5TreeModel.py b/silx/gui/hdf5/Hdf5TreeModel.py
index 2d62429..835708a 100644
--- a/silx/gui/hdf5/Hdf5TreeModel.py
+++ b/silx/gui/hdf5/Hdf5TreeModel.py
@@ -25,7 +25,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "29/11/2017"
+__date__ = "11/06/2018"
import os
@@ -205,7 +205,23 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
]
"""List of logical columns available"""
- def __init__(self, parent=None):
+ sigH5pyObjectLoaded = qt.Signal(object)
+ """Emitted when a new root item was loaded and inserted to the model."""
+
+ sigH5pyObjectRemoved = qt.Signal(object)
+ """Emitted when a root item is removed from the model."""
+
+ sigH5pyObjectSynchronized = qt.Signal(object, object)
+ """Emitted when an item was synchronized."""
+
+ def __init__(self, parent=None, ownFiles=True):
+ """
+ Constructor
+
+ :param qt.QWidget parent: Parent widget
+ :param bool ownFiles: If true (default) the model will manage the files
+ life cycle when they was added using path (like DnD).
+ """
super(Hdf5TreeModel, self).__init__(parent)
self.header_labels = [None] * len(self.COLUMN_IDS)
@@ -221,6 +237,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
self.__root = Hdf5Node()
self.__fileDropEnabled = True
self.__fileMoveEnabled = True
+ self.__datasetDragEnabled = False
self.__animatedIcon = icons.getWaitIcon()
self.__animatedIcon.iconChanged.connect(self.__updateLoadingItems)
@@ -235,6 +252,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
self.__icons.append(icons.getQIcon("item-3dim"))
self.__icons.append(icons.getQIcon("item-ndim"))
+ self.__ownFiles = ownFiles
self.__openedFiles = []
"""Store the list of files opened by the model itself."""
# FIXME: It should be managed one by one by Hdf5Item itself
@@ -285,16 +303,25 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
newItem = _unwrapNone(newItem)
error = _unwrapNone(error)
row = self.__root.indexOfChild(oldItem)
+
rootIndex = qt.QModelIndex()
self.beginRemoveRows(rootIndex, row, row)
self.__root.removeChildAtIndex(row)
self.endRemoveRows()
+
if newItem is not None:
rootIndex = qt.QModelIndex()
- self.__openedFiles.append(newItem.obj)
+ if self.__ownFiles:
+ self.__openedFiles.append(newItem.obj)
self.beginInsertRows(rootIndex, row, row)
self.__root.insertChild(row, newItem)
self.endInsertRows()
+
+ if isinstance(oldItem, Hdf5LoadingItem):
+ self.sigH5pyObjectLoaded.emit(newItem.obj)
+ else:
+ self.sigH5pyObjectSynchronized.emit(oldItem.obj, newItem.obj)
+
# FIXME the error must be displayed
def isFileDropEnabled(self):
@@ -306,6 +333,15 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
fileDropEnabled = qt.Property(bool, isFileDropEnabled, setFileDropEnabled)
"""Property to enable/disable file dropping in the model."""
+ def isDatasetDragEnabled(self):
+ return self.__datasetDragEnabled
+
+ def setDatasetDragEnabled(self, enabled):
+ self.__datasetDragEnabled = enabled
+
+ datasetDragEnabled = qt.Property(bool, isDatasetDragEnabled, setDatasetDragEnabled)
+ """Property to enable/disable drag of datasets."""
+
def isFileMoveEnabled(self):
return self.__fileMoveEnabled
@@ -323,10 +359,12 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
return 0
def mimeTypes(self):
+ types = []
if self.__fileMoveEnabled:
- return [_utils.Hdf5NodeMimeData.MIME_TYPE]
- else:
- return []
+ types.append(_utils.Hdf5NodeMimeData.MIME_TYPE)
+ if self.__datasetDragEnabled:
+ types.append(_utils.Hdf5DatasetMimeData.MIME_TYPE)
+ return types
def mimeData(self, indexes):
"""
@@ -336,7 +374,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
:param List[qt.QModelIndex] indexes: List of indexes
:rtype: qt.QMimeData
"""
- if not self.__fileMoveEnabled or len(indexes) == 0:
+ if len(indexes) == 0:
return None
indexes = [i for i in indexes if i.column() == 0]
@@ -346,7 +384,13 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
raise NotImplementedError("Drag of cell is not implemented")
node = self.nodeFromIndex(indexes[0])
- mimeData = _utils.Hdf5NodeMimeData(node)
+
+ if self.__fileMoveEnabled and node.parent is self.__root:
+ mimeData = _utils.Hdf5NodeMimeData(node=node)
+ elif self.__datasetDragEnabled:
+ mimeData = _utils.Hdf5DatasetMimeData(node=node)
+ else:
+ mimeData = None
return mimeData
def flags(self, index):
@@ -357,6 +401,8 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
if self.__fileMoveEnabled and node.parent is self.__root:
# that's a root
return qt.Qt.ItemIsDragEnabled | defaultFlags
+ elif self.__datasetDragEnabled:
+ return qt.Qt.ItemIsDragEnabled | defaultFlags
return defaultFlags
elif self.__fileDropEnabled or self.__fileMoveEnabled:
return qt.Qt.ItemIsDropEnabled | defaultFlags
@@ -543,8 +589,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
return
filename = node.obj.filename
- self.removeIndex(index)
- self.insertFileAsync(filename, index.row())
+ self.insertFileAsync(filename, index.row(), synchronizingNode=node)
def synchronizeH5pyObject(self, h5pyObject):
"""
@@ -560,8 +605,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
if item.obj is h5pyObject:
qindex = self.index(index, 0, qt.QModelIndex())
self.synchronizeIndex(qindex)
- else:
- index += 1
+ index += 1
def removeIndex(self, index):
"""
@@ -576,6 +620,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
self.beginRemoveRows(qt.QModelIndex(), index.row(), index.row())
self.__root.removeChildAtIndex(index.row())
self.endRemoveRows()
+ self.sigH5pyObjectRemoved.emit(node.obj)
def removeH5pyObject(self, h5pyObject):
"""
@@ -608,14 +653,17 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
def hasPendingOperations(self):
return len(self.__runnerSet) > 0
- def insertFileAsync(self, filename, row=-1):
+ def insertFileAsync(self, filename, row=-1, synchronizingNode=None):
if not os.path.isfile(filename):
raise IOError("Filename '%s' must be a file path" % filename)
# create temporary item
- text = os.path.basename(filename)
- item = Hdf5LoadingItem(text=text, parent=self.__root, animatedIcon=self.__animatedIcon)
- self.insertNode(row, item)
+ if synchronizingNode is None:
+ text = os.path.basename(filename)
+ item = Hdf5LoadingItem(text=text, parent=self.__root, animatedIcon=self.__animatedIcon)
+ self.insertNode(row, item)
+ else:
+ item = synchronizingNode
# start loading the real one
runnable = LoadingItemRunnable(filename, item)
@@ -634,12 +682,20 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
"""
try:
h5file = silx_io.open(filename)
- self.__openedFiles.append(h5file)
+ if self.__ownFiles:
+ self.__openedFiles.append(h5file)
+ self.sigH5pyObjectLoaded.emit(h5file)
self.insertH5pyObject(h5file, row=row)
except IOError:
_logger.debug("File '%s' can't be read.", filename, exc_info=True)
raise
+ def clear(self):
+ """Remove all the content of the model"""
+ for _ in range(self.rowCount()):
+ qindex = self.index(0, 0, qt.QModelIndex())
+ self.removeIndex(qindex)
+
def appendFile(self, filename):
self.insertFile(filename, -1)
diff --git a/silx/gui/hdf5/Hdf5TreeView.py b/silx/gui/hdf5/Hdf5TreeView.py
index 78b5c19..a86140a 100644
--- a/silx/gui/hdf5/Hdf5TreeView.py
+++ b/silx/gui/hdf5/Hdf5TreeView.py
@@ -25,7 +25,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "20/02/2018"
+__date__ = "30/04/2018"
import logging
@@ -66,10 +66,8 @@ class Hdf5TreeView(qt.QTreeView):
"""
qt.QTreeView.__init__(self, parent)
- model = Hdf5TreeModel(self)
- proxy_model = NexusSortFilterProxyModel(self)
- proxy_model.setSourceModel(model)
- self.setModel(proxy_model)
+ model = self.createDefaultModel()
+ self.setModel(model)
self.setHeader(Hdf5HeaderView(qt.Qt.Horizontal, self))
self.setSelectionBehavior(qt.QAbstractItemView.SelectRows)
@@ -87,6 +85,15 @@ class Hdf5TreeView(qt.QTreeView):
self.setContextMenuPolicy(qt.Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self._createContextMenu)
+ def createDefaultModel(self):
+ """Creates and returns the default model.
+
+ Inherite to custom the default model"""
+ model = Hdf5TreeModel(self)
+ proxy_model = NexusSortFilterProxyModel(self)
+ proxy_model.setSourceModel(model)
+ return proxy_model
+
def __removeContextMenuProxies(self, ref):
"""Callback to remove dead proxy from the list"""
self.__context_menu_callbacks.remove(ref)
diff --git a/silx/gui/hdf5/NexusSortFilterProxyModel.py b/silx/gui/hdf5/NexusSortFilterProxyModel.py
index 9a27968..3f2cf8d 100644
--- a/silx/gui/hdf5/NexusSortFilterProxyModel.py
+++ b/silx/gui/hdf5/NexusSortFilterProxyModel.py
@@ -25,7 +25,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "10/10/2017"
+__date__ = "25/06/2018"
import logging
@@ -34,6 +34,7 @@ import numpy
from .. import qt
from .Hdf5TreeModel import Hdf5TreeModel
import silx.io.utils
+from silx.gui import icons
_logger = logging.getLogger(__name__)
@@ -45,6 +46,7 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel):
def __init__(self, parent=None):
qt.QSortFilterProxyModel.__init__(self, parent)
self.__split = re.compile("(\\d+|\\D+)")
+ self.__iconCache = {}
def lessThan(self, sourceLeft, sourceRight):
"""Returns True if the value of the item referred to by the given
@@ -86,6 +88,14 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel):
nxClass = node.obj.attrs.get("NX_class", None)
return nxClass == "NXentry"
+ def __isNXnode(self, node):
+ """Returns true if the node is an NX concept"""
+ class_ = node.h5Class
+ if class_ is None or class_ != silx.io.utils.H5Type.GROUP:
+ return False
+ nxClass = node.obj.attrs.get("NX_class", None)
+ return nxClass is not None
+
def getWordsAndNumbers(self, name):
"""
Returns a list of words and integers composing the name.
@@ -96,11 +106,14 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel):
:param str name: A name
:rtype: List
"""
+ nonSensitive = self.sortCaseSensitivity() == qt.Qt.CaseInsensitive
words = self.__split.findall(name)
result = []
for i in words:
if i[0].isdigit():
i = int(i)
+ elif nonSensitive:
+ i = i.lower()
result.append(i)
return result
@@ -145,3 +158,47 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel):
except Exception:
_logger.debug("Exception occurred", exc_info=True)
return None
+
+ def __createCompoundIcon(self, backgroundIcon, foregroundIcon):
+ icon = qt.QIcon()
+
+ sizes = backgroundIcon.availableSizes()
+ sizes = sorted(sizes, key=lambda s: s.height())
+ sizes = filter(lambda s: s.height() < 100, sizes)
+ sizes = list(sizes)
+ if len(sizes) > 0:
+ baseSize = sizes[-1]
+ else:
+ baseSize = qt.QSize(32, 32)
+
+ modes = [qt.QIcon.Normal, qt.QIcon.Disabled]
+ for mode in modes:
+ pixmap = qt.QPixmap(baseSize)
+ pixmap.fill(qt.Qt.transparent)
+ painter = qt.QPainter(pixmap)
+ painter.drawPixmap(0, 0, backgroundIcon.pixmap(baseSize, mode=mode))
+ painter.drawPixmap(0, 0, foregroundIcon.pixmap(baseSize, mode=mode))
+ painter.end()
+ icon.addPixmap(pixmap, mode=mode)
+
+ return icon
+
+ def __getNxIcon(self, baseIcon):
+ iconHash = baseIcon.cacheKey()
+ icon = self.__iconCache.get(iconHash, None)
+ if icon is None:
+ nxIcon = icons.getQIcon("layer-nx")
+ icon = self.__createCompoundIcon(baseIcon, nxIcon)
+ self.__iconCache[iconHash] = icon
+ return icon
+
+ def data(self, index, role=qt.Qt.DisplayRole):
+ result = super(NexusSortFilterProxyModel, self).data(index, role)
+
+ if index.column() == Hdf5TreeModel.NAME_COLUMN:
+ if role == qt.Qt.DecorationRole:
+ sourceIndex = self.mapToSource(index)
+ item = self.sourceModel().data(sourceIndex, Hdf5TreeModel.H5PY_ITEM_ROLE)
+ if self.__isNXnode(item):
+ result = self.__getNxIcon(result)
+ return result
diff --git a/silx/gui/hdf5/_utils.py b/silx/gui/hdf5/_utils.py
index ddf4db5..8385129 100644
--- a/silx/gui/hdf5/_utils.py
+++ b/silx/gui/hdf5/_utils.py
@@ -28,7 +28,7 @@ package `silx.gui.hdf5` package.
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "20/12/2017"
+__date__ = "04/05/2018"
import logging
@@ -102,6 +102,26 @@ def htmlFromDict(dictionary, title=None):
return result
+class Hdf5DatasetMimeData(qt.QMimeData):
+ """Mimedata class to identify an internal drag and drop of a Hdf5Node."""
+
+ MIME_TYPE = "application/x-internal-h5py-dataset"
+
+ def __init__(self, node=None, dataset=None):
+ qt.QMimeData.__init__(self)
+ self.__dataset = dataset
+ self.__node = node
+ self.setData(self.MIME_TYPE, "".encode(encoding='utf-8'))
+
+ def node(self):
+ return self.__node
+
+ def dataset(self):
+ if self.__node is not None:
+ return self.__node.obj
+ return self.__dataset
+
+
class Hdf5NodeMimeData(qt.QMimeData):
"""Mimedata class to identify an internal drag and drop of a Hdf5Node."""
diff --git a/silx/gui/hdf5/test/test_hdf5.py b/silx/gui/hdf5/test/test_hdf5.py
index 44c4456..fc27f6b 100644
--- a/silx/gui/hdf5/test/test_hdf5.py
+++ b/silx/gui/hdf5/test/test_hdf5.py
@@ -26,7 +26,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "20/02/2018"
+__date__ = "03/05/2018"
import time
@@ -39,6 +39,7 @@ from contextlib import contextmanager
from silx.gui import qt
from silx.gui.test.utils import TestCaseQt
from silx.gui import hdf5
+from silx.gui.test.utils import SignalListener
from silx.io import commonh5
import weakref
@@ -48,6 +49,29 @@ except ImportError:
h5py = None
+_tmpDirectory = None
+
+
+def setUpModule():
+ global _tmpDirectory
+ _tmpDirectory = tempfile.mkdtemp(prefix=__name__)
+
+ if h5py is not None:
+ filename = _tmpDirectory + "/data.h5"
+
+ # create h5 data
+ f = h5py.File(filename, "w")
+ g = f.create_group("arrays")
+ g.create_dataset("scalar", data=10)
+ f.close()
+
+
+def tearDownModule():
+ global _tmpDirectory
+ shutil.rmtree(_tmpDirectory)
+ _tmpDirectory = None
+
+
_called = 0
@@ -71,7 +95,7 @@ class TestHdf5TreeModel(TestCaseQt):
self.skipTest("h5py is not available")
def waitForPendingOperations(self, model):
- for i in range(10):
+ for _ in range(10):
if not model.hasPendingOperations():
break
self.qWait(10)
@@ -97,53 +121,53 @@ class TestHdf5TreeModel(TestCaseQt):
self.assertIsNotNone(model)
def testAppendFilename(self):
- with self.h5TempFile() as filename:
+ filename = _tmpDirectory + "/data.h5"
+ model = hdf5.Hdf5TreeModel()
+ self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
+ model.appendFile(filename)
+ self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
+ # clean up
+ index = model.index(0, 0, qt.QModelIndex())
+ h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE)
+ ref = weakref.ref(model)
+ model = None
+ self.qWaitForDestroy(ref)
+
+ def testAppendBadFilename(self):
+ model = hdf5.Hdf5TreeModel()
+ self.assertRaises(IOError, model.appendFile, "#%$")
+
+ def testInsertFilename(self):
+ filename = _tmpDirectory + "/data.h5"
+ try:
model = hdf5.Hdf5TreeModel()
self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
- model.appendFile(filename)
+ model.insertFile(filename)
self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
# clean up
index = model.index(0, 0, qt.QModelIndex())
h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE)
+ self.assertIsNotNone(h5File)
+ finally:
ref = weakref.ref(model)
model = None
self.qWaitForDestroy(ref)
- def testAppendBadFilename(self):
- model = hdf5.Hdf5TreeModel()
- self.assertRaises(IOError, model.appendFile, "#%$")
-
- def testInsertFilename(self):
- with self.h5TempFile() as filename:
- try:
- model = hdf5.Hdf5TreeModel()
- self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
- model.insertFile(filename)
- self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
- # clean up
- index = model.index(0, 0, qt.QModelIndex())
- h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE)
- self.assertIsNotNone(h5File)
- finally:
- ref = weakref.ref(model)
- model = None
- self.qWaitForDestroy(ref)
-
def testInsertFilenameAsync(self):
- with self.h5TempFile() as filename:
- try:
- model = hdf5.Hdf5TreeModel()
- self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
- model.insertFileAsync(filename)
- index = model.index(0, 0, qt.QModelIndex())
- self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5LoadingItem.Hdf5LoadingItem)
- self.waitForPendingOperations(model)
- index = model.index(0, 0, qt.QModelIndex())
- self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item)
- finally:
- ref = weakref.ref(model)
- model = None
- self.qWaitForDestroy(ref)
+ filename = _tmpDirectory + "/data.h5"
+ try:
+ model = hdf5.Hdf5TreeModel()
+ self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
+ model.insertFileAsync(filename)
+ index = model.index(0, 0, qt.QModelIndex())
+ self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5LoadingItem.Hdf5LoadingItem)
+ self.waitForPendingOperations(model)
+ index = model.index(0, 0, qt.QModelIndex())
+ self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item)
+ finally:
+ ref = weakref.ref(model)
+ model = None
+ self.qWaitForDestroy(ref)
def testInsertObject(self):
h5 = commonh5.File("/foo/bar/1.mock", "w")
@@ -162,36 +186,37 @@ class TestHdf5TreeModel(TestCaseQt):
self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
def testSynchronizeObject(self):
- with self.h5TempFile() as filename:
- h5 = h5py.File(filename)
- model = hdf5.Hdf5TreeModel()
- model.insertH5pyObject(h5)
- self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
- index = model.index(0, 0, qt.QModelIndex())
- node1 = model.nodeFromIndex(index)
- model.synchronizeH5pyObject(h5)
- # Now h5 was loaded from it's filename
- # Another ref is owned by the model
- h5.close()
+ filename = _tmpDirectory + "/data.h5"
+ h5 = h5py.File(filename)
+ model = hdf5.Hdf5TreeModel()
+ model.insertH5pyObject(h5)
+ self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
+ index = model.index(0, 0, qt.QModelIndex())
+ node1 = model.nodeFromIndex(index)
+ model.synchronizeH5pyObject(h5)
+ self.waitForPendingOperations(model)
+ # Now h5 was loaded from it's filename
+ # Another ref is owned by the model
+ h5.close()
- index = model.index(0, 0, qt.QModelIndex())
- node2 = model.nodeFromIndex(index)
- self.assertIsNot(node1, node2)
- # after sync
- time.sleep(0.1)
- self.qapp.processEvents()
- time.sleep(0.1)
- index = model.index(0, 0, qt.QModelIndex())
- self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item)
- # clean up
- index = model.index(0, 0, qt.QModelIndex())
- h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE)
- self.assertIsNotNone(h5File)
- h5File = None
- # delete the model
- ref = weakref.ref(model)
- model = None
- self.qWaitForDestroy(ref)
+ index = model.index(0, 0, qt.QModelIndex())
+ node2 = model.nodeFromIndex(index)
+ self.assertIsNot(node1, node2)
+ # after sync
+ time.sleep(0.1)
+ self.qapp.processEvents()
+ time.sleep(0.1)
+ index = model.index(0, 0, qt.QModelIndex())
+ self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item)
+ # clean up
+ index = model.index(0, 0, qt.QModelIndex())
+ h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE)
+ self.assertIsNotNone(h5File)
+ h5File = None
+ # delete the model
+ ref = weakref.ref(model)
+ model = None
+ self.qWaitForDestroy(ref)
def testFileMoveState(self):
model = hdf5.Hdf5TreeModel()
@@ -222,24 +247,24 @@ class TestHdf5TreeModel(TestCaseQt):
self.assertNotEquals(model.supportedDropActions(), 0)
def testDropExternalFile(self):
- with self.h5TempFile() as filename:
- model = hdf5.Hdf5TreeModel()
- mimeData = qt.QMimeData()
- mimeData.setUrls([qt.QUrl.fromLocalFile(filename)])
- model.dropMimeData(mimeData, qt.Qt.CopyAction, 0, 0, qt.QModelIndex())
- self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
- # after sync
- self.waitForPendingOperations(model)
- index = model.index(0, 0, qt.QModelIndex())
- self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item)
- # clean up
- index = model.index(0, 0, qt.QModelIndex())
- h5File = model.data(index, role=hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE)
- self.assertIsNotNone(h5File)
- h5File = None
- ref = weakref.ref(model)
- model = None
- self.qWaitForDestroy(ref)
+ filename = _tmpDirectory + "/data.h5"
+ model = hdf5.Hdf5TreeModel()
+ mimeData = qt.QMimeData()
+ mimeData.setUrls([qt.QUrl.fromLocalFile(filename)])
+ model.dropMimeData(mimeData, qt.Qt.CopyAction, 0, 0, qt.QModelIndex())
+ self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
+ # after sync
+ self.waitForPendingOperations(model)
+ index = model.index(0, 0, qt.QModelIndex())
+ self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item)
+ # clean up
+ index = model.index(0, 0, qt.QModelIndex())
+ h5File = model.data(index, role=hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE)
+ self.assertIsNotNone(h5File)
+ h5File = None
+ ref = weakref.ref(model)
+ model = None
+ self.qWaitForDestroy(ref)
def getRowDataAsDict(self, model, row):
displayed = {}
@@ -337,6 +362,66 @@ class TestHdf5TreeModel(TestCaseQt):
self.assertEquals(index, qt.QModelIndex())
+class TestHdf5TreeModelSignals(TestCaseQt):
+
+ def setUp(self):
+ TestCaseQt.setUp(self)
+ self.model = hdf5.Hdf5TreeModel()
+ filename = _tmpDirectory + "/data.h5"
+ self.h5 = h5py.File(filename)
+ self.model.insertH5pyObject(self.h5)
+
+ self.listener = SignalListener()
+ self.model.sigH5pyObjectLoaded.connect(self.listener.partial(signal="loaded"))
+ self.model.sigH5pyObjectRemoved.connect(self.listener.partial(signal="removed"))
+ self.model.sigH5pyObjectSynchronized.connect(self.listener.partial(signal="synchronized"))
+
+ def tearDown(self):
+ self.signals = None
+ ref = weakref.ref(self.model)
+ self.model = None
+ self.qWaitForDestroy(ref)
+ self.h5.close()
+ self.h5 = None
+ TestCaseQt.tearDown(self)
+
+ def waitForPendingOperations(self, model):
+ for _ in range(10):
+ if not model.hasPendingOperations():
+ break
+ self.qWait(10)
+ else:
+ raise RuntimeError("Still waiting for a pending operation")
+
+ def testInsert(self):
+ filename = _tmpDirectory + "/data.h5"
+ h5 = h5py.File(filename)
+ self.model.insertH5pyObject(h5)
+ self.assertEquals(self.listener.callCount(), 0)
+
+ def testLoaded(self):
+ filename = _tmpDirectory + "/data.h5"
+ self.model.insertFile(filename)
+ self.assertEquals(self.listener.callCount(), 1)
+ self.assertEquals(self.listener.karguments(argumentName="signal")[0], "loaded")
+ self.assertIsNot(self.listener.arguments(callIndex=0)[0], self.h5)
+ self.assertEquals(self.listener.arguments(callIndex=0)[0].filename, filename)
+
+ def testRemoved(self):
+ self.model.removeH5pyObject(self.h5)
+ self.assertEquals(self.listener.callCount(), 1)
+ self.assertEquals(self.listener.karguments(argumentName="signal")[0], "removed")
+ self.assertIs(self.listener.arguments(callIndex=0)[0], self.h5)
+
+ def testSynchonized(self):
+ self.model.synchronizeH5pyObject(self.h5)
+ self.waitForPendingOperations(self.model)
+ self.assertEquals(self.listener.callCount(), 1)
+ self.assertEquals(self.listener.karguments(argumentName="signal")[0], "synchronized")
+ self.assertIs(self.listener.arguments(callIndex=0)[0], self.h5)
+ self.assertIsNot(self.listener.arguments(callIndex=0)[1], self.h5)
+
+
class TestNexusSortFilterProxyModel(TestCaseQt):
def getChildNames(self, model, index):
@@ -873,6 +958,7 @@ def suite():
test_suite = unittest.TestSuite()
loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
test_suite.addTest(loadTests(TestHdf5TreeModel))
+ test_suite.addTest(loadTests(TestHdf5TreeModelSignals))
test_suite.addTest(loadTests(TestNexusSortFilterProxyModel))
test_suite.addTest(loadTests(TestHdf5TreeView))
test_suite.addTest(loadTests(TestH5Node))
diff --git a/silx/gui/icons.py b/silx/gui/icons.py
index 0108b3a..bd10300 100644
--- a/silx/gui/icons.py
+++ b/silx/gui/icons.py
@@ -29,7 +29,7 @@ Use :func:`getQIcon` to create Qt QIcon from the name identifying an icon.
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "06/09/2017"
+__date__ = "19/06/2018"
import os
@@ -193,10 +193,13 @@ class MultiImageAnimatedIcon(AbstractAnimatedIcon):
self.__frames = []
for i in range(100):
try:
- pixmap = getQPixmap("%s/%02d" % (filename, i))
+ filename = getQFile("%s/%02d" % (filename, i))
+ except ValueError:
+ break
+ try:
+ icon = qt.QIcon(filename.fileName())
except ValueError:
break
- icon = qt.QIcon(pixmap)
self.__frames.append(icon)
if len(self.__frames) == 0:
@@ -328,8 +331,7 @@ def getQIcon(name):
"""
if name not in _cached_icons:
qfile = getQFile(name)
- pixmap = qt.QPixmap(qfile.fileName())
- icon = qt.QIcon(pixmap)
+ icon = qt.QIcon(qfile.fileName())
_cached_icons[name] = icon
else:
icon = _cached_icons[name]
@@ -392,7 +394,7 @@ def getQFile(name):
for format_ in _supported_formats:
format_ = str(format_)
filename = silx.resources._resource_filename('%s.%s' % (name, format_),
- default_directory=os.path.join('gui', 'icons'))
+ default_directory=os.path.join('gui', 'icons'))
qfile = qt.QFile(filename)
if qfile.exists():
return qfile
diff --git a/silx/gui/plot/ColorBar.py b/silx/gui/plot/ColorBar.py
index 2db7b79..0941e82 100644
--- a/silx/gui/plot/ColorBar.py
+++ b/silx/gui/plot/ColorBar.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -27,14 +27,16 @@
__authors__ = ["H. Payno", "T. Vincent"]
__license__ = "MIT"
-__date__ = "15/02/2018"
+__date__ = "24/04/2018"
import logging
+import weakref
import numpy
+
from ._utils import ticklayout
-from .. import qt, icons
-from silx.gui.plot import Colormap
+from .. import qt
+from silx.gui import colors
_logger = logging.getLogger(__name__)
@@ -70,7 +72,7 @@ class ColorBarWidget(qt.QWidget):
def __init__(self, parent=None, plot=None, legend=None):
self._isConnected = False
- self._plot = None
+ self._plotRef = None
self._colormap = None
self._data = None
@@ -96,7 +98,7 @@ class ColorBarWidget(qt.QWidget):
def getPlot(self):
"""Returns the :class:`Plot` associated to this widget or None"""
- return self._plot
+ return None if self._plotRef is None else self._plotRef()
def setPlot(self, plot):
"""Associate a plot to the ColorBar
@@ -105,27 +107,38 @@ class ColorBarWidget(qt.QWidget):
If None will remove any connection with a previous plot.
"""
self._disconnectPlot()
- self._plot = plot
+ self._plotRef = None if plot is None else weakref.ref(plot)
self._connectPlot()
def _disconnectPlot(self):
"""Disconnect from Plot signals"""
- if self._plot is not None and self._isConnected:
+ plot = self.getPlot()
+ if plot is not None and self._isConnected:
self._isConnected = False
- self._plot.sigActiveImageChanged.disconnect(
+ plot.sigActiveImageChanged.disconnect(
self._activeImageChanged)
- self._plot.sigPlotSignal.disconnect(self._defaultColormapChanged)
+ plot.sigActiveScatterChanged.disconnect(
+ self._activeScatterChanged)
+ plot.sigPlotSignal.disconnect(self._defaultColormapChanged)
def _connectPlot(self):
"""Connect to Plot signals"""
- if self._plot is not None and not self._isConnected:
- activeImageLegend = self._plot.getActiveImage(just_legend=True)
- if activeImageLegend is None: # Show plot default colormap
+ plot = self.getPlot()
+ if plot is not None and not self._isConnected:
+ activeImageLegend = plot.getActiveImage(just_legend=True)
+ activeScatterLegend = plot._getActiveItem(
+ kind='scatter', just_legend=True)
+ if activeImageLegend is None and activeScatterLegend is None:
+ # Show plot default colormap
self._syncWithDefaultColormap()
- else: # Show active image colormap
+ elif activeImageLegend is not None: # Show active image colormap
self._activeImageChanged(None, activeImageLegend)
- self._plot.sigActiveImageChanged.connect(self._activeImageChanged)
- self._plot.sigPlotSignal.connect(self._defaultColormapChanged)
+ elif activeScatterLegend is not None: # Show active scatter colormap
+ self._activeScatterChanged(None, activeScatterLegend)
+
+ plot.sigActiveImageChanged.connect(self._activeImageChanged)
+ plot.sigActiveScatterChanged.connect(self._activeScatterChanged)
+ plot.sigPlotSignal.connect(self._defaultColormapChanged)
self._isConnected = True
def setVisible(self, isVisible):
@@ -196,36 +209,58 @@ class ColorBarWidget(qt.QWidget):
"""
return self.legend.getText()
- def _activeImageChanged(self, previous, legend):
- """Handle plot active curve changed"""
- if legend is None: # No active image, display no colormap
- self.setColormap(colormap=None)
- return
+ def _activeScatterChanged(self, previous, legend):
+ """Handle plot active scatter changed"""
+ plot = self.getPlot()
- # Sync with active image
- image = self._plot.getActiveImage().getData(copy=False)
+ # Do not handle active scatter while there is an image
+ if plot.getActiveImage() is not None:
+ return
- # RGB(A) image, display default colormap
- if image.ndim != 2:
+ if legend is None: # No active scatter, display no colormap
self.setColormap(colormap=None)
return
- # data image, sync with image colormap
- # do we need the copy here : used in the case we are changing
- # vmin and vmax but should have already be done by the plot
- self.setColormap(colormap=self._plot.getActiveImage().getColormap(),
- data=image)
+ # Sync with active scatter
+ activeScatter = plot._getActiveItem(kind='scatter')
+
+ self.setColormap(colormap=activeScatter.getColormap(),
+ data=activeScatter.getValueData(copy=False))
+
+ def _activeImageChanged(self, previous, legend):
+ """Handle plot active image changed"""
+ plot = self.getPlot()
+
+ if legend is None: # No active image, try with active scatter
+ activeScatterLegend = plot._getActiveItem(
+ kind='scatter', just_legend=True)
+ # No more active image, use active scatter if any
+ self._activeScatterChanged(None, activeScatterLegend)
+ else:
+ # Sync with active image
+ image = plot.getActiveImage().getData(copy=False)
+
+ # RGB(A) image, display default colormap
+ if image.ndim != 2:
+ self.setColormap(colormap=None)
+ return
+
+ # data image, sync with image colormap
+ # do we need the copy here : used in the case we are changing
+ # vmin and vmax but should have already be done by the plot
+ self.setColormap(colormap=plot.getActiveImage().getColormap(),
+ data=image)
def _defaultColormapChanged(self, event):
"""Handle plot default colormap changed"""
if (event['event'] == 'defaultColormapChanged' and
- self._plot.getActiveImage() is None):
+ self.getPlot().getActiveImage() is None):
# No active image, take default colormap update into account
self._syncWithDefaultColormap()
def _syncWithDefaultColormap(self, data=None):
"""Update colorbar according to plot default colormap"""
- self.setColormap(self._plot.getDefaultColormap(), data)
+ self.setColormap(self.getPlot().getDefaultColormap(), data)
def getColorScaleBar(self):
"""
@@ -316,9 +351,9 @@ class ColorScaleBar(qt.QWidget):
if colormap:
vmin, vmax = colormap.getColormapRange(data)
else:
- vmin, vmax = Colormap.DEFAULT_MIN_LIN, Colormap.DEFAULT_MAX_LIN
+ vmin, vmax = colors.DEFAULT_MIN_LIN, colors.DEFAULT_MAX_LIN
- norm = colormap.getNormalization() if colormap else Colormap.Colormap.LINEAR
+ norm = colormap.getNormalization() if colormap else colors.Colormap.LINEAR
self.tickbar = _TickBar(vmin=vmin,
vmax=vmax,
norm=norm,
@@ -503,7 +538,7 @@ class _ColorScale(qt.QWidget):
if colormap is None:
self.vmin, self.vmax = None, None
else:
- assert colormap.getNormalization() in Colormap.Colormap.NORMALIZATIONS
+ assert colormap.getNormalization() in colors.Colormap.NORMALIZATIONS
self.vmin, self.vmax = self._colormap.getColormapRange(data=data)
self._updateColorGradient()
self.update()
@@ -575,9 +610,9 @@ class _ColorScale(qt.QWidget):
vmin = self.vmin
vmax = self.vmax
- if colormap.getNormalization() == Colormap.Colormap.LINEAR:
+ if colormap.getNormalization() == colors.Colormap.LINEAR:
return vmin + (vmax - vmin) * value
- elif colormap.getNormalization() == Colormap.Colormap.LOGARITHM:
+ elif colormap.getNormalization() == colors.Colormap.LOGARITHM:
rpos = (numpy.log10(vmax) - numpy.log10(vmin)) * value + numpy.log10(vmin)
return numpy.power(10., rpos)
else:
@@ -706,9 +741,9 @@ class _TickBar(qt.QWidget):
# No range: no ticks
self.ticks = ()
self.subTicks = ()
- elif self._norm == Colormap.Colormap.LOGARITHM:
+ elif self._norm == colors.Colormap.LOGARITHM:
self._computeTicksLog(nticks)
- elif self._norm == Colormap.Colormap.LINEAR:
+ elif self._norm == colors.Colormap.LINEAR:
self._computeTicksLin(nticks)
else:
err = 'TickBar - Wrong normalization %s' % self._norm
@@ -765,9 +800,9 @@ class _TickBar(qt.QWidget):
def _getRelativePosition(self, val):
"""Return the relative position of val according to min and max value
"""
- if self._norm == Colormap.Colormap.LINEAR:
+ if self._norm == colors.Colormap.LINEAR:
return 1 - (val - self._vmin) / (self._vmax - self._vmin)
- elif self._norm == Colormap.Colormap.LOGARITHM:
+ elif self._norm == colors.Colormap.LOGARITHM:
return 1 - (numpy.log10(val) - numpy.log10(self._vmin)) / (numpy.log10(self._vmax) - numpy.log(self._vmin))
else:
raise ValueError('Norm is not recognized')
diff --git a/silx/gui/plot/Colormap.py b/silx/gui/plot/Colormap.py
index 9adf0d4..e797d89 100644
--- a/silx/gui/plot/Colormap.py
+++ b/silx/gui/plot/Colormap.py
@@ -22,568 +22,23 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""This module provides the Colormap object
+"""Deprecated module providing the Colormap object
"""
from __future__ import absolute_import
__authors__ = ["T. Vincent", "H.Payno"]
__license__ = "MIT"
-__date__ = "08/01/2018"
+__date__ = "24/04/2018"
-from silx.gui import qt
-import copy as copy_mdl
-import numpy
-from .matplotlib import Colormap as MPLColormap
-import logging
-from silx.math.combo import min_max
-from silx.utils.exceptions import NotEditableError
+import silx.utils.deprecation
-_logger = logging.getLogger(__file__)
+silx.utils.deprecation.deprecated_warning("Module",
+ name="silx.gui.plot.Colormap",
+ reason="moved",
+ replacement="silx.gui.colors.Colormap",
+ since_version="0.8.0",
+ only_once=True,
+ skip_backtrace_count=1)
-DEFAULT_COLORMAPS = (
- 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue')
-"""Tuple of supported colormap names."""
-
-DEFAULT_MIN_LIN = 0
-"""Default min value if in linear normalization"""
-DEFAULT_MAX_LIN = 1
-"""Default max value if in linear normalization"""
-DEFAULT_MIN_LOG = 1
-"""Default min value if in log normalization"""
-DEFAULT_MAX_LOG = 10
-"""Default max value if in log normalization"""
-
-
-class Colormap(qt.QObject):
- """Description of a colormap
-
- :param str name: Name of the colormap
- :param tuple colors: optional, custom colormap.
- Nx3 or Nx4 numpy array of RGB(A) colors,
- either uint8 or float in [0, 1].
- If 'name' is None, then this array is used as the colormap.
- :param str normalization: Normalization: 'linear' (default) or 'log'
- :param float vmin:
- Lower bound of the colormap or None for autoscale (default)
- :param float vmax:
- Upper bounds of the colormap or None for autoscale (default)
- """
-
- LINEAR = 'linear'
- """constant for linear normalization"""
-
- LOGARITHM = 'log'
- """constant for logarithmic normalization"""
-
- NORMALIZATIONS = (LINEAR, LOGARITHM)
- """Tuple of managed normalizations"""
-
- sigChanged = qt.Signal()
- """Signal emitted when the colormap has changed."""
-
- def __init__(self, name='gray', colors=None, normalization=LINEAR, vmin=None, vmax=None):
- qt.QObject.__init__(self)
- assert normalization in Colormap.NORMALIZATIONS
- assert not (name is None and colors is None)
- if normalization is Colormap.LOGARITHM:
- if (vmin is not None and vmin < 0) or (vmax is not None and vmax < 0):
- m = "Unsuported vmin (%s) and/or vmax (%s) given for a log scale."
- m += ' Autoscale will be performed.'
- m = m % (vmin, vmax)
- _logger.warning(m)
- vmin = None
- vmax = None
-
- self._name = str(name) if name is not None else None
- self._setColors(colors)
- self._normalization = str(normalization)
- self._vmin = float(vmin) if vmin is not None else None
- self._vmax = float(vmax) if vmax is not None else None
- self._editable = True
-
- def isAutoscale(self):
- """Return True if both min and max are in autoscale mode"""
- return self._vmin is None and self._vmax is None
-
- def getName(self):
- """Return the name of the colormap
- :rtype: str
- """
- return self._name
-
- def _setColors(self, colors):
- if colors is None:
- self._colors = None
- else:
- self._colors = numpy.array(colors, copy=True)
-
- def getNColors(self, nbColors=None):
- """Returns N colors computed by sampling the colormap regularly.
-
- :param nbColors:
- The number of colors in the returned array or None for the default value.
- The default value is 256 for colormap with a name (see :meth:`setName`) and
- it is the size of the LUT for colormap defined with :meth:`setColormapLUT`.
- :type nbColors: int or None
- :return: 2D array of uint8 of shape (nbColors, 4)
- :rtype: numpy.ndarray
- """
- # Handle default value for nbColors
- if nbColors is None:
- lut = self.getColormapLUT()
- if lut is not None: # In this case uses LUT length
- nbColors = len(lut)
- else: # Default to 256
- nbColors = 256
-
- nbColors = int(nbColors)
-
- colormap = self.copy()
- colormap.setNormalization(Colormap.LINEAR)
- colormap.setVRange(vmin=None, vmax=None)
- colors = colormap.applyToData(
- numpy.arange(nbColors, dtype=numpy.int))
- return colors
-
- def setName(self, name):
- """Set the name of the colormap to use.
-
- :param str name: The name of the colormap.
- At least the following names are supported: 'gray',
- 'reversed gray', 'temperature', 'red', 'green', 'blue', 'jet',
- 'viridis', 'magma', 'inferno', 'plasma'.
- """
- if self.isEditable() is False:
- raise NotEditableError('Colormap is not editable')
- assert name in self.getSupportedColormaps()
- self._name = str(name)
- self._colors = None
- self.sigChanged.emit()
-
- def getColormapLUT(self):
- """Return the list of colors for the colormap or None if not set
-
- :return: the list of colors for the colormap or None if not set
- :rtype: numpy.ndarray or None
- """
- if self._colors is None:
- return None
- else:
- return numpy.array(self._colors, copy=True)
-
- def setColormapLUT(self, colors):
- """Set the colors of the colormap.
-
- :param numpy.ndarray colors: the colors of the LUT
-
- .. warning: this will set the value of name to None
- """
- if self.isEditable() is False:
- raise NotEditableError('Colormap is not editable')
- self._setColors(colors)
- if len(colors) is 0:
- self._colors = None
-
- self._name = None
- self.sigChanged.emit()
-
- def getNormalization(self):
- """Return the normalization of the colormap ('log' or 'linear')
-
- :return: the normalization of the colormap
- :rtype: str
- """
- return self._normalization
-
- def setNormalization(self, norm):
- """Set the norm ('log', 'linear')
-
- :param str norm: the norm to set
- """
- if self.isEditable() is False:
- raise NotEditableError('Colormap is not editable')
- self._normalization = str(norm)
- self.sigChanged.emit()
-
- def getVMin(self):
- """Return the lower bound of the colormap
-
- :return: the lower bound of the colormap
- :rtype: float or None
- """
- return self._vmin
-
- def setVMin(self, vmin):
- """Set the minimal value of the colormap
-
- :param float vmin: Lower bound of the colormap or None for autoscale
- (default)
- value)
- """
- if self.isEditable() is False:
- raise NotEditableError('Colormap is not editable')
- if vmin is not None:
- if self._vmax is not None and vmin > self._vmax:
- err = "Can't set vmin because vmin >= vmax. " \
- "vmin = %s, vmax = %s" % (vmin, self._vmax)
- raise ValueError(err)
-
- self._vmin = vmin
- self.sigChanged.emit()
-
- def getVMax(self):
- """Return the upper bounds of the colormap or None
-
- :return: the upper bounds of the colormap or None
- :rtype: float or None
- """
- return self._vmax
-
- def setVMax(self, vmax):
- """Set the maximal value of the colormap
-
- :param float vmax: Upper bounds of the colormap or None for autoscale
- (default)
- """
- if self.isEditable() is False:
- raise NotEditableError('Colormap is not editable')
- if vmax is not None:
- if self._vmin is not None and vmax < self._vmin:
- err = "Can't set vmax because vmax <= vmin. " \
- "vmin = %s, vmax = %s" % (self._vmin, vmax)
- raise ValueError(err)
-
- self._vmax = vmax
- self.sigChanged.emit()
-
- def isEditable(self):
- """ Return if the colormap is editable or not
-
- :return: editable state of the colormap
- :rtype: bool
- """
- return self._editable
-
- def setEditable(self, editable):
- """
- Set the editable state of the colormap
-
- :param bool editable: is the colormap editable
- """
- assert type(editable) is bool
- self._editable = editable
- self.sigChanged.emit()
-
- def getColormapRange(self, data=None):
- """Return (vmin, vmax)
-
- :return: the tuple vmin, vmax fitting vmin, vmax, normalization and
- data if any given
- :rtype: tuple
- """
- vmin = self._vmin
- vmax = self._vmax
- assert vmin is None or vmax is None or vmin <= vmax # TODO handle this in setters
-
- if self.getNormalization() == self.LOGARITHM:
- # Handle negative bounds as autoscale
- if vmin is not None and (vmin is not None and vmin <= 0.):
- mess = 'negative vmin, moving to autoscale for lower bound'
- _logger.warning(mess)
- vmin = None
- if vmax is not None and (vmax is not None and vmax <= 0.):
- mess = 'negative vmax, moving to autoscale for upper bound'
- _logger.warning(mess)
- vmax = None
-
- if vmin is None or vmax is None: # Handle autoscale
- # Get min/max from data
- if data is not None:
- data = numpy.array(data, copy=False)
- if data.size == 0: # Fallback an array but no data
- min_, max_ = self._getDefaultMin(), self._getDefaultMax()
- else:
- if self.getNormalization() == self.LOGARITHM:
- result = min_max(data, min_positive=True, finite=True)
- min_ = result.min_positive # >0 or None
- max_ = result.maximum # can be <= 0
- else:
- min_, max_ = min_max(data, min_positive=False, finite=True)
-
- # Handle fallback
- if min_ is None or not numpy.isfinite(min_):
- min_ = self._getDefaultMin()
- if max_ is None or not numpy.isfinite(max_):
- max_ = self._getDefaultMax()
- else: # Fallback if no data is provided
- min_, max_ = self._getDefaultMin(), self._getDefaultMax()
-
- if vmin is None: # Set vmin respecting provided vmax
- vmin = min_ if vmax is None else min(min_, vmax)
-
- if vmax is None:
- vmax = max(max_, vmin) # Handle max_ <= 0 for log scale
-
- return vmin, vmax
-
- def setVRange(self, vmin, vmax):
- """Set the bounds of the colormap
-
- :param vmin: Lower bound of the colormap or None for autoscale
- (default)
- :param vmax: Upper bounds of the colormap or None for autoscale
- (default)
- """
- if self.isEditable() is False:
- raise NotEditableError('Colormap is not editable')
- if vmin is not None and vmax is not None:
- if vmin > vmax:
- err = "Can't set vmin and vmax because vmin >= vmax " \
- "vmin = %s, vmax = %s" % (vmin, vmax)
- raise ValueError(err)
-
- if self._vmin == vmin and self._vmax == vmax:
- return
-
- self._vmin = vmin
- self._vmax = vmax
- self.sigChanged.emit()
-
- def __getitem__(self, item):
- if item == 'autoscale':
- return self.isAutoscale()
- elif item == 'name':
- return self.getName()
- elif item == 'normalization':
- return self.getNormalization()
- elif item == 'vmin':
- return self.getVMin()
- elif item == 'vmax':
- return self.getVMax()
- elif item == 'colors':
- return self.getColormapLUT()
- else:
- raise KeyError(item)
-
- def _toDict(self):
- """Return the equivalent colormap as a dictionary
- (old colormap representation)
-
- :return: the representation of the Colormap as a dictionary
- :rtype: dict
- """
- return {
- 'name': self._name,
- 'colors': copy_mdl.copy(self._colors),
- 'vmin': self._vmin,
- 'vmax': self._vmax,
- 'autoscale': self.isAutoscale(),
- 'normalization': self._normalization
- }
-
- def _setFromDict(self, dic):
- """Set values to the colormap from a dictionary
-
- :param dict dic: the colormap as a dictionary
- """
- if self.isEditable() is False:
- raise NotEditableError('Colormap is not editable')
- name = dic['name'] if 'name' in dic else None
- colors = dic['colors'] if 'colors' in dic else None
- vmin = dic['vmin'] if 'vmin' in dic else None
- vmax = dic['vmax'] if 'vmax' in dic else None
- if 'normalization' in dic:
- normalization = dic['normalization']
- else:
- warn = 'Normalization not given in the dictionary, '
- warn += 'set by default to ' + Colormap.LINEAR
- _logger.warning(warn)
- normalization = Colormap.LINEAR
-
- if name is None and colors is None:
- err = 'The colormap should have a name defined or a tuple of colors'
- raise ValueError(err)
- if normalization not in Colormap.NORMALIZATIONS:
- err = 'Given normalization is not recoginized (%s)' % normalization
- raise ValueError(err)
-
- # If autoscale, then set boundaries to None
- if dic.get('autoscale', False):
- vmin, vmax = None, None
-
- self._name = name
- self._colors = colors
- self._vmin = vmin
- self._vmax = vmax
- self._autoscale = True if (vmin is None and vmax is None) else False
- self._normalization = normalization
-
- self.sigChanged.emit()
-
- @staticmethod
- def _fromDict(dic):
- colormap = Colormap(name="")
- colormap._setFromDict(dic)
- return colormap
-
- def copy(self):
- """Return a copy of the Colormap.
-
- :rtype: silx.gui.plot.Colormap.Colormap
- """
- return Colormap(name=self._name,
- colors=copy_mdl.copy(self._colors),
- vmin=self._vmin,
- vmax=self._vmax,
- normalization=self._normalization)
-
- def applyToData(self, data):
- """Apply the colormap to the data
-
- :param numpy.ndarray data: The data to convert.
- """
- rgbaImage = MPLColormap.applyColormapToData(colormap=self, data=data)
- return rgbaImage
-
- @staticmethod
- def getSupportedColormaps():
- """Get the supported colormap names as a tuple of str.
-
- The list should at least contain and start by:
- ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue')
- :rtype: tuple
- """
- maps = MPLColormap.getSupportedColormaps()
- return DEFAULT_COLORMAPS + maps
-
- def __str__(self):
- return str(self._toDict())
-
- def _getDefaultMin(self):
- return DEFAULT_MIN_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MIN_LOG
-
- def _getDefaultMax(self):
- return DEFAULT_MAX_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MAX_LOG
-
- def __eq__(self, other):
- """Compare colormap values and not pointers"""
- return (self.getName() == other.getName() and
- self.getNormalization() == other.getNormalization() and
- self.getVMin() == other.getVMin() and
- self.getVMax() == other.getVMax() and
- numpy.array_equal(self.getColormapLUT(), other.getColormapLUT())
- )
-
- _SERIAL_VERSION = 1
-
- def restoreState(self, byteArray):
- """
- Read the colormap state from a QByteArray.
-
- :param qt.QByteArray byteArray: Stream containing the state
- :return: True if the restoration sussseed
- :rtype: bool
- """
- if self.isEditable() is False:
- raise NotEditableError('Colormap is not editable')
- stream = qt.QDataStream(byteArray, qt.QIODevice.ReadOnly)
-
- className = stream.readQString()
- if className != self.__class__.__name__:
- _logger.warning("Classname mismatch. Found %s." % className)
- return False
-
- version = stream.readUInt32()
- if version != self._SERIAL_VERSION:
- _logger.warning("Serial version mismatch. Found %d." % version)
- return False
-
- name = stream.readQString()
- isNull = stream.readBool()
- if not isNull:
- vmin = stream.readQVariant()
- else:
- vmin = None
- isNull = stream.readBool()
- if not isNull:
- vmax = stream.readQVariant()
- else:
- vmax = None
- normalization = stream.readQString()
-
- # emit change event only once
- old = self.blockSignals(True)
- try:
- self.setName(name)
- self.setNormalization(normalization)
- self.setVRange(vmin, vmax)
- finally:
- self.blockSignals(old)
- self.sigChanged.emit()
- return True
-
- def saveState(self):
- """
- Save state of the colomap into a QDataStream.
-
- :rtype: qt.QByteArray
- """
- data = qt.QByteArray()
- stream = qt.QDataStream(data, qt.QIODevice.WriteOnly)
-
- stream.writeQString(self.__class__.__name__)
- stream.writeUInt32(self._SERIAL_VERSION)
- stream.writeQString(self.getName())
- stream.writeBool(self.getVMin() is None)
- if self.getVMin() is not None:
- stream.writeQVariant(self.getVMin())
- stream.writeBool(self.getVMax() is None)
- if self.getVMax() is not None:
- stream.writeQVariant(self.getVMax())
- stream.writeQString(self.getNormalization())
- return data
-
-
-_PREFERRED_COLORMAPS = DEFAULT_COLORMAPS
-"""
-Tuple of preferred colormap names accessed with :meth:`preferredColormaps`.
-"""
-
-
-def preferredColormaps():
- """Returns the name of the preferred colormaps.
-
- This list is used by widgets allowing to change the colormap
- like the :class:`ColormapDialog` as a subset of colormap choices.
-
- :rtype: tuple of str
- """
- return _PREFERRED_COLORMAPS
-
-
-def setPreferredColormaps(colormaps):
- """Set the list of preferred colormap names.
-
- Warning: If a colormap name is not available
- it will be removed from the list.
-
- :param colormaps: Not empty list of colormap names
- :type colormaps: iterable of str
- :raise ValueError: if the list of available preferred colormaps is empty.
- """
- supportedColormaps = Colormap.getSupportedColormaps()
- colormaps = tuple(
- cmap for cmap in colormaps if cmap in supportedColormaps)
- if len(colormaps) == 0:
- raise ValueError("Cannot set preferred colormaps to an empty list")
-
- global _PREFERRED_COLORMAPS
- _PREFERRED_COLORMAPS = colormaps
-
-
-# Initialize preferred colormaps
-setPreferredColormaps(('gray', 'reversed gray',
- 'temperature', 'red', 'green', 'blue', 'jet',
- 'viridis', 'magma', 'inferno', 'plasma',
- 'hsv'))
+from ..colors import * # noqa
diff --git a/silx/gui/plot/ColormapDialog.py b/silx/gui/plot/ColormapDialog.py
index 4aefab6..7c66cb8 100644
--- a/silx/gui/plot/ColormapDialog.py
+++ b/silx/gui/plot/ColormapDialog.py
@@ -22,960 +22,22 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""A QDialog widget to set-up the colormap.
+"""Deprecated module providing ColormapDialog."""
-It uses a description of colormaps as dict compatible with :class:`Plot`.
+from __future__ import absolute_import
-To run the following sample code, a QApplication must be initialized.
-
-Create the colormap dialog and set the colormap description and data range:
-
->>> from silx.gui.plot.ColormapDialog import ColormapDialog
->>> from silx.gui.plot.Colormap import Colormap
-
->>> dialog = ColormapDialog()
->>> colormap = Colormap(name='red', normalization='log',
-... vmin=1., vmax=2.)
-
->>> dialog.setColormap(colormap)
->>> colormap.setVRange(1., 100.) # This scale the width of the plot area
->>> dialog.show()
-
-Get the colormap description (compatible with :class:`Plot`) from the dialog:
-
->>> cmap = dialog.getColormap()
->>> cmap.getName()
-'red'
-
-It is also possible to display an histogram of the image in the dialog.
-This updates the data range with the range of the bins.
-
->>> import numpy
->>> image = numpy.random.normal(size=512 * 512).reshape(512, -1)
->>> hist, bin_edges = numpy.histogram(image, bins=10)
->>> dialog.setHistogram(hist, bin_edges)
-
-The updates of the colormap description are also available through the signal:
-:attr:`ColormapDialog.sigColormapChanged`.
-""" # noqa
-
-from __future__ import division
-
-__authors__ = ["V.A. Sole", "T. Vincent", "H. Payno"]
+__authors__ = ["T. Vincent", "H.Payno"]
__license__ = "MIT"
-__date__ = "09/02/2018"
-
-
-import logging
-
-import numpy
-
-from .. import qt
-from .Colormap import Colormap, preferredColormaps
-from . import PlotWidget
-from silx.gui.widgets.FloatEdit import FloatEdit
-import weakref
-from silx.math.combo import min_max
-from silx.third_party import enum
-from silx.gui import icons
-from silx.math.histogram import Histogramnd
-
-_logger = logging.getLogger(__name__)
-
-
-_colormapIconPreview = {}
-
-
-class _BoundaryWidget(qt.QWidget):
- """Widget to edit a boundary of the colormap (vmin, vmax)"""
- sigValueChanged = qt.Signal(object)
- """Signal emitted when value is changed"""
-
- def __init__(self, parent=None, value=0.0):
- qt.QWidget.__init__(self, parent=None)
- self.setLayout(qt.QHBoxLayout())
- self.layout().setContentsMargins(0, 0, 0, 0)
- self._numVal = FloatEdit(parent=self, value=value)
- self.layout().addWidget(self._numVal)
- self._autoCB = qt.QCheckBox('auto', parent=self)
- self.layout().addWidget(self._autoCB)
- self._autoCB.setChecked(False)
-
- self._autoCB.toggled.connect(self._autoToggled)
- self.sigValueChanged = self._autoCB.toggled
- self.textEdited = self._numVal.textEdited
- self.editingFinished = self._numVal.editingFinished
- self._dataValue = None
-
- def isAutoChecked(self):
- return self._autoCB.isChecked()
-
- def getValue(self):
- return None if self._autoCB.isChecked() else self._numVal.value()
-
- def getFiniteValue(self):
- if not self._autoCB.isChecked():
- return self._numVal.value()
- elif self._dataValue is None:
- return self._numVal.value()
- else:
- return self._dataValue
-
- def _autoToggled(self, enabled):
- self._numVal.setEnabled(not enabled)
- self._updateDisplayedText()
-
- def _updateDisplayedText(self):
- # if dataValue is finite
- if self._autoCB.isChecked() and self._dataValue is not None:
- old = self._numVal.blockSignals(True)
- self._numVal.setValue(self._dataValue)
- self._numVal.blockSignals(old)
-
- def setDataValue(self, dataValue):
- self._dataValue = dataValue
- self._updateDisplayedText()
-
- def setFiniteValue(self, value):
- assert(value is not None)
- old = self._numVal.blockSignals(True)
- self._numVal.setValue(value)
- self._numVal.blockSignals(old)
-
- def setValue(self, value, isAuto=False):
- self._autoCB.setChecked(isAuto or value is None)
- if value is not None:
- self._numVal.setValue(value)
- self._updateDisplayedText()
-
-
-class _ColormapNameCombox(qt.QComboBox):
- def __init__(self, parent=None):
- qt.QComboBox.__init__(self, parent)
- self.__initItems()
-
- ORIGINAL_NAME = qt.Qt.UserRole + 1
-
- def __initItems(self):
- for colormapName in preferredColormaps():
- index = self.count()
- self.addItem(str.title(colormapName))
- self.setItemIcon(index, self.getIconPreview(colormapName))
- self.setItemData(index, colormapName, role=self.ORIGINAL_NAME)
-
- def getIconPreview(self, colormapName):
- """Return an icon preview from a LUT name.
-
- This icons are cached into a global structure.
-
- :param str colormapName: str
- :rtype: qt.QIcon
- """
- if colormapName not in _colormapIconPreview:
- icon = self.createIconPreview(colormapName)
- _colormapIconPreview[colormapName] = icon
- return _colormapIconPreview[colormapName]
-
- def createIconPreview(self, colormapName):
- """Create and return an icon preview from a LUT name.
-
- This icons are cached into a global structure.
-
- :param str colormapName: Name of the LUT
- :rtype: qt.QIcon
- """
- colormap = Colormap(colormapName)
- size = 32
- lut = colormap.getNColors(size)
- if lut is None or len(lut) == 0:
- return qt.QIcon()
-
- pixmap = qt.QPixmap(size, size)
- painter = qt.QPainter(pixmap)
- for i in range(size):
- rgb = lut[i]
- r, g, b = rgb[0], rgb[1], rgb[2]
- painter.setPen(qt.QColor(r, g, b))
- painter.drawPoint(qt.QPoint(i, 0))
-
- painter.drawPixmap(0, 1, size, size - 1, pixmap, 0, 0, size, 1)
- painter.end()
-
- return qt.QIcon(pixmap)
-
- def getCurrentName(self):
- return self.itemData(self.currentIndex(), self.ORIGINAL_NAME)
-
- def findColormap(self, name):
- return self.findData(name, role=self.ORIGINAL_NAME)
-
- def setCurrentName(self, name):
- index = self.findColormap(name)
- if index < 0:
- index = self.count()
- self.addItem(str.title(name))
- self.setItemIcon(index, self.getIconPreview(name))
- self.setItemData(index, name, role=self.ORIGINAL_NAME)
- self.setCurrentIndex(index)
-
-
-@enum.unique
-class _DataInPlotMode(enum.Enum):
- """Enum for each mode of display of the data in the plot."""
- NONE = 'none'
- RANGE = 'range'
- HISTOGRAM = 'histogram'
-
-
-class ColormapDialog(qt.QDialog):
- """A QDialog widget to set the colormap.
-
- :param parent: See :class:`QDialog`
- :param str title: The QDialog title
- """
-
- visibleChanged = qt.Signal(bool)
- """This event is sent when the dialog visibility change"""
-
- def __init__(self, parent=None, title="Colormap Dialog"):
- qt.QDialog.__init__(self, parent)
- self.setWindowTitle(title)
-
- self._colormap = None
- self._data = None
- self._dataInPlotMode = _DataInPlotMode.RANGE
-
- self._ignoreColormapChange = False
- """Used as a semaphore to avoid editing the colormap object when we are
- only attempt to display it.
- Used instead of n connect and disconnect of the sigChanged. The
- disconnection to sigChanged was also limiting when this colormapdialog
- is used in the colormapaction and associated to the activeImageChanged.
- (because the activeImageChanged is send when the colormap changed and
- the self.setcolormap is a callback)
- """
-
- self._histogramData = None
- self._minMaxWasEdited = False
- self._initialRange = None
-
- self._dataRange = None
- """If defined 3-tuple containing information from a data:
- minimum, positive minimum, maximum"""
-
- self._colormapStoredState = None
-
- # Make the GUI
- vLayout = qt.QVBoxLayout(self)
-
- formWidget = qt.QWidget(parent=self)
- vLayout.addWidget(formWidget)
- formLayout = qt.QFormLayout(formWidget)
- formLayout.setContentsMargins(10, 10, 10, 10)
- formLayout.setSpacing(0)
-
- # Colormap row
- self._comboBoxColormap = _ColormapNameCombox(parent=formWidget)
- self._comboBoxColormap.currentIndexChanged[int].connect(self._updateName)
- formLayout.addRow('Colormap:', self._comboBoxColormap)
-
- # Normalization row
- self._normButtonLinear = qt.QRadioButton('Linear')
- self._normButtonLinear.setChecked(True)
- self._normButtonLog = qt.QRadioButton('Log')
- self._normButtonLog.toggled.connect(self._activeLogNorm)
-
- normButtonGroup = qt.QButtonGroup(self)
- normButtonGroup.setExclusive(True)
- normButtonGroup.addButton(self._normButtonLinear)
- normButtonGroup.addButton(self._normButtonLog)
- self._normButtonLinear.toggled[bool].connect(self._updateLinearNorm)
-
- normLayout = qt.QHBoxLayout()
- normLayout.setContentsMargins(0, 0, 0, 0)
- normLayout.setSpacing(10)
- normLayout.addWidget(self._normButtonLinear)
- normLayout.addWidget(self._normButtonLog)
-
- formLayout.addRow('Normalization:', normLayout)
-
- # Min row
- self._minValue = _BoundaryWidget(parent=self, value=1.0)
- self._minValue.textEdited.connect(self._minMaxTextEdited)
- self._minValue.editingFinished.connect(self._minEditingFinished)
- self._minValue.sigValueChanged.connect(self._updateMinMax)
- formLayout.addRow('\tMin:', self._minValue)
-
- # Max row
- self._maxValue = _BoundaryWidget(parent=self, value=10.0)
- self._maxValue.textEdited.connect(self._minMaxTextEdited)
- self._maxValue.sigValueChanged.connect(self._updateMinMax)
- self._maxValue.editingFinished.connect(self._maxEditingFinished)
- formLayout.addRow('\tMax:', self._maxValue)
-
- # Add plot for histogram
- self._plotToolbar = qt.QToolBar(self)
- self._plotToolbar.setFloatable(False)
- self._plotToolbar.setMovable(False)
- self._plotToolbar.setIconSize(qt.QSize(8, 8))
- self._plotToolbar.setStyleSheet("QToolBar { border: 0px }")
- self._plotToolbar.setOrientation(qt.Qt.Vertical)
-
- group = qt.QActionGroup(self._plotToolbar)
- group.setExclusive(True)
-
- action = qt.QAction("Nothing", self)
- action.setToolTip("No range nor histogram are displayed. No extra computation have to be done.")
- action.setIcon(icons.getQIcon('colormap-none'))
- action.setCheckable(True)
- action.setData(_DataInPlotMode.NONE)
- action.setChecked(action.data() == self._dataInPlotMode)
- self._plotToolbar.addAction(action)
- group.addAction(action)
- action = qt.QAction("Data range", self)
- action.setToolTip("Display the data range within the colormap range. A fast data processing have to be done.")
- action.setIcon(icons.getQIcon('colormap-range'))
- action.setCheckable(True)
- action.setData(_DataInPlotMode.RANGE)
- action.setChecked(action.data() == self._dataInPlotMode)
- self._plotToolbar.addAction(action)
- group.addAction(action)
- action = qt.QAction("Histogram", self)
- action.setToolTip("Display the data histogram within the colormap range. A slow data processing have to be done. ")
- action.setIcon(icons.getQIcon('colormap-histogram'))
- action.setCheckable(True)
- action.setData(_DataInPlotMode.HISTOGRAM)
- action.setChecked(action.data() == self._dataInPlotMode)
- self._plotToolbar.addAction(action)
- group.addAction(action)
- group.triggered.connect(self._displayDataInPlotModeChanged)
-
- self._plotBox = qt.QWidget(self)
- self._plotInit()
-
- plotBoxLayout = qt.QHBoxLayout()
- plotBoxLayout.setContentsMargins(0, 0, 0, 0)
- plotBoxLayout.setSpacing(2)
- plotBoxLayout.addWidget(self._plotToolbar)
- plotBoxLayout.addWidget(self._plot)
- plotBoxLayout.setSizeConstraint(qt.QLayout.SetMinimumSize)
- self._plotBox.setLayout(plotBoxLayout)
- vLayout.addWidget(self._plotBox)
-
- # define modal buttons
- types = qt.QDialogButtonBox.Ok | qt.QDialogButtonBox.Cancel
- self._buttonsModal = qt.QDialogButtonBox(parent=self)
- self._buttonsModal.setStandardButtons(types)
- self.layout().addWidget(self._buttonsModal)
- self._buttonsModal.accepted.connect(self.accept)
- self._buttonsModal.rejected.connect(self.reject)
-
- # define non modal buttons
- types = qt.QDialogButtonBox.Close | qt.QDialogButtonBox.Reset
- self._buttonsNonModal = qt.QDialogButtonBox(parent=self)
- self._buttonsNonModal.setStandardButtons(types)
- self.layout().addWidget(self._buttonsNonModal)
- self._buttonsNonModal.button(qt.QDialogButtonBox.Close).clicked.connect(self.accept)
- self._buttonsNonModal.button(qt.QDialogButtonBox.Reset).clicked.connect(self.resetColormap)
-
- # Set the colormap to default values
- self.setColormap(Colormap(name='gray', normalization='linear',
- vmin=None, vmax=None))
-
- self.setModal(self.isModal())
-
- vLayout.setSizeConstraint(qt.QLayout.SetMinimumSize)
- self.setFixedSize(self.sizeHint())
- self._applyColormap()
-
- def showEvent(self, event):
- self.visibleChanged.emit(True)
- super(ColormapDialog, self).showEvent(event)
-
- def closeEvent(self, event):
- if not self.isModal():
- self.accept()
- super(ColormapDialog, self).closeEvent(event)
-
- def hideEvent(self, event):
- self.visibleChanged.emit(False)
- super(ColormapDialog, self).hideEvent(event)
-
- def close(self):
- self.accept()
- qt.QDialog.close(self)
-
- def setModal(self, modal):
- assert type(modal) is bool
- self._buttonsNonModal.setVisible(not modal)
- self._buttonsModal.setVisible(modal)
- qt.QDialog.setModal(self, modal)
-
- def exec_(self):
- wasModal = self.isModal()
- self.setModal(True)
- result = super(ColormapDialog, self).exec_()
- self.setModal(wasModal)
- return result
-
- def _plotInit(self):
- """Init the plot to display the range and the values"""
- self._plot = PlotWidget()
- self._plot.setDataMargins(yMinMargin=0.125, yMaxMargin=0.125)
- self._plot.getXAxis().setLabel("Data Values")
- self._plot.getYAxis().setLabel("")
- self._plot.setInteractiveMode('select', zoomOnWheel=False)
- self._plot.setActiveCurveHandling(False)
- self._plot.setMinimumSize(qt.QSize(250, 200))
- self._plot.sigPlotSignal.connect(self._plotSlot)
-
- self._plotUpdate()
-
- def sizeHint(self):
- return self.layout().minimumSize()
-
- def _plotUpdate(self, updateMarkers=True):
- """Update the plot content
-
- :param bool updateMarkers: True to update markers, False otherwith
- """
- colormap = self.getColormap()
- if colormap is None:
- if self._plotBox.isVisibleTo(self):
- self._plotBox.setVisible(False)
- self.setFixedSize(self.sizeHint())
- return
-
- if not self._plotBox.isVisibleTo(self):
- self._plotBox.setVisible(True)
- self.setFixedSize(self.sizeHint())
-
- minData, maxData = self._minValue.getFiniteValue(), self._maxValue.getFiniteValue()
- if minData > maxData:
- # avoid a full collapse
- minData, maxData = maxData, minData
- minimum = minData
- maximum = maxData
-
- if self._dataRange is not None:
- minRange = self._dataRange[0]
- maxRange = self._dataRange[2]
- minimum = min(minimum, minRange)
- maximum = max(maximum, maxRange)
-
- if self._histogramData is not None:
- minHisto = self._histogramData[1][0]
- maxHisto = self._histogramData[1][-1]
- minimum = min(minimum, minHisto)
- maximum = max(maximum, maxHisto)
-
- marge = abs(maximum - minimum) / 6.0
- if marge < 0.0001:
- # Smaller that the QLineEdit precision
- marge = 0.0001
-
- minView, maxView = minimum - marge, maximum + marge
-
- if updateMarkers:
- # Save the state in we are not moving the markers
- self._initialRange = minView, maxView
- elif self._initialRange is not None:
- minView = min(minView, self._initialRange[0])
- maxView = max(maxView, self._initialRange[1])
-
- x = [minView, minData, maxData, maxView]
- y = [0, 0, 1, 1]
-
- self._plot.addCurve(x, y,
- legend="ConstrainedCurve",
- color='black',
- symbol='o',
- linestyle='-',
- resetzoom=False)
-
- if updateMarkers:
- minDraggable = (self._colormap().isEditable() and
- not self._minValue.isAutoChecked())
- self._plot.addXMarker(
- self._minValue.getFiniteValue(),
- legend='Min',
- text='Min',
- draggable=minDraggable,
- color='blue',
- constraint=self._plotMinMarkerConstraint)
-
- maxDraggable = (self._colormap().isEditable() and
- not self._maxValue.isAutoChecked())
- self._plot.addXMarker(
- self._maxValue.getFiniteValue(),
- legend='Max',
- text='Max',
- draggable=maxDraggable,
- color='blue',
- constraint=self._plotMaxMarkerConstraint)
-
- self._plot.resetZoom()
-
- def _plotMinMarkerConstraint(self, x, y):
- """Constraint of the min marker"""
- return min(x, self._maxValue.getFiniteValue()), y
-
- def _plotMaxMarkerConstraint(self, x, y):
- """Constraint of the max marker"""
- return max(x, self._minValue.getFiniteValue()), y
-
- def _plotSlot(self, event):
- """Handle events from the plot"""
- if event['event'] in ('markerMoving', 'markerMoved'):
- value = float(str(event['xdata']))
- if event['label'] == 'Min':
- self._minValue.setValue(value)
- elif event['label'] == 'Max':
- self._maxValue.setValue(value)
-
- # This will recreate the markers while interacting...
- # It might break if marker interaction is changed
- if event['event'] == 'markerMoved':
- self._initialRange = None
- self._updateMinMax()
- else:
- self._plotUpdate(updateMarkers=False)
-
- @staticmethod
- def computeDataRange(data):
- """Compute the data range as used by :meth:`setDataRange`.
-
- :param data: The data to process
- :rtype: Tuple(float, float, float)
- """
- if data is None or len(data) == 0:
- return None, None, None
-
- dataRange = min_max(data, min_positive=True, finite=True)
- if dataRange.minimum is None:
- # Only non-finite data
- dataRange = None
-
- if dataRange is not None:
- min_positive = dataRange.min_positive
- if min_positive is None:
- min_positive = float('nan')
- dataRange = dataRange.minimum, min_positive, dataRange.maximum
-
- if dataRange is None or len(dataRange) != 3:
- qt.QMessageBox.warning(
- None, "No Data",
- "Image data does not contain any real value")
- dataRange = 1., 1., 10.
-
- return dataRange
-
- @staticmethod
- def computeHistogram(data):
- """Compute the data histogram as used by :meth:`setHistogram`.
-
- :param data: The data to process
- :rtype: Tuple(List(float),List(float)
- """
- _data = data
- if _data.ndim == 3: # RGB(A) images
- _logger.info('Converting current image from RGB(A) to grayscale\
- in order to compute the intensity distribution')
- _data = (_data[:, :, 0] * 0.299 +
- _data[:, :, 1] * 0.587 +
- _data[:, :, 2] * 0.114)
-
- if len(_data) == 0:
- return None, None
-
- xmin, xmax = min_max(_data, min_positive=False, finite=True)
- nbins = min(256, int(numpy.sqrt(_data.size)))
- data_range = xmin, xmax
-
- # bad hack: get 256 bins in the case we have a B&W
- if numpy.issubdtype(_data.dtype, numpy.integer):
- if nbins > xmax - xmin:
- nbins = xmax - xmin
-
- nbins = max(2, nbins)
- _data = _data.ravel().astype(numpy.float32)
-
- histogram = Histogramnd(_data, n_bins=nbins, histo_range=data_range)
- return histogram.histo, histogram.edges[0]
-
- def _getData(self):
- if self._data is None:
- return None
- return self._data()
-
- def setData(self, data):
- """Store the data as a weakref.
-
- According to the state of the dialog, the data will be used to display
- the data range or the histogram of the data using :meth:`setDataRange`
- and :meth:`setHistogram`
- """
- oldData = self._getData()
- if oldData is data:
- return
-
- if data is None:
- self.setDataRange()
- self.setHistogram()
- self._data = None
- return
-
- self._data = weakref.ref(data, self._dataAboutToFinalize)
-
- self._updateDataInPlot()
-
- def _setDataInPlotMode(self, mode):
- if self._dataInPlotMode == mode:
- return
- self._dataInPlotMode = mode
- self._updateDataInPlot()
-
- def _displayDataInPlotModeChanged(self, action):
- mode = action.data()
- self._setDataInPlotMode(mode)
-
- def _updateDataInPlot(self):
- data = self._getData()
- if data is None:
- return
-
- mode = self._dataInPlotMode
-
- if mode == _DataInPlotMode.NONE:
- self.setHistogram()
- self.setDataRange()
- elif mode == _DataInPlotMode.RANGE:
- result = self.computeDataRange(data)
- self.setHistogram()
- self.setDataRange(*result)
- elif mode == _DataInPlotMode.HISTOGRAM:
- # The histogram should be done in a worker thread
- result = self.computeHistogram(data)
- self.setHistogram(*result)
- self.setDataRange()
-
- def _colormapAboutToFinalize(self, weakrefColormap):
- """Callback when the data weakref is about to be finalized."""
- if self._colormap is weakrefColormap:
- self.setColormap(None)
-
- def _dataAboutToFinalize(self, weakrefData):
- """Callback when the data weakref is about to be finalized."""
- if self._data is weakrefData:
- self.setData(None)
-
- def getHistogram(self):
- """Returns the counts and bin edges of the displayed histogram.
-
- :return: (hist, bin_edges)
- :rtype: 2-tuple of numpy arrays"""
- if self._histogramData is None:
- return None
- else:
- bins, counts = self._histogramData
- return numpy.array(bins, copy=True), numpy.array(counts, copy=True)
-
- def setHistogram(self, hist=None, bin_edges=None):
- """Set the histogram to display.
-
- This update the data range with the bounds of the bins.
-
- :param hist: array-like of counts or None to hide histogram
- :param bin_edges: array-like of bins edges or None to hide histogram
- """
- if hist is None or bin_edges is None:
- self._histogramData = None
- self._plot.remove(legend='Histogram', kind='histogram')
- else:
- hist = numpy.array(hist, copy=True)
- bin_edges = numpy.array(bin_edges, copy=True)
- self._histogramData = hist, bin_edges
- norm_hist = hist / max(hist)
- self._plot.addHistogram(norm_hist,
- bin_edges,
- legend="Histogram",
- color='gray',
- align='center',
- fill=True)
- self._updateMinMaxData()
-
- def getColormap(self):
- """Return the colormap description as a :class:`.Colormap`.
-
- """
- if self._colormap is None:
- return None
- return self._colormap()
-
- def resetColormap(self):
- """
- Reset the colormap state before modification.
-
- ..note :: the colormap reference state is the state when set or the
- state when validated
- """
- colormap = self.getColormap()
- if colormap is not None and self._colormapStoredState is not None:
- if self._colormap()._toDict() != self._colormapStoredState:
- self._ignoreColormapChange = True
- colormap._setFromDict(self._colormapStoredState)
- self._ignoreColormapChange = False
- self._applyColormap()
-
- def setDataRange(self, minimum=None, positiveMin=None, maximum=None):
- """Set the range of data to use for the range of the histogram area.
-
- :param float minimum: The minimum of the data
- :param float positiveMin: The positive minimum of the data
- :param float maximum: The maximum of the data
- """
- if minimum is None or positiveMin is None or maximum is None:
- self._dataRange = None
- self._plot.remove(legend='Range', kind='histogram')
- else:
- hist = numpy.array([1])
- bin_edges = numpy.array([minimum, maximum])
- self._plot.addHistogram(hist,
- bin_edges,
- legend="Range",
- color='gray',
- align='center',
- fill=True)
- self._dataRange = minimum, positiveMin, maximum
- self._updateMinMaxData()
-
- def _updateMinMaxData(self):
- """Update the min and max of the data according to the data range and
- the histogram preset."""
- colormap = self.getColormap()
-
- minimum = float("+inf")
- maximum = float("-inf")
-
- if colormap is not None and colormap.getNormalization() == colormap.LOGARITHM:
- # find a range in the positive part of the data
- if self._dataRange is not None:
- minimum = min(minimum, self._dataRange[1])
- maximum = max(maximum, self._dataRange[2])
- if self._histogramData is not None:
- positives = list(filter(lambda x: x > 0, self._histogramData[1]))
- if len(positives) > 0:
- minimum = min(minimum, positives[0])
- maximum = max(maximum, positives[-1])
- else:
- if self._dataRange is not None:
- minimum = min(minimum, self._dataRange[0])
- maximum = max(maximum, self._dataRange[2])
- if self._histogramData is not None:
- minimum = min(minimum, self._histogramData[1][0])
- maximum = max(maximum, self._histogramData[1][-1])
-
- if not numpy.isfinite(minimum):
- minimum = None
- if not numpy.isfinite(maximum):
- maximum = None
-
- self._minValue.setDataValue(minimum)
- self._maxValue.setDataValue(maximum)
- self._plotUpdate()
-
- def accept(self):
- self.storeCurrentState()
- qt.QDialog.accept(self)
-
- def storeCurrentState(self):
- """
- save the current value sof the colormap if the user want to undo is
- modifications
- """
- colormap = self.getColormap()
- if colormap is not None:
- self._colormapStoredState = colormap._toDict()
- else:
- self._colormapStoredState = None
-
- def reject(self):
- self.resetColormap()
- qt.QDialog.reject(self)
-
- def setColormap(self, colormap):
- """Set the colormap description
-
- :param :class:`Colormap` colormap: the colormap to edit
- """
- assert colormap is None or isinstance(colormap, Colormap)
- if self._ignoreColormapChange is True:
- return
-
- oldColormap = self.getColormap()
- if oldColormap is colormap:
- return
- if oldColormap is not None:
- oldColormap.sigChanged.disconnect(self._applyColormap)
-
- if colormap is not None:
- colormap.sigChanged.connect(self._applyColormap)
- colormap = weakref.ref(colormap, self._colormapAboutToFinalize)
-
- self._colormap = colormap
- self.storeCurrentState()
- self._updateResetButton()
- self._applyColormap()
-
- def _updateResetButton(self):
- resetButton = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset)
- rStateEnabled = False
- colormap = self.getColormap()
- if colormap is not None and colormap.isEditable():
- # can reset only in the case the colormap changed
- rStateEnabled = colormap._toDict() != self._colormapStoredState
- resetButton.setEnabled(rStateEnabled)
-
- def _applyColormap(self):
- self._updateResetButton()
- if self._ignoreColormapChange is True:
- return
-
- colormap = self.getColormap()
- if colormap is None:
- self._comboBoxColormap.setEnabled(False)
- self._normButtonLinear.setEnabled(False)
- self._normButtonLog.setEnabled(False)
- self._minValue.setEnabled(False)
- self._maxValue.setEnabled(False)
- else:
- self._ignoreColormapChange = True
-
- if colormap.getName() is not None:
- name = colormap.getName()
- self._comboBoxColormap.setCurrentName(name)
- self._comboBoxColormap.setEnabled(self._colormap().isEditable())
-
- assert colormap.getNormalization() in Colormap.NORMALIZATIONS
- self._normButtonLinear.setChecked(
- colormap.getNormalization() == Colormap.LINEAR)
- self._normButtonLog.setChecked(
- colormap.getNormalization() == Colormap.LOGARITHM)
- vmin = colormap.getVMin()
- vmax = colormap.getVMax()
- dataRange = colormap.getColormapRange()
- self._normButtonLinear.setEnabled(self._colormap().isEditable())
- self._normButtonLog.setEnabled(self._colormap().isEditable())
- self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None)
- self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None)
- self._minValue.setEnabled(self._colormap().isEditable())
- self._maxValue.setEnabled(self._colormap().isEditable())
- self._ignoreColormapChange = False
-
- self._plotUpdate()
-
- def _updateMinMax(self):
- if self._ignoreColormapChange is True:
- return
-
- vmin = self._minValue.getFiniteValue()
- vmax = self._maxValue.getFiniteValue()
- if vmax is not None and vmin is not None and vmax < vmin:
- # If only one autoscale is checked constraints are too strong
- # We have to edit a user value anyway it is not requested
- # TODO: It would be better IMO to disable the auto checkbox before
- # this case occur (valls)
- cmin = self._minValue.isAutoChecked()
- cmax = self._maxValue.isAutoChecked()
- if cmin is False:
- self._minValue.setFiniteValue(vmax)
- if cmax is False:
- self._maxValue.setFiniteValue(vmin)
-
- vmin = self._minValue.getValue()
- vmax = self._maxValue.getValue()
- self._ignoreColormapChange = True
- colormap = self._colormap()
- if colormap is not None:
- colormap.setVRange(vmin, vmax)
- self._ignoreColormapChange = False
- self._plotUpdate()
- self._updateResetButton()
-
- def _updateName(self):
- if self._ignoreColormapChange is True:
- return
-
- if self._colormap():
- self._ignoreColormapChange = True
- self._colormap().setName(
- self._comboBoxColormap.getCurrentName())
- self._ignoreColormapChange = False
-
- def _updateLinearNorm(self, isNormLinear):
- if self._ignoreColormapChange is True:
- return
-
- if self._colormap():
- self._ignoreColormapChange = True
- norm = Colormap.LINEAR if isNormLinear else Colormap.LOGARITHM
- self._colormap().setNormalization(norm)
- self._ignoreColormapChange = False
-
- def _minMaxTextEdited(self, text):
- """Handle _minValue and _maxValue textEdited signal"""
- self._minMaxWasEdited = True
-
- def _minEditingFinished(self):
- """Handle _minValue editingFinished signal
-
- Together with :meth:`_minMaxTextEdited`, this avoids to notify
- colormap change when the min and max value where not edited.
- """
- if self._minMaxWasEdited:
- self._minMaxWasEdited = False
-
- # Fix start value
- if (self._maxValue.getValue() is not None and
- self._minValue.getValue() > self._maxValue.getValue()):
- self._minValue.setValue(self._maxValue.getValue())
- self._updateMinMax()
-
- def _maxEditingFinished(self):
- """Handle _maxValue editingFinished signal
-
- Together with :meth:`_minMaxTextEdited`, this avoids to notify
- colormap change when the min and max value where not edited.
- """
- if self._minMaxWasEdited:
- self._minMaxWasEdited = False
-
- # Fix end value
- if (self._minValue.getValue() is not None and
- self._minValue.getValue() > self._maxValue.getValue()):
- self._maxValue.setValue(self._minValue.getValue())
- self._updateMinMax()
+__date__ = "24/04/2018"
- def keyPressEvent(self, event):
- """Override key handling.
+import silx.utils.deprecation
- It disables leaving the dialog when editing a text field.
- """
- if event.key() == qt.Qt.Key_Enter and (self._minValue.hasFocus() or
- self._maxValue.hasFocus()):
- # Bypass QDialog keyPressEvent
- # To avoid leaving the dialog when pressing enter on a text field
- super(qt.QDialog, self).keyPressEvent(event)
- else:
- # Use QDialog keyPressEvent
- super(ColormapDialog, self).keyPressEvent(event)
+silx.utils.deprecation.deprecated_warning("Module",
+ name="silx.gui.plot.ColormapDialog",
+ reason="moved",
+ replacement="silx.gui.dialog.ColormapDialog",
+ since_version="0.8.0",
+ only_once=True,
+ skip_backtrace_count=1)
- def _activeLogNorm(self, isLog):
- if self._ignoreColormapChange is True:
- return
- if self._colormap():
- self._ignoreColormapChange = True
- norm = Colormap.LOGARITHM if isLog is True else Colormap.LINEAR
- self._colormap().setNormalization(norm)
- self._ignoreColormapChange = False
- self._updateMinMaxData()
+from ..dialog.ColormapDialog import * # noqa
diff --git a/silx/gui/plot/Colors.py b/silx/gui/plot/Colors.py
index 2d44d4d..277e104 100644
--- a/silx/gui/plot/Colors.py
+++ b/silx/gui/plot/Colors.py
@@ -28,120 +28,22 @@ from __future__ import absolute_import
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "15/05/2017"
+__date__ = "14/06/2018"
+import silx.utils.deprecation
-from silx.utils.deprecation import deprecated
-import logging
-import numpy
+silx.utils.deprecation.deprecated_warning("Module",
+ name="silx.gui.plot.Colors",
+ reason="moved",
+ replacement="silx.gui.colors",
+ since_version="0.8.0",
+ only_once=True,
+ skip_backtrace_count=1)
-from .Colormap import Colormap
+from ..colors import * # noqa
-_logger = logging.getLogger(__name__)
-
-
-COLORDICT = {}
-"""Dictionary of common colors."""
-
-COLORDICT['b'] = COLORDICT['blue'] = '#0000ff'
-COLORDICT['r'] = COLORDICT['red'] = '#ff0000'
-COLORDICT['g'] = COLORDICT['green'] = '#00ff00'
-COLORDICT['k'] = COLORDICT['black'] = '#000000'
-COLORDICT['w'] = COLORDICT['white'] = '#ffffff'
-COLORDICT['pink'] = '#ff66ff'
-COLORDICT['brown'] = '#a52a2a'
-COLORDICT['orange'] = '#ff9900'
-COLORDICT['violet'] = '#6600ff'
-COLORDICT['gray'] = COLORDICT['grey'] = '#a0a0a4'
-# COLORDICT['darkGray'] = COLORDICT['darkGrey'] = '#808080'
-# COLORDICT['lightGray'] = COLORDICT['lightGrey'] = '#c0c0c0'
-COLORDICT['y'] = COLORDICT['yellow'] = '#ffff00'
-COLORDICT['m'] = COLORDICT['magenta'] = '#ff00ff'
-COLORDICT['c'] = COLORDICT['cyan'] = '#00ffff'
-COLORDICT['darkBlue'] = '#000080'
-COLORDICT['darkRed'] = '#800000'
-COLORDICT['darkGreen'] = '#008000'
-COLORDICT['darkBrown'] = '#660000'
-COLORDICT['darkCyan'] = '#008080'
-COLORDICT['darkYellow'] = '#808000'
-COLORDICT['darkMagenta'] = '#800080'
-
-
-def rgba(color, colorDict=None):
- """Convert color code '#RRGGBB' and '#RRGGBBAA' to (R, G, B, A)
-
- It also convert RGB(A) values from uint8 to float in [0, 1] and
- accept a QColor as color argument.
-
- :param str color: The color to convert
- :param dict colorDict: A dictionary of color name conversion to color code
- :returns: RGBA colors as floats in [0., 1.]
- :rtype: tuple
- """
- if colorDict is None:
- colorDict = COLORDICT
-
- if hasattr(color, 'getRgbF'): # QColor support
- color = color.getRgbF()
-
- values = numpy.asarray(color).ravel()
-
- if values.dtype.kind in 'iuf': # integer or float
- # Color is an array
- assert len(values) in (3, 4)
-
- # Convert from integers in [0, 255] to float in [0, 1]
- if values.dtype.kind in 'iu':
- values = values / 255.
-
- # Clip to [0, 1]
- values[values < 0.] = 0.
- values[values > 1.] = 1.
-
- if len(values) == 3:
- return values[0], values[1], values[2], 1.
- else:
- return tuple(values)
-
- # We assume color is a string
- if not color.startswith('#'):
- color = colorDict[color]
-
- assert len(color) in (7, 9) and color[0] == '#'
- r = int(color[1:3], 16) / 255.
- g = int(color[3:5], 16) / 255.
- b = int(color[5:7], 16) / 255.
- a = int(color[7:9], 16) / 255. if len(color) == 9 else 1.
- return r, g, b, a
-
-
-_COLORMAP_CURSOR_COLORS = {
- 'gray': 'pink',
- 'reversed gray': 'pink',
- 'temperature': 'pink',
- 'red': 'green',
- 'green': 'pink',
- 'blue': 'yellow',
- 'jet': 'pink',
- 'viridis': 'pink',
- 'magma': 'green',
- 'inferno': 'green',
- 'plasma': 'green',
-}
-
-
-def cursorColorForColormap(colormapName):
- """Get a color suitable for overlay over a colormap.
-
- :param str colormapName: The name of the colormap.
- :return: Name of the color.
- :rtype: str
- """
- return _COLORMAP_CURSOR_COLORS.get(colormapName, 'black')
-
-
-@deprecated(replacement='silx.gui.plot.Colormap.applyColormap')
+@silx.utils.deprecation.deprecated(replacement='silx.gui.colors.Colormap.applyColormap')
def applyColormapToData(data,
name='gray',
normalization='linear',
@@ -178,7 +80,7 @@ def applyColormapToData(data,
return colormap.applyToData(data)
-@deprecated(replacement='silx.gui.plot.Colormap.getSupportedColormaps')
+@silx.utils.deprecation.deprecated(replacement='silx.gui.colors.Colormap.getSupportedColormaps')
def getSupportedColormaps():
"""Get the supported colormap names as a tuple of str.
diff --git a/silx/gui/plot/ComplexImageView.py b/silx/gui/plot/ComplexImageView.py
index ebff175..bbcb0a5 100644
--- a/silx/gui/plot/ComplexImageView.py
+++ b/silx/gui/plot/ComplexImageView.py
@@ -32,7 +32,7 @@ from __future__ import absolute_import
__authors__ = ["Vincent Favre-Nicolin", "T. Vincent"]
__license__ = "MIT"
-__date__ = "19/01/2018"
+__date__ = "24/04/2018"
import logging
@@ -410,7 +410,7 @@ class ComplexImageView(qt.QWidget):
WARNING: This colormap is not used when displaying both
amplitude and phase.
- :param ~silx.gui.plot.Colormap.Colormap colormap: The colormap
+ :param ~silx.gui.colors.Colormap colormap: The colormap
:param Mode mode: If specified, set the colormap of this specific mode
"""
self._plotImage.setColormap(colormap, mode)
@@ -419,7 +419,7 @@ class ComplexImageView(qt.QWidget):
"""Returns the colormap used to display the data.
:param Mode mode: If specified, set the colormap of this specific mode
- :rtype: ~silx.gui.plot.Colormap.Colormap
+ :rtype: ~silx.gui.colors.Colormap
"""
return self._plotImage.getColormap(mode=mode)
diff --git a/silx/gui/plot/CurvesROIWidget.py b/silx/gui/plot/CurvesROIWidget.py
index ccb6866..81e684e 100644
--- a/silx/gui/plot/CurvesROIWidget.py
+++ b/silx/gui/plot/CurvesROIWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -33,13 +33,11 @@ ROI are defined by :
This can be used to apply or not some ROI to a curve and do some post processing.
- The x coordinate of the left limit (`from` column)
- The x coordinate of the right limit (`to` column)
-- Raw counts: integral of the curve between the
- min ROI point and the max ROI point to the y = 0 line
+- Raw counts: Sum of the curve's values in the defined Region Of Intereset.
.. image:: img/rawCounts.png
-- Net counts: the integral of the curve between the
- min ROI point and the max ROI point to [ROI min point, ROI max point] segment
+- Net counts: Raw counts minus background
.. image:: img/netCounts.png
"""
@@ -53,6 +51,7 @@ from collections import OrderedDict
import logging
import os
import sys
+import weakref
import numpy
@@ -93,7 +92,8 @@ class CurvesROIWidget(qt.QWidget):
if name is not None:
self.setWindowTitle(name)
assert plot is not None
- self.plot = plot
+ self._plotRef = weakref.ref(plot)
+
layout = qt.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
@@ -162,6 +162,13 @@ class CurvesROIWidget(qt.QWidget):
self._isConnected = False # True if connected to plot signals
self._isInit = False
+ def getPlotWidget(self):
+ """Returns the associated PlotWidget or None
+
+ :rtype: Union[~silx.gui.plot.PlotWidget,None]
+ """
+ return None if self._plotRef is None else self._plotRef()
+
def showEvent(self, event):
self._visibilityChangedHandler(visible=True)
qt.QWidget.showEvent(self, event)
@@ -400,14 +407,18 @@ class CurvesROIWidget(qt.QWidget):
def _roiSignal(self, ddict):
"""Handle ROI widget signal"""
_logger.debug("CurvesROIWidget._roiSignal %s", str(ddict))
+ plot = self.getPlotWidget()
+ if plot is None:
+ return
+
if ddict['event'] == "AddROI":
- xmin, xmax = self.plot.getXAxis().getLimits()
+ xmin, xmax = plot.getXAxis().getLimits()
fromdata = xmin + 0.25 * (xmax - xmin)
todata = xmin + 0.75 * (xmax - xmin)
- self.plot.remove('ROI min', kind='marker')
- self.plot.remove('ROI max', kind='marker')
+ plot.remove('ROI min', kind='marker')
+ plot.remove('ROI max', kind='marker')
if self._middleROIMarkerFlag:
- self.plot.remove('ROI middle', kind='marker')
+ plot.remove('ROI middle', kind='marker')
roiList, roiDict = self.roiTable.getROIListAndDict()
nrois = len(roiList)
if nrois == 0:
@@ -416,6 +427,7 @@ class CurvesROIWidget(qt.QWidget):
draggable = False
color = 'black'
else:
+ # find the next index free for newroi.
for i in range(nrois):
i += 1
newroi = "newroi %d" % i
@@ -423,29 +435,29 @@ class CurvesROIWidget(qt.QWidget):
break
color = 'blue'
draggable = True
- self.plot.addXMarker(fromdata,
- legend='ROI min',
- text='ROI min',
- color=color,
- draggable=draggable)
- self.plot.addXMarker(todata,
- legend='ROI max',
- text='ROI max',
- color=color,
- draggable=draggable)
+ plot.addXMarker(fromdata,
+ legend='ROI min',
+ text='ROI min',
+ color=color,
+ draggable=draggable)
+ plot.addXMarker(todata,
+ legend='ROI max',
+ text='ROI max',
+ color=color,
+ draggable=draggable)
if draggable and self._middleROIMarkerFlag:
pos = 0.5 * (fromdata + todata)
- self.plot.addXMarker(pos,
- legend='ROI middle',
- text="",
- color='yellow',
- draggable=draggable)
+ plot.addXMarker(pos,
+ legend='ROI middle',
+ text="",
+ color='yellow',
+ draggable=draggable)
roiList.append(newroi)
roiDict[newroi] = {}
if newroi == "ICR":
roiDict[newroi]['type'] = "Default"
else:
- roiDict[newroi]['type'] = self.plot.getXAxis().getLabel()
+ roiDict[newroi]['type'] = plot.getXAxis().getLabel()
roiDict[newroi]['from'] = fromdata
roiDict[newroi]['to'] = todata
self.roiTable.fillFromROIDict(roilist=roiList,
@@ -454,10 +466,10 @@ class CurvesROIWidget(qt.QWidget):
self.currentROI = newroi
self.calculateRois()
elif ddict['event'] in ['DelROI', "ResetROI"]:
- self.plot.remove('ROI min', kind='marker')
- self.plot.remove('ROI max', kind='marker')
+ plot.remove('ROI min', kind='marker')
+ plot.remove('ROI max', kind='marker')
if self._middleROIMarkerFlag:
- self.plot.remove('ROI middle', kind='marker')
+ plot.remove('ROI middle', kind='marker')
roiList, roiDict = self.roiTable.getROIListAndDict()
roiDictKeys = list(roiDict.keys())
if len(roiDictKeys):
@@ -480,37 +492,37 @@ class CurvesROIWidget(qt.QWidget):
self.roilist, self.roidict = self.roiTable.getROIListAndDict()
fromdata = ddict['roi']['from']
todata = ddict['roi']['to']
- self.plot.remove('ROI min', kind='marker')
- self.plot.remove('ROI max', kind='marker')
+ plot.remove('ROI min', kind='marker')
+ plot.remove('ROI max', kind='marker')
if ddict['key'] == 'ICR':
draggable = False
color = 'black'
else:
draggable = True
color = 'blue'
- self.plot.addXMarker(fromdata,
- legend='ROI min',
- text='ROI min',
- color=color,
- draggable=draggable)
- self.plot.addXMarker(todata,
- legend='ROI max',
- text='ROI max',
- color=color,
- draggable=draggable)
+ plot.addXMarker(fromdata,
+ legend='ROI min',
+ text='ROI min',
+ color=color,
+ draggable=draggable)
+ plot.addXMarker(todata,
+ legend='ROI max',
+ text='ROI max',
+ color=color,
+ draggable=draggable)
if draggable and self._middleROIMarkerFlag:
pos = 0.5 * (fromdata + todata)
- self.plot.addXMarker(pos,
- legend='ROI middle',
- text="",
- color='yellow',
- draggable=True)
+ plot.addXMarker(pos,
+ legend='ROI middle',
+ text="",
+ color='yellow',
+ draggable=True)
self.currentROI = ddict['key']
if ddict['colheader'] in ['From', 'To']:
dict0 = {}
dict0['event'] = "SetActiveCurveEvent"
- dict0['legend'] = self.plot.getActiveCurve(just_legend=1)
- self.plot.setActiveCurve(dict0['legend'])
+ dict0['legend'] = plot.getActiveCurve(just_legend=1)
+ plot.setActiveCurve(dict0['legend'])
elif ddict['colheader'] == 'Raw Counts':
pass
elif ddict['colheader'] == 'Net Counts':
@@ -523,7 +535,8 @@ class CurvesROIWidget(qt.QWidget):
def _getAllLimits(self):
"""Retrieve the limits based on the curves."""
- curves = self.plot.getAllCurves()
+ plot = self.getPlotWidget()
+ curves = () if plot is None else plot.getAllCurves()
if not curves:
return 1.0, 1.0, 100., 100.
@@ -562,7 +575,12 @@ class CurvesROIWidget(qt.QWidget):
if roiList is None or roiDict is None:
roiList, roiDict = self.roiTable.getROIListAndDict()
- activeCurve = self.plot.getActiveCurve(just_legend=False)
+ plot = self.getPlotWidget()
+ if plot is None:
+ activeCurve = None
+ else:
+ activeCurve = plot.getActiveCurve(just_legend=False)
+
if activeCurve is None:
xproc = None
yproc = None
@@ -640,6 +658,11 @@ class CurvesROIWidget(qt.QWidget):
return
if self.currentROI not in roiDict:
return
+
+ plot = self.getPlotWidget()
+ if plot is None:
+ return
+
x = ddict['x']
if label == 'ROI min':
@@ -647,36 +670,36 @@ class CurvesROIWidget(qt.QWidget):
if self._middleROIMarkerFlag:
pos = 0.5 * (roiDict[self.currentROI]['to'] +
roiDict[self.currentROI]['from'])
- self.plot.addXMarker(pos,
- legend='ROI middle',
- text='',
- color='yellow',
- draggable=True)
+ plot.addXMarker(pos,
+ legend='ROI middle',
+ text='',
+ color='yellow',
+ draggable=True)
elif label == 'ROI max':
roiDict[self.currentROI]['to'] = x
if self._middleROIMarkerFlag:
pos = 0.5 * (roiDict[self.currentROI]['to'] +
roiDict[self.currentROI]['from'])
- self.plot.addXMarker(pos,
- legend='ROI middle',
- text='',
- color='yellow',
- draggable=True)
+ plot.addXMarker(pos,
+ legend='ROI middle',
+ text='',
+ color='yellow',
+ draggable=True)
elif label == 'ROI middle':
delta = x - 0.5 * (roiDict[self.currentROI]['from'] +
roiDict[self.currentROI]['to'])
roiDict[self.currentROI]['from'] += delta
roiDict[self.currentROI]['to'] += delta
- self.plot.addXMarker(roiDict[self.currentROI]['from'],
- legend='ROI min',
- text='ROI min',
- color='blue',
- draggable=True)
- self.plot.addXMarker(roiDict[self.currentROI]['to'],
- legend='ROI max',
- text='ROI max',
- color='blue',
- draggable=True)
+ plot.addXMarker(roiDict[self.currentROI]['from'],
+ legend='ROI min',
+ text='ROI min',
+ color='blue',
+ draggable=True)
+ plot.addXMarker(roiDict[self.currentROI]['to'],
+ legend='ROI max',
+ text='ROI max',
+ color='blue',
+ draggable=True)
else:
return
self.calculateRois(roiList, roiDict)
@@ -687,32 +710,39 @@ class CurvesROIWidget(qt.QWidget):
It is connected to plot signals only when visible.
"""
+ plot = self.getPlotWidget()
+
if visible:
if not self._isInit:
# Deferred ROI widget init finalization
- self._isInit = True
- self.sigROIWidgetSignal.connect(self._roiSignal)
- # initialize with the ICR
- self._roiSignal({'event': "AddROI"})
-
- if not self._isConnected:
- self.plot.sigPlotSignal.connect(self._handleROIMarkerEvent)
- self.plot.sigActiveCurveChanged.connect(
+ self._finalizeInit()
+
+ if not self._isConnected and plot is not None:
+ plot.sigPlotSignal.connect(self._handleROIMarkerEvent)
+ plot.sigActiveCurveChanged.connect(
self._activeCurveChanged)
self._isConnected = True
self.calculateRois()
else:
if self._isConnected:
- self.plot.sigPlotSignal.disconnect(self._handleROIMarkerEvent)
- self.plot.sigActiveCurveChanged.disconnect(
- self._activeCurveChanged)
+ if plot is not None:
+ plot.sigPlotSignal.disconnect(self._handleROIMarkerEvent)
+ plot.sigActiveCurveChanged.disconnect(
+ self._activeCurveChanged)
self._isConnected = False
def _activeCurveChanged(self, *args):
"""Recompute ROIs when active curve changed."""
self.calculateRois()
+ def _finalizeInit(self):
+ self._isInit = True
+ self.sigROIWidgetSignal.connect(self._roiSignal)
+ # initialize with the ICR if no ROi existing yet
+ if len(self.getRois()) is 0:
+ self._roiSignal({'event': "AddROI"})
+
class ROITable(qt.QTableWidget):
"""Table widget displaying ROI information.
@@ -977,9 +1007,6 @@ class CurvesROIDockWidget(qt.QDockWidget):
def __init__(self, parent=None, plot=None, name=None):
super(CurvesROIDockWidget, self).__init__(name, parent)
- assert plot is not None
- self.plot = plot
-
self.roiWidget = CurvesROIWidget(self, name, plot=plot)
"""Main widget of type :class:`CurvesROIWidget`"""
diff --git a/silx/gui/plot/ImageView.py b/silx/gui/plot/ImageView.py
index 46e56e6..c28ffca 100644
--- a/silx/gui/plot/ImageView.py
+++ b/silx/gui/plot/ImageView.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -42,18 +42,19 @@ from __future__ import division
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "17/08/2017"
+__date__ = "26/04/2018"
import logging
import numpy
+import silx
from .. import qt
from . import items, PlotWindow, PlotWidget, actions
-from .Colormap import Colormap
-from .Colors import cursorColorForColormap
-from .PlotTools import LimitsToolBar
+from ..colors import Colormap
+from ..colors import cursorColorForColormap
+from .tools import LimitsToolBar
from .Profile import ProfileToolBar
@@ -296,6 +297,9 @@ class ImageView(PlotWindow):
if parent is None:
self.setWindowTitle('ImageView')
+ if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward':
+ self.getYAxis().setInverted(True)
+
self._initWidgets(backend)
self.profile = ProfileToolBar(plot=self)
@@ -356,7 +360,7 @@ class ImageView(PlotWindow):
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
- centralWidget = qt.QWidget()
+ centralWidget = qt.QWidget(self)
centralWidget.setLayout(layout)
self.setCentralWidget(centralWidget)
@@ -773,7 +777,7 @@ class ImageView(PlotWindow):
legend=self._imageLegend,
origin=origin, scale=scale,
colormap=self.getColormap(),
- replace=False, resetzoom=False)
+ resetzoom=False)
self.setActiveImage(self._imageLegend)
self._updateHistograms()
@@ -810,17 +814,17 @@ class ImageViewMainWindow(ImageView):
self.statusBar()
menu = self.menuBar().addMenu('File')
- menu.addAction(self.saveAction)
- menu.addAction(self.printAction)
+ menu.addAction(self.getOutputToolBar().getSaveAction())
+ menu.addAction(self.getOutputToolBar().getPrintAction())
menu.addSeparator()
action = menu.addAction('Quit')
action.triggered[bool].connect(qt.QApplication.instance().quit)
menu = self.menuBar().addMenu('Edit')
- menu.addAction(self.copyAction)
+ menu.addAction(self.getOutputToolBar().getCopyAction())
menu.addSeparator()
- menu.addAction(self.resetZoomAction)
- menu.addAction(self.colormapAction)
+ menu.addAction(self.getResetZoomAction())
+ menu.addAction(self.getColormapAction())
menu.addAction(actions.control.KeepAspectRatioAction(self, self))
menu.addAction(actions.control.YAxisInvertedAction(self, self))
diff --git a/silx/gui/plot/MaskToolsWidget.py b/silx/gui/plot/MaskToolsWidget.py
index 09c5ca5..797068e 100644
--- a/silx/gui/plot/MaskToolsWidget.py
+++ b/silx/gui/plot/MaskToolsWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -35,7 +35,7 @@ from __future__ import division
__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "20/06/2017"
+__date__ = "24/04/2018"
import os
@@ -48,7 +48,7 @@ from silx.image import shapes
from ._BaseMaskToolsWidget import BaseMask, BaseMaskToolsWidget, BaseMaskToolsDockWidget
from . import items
-from .Colors import cursorColorForColormap, rgba
+from ..colors import cursorColorForColormap, rgba
from .. import qt
from silx.third_party.EdfFile import EdfFile
@@ -76,6 +76,7 @@ class ImageMask(BaseMask):
:param image: :class:`silx.gui.plot.items.ImageBase` instance
"""
BaseMask.__init__(self, image)
+ self.reset(shape=(0, 0)) # Init the mask with a 2D shape
def getDataValues(self):
"""Return image data as a 2D or 3D array (if it is a RGBA image).
@@ -222,7 +223,8 @@ class MaskToolsWidget(BaseMaskToolsWidget):
def setSelectionMask(self, mask, copy=True):
"""Set the mask to a new array.
- :param numpy.ndarray mask: The array to use for the mask.
+ :param numpy.ndarray mask:
+ The array to use for the mask or None to reset the mask.
:type mask: numpy.ndarray of uint8 of dimension 2, C-contiguous.
Array of other types are converted.
:param bool copy: True (the default) to copy the array,
@@ -231,11 +233,19 @@ class MaskToolsWidget(BaseMaskToolsWidget):
The mask can be cropped or padded to fit active image,
the returned shape is that of the active image.
"""
+ if mask is None:
+ self.resetSelectionMask()
+ return self._data.shape[:2]
+
mask = numpy.array(mask, copy=False, dtype=numpy.uint8)
if len(mask.shape) != 2:
_logger.error('Not an image, shape: %d', len(mask.shape))
return None
+ # if mask has not changed, do nothing
+ if numpy.array_equal(mask, self.getSelectionMask()):
+ return mask.shape
+
# ensure all mask attributes are synchronized with the active image
# and connect listener
activeImage = self.plot.getActiveImage()
@@ -265,7 +275,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
def _updatePlotMask(self):
"""Update mask image in plot"""
mask = self.getSelectionMask(copy=False)
- if len(mask):
+ if mask is not None:
# get the mask from the plot
maskItem = self.plot.getImage(self._maskName)
mustBeAdded = maskItem is None
@@ -303,7 +313,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
if not self.browseAction.isChecked():
self.browseAction.trigger() # Disable drawing tool
- if len(self.getSelectionMask(copy=False)):
+ if self.getSelectionMask(copy=False) is not None:
self.plot.sigActiveImageChanged.connect(
self._activeImageChangedAfterCare)
@@ -328,6 +338,13 @@ class MaskToolsWidget(BaseMaskToolsWidget):
activeImage = self.plot.getActiveImage()
if activeImage is None or activeImage.getLegend() == self._maskName:
# No active image or active image is the mask...
+ self._data = numpy.zeros((0, 0), dtype=numpy.uint8)
+ self._mask.setDataItem(None)
+ self._mask.reset()
+
+ if self.plot.getImage(self._maskName):
+ self.plot.remove(self._maskName, kind='image')
+
self.plot.sigActiveImageChanged.disconnect(
self._activeImageChangedAfterCare)
else:
@@ -340,7 +357,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
self._scale = activeImage.getScale()
self._z = activeImage.getZValue() + 1
self._data = activeImage.getData(copy=False)
- if self._data.shape[:2] != self.getSelectionMask(copy=False).shape:
+ if self._data.shape[:2] != self._mask.getMask(copy=False).shape:
# Image has not the same size, remove mask and stop listening
if self.plot.getImage(self._maskName):
self.plot.remove(self._maskName, kind='image')
@@ -378,7 +395,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
self._z = activeImage.getZValue() + 1
self._data = activeImage.getData(copy=False)
self._mask.setDataItem(activeImage)
- if self._data.shape[:2] != self.getSelectionMask(copy=False).shape:
+ if self._data.shape[:2] != self._mask.getMask(copy=False).shape:
self._mask.reset(self._data.shape[:2])
self._mask.commit()
else:
@@ -597,7 +614,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
# convert from plot to array coords
col, row = (event['points'][-1] - self._origin) / self._scale
col, row = int(col), int(row)
- brushSize = self.pencilSpinBox.value()
+ brushSize = self._getPencilWidth()
if self._lastPencilPos != (row, col):
if self._lastPencilPos is not None:
diff --git a/silx/gui/plot/PlotInteraction.py b/silx/gui/plot/PlotInteraction.py
index 865073b..356bda6 100644
--- a/silx/gui/plot/PlotInteraction.py
+++ b/silx/gui/plot/PlotInteraction.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -26,7 +26,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "27/06/2017"
+__date__ = "24/04/2018"
import math
@@ -34,7 +34,8 @@ import numpy
import time
import weakref
-from . import Colors
+from .. import colors
+from .. import qt
from . import items
from .Interaction import (ClickOrDrag, LEFT_BTN, RIGHT_BTN,
State, StateMachine)
@@ -115,11 +116,52 @@ class _ZoomOnWheel(ClickOrDrag, _PlotInteraction):
Base class for :class:`Pan` and :class:`Zoom`
"""
+
+ _DOUBLE_CLICK_TIMEOUT = 0.4
+
class ZoomIdle(ClickOrDrag.Idle):
def onWheel(self, x, y, angle):
scaleF = 1.1 if angle > 0 else 1. / 1.1
applyZoomToPlot(self.machine.plot, scaleF, (x, y))
+ def click(self, x, y, btn):
+ """Handle clicks by sending events
+
+ :param int x: Mouse X position in pixels
+ :param int y: Mouse Y position in pixels
+ :param btn: Clicked mouse button
+ """
+ if btn == LEFT_BTN:
+ lastClickTime, lastClickPos = self._lastClick
+
+ # Signal mouse double clicked event first
+ if (time.time() - lastClickTime) <= self._DOUBLE_CLICK_TIMEOUT:
+ # Use position of first click
+ eventDict = prepareMouseSignal('mouseDoubleClicked', 'left',
+ *lastClickPos)
+ self.plot.notify(**eventDict)
+
+ self._lastClick = 0., None
+ else:
+ # Signal mouse clicked event
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
+ eventDict = prepareMouseSignal('mouseClicked', 'left',
+ dataPos[0], dataPos[1],
+ x, y)
+ self.plot.notify(**eventDict)
+
+ self._lastClick = time.time(), (dataPos[0], dataPos[1], x, y)
+
+ elif btn == RIGHT_BTN:
+ # Signal mouse clicked event
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
+ eventDict = prepareMouseSignal('mouseClicked', 'right',
+ dataPos[0], dataPos[1],
+ x, y)
+ self.plot.notify(**eventDict)
+
def __init__(self, plot):
"""Init.
@@ -135,6 +177,8 @@ class _ZoomOnWheel(ClickOrDrag, _PlotInteraction):
}
StateMachine.__init__(self, states, 'idle')
+ self._lastClick = 0., None
+
# Pan #########################################################################
@@ -229,11 +273,9 @@ class Zoom(_ZoomOnWheel):
Zoom-in on selected area, zoom-out on right click,
and zoom on mouse wheel.
"""
- _DOUBLE_CLICK_TIMEOUT = 0.4
def __init__(self, plot, color):
self.color = color
- self._lastClick = 0., None
super(Zoom, self).__init__(plot)
self.plot.getLimitsHistory().clear()
@@ -263,38 +305,6 @@ class Zoom(_ZoomOnWheel):
return areaX0, areaY0, areaX1, areaY1
- def click(self, x, y, btn):
- if btn == LEFT_BTN:
- lastClickTime, lastClickPos = self._lastClick
-
- # Signal mouse double clicked event first
- if (time.time() - lastClickTime) <= self._DOUBLE_CLICK_TIMEOUT:
- # Use position of first click
- eventDict = prepareMouseSignal('mouseDoubleClicked', 'left',
- *lastClickPos)
- self.plot.notify(**eventDict)
-
- self._lastClick = 0., None
- else:
- # Signal mouse clicked event
- dataPos = self.plot.pixelToData(x, y)
- assert dataPos is not None
- eventDict = prepareMouseSignal('mouseClicked', 'left',
- dataPos[0], dataPos[1],
- x, y)
- self.plot.notify(**eventDict)
-
- self._lastClick = time.time(), (dataPos[0], dataPos[1], x, y)
-
- elif btn == RIGHT_BTN:
- # Signal mouse clicked event
- dataPos = self.plot.pixelToData(x, y)
- assert dataPos is not None
- eventDict = prepareMouseSignal('mouseClicked', 'right',
- dataPos[0], dataPos[1],
- x, y)
- self.plot.notify(**eventDict)
-
def beginDrag(self, x, y):
dataPos = self.plot.pixelToData(x, y)
assert dataPos is not None
@@ -424,7 +434,7 @@ class SelectPolygon(Select):
"""Update drawing first point, using self._firstPos"""
x, y = self.machine.plot.dataToPixel(*self._firstPos, check=False)
- offset = self.machine.DRAG_THRESHOLD_DIST
+ offset = self.machine.getDragThreshold()
points = [(x - offset, y - offset),
(x - offset, y + offset),
(x + offset, y + offset),
@@ -458,10 +468,10 @@ class SelectPolygon(Select):
check=False)
dx, dy = abs(firstPos[0] - x), abs(firstPos[1] - y)
+ threshold = self.machine.getDragThreshold()
+
# Only allow to close polygon after first point
- if (len(self.points) > 2 and
- dx < self.machine.DRAG_THRESHOLD_DIST and
- dy < self.machine.DRAG_THRESHOLD_DIST):
+ if len(self.points) > 2 and dx <= threshold and dy <= threshold:
self.machine.resetSelectionArea()
self.points[-1] = self.points[0]
@@ -489,8 +499,7 @@ class SelectPolygon(Select):
previousPos = self.machine.plot.dataToPixel(*self.points[-2],
check=False)
dx, dy = abs(previousPos[0] - x), abs(previousPos[1] - y)
- if(dx >= self.machine.DRAG_THRESHOLD_DIST or
- dy >= self.machine.DRAG_THRESHOLD_DIST):
+ if dx >= threshold or dy >= threshold:
self.points.append(dataPos)
else:
self.points[-1] = dataPos
@@ -502,8 +511,9 @@ class SelectPolygon(Select):
firstPos = self.machine.plot.dataToPixel(*self._firstPos,
check=False)
dx, dy = abs(firstPos[0] - x), abs(firstPos[1] - y)
- if (dx < self.machine.DRAG_THRESHOLD_DIST and
- dy < self.machine.DRAG_THRESHOLD_DIST):
+ threshold = self.machine.getDragThreshold()
+
+ if dx <= threshold and dy <= threshold:
x, y = firstPos # Snap to first point
dataPos = self.machine.plot.pixelToData(x, y)
@@ -523,6 +533,17 @@ class SelectPolygon(Select):
if isinstance(self.state, self.states['select']):
self.resetSelectionArea()
+ def getDragThreshold(self):
+ """Return dragging ratio with device to pixel ratio applied.
+
+ :rtype: float
+ """
+ ratio = 1.
+ if qt.BINDING in ('PyQt5', 'PySide2'):
+ ratio = self.plot.window().windowHandle().devicePixelRatio()
+ return self.DRAG_THRESHOLD_DIST * ratio
+
+
class Select2Points(Select):
"""Base class for drawing selection based on 2 input points."""
@@ -1204,6 +1225,48 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
self.plot.setGraphCursorShape()
+class ItemsInteractionForCombo(ItemsInteraction):
+ """Interaction with items to combine through :class:`FocusManager`.
+ """
+
+ class Idle(ItemsInteraction.Idle):
+ def onPress(self, x, y, btn):
+ if btn == LEFT_BTN:
+ def test(item):
+ return (item.isSelectable() or
+ (isinstance(item, items.DraggableMixIn) and
+ item.isDraggable()))
+
+ picked = self.machine.plot._pickMarker(x, y, test)
+ if picked is not None:
+ itemInteraction = True
+
+ else:
+ picked = self.machine.plot._pickImageOrCurve(x, y, test)
+ itemInteraction = picked is not None
+
+ if itemInteraction: # Request focus and handle interaction
+ self.goto('clickOrDrag', x, y)
+ return True
+ else: # Do not request focus
+ return False
+
+ elif btn == RIGHT_BTN:
+ self.goto('rightClick', x, y)
+ return True
+
+ def __init__(self, plot):
+ _PlotInteraction.__init__(self, plot)
+
+ states = {
+ 'idle': ItemsInteractionForCombo.Idle,
+ 'rightClick': ClickOrDrag.RightClick,
+ 'clickOrDrag': ClickOrDrag.ClickOrDrag,
+ 'drag': ClickOrDrag.Drag
+ }
+ StateMachine.__init__(self, states, 'idle')
+
+
# FocusManager ################################################################
class FocusManager(StateMachine):
@@ -1344,6 +1407,74 @@ class ZoomAndSelect(ItemsInteraction):
return super(ZoomAndSelect, self).endDrag(startPos, endPos)
+class PanAndSelect(ItemsInteraction):
+ """Combine Pan and ItemInteraction state machine.
+
+ :param plot: The Plot to which this interaction is attached
+ """
+
+ def __init__(self, plot):
+ super(PanAndSelect, self).__init__(plot)
+ self._pan = Pan(plot)
+ self._doPan = False
+
+ def click(self, x, y, btn):
+ """Handle mouse click
+
+ :param x: X position of the mouse in pixels
+ :param y: Y position of the mouse in pixels
+ :param btn: Pressed button id
+ :return: True if click is catched by an item, False otherwise
+ """
+ eventDict = self._handleClick(x, y, btn)
+
+ if eventDict is not None:
+ # Signal mouse clicked event
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
+ clickedEventDict = prepareMouseSignal('mouseClicked', btn,
+ dataPos[0], dataPos[1],
+ x, y)
+ self.plot.notify(**clickedEventDict)
+
+ self.plot.notify(**eventDict)
+
+ else:
+ self._pan.click(x, y, btn)
+
+ def beginDrag(self, x, y):
+ """Handle start drag and switching between zoom and item drag.
+
+ :param x: X position in pixels
+ :param y: Y position in pixels
+ """
+ self._doPan = not super(PanAndSelect, self).beginDrag(x, y)
+ if self._doPan:
+ self._pan.beginDrag(x, y)
+
+ def drag(self, x, y):
+ """Handle drag, eventually forwarding to zoom.
+
+ :param x: X position in pixels
+ :param y: Y position in pixels
+ """
+ if self._doPan:
+ return self._pan.drag(x, y)
+ else:
+ return super(PanAndSelect, self).drag(x, y)
+
+ def endDrag(self, startPos, endPos):
+ """Handle end of drag, eventually forwarding to zoom.
+
+ :param startPos: (x, y) position at the beginning of the drag
+ :param endPos: (x, y) position at the end of the drag
+ """
+ if self._doPan:
+ return self._pan.endDrag(startPos, endPos)
+ else:
+ return super(PanAndSelect, self).endDrag(startPos, endPos)
+
+
# Interaction mode control ####################################################
class PlotInteraction(object):
@@ -1384,12 +1515,21 @@ class PlotInteraction(object):
if isinstance(self._eventHandler, ZoomAndSelect):
return {'mode': 'zoom', 'color': self._eventHandler.color}
+ elif isinstance(self._eventHandler, FocusManager):
+ drawHandler = self._eventHandler.eventHandlers[1]
+ if not isinstance(drawHandler, Select):
+ raise RuntimeError('Unknown interactive mode')
+
+ result = drawHandler.parameters.copy()
+ result['mode'] = 'draw'
+ return result
+
elif isinstance(self._eventHandler, Select):
result = self._eventHandler.parameters.copy()
result['mode'] = 'draw'
return result
- elif isinstance(self._eventHandler, Pan):
+ elif isinstance(self._eventHandler, PanAndSelect):
return {'mode': 'pan'}
else:
@@ -1400,7 +1540,7 @@ class PlotInteraction(object):
"""Switch the interactive mode.
:param str mode: The name of the interactive mode.
- In 'draw', 'pan', 'select', 'zoom'.
+ In 'draw', 'pan', 'select', 'select-draw', 'zoom'.
:param color: Only for 'draw' and 'zoom' modes.
Color to use for drawing selection area. Default black.
If None, selection area is not drawn.
@@ -1413,15 +1553,15 @@ class PlotInteraction(object):
:param str label: Only for 'draw' mode.
:param float width: Width of the pencil. Only for draw pencil mode.
"""
- assert mode in ('draw', 'pan', 'select', 'zoom')
+ assert mode in ('draw', 'pan', 'select', 'select-draw', 'zoom')
plot = self._plot()
assert plot is not None
if color not in (None, 'video inverted'):
- color = Colors.rgba(color)
+ color = colors.rgba(color)
- if mode == 'draw':
+ if mode in ('draw', 'select-draw'):
assert shape in self._DRAW_MODES
eventHandlerClass = self._DRAW_MODES[shape]
parameters = {
@@ -1430,14 +1570,21 @@ class PlotInteraction(object):
'color': color,
'width': width,
}
+ eventHandler = eventHandlerClass(plot, parameters)
self._eventHandler.cancel()
- self._eventHandler = eventHandlerClass(plot, parameters)
+
+ if mode == 'draw':
+ self._eventHandler = eventHandler
+
+ else: # mode == 'select-draw'
+ self._eventHandler = FocusManager(
+ (ItemsInteractionForCombo(plot), eventHandler))
elif mode == 'pan':
# Ignores color, shape and label
self._eventHandler.cancel()
- self._eventHandler = Pan(plot)
+ self._eventHandler = PanAndSelect(plot)
elif mode == 'zoom':
# Ignores shape and label
diff --git a/silx/gui/plot/PlotToolButtons.py b/silx/gui/plot/PlotToolButtons.py
index fc5fcf4..e354877 100644
--- a/silx/gui/plot/PlotToolButtons.py
+++ b/silx/gui/plot/PlotToolButtons.py
@@ -30,6 +30,7 @@ The following QToolButton are available:
- :class:`.AspectToolButton`
- :class:`.YAxisOriginToolButton`
- :class:`.ProfileToolButton`
+- :class:`.SymbolToolButton`
"""
@@ -38,10 +39,15 @@ __license__ = "MIT"
__date__ = "27/06/2017"
+import functools
import logging
+import weakref
+
from .. import icons
from .. import qt
+from .items import SymbolMixIn
+
_logger = logging.getLogger(__name__)
@@ -52,7 +58,7 @@ class PlotToolButton(qt.QToolButton):
def __init__(self, parent=None, plot=None):
super(PlotToolButton, self).__init__(parent)
- self._plot = None
+ self._plotRef = None
if plot is not None:
self.setPlot(plot)
@@ -60,7 +66,7 @@ class PlotToolButton(qt.QToolButton):
"""
Returns the plot connected to the widget.
"""
- return self._plot
+ return None if self._plotRef is None else self._plotRef()
def setPlot(self, plot):
"""
@@ -68,13 +74,18 @@ class PlotToolButton(qt.QToolButton):
:param plot: :class:`.PlotWidget` instance on which to operate.
"""
- if self._plot is plot:
+ previousPlot = self.plot()
+
+ if previousPlot is plot:
return
- if self._plot is not None:
- self._disconnectPlot(self._plot)
- self._plot = plot
- if self._plot is not None:
- self._connectPlot(self._plot)
+ if previousPlot is not None:
+ self._disconnectPlot(previousPlot)
+
+ if plot is None:
+ self._plotRef = None
+ else:
+ self._plotRef = weakref.ref(plot)
+ self._connectPlot(plot)
def _connectPlot(self, plot):
"""
@@ -282,3 +293,71 @@ class ProfileToolButton(PlotToolButton):
def computeProfileIn2D(self):
self._profileDimensionChanged(2)
+
+
+class SymbolToolButton(PlotToolButton):
+ """A tool button with a drop-down menu to control symbol size and marker.
+
+ :param parent: See QWidget
+ :param plot: The `~silx.gui.plot.PlotWidget` to control
+ """
+
+ def __init__(self, parent=None, plot=None):
+ super(SymbolToolButton, self).__init__(parent=parent, plot=plot)
+
+ self.setToolTip('Set symbol size and marker')
+ self.setIcon(icons.getQIcon('plot-symbols'))
+
+ menu = qt.QMenu(self)
+
+ # Size slider
+
+ slider = qt.QSlider(qt.Qt.Horizontal)
+ slider.setRange(1, 20)
+ slider.setValue(SymbolMixIn._DEFAULT_SYMBOL_SIZE)
+ slider.setTracking(False)
+ slider.valueChanged.connect(self._sizeChanged)
+ widgetAction = qt.QWidgetAction(menu)
+ widgetAction.setDefaultWidget(slider)
+ menu.addAction(widgetAction)
+
+ menu.addSeparator()
+
+ # Marker actions
+
+ for marker, name in zip(SymbolMixIn.getSupportedSymbols(),
+ SymbolMixIn.getSupportedSymbolNames()):
+ action = qt.QAction(name, menu)
+ action.setCheckable(False)
+ action.triggered.connect(
+ functools.partial(self._markerChanged, marker))
+ menu.addAction(action)
+
+ self.setMenu(menu)
+ self.setPopupMode(qt.QToolButton.InstantPopup)
+
+ def _sizeChanged(self, value):
+ """Manage slider value changed
+
+ :param int value: Marker size
+ """
+ plot = self.plot()
+ if plot is None:
+ return
+
+ for item in plot._getItems(withhidden=True):
+ if isinstance(item, SymbolMixIn):
+ item.setSymbolSize(value)
+
+ def _markerChanged(self, marker):
+ """Manage change of marker.
+
+ :param str marker: Letter describing the marker
+ """
+ plot = self.plot()
+ if plot is None:
+ return
+
+ for item in plot._getItems(withhidden=True):
+ if isinstance(item, SymbolMixIn):
+ item.setSymbol(marker)
diff --git a/silx/gui/plot/PlotTools.py b/silx/gui/plot/PlotTools.py
index 7fadfd2..5929473 100644
--- a/silx/gui/plot/PlotTools.py
+++ b/silx/gui/plot/PlotTools.py
@@ -25,288 +25,19 @@
"""Set of widgets to associate with a :class:'PlotWidget'.
"""
-from __future__ import division
+from __future__ import absolute_import
-__authors__ = ["V.A. Sole", "T. Vincent"]
+__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "16/10/2017"
+__date__ = "01/03/2018"
-import logging
-import numbers
-import traceback
-import weakref
+from ...utils.deprecation import deprecated_warning
-import numpy
+deprecated_warning(type_='module',
+ name=__file__,
+ reason='Plot tools refactoring',
+ replacement='silx.gui.plot.tools',
+ since_version='0.8')
-from .. import qt
-from silx.gui.widgets.FloatEdit import FloatEdit
-
-_logger = logging.getLogger(__name__)
-
-
-# PositionInfo ################################################################
-
-class PositionInfo(qt.QWidget):
- """QWidget displaying coords converted from data coords of the mouse.
-
- Provide this widget with a list of couple:
-
- - A name to display before the data
- - A function that takes (x, y) as arguments and returns something that
- gets converted to a string.
- If the result is a float it is converted with '%.7g' format.
-
- To run the following sample code, a QApplication must be initialized.
- First, create a PlotWindow and add a QToolBar where to place the
- PositionInfo widget.
-
- >>> from silx.gui.plot import PlotWindow
- >>> from silx.gui import qt
-
- >>> plot = PlotWindow() # Create a PlotWindow to add the widget to
- >>> toolBar = qt.QToolBar() # Create a toolbar to place the widget in
- >>> plot.addToolBar(qt.Qt.BottomToolBarArea, toolBar) # Add it to plot
-
- Then, create the PositionInfo widget and add it to the toolbar.
- The PositionInfo widget is created with a list of converters, here
- to display polar coordinates of the mouse position.
-
- >>> import numpy
- >>> from silx.gui.plot.PlotTools import PositionInfo
-
- >>> position = PositionInfo(plot=plot, converters=[
- ... ('Radius', lambda x, y: numpy.sqrt(x*x + y*y)),
- ... ('Angle', lambda x, y: numpy.degrees(numpy.arctan2(y, x)))])
- >>> toolBar.addWidget(position) # Add the widget to the toolbar
- <...>
- >>> plot.show() # To display the PlotWindow with the position widget
-
- :param plot: The PlotWidget this widget is displaying data coords from.
- :param converters:
- List of 2-tuple: name to display and conversion function from (x, y)
- in data coords to displayed value.
- If None, the default, it displays X and Y.
- :param parent: Parent widget
- """
-
- def __init__(self, parent=None, plot=None, converters=None):
- assert plot is not None
- self._plotRef = weakref.ref(plot)
-
- super(PositionInfo, self).__init__(parent)
-
- if converters is None:
- converters = (('X', lambda x, y: x), ('Y', lambda x, y: y))
-
- self.autoSnapToActiveCurve = False
- """Toggle snapping use position to active curve.
-
- - True to snap used coordinates to the active curve if the active curve
- is displayed with symbols and mouse is close enough.
- If the mouse is not close to a point of the curve, values are
- displayed in red.
- - False (the default) to always use mouse coordinates.
-
- """
-
- self._fields = [] # To store (QLineEdit, name, function (x, y)->v)
-
- # Create a new layout with new widgets
- layout = qt.QHBoxLayout()
- layout.setContentsMargins(0, 0, 0, 0)
- # layout.setSpacing(0)
-
- # Create all QLabel and store them with the corresponding converter
- for name, func in converters:
- layout.addWidget(qt.QLabel('<b>' + name + ':</b>'))
-
- contentWidget = qt.QLabel()
- contentWidget.setText('------')
- contentWidget.setTextInteractionFlags(qt.Qt.TextSelectableByMouse)
- contentWidget.setFixedWidth(
- contentWidget.fontMetrics().width('##############'))
- layout.addWidget(contentWidget)
- self._fields.append((contentWidget, name, func))
-
- layout.addStretch(1)
- self.setLayout(layout)
-
- # Connect to Plot events
- plot.sigPlotSignal.connect(self._plotEvent)
-
- @property
- def plot(self):
- """The :class:`.PlotWindow` this widget is attached to."""
- return self._plotRef()
-
- def getConverters(self):
- """Return the list of converters as 2-tuple (name, function)."""
- return [(name, func) for _label, name, func in self._fields]
-
- def _plotEvent(self, event):
- """Handle events from the Plot.
-
- :param dict event: Plot event
- """
- if event['event'] == 'mouseMoved':
- x, y = event['x'], event['y']
- xPixel, yPixel = event['xpixel'], event['ypixel']
- self._updateStatusBar(x, y, xPixel, yPixel)
-
- def _updateStatusBar(self, x, y, xPixel, yPixel):
- """Update information from the status bar using the definitions.
-
- :param float x: Position-x in data
- :param float y: Position-y in data
- :param float xPixel: Position-x in pixels
- :param float yPixel: Position-y in pixels
- """
- styleSheet = "color: rgb(0, 0, 0);" # Default style
-
- if self.autoSnapToActiveCurve and self.plot.getGraphCursor():
- # Check if near active curve with symbols.
-
- styleSheet = "color: rgb(255, 0, 0);" # Style far from curve
-
- activeCurve = self.plot.getActiveCurve()
- if activeCurve:
- xData = activeCurve.getXData(copy=False)
- yData = activeCurve.getYData(copy=False)
- if activeCurve.getSymbol(): # Only handled if symbols on curve
- closestIndex = numpy.argmin(
- pow(xData - x, 2) + pow(yData - y, 2))
-
- xClosest = xData[closestIndex]
- yClosest = yData[closestIndex]
-
- closestInPixels = self.plot.dataToPixel(
- xClosest, yClosest, axis=activeCurve.getYAxis())
- if closestInPixels is not None:
- if (abs(closestInPixels[0] - xPixel) < 5 and
- abs(closestInPixels[1] - yPixel) < 5):
- # Update label style sheet
- styleSheet = "color: rgb(0, 0, 0);"
-
- # if close enough, wrap to data point coords
- x, y = xClosest, yClosest
-
- for label, name, func in self._fields:
- label.setStyleSheet(styleSheet)
-
- try:
- value = func(x, y)
- text = self.valueToString(value)
- label.setText(text)
- except:
- label.setText('Error')
- _logger.error(
- "Error while converting coordinates (%f, %f)"
- "with converter '%s'" % (x, y, name))
- _logger.error(traceback.format_exc())
-
- def valueToString(self, value):
- if isinstance(value, (tuple, list)):
- value = [self.valueToString(v) for v in value]
- return ", ".join(value)
- elif isinstance(value, numbers.Real):
- # Use this for floats and int
- return '%.7g' % value
- else:
- # Fallback for other types
- return str(value)
-
-# LimitsToolBar ##############################################################
-
-class LimitsToolBar(qt.QToolBar):
- """QToolBar displaying and controlling the limits of a :class:`PlotWidget`.
-
- To run the following sample code, a QApplication must be initialized.
- First, create a PlotWindow:
-
- >>> from silx.gui.plot import PlotWindow
- >>> plot = PlotWindow() # Create a PlotWindow to add the toolbar to
-
- Then, create the LimitsToolBar and add it to the PlotWindow.
-
- >>> from silx.gui import qt
- >>> from silx.gui.plot.PlotTools import LimitsToolBar
-
- >>> toolbar = LimitsToolBar(plot=plot) # Create the toolbar
- >>> plot.addToolBar(qt.Qt.BottomToolBarArea, toolbar) # Add it to the plot
- >>> plot.show() # To display the PlotWindow with the limits toolbar
-
- :param parent: See :class:`QToolBar`.
- :param plot: :class:`PlotWidget` instance on which to operate.
- :param str title: See :class:`QToolBar`.
- """
-
- def __init__(self, parent=None, plot=None, title='Limits'):
- super(LimitsToolBar, self).__init__(title, parent)
- assert plot is not None
- self._plot = plot
- self._plot.sigPlotSignal.connect(self._plotWidgetSlot)
-
- self._initWidgets()
-
- @property
- def plot(self):
- """The :class:`PlotWidget` the toolbar is attached to."""
- return self._plot
-
- def _initWidgets(self):
- """Create and init Toolbar widgets."""
- xMin, xMax = self.plot.getXAxis().getLimits()
- yMin, yMax = self.plot.getYAxis().getLimits()
-
- self.addWidget(qt.QLabel('Limits: '))
- self.addWidget(qt.QLabel(' X: '))
- self._xMinFloatEdit = FloatEdit(self, xMin)
- self._xMinFloatEdit.editingFinished[()].connect(
- self._xFloatEditChanged)
- self.addWidget(self._xMinFloatEdit)
-
- self._xMaxFloatEdit = FloatEdit(self, xMax)
- self._xMaxFloatEdit.editingFinished[()].connect(
- self._xFloatEditChanged)
- self.addWidget(self._xMaxFloatEdit)
-
- self.addWidget(qt.QLabel(' Y: '))
- self._yMinFloatEdit = FloatEdit(self, yMin)
- self._yMinFloatEdit.editingFinished[()].connect(
- self._yFloatEditChanged)
- self.addWidget(self._yMinFloatEdit)
-
- self._yMaxFloatEdit = FloatEdit(self, yMax)
- self._yMaxFloatEdit.editingFinished[()].connect(
- self._yFloatEditChanged)
- self.addWidget(self._yMaxFloatEdit)
-
- def _plotWidgetSlot(self, event):
- """Listen to :class:`PlotWidget` events."""
- if event['event'] not in ('limitsChanged',):
- return
-
- xMin, xMax = self.plot.getXAxis().getLimits()
- yMin, yMax = self.plot.getYAxis().getLimits()
-
- self._xMinFloatEdit.setValue(xMin)
- self._xMaxFloatEdit.setValue(xMax)
- self._yMinFloatEdit.setValue(yMin)
- self._yMaxFloatEdit.setValue(yMax)
-
- def _xFloatEditChanged(self):
- """Handle X limits changed from the GUI."""
- xMin, xMax = self._xMinFloatEdit.value(), self._xMaxFloatEdit.value()
- if xMax < xMin:
- xMin, xMax = xMax, xMin
-
- self.plot.getXAxis().setLimits(xMin, xMax)
-
- def _yFloatEditChanged(self):
- """Handle Y limits changed from the GUI."""
- yMin, yMax = self._yMinFloatEdit.value(), self._yMaxFloatEdit.value()
- if yMax < yMin:
- yMin, yMax = yMax, yMin
-
- self.plot.getYAxis().setLimits(yMin, yMax)
+from .tools import PositionInfo, LimitsToolBar # noqa
diff --git a/silx/gui/plot/PlotWidget.py b/silx/gui/plot/PlotWidget.py
index 3641b8c..2f7132c 100644
--- a/silx/gui/plot/PlotWidget.py
+++ b/silx/gui/plot/PlotWidget.py
@@ -31,37 +31,43 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "18/10/2017"
+__date__ = "14/06/2018"
from collections import OrderedDict, namedtuple
from contextlib import contextmanager
+import datetime as dt
import itertools
import logging
import numpy
+import silx
+from silx.utils.weakref import WeakMethodProxy
+from silx.utils import deprecation
+from silx.utils.property import classproperty
from silx.utils.deprecation import deprecated
# Import matplotlib backend here to init matplotlib our way
from .backends.BackendMatplotlib import BackendMatplotlibQt
-from .Colormap import Colormap
-from . import Colors
+from ..colors import Colormap
+from .. import colors
from . import PlotInteraction
from . import PlotEvents
from .LimitsHistory import LimitsHistory
from . import _utils
from . import items
+from .items.axis import TickMode
from .. import qt
from ._utils.panzoom import ViewConstraints
-
+from ...gui.plot._utils.dtime_ticklayout import timestamp
_logger = logging.getLogger(__name__)
-_COLORDICT = Colors.COLORDICT
+_COLORDICT = colors.COLORDICT
_COLORLIST = [_COLORDICT['black'],
_COLORDICT['blue'],
_COLORDICT['red'],
@@ -110,8 +116,12 @@ class PlotWidget(qt.QMainWindow):
:type backend: str or :class:`BackendBase.BackendBase`
"""
- DEFAULT_BACKEND = 'matplotlib'
- """Class attribute setting the default backend for all instances."""
+ # TODO: Can be removed for silx 0.10
+ @classproperty
+ @deprecation.deprecated(replacement="silx.config.DEFAULT_PLOT_BACKEND", since_version="0.8", skip_backtrace_count=2)
+ def DEFAULT_BACKEND(self):
+ """Class attribute setting the default backend for all instances."""
+ return silx.config.DEFAULT_PLOT_BACKEND
colorList = _COLORLIST
colorDict = _COLORDICT
@@ -209,7 +219,7 @@ class PlotWidget(qt.QMainWindow):
self.setWindowTitle('PlotWidget')
if backend is None:
- backend = self.DEFAULT_BACKEND
+ backend = silx.config.DEFAULT_PLOT_BACKEND
if hasattr(backend, "__call__"):
self._backend = backend(self, parent)
@@ -296,7 +306,9 @@ class PlotWidget(qt.QMainWindow):
self.setGraphYLimits(0., 100., axis='right')
self.setGraphYLimits(0., 100., axis='left')
+ # TODO: Can be removed for silx 0.10
@staticmethod
+ @deprecation.deprecated(replacement="silx.config.DEFAULT_PLOT_BACKEND", since_version="0.8", skip_backtrace_count=2)
def setDefaultBackend(backend):
"""Set system wide default plot backend.
@@ -306,7 +318,7 @@ class PlotWidget(qt.QMainWindow):
'matplotlib' (default), 'mpl', 'opengl', 'gl', 'none'
or a :class:`BackendBase.BackendBase` class
"""
- PlotWidget.DEFAULT_BACKEND = backend
+ silx.config.DEFAULT_PLOT_BACKEND = backend
def _getDirtyPlot(self):
"""Return the plot dirty flag.
@@ -525,7 +537,9 @@ class PlotWidget(qt.QMainWindow):
:param numpy.ndarray x: The data corresponding to the x coordinates.
If you attempt to plot an histogram you can set edges values in x.
- In this case len(x) = len(y) + 1
+ In this case len(x) = len(y) + 1.
+ If x contains datetime objects the XAxis tickMode is set to
+ TickMode.TIME_SERIES.
:param numpy.ndarray y: The data corresponding to the y coordinates
:param str legend: The legend to be associated to the curve (or None)
:param info: User-defined information associated to the curve
@@ -533,7 +547,7 @@ class PlotWidget(qt.QMainWindow):
curves
:param color: color(s) to be used
:type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or
- one of the predefined color names defined in Colors.py
+ one of the predefined color names defined in colors.py
:param str symbol: Symbol to be drawn at each (x, y) position::
- 'o' circle
@@ -686,6 +700,13 @@ class PlotWidget(qt.QMainWindow):
if yerror is None:
yerror = curve.getYErrorData(copy=False)
+ # Convert x to timestamps so that the internal representation
+ # remains floating points. The user is expected to set the axis'
+ # tickMode to TickMode.TIME_SERIES and, if necessary, set the axis
+ # to the correct time zone.
+ if len(x) > 0 and isinstance(x[0], dt.datetime):
+ x = [timestamp(d) for d in x]
+
curve.setData(x, y, xerror, yerror, copy=copy)
if replace: # Then remove all other curves
@@ -739,7 +760,7 @@ class PlotWidget(qt.QMainWindow):
The legend to be associated to the histogram (or None)
:param color: color to be used
:type color: str ("#RRGGBB") or RGB unsigned byte array or
- one of the predefined color names defined in Colors.py
+ one of the predefined color names defined in colors.py
:param bool fill: True to fill the curve, False otherwise (default).
:param str align:
In case histogram values and edges have the same length N,
@@ -785,7 +806,7 @@ class PlotWidget(qt.QMainWindow):
return legend
def addImage(self, data, legend=None, info=None,
- replace=True, replot=None,
+ replace=False, replot=None,
xScale=None, yScale=None, z=None,
selectable=None, draggable=None,
colormap=None, pixmap=None,
@@ -811,7 +832,8 @@ class PlotWidget(qt.QMainWindow):
Note: boolean values are converted to int8.
:param str legend: The legend to be associated to the image (or None)
:param info: User-defined information associated to the image
- :param bool replace: True (default) to delete already existing images
+ :param bool replace:
+ True to delete already existing images (Default: False).
:param int z: Layer on which to draw the image (default: 0)
This allows to control the overlay.
:param bool selectable: Indicate if the image can be selected.
@@ -821,7 +843,7 @@ class PlotWidget(qt.QMainWindow):
:param colormap: Description of the :class:`.Colormap` to use
(or None).
This is ignored if data is a RGB(A) image.
- :type colormap: Union[silx.gui.plot.Colormap.Colormap, dict]
+ :type colormap: Union[silx.gui.colors.Colormap, dict]
:param pixmap: Pixmap representation of the data (if any)
:type pixmap: (nrows, ncolumns, RGBA) ubyte array or None (default)
:param str xlabel: X axis label to show when this curve is active,
@@ -964,7 +986,7 @@ class PlotWidget(qt.QMainWindow):
:param numpy.ndarray y: The data corresponding to the y coordinates
:param numpy.ndarray value: The data value associated with each point
:param str legend: The legend to be associated to the scatter (or None)
- :param silx.gui.plot.Colormap.Colormap colormap:
+ :param silx.gui.colors.Colormap colormap:
The :class:`.Colormap`. to be used for the scatter (or None)
:param info: User-defined information associated to the curve
:param str symbol: Symbol to be drawn at each (x, y) position::
@@ -1477,7 +1499,7 @@ class PlotWidget(qt.QMainWindow):
:param bool flag: Toggle the display of a crosshair cursor.
The crosshair cursor is hidden by default.
:param color: The color to use for the crosshair.
- :type color: A string (either a predefined color name in Colors.py
+ :type color: A string (either a predefined color name in colors.py
or "#RRGGBB")) or a 4 columns unsigned byte array
(Default: black).
:param int linewidth: The width of the lines of the crosshair
@@ -2264,13 +2286,13 @@ class PlotWidget(qt.QMainWindow):
It only affects future calls to :meth:`addImage` without the colormap
parameter.
- :param silx.gui.plot.Colormap.Colormap colormap:
+ :param silx.gui.colors.Colormap colormap:
The description of the default colormap, or
None to set the :class:`.Colormap` to a linear
autoscale gray colormap.
"""
if colormap is None:
- colormap = Colormap(name='gray',
+ colormap = Colormap(name=silx.config.DEFAULT_COLORMAP_NAME,
normalization='linear',
vmin=None,
vmax=None)
@@ -2370,10 +2392,10 @@ class PlotWidget(qt.QMainWindow):
to handle the graph events
If None (default), use a default listener.
"""
- # TODO allow multiple listeners, keep a weakref on it
+ # TODO allow multiple listeners
# allow register listener by event type
if callbackFunction is None:
- callbackFunction = self.graphCallback
+ callbackFunction = WeakMethodProxy(self.graphCallback)
self._callback = callbackFunction
def graphCallback(self, ddict=None):
@@ -2392,6 +2414,8 @@ class PlotWidget(qt.QMainWindow):
if ddict['button'] == "left":
self.setActiveCurve(ddict['label'])
qt.QToolTip.showText(self.cursor().pos(), ddict['label'])
+ elif ddict['event'] == 'mouseClicked' and ddict['button'] == 'left':
+ self.setActiveCurve(None)
def saveGraph(self, filename, fileFormat=None, dpi=None, **kw):
"""Save a snapshot of the plot.
@@ -2519,9 +2543,8 @@ class PlotWidget(qt.QMainWindow):
# Compute bbox wth figure aspect ratio
plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:]
- plotRatio = plotHeight / plotWidth
-
- if plotRatio > 0.:
+ if plotWidth > 0 and plotHeight > 0:
+ plotRatio = plotHeight / plotWidth
dataRatio = (ymax - ymin) / (xmax - xmin)
if dataRatio < plotRatio:
# Increase y range
@@ -2741,6 +2764,39 @@ class PlotWidget(qt.QMainWindow):
return None
+ def _pick(self, x, y):
+ """Pick items in the plot at given position.
+
+ :param float x: X position in pixels
+ :param float y: Y position in pixels
+ :return: Iterable of (plot item, indices) at picked position.
+ Items are ordered from back to front.
+ """
+ items = []
+
+ # Convert backend result to plot items
+ for itemInfo in self._backend.pickItems(
+ x, y, kinds=('marker', 'curve', 'image')):
+ kind, legend = itemInfo['kind'], itemInfo['legend']
+
+ if kind in ('marker', 'image'):
+ item = self._getItem(kind=kind, legend=legend)
+ indices = None # TODO compute indices for images
+
+ else: # backend kind == 'curve'
+ for kind in ('curve', 'histogram', 'scatter'):
+ item = self._getItem(kind=kind, legend=legend)
+ if item is not None:
+ indices = itemInfo['indices']
+ break
+ else:
+ _logger.error(
+ 'Cannot find corresponding picked item')
+ continue
+ items.append((item, indices))
+
+ return tuple(items)
+
# User event handling #
def _isPositionInPlotArea(self, x, y):
@@ -2846,7 +2902,7 @@ class PlotWidget(qt.QMainWindow):
"""Switch the interactive mode.
:param str mode: The name of the interactive mode.
- In 'draw', 'pan', 'select', 'zoom'.
+ In 'draw', 'pan', 'select', 'select-draw', 'zoom'.
:param color: Only for 'draw' and 'zoom' modes.
Color to use for drawing selection area. Default black.
:type color: Color description: The name as a str or
@@ -2959,7 +3015,7 @@ class PlotWidget(qt.QMainWindow):
:param str label: Associated text for identifying draw signals
:param color: The color to use to draw the selection area
:type color: string ("#RRGGBB") or 4 column unsigned byte array or
- one of the predefined color names defined in Colors.py
+ one of the predefined color names defined in colors.py
"""
_logger.warning(
'setDrawModeEnabled deprecated, use setInteractiveMode instead')
@@ -3011,7 +3067,7 @@ class PlotWidget(qt.QMainWindow):
(Default: 'black')
:param color: The color to use to draw the selection area
:type color: string ("#RRGGBB") or 4 column unsigned byte array or
- one of the predefined color names defined in Colors.py
+ one of the predefined color names defined in colors.py
"""
_logger.warning(
'setZoomModeEnabled deprecated, use setInteractiveMode instead')
diff --git a/silx/gui/plot/PlotWindow.py b/silx/gui/plot/PlotWindow.py
index 5c7e661..459ffdc 100644
--- a/silx/gui/plot/PlotWindow.py
+++ b/silx/gui/plot/PlotWindow.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -29,11 +29,14 @@ The :class:`PlotWindow` is a subclass of :class:`.PlotWidget`.
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "15/02/2018"
+__date__ = "05/06/2018"
import collections
import logging
+import weakref
+import silx
+from silx.utils.weakref import WeakMethodProxy
from silx.utils.deprecation import deprecated
from . import PlotWidget
@@ -44,11 +47,12 @@ from .actions import fit as actions_fit
from .actions import control as actions_control
from .actions import histogram as actions_histogram
from . import PlotToolButtons
-from .PlotTools import PositionInfo
+from . import tools
from .Profile import ProfileToolBar
from .LegendSelector import LegendsDockWidget
from .CurvesROIWidget import CurvesROIDockWidget
from .MaskToolsWidget import MaskToolsDockWidget
+from .StatsWidget import BasicStatsWidget
from .ColorBar import ColorBarWidget
try:
from ..console import IPythonDockWidget
@@ -90,7 +94,7 @@ class PlotWindow(PlotWidget):
(Default: False).
It also supports a list of (name, funct(x, y)->value)
to customize the displayed values.
- See :class:`silx.gui.plot.PlotTools.PositionInfo`.
+ See :class:`~silx.gui.plot.tools.PositionInfo`.
:param bool roi: Toggle visibilty of ROI action.
:param bool mask: Toggle visibilty of mask action.
:param bool fit: Toggle visibilty of fit action.
@@ -114,6 +118,7 @@ class PlotWindow(PlotWidget):
self._curvesROIDockWidget = None
self._maskToolsDockWidget = None
self._consoleDockWidget = None
+ self._statsWidget = None
# Create color bar, hidden by default for backward compatibility
self._colorbar = ColorBarWidget(parent=self, plot=self)
@@ -122,11 +127,6 @@ class PlotWindow(PlotWidget):
self.group = qt.QActionGroup(self)
self.group.setExclusive(False)
- self.zoomModeAction = self.group.addAction(
- actions.mode.ZoomModeAction(self))
- self.panModeAction = self.group.addAction(
- actions.mode.PanModeAction(self))
-
self.resetZoomAction = self.group.addAction(
actions.control.ResetZoomAction(self))
self.resetZoomAction.setVisible(resetzoom)
@@ -205,28 +205,13 @@ class PlotWindow(PlotWidget):
actions_medfilt.MedianFilter1DAction(self))
self._medianFilter1DAction.setVisible(False)
- self._separator = qt.QAction('separator', self)
- self._separator.setSeparator(True)
- self.group.addAction(self._separator)
-
- self.copyAction = self.group.addAction(actions.io.CopyAction(self))
- self.copyAction.setVisible(copy)
- self.addAction(self.copyAction)
-
- self.saveAction = self.group.addAction(actions.io.SaveAction(self))
- self.saveAction.setVisible(save)
- self.addAction(self.saveAction)
-
- self.printAction = self.group.addAction(actions.io.PrintAction(self))
- self.printAction.setVisible(print_)
- self.addAction(self.printAction)
-
self.fitAction = self.group.addAction(actions_fit.FitAction(self))
self.fitAction.setVisible(fit)
self.addAction(self.fitAction)
# lazy loaded actions needed by the controlButton menu
self._consoleAction = None
+ self._statsAction = None
self._panWithArrowKeysAction = None
self._crosshairAction = None
@@ -244,10 +229,12 @@ class PlotWindow(PlotWidget):
gridLayout.addWidget(self._colorbar, 0, 1)
gridLayout.setRowStretch(0, 1)
gridLayout.setColumnStretch(0, 1)
- centralWidget = qt.QWidget()
+ centralWidget = qt.QWidget(self)
centralWidget.setLayout(gridLayout)
self.setCentralWidget(centralWidget)
+ self._positionWidget = None
+
if control or position:
hbox = qt.QHBoxLayout()
hbox.setContentsMargins(0, 0, 0, 0)
@@ -270,22 +257,69 @@ class PlotWindow(PlotWidget):
converters = position
else:
converters = None
- self.positionWidget = PositionInfo(
+ self._positionWidget = tools.PositionInfo(
plot=self, converters=converters)
- self.positionWidget.autoSnapToActiveCurve = True
+ # Set a snapping mode that is consistent with legacy one
+ self._positionWidget.setSnappingMode(
+ tools.PositionInfo.SNAPPING_CROSSHAIR |
+ tools.PositionInfo.SNAPPING_ACTIVE_ONLY |
+ tools.PositionInfo.SNAPPING_SYMBOLS_ONLY |
+ tools.PositionInfo.SNAPPING_CURVE |
+ tools.PositionInfo.SNAPPING_SCATTER)
- hbox.addWidget(self.positionWidget)
+ hbox.addWidget(self._positionWidget)
hbox.addStretch(1)
- bottomBar = qt.QWidget()
+ bottomBar = qt.QWidget(centralWidget)
bottomBar.setLayout(hbox)
gridLayout.addWidget(bottomBar, 1, 0, 1, -1)
# Creating the toolbar also create actions for toolbuttons
+ self._interactiveModeToolBar = tools.InteractiveModeToolBar(
+ parent=self, plot=self)
+ self.addToolBar(self._interactiveModeToolBar)
+
self._toolbar = self._createToolBar(title='Plot', parent=None)
self.addToolBar(self._toolbar)
+ self._outputToolBar = tools.OutputToolBar(parent=self, plot=self)
+ self._outputToolBar.getCopyAction().setVisible(copy)
+ self._outputToolBar.getSaveAction().setVisible(save)
+ self._outputToolBar.getPrintAction().setVisible(print_)
+ self.addToolBar(self._outputToolBar)
+
+ # Activate shortcuts in PlotWindow widget:
+ for toolbar in (self._interactiveModeToolBar, self._outputToolBar):
+ for action in toolbar.actions():
+ self.addAction(action)
+
+ def getInteractiveModeToolBar(self):
+ """Returns QToolBar controlling interactive mode.
+
+ :rtype: QToolBar
+ """
+ return self._interactiveModeToolBar
+
+ def getOutputToolBar(self):
+ """Returns QToolBar containing save, copy and print actions
+
+ :rtype: QToolBar
+ """
+ return self._outputToolBar
+
+ @property
+ @deprecated(replacement="getPositionInfoWidget()", since_version="0.8.0")
+ def positionWidget(self):
+ return self.getPositionInfoWidget()
+
+ def getPositionInfoWidget(self):
+ """Returns the widget displaying current cursor position information
+
+ :rtype: ~silx.gui.plot.tools.PositionInfo
+ """
+ return self._positionWidget
+
def getSelectionMask(self):
"""Return the current mask handled by :attr:`maskToolsDockWidget`.
@@ -313,7 +347,7 @@ class PlotWindow(PlotWidget):
show it or hide it."""
# create widget if needed (first call)
if self._consoleDockWidget is None:
- available_vars = {"plt": self}
+ available_vars = {"plt": weakref.proxy(self)}
banner = "The variable 'plt' is available. Use the 'whos' "
banner += "and 'help(plt)' commands for more information.\n\n"
self._consoleDockWidget = IPythonDockWidget(
@@ -327,6 +361,9 @@ class PlotWindow(PlotWidget):
self._consoleDockWidget.setVisible(isChecked)
+ def _toggleStatsVisibility(self, isChecked=False):
+ self.getStatsWidget().parent().setVisible(isChecked)
+
def _createToolBar(self, title, parent):
"""Create a QToolBar from the QAction of the PlotWindow.
@@ -355,8 +392,6 @@ class PlotWindow(PlotWidget):
self.yAxisInvertedAction = toolbar.addWidget(obj)
else:
raise RuntimeError()
- if obj is self.panModeAction:
- toolbar.addSeparator()
return toolbar
def toolBar(self):
@@ -381,6 +416,7 @@ class PlotWindow(PlotWidget):
controlMenu.clear()
controlMenu.addAction(self.getLegendsDockWidget().toggleViewAction())
controlMenu.addAction(self.getRoiAction())
+ controlMenu.addAction(self.getStatsAction())
controlMenu.addAction(self.getMaskAction())
controlMenu.addAction(self.getConsoleAction())
@@ -474,8 +510,35 @@ class PlotWindow(PlotWidget):
self.addTabbedDockWidget(self._maskToolsDockWidget)
return self._maskToolsDockWidget
+ def getStatsWidget(self):
+ """Returns a BasicStatsWidget connected to this plot
+
+ :rtype: BasicStatsWidget
+ """
+ if self._statsWidget is None:
+ dockWidget = qt.QDockWidget(parent=self)
+ dockWidget.setWindowTitle("Curves stats")
+ dockWidget.layout().setContentsMargins(0, 0, 0, 0)
+ self._statsWidget = BasicStatsWidget(parent=self, plot=self)
+ dockWidget.setWidget(self._statsWidget)
+ dockWidget.hide()
+ self.addTabbedDockWidget(dockWidget)
+ return self._statsWidget
+
# getters for actions
@property
+ @deprecated(replacement="getInteractiveModeToolBar().getZoomModeAction()",
+ since_version="0.8.0")
+ def zoomModeAction(self):
+ return self.getInteractiveModeToolBar().getZoomModeAction()
+
+ @property
+ @deprecated(replacement="getInteractiveModeToolBar().getPanModeAction()",
+ since_version="0.8.0")
+ def panModeAction(self):
+ return self.getInteractiveModeToolBar().getPanModeAction()
+
+ @property
@deprecated(replacement="getConsoleAction()", since_version="0.4.0")
def consoleAction(self):
return self.getConsoleAction()
@@ -545,6 +608,14 @@ class PlotWindow(PlotWidget):
def roiAction(self):
return self.getRoiAction()
+ def getStatsAction(self):
+ if self._statsAction is None:
+ self._statsAction = qt.QAction('Curves stats', self)
+ self._statsAction.setCheckable(True)
+ self._statsAction.setChecked(self.getStatsWidget().parent().isVisible())
+ self._statsAction.toggled.connect(self._toggleStatsVisibility)
+ return self._statsAction
+
def getRoiAction(self):
"""QAction toggling curve ROI dock widget
@@ -667,21 +738,21 @@ class PlotWindow(PlotWidget):
:rtype: actions.PlotAction
"""
- return self.copyAction
+ return self.getOutputToolBar().getCopyAction()
def getSaveAction(self):
"""Action to save plot
:rtype: actions.PlotAction
"""
- return self.saveAction
+ return self.getOutputToolBar().getSaveAction()
def getPrintAction(self):
"""Action to print plot
:rtype: actions.PlotAction
"""
- return self.printAction
+ return self.getOutputToolBar().getPrintAction()
def getFitAction(self):
"""Action to fit selected curve
@@ -757,7 +828,7 @@ class Plot2D(PlotWindow):
posInfo = [
('X', lambda x, y: x),
('Y', lambda x, y: y),
- ('Data', self._getImageValue)]
+ ('Data', WeakMethodProxy(self._getImageValue))]
super(Plot2D, self).__init__(parent=parent, backend=backend,
resetzoom=True, autoScale=False,
@@ -772,6 +843,9 @@ class Plot2D(PlotWindow):
self.getXAxis().setLabel('Columns')
self.getYAxis().setLabel('Rows')
+ if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward':
+ self.getYAxis().setInverted(True)
+
self.profile = ProfileToolBar(plot=self)
self.addToolBar(self.profile)
@@ -780,10 +854,41 @@ class Plot2D(PlotWindow):
# Put colorbar action after colormap action
actions = self.toolBar().actions()
- for index, action in enumerate(actions):
+ for action in actions:
if action is self.getColormapAction():
break
+ self.sigActiveImageChanged.connect(self.__activeImageChanged)
+
+ def __activeImageChanged(self, previous, legend):
+ """Handle change of active image
+
+ :param Union[str,None] previous: Legend of previous active image
+ :param Union[str,None] legend: Legend of current active image
+ """
+ if previous is not None:
+ item = self.getImage(previous)
+ if item is not None:
+ item.sigItemChanged.disconnect(self.__imageChanged)
+
+ if legend is not None:
+ item = self.getImage(legend)
+ item.sigItemChanged.connect(self.__imageChanged)
+
+ positionInfo = self.getPositionInfoWidget()
+ if positionInfo is not None:
+ positionInfo.updateInfo()
+
+ def __imageChanged(self, event):
+ """Handle update of active image item
+
+ :param event: Type of changed event
+ """
+ if event == items.ItemChangedType.DATA:
+ positionInfo = self.getPositionInfoWidget()
+ if positionInfo is not None:
+ positionInfo.updateInfo()
+
def _getImageValue(self, x, y):
"""Get status bar value of top most image at position (x, y)
diff --git a/silx/gui/plot/Profile.py b/silx/gui/plot/Profile.py
index f61412d..5a733fe 100644
--- a/silx/gui/plot/Profile.py
+++ b/silx/gui/plot/Profile.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -28,7 +28,7 @@ and stacks of images"""
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel", "H. Payno"]
__license__ = "MIT"
-__date__ = "17/08/2017"
+__date__ = "24/04/2018"
import weakref
@@ -40,7 +40,7 @@ from silx.image.bilinear import BilinearImage
from .. import icons
from .. import qt
from . import items
-from .Colors import cursorColorForColormap
+from ..colors import cursorColorForColormap
from . import actions
from .PlotToolButtons import ProfileToolButton
from .ProfileMainWindow import ProfileMainWindow
@@ -637,6 +637,12 @@ class ProfileToolBar(qt.QToolBar):
colormap=colormap)
else:
coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
+ # Scale horizontal and vertical profile coordinates
+ if self._roiInfo[2] == 'X':
+ coords = coords * scale[0] + origin[0]
+ elif self._roiInfo[2] == 'Y':
+ coords = coords * scale[1] + origin[1]
+
self.getProfilePlot().addCurve(coords,
profile[0],
legend=profileName,
diff --git a/silx/gui/plot/ProfileMainWindow.py b/silx/gui/plot/ProfileMainWindow.py
index 835de2c..3738511 100644
--- a/silx/gui/plot/ProfileMainWindow.py
+++ b/silx/gui/plot/ProfileMainWindow.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -73,6 +73,8 @@ class ProfileMainWindow(qt.QMainWindow):
self._plot2D.setParent(None) # necessary to avoid widget destruction
if self._plot1D is None:
self._plot1D = Plot1D()
+ self._plot1D.setGraphYLabel('Profile')
+ self._plot1D.setGraphXLabel('')
self.setCentralWidget(self._plot1D)
elif self._profileType == "2D":
if self._plot1D is not None:
diff --git a/silx/gui/plot/ScatterMaskToolsWidget.py b/silx/gui/plot/ScatterMaskToolsWidget.py
index a9c1073..2a10f6d 100644
--- a/silx/gui/plot/ScatterMaskToolsWidget.py
+++ b/silx/gui/plot/ScatterMaskToolsWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -35,7 +35,7 @@ from __future__ import division
__authors__ = ["P. Knobel"]
__license__ = "MIT"
-__date__ = "07/04/2017"
+__date__ = "24/04/2018"
import math
@@ -45,10 +45,11 @@ import numpy
import sys
from .. import qt
+from ...math.combo import min_max
from ...image import shapes
from ._BaseMaskToolsWidget import BaseMask, BaseMaskToolsWidget, BaseMaskToolsDockWidget
-from .Colors import cursorColorForColormap, rgba
+from ..colors import cursorColorForColormap, rgba
_logger = logging.getLogger(__name__)
@@ -186,13 +187,18 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
self._z = 2 # Mask layer in plot
self._data_scatter = None
"""plot Scatter item for data"""
+
+ self._data_extent = None
+ """Maximum extent of the data i.e., max(xMax-xMin, yMax-yMin)"""
+
self._mask_scatter = None
"""plot Scatter item for representing the mask"""
def setSelectionMask(self, mask, copy=True):
"""Set the mask to a new array.
- :param numpy.ndarray mask: The array to use for the mask.
+ :param numpy.ndarray mask:
+ The array to use for the mask or None to reset the mask.
:type mask: numpy.ndarray of uint8, C-contiguous.
Array of other types are converted.
:param bool copy: True (the default) to copy the array,
@@ -201,6 +207,10 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
The mask can be cropped or padded to fit active scatter,
the returned shape is that of the scatter data.
"""
+ if mask is None:
+ self.resetSelectionMask()
+ return self._data_scatter.getXData(copy=False).shape
+
mask = numpy.array(mask, copy=False, dtype=numpy.uint8)
if self._data_scatter.getXData(copy=False).shape == (0,) \
@@ -216,7 +226,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
def _updatePlotMask(self):
"""Update mask image in plot"""
mask = self.getSelectionMask(copy=False)
- if len(mask):
+ if mask is not None:
self.plot.addScatter(self._data_scatter.getXData(),
self._data_scatter.getYData(),
mask,
@@ -226,8 +236,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
self._mask_scatter = self.plot._getItem(kind="scatter",
legend=self._maskName)
self._mask_scatter.setSymbolSize(
- self._data_scatter.getSymbolSize() * 4.0
- )
+ self._data_scatter.getSymbolSize() + 2.0)
elif self.plot._getItem(kind="scatter",
legend=self._maskName) is not None:
self.plot.remove(self._maskName, kind='scatter')
@@ -248,7 +257,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
if not self.browseAction.isChecked():
self.browseAction.trigger() # Disable drawing tool
- if len(self.getSelectionMask(copy=False)):
+ if self.getSelectionMask(copy=False) is not None:
self.plot.sigActiveScatterChanged.connect(
self._activeScatterChangedAfterCare)
@@ -265,6 +274,9 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
# No active scatter or active scatter is the mask...
self.plot.sigActiveScatterChanged.disconnect(
self._activeScatterChangedAfterCare)
+ self._data_extent = None
+ self._data_scatter = None
+
else:
colormap = activeScatter.getColormap()
self._defaultOverlayColor = rgba(cursorColorForColormap(colormap['name']))
@@ -274,13 +286,22 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
self._z = activeScatter.getZValue() + 1
self._data_scatter = activeScatter
- if self._data_scatter.getXData(copy=False).shape != self.getSelectionMask(copy=False).shape:
+
+ # Adjust brush size to data range
+ xMin, xMax = min_max(self._data_scatter.getXData(copy=False))
+ yMin, yMax = min_max(self._data_scatter.getYData(copy=False))
+ self._data_extent = max(xMax - xMin, yMax - yMin)
+
+ if self._data_scatter.getXData(copy=False).shape != self._mask.getMask(copy=False).shape:
# scatter has not the same size, remove mask and stop listening
if self.plot._getItem(kind="scatter", legend=self._maskName):
self.plot.remove(self._maskName, kind='scatter')
self.plot.sigActiveScatterChanged.disconnect(
self._activeScatterChangedAfterCare)
+ self._data_extent = None
+ self._data_scatter = None
+
else:
# Refresh in case z changed
self._mask.setDataItem(self._data_scatter)
@@ -295,6 +316,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
self.setEnabled(False)
self._data_scatter = None
+ self._data_extent = None
self._mask.reset()
self._mask.commit()
@@ -309,8 +331,19 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
self._z = activeScatter.getZValue() + 1
self._data_scatter = activeScatter
+
+ # Adjust brush size to data range
+ xData = self._data_scatter.getXData(copy=False)
+ yData = self._data_scatter.getYData(copy=False)
+ if xData.size > 0 and yData.size > 0:
+ xMin, xMax = min_max(xData)
+ yMin, yMax = min_max(yData)
+ self._data_extent = max(xMax - xMin, yMax - yMin)
+ else:
+ self._data_extent = None
+
self._mask.setDataItem(self._data_scatter)
- if self._data_scatter.getXData(copy=False).shape != self.getSelectionMask(copy=False).shape:
+ if self._data_scatter.getXData(copy=False).shape != self._mask.getMask(copy=False).shape:
self._mask.reset(self._data_scatter.getXData(copy=False).shape)
self._mask.commit()
else:
@@ -439,6 +472,16 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
shape=self._data_scatter.getXData(copy=False).shape)
self._mask.commit()
+ def _getPencilWidth(self):
+ """Returns the width of the pencil to use in data coordinates`
+
+ :rtype: float
+ """
+ width = super(ScatterMaskToolsWidget, self)._getPencilWidth()
+ if self._data_extent is not None:
+ width *= 0.01 * self._data_extent
+ return width
+
def _plotDrawEvent(self, event):
"""Handle draw events from the plot"""
if (self._drawingMode is None or
@@ -467,7 +510,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
event['event'] == 'drawingFinished'):
doMask = self._isMasking()
vertices = event['points']
- vertices = vertices.astype(numpy.int)[:, (1, 0)] # (y, x)
+ vertices = vertices[:, (1, 0)] # (y, x)
self._mask.updatePolygon(level, vertices, doMask)
self._mask.commit()
@@ -475,7 +518,8 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
doMask = self._isMasking()
# convert from plot to array coords
x, y = event['points'][-1]
- brushSize = self.pencilSpinBox.value()
+
+ brushSize = self._getPencilWidth()
if self._lastPencilPos != (y, x):
if self._lastPencilPos is not None:
diff --git a/silx/gui/plot/ScatterView.py b/silx/gui/plot/ScatterView.py
new file mode 100644
index 0000000..f830cb3
--- /dev/null
+++ b/silx/gui/plot/ScatterView.py
@@ -0,0 +1,353 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""A widget dedicated to display scatter plots
+
+It is based on a :class:`~silx.gui.plot.PlotWidget` with additional tools
+for scatter plots.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "14/06/2018"
+
+
+import logging
+import weakref
+
+import numpy
+
+from . import items
+from . import PlotWidget
+from . import tools
+from .tools.profile import ScatterProfileToolBar
+from .ColorBar import ColorBarWidget
+from .ScatterMaskToolsWidget import ScatterMaskToolsWidget
+
+from ..widgets.BoxLayoutDockWidget import BoxLayoutDockWidget
+from .. import qt, icons
+
+
+_logger = logging.getLogger(__name__)
+
+
+class ScatterView(qt.QMainWindow):
+ """Main window with a PlotWidget and tools specific for scatter plots.
+
+ :param parent: The parent of this widget
+ :param backend: The backend to use for the plot (default: matplotlib).
+ See :class:`~silx.gui.plot.PlotWidget` for the list of supported backend.
+ :type backend: Union[str,~silx.gui.plot.backends.BackendBase.BackendBase]
+ """
+
+ _SCATTER_LEGEND = ' '
+ """Legend used for the scatter item"""
+
+ def __init__(self, parent=None, backend=None):
+ super(ScatterView, self).__init__(parent=parent)
+ if parent is not None:
+ # behave as a widget
+ self.setWindowFlags(qt.Qt.Widget)
+ else:
+ self.setWindowTitle('ScatterView')
+
+ # Create plot widget
+ plot = PlotWidget(parent=self, backend=backend)
+ self._plot = weakref.ref(plot)
+
+ # Add an empty scatter
+ plot.addScatter(x=(), y=(), value=(), legend=self._SCATTER_LEGEND)
+
+ # Create colorbar widget with white background
+ self._colorbar = ColorBarWidget(parent=self, plot=plot)
+ self._colorbar.setAutoFillBackground(True)
+ palette = self._colorbar.palette()
+ palette.setColor(qt.QPalette.Background, qt.Qt.white)
+ palette.setColor(qt.QPalette.Window, qt.Qt.white)
+ self._colorbar.setPalette(palette)
+
+ # Create PositionInfo widget
+ self.__lastPickingPos = None
+ self.__pickingCache = None
+ self._positionInfo = tools.PositionInfo(
+ plot=plot,
+ converters=(('X', lambda x, y: x),
+ ('Y', lambda x, y: y),
+ ('Data', lambda x, y: self._getScatterValue(x, y)),
+ ('Index', lambda x, y: self._getScatterIndex(x, y))))
+
+ # Combine plot, position info and colorbar into central widget
+ gridLayout = qt.QGridLayout()
+ gridLayout.setSpacing(0)
+ gridLayout.setContentsMargins(0, 0, 0, 0)
+ gridLayout.addWidget(plot, 0, 0)
+ gridLayout.addWidget(self._colorbar, 0, 1)
+ gridLayout.addWidget(self._positionInfo, 1, 0, 1, -1)
+ gridLayout.setRowStretch(0, 1)
+ gridLayout.setColumnStretch(0, 1)
+ centralWidget = qt.QWidget(self)
+ centralWidget.setLayout(gridLayout)
+ self.setCentralWidget(centralWidget)
+
+ # Create mask tool dock widget
+ self._maskToolsWidget = ScatterMaskToolsWidget(parent=self, plot=plot)
+ self._maskDock = BoxLayoutDockWidget()
+ self._maskDock.setWindowTitle('Scatter Mask')
+ self._maskDock.setWidget(self._maskToolsWidget)
+ self._maskDock.setVisible(False)
+ self.addDockWidget(qt.Qt.BottomDockWidgetArea, self._maskDock)
+
+ self._maskAction = self._maskDock.toggleViewAction()
+ self._maskAction.setIcon(icons.getQIcon('image-mask'))
+ self._maskAction.setToolTip("Display/hide mask tools")
+
+ # Create toolbars
+ self._interactiveModeToolBar = tools.InteractiveModeToolBar(
+ parent=self, plot=plot)
+
+ self._scatterToolBar = tools.ScatterToolBar(
+ parent=self, plot=plot)
+ self._scatterToolBar.addAction(self._maskAction)
+
+ self._profileToolBar = ScatterProfileToolBar(parent=self, plot=plot)
+
+ self._outputToolBar = tools.OutputToolBar(parent=self, plot=plot)
+
+ # Activate shortcuts in PlotWindow widget:
+ for toolbar in (self._interactiveModeToolBar,
+ self._scatterToolBar,
+ self._profileToolBar,
+ self._outputToolBar):
+ self.addToolBar(toolbar)
+ for action in toolbar.actions():
+ self.addAction(action)
+
+ def _pickScatterData(self, x, y):
+ """Get data and index and value of top most scatter plot at position (x, y)
+
+ :param float x: X position in plot coordinates
+ :param float y: Y position in plot coordinates
+ :return: The data index and value at that point or None
+ """
+ pickingPos = x, y
+ if self.__lastPickingPos != pickingPos:
+ self.__pickingCache = None
+ self.__lastPickingPos = pickingPos
+
+ plot = self.getPlotWidget()
+ if plot is not None:
+ pixelPos = plot.dataToPixel(x, y)
+ if pixelPos is not None:
+ # Start from top-most item
+ for item, indices in reversed(plot._pick(*pixelPos)):
+ if isinstance(item, items.Scatter):
+ # Get last index
+ # with matplotlib it should be the top-most point
+ dataIndex = indices[-1]
+ self.__pickingCache = (
+ dataIndex,
+ item.getValueData(copy=False)[dataIndex])
+ break
+
+ return self.__pickingCache
+
+ def _getScatterValue(self, x, y):
+ """Get data value of top most scatter plot at position (x, y)
+
+ :param float x: X position in plot coordinates
+ :param float y: Y position in plot coordinates
+ :return: The data value at that point or '-'
+ """
+ picking = self._pickScatterData(x, y)
+ return '-' if picking is None else picking[1]
+
+ def _getScatterIndex(self, x, y):
+ """Get data index of top most scatter plot at position (x, y)
+
+ :param float x: X position in plot coordinates
+ :param float y: Y position in plot coordinates
+ :return: The data index at that point or '-'
+ """
+ picking = self._pickScatterData(x, y)
+ return '-' if picking is None else picking[0]
+
+ _PICK_OFFSET = 3 # Offset in pixel used for picking
+
+ def _mouseInPlotArea(self, x, y):
+ """Clip mouse coordinates to plot area coordinates
+
+ :param float x: X position in pixels
+ :param float y: Y position in pixels
+ :return: (x, y) in data coordinates
+ """
+ plot = self.getPlotWidget()
+ left, top, width, height = plot.getPlotBoundsInPixels()
+ xPlot = numpy.clip(x, left, left + width - 1)
+ yPlot = numpy.clip(y, top, top + height - 1)
+ return xPlot, yPlot
+
+ def getPlotWidget(self):
+ """Returns the :class:`~silx.gui.plot.PlotWidget` this window is based on.
+
+ :rtype: ~silx.gui.plot.PlotWidget
+ """
+ return self._plot()
+
+ def getPositionInfoWidget(self):
+ """Returns the widget display mouse coordinates information.
+
+ :rtype: ~silx.gui.plot.tools.PositionInfo
+ """
+ return self._positionInfo
+
+ def getMaskToolsWidget(self):
+ """Returns the widget controlling mask drawing
+
+ :rtype: ~silx.gui.plot.ScatterMaskToolsWidget
+ """
+ return self._maskToolsWidget
+
+ def getInteractiveModeToolBar(self):
+ """Returns QToolBar controlling interactive mode.
+
+ :rtype: ~silx.gui.plot.tools.InteractiveModeToolBar
+ """
+ return self._interactiveModeToolBar
+
+ def getScatterToolBar(self):
+ """Returns QToolBar providing scatter plot tools.
+
+ :rtype: ~silx.gui.plot.tools.ScatterToolBar
+ """
+ return self._scatterToolBar
+
+ def getScatterProfileToolBar(self):
+ """Returns QToolBar providing scatter profile tools.
+
+ :rtype: ~silx.gui.plot.tools.profile.ScatterProfileToolBar
+ """
+ return self._profileToolBar
+
+ def getOutputToolBar(self):
+ """Returns QToolBar containing save, copy and print actions
+
+ :rtype: ~silx.gui.plot.tools.OutputToolBar
+ """
+ return self._outputToolBar
+
+ def setColormap(self, colormap=None):
+ """Set the colormap for the displayed scatter and the
+ default plot colormap.
+
+ :param ~silx.gui.colors.Colormap colormap:
+ The description of the colormap.
+ """
+ self.getScatterItem().setColormap(colormap)
+ # Resilient to call to PlotWidget API (e.g., clear)
+ self.getPlotWidget().setDefaultColormap(colormap)
+
+ def getColormap(self):
+ """Return the :class:`.Colormap` in use.
+
+ :return: Colormap currently in use
+ :rtype: ~silx.gui.colors.Colormap
+ """
+ self.getScatterItem().getColormap()
+
+ # Control displayed scatter plot
+
+ def setData(self, x, y, value, xerror=None, yerror=None, copy=True):
+ """Set the data of the scatter plot.
+
+ To reset the scatter plot, set x, y and value to None.
+
+ :param Union[numpy.ndarray,None] x: X coordinates.
+ :param Union[numpy.ndarray,None] y: Y coordinates.
+ :param Union[numpy.ndarray,None] value:
+ The data corresponding to the value of the data points.
+ :param xerror: Values with the uncertainties on the x values.
+ If it is an array, it can either be a 1D array of
+ same length as the data or a 2D array with 2 rows
+ of same length as the data: row 0 for positive errors,
+ row 1 for negative errors.
+ :type xerror: A float, or a numpy.ndarray of float32.
+
+ :param yerror: Values with the uncertainties on the y values
+ :type yerror: A float, or a numpy.ndarray of float32. See xerror.
+ :param bool copy: True make a copy of the data (default),
+ False to use provided arrays.
+ """
+ x = () if x is None else x
+ y = () if y is None else y
+ value = () if value is None else value
+
+ self.getScatterItem().setData(
+ x=x, y=y, value=value, xerror=xerror, yerror=yerror, copy=copy)
+
+ def getData(self, *args, **kwargs):
+ return self.getScatterItem().getData(*args, **kwargs)
+
+ getData.__doc__ = items.Scatter.getData.__doc__
+
+ def getScatterItem(self):
+ """Returns the plot item displaying the scatter data.
+
+ This allows to set the style of the displayed scatter.
+
+ :rtype: ~silx.gui.plot.items.Scatter
+ """
+ plot = self.getPlotWidget()
+ scatter = plot._getItem(kind='scatter', legend=self._SCATTER_LEGEND)
+ if scatter is None: # Resilient to call to PlotWidget API (e.g., clear)
+ plot.addScatter(x=(), y=(), value=(), legend=self._SCATTER_LEGEND)
+ scatter = plot._getItem(
+ kind='scatter', legend=self._SCATTER_LEGEND)
+ return scatter
+
+ # Convenient proxies
+
+ def getXAxis(self, *args, **kwargs):
+ return self.getPlotWidget().getXAxis(*args, **kwargs)
+
+ getXAxis.__doc__ = PlotWidget.getXAxis.__doc__
+
+ def getYAxis(self, *args, **kwargs):
+ return self.getPlotWidget().getYAxis(*args, **kwargs)
+
+ getYAxis.__doc__ = PlotWidget.getYAxis.__doc__
+
+ def setGraphTitle(self, *args, **kwargs):
+ return self.getPlotWidget().setGraphTitle(*args, **kwargs)
+
+ setGraphTitle.__doc__ = PlotWidget.setGraphTitle.__doc__
+
+ def getGraphTitle(self, *args, **kwargs):
+ return self.getPlotWidget().getGraphTitle(*args, **kwargs)
+
+ getGraphTitle.__doc__ = PlotWidget.getGraphTitle.__doc__
+
+ def resetZoom(self, *args, **kwargs):
+ return self.getPlotWidget().resetZoom(*args, **kwargs)
+
+ resetZoom.__doc__ = PlotWidget.resetZoom.__doc__
diff --git a/silx/gui/plot/StackView.py b/silx/gui/plot/StackView.py
index 1fb188c..d1e8e3c 100644
--- a/silx/gui/plot/StackView.py
+++ b/silx/gui/plot/StackView.py
@@ -69,16 +69,18 @@ Example::
__authors__ = ["P. Knobel", "H. Payno"]
__license__ = "MIT"
-__date__ = "15/02/2018"
+__date__ = "26/04/2018"
import numpy
+import logging
+import silx
from silx.gui import qt
from .. import icons
from . import items, PlotWindow, actions
-from .Colormap import Colormap
-from .Colors import cursorColorForColormap
-from .PlotTools import LimitsToolBar
+from ..colors import Colormap
+from ..colors import cursorColorForColormap
+from .tools import LimitsToolBar
from .Profile import Profile3DToolBar
from ..widgets.FrameBrowser import HorizontalSliderWithBrowser
@@ -96,6 +98,8 @@ except ImportError:
else:
from silx.io.utils import is_dataset
+_logger = logging.getLogger(__name__)
+
class StackView(qt.QMainWindow):
"""Stack view widget, to display and browse through stack of
@@ -156,6 +160,12 @@ class StackView(qt.QMainWindow):
integer.
"""
+ sigFrameChanged = qt.Signal(int)
+ """Signal emitter when the frame number has changed.
+
+ This signal provides the current frame number.
+ """
+
def __init__(self, parent=None, resetzoom=True, backend=None,
autoScale=False, logScale=False, grid=False,
colormap=True, aspectRatio=True, yinverted=True,
@@ -206,6 +216,9 @@ class StackView(qt.QMainWindow):
self.sigActiveImageChanged = self._plot.sigActiveImageChanged
self.sigPlotSignal = self._plot.sigPlotSignal
+ if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward':
+ self._plot.getYAxis().setInverted(True)
+
self._addColorBarAction()
self._plot.profile = Profile3DToolBar(parent=self._plot,
@@ -221,6 +234,7 @@ class StackView(qt.QMainWindow):
self._browser_label = qt.QLabel("Image index (Dim0):")
self._browser = HorizontalSliderWithBrowser(central_widget)
+ self._browser.setRange(0, 0)
self._browser.valueChanged[int].connect(self.__updateFrameNumber)
self._browser.setEnabled(False)
@@ -313,7 +327,7 @@ class StackView(qt.QMainWindow):
assert self._stack is not None
assert 0 <= self._perspective < 3
- # ensure we have the stack encapsulated in an array like object
+ # ensure we have the stack encapsulated in an array-like object
# having a transpose() method
if isinstance(self._stack, numpy.ndarray):
self.__transposed_view = self._stack
@@ -324,7 +338,7 @@ class StackView(qt.QMainWindow):
elif isinstance(self._stack, ListOfImages):
self.__transposed_view = ListOfImages(self._stack)
- # transpose the array like object if necessary
+ # transpose the array-like object if necessary
if self._perspective == 1:
self.__transposed_view = self.__transposed_view.transpose((1, 0, 2))
elif self._perspective == 2:
@@ -338,13 +352,16 @@ class StackView(qt.QMainWindow):
:param index: index of the frame to be displayed
"""
- assert self.__transposed_view is not None
+ if self.__transposed_view is None:
+ # no data set
+ return
self._plot.addImage(self.__transposed_view[index, :, :],
origin=self._getImageOrigin(),
scale=self._getImageScale(),
legend=self.__imageLegend,
- resetzoom=False, replace=False)
+ resetzoom=False)
self._updateTitle()
+ self.sigFrameChanged.emit(index)
def _set3DScaleAndOrigin(self, calibrations):
"""Set scale and origin for all 3 axes, to be used when plotting
@@ -358,7 +375,7 @@ class StackView(qt.QMainWindow):
calibration.NoCalibration())
else:
self.calibrations3D = []
- for calib in calibrations:
+ for i, calib in enumerate(calibrations):
if hasattr(calib, "__len__") and len(calib) == 2:
calib = calibration.LinearCalibration(calib[0], calib[1])
elif calib is None:
@@ -367,9 +384,19 @@ class StackView(qt.QMainWindow):
raise TypeError("calibration must be a 2-tuple, None or" +
" an instance of an AbstractCalibration " +
"subclass")
+ elif not calib.is_affine():
+ _logger.warning(
+ "Calibration for dimension %d is not linear, "
+ "it will be ignored for scaling the graph axes.",
+ i)
self.calibrations3D.append(calib)
def _getXYZCalibs(self):
+ """Return calibrations sorted in the XYZ graph order.
+
+ If the X or Y calibration is not linear, it will be replaced
+ with a :class:`calibration.NoCalibration` object
+ and as a result the corresponding axis will not be scaled."""
xy_dims = [0, 1, 2]
xy_dims.remove(self._perspective)
@@ -377,6 +404,12 @@ class StackView(qt.QMainWindow):
ycalib = self.calibrations3D[min(xy_dims)]
zcalib = self.calibrations3D[self._perspective]
+ # filter out non-linear calibration for graph axes
+ if not xcalib.is_affine():
+ xcalib = calibration.NoCalibration()
+ if not ycalib.is_affine():
+ ycalib = calibration.NoCalibration()
+
return xcalib, ycalib, zcalib
def _getImageScale(self):
@@ -469,6 +502,7 @@ class StackView(qt.QMainWindow):
colormap=self.getColormap(),
origin=self._getImageOrigin(),
scale=self._getImageScale(),
+ replace=True,
resetzoom=False)
self._plot.setActiveImage(self.__imageLegend)
self._plot.setGraphTitle("Image z=%g" % self._getImageZ(0))
@@ -586,6 +620,14 @@ class StackView(qt.QMainWindow):
"""
self._browser.setValue(number)
+ def getFrameNumber(self):
+ """Set the frame selection to a specific value
+
+ :return: Index of currently displayed frame
+ :rtype: int
+ """
+ return self._browser.value()
+
def setFirstStackDimension(self, first_stack_dimension):
"""When viewing the last 3 dimensions of an n-D array (n>3), you can
use this method to change the text in the combobox.
@@ -641,6 +683,8 @@ class StackView(qt.QMainWindow):
self.__transposed_view = None
self._perspective = 0
self._browser.setEnabled(False)
+ # reset browser range
+ self._browser.setRange(0, 0)
self._plot.clear()
def setLabels(self, labels=None):
@@ -1101,17 +1145,17 @@ class StackViewMainWindow(StackView):
self.statusBar()
menu = self.menuBar().addMenu('File')
- menu.addAction(self._plot.saveAction)
- menu.addAction(self._plot.printAction)
+ menu.addAction(self._plot.getOutputToolBar().getSaveAction())
+ menu.addAction(self._plot.getOutputToolBar().getPrintAction())
menu.addSeparator()
action = menu.addAction('Quit')
action.triggered[bool].connect(qt.QApplication.instance().quit)
menu = self.menuBar().addMenu('Edit')
- menu.addAction(self._plot.copyAction)
+ menu.addAction(self._plot.getOutputToolBar().getCopyAction())
menu.addSeparator()
- menu.addAction(self._plot.resetZoomAction)
- menu.addAction(self._plot.colormapAction)
+ menu.addAction(self._plot.getResetZoomAction())
+ menu.addAction(self._plot.getColormapAction())
menu.addAction(self.getColorBarAction())
menu.addAction(actions.control.KeepAspectRatioAction(self._plot, self))
diff --git a/silx/gui/plot/StatsWidget.py b/silx/gui/plot/StatsWidget.py
new file mode 100644
index 0000000..a36dd9f
--- /dev/null
+++ b/silx/gui/plot/StatsWidget.py
@@ -0,0 +1,572 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""
+Module containing widgets displaying stats from items of a plot.
+"""
+
+__authors__ = ["H. Payno"]
+__license__ = "MIT"
+__date__ = "12/06/2018"
+
+
+import functools
+import logging
+import numpy
+from collections import OrderedDict
+
+import silx.utils.weakref
+from silx.gui import qt
+from silx.gui import icons
+from silx.gui.plot.items.curve import Curve as CurveItem
+from silx.gui.plot.items.histogram import Histogram as HistogramItem
+from silx.gui.plot.items.image import ImageBase as ImageItem
+from silx.gui.plot.items.scatter import Scatter as ScatterItem
+from silx.gui.plot import stats as statsmdl
+from silx.gui.widgets.TableWidget import TableWidget
+from silx.gui.plot.stats.statshandler import StatsHandler, StatFormatter
+
+logger = logging.getLogger(__name__)
+
+
+class StatsWidget(qt.QWidget):
+ """
+ Widget displaying a set of :class:`Stat` to be displayed on a
+ :class:`StatsTable` and to be apply on items contained in the :class:`Plot`
+ Also contains options to:
+
+ * compute statistics on all the data or on visible data only
+ * show statistics of all items or only the active one
+
+ :param parent: Qt parent
+ :param plot: the plot containing items on which we want statistics.
+ """
+
+ NUMBER_FORMAT = '{0:.3f}'
+
+ class OptionsWidget(qt.QToolBar):
+
+ def __init__(self, parent=None):
+ qt.QToolBar.__init__(self, parent)
+ self.setIconSize(qt.QSize(16, 16))
+
+ action = qt.QAction(self)
+ action.setIcon(icons.getQIcon("stats-active-items"))
+ action.setText("Active items only")
+ action.setToolTip("Display stats for active items only.")
+ action.setCheckable(True)
+ action.setChecked(True)
+ self.__displayActiveItems = action
+
+ action = qt.QAction(self)
+ action.setIcon(icons.getQIcon("stats-whole-items"))
+ action.setText("All items")
+ action.setToolTip("Display stats for all available items.")
+ action.setCheckable(True)
+ self.__displayWholeItems = action
+
+ action = qt.QAction(self)
+ action.setIcon(icons.getQIcon("stats-visible-data"))
+ action.setText("Use the visible data range")
+ action.setToolTip("Use the visible data range.<br/>"
+ "If activated the data is filtered to only use"
+ "visible data of the plot."
+ "The filtering is a data sub-sampling."
+ "No interpolation is made to fit data to"
+ "boundaries.")
+ action.setCheckable(True)
+ self.__useVisibleData = action
+
+ action = qt.QAction(self)
+ action.setIcon(icons.getQIcon("stats-whole-data"))
+ action.setText("Use the full data range")
+ action.setToolTip("Use the full data range.")
+ action.setCheckable(True)
+ action.setChecked(True)
+ self.__useWholeData = action
+
+ self.addAction(self.__displayWholeItems)
+ self.addAction(self.__displayActiveItems)
+ self.addSeparator()
+ self.addAction(self.__useVisibleData)
+ self.addAction(self.__useWholeData)
+
+ self.itemSelection = qt.QActionGroup(self)
+ self.itemSelection.setExclusive(True)
+ self.itemSelection.addAction(self.__displayActiveItems)
+ self.itemSelection.addAction(self.__displayWholeItems)
+
+ self.dataRangeSelection = qt.QActionGroup(self)
+ self.dataRangeSelection.setExclusive(True)
+ self.dataRangeSelection.addAction(self.__useWholeData)
+ self.dataRangeSelection.addAction(self.__useVisibleData)
+
+ def isActiveItemMode(self):
+ return self.itemSelection.checkedAction() is self.__displayActiveItems
+
+ def isVisibleDataRangeMode(self):
+ return self.dataRangeSelection.checkedAction() is self.__useVisibleData
+
+ def __init__(self, parent=None, plot=None, stats=None):
+ qt.QWidget.__init__(self, parent)
+ self.setLayout(qt.QVBoxLayout())
+ self.layout().setContentsMargins(0, 0, 0, 0)
+ self._options = self.OptionsWidget(parent=self)
+ self.layout().addWidget(self._options)
+ self._statsTable = StatsTable(parent=self, plot=plot)
+ self.setStats = self._statsTable.setStats
+ self.setStats(stats)
+
+ self.layout().addWidget(self._statsTable)
+ self.setPlot = self._statsTable.setPlot
+
+ self._options.itemSelection.triggered.connect(
+ self._optSelectionChanged)
+ self._options.dataRangeSelection.triggered.connect(
+ self._optDataRangeChanged)
+ self._optSelectionChanged()
+ self._optDataRangeChanged()
+
+ self.setDisplayOnlyActiveItem = self._statsTable.setDisplayOnlyActiveItem
+ self.setStatsOnVisibleData = self._statsTable.setStatsOnVisibleData
+
+ def _optSelectionChanged(self, action=None):
+ self._statsTable.setDisplayOnlyActiveItem(self._options.isActiveItemMode())
+
+ def _optDataRangeChanged(self, action=None):
+ self._statsTable.setStatsOnVisibleData(self._options.isVisibleDataRangeMode())
+
+
+class BasicStatsWidget(StatsWidget):
+ """
+ Widget defining a simple set of :class:`Stat` to be displayed on a
+ :class:`StatsWidget`.
+
+ :param parent: Qt parent
+ :param plot: the plot containing items on which we want statistics.
+ """
+
+ STATS = StatsHandler((
+ (statsmdl.StatMin(), StatFormatter()),
+ statsmdl.StatCoordMin(),
+ (statsmdl.StatMax(), StatFormatter()),
+ statsmdl.StatCoordMax(),
+ (('std', numpy.std), StatFormatter()),
+ (('mean', numpy.mean), StatFormatter()),
+ statsmdl.StatCOM()
+ ))
+
+ def __init__(self, parent=None, plot=None):
+ StatsWidget.__init__(self, parent=parent, plot=plot, stats=self.STATS)
+
+
+class StatsTable(TableWidget):
+ """
+ TableWidget displaying for each curves contained by the Plot some
+ information:
+
+ * legend
+ * minimal value
+ * maximal value
+ * standard deviation (std)
+
+ :param parent: The widget's parent.
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ """
+
+ COMPATIBLE_KINDS = {
+ 'curve': CurveItem,
+ 'image': ImageItem,
+ 'scatter': ScatterItem,
+ 'histogram': HistogramItem
+ }
+
+ COMPATIBLE_ITEMS = tuple(COMPATIBLE_KINDS.values())
+
+ def __init__(self, parent=None, plot=None):
+ TableWidget.__init__(self, parent)
+ """Next freeID for the curve"""
+ self.plot = None
+ self._displayOnlyActItem = False
+ self._statsOnVisibleData = False
+ self._lgdAndKindToItems = {}
+ """Associate to a tuple(legend, kind) the items legend"""
+ self.callbackImage = None
+ self.callbackScatter = None
+ self.callbackCurve = None
+ """Associate the curve legend to his first item"""
+ self._statsHandler = None
+ self._legendsSet = []
+ """list of legends actually displayed"""
+ self._resetColumns()
+
+ self.setColumnCount(len(self._columns))
+ self.setSelectionBehavior(qt.QAbstractItemView.SelectRows)
+ self.setPlot(plot)
+ self.setSortingEnabled(True)
+
+ def _resetColumns(self):
+ self._columns_index = OrderedDict([('legend', 0), ('kind', 1)])
+ self._columns = self._columns_index.keys()
+ self.setColumnCount(len(self._columns))
+
+ def setStats(self, statsHandler):
+ """
+
+ :param statsHandler: Set the statistics to be displayed and how to
+ format them using
+ :rtype: :class:`StatsHandler`
+ """
+ _statsHandler = statsHandler
+ if statsHandler is None:
+ _statsHandler = StatsHandler(statFormatters=())
+ if isinstance(_statsHandler, (list, tuple)):
+ _statsHandler = StatsHandler(_statsHandler)
+ assert isinstance(_statsHandler, StatsHandler)
+ self._resetColumns()
+ self.clear()
+
+ for statName, stat in list(_statsHandler.stats.items()):
+ assert isinstance(stat, statsmdl.StatBase)
+ self._columns_index[statName] = len(self._columns_index)
+ self._statsHandler = _statsHandler
+ self._columns = self._columns_index.keys()
+ self.setColumnCount(len(self._columns))
+
+ self._updateItemObserve()
+ self._updateAllStats()
+
+ def getStatsHandler(self):
+ return self._statsHandler
+
+ def _updateAllStats(self):
+ for (legend, kind) in self._lgdAndKindToItems:
+ self._updateStats(legend, kind)
+
+ @staticmethod
+ def _getKind(myItem):
+ if isinstance(myItem, CurveItem):
+ return 'curve'
+ elif isinstance(myItem, ImageItem):
+ return 'image'
+ elif isinstance(myItem, ScatterItem):
+ return 'scatter'
+ elif isinstance(myItem, HistogramItem):
+ return 'histogram'
+ else:
+ return None
+
+ def setPlot(self, plot):
+ """
+ Define the plot to interact with
+
+ :param plot: the plot containing the items on which statistics are
+ applied
+ :rtype: :class:`.PlotWidget`
+ """
+ if self.plot:
+ self._dealWithPlotConnection(create=False)
+ self.plot = plot
+ self.clear()
+ if self.plot:
+ self._dealWithPlotConnection(create=True)
+ self._updateItemObserve()
+
+ def _updateItemObserve(self):
+ if self.plot:
+ self.clear()
+ if self._displayOnlyActItem is True:
+ activeCurve = self.plot.getActiveCurve(just_legend=False)
+ activeScatter = self.plot._getActiveItem(kind='scatter',
+ just_legend=False)
+ activeImage = self.plot.getActiveImage(just_legend=False)
+ if activeCurve:
+ self._addItem(activeCurve)
+ if activeImage:
+ self._addItem(activeImage)
+ if activeScatter:
+ self._addItem(activeScatter)
+ else:
+ [self._addItem(curve) for curve in self.plot.getAllCurves()]
+ [self._addItem(image) for image in self.plot.getAllImages()]
+ scatters = self.plot._getItems(kind='scatter',
+ just_legend=False,
+ withhidden=True)
+ [self._addItem(scatter) for scatter in scatters]
+ histograms = self.plot._getItems(kind='histogram',
+ just_legend=False,
+ withhidden=True)
+ [self._addItem(histogram) for histogram in histograms]
+
+ def _dealWithPlotConnection(self, create=True):
+ """
+ Manage connection to plot signals
+
+ Note: connection on Item are managed by the _removeItem function
+ """
+ if self.plot is None:
+ return
+ if self._displayOnlyActItem:
+ if create is True:
+ if self.callbackImage is None:
+ self.callbackImage = functools.partial(self._activeItemChanged, 'image')
+ self.callbackScatter = functools.partial(self._activeItemChanged, 'scatter')
+ self.callbackCurve = functools.partial(self._activeItemChanged, 'curve')
+ self.plot.sigActiveImageChanged.connect(self.callbackImage)
+ self.plot.sigActiveScatterChanged.connect(self.callbackScatter)
+ self.plot.sigActiveCurveChanged.connect(self.callbackCurve)
+ else:
+ if self.callbackImage is not None:
+ self.plot.sigActiveImageChanged.disconnect(self.callbackImage)
+ self.plot.sigActiveScatterChanged.disconnect(self.callbackScatter)
+ self.plot.sigActiveCurveChanged.disconnect(self.callbackCurve)
+ self.callbackImage = None
+ self.callbackScatter = None
+ self.callbackCurve = None
+ else:
+ if create is True:
+ self.plot.sigContentChanged.connect(self._plotContentChanged)
+ else:
+ self.plot.sigContentChanged.disconnect(self._plotContentChanged)
+ if create is True:
+ self.plot.sigPlotSignal.connect(self._zoomPlotChanged)
+ else:
+ self.plot.sigPlotSignal.disconnect(self._zoomPlotChanged)
+
+ def clear(self):
+ """
+ Clear all existing items
+ """
+ lgdsAndKinds = list(self._lgdAndKindToItems.keys())
+ for lgdAndKind in lgdsAndKinds:
+ self._removeItem(legend=lgdAndKind[0], kind=lgdAndKind[1])
+ self._lgdAndKindToItems = {}
+ qt.QTableWidget.clear(self)
+ self.setRowCount(0)
+
+ # It have to called befor3e accessing to the header items
+ self.setHorizontalHeaderLabels(self._columns)
+
+ if self._statsHandler is not None:
+ for columnId, name in enumerate(self._columns):
+ item = self.horizontalHeaderItem(columnId)
+ if name in self._statsHandler.stats:
+ stat = self._statsHandler.stats[name]
+ text = stat.name[0].upper() + stat.name[1:]
+ if stat.description is not None:
+ tooltip = stat.description
+ else:
+ tooltip = ""
+ else:
+ text = name[0].upper() + name[1:]
+ tooltip = ""
+ item.setToolTip(tooltip)
+ item.setText(text)
+
+ if hasattr(self.horizontalHeader(), 'setSectionResizeMode'): # Qt5
+ self.horizontalHeader().setSectionResizeMode(qt.QHeaderView.ResizeToContents)
+ else: # Qt4
+ self.horizontalHeader().setResizeMode(qt.QHeaderView.ResizeToContents)
+ self.setColumnHidden(self._columns_index['kind'], True)
+
+ def _addItem(self, item):
+ assert isinstance(item, self.COMPATIBLE_ITEMS)
+ if (item.getLegend(), self._getKind(item)) in self._lgdAndKindToItems:
+ self._updateStats(item.getLegend(), self._getKind(item))
+ return
+
+ self.setRowCount(self.rowCount() + 1)
+ indexTable = self.rowCount() - 1
+ kind = self._getKind(item)
+
+ self._lgdAndKindToItems[(item.getLegend(), kind)] = {}
+
+ # the get item will manage the item creation of not existing
+ _createItem = self._getItem
+ for itemName in self._columns:
+ _createItem(name=itemName, legend=item.getLegend(), kind=kind,
+ indexTable=indexTable)
+
+ self._updateStats(legend=item.getLegend(), kind=kind)
+
+ callback = functools.partial(
+ silx.utils.weakref.WeakMethodProxy(self._updateStats),
+ item.getLegend(), kind)
+ item.sigItemChanged.connect(callback)
+ self.setColumnHidden(self._columns_index['kind'],
+ item.getLegend() not in self._legendsSet)
+ self._legendsSet.append(item.getLegend())
+
+ def _getItem(self, name, legend, kind, indexTable):
+ if (legend, kind) not in self._lgdAndKindToItems:
+ self._lgdAndKindToItems[(legend, kind)] = {}
+ if not (name in self._lgdAndKindToItems[(legend, kind)] and
+ self._lgdAndKindToItems[(legend, kind)]):
+ if name in ('legend', 'kind'):
+ _item = qt.QTableWidgetItem(type=qt.QTableWidgetItem.Type)
+ if name == 'legend':
+ _item.setText(legend)
+ else:
+ assert name == 'kind'
+ _item.setText(kind)
+ else:
+ if self._statsHandler.formatters[name]:
+ _item = self._statsHandler.formatters[name].tabWidgetItemClass()
+ else:
+ _item = qt.QTableWidgetItem()
+ tooltip = self._statsHandler.stats[name].getToolTip(kind=kind)
+ if tooltip is not None:
+ _item.setToolTip(tooltip)
+
+ _item.setFlags(qt.Qt.ItemIsEnabled | qt.Qt.ItemIsSelectable)
+ self.setItem(indexTable, self._columns_index[name], _item)
+ self._lgdAndKindToItems[(legend, kind)][name] = _item
+
+ return self._lgdAndKindToItems[(legend, kind)][name]
+
+ def _removeItem(self, legend, kind):
+ if (legend, kind) not in self._lgdAndKindToItems or not self.plot:
+ return
+
+ self.firstItem = self._lgdAndKindToItems[(legend, kind)]['legend']
+ del self._lgdAndKindToItems[(legend, kind)]
+ self.removeRow(self.firstItem.row())
+ self._legendsSet.remove(legend)
+ self.setColumnHidden(self._columns_index['kind'],
+ legend not in self._legendsSet)
+
+ def _updateCurrentStats(self):
+ for lgdAndKind in self._lgdAndKindToItems:
+ self._updateStats(lgdAndKind[0], lgdAndKind[1])
+
+ def _updateStats(self, legend, kind, event=None):
+ if self._statsHandler is None:
+ return
+
+ assert kind in ('curve', 'image', 'scatter', 'histogram')
+ if kind == 'curve':
+ item = self.plot.getCurve(legend)
+ elif kind == 'image':
+ item = self.plot.getImage(legend)
+ elif kind == 'scatter':
+ item = self.plot.getScatter(legend)
+ elif kind == 'histogram':
+ item = self.plot.getHistogram(legend)
+ else:
+ raise ValueError('kind not managed')
+
+ if not item or (item.getLegend(), kind) not in self._lgdAndKindToItems:
+ return
+
+ assert isinstance(item, self.COMPATIBLE_ITEMS)
+
+ statsValDict = self._statsHandler.calculate(item, self.plot,
+ self._statsOnVisibleData)
+
+ lgdItem = self._lgdAndKindToItems[(item.getLegend(), kind)]['legend']
+ assert lgdItem
+ rowStat = lgdItem.row()
+
+ for statName, statVal in list(statsValDict.items()):
+ assert statName in self._lgdAndKindToItems[(item.getLegend(), kind)]
+ tableItem = self._getItem(name=statName, legend=item.getLegend(),
+ kind=kind, indexTable=rowStat)
+ tableItem.setText(str(statVal))
+
+ def currentChanged(self, current, previous):
+ if current.row() >= 0:
+ legendItem = self.item(current.row(), self._columns_index['legend'])
+ assert legendItem
+ kindItem = self.item(current.row(), self._columns_index['kind'])
+ kind = kindItem.text()
+ if kind == 'curve':
+ self.plot.setActiveCurve(legendItem.text())
+ elif kind == 'image':
+ self.plot.setActiveImage(legendItem.text())
+ elif kind == 'scatter':
+ self.plot._setActiveItem('scatter', legendItem.text())
+ elif kind == 'histogram':
+ # active histogram not managed by the plot actually
+ pass
+ else:
+ raise ValueError('kind not managed')
+ qt.QTableWidget.currentChanged(self, current, previous)
+
+ def setDisplayOnlyActiveItem(self, displayOnlyActItem):
+ """
+
+ :param bool displayOnlyActItem: True if we want to only show active
+ item
+ """
+ if self._displayOnlyActItem == displayOnlyActItem:
+ return
+ self._displayOnlyActItem = displayOnlyActItem
+ self._dealWithPlotConnection(create=False)
+ self._updateItemObserve()
+ self._dealWithPlotConnection(create=True)
+
+ def setStatsOnVisibleData(self, b):
+ """
+ .. warning:: When visible data is activated we will process to a simple
+ filtering of visible data by the user. The filtering is a
+ simple data sub-sampling. No interpolation is made to fit
+ data to boundaries.
+
+ :param bool b: True if we want to apply statistics only on visible data
+ """
+ if self._statsOnVisibleData != b:
+ self._statsOnVisibleData = b
+ self._updateCurrentStats()
+
+ def _activeItemChanged(self, kind):
+ """Callback used when plotting only the active item"""
+ assert kind in ('curve', 'image', 'scatter', 'histogram')
+ self._updateItemObserve()
+
+ def _plotContentChanged(self, action, kind, legend):
+ """Callback used when plotting all the plot items"""
+ if kind not in ('curve', 'image', 'scatter', 'histogram'):
+ return
+ if kind == 'curve':
+ item = self.plot.getCurve(legend)
+ elif kind == 'image':
+ item = self.plot.getImage(legend)
+ elif kind == 'scatter':
+ item = self.plot.getScatter(legend)
+ elif kind == 'histogram':
+ item = self.plot.getHistogram(legend)
+ else:
+ raise ValueError('kind not managed')
+
+ if action == 'add':
+ if item is None:
+ raise ValueError('Item from legend "%s" do not exists' % legend)
+ self._addItem(item)
+ elif action == 'remove':
+ self._removeItem(legend, kind)
+
+ def _zoomPlotChanged(self, event):
+ if self._statsOnVisibleData is True:
+ if 'event' in event and event['event'] == 'limitsChanged':
+ self._updateCurrentStats()
diff --git a/silx/gui/plot/_BaseMaskToolsWidget.py b/silx/gui/plot/_BaseMaskToolsWidget.py
index 35a48ae..da0dbf5 100644
--- a/silx/gui/plot/_BaseMaskToolsWidget.py
+++ b/silx/gui/plot/_BaseMaskToolsWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -29,16 +29,17 @@ from __future__ import division
__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "02/10/2017"
+__date__ = "24/04/2018"
import os
+import weakref
import numpy
from silx.gui import qt, icons
from silx.gui.widgets.FloatEdit import FloatEdit
-from silx.gui.plot.Colormap import Colormap
-from silx.gui.plot.Colors import rgba
+from silx.gui.colors import Colormap
+from silx.gui.colors import rgba
from .actions.mode import PanModeAction
@@ -372,7 +373,7 @@ class BaseMaskToolsWidget(qt.QWidget):
# as parent have to be the first argument of the widget to fit
# QtDesigner need but here plot can't be None by default.
assert plot is not None
- self._plot = plot
+ self._plotRef = weakref.ref(plot)
self._maskName = '__MASK_TOOLS_%d' % id(self) # Legend of the mask
self._colormap = Colormap(name="",
@@ -409,12 +410,21 @@ class BaseMaskToolsWidget(qt.QWidget):
:param bool copy: True (default) to get a copy of the mask.
If False, the returned array MUST not be modified.
- :return: The array of the mask with dimension of the 'active' plot item.
- If there is no active image or scatter, an empty array is
- returned.
- :rtype: numpy.ndarray of uint8
+ :return: The mask (as an array of uint8) with dimension of
+ the 'active' plot item.
+ If there is no active image or scatter, it returns None.
+ :rtype: Union[numpy.ndarray,None]
"""
- return self._mask.getMask(copy=copy)
+ mask = self._mask.getMask(copy=copy)
+ return None if mask.size == 0 else mask
+
+ def setSelectionMask(self, mask):
+ """Set the mask: Must be implemented in subclass"""
+ raise NotImplementedError()
+
+ def resetSelectionMask(self):
+ """Reset the mask: Must be implemented in subclass"""
+ raise NotImplementedError()
def multipleMasks(self):
"""Return the current mode of multiple masks support.
@@ -453,7 +463,11 @@ class BaseMaskToolsWidget(qt.QWidget):
@property
def plot(self):
"""The :class:`.PlotWindow` this widget is attached to."""
- return self._plot
+ plot = self._plotRef()
+ if plot is None:
+ raise RuntimeError(
+ 'Mask widget attached to a PlotWidget that no longer exists')
+ return plot
def setDirection(self, direction=qt.QBoxLayout.LeftToRight):
"""Set the direction of the layout of the widget
@@ -604,8 +618,8 @@ class BaseMaskToolsWidget(qt.QWidget):
self.polygonAction.setShortcut(qt.QKeySequence(qt.Qt.Key_S))
self.polygonAction.setToolTip(
'Polygon selection tool: (Un)Mask a polygonal region <b>S</b><br>'
- 'Left-click to place polygon corners<br>'
- 'Right-click to place the last corner')
+ 'Left-click to place new polygon corners<br>'
+ 'Left-click on first corner to close the polygon')
self.polygonAction.setCheckable(True)
self.polygonAction.triggered.connect(self._activePolygonMode)
self.addAction(self.polygonAction)
@@ -962,13 +976,20 @@ class BaseMaskToolsWidget(qt.QWidget):
self.plot.setInteractiveMode('draw', shape='polygon', source=self, color=color)
self._updateDrawingModeWidgets()
+ def _getPencilWidth(self):
+ """Returns the width of the pencil to use in data coordinates`
+
+ :rtype: float
+ """
+ return self.pencilSpinBox.value()
+
def _activePencilMode(self):
"""Handle pencil action mode triggering"""
self._releaseDrawingMode()
self._drawingMode = 'pencil'
self.plot.sigPlotSignal.connect(self._plotDrawEvent)
color = self.getCurrentMaskColor()
- width = self.pencilSpinBox.value()
+ width = self._getPencilWidth()
self.plot.setInteractiveMode(
'draw', shape='pencil', source=self, color=color, width=width)
self._updateDrawingModeWidgets()
diff --git a/silx/gui/plot/__init__.py b/silx/gui/plot/__init__.py
index b03392d..3a141b3 100644
--- a/silx/gui/plot/__init__.py
+++ b/silx/gui/plot/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -37,6 +37,7 @@ List of Qt widgets:
- :mod:`.PlotWindow`: A :mod:`.PlotWidget` with a configurable set of tools.
- :class:`.Plot1D`: A widget with tools for curves.
- :class:`.Plot2D`: A widget with tools for images.
+- :class:`.ScatterView`: A widget with tools for scatter plot.
- :class:`.ImageView`: A widget with tools for images and a side histogram.
- :class:`.StackView`: A widget with tools for a stack of images.
@@ -61,8 +62,10 @@ __date__ = "03/05/2017"
from .PlotWidget import PlotWidget # noqa
from .PlotWindow import PlotWindow, Plot1D, Plot2D # noqa
+from .items.axis import TickMode
from .ImageView import ImageView # noqa
from .StackView import StackView # noqa
+from .ScatterView import ScatterView # noqa
__all__ = ['ImageView', 'PlotWidget', 'PlotWindow', 'Plot1D', 'Plot2D',
- 'StackView']
+ 'StackView', 'ScatterView', 'TickMode']
diff --git a/silx/gui/plot/_utils/dtime_ticklayout.py b/silx/gui/plot/_utils/dtime_ticklayout.py
new file mode 100644
index 0000000..95fc235
--- /dev/null
+++ b/silx/gui/plot/_utils/dtime_ticklayout.py
@@ -0,0 +1,438 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2014-2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module implements date-time labels layout on graph axes."""
+
+from __future__ import absolute_import, division, unicode_literals
+
+__authors__ = ["P. Kenter"]
+__license__ = "MIT"
+__date__ = "04/04/2018"
+
+
+import datetime as dt
+import logging
+import math
+import time
+
+import dateutil.tz
+
+from dateutil.relativedelta import relativedelta
+
+from silx.third_party import enum
+from .ticklayout import niceNumGeneric
+
+_logger = logging.getLogger(__name__)
+
+
+MICROSECONDS_PER_SECOND = 1000000
+SECONDS_PER_MINUTE = 60
+SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE
+SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR
+SECONDS_PER_YEAR = 365.25 * SECONDS_PER_DAY
+SECONDS_PER_MONTH_AVERAGE = SECONDS_PER_YEAR / 12 # Seconds per average month
+
+
+# No dt.timezone in Python 2.7 so we use dateutil.tz.tzutc
+_EPOCH = dt.datetime(1970, 1, 1, tzinfo=dateutil.tz.tzutc())
+
+def timestamp(dtObj):
+ """ Returns POSIX timestamp of a datetime objects.
+
+ If the dtObj object has a timestamp() method (python 3.3), this is
+ used. Otherwise (e.g. python 2.7) it is calculated here.
+
+ The POSIX timestamp is a floating point value of the number of seconds
+ since the start of an epoch (typically 1970-01-01). For details see:
+ https://docs.python.org/3/library/datetime.html#datetime.datetime.timestamp
+
+ :param datetime.datetime dtObj: date-time representation.
+ :return: POSIX timestamp
+ :rtype: float
+ """
+ if hasattr(dtObj, "timestamp"):
+ return dtObj.timestamp()
+ else:
+ # Back ported from Python 3.5
+ if dtObj.tzinfo is None:
+ return time.mktime((dtObj.year, dtObj.month, dtObj.day,
+ dtObj.hour, dtObj.minute, dtObj.second,
+ -1, -1, -1)) + dtObj.microsecond / 1e6
+ else:
+ return (dtObj - _EPOCH).total_seconds()
+
+
+@enum.unique
+class DtUnit(enum.Enum):
+ YEARS = 0
+ MONTHS = 1
+ DAYS = 2
+ HOURS = 3
+ MINUTES = 4
+ SECONDS = 5
+ MICRO_SECONDS = 6 # a fraction of a second
+
+
+def getDateElement(dateTime, unit):
+ """ Picks the date element with the unit from the dateTime
+
+ E.g. getDateElement(datetime(1970, 5, 6), DtUnit.Day) will return 6
+
+ :param datetime dateTime: date/time to pick from
+ :param DtUnit unit: The unit describing the date element.
+ """
+ if unit == DtUnit.YEARS:
+ return dateTime.year
+ elif unit == DtUnit.MONTHS:
+ return dateTime.month
+ elif unit == DtUnit.DAYS:
+ return dateTime.day
+ elif unit == DtUnit.HOURS:
+ return dateTime.hour
+ elif unit == DtUnit.MINUTES:
+ return dateTime.minute
+ elif unit == DtUnit.SECONDS:
+ return dateTime.second
+ elif unit == DtUnit.MICRO_SECONDS:
+ return dateTime.microsecond
+ else:
+ raise ValueError("Unexpected DtUnit: {}".format(unit))
+
+
+def setDateElement(dateTime, value, unit):
+ """ Returns a copy of dateTime with the tickStep unit set to value
+
+ :param datetime.datetime: date time object
+ :param int value: value to set
+ :param DtUnit unit: unit
+ :return: datetime.datetime
+ """
+ intValue = int(value)
+ _logger.debug("setDateElement({}, {} (int={}), {})"
+ .format(dateTime, value, intValue, unit))
+
+ year = dateTime.year
+ month = dateTime.month
+ day = dateTime.day
+ hour = dateTime.hour
+ minute = dateTime.minute
+ second = dateTime.second
+ microsecond = dateTime.microsecond
+
+ if unit == DtUnit.YEARS:
+ year = intValue
+ elif unit == DtUnit.MONTHS:
+ month = intValue
+ elif unit == DtUnit.DAYS:
+ day = intValue
+ elif unit == DtUnit.HOURS:
+ hour = intValue
+ elif unit == DtUnit.MINUTES:
+ minute = intValue
+ elif unit == DtUnit.SECONDS:
+ second = intValue
+ elif unit == DtUnit.MICRO_SECONDS:
+ microsecond = intValue
+ else:
+ raise ValueError("Unexpected DtUnit: {}".format(unit))
+
+ _logger.debug("creating date time {}"
+ .format((year, month, day, hour, minute, second, microsecond)))
+
+ return dt.datetime(year, month, day, hour, minute, second, microsecond,
+ tzinfo=dateTime.tzinfo)
+
+
+
+def roundToElement(dateTime, unit):
+ """ Returns a copy of dateTime with the
+
+ :param datetime.datetime: date time object
+ :param DtUnit unit: unit
+ :return: datetime.datetime
+ """
+ year = dateTime.year
+ month = dateTime.month
+ day = dateTime.day
+ hour = dateTime.hour
+ minute = dateTime.minute
+ second = dateTime.second
+ microsecond = dateTime.microsecond
+
+ if unit.value < DtUnit.YEARS.value:
+ pass # Never round years
+ if unit.value < DtUnit.MONTHS.value:
+ month = 1
+ if unit.value < DtUnit.DAYS.value:
+ day = 1
+ if unit.value < DtUnit.HOURS.value:
+ hour = 0
+ if unit.value < DtUnit.MINUTES.value:
+ minute = 0
+ if unit.value < DtUnit.SECONDS.value:
+ second = 0
+ if unit.value < DtUnit.MICRO_SECONDS.value:
+ microsecond = 0
+
+ result = dt.datetime(year, month, day, hour, minute, second, microsecond,
+ tzinfo=dateTime.tzinfo)
+
+ return result
+
+
+def addValueToDate(dateTime, value, unit):
+ """ Adds a value with unit to a dateTime.
+
+ Uses dateutil.relativedelta.relativedelta from the standard library to do
+ the actual math. This function doesn't allow for fractional month or years,
+ so month and year are truncated to integers before adding.
+
+ :param datetime dateTime: date time
+ :param float value: value to be added
+ :param DtUnit unit: of the value
+ :return:
+ """
+ #logger.debug("addValueToDate({}, {}, {})".format(dateTime, value, unit))
+
+ if unit == DtUnit.YEARS:
+ intValue = int(value) # floats not implemented in relativeDelta(years)
+ return dateTime + relativedelta(years=intValue)
+ elif unit == DtUnit.MONTHS:
+ intValue = int(value) # floats not implemented in relativeDelta(mohths)
+ return dateTime + relativedelta(months=intValue)
+ elif unit == DtUnit.DAYS:
+ return dateTime + relativedelta(days=value)
+ elif unit == DtUnit.HOURS:
+ return dateTime + relativedelta(hours=value)
+ elif unit == DtUnit.MINUTES:
+ return dateTime + relativedelta(minutes=value)
+ elif unit == DtUnit.SECONDS:
+ return dateTime + relativedelta(seconds=value)
+ elif unit == DtUnit.MICRO_SECONDS:
+ return dateTime + relativedelta(microseconds=value)
+ else:
+ raise ValueError("Unexpected DtUnit: {}".format(unit))
+
+
+def bestUnit(durationInSeconds):
+ """ Gets the best tick spacing given a duration in seconds.
+
+ :param durationInSeconds: time span duration in seconds
+ :return: DtUnit enumeration.
+ """
+
+ # Based on; https://stackoverflow.com/a/2144398/
+ # If the duration is longer than two years the tick spacing will be in
+ # years. Else, if the duration is longer than two months, the spacing will
+ # be in months, Etcetera.
+ #
+ # This factor differs per unit. As a baseline it is 2, but for instance,
+ # for Months this needs to be higher (3>), This because it is impossible to
+ # have partial months so the tick spacing is always at least 1 month. A
+ # duration of two months would result in two ticks, which is too few.
+ # months would then results
+
+ if durationInSeconds > SECONDS_PER_YEAR * 3:
+ return (durationInSeconds / SECONDS_PER_YEAR, DtUnit.YEARS)
+ elif durationInSeconds > SECONDS_PER_MONTH_AVERAGE * 3:
+ return (durationInSeconds / SECONDS_PER_MONTH_AVERAGE, DtUnit.MONTHS)
+ elif durationInSeconds > SECONDS_PER_DAY * 2:
+ return (durationInSeconds / SECONDS_PER_DAY, DtUnit.DAYS)
+ elif durationInSeconds > SECONDS_PER_HOUR * 2:
+ return (durationInSeconds / SECONDS_PER_HOUR, DtUnit.HOURS)
+ elif durationInSeconds > SECONDS_PER_MINUTE * 2:
+ return (durationInSeconds / SECONDS_PER_MINUTE, DtUnit.MINUTES)
+ elif durationInSeconds > 1 * 2:
+ return (durationInSeconds, DtUnit.SECONDS)
+ else:
+ return (durationInSeconds * MICROSECONDS_PER_SECOND,
+ DtUnit.MICRO_SECONDS)
+
+
+NICE_DATE_VALUES = {
+ DtUnit.YEARS: [1, 2, 5, 10],
+ DtUnit.MONTHS: [1, 2, 3, 4, 6, 12],
+ DtUnit.DAYS: [1, 2, 3, 7, 14, 28],
+ DtUnit.HOURS: [1, 2, 3, 4, 6, 12],
+ DtUnit.MINUTES: [1, 2, 3, 5, 10, 15, 30],
+ DtUnit.SECONDS: [1, 2, 3, 5, 10, 15, 30],
+ DtUnit.MICRO_SECONDS : [1.0, 2.0, 5.0, 10.0], # floats for microsec
+}
+
+
+def bestFormatString(spacing, unit):
+ """ Finds the best format string given the spacing and DtUnit.
+
+ If the spacing is a fractional number < 1 the format string will take this
+ into account
+
+ :param spacing: spacing between ticks
+ :param DtUnit unit:
+ :return: Format string for use in strftime
+ :rtype: str
+ """
+ isSmall = spacing < 1
+
+ if unit == DtUnit.YEARS:
+ return "%Y-m" if isSmall else "%Y"
+ elif unit == DtUnit.MONTHS:
+ return "%Y-%m-%d" if isSmall else "%Y-%m"
+ elif unit == DtUnit.DAYS:
+ return "%H:%M" if isSmall else "%Y-%m-%d"
+ elif unit == DtUnit.HOURS:
+ return "%H:%M" if isSmall else "%H:%M"
+ elif unit == DtUnit.MINUTES:
+ return "%H:%M:%S" if isSmall else "%H:%M"
+ elif unit == DtUnit.SECONDS:
+ return "%S.%f" if isSmall else "%H:%M:%S"
+ elif unit == DtUnit.MICRO_SECONDS:
+ return "%S.%f"
+ else:
+ raise ValueError("Unexpected DtUnit: {}".format(unit))
+
+
+def niceDateTimeElement(value, unit, isRound=False):
+ """ Uses the Nice Numbers algorithm to determine a nice value.
+
+ The fractions are optimized for the unit of the date element.
+ """
+
+ niceValues = NICE_DATE_VALUES[unit]
+ elemValue = niceNumGeneric(value, niceValues, isRound=isRound)
+
+ if unit == DtUnit.YEARS or unit == DtUnit.MONTHS:
+ elemValue = max(1, int(elemValue))
+
+ return elemValue
+
+
+def findStartDate(dMin, dMax, nTicks):
+ """ Rounds a date down to the nearest nice number of ticks
+ """
+ assert dMax > dMin, \
+ "dMin ({}) should come before dMax ({})".format(dMin, dMax)
+
+ delta = dMax - dMin
+ lengthSec = delta.total_seconds()
+ _logger.debug("findStartDate: {}, {} (duration = {} sec, {} days)"
+ .format(dMin, dMax, lengthSec, lengthSec / SECONDS_PER_DAY))
+
+ length, unit = bestUnit(delta.total_seconds())
+ niceLength = niceDateTimeElement(length, unit)
+
+ _logger.debug("Length: {:8.3f} {} (nice = {})"
+ .format(length, unit.name, niceLength))
+
+ niceSpacing = niceDateTimeElement(niceLength / nTicks, unit, isRound=True)
+
+ _logger.debug("Spacing: {:8.3f} {} (nice = {})"
+ .format(niceLength / nTicks, unit.name, niceSpacing))
+
+ dVal = getDateElement(dMin, unit)
+
+ if unit == DtUnit.MONTHS: # TODO: better rounding?
+ niceVal = math.floor((dVal-1) / niceSpacing) * niceSpacing + 1
+ elif unit == DtUnit.DAYS:
+ niceVal = math.floor((dVal-1) / niceSpacing) * niceSpacing + 1
+ else:
+ niceVal = math.floor(dVal / niceSpacing) * niceSpacing
+
+ _logger.debug("StartValue: dVal = {}, niceVal: {} ({})"
+ .format(dVal, niceVal, unit.name))
+
+ startDate = roundToElement(dMin, unit)
+ startDate = setDateElement(startDate, niceVal, unit)
+
+ return startDate, niceSpacing, unit
+
+
+def dateRange(dMin, dMax, step, unit, includeFirstBeyond = False):
+ """ Generates a range of dates
+
+ :param datetime dMin: start date
+ :param datetime dMax: end date
+ :param int step: the step size
+ :param DtUnit unit: the unit of the step size
+ :param bool includeFirstBeyond: if True the first date later than dMax will
+ be included in the range. If False (the default), the last generated
+ datetime will always be smaller than dMax.
+ :return:
+ """
+ if (unit == DtUnit.YEARS or unit == DtUnit.MONTHS or
+ unit == DtUnit.MICRO_SECONDS):
+
+ # Month and years will be converted to integers
+ assert int(step) > 0, "Integer value or tickstep is 0"
+ else:
+ assert step > 0, "tickstep is 0"
+
+ dateTime = dMin
+ while dateTime < dMax:
+ yield dateTime
+ dateTime = addValueToDate(dateTime, step, unit)
+
+ if includeFirstBeyond:
+ yield dateTime
+
+
+
+def calcTicks(dMin, dMax, nTicks):
+ """Returns tick positions.
+
+ :param datetime.datetime dMin: The min value on the axis
+ :param datetime.datetime dMax: The max value on the axis
+ :param int nTicks: The target number of ticks. The actual number of found
+ ticks may differ.
+ :returns: (list of datetimes, DtUnit) tuple
+ """
+ _logger.debug("Calc calcTicks({}, {}, nTicks={})"
+ .format(dMin, dMax, nTicks))
+
+ startDate, niceSpacing, unit = findStartDate(dMin, dMax, nTicks)
+
+ result = []
+ for d in dateRange(startDate, dMax, niceSpacing, unit,
+ includeFirstBeyond=True):
+ result.append(d)
+
+ assert result[0] <= dMin, \
+ "First nice date ({}) should be <= dMin {}".format(result[0], dMin)
+
+ assert result[-1] >= dMax, \
+ "Last nice date ({}) should be >= dMax {}".format(result[-1], dMax)
+
+ return result, niceSpacing, unit
+
+
+def calcTicksAdaptive(dMin, dMax, axisLength, tickDensity):
+ """ Calls calcTicks with a variable number of ticks, depending on axisLength
+ """
+ # At least 2 ticks
+ nticks = max(2, int(round(tickDensity * axisLength)))
+ return calcTicks(dMin, dMax, nticks)
+
+
+
+
+
diff --git a/silx/gui/plot/_utils/test/__init__.py b/silx/gui/plot/_utils/test/__init__.py
index 4a443ac..624dbcb 100644
--- a/silx/gui/plot/_utils/test/__init__.py
+++ b/silx/gui/plot/_utils/test/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -32,10 +32,12 @@ __date__ = "18/10/2016"
import unittest
+from .test_dtime_ticklayout import suite as test_dtime_ticklayout_suite
from .test_ticklayout import suite as test_ticklayout_suite
def suite():
testsuite = unittest.TestSuite()
+ testsuite.addTest(test_dtime_ticklayout_suite())
testsuite.addTest(test_ticklayout_suite())
return testsuite
diff --git a/silx/gui/plot/_utils/test/testColormap.py b/silx/gui/plot/_utils/test/testColormap.py
new file mode 100644
index 0000000..d77fa65
--- /dev/null
+++ b/silx/gui/plot/_utils/test/testColormap.py
@@ -0,0 +1,648 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+
+import logging
+import time
+import unittest
+
+import numpy
+from PyMca5 import spslut
+
+from silx.image.colormap import dataToRGBAColormap
+
+_logger = logging.getLogger(__name__)
+
+# TODOs:
+# what to do with max < min: as SPS LUT or also invert outside boundaries?
+# test usedMin and usedMax
+# benchmark
+
+
+# common ######################################################################
+
+class _TestColormap(unittest.TestCase):
+ # Array data types to test
+ FLOATING_DTYPES = numpy.float16, numpy.float32, numpy.float64
+ SIGNED_DTYPES = FLOATING_DTYPES + (numpy.int8, numpy.int16,
+ numpy.int32, numpy.int64)
+ UNSIGNED_DTYPES = numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64
+ DTYPES = SIGNED_DTYPES + UNSIGNED_DTYPES
+
+ # Array sizes to test
+ SIZES = 2, 10, 256, 1024 # , 2048, 4096
+
+ # Colormaps definitions
+ _LUT_RED_256 = numpy.zeros((256, 4), dtype=numpy.uint8)
+ _LUT_RED_256[:, 0] = numpy.arange(256, dtype=numpy.uint8)
+ _LUT_RED_256[:, 3] = 255
+
+ _LUT_RGB_3 = numpy.array(((255, 0, 0, 255),
+ (0, 255, 0, 255),
+ (0, 0, 255, 255)), dtype=numpy.uint8)
+
+ _LUT_RGB_768 = numpy.zeros((768, 4), dtype=numpy.uint8)
+ _LUT_RGB_768[0:256, 0] = numpy.arange(256, dtype=numpy.uint8)
+ _LUT_RGB_768[256:512, 1] = numpy.arange(256, dtype=numpy.uint8)
+ _LUT_RGB_768[512:768, 1] = numpy.arange(256, dtype=numpy.uint8)
+ _LUT_RGB_768[:, 3] = 255
+
+ COLORMAPS = {
+ 'red 256': _LUT_RED_256,
+ 'rgb 3': _LUT_RGB_3,
+ 'rgb 768': _LUT_RGB_768,
+ }
+
+ @staticmethod
+ def _log(*args):
+ """Logging used by test for debugging."""
+ _logger.debug(str(args))
+
+ @staticmethod
+ def buildControlPixmap(data, colormap, start=None, end=None,
+ isLog10=False):
+ """Generate a pixmap used to test C pixmap."""
+ if isLog10: # Convert to log
+ if start is None:
+ posValue = data[numpy.nonzero(data > 0)]
+ if posValue.size != 0:
+ start = numpy.nanmin(posValue)
+ else:
+ start = 0.
+
+ if end is None:
+ end = numpy.nanmax(data)
+
+ start = 0. if start <= 0. else numpy.log10(start,
+ dtype=numpy.float64)
+ end = 0. if end <= 0. else numpy.log10(end,
+ dtype=numpy.float64)
+
+ data = numpy.log10(data, dtype=numpy.float64)
+ else:
+ if start is None:
+ start = numpy.nanmin(data)
+ if end is None:
+ end = numpy.nanmax(data)
+
+ start, end = float(start), float(end)
+ min_, max_ = min(start, end), max(start, end)
+
+ if start == end:
+ indices = numpy.asarray((len(colormap) - 1) * (data >= max_),
+ dtype=numpy.int)
+ else:
+ clipData = numpy.clip(data, min_, max_) # Clip first avoid overflow
+ scale = len(colormap) / (end - start)
+ normData = scale * (numpy.asarray(clipData, numpy.float64) - start)
+
+ # Clip again to makes sure <= len(colormap) - 1
+ indices = numpy.asarray(numpy.clip(normData,
+ 0, len(colormap) - 1),
+ dtype=numpy.uint32)
+
+ pixmap = numpy.take(colormap, indices, axis=0)
+ pixmap.shape = data.shape + (4,)
+ return numpy.ascontiguousarray(pixmap)
+
+ @staticmethod
+ def buildSPSLUTRedPixmap(data, start=None, end=None, isLog10=False):
+ """Generate a pixmap with SPS LUT.
+ Only supports red colormap with 256 colors.
+ """
+ colormap = spslut.RED
+ mapping = spslut.LOG if isLog10 else spslut.LINEAR
+
+ if start is None and end is None:
+ autoScale = 1
+ start, end = 0, 1
+ else:
+ autoScale = 0
+ if start is None:
+ start = data.min()
+ if end is None:
+ end = data.max()
+
+ pixmap, size, minMax = spslut.transform(data,
+ (1, 0),
+ (mapping, 3.0),
+ 'RGBX',
+ colormap,
+ autoScale,
+ (start, end),
+ (0, 255),
+ 1)
+ pixmap.shape = data.shape[0], data.shape[1], 4
+
+ return pixmap
+
+ def _testColormap(self, data, colormap, start, end, control=None,
+ isLog10=False, nanColor=None):
+ """Test pixmap built with C code against SPS LUT if possible,
+ else against Python control code."""
+ startTime = time.time()
+ pixmap = dataToRGBAColormap(data,
+ colormap,
+ start,
+ end,
+ isLog10,
+ nanColor)
+ duration = time.time() - startTime
+
+ # Compare with result
+ controlType = 'array'
+ if control is None:
+ startTime = time.time()
+
+ # Compare with SPS LUT if possible
+ if (colormap.shape == self.COLORMAPS['red 256'].shape and
+ numpy.all(numpy.equal(colormap, self.COLORMAPS['red 256'])) and
+ data.size % 2 == 0 and
+ data.dtype in (numpy.float32, numpy.float64)):
+ # Only works with red colormap and even size
+ # as it needs 2D data
+ if len(data.shape) == 1:
+ data.shape = data.size // 2, -1
+ pixmap.shape = data.shape + (4,)
+ control = self.buildSPSLUTRedPixmap(data, start, end, isLog10)
+ controlType = 'SPS LUT'
+
+ # Compare with python test implementation
+ else:
+ control = self.buildControlPixmap(data, colormap, start, end,
+ isLog10)
+ controlType = 'Python control code'
+
+ controlDuration = time.time() - startTime
+ if duration >= controlDuration:
+ self._log('duration', duration, 'control', controlDuration)
+ # Allows duration to be 20% over SPS LUT duration
+ # self.assertTrue(duration < 1.2 * controlDuration)
+
+ difference = numpy.fabs(numpy.asarray(pixmap, dtype=numpy.float64) -
+ numpy.asarray(control, dtype=numpy.float64))
+ if numpy.any(difference != 0.0):
+ self._log('control', controlType)
+ self._log('data', data)
+ self._log('pixmap', pixmap)
+ self._log('control', control)
+ self._log('errors', numpy.ravel(difference))
+ self._log('errors', difference[difference != 0])
+ self._log('in pixmap', pixmap[difference != 0])
+ self._log('in control', control[difference != 0])
+ self._log('Max error', difference.max())
+
+ # Allows a difference of 1 per channel
+ self.assertTrue(numpy.all(difference <= 1.0))
+
+ return duration
+
+
+# TestColormap ################################################################
+
+class TestColormap(_TestColormap):
+ """Test common limit case for colormap in C with both linear and log mode.
+
+ Test with different: data types, sizes, colormaps (with different sizes),
+ mapping range.
+ """
+
+ def testNoData(self):
+ """Test pixmap generation with empty data."""
+ self._log("TestColormap.testNoData")
+ cmapName = 'red 256'
+ colormap = self.COLORMAPS[cmapName]
+
+ for dtype in self.DTYPES:
+ for isLog10 in (False, True):
+ data = numpy.array((), dtype=dtype)
+ result = numpy.array((), dtype=numpy.uint8)
+ result.shape = 0, 4
+ duration = self._testColormap(data, colormap,
+ None, None, result, isLog10)
+ self._log('No data', 'red 256', dtype, len(data), (None, None),
+ 'isLog10:', isLog10, duration)
+
+ def testNaN(self):
+ """Test pixmap generation with NaN values and no NaN color."""
+ self._log("TestColormap.testNaN")
+ cmapName = 'red 256'
+ colormap = self.COLORMAPS[cmapName]
+
+ for dtype in self.FLOATING_DTYPES:
+ for isLog10 in (False, True):
+ # All NaNs
+ data = numpy.array((float('nan'),) * 4, dtype=dtype)
+ result = numpy.array(((0, 0, 0, 255),
+ (0, 0, 0, 255),
+ (0, 0, 0, 255),
+ (0, 0, 0, 255)), dtype=numpy.uint8)
+ duration = self._testColormap(data, colormap,
+ None, None, result, isLog10)
+ self._log('All NaNs', 'red 256', dtype, len(data),
+ (None, None), 'isLog10:', isLog10, duration)
+
+ # Some NaNs
+ data = numpy.array((1., float('nan'), 0., float('nan')),
+ dtype=dtype)
+ result = numpy.array(((255, 0, 0, 255),
+ (0, 0, 0, 255),
+ (0, 0, 0, 255),
+ (0, 0, 0, 255)), dtype=numpy.uint8)
+ duration = self._testColormap(data, colormap,
+ None, None, result, isLog10)
+ self._log('Some NaNs', 'red 256', dtype, len(data),
+ (None, None), 'isLog10:', isLog10, duration)
+
+ def testNaNWithColor(self):
+ """Test pixmap generation with NaN values with a NaN color."""
+ self._log("TestColormap.testNaNWithColor")
+ cmapName = 'red 256'
+ colormap = self.COLORMAPS[cmapName]
+
+ for dtype in self.FLOATING_DTYPES:
+ for isLog10 in (False, True):
+ # All NaNs
+ data = numpy.array((float('nan'),) * 4, dtype=dtype)
+ result = numpy.array(((128, 128, 128, 255),
+ (128, 128, 128, 255),
+ (128, 128, 128, 255),
+ (128, 128, 128, 255)), dtype=numpy.uint8)
+ duration = self._testColormap(data, colormap,
+ None, None, result, isLog10,
+ nanColor=(128, 128, 128, 255))
+ self._log('All NaNs', 'red 256', dtype, len(data),
+ (None, None), 'isLog10:', isLog10, duration)
+
+ # Some NaNs
+ data = numpy.array((1., float('nan'), 0., float('nan')),
+ dtype=dtype)
+ result = numpy.array(((255, 0, 0, 255),
+ (128, 128, 128, 255),
+ (0, 0, 0, 255),
+ (128, 128, 128, 255)), dtype=numpy.uint8)
+ duration = self._testColormap(data, colormap,
+ None, None, result, isLog10,
+ nanColor=(128, 128, 128, 255))
+ self._log('Some NaNs', 'red 256', dtype, len(data),
+ (None, None), 'isLog10:', isLog10, duration)
+
+
+# TestLinearColormap ##########################################################
+
+class TestLinearColormap(_TestColormap):
+ """Test fill pixmap with colormap in C with linear mode.
+
+ Test with different: data types, sizes, colormaps (with different sizes),
+ mapping range.
+ """
+
+ # Colormap ranges to map
+ RANGES = (None, None), (1, 10)
+
+ def test1DData(self):
+ """Test pixmap generation for 1D data of different size and types."""
+ self._log("TestLinearColormap.test1DData")
+ for cmapName, colormap in self.COLORMAPS.items():
+ for size in self.SIZES:
+ for dtype in self.DTYPES:
+ for start, end in self.RANGES:
+ # Increasing values
+ data = numpy.arange(size, dtype=dtype)
+ duration = self._testColormap(data, colormap,
+ start, end)
+
+ self._log('1D', cmapName, dtype, size, (start, end),
+ duration)
+
+ # Reverse order
+ data = data[::-1]
+ duration = self._testColormap(data, colormap,
+ start, end)
+
+ self._log('1D', cmapName, dtype, size, (start, end),
+ duration)
+
+ def test2DData(self):
+ """Test pixmap generation for 2D data of different size and types."""
+ self._log("TestLinearColormap.test2DData")
+ for cmapName, colormap in self.COLORMAPS.items():
+ for size in self.SIZES:
+ for dtype in self.DTYPES:
+ for start, end in self.RANGES:
+ # Increasing values
+ data = numpy.arange(size * size, dtype=dtype)
+ data = numpy.nan_to_num(data)
+ data.shape = size, size
+ duration = self._testColormap(data, colormap,
+ start, end)
+
+ self._log('2D', cmapName, dtype, size, (start, end),
+ duration)
+
+ # Reverse order
+ data = data[::-1, ::-1]
+ duration = self._testColormap(data, colormap,
+ start, end)
+
+ self._log('2D', cmapName, dtype, size, (start, end),
+ duration)
+
+ def testInf(self):
+ """Test pixmap generation with Inf values."""
+ self._log("TestLinearColormap.testInf")
+
+ for dtype in self.FLOATING_DTYPES:
+ # All positive Inf
+ data = numpy.array((float('inf'),) * 4, dtype=dtype)
+ result = numpy.array(((255, 0, 0, 255),
+ (255, 0, 0, 255),
+ (255, 0, 0, 255),
+ (255, 0, 0, 255)), dtype=numpy.uint8)
+ duration = self._testColormap(data, self.COLORMAPS['red 256'],
+ None, None, result)
+ self._log('All +Inf', 'red 256', dtype, len(data), (None, None),
+ duration)
+
+ # All negative Inf
+ data = numpy.array((float('-inf'),) * 4, dtype=dtype)
+ result = numpy.array(((255, 0, 0, 255),
+ (255, 0, 0, 255),
+ (255, 0, 0, 255),
+ (255, 0, 0, 255)), dtype=numpy.uint8)
+ duration = self._testColormap(data, self.COLORMAPS['red 256'],
+ None, None, result)
+ self._log('All -Inf', 'red 256', dtype, len(data), (None, None),
+ duration)
+
+ # All +/-Inf
+ data = numpy.array((float('inf'), float('-inf'),
+ float('-inf'), float('inf')), dtype=dtype)
+ result = numpy.array(((255, 0, 0, 255),
+ (0, 0, 0, 255),
+ (0, 0, 0, 255),
+ (255, 0, 0, 255)), dtype=numpy.uint8)
+ duration = self._testColormap(data, self.COLORMAPS['red 256'],
+ None, None, result)
+ self._log('All +/-Inf', 'red 256', dtype, len(data), (None, None),
+ duration)
+
+ # Some +/-Inf
+ data = numpy.array((float('inf'), 0., float('-inf'), -10.),
+ dtype=dtype)
+ result = numpy.array(((255, 0, 0, 255),
+ (0, 0, 0, 255),
+ (0, 0, 0, 255),
+ (0, 0, 0, 255)), dtype=numpy.uint8)
+ duration = self._testColormap(data, self.COLORMAPS['red 256'],
+ None, None,
+ result) # Seg Fault with SPS
+ self._log('Some +/-Inf', 'red 256', dtype, len(data), (None, None),
+ duration)
+
+ @unittest.skip("Not for reproductible tests")
+ def test1DDataRandom(self):
+ """Test pixmap generation for 1D data of different size and types."""
+ self._log("TestLinearColormap.test1DDataRandom")
+ for cmapName, colormap in self.COLORMAPS.items():
+ for size in self.SIZES:
+ for dtype in self.DTYPES:
+ for start, end in self.RANGES:
+ try:
+ dtypeMax = numpy.iinfo(dtype).max
+ except ValueError:
+ dtypeMax = numpy.finfo(dtype).max
+ data = numpy.asarray(numpy.random.rand(size) * dtypeMax,
+ dtype=dtype)
+ duration = self._testColormap(data, colormap,
+ start, end)
+
+ self._log('1D Random', cmapName, dtype, size,
+ (start, end), duration)
+
+
+# TestLog10Colormap ###########################################################
+
+class TestLog10Colormap(_TestColormap):
+ """Test fill pixmap with colormap in C with log mode.
+
+ Test with different: data types, sizes, colormaps (with different sizes),
+ mapping range.
+ """
+ # Colormap ranges to map
+ RANGES = (None, None), (1, 10) # , (10, 1)
+
+ def test1DDataAllPositive(self):
+ """Test pixmap generation for all positive 1D data."""
+ self._log("TestLog10Colormap.test1DDataAllPositive")
+ for cmapName, colormap in self.COLORMAPS.items():
+ for size in self.SIZES:
+ for dtype in self.DTYPES:
+ for start, end in self.RANGES:
+ # Increasing values
+ data = numpy.arange(size, dtype=dtype) + 1
+ duration = self._testColormap(data, colormap,
+ start, end,
+ isLog10=True)
+
+ self._log('1D', cmapName, dtype, size, (start, end),
+ duration)
+
+ # Reverse order
+ data = data[::-1]
+ duration = self._testColormap(data, colormap,
+ start, end,
+ isLog10=True)
+
+ self._log('1D', cmapName, dtype, size, (start, end),
+ duration)
+
+ def test2DDataAllPositive(self):
+ """Test pixmap generation for all positive 2D data."""
+ self._log("TestLog10Colormap.test2DDataAllPositive")
+ for cmapName, colormap in self.COLORMAPS.items():
+ for size in self.SIZES:
+ for dtype in self.DTYPES:
+ for start, end in self.RANGES:
+ # Increasing values
+ data = numpy.arange(size * size, dtype=dtype) + 1
+ data = numpy.nan_to_num(data)
+ data.shape = size, size
+ duration = self._testColormap(data, colormap,
+ start, end,
+ isLog10=True)
+
+ self._log('2D', cmapName, dtype, size, (start, end),
+ duration)
+
+ # Reverse order
+ data = data[::-1, ::-1]
+ duration = self._testColormap(data, colormap,
+ start, end,
+ isLog10=True)
+
+ self._log('2D', cmapName, dtype, size, (start, end),
+ duration)
+
+ def testAllNegative(self):
+ """Test pixmap generation for all negative 1D data."""
+ self._log("TestLog10Colormap.testAllNegative")
+ for cmapName, colormap in self.COLORMAPS.items():
+ for size in self.SIZES:
+ for dtype in self.SIGNED_DTYPES:
+ for start, end in self.RANGES:
+ # Increasing values
+ data = numpy.arange(-size, 0, dtype=dtype)
+ duration = self._testColormap(data, colormap,
+ start, end,
+ isLog10=True)
+
+ self._log('1D', cmapName, dtype, size, (start, end),
+ duration)
+
+ # Reverse order
+ data = data[::-1]
+ duration = self._testColormap(data, colormap,
+ start, end,
+ isLog10=True)
+
+ self._log('1D', cmapName, dtype, size, (start, end),
+ duration)
+
+ def testCrossingZero(self):
+ """Test pixmap generation for 1D data with negative and zero."""
+ self._log("TestLog10Colormap.testCrossingZero")
+ for cmapName, colormap in self.COLORMAPS.items():
+ for size in self.SIZES:
+ for dtype in self.SIGNED_DTYPES:
+ for start, end in self.RANGES:
+ # Increasing values
+ data = numpy.arange(-size/2, size/2 + 1, dtype=dtype)
+ duration = self._testColormap(data, colormap,
+ start, end,
+ isLog10=True)
+
+ self._log('1D', cmapName, dtype, size, (start, end),
+ duration)
+
+ # Reverse order
+ data = data[::-1]
+ duration = self._testColormap(data, colormap,
+ start, end,
+ isLog10=True)
+
+ self._log('1D', cmapName, dtype, size, (start, end),
+ duration)
+
+ @unittest.skip("Not for reproductible tests")
+ def test1DDataRandom(self):
+ """Test pixmap generation for 1D data of different size and types."""
+ self._log("TestLog10Colormap.test1DDataRandom")
+ for cmapName, colormap in self.COLORMAPS.items():
+ for size in self.SIZES:
+ for dtype in self.DTYPES:
+ for start, end in self.RANGES:
+ try:
+ dtypeMax = numpy.iinfo(dtype).max
+ dtypeMin = numpy.iinfo(dtype).min
+ except ValueError:
+ dtypeMax = numpy.finfo(dtype).max
+ dtypeMin = numpy.finfo(dtype).min
+ if dtypeMin < 0:
+ data = numpy.asarray(-dtypeMax/2. +
+ numpy.random.rand(size) * dtypeMax,
+ dtype=dtype)
+ else:
+ data = numpy.asarray(numpy.random.rand(size) * dtypeMax,
+ dtype=dtype)
+
+ duration = self._testColormap(data, colormap,
+ start, end,
+ isLog10=True)
+
+ self._log('1D Random', cmapName, dtype, size,
+ (start, end), duration)
+
+ def testInf(self):
+ """Test pixmap generation with Inf values."""
+ self._log("TestLog10Colormap.testInf")
+
+ for dtype in self.FLOATING_DTYPES:
+ # All positive Inf
+ data = numpy.array((float('inf'),) * 4, dtype=dtype)
+ result = numpy.array(((255, 0, 0, 255),
+ (255, 0, 0, 255),
+ (255, 0, 0, 255),
+ (255, 0, 0, 255)), dtype=numpy.uint8)
+ duration = self._testColormap(data, self.COLORMAPS['red 256'],
+ None, None, result, isLog10=True)
+ self._log('All +Inf', 'red 256', dtype, len(data), (None, None),
+ duration)
+
+ # All negative Inf
+ data = numpy.array((float('-inf'),) * 4, dtype=dtype)
+ result = numpy.array(((0, 0, 0, 255),
+ (0, 0, 0, 255),
+ (0, 0, 0, 255),
+ (0, 0, 0, 255)), dtype=numpy.uint8)
+ duration = self._testColormap(data, self.COLORMAPS['red 256'],
+ None, None, result, isLog10=True)
+ self._log('All -Inf', 'red 256', dtype, len(data), (None, None),
+ duration)
+
+ # All +/-Inf
+ data = numpy.array((float('inf'), float('-inf'),
+ float('-inf'), float('inf')), dtype=dtype)
+ result = numpy.array(((255, 0, 0, 255),
+ (0, 0, 0, 255),
+ (0, 0, 0, 255),
+ (255, 0, 0, 255)), dtype=numpy.uint8)
+ duration = self._testColormap(data, self.COLORMAPS['red 256'],
+ None, None, result, isLog10=True)
+ self._log('All +/-Inf', 'red 256', dtype, len(data), (None, None),
+ duration)
+
+ # Some +/-Inf
+ data = numpy.array((float('inf'), 0., float('-inf'), -10.),
+ dtype=dtype)
+ result = numpy.array(((255, 0, 0, 255),
+ (0, 0, 0, 255),
+ (0, 0, 0, 255),
+ (0, 0, 0, 255)), dtype=numpy.uint8)
+ duration = self._testColormap(data, self.COLORMAPS['red 256'],
+ None, None, result, isLog10=True)
+ self._log('Some +/-Inf', 'red 256', dtype, len(data), (None, None),
+ duration)
+
+
+def suite():
+ testSuite = unittest.TestSuite()
+ for testClass in (TestColormap, TestLinearColormap): # , TestLog10Colormap):
+ testSuite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(testClass))
+ return testSuite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/_utils/test/test_dtime_ticklayout.py b/silx/gui/plot/_utils/test/test_dtime_ticklayout.py
new file mode 100644
index 0000000..2b87148
--- /dev/null
+++ b/silx/gui/plot/_utils/test/test_dtime_ticklayout.py
@@ -0,0 +1,93 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+
+from __future__ import absolute_import, division, unicode_literals
+
+__authors__ = ["P. Kenter"]
+__license__ = "MIT"
+__date__ = "06/04/2018"
+
+
+import datetime as dt
+import unittest
+
+
+from silx.gui.plot._utils.dtime_ticklayout import (
+ calcTicks, DtUnit, SECONDS_PER_YEAR)
+
+
+class DtTestTickLayout(unittest.TestCase):
+ """Test ticks layout algorithms"""
+
+ def testSmallMonthlySpacing(self):
+ """ Tests a range that did result in a spacing of less than 1 month.
+ It is impossible to add fractional month so the unit must be in days
+ """
+ from dateutil import parser
+ d1 = parser.parse("2017-01-03 13:15:06.000044")
+ d2 = parser.parse("2017-03-08 09:16:16.307584")
+ _ticks, _units, spacing = calcTicks(d1, d2, nTicks=4)
+
+ self.assertEqual(spacing, DtUnit.DAYS)
+
+
+ def testNoCrash(self):
+ """ Creates many combinations of and number-of-ticks and end-dates;
+ tests that it doesn't give an exception and returns a reasonable number
+ of ticks.
+ """
+ d1 = dt.datetime(2017, 1, 3, 13, 15, 6, 44)
+
+ value = 100e-6 # Start at 100 micro sec range.
+
+ while value <= 200 * SECONDS_PER_YEAR:
+
+ d2 = d1 + dt.timedelta(microseconds=value*1e6) # end date range
+
+ for numTicks in range(2, 12):
+ ticks, _, _ = calcTicks(d1, d2, numTicks)
+
+ margin = 2.5
+ self.assertTrue(
+ numTicks/margin <= len(ticks) <= numTicks*margin,
+ "Condition {} <= {} <= {} failed for # ticks={} and d2={}:"
+ .format(numTicks/margin, len(ticks), numTicks * margin,
+ numTicks, d2))
+
+ value = value * 1.5 # let date period grow exponentially
+
+
+
+
+
+def suite():
+ testsuite = unittest.TestSuite()
+ testsuite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(DtTestTickLayout))
+ return testsuite
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/silx/gui/plot/_utils/ticklayout.py b/silx/gui/plot/_utils/ticklayout.py
index 6e9f654..c9fd3e6 100644
--- a/silx/gui/plot/_utils/ticklayout.py
+++ b/silx/gui/plot/_utils/ticklayout.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -51,28 +51,65 @@ def numberOfDigits(tickSpacing):
# Nice Numbers ################################################################
-def _niceNum(value, isRound=False):
- expvalue = math.floor(math.log10(value))
- frac = value/pow(10., expvalue)
- if isRound:
- if frac < 1.5:
- nicefrac = 1.
- elif frac < 3.:
- nicefrac = 2.
- elif frac < 7.:
- nicefrac = 5.
- else:
- nicefrac = 10.
+# This is the original niceNum implementation. For the date time ticks a more
+# generic implementation was needed.
+#
+# def _niceNum(value, isRound=False):
+# expvalue = math.floor(math.log10(value))
+# frac = value/pow(10., expvalue)
+# if isRound:
+# if frac < 1.5:
+# nicefrac = 1.
+# elif frac < 3.: # In niceNumGeneric this is (2+5)/2 = 3.5
+# nicefrac = 2.
+# elif frac < 7.:
+# nicefrac = 5. # In niceNumGeneric this is (5+10)/2 = 7.5
+# else:
+# nicefrac = 10.
+# else:
+# if frac <= 1.:
+# nicefrac = 1.
+# elif frac <= 2.:
+# nicefrac = 2.
+# elif frac <= 5.:
+# nicefrac = 5.
+# else:
+# nicefrac = 10.
+# return nicefrac * pow(10., expvalue)
+
+
+def niceNumGeneric(value, niceFractions=None, isRound=False):
+ """ A more generic implementation of the _niceNum function
+
+ Allows the user to specify the fractions instead of using a hardcoded
+ list of [1, 2, 5, 10.0].
+ """
+ if value == 0:
+ return value
+
+ if niceFractions is None: # Use default values
+ niceFractions = 1., 2., 5., 10.
+ roundFractions = (1.5, 3., 7., 10.) if isRound else niceFractions
+
else:
- if frac <= 1.:
- nicefrac = 1.
- elif frac <= 2.:
- nicefrac = 2.
- elif frac <= 5.:
- nicefrac = 5.
- else:
- nicefrac = 10.
- return nicefrac * pow(10., expvalue)
+ roundFractions = list(niceFractions)
+ if isRound:
+ # Take the average with the next element. The last remains the same.
+ for i in range(len(roundFractions) - 1):
+ roundFractions[i] = (niceFractions[i] + niceFractions[i+1]) / 2
+
+ highest = niceFractions[-1]
+ value = float(value)
+
+ expvalue = math.floor(math.log(value, highest))
+ frac = value / pow(highest, expvalue)
+
+ for niceFrac, roundFrac in zip(niceFractions, roundFractions):
+ if frac <= roundFrac:
+ return niceFrac * pow(highest, expvalue)
+
+ # should not come here
+ assert False, "should not come here"
def niceNumbers(vMin, vMax, nTicks=5):
@@ -89,8 +126,8 @@ def niceNumbers(vMin, vMax, nTicks=5):
number of fractional digit to show
:rtype: tuple
"""
- vrange = _niceNum(vMax - vMin, False)
- spacing = _niceNum(vrange / nTicks, True)
+ vrange = niceNumGeneric(vMax - vMin, isRound=False)
+ spacing = niceNumGeneric(vrange / nTicks, isRound=True)
graphmin = math.floor(vMin / spacing) * spacing
graphmax = math.ceil(vMax / spacing) * spacing
nfrac = numberOfDigits(spacing)
diff --git a/silx/gui/plot/actions/control.py b/silx/gui/plot/actions/control.py
index ac6dc2f..6e08f21 100644
--- a/silx/gui/plot/actions/control.py
+++ b/silx/gui/plot/actions/control.py
@@ -50,12 +50,11 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "15/02/2018"
+__date__ = "24/04/2018"
from . import PlotAction
import logging
from silx.gui.plot import items
-from silx.gui.plot.ColormapDialog import ColormapDialog
from silx.gui.plot._utils import applyZoomToPlot as _applyZoomToPlot
from silx.gui import qt
from silx.gui import icons
@@ -328,6 +327,7 @@ class ColormapAction(PlotAction):
triggered=self._actionTriggered,
checkable=True, parent=parent)
self.plot.sigActiveImageChanged.connect(self._updateColormap)
+ self.plot.sigActiveScatterChanged.connect(self._updateColormap)
def setColorDialog(self, colorDialog):
"""Set a specific color dialog instead of using the default dialog."""
@@ -344,6 +344,7 @@ class ColormapAction(PlotAction):
:parent QWidget parent: Parent of the new colormap
:rtype: ColormapDialog
"""
+ from silx.gui.dialog.ColormapDialog import ColormapDialog
dialog = ColormapDialog(parent=parent)
dialog.setModal(False)
return dialog
@@ -393,10 +394,19 @@ class ColormapAction(PlotAction):
else:
# No active image or active image is RGBA,
- # set dialog from default info
- colormap = self.plot.getDefaultColormap()
- # Reset histogram and range if any
- self._dialog.setData(None)
+ # Check for active scatter plot
+ scatter = self.plot._getActiveItem(kind='scatter')
+ if scatter is not None:
+ colormap = scatter.getColormap()
+ data = scatter.getValueData(copy=False)
+ self._dialog.setData(data)
+
+ else:
+ # No active data image nor scatter,
+ # set dialog from default info
+ colormap = self.plot.getDefaultColormap()
+ # Reset histogram and range if any
+ self._dialog.setData(None)
self._dialog.setColormap(colormap)
@@ -408,7 +418,7 @@ class ColorBarAction(PlotAction):
:param parent: See :class:`QAction`
"""
def __init__(self, plot, parent=None):
- self._dialog = None # To store an instance of ColormapDialog
+ self._dialog = None # To store an instance of ColorBar
super(ColorBarAction, self).__init__(
plot, icon='colorbar', text='Colorbar',
tooltip="Show/Hide the colorbar",
diff --git a/silx/gui/plot/actions/histogram.py b/silx/gui/plot/actions/histogram.py
index 40ef873..d6e3269 100644
--- a/silx/gui/plot/actions/histogram.py
+++ b/silx/gui/plot/actions/histogram.py
@@ -34,7 +34,7 @@ The following QAction are available:
from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
-__date__ = "27/06/2017"
+__date__ = "30/04/2018"
__license__ = "MIT"
from . import PlotAction
@@ -129,7 +129,7 @@ class PixelIntensitiesHistoAction(PlotAction):
edges=edges,
legend='pixel intensity',
fill=True,
- color='red')
+ color='#66aad7')
plot.resetZoom()
def eventFilter(self, qobject, event):
diff --git a/silx/gui/plot/actions/io.py b/silx/gui/plot/actions/io.py
index d6d5909..ac06942 100644
--- a/silx/gui/plot/actions/io.py
+++ b/silx/gui/plot/actions/io.py
@@ -44,13 +44,16 @@ from silx.io.utils import save1D, savespec
from silx.io.nxdata import save_NXdata
import logging
import sys
+import os.path
from collections import OrderedDict
import traceback
import numpy
-from silx.gui import qt
+from silx.utils.deprecation import deprecated
+from silx.gui import qt, printer
+from silx.gui.dialog.GroupDialog import GroupDialog
from silx.third_party.EdfFile import EdfFile
from silx.third_party.TiffIO import TiffIO
-from silx.gui._utils import convertArrayToQImage
+from ...utils._image import convertArrayToQImage
if sys.version_info[0] == 3:
from io import BytesIO
else:
@@ -60,10 +63,26 @@ else:
_logger = logging.getLogger(__name__)
-_NEXUS_HDF5_EXT = [".nx5", ".nxs", ".hdf", ".hdf5", ".cxi", ".h5"]
+_NEXUS_HDF5_EXT = [".h5", ".nx5", ".nxs", ".hdf", ".hdf5", ".cxi"]
_NEXUS_HDF5_EXT_STR = ' '.join(['*' + ext for ext in _NEXUS_HDF5_EXT])
+def selectOutputGroup(h5filename):
+ """Open a dialog to prompt the user to select a group in
+ which to output data.
+
+ :param str h5filename: name of an existing HDF5 file
+ :rtype: str
+ :return: Name of output group, or None if the dialog was cancelled
+ """
+ dialog = GroupDialog()
+ dialog.addFile(h5filename)
+ dialog.setWindowTitle("Select an output group")
+ if not dialog.exec_():
+ return None
+ return dialog.getSelectedDataUrl().data_path()
+
+
class SaveAction(PlotAction):
"""QAction for saving Plot content.
@@ -72,12 +91,11 @@ class SaveAction(PlotAction):
:param plot: :class:`.PlotWidget` instance on which to operate.
:param parent: See :class:`QAction`.
"""
- # TODO find a way to make the filter list selectable and extensible
SNAPSHOT_FILTER_SVG = 'Plot Snapshot as SVG (*.svg)'
SNAPSHOT_FILTER_PNG = 'Plot Snapshot as PNG (*.png)'
- SNAPSHOT_FILTERS = (SNAPSHOT_FILTER_PNG, SNAPSHOT_FILTER_SVG)
+ DEFAULT_ALL_FILTERS = (SNAPSHOT_FILTER_PNG, SNAPSHOT_FILTER_SVG)
# Dict of curve filters with CSV-like format
# Using ordered dict to guarantee filters order
@@ -101,10 +119,10 @@ class SaveAction(PlotAction):
CURVE_FILTER_NXDATA = 'Curve as NXdata (%s)' % _NEXUS_HDF5_EXT_STR
- CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [CURVE_FILTER_NPY,
- CURVE_FILTER_NXDATA]
+ DEFAULT_CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [
+ CURVE_FILTER_NPY, CURVE_FILTER_NXDATA]
- ALL_CURVES_FILTERS = ("All curves as SpecFile (*.dat)", )
+ DEFAULT_ALL_CURVES_FILTERS = ("All curves as Spe