summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.rst100
-rw-r--r--PKG-INFO9
-rwxr-xr-xbuild-deb.sh91
-rw-r--r--debian/changelog16
-rw-r--r--debian/control12
-rwxr-xr-xdebian/rules5
-rw-r--r--doc/source/conf.py1
-rw-r--r--doc/source/install.rst10
-rw-r--r--doc/source/modules/gui/data/img/ArrayTableWidget.pngbin21097 -> 42383 bytes
-rw-r--r--doc/source/modules/gui/data/img/DataViewer.pngbin20225 -> 38670 bytes
-rw-r--r--doc/source/modules/gui/hdf5/getting_started.rst2
-rw-r--r--doc/source/modules/gui/icons.rst54
-rw-r--r--doc/source/modules/gui/plot/getting_started.rst16
-rw-r--r--doc/source/modules/gui/plot/img/BasicGridStatsWidget.pngbin5702 -> 12081 bytes
-rw-r--r--doc/source/modules/gui/plot/img/BasicStatsWidget.pngbin6575 -> 8699 bytes
-rw-r--r--doc/source/modules/gui/plot/img/LimitsToolBar.pngbin21499 -> 21697 bytes
-rw-r--r--doc/source/modules/gui/plot/img/logColorbar.pngbin5240 -> 11461 bytes
-rw-r--r--doc/source/modules/gui/plot/index.rst2
-rw-r--r--doc/source/modules/gui/plot/tools/img/CurveLegendsWidget.png (renamed from doc/source/modules/gui/plot/img/CurveLegendsWidget.png)bin30043 -> 30043 bytes
-rw-r--r--doc/source/modules/gui/plot/tools/img/linearColorbar.png (renamed from doc/source/modules/gui/plot/img/linearColorbar.png)bin6585 -> 6585 bytes
-rw-r--r--doc/source/modules/gui/plot/tools/index.rst (renamed from doc/source/modules/gui/plot/tools.rst)32
-rw-r--r--doc/source/modules/gui/plot/tools/profile.rst84
-rw-r--r--doc/source/modules/gui/widgets/img/FrameBrowser.pngbin2161 -> 3186 bytes
-rw-r--r--doc/source/modules/gui/widgets/img/HorizontalSliderWithBrowser.pngbin2278 -> 2884 bytes
-rw-r--r--doc/source/modules/gui/widgets/img/PeriodicCombo.pngbin1878 -> 2607 bytes
-rw-r--r--doc/source/modules/gui/widgets/img/PeriodicList.pngbin17621 -> 25731 bytes
-rw-r--r--doc/source/modules/gui/widgets/img/PeriodicTable.pngbin25540 -> 33564 bytes
-rw-r--r--doc/source/modules/gui/widgets/img/RangeSlider.pngbin1024 -> 1028 bytes
-rw-r--r--doc/source/modules/gui/widgets/img/TableWidget.pngbin3156 -> 3789 bytes
-rw-r--r--doc/source/modules/gui/widgets/img/ThreadPoolPushButton.pngbin1577 -> 2151 bytes
-rw-r--r--doc/source/modules/gui/widgets/img/WaitingPushButton.pngbin941 -> 1068 bytes
-rw-r--r--doc/source/overview.rst6
-rw-r--r--doc/source/sample_code/img/customSilxView.pngbin0 -> 93797 bytes
-rw-r--r--doc/source/sample_code/img/imageStack.pngbin0 -> 347747 bytes
-rw-r--r--doc/source/sample_code/img/plotProfile.pngbin0 -> 86902 bytes
-rw-r--r--doc/source/sample_code/index.rst35
-rw-r--r--doc/source/troubleshooting.rst2
-rw-r--r--doc/source/virtualenv.rst97
-rw-r--r--examples/colormapDialog.py36
-rw-r--r--examples/customSilxView.py88
-rw-r--r--examples/dropZones.py113
-rwxr-xr-xexamples/fftPlotAction.py4
-rw-r--r--examples/findContours.py7
-rwxr-xr-xexamples/hdf5widget.py2
-rw-r--r--examples/icons.py2
-rw-r--r--examples/imageStack.py143
-rw-r--r--examples/plotCurveLegendWidget.py6
-rw-r--r--examples/plotInteractiveImageROI.py8
-rwxr-xr-xexamples/plotItemsSelector.py4
-rw-r--r--examples/plotProfile.py207
-rwxr-xr-xexamples/simplewidget.py24
-rw-r--r--examples/viewer3DVolume.py4
-rw-r--r--package/debian10/control52
-rwxr-xr-xpackage/debian10/rules5
-rw-r--r--package/debian10/tests/control16
-rw-r--r--package/debian11/changelog156
-rw-r--r--package/debian11/control170
-rw-r--r--package/debian11/gbp.conf2
-rw-r--r--package/debian11/patches/0002-use-the-system-mathjax-privacy-breach.patch25
-rw-r--r--package/debian11/patches/0003-do-not-modify-PYTHONPATH-from-setup.py.patch22
-rw-r--r--package/debian11/patches/series2
-rw-r--r--package/debian11/py3dist-overrides1
-rw-r--r--package/debian11/python-silx-doc.doc-base9
-rwxr-xr-xpackage/debian11/rules82
-rw-r--r--package/debian11/source/format1
-rw-r--r--package/debian11/source/options1
-rw-r--r--package/debian11/tests/control15
-rw-r--r--package/debian11/watch7
-rw-r--r--package/debian9/control56
-rwxr-xr-xpackage/debian9/rules8
-rw-r--r--package/windows/README.rst14
-rw-r--r--package/windows/pyinstaller-silx-view.spec7
-rw-r--r--package/windows/pyinstaller.spec7
-rw-r--r--requirements-dev.txt2
-rw-r--r--requirements.txt20
-rw-r--r--setup.py131
-rw-r--r--silx.egg-info/PKG-INFO9
-rw-r--r--silx.egg-info/SOURCES.txt81
-rw-r--r--silx.egg-info/requires.txt2
-rw-r--r--silx/app/view/Viewer.py96
-rw-r--r--silx/app/view/main.py9
-rw-r--r--silx/app/view/test/test_view.py14
-rw-r--r--silx/gui/_glutils/OpenGLWidget.py48
-rw-r--r--silx/gui/_glutils/font.py6
-rwxr-xr-xsilx/gui/colors.py429
-rw-r--r--silx/gui/data/DataViewer.py38
-rw-r--r--silx/gui/data/DataViews.py337
-rw-r--r--silx/gui/data/Hdf5TableView.py68
-rw-r--r--silx/gui/data/NXdataWidgets.py82
-rw-r--r--silx/gui/data/_RecordPlot.py92
-rw-r--r--silx/gui/data/test/test_arraywidget.py4
-rw-r--r--silx/gui/dialog/ColormapDialog.py1712
-rw-r--r--silx/gui/dialog/DataFileDialog.py4
-rw-r--r--silx/gui/dialog/test/test_colormapdialog.py113
-rw-r--r--silx/gui/dialog/test/test_imagefiledialog.py4
-rw-r--r--silx/gui/fit/FitWidget.py40
-rwxr-xr-xsilx/gui/hdf5/test/test_hdf5.py10
-rw-r--r--silx/gui/plot/ColorBar.py112
-rw-r--r--silx/gui/plot/ComplexImageView.py8
-rw-r--r--silx/gui/plot/CurvesROIWidget.py13
-rw-r--r--silx/gui/plot/ImageStack.py586
-rw-r--r--silx/gui/plot/ImageView.py1
-rw-r--r--silx/gui/plot/Interaction.py120
-rw-r--r--silx/gui/plot/ItemsSelectionDialog.py32
-rwxr-xr-xsilx/gui/plot/LegendSelector.py10
-rw-r--r--silx/gui/plot/MaskToolsWidget.py16
-rw-r--r--silx/gui/plot/PlotInteraction.py396
-rw-r--r--silx/gui/plot/PlotToolButtons.py142
-rwxr-xr-xsilx/gui/plot/PlotWidget.py428
-rw-r--r--silx/gui/plot/PlotWindow.py71
-rw-r--r--silx/gui/plot/Profile.py824
-rw-r--r--silx/gui/plot/ProfileMainWindow.py68
-rw-r--r--silx/gui/plot/ScatterMaskToolsWidget.py14
-rw-r--r--silx/gui/plot/ScatterView.py19
-rw-r--r--silx/gui/plot/StackView.py134
-rw-r--r--silx/gui/plot/StatsWidget.py15
-rw-r--r--silx/gui/plot/_BaseMaskToolsWidget.py155
-rw-r--r--silx/gui/plot/actions/PlotToolAction.py4
-rwxr-xr-xsilx/gui/plot/actions/control.py35
-rw-r--r--silx/gui/plot/actions/fit.py359
-rw-r--r--silx/gui/plot/actions/histogram.py201
-rw-r--r--silx/gui/plot/actions/medfilt.py4
-rwxr-xr-xsilx/gui/plot/backends/BackendBase.py34
-rwxr-xr-xsilx/gui/plot/backends/BackendMatplotlib.py252
-rwxr-xr-xsilx/gui/plot/backends/BackendOpenGL.py78
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotCurve.py15
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotImage.py122
-rw-r--r--silx/gui/plot/backends/glutils/PlotImageFile.py6
-rw-r--r--silx/gui/plot/items/__init__.py9
-rw-r--r--silx/gui/plot/items/_pick.py6
-rw-r--r--silx/gui/plot/items/axis.py4
-rw-r--r--silx/gui/plot/items/complex.py11
-rw-r--r--silx/gui/plot/items/core.py223
-rw-r--r--silx/gui/plot/items/curve.py64
-rw-r--r--silx/gui/plot/items/histogram.py20
-rw-r--r--silx/gui/plot/items/image.py116
-rwxr-xr-xsilx/gui/plot/items/marker.py22
-rw-r--r--silx/gui/plot/items/roi.py3025
-rw-r--r--silx/gui/plot/items/scatter.py286
-rw-r--r--silx/gui/plot/items/shape.py109
-rw-r--r--silx/gui/plot/matplotlib/Colormap.py9
-rw-r--r--silx/gui/plot/matplotlib/__init__.py10
-rw-r--r--silx/gui/plot/test/__init__.py8
-rw-r--r--silx/gui/plot/test/testColorBar.py19
-rw-r--r--silx/gui/plot/test/testCurvesROIWidget.py37
-rw-r--r--silx/gui/plot/test/testImageStack.py197
-rw-r--r--silx/gui/plot/test/testInteraction.py22
-rw-r--r--silx/gui/plot/test/testItem.py4
-rw-r--r--silx/gui/plot/test/testPixelIntensityHistoAction.py55
-rwxr-xr-xsilx/gui/plot/test/testPlotWidget.py145
-rw-r--r--silx/gui/plot/test/testPlotWidgetNoBackend.py50
-rw-r--r--silx/gui/plot/test/testPlotWindow.py59
-rw-r--r--silx/gui/plot/test/testProfile.py287
-rw-r--r--silx/gui/plot/test/testStats.py35
-rw-r--r--silx/gui/plot/tools/CurveLegendsWidget.py8
-rw-r--r--silx/gui/plot/tools/PositionInfo.py9
-rw-r--r--silx/gui/plot/tools/profile/ScatterProfileToolBar.py179
-rw-r--r--silx/gui/plot/tools/profile/_BaseProfileToolBar.py430
-rw-r--r--silx/gui/plot/tools/profile/core.py522
-rw-r--r--silx/gui/plot/tools/profile/editors.py307
-rw-r--r--silx/gui/plot/tools/profile/manager.py1059
-rw-r--r--silx/gui/plot/tools/profile/rois.py1168
-rw-r--r--silx/gui/plot/tools/profile/toolbar.py172
-rw-r--r--silx/gui/plot/tools/roi.py403
-rw-r--r--silx/gui/plot/tools/test/__init__.py2
-rw-r--r--silx/gui/plot/tools/test/testProfile.py673
-rw-r--r--silx/gui/plot/tools/test/testROI.py191
-rw-r--r--silx/gui/plot/tools/test/testScatterProfileToolBar.py137
-rw-r--r--silx/gui/plot/tools/toolbars.py2
-rw-r--r--silx/gui/plot/utils/intersections.py101
-rw-r--r--silx/gui/plot3d/SFViewParamTree.py8
-rw-r--r--silx/gui/plot3d/_model/items.py75
-rw-r--r--silx/gui/plot3d/items/image.py4
-rw-r--r--silx/gui/plot3d/items/mesh.py5
-rw-r--r--silx/gui/plot3d/items/mixins.py38
-rw-r--r--silx/gui/plot3d/items/scatter.py7
-rw-r--r--silx/gui/plot3d/items/volume.py51
-rw-r--r--silx/gui/plot3d/scene/function.py93
-rw-r--r--silx/gui/plot3d/tools/GroupPropertiesWidget.py4
-rw-r--r--silx/gui/plot3d/utils/mng.py4
-rw-r--r--silx/gui/qt/_qt.py8
-rw-r--r--silx/gui/qt/_utils.py8
-rw-r--r--silx/gui/test/__init__.py5
-rwxr-xr-xsilx/gui/test/test_colors.py86
-rwxr-xr-xsilx/gui/utils/__init__.py17
-rw-r--r--silx/gui/utils/glutils.py199
-rwxr-xr-xsilx/gui/utils/qtutils.py26
-rwxr-xr-xsilx/gui/utils/test/__init__.py4
-rw-r--r--silx/gui/utils/test/test_glutils.py66
-rw-r--r--silx/gui/utils/testutils.py16
-rw-r--r--silx/gui/widgets/ElidedLabel.py137
-rwxr-xr-xsilx/gui/widgets/LegendIconWidget.py7
-rw-r--r--silx/gui/widgets/MultiModeAction.py83
-rw-r--r--silx/gui/widgets/RangeSlider.py8
-rw-r--r--silx/gui/widgets/UrlSelectionTable.py8
-rw-r--r--silx/gui/widgets/test/__init__.py2
-rw-r--r--silx/gui/widgets/test/test_elidedlabel.py111
-rw-r--r--silx/image/_boundingbox.py100
-rw-r--r--silx/image/medianfilter.py2
-rw-r--r--silx/image/test/__init__.py2
-rw-r--r--silx/image/test/test_bb.py86
-rw-r--r--silx/io/dictdump.py125
-rw-r--r--silx/io/nxdata/parse.py111
-rw-r--r--silx/io/octaveh5.py4
-rw-r--r--silx/io/test/test_dictdump.py190
-rwxr-xr-xsilx/io/test/test_fabioh5.py22
-rw-r--r--silx/io/test/test_nxdata.py7
-rw-r--r--silx/io/test/test_spectoh5.py11
-rw-r--r--silx/io/test/test_url.py9
-rw-r--r--silx/io/test/test_utils.py2
-rw-r--r--silx/io/url.py8
-rw-r--r--silx/io/utils.py6
-rw-r--r--silx/math/colormap.pyx336
-rw-r--r--silx/math/fft/test/test_fft.py10
-rw-r--r--silx/math/fit/fittheories.py7
-rw-r--r--silx/math/test/test_colormap.py88
-rw-r--r--silx/math/test/test_combo.py6
-rw-r--r--silx/opencl/codec/byte_offset.py4
-rw-r--r--silx/opencl/codec/test/test_byte_offset.py6
-rw-r--r--silx/opencl/convolution.py8
-rw-r--r--silx/opencl/test/test_convolution.py9
-rw-r--r--silx/opencl/test/test_linalg.py2
-rw-r--r--silx/resources/gui/icons/3d-plane-normal-x.svg12
-rw-r--r--silx/resources/gui/icons/3d-plane-normal-y.svg12
-rw-r--r--silx/resources/gui/icons/3d-plane-normal-z.svg16
-rw-r--r--silx/resources/gui/icons/3d-plane-pan.svg22
-rw-r--r--silx/resources/gui/icons/3d-plane.svg8
-rw-r--r--silx/resources/gui/icons/add-range-horizontal.pngbin0 -> 560 bytes
-rw-r--r--silx/resources/gui/icons/add-range-horizontal.svg2
-rw-r--r--silx/resources/gui/icons/add-shape-circle.pngbin0 -> 1238 bytes
-rw-r--r--silx/resources/gui/icons/add-shape-circle.svg2
-rw-r--r--silx/resources/gui/icons/add-shape-cross.pngbin0 -> 501 bytes
-rw-r--r--silx/resources/gui/icons/add-shape-cross.svg2
-rw-r--r--silx/resources/gui/icons/add-shape-ellipse.pngbin0 -> 1180 bytes
-rw-r--r--silx/resources/gui/icons/add-shape-ellipse.svg2
-rw-r--r--silx/resources/gui/icons/add-shape-point.pngbin494 -> 482 bytes
-rw-r--r--silx/resources/gui/icons/add-shape-point.svg2
-rw-r--r--silx/resources/gui/icons/axis.svg2
-rw-r--r--silx/resources/gui/icons/camera.svg12
-rw-r--r--silx/resources/gui/icons/colormap-histogram.svg48
-rw-r--r--silx/resources/gui/icons/colormap-none.svg43
-rw-r--r--silx/resources/gui/icons/colormap-norm-arcsinh.pngbin0 -> 648 bytes
-rw-r--r--silx/resources/gui/icons/colormap-norm-arcsinh.svg2
-rw-r--r--silx/resources/gui/icons/colormap-norm-gamma.pngbin0 -> 994 bytes
-rw-r--r--silx/resources/gui/icons/colormap-norm-gamma.svg2
-rw-r--r--silx/resources/gui/icons/colormap-norm-linear.pngbin0 -> 675 bytes
-rw-r--r--silx/resources/gui/icons/colormap-norm-linear.svg2
-rw-r--r--silx/resources/gui/icons/colormap-norm-log.pngbin0 -> 512 bytes
-rw-r--r--silx/resources/gui/icons/colormap-norm-log.svg2
-rw-r--r--silx/resources/gui/icons/colormap-norm-sqrt.pngbin0 -> 569 bytes
-rw-r--r--silx/resources/gui/icons/colormap-norm-sqrt.svg2
-rw-r--r--silx/resources/gui/icons/colormap-range.svg48
-rw-r--r--silx/resources/gui/icons/crosshair.svg2
-rw-r--r--silx/resources/gui/icons/cube-back.svg12
-rw-r--r--silx/resources/gui/icons/cube-bottom.svg12
-rw-r--r--silx/resources/gui/icons/cube-front.svg12
-rw-r--r--silx/resources/gui/icons/cube-left.svg12
-rw-r--r--silx/resources/gui/icons/cube-right.svg12
-rw-r--r--silx/resources/gui/icons/cube-rotate.svg18
-rw-r--r--silx/resources/gui/icons/cube-top.svg12
-rw-r--r--silx/resources/gui/icons/cube.svg14
-rw-r--r--silx/resources/gui/icons/first.svg24
-rw-r--r--silx/resources/gui/icons/image-mask.svg16
-rw-r--r--silx/resources/gui/icons/item-0dim.svg2
-rw-r--r--silx/resources/gui/icons/item-1dim.svg2
-rw-r--r--silx/resources/gui/icons/item-2dim.svg2
-rw-r--r--silx/resources/gui/icons/item-3dim.svg10
-rw-r--r--silx/resources/gui/icons/item-ndim.svg46
-rw-r--r--silx/resources/gui/icons/item-none.svg4
-rw-r--r--silx/resources/gui/icons/item-object.svg22
-rw-r--r--silx/resources/gui/icons/last.svg24
-rw-r--r--silx/resources/gui/icons/mask-clear-all.pngbin0 -> 1383 bytes
-rw-r--r--silx/resources/gui/icons/mask-clear-all.svg2
-rw-r--r--silx/resources/gui/icons/mask-clear.pngbin0 -> 1086 bytes
-rw-r--r--silx/resources/gui/icons/mask-clear.svg2
-rw-r--r--silx/resources/gui/icons/mask-invert.pngbin0 -> 717 bytes
-rw-r--r--silx/resources/gui/icons/mask-invert.svg2
-rw-r--r--silx/resources/gui/icons/math-peak-search.svg2
-rw-r--r--silx/resources/gui/icons/math-phase.svg2
-rw-r--r--silx/resources/gui/icons/median-filter.svg10
-rw-r--r--silx/resources/gui/icons/next.svg22
-rw-r--r--silx/resources/gui/icons/normal.svg2
-rw-r--r--silx/resources/gui/icons/pan.svg12
-rw-r--r--silx/resources/gui/icons/pixel-intensities.svg10
-rw-r--r--silx/resources/gui/icons/previous.svg22
-rw-r--r--silx/resources/gui/icons/profile1D.svg12
-rw-r--r--silx/resources/gui/icons/profile2D.svg18
-rw-r--r--silx/resources/gui/icons/rotate-3d.svg8
-rw-r--r--silx/resources/gui/icons/shape-cross.pngbin0 -> 356 bytes
-rw-r--r--silx/resources/gui/icons/shape-cross.svg2
-rw-r--r--silx/resources/gui/icons/shape-diagonal-directed.pngbin0 -> 542 bytes
-rw-r--r--silx/resources/gui/icons/shape-diagonal-directed.svg4
-rw-r--r--silx/resources/gui/icons/shape-ellipse.svg4
-rw-r--r--silx/resources/gui/icons/slice-cross.pngbin0 -> 1057 bytes
-rw-r--r--silx/resources/gui/icons/slice-cross.svg2
-rw-r--r--silx/resources/gui/icons/slice-horizontal.pngbin0 -> 967 bytes
-rw-r--r--silx/resources/gui/icons/slice-horizontal.svg4
-rw-r--r--silx/resources/gui/icons/slice-vertical.pngbin0 -> 1023 bytes
-rw-r--r--silx/resources/gui/icons/slice-vertical.svg2
-rw-r--r--silx/resources/gui/icons/tree-sort.pngbin0 -> 655 bytes
-rw-r--r--silx/resources/gui/icons/tree-sort.svg5
-rw-r--r--silx/resources/gui/icons/view-1d.svg10
-rw-r--r--silx/resources/gui/icons/view-2d-stack.svg6
-rw-r--r--silx/resources/gui/icons/view-2d.svg2
-rw-r--r--silx/resources/gui/icons/view-3d.svg10
-rw-r--r--silx/resources/gui/icons/view-hdf5.svg4
-rw-r--r--silx/resources/gui/icons/view-nexus.svg4
-rw-r--r--silx/resources/gui/icons/view-nofullscreen.svg2
-rw-r--r--silx/resources/gui/icons/view-raw.svg18
-rw-r--r--silx/resources/gui/icons/view-text.svg14
-rw-r--r--silx/resources/gui/icons/window-new.svg2
-rw-r--r--silx/sx/_plot.py1
-rw-r--r--silx/third_party/EdfFile.py6
-rw-r--r--silx/third_party/TiffIO.py6
-rw-r--r--silx/utils/ExternalResources.py21
-rw-r--r--silx/utils/array_like.py3
-rw-r--r--silx/utils/deprecation.py6
-rw-r--r--version.py4
318 files changed, 16921 insertions, 5789 deletions
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 0777568..e194827 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,106 @@
Change Log
==========
+0.13.1: 2020/07/22
+------------------
+
+Bug fix release:
+
+* `silx.gui.plot.dialog`: Fixed `ColormapDialog` custom range input (PR #3155)
+* Build: Fixed cython 3 compatibility (PR #3163).
+* Documentation: Update version number and changelog (PR #3156)
+
+
+0.13.0: 2020/06/23
+------------------
+
+This version drops the support of Python 2.7 and Python <= 3.4.
+
+* silx view application:
+
+ * Added support of compound data (PR #2948)
+ * Added `Close All` menu (PR #2963)
+ * Added default title to plots (PR #2979, #2999)
+ * Added a button to enable/disable file content sorting (PR #3132)
+ * Added support of a `SILX_style` HDF5 attribute to provide axes and colormap scale (PR #3092)
+ * Improved `HDF5TableView` information table to make text selectable and ease copy (PR #2903)
+ * Fixes (PR #2881, #2902, #3083)
+
+* `silx.gui`:
+
+ * `silx.gui.colors.Colormap`:
+
+ * Added mean+/-3std autoscale mode (PR #2877, #2900)
+ * Added sqrt, arcsinh and gamma correction colormap normalizations (PR #3010, #3054, #3057, #3066, #3070, #3133)
+ * Limit number of threads used for computing the colormap (PR #3073)
+ * Reordered colormaps (PR #3137)
+
+ * `silx.gui.dialog.ColormapDialog`: Improved widget (PR #2874, #2915, #2924, #2954, #3136)
+ * `silx.gui.plot`:
+
+ * Major rework/extension of the regions of interest (ROI) (PR #3007, #3008, #3018, #3020, #3022, #3026, #3029, #3044, #3045, #3055, #3059, #3074, #3076, #3078, #3079, #3081, #3131)
+ * Major rework/extension of the profile tools (PR #2933, #2980, #2988, #3004, #3011, #3037, #3048, #3058, #3084, #3088, #3095, #3097)
+ * Added `silx.gui.plot.ImageStack` widget (PR #2480)
+ * Added support of scatter in `PixelIntensitiesHistoAction` (PR #3089, #3107)
+ * Added auto update of `FitAction` fitted data and range (PR #2960, #2961, #2969, #2981)
+ * Improved mask tools (PR #2986)
+ * Fixed `PlotWindow` (PR #2965) and `MaskToolsWidget` (PR #3125)
+
+ * `silx.gui.plot.PlotWidget`:
+
+ * Changed behaviour of `PlotWidget.addItem` and `PlotWidget.removeItem` to handle object items (previous behavior deprecated, not removed) and added `PlotWidget.addShape` method to add `Shape` items (PR #2873, #2904, #2919, #2925, #3120)
+ * Added support of uint16 RGBA images (PR #2889)
+ * Improved interaction (PR #2909, #3014, #3033)
+ * Fixed `PlotWidget` (PR #2884, #2901, #2970, #3002)
+ * Fixed and cleaned-up backends (PR #2887, #2910, #2913, #2957, #2964, #2984, #2991, #3023, #3064, #3135)
+
+ * `silx.gui.plot.items`:
+
+ * Added `sigDragStarted` and `sigDragFinished` signals to marker items and `sigEditingStarted` and `sigEditingFinished` signals to region of interest items (PR #2754)
+ * Added `XAxisExtent` and `YAxisExtent` items in `silx.gui.plot.items` to control the plot data extent (PR #2932)
+ * Added `ImageStack` item (PR #2994)
+ * Added `Scatter` item histogram visualization mode (PR #2912, #2923)
+ * Added `isDragged` method to marker items (PR #3000)
+ * Improved performance of colormapped items by caching data min/max (PR #2876, #2886)
+ * Improved `Scatter` item regular grid (PR #2918) and irregular grid (PR #3108) visualizations
+
+ * `silx.gui.qt`:
+
+ * Changed behavior of `QObject` multiple-inheritance (PR #3052)
+ * Limit `silxGlobalThreadPool` function to use 4 threads maximum (PR #3072)
+
+ * `silx.gui.utils.glutils`: Added `isOpenGLAvailable` to check the availability of OpenGL (PR #2878)
+ * `silx.gui.widgets`:
+
+ * Added `ElidedLabel` widget (PR #3110, #3111)
+ * Fixed `LegendIconWidget` (PR #3112)
+
+* `silx.io`:
+
+ * Added support of signal dataset name-based errors to NXdata (PR #2976)
+ * Added `dicttonx` function and support of HDF5 attibutes in `dicttoh5` function (PR #3013, #3017, #3031, #3093)
+ * Fixed `url.DataUrl.path` (PR #2973)
+
+* `silx.opencl`:
+
+ * Fixed issue with Python 3.8 (PR #3036)
+ * Disable textures for Nvidia Fermi GPUs for `convolution` (PR #3101)
+
+* Miscellaneous:
+
+ * Requires fabio >= 0.9 (PR #2937)
+ * Fixed compatibility with h5py<v2.9 (PR #3024), cython 3 (PR #3034)
+ * Avoid deprecation warnings (PR #3104) from Python 3.7 (PR #3012), Python 3.8 (PR #2891, #2934, #2989, #2993, #3127), h5py (PR #2854, #2893), matplotlib (PR #2890), fabio (PR #2930) and numpy (PR #3129)
+ * Use `numpy.errstate` to ignore warnings rather than the `warnings` module (PR #2920)
+
+* Build, documentation and tests:
+
+ * Dropped Python2 support (PR #3119, #3140) and removed Python 2 tests and packaging (PR #2838, #2917)
+ * Added debian 11/Ubuntu 20.04 packaging (PR #2875)
+ * Improved test environment (PR #2870, #2949, #2995, #3009, #3061, #3086, #3087, #3122), documentation (PR #2872, #2894, #2937, #2987, #3042, #3053, #3068, #3091, #3103, #3115) and sample code (PR #2978, #3130, #3138)
+ * Fixed Windows "fat binary" build (PR #2971)
+
+
0.12.0: 2020/01/09
------------------
diff --git a/PKG-INFO b/PKG-INFO
index 1c225b2..74f97d5 100644
--- a/PKG-INFO
+++ b/PKG-INFO
@@ -1,6 +1,6 @@
Metadata-Version: 2.1
Name: silx
-Version: 0.12.0
+Version: 0.13.1
Summary: Software library for X-ray data analysis
Home-page: http://www.silx.org/
Author: data analysis unit
@@ -129,12 +129,9 @@ Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Cython
-Classifier: Programming Language :: Python :: 2.7
-Classifier: Programming Language :: Python :: 3.5
-Classifier: Programming Language :: Python :: 3.6
-Classifier: Programming Language :: Python :: 3.7
-Classifier: Programming Language :: Python :: 3.8
+Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: Scientific/Engineering :: Physics
Classifier: Topic :: Software Development :: Libraries :: Python Modules
+Requires-Python: >=3.5
Provides-Extra: full
diff --git a/build-deb.sh b/build-deb.sh
index 7ca6d7e..25718f3 100755
--- a/build-deb.sh
+++ b/build-deb.sh
@@ -3,7 +3,7 @@
# Project: Silx
# https://github.com/silx-kit/silx
#
-# Copyright (C) 2015-2019 European Synchrotron Radiation Facility, Grenoble, France
+# Copyright (C) 2015-2020 European Synchrotron Radiation Facility, Grenoble, France
#
# Principal author: Jérôme Kieffer (Jerome.Kieffer@ESRF.eu)
#
@@ -50,6 +50,9 @@ then
buster)
debian_version=10
;;
+ bullseye)
+ debian_version=11
+ ;;
esac
fi
@@ -76,14 +79,19 @@ If the build succeed the directory dist/debian${debian_version} will
contains the packages.
optional arguments:
- --help show this help text
- --install install the packages generated at the end of
- the process using 'sudo dpkg'
- --debian9 Simulate a debian 9 Stretch system
- --debian10 Simulate a debian 10 Buster system
+ --help Show this help text
+ --install Install the packages generated at the end of
+ the process using 'sudo dpkg'
+ --stdeb-py3 Build using stdeb for python3
+ --stdeb-py2.py3 Build using stdeb for python2 and python3
+ --debian9 Simulate a debian 9 Stretch system
+ --debian10 Simulate a debian 10 Buster system
+ --debian11 Simulate a debian 11 Bullseye system
"
install=0
+use_stdeb=0
+stdeb_all_python=0
while :
do
@@ -96,6 +104,16 @@ do
install=1
shift
;;
+ --stdeb-py2)
+ use_stdeb=1
+ stdeb_all_python=0
+ shift
+ ;;
+ --stdeb-py2.py3)
+ use_stdeb=1
+ stdeb_all_python=1
+ shift
+ ;;
--debian9)
debian_version=9
target_system=debian${debian_version}
@@ -103,6 +121,20 @@ do
build_directory=${project_directory}/build/${target_system}
shift
;;
+ --debian10)
+ debian_version=10
+ target_system=debian${debian_version}
+ dist_directory=${project_directory}/dist/${target_system}
+ build_directory=${project_directory}/build/${target_system}
+ shift
+ ;;
+ --debian11)
+ debian_version=11
+ target_system=debian${debian_version}
+ dist_directory=${project_directory}/dist/${target_system}
+ build_directory=${project_directory}/build/${target_system}
+ shift
+ ;;
-*)
echo "Error: Unknown option: $1" >&2
echo "$usage"
@@ -123,8 +155,7 @@ clean_up()
mkdir -p ${build_directory}
}
-build_deb () {
- echo "Build for debian 8 or newer using actual packaging"
+build_deb() {
tarname=${project}_${debianversion}.orig.tar.gz
clean_up
python3 setup.py debian_src
@@ -187,6 +218,9 @@ build_deb () {
10)
debian_name=buster
;;
+ 11)
+ debian_name=bullseye
+ ;;
esac
dch -v ${debianversion}-1 "upstream development build of ${project} ${version}"
@@ -212,10 +246,47 @@ build_deb () {
}
-build_deb
+build_stdeb () {
+ echo "Build for debian using stdeb"
+ tarname=${project}-${strictversion}.tar.gz
+ clean_up
+
+ python setup.py sdist
+ cp -f dist/${tarname} ${build_directory}
+ cd ${build_directory}
+ tar -xzf ${tarname}
+ cd ${project}-${strictversion}
+
+ if [ $stdeb_all_python -eq 1 ]; then
+ echo Using Python 2+3
+ python3 setup.py --command-packages=stdeb.command sdist_dsc --with-python2=True --with-python3=True --no-python3-scripts=True build --no-cython bdist_deb
+ rc=$?
+ else
+ echo Using Python 3
+ # bdist_deb feed /usr/bin using setup.py entry-points
+ python3 setup.py --command-packages=stdeb.command build --no-cython bdist_deb
+ rc=$?
+ fi
+
+ # move packages to dist directory
+ rm -rf ${dist_directory}
+ mkdir -p ${dist_directory}
+ mv -f deb_dist/*.deb ${dist_directory}
+
+ # back to the root
+ cd ../../..
+}
+
+
+if [ $use_stdeb -eq 1 ]; then
+ build_stdeb
+else
+ build_deb
+fi
+
if [ $install -eq 1 ]; then
- sudo su -c "dpkg -i ${dist_directory}/*.deb"
+ sudo -v su -c "dpkg -i ${dist_directory}/*.deb"
fi
exit "$rc"
diff --git a/debian/changelog b/debian/changelog
index aef7cef..3738c5b 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,14 +1,18 @@
-silx (0.12.0+dfsg-1) unstable; urgency=medium
+silx (0.13.1+dfsg-1) unstable; urgency=medium
- * New upstream version 0.12.0+dfsg
+ * New upstream version 0.13.1+dfsg (Closes: #966933)
+ * d/control: Standards-Version bumped to 4.1.3 (nothing to do)
+ * d/control: Rules-Requires-Root: no
+ * Demote documentation dependencies to B-D-I. (Closes: #946110)
+ Thanks Helmut Grohne for the patch
- -- Alexandre Marie <alexandre.marie@synchrotron-soleil.fr> Thu, 30 Jan 2020 11:48:55 +0100
+ -- Picca Frédéric-Emmanuel <picca@debian.org> Tue, 04 Aug 2020 10:25:26 +0200
-silx (0.12.0~b0+dfsg-1) UNRELEASED; urgency=medium
+silx (0.12.0+dfsg-1) unstable; urgency=medium
- * New upstream version 0.12.0~b0+dfsg
+ * New upstream version 0.12.0+dfsg
- -- Picca Frédéric-Emmanuel <picca@debian.org> Mon, 23 Dec 2019 13:49:12 +0100
+ -- Alexandre Marie <alexandre.marie@synchrotron-soleil.fr> Thu, 30 Jan 2020 11:48:55 +0100
silx (0.11.0+dfsg-3) unstable; urgency=medium
diff --git a/debian/control b/debian/control
index fa42f3d..1b5c4c1 100644
--- a/debian/control
+++ b/debian/control
@@ -11,7 +11,6 @@ Build-Depends: cython3 (>= 0.23.2),
dh-python,
help2man,
ipython3,
- pandoc <!nodoc>,
python3-all-dbg,
python3-all-dev,
python3-fabio,
@@ -21,7 +20,6 @@ Build-Depends: cython3 (>= 0.23.2),
python3-mako,
python3-matplotlib,
python3-matplotlib-dbg,
- python3-nbsphinx <!nodoc>,
python3-numpy,
python3-numpy-dbg,
python3-opengl,
@@ -38,14 +36,18 @@ Build-Depends: cython3 (>= 0.23.2),
python3-scipy,
python3-scipy-dbg,
python3-setuptools,
- python3-sphinx,
- python3-sphinxcontrib.programoutput,
xauth,
xvfb
-Standards-Version: 4.1.3
+Build-Depends-Indep:
+ pandoc <!nodoc>,
+ python3-nbsphinx <!nodoc>,
+ python3-sphinx,
+ python3-sphinxcontrib.programoutput,
+Standards-Version: 4.3.0
Vcs-Browser: https://salsa.debian.org/science-team/silx
Vcs-Git: https://salsa.debian.org/science-team/silx.git
Homepage: https://github.com/silx-kit/silx
+Rules-Requires-Root: no
Package: silx
Architecture: all
diff --git a/debian/rules b/debian/rules
index d086f63..19abca1 100755
--- a/debian/rules
+++ b/debian/rules
@@ -11,6 +11,8 @@ export PYBUILD_NAME=silx
export SPECFILE_USE_GNU_SOURCE=1
export SILX_FULL_INSTALL_REQUIRES=1
+DOPACKAGES=$(shell dh_listpackages)
+
DEB_HOST_MULTIARCH ?= $(shell dpkg-architecture -qDEB_HOST_MULTIARCH)
# Make does not offer a recursive wildcard function, so here's one:
@@ -24,7 +26,8 @@ ALL_PYX := $(call rwildcard,silx/,*.pyx)
PY3VER := $(shell py3versions -dv)
%:
- dh $@ --with python3,sphinxdoc --buildsystem=pybuild
+ dh $@ --with python3 $(DH_ADDONS) --buildsystem=pybuild
+build binary %-indep: DH_ADDONS=--with=sphinxdoc
override_dh_clean:
dh_clean
diff --git a/doc/source/conf.py b/doc/source/conf.py
index 18bfce2..86e7bfa 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -75,6 +75,7 @@ extensions = [
'sphinx.ext.mathjax',
'sphinx.ext.viewcode',
'sphinx.ext.doctest',
+ 'sphinx.ext.inheritance_diagram',
'sphinxext-archive',
'snapshotqt_directive',
'nbsphinx'
diff --git a/doc/source/install.rst b/doc/source/install.rst
index 8e5220b..8ed5136 100644
--- a/doc/source/install.rst
+++ b/doc/source/install.rst
@@ -10,11 +10,11 @@ This table summarizes the support matrix of silx:
+------------+--------------+---------------------+
| System | Python vers. | Qt and its bindings |
+------------+--------------+---------------------+
-| `Windows`_ | 3.5, 3.6-3.7 | PyQt5.6+, PySide2 |
+| `Windows`_ | 3.5-3.8 | PyQt5.6+, PySide2 |
+------------+--------------+---------------------+
-| `MacOS`_ | 2.7, 3.5-3.7 | PyQt5.6+, PySide2 |
+| `MacOS`_ | 3.5-3.8 | PyQt5.6+, PySide2 |
+------------+--------------+---------------------+
-| `Linux`_ | 2.7, 3.4-3.7 | PyQt5.3+, PySide2 |
+| `Linux`_ | 3.5-3.8 | PyQt5.3+, PySide2 |
+------------+--------------+---------------------+
For the description of *silx* dependencies, see the Dependencies_ section.
@@ -211,8 +211,8 @@ prompt.
MacOS
-----
-While Apple ships Python 2.7 by default on their operating systems, we recommend
-using Python 3.5 or newer to ease the installation of the Qt library.
+Apple ships Python 2.7 by default on their operating systems.
+You must install Python 3.5 or newer to install silx.
Then, install *silx* with ``pip``, see `Installing with pip`_::
diff --git a/doc/source/modules/gui/data/img/ArrayTableWidget.png b/doc/source/modules/gui/data/img/ArrayTableWidget.png
index 6ae9114..c879427 100644
--- a/doc/source/modules/gui/data/img/ArrayTableWidget.png
+++ b/doc/source/modules/gui/data/img/ArrayTableWidget.png
Binary files differ
diff --git a/doc/source/modules/gui/data/img/DataViewer.png b/doc/source/modules/gui/data/img/DataViewer.png
index c2185d3..a1fabb9 100644
--- a/doc/source/modules/gui/data/img/DataViewer.png
+++ b/doc/source/modules/gui/data/img/DataViewer.png
Binary files differ
diff --git a/doc/source/modules/gui/hdf5/getting_started.rst b/doc/source/modules/gui/hdf5/getting_started.rst
index 1a81a0a..6951247 100644
--- a/doc/source/modules/gui/hdf5/getting_started.rst
+++ b/doc/source/modules/gui/hdf5/getting_started.rst
@@ -86,7 +86,7 @@ We can use directly h5py Files, Groups and Datasets.
.. code-block:: python
import h5py
- h5 = h5py.File("test.h5")
+ h5 = h5py.File("test.h5", mode="r")
# We can use file
model.insertH5pyObject(h5)
diff --git a/doc/source/modules/gui/icons.rst b/doc/source/modules/gui/icons.rst
index ba7d89b..8a939ea 100644
--- a/doc/source/modules/gui/icons.rst
+++ b/doc/source/modules/gui/icons.rst
@@ -29,10 +29,18 @@ Available icons
- 3d-plane-pan
* - |3d-plane|
- 3d-plane
+ * - |add-range-horizontal|
+ - add-range-horizontal
* - |add-shape-arc|
- add-shape-arc
+ * - |add-shape-circle|
+ - add-shape-circle
+ * - |add-shape-cross|
+ - add-shape-cross
* - |add-shape-diagonal|
- add-shape-diagonal
+ * - |add-shape-ellipse|
+ - add-shape-ellipse
* - |add-shape-horizontal|
- add-shape-horizontal
* - |add-shape-point|
@@ -61,6 +69,16 @@ Available icons
- colormap-histogram
* - |colormap-none|
- colormap-none
+ * - |colormap-norm-arcsinh|
+ - colormap-norm-arcsinh
+ * - |colormap-norm-gamma|
+ - colormap-norm-gamma
+ * - |colormap-norm-linear|
+ - colormap-norm-linear
+ * - |colormap-norm-log|
+ - colormap-norm-log
+ * - |colormap-norm-sqrt|
+ - colormap-norm-sqrt
* - |colormap-range|
- colormap-range
* - |colormap|
@@ -173,6 +191,12 @@ Available icons
- last
* - |layer-nx|
- layer-nx
+ * - |mask-clear-all|
+ - mask-clear-all
+ * - |mask-clear|
+ - mask-clear
+ * - |mask-invert|
+ - mask-invert
* - |math-amplitude|
- math-amplitude
* - |math-average|
@@ -289,6 +313,10 @@ Available icons
- shape-circle-solid
* - |shape-circle|
- shape-circle
+ * - |shape-cross|
+ - shape-cross
+ * - |shape-diagonal-directed|
+ - shape-diagonal-directed
* - |shape-diagonal|
- shape-diagonal
* - |shape-ellipse-solid|
@@ -307,6 +335,12 @@ Available icons
- shape-vertical
* - |silx|
- silx
+ * - |slice-cross|
+ - slice-cross
+ * - |slice-horizontal|
+ - slice-horizontal
+ * - |slice-vertical|
+ - slice-vertical
* - |sliders-off|
- sliders-off
* - |sliders-on|
@@ -325,6 +359,8 @@ Available icons
- tree-collapse-all
* - |tree-expand-all|
- tree-expand-all
+ * - |tree-sort|
+ - tree-sort
* - |view-1d|
- view-1d
* - |view-2d-stack|
@@ -365,8 +401,12 @@ Available icons
.. |3d-plane-normal-z| image:: ../../../../silx/resources/gui/icons/3d-plane-normal-z.png
.. |3d-plane-pan| image:: ../../../../silx/resources/gui/icons/3d-plane-pan.png
.. |3d-plane| image:: ../../../../silx/resources/gui/icons/3d-plane.png
+.. |add-range-horizontal| image:: ../../../../silx/resources/gui/icons/add-range-horizontal.png
.. |add-shape-arc| image:: ../../../../silx/resources/gui/icons/add-shape-arc.png
+.. |add-shape-circle| image:: ../../../../silx/resources/gui/icons/add-shape-circle.png
+.. |add-shape-cross| image:: ../../../../silx/resources/gui/icons/add-shape-cross.png
.. |add-shape-diagonal| image:: ../../../../silx/resources/gui/icons/add-shape-diagonal.png
+.. |add-shape-ellipse| image:: ../../../../silx/resources/gui/icons/add-shape-ellipse.png
.. |add-shape-horizontal| image:: ../../../../silx/resources/gui/icons/add-shape-horizontal.png
.. |add-shape-point| image:: ../../../../silx/resources/gui/icons/add-shape-point.png
.. |add-shape-polygon| image:: ../../../../silx/resources/gui/icons/add-shape-polygon.png
@@ -381,6 +421,11 @@ Available icons
.. |colorbar| image:: ../../../../silx/resources/gui/icons/colorbar.png
.. |colormap-histogram| image:: ../../../../silx/resources/gui/icons/colormap-histogram.png
.. |colormap-none| image:: ../../../../silx/resources/gui/icons/colormap-none.png
+.. |colormap-norm-arcsinh| image:: ../../../../silx/resources/gui/icons/colormap-norm-arcsinh.png
+.. |colormap-norm-gamma| image:: ../../../../silx/resources/gui/icons/colormap-norm-gamma.png
+.. |colormap-norm-linear| image:: ../../../../silx/resources/gui/icons/colormap-norm-linear.png
+.. |colormap-norm-log| image:: ../../../../silx/resources/gui/icons/colormap-norm-log.png
+.. |colormap-norm-sqrt| image:: ../../../../silx/resources/gui/icons/colormap-norm-sqrt.png
.. |colormap-range| image:: ../../../../silx/resources/gui/icons/colormap-range.png
.. |colormap| image:: ../../../../silx/resources/gui/icons/colormap.png
.. |compare-align-auto| image:: ../../../../silx/resources/gui/icons/compare-align-auto.png
@@ -437,6 +482,9 @@ Available icons
.. |item-object| image:: ../../../../silx/resources/gui/icons/item-object.png
.. |last| image:: ../../../../silx/resources/gui/icons/last.png
.. |layer-nx| image:: ../../../../silx/resources/gui/icons/layer-nx.png
+.. |mask-clear-all| image:: ../../../../silx/resources/gui/icons/mask-clear-all.png
+.. |mask-clear| image:: ../../../../silx/resources/gui/icons/mask-clear.png
+.. |mask-invert| image:: ../../../../silx/resources/gui/icons/mask-invert.png
.. |math-amplitude| image:: ../../../../silx/resources/gui/icons/math-amplitude.png
.. |math-average| image:: ../../../../silx/resources/gui/icons/math-average.png
.. |math-derive| image:: ../../../../silx/resources/gui/icons/math-derive.png
@@ -495,6 +543,8 @@ Available icons
.. |selected| image:: ../../../../silx/resources/gui/icons/selected.png
.. |shape-circle-solid| image:: ../../../../silx/resources/gui/icons/shape-circle-solid.png
.. |shape-circle| image:: ../../../../silx/resources/gui/icons/shape-circle.png
+.. |shape-cross| image:: ../../../../silx/resources/gui/icons/shape-cross.png
+.. |shape-diagonal-directed| image:: ../../../../silx/resources/gui/icons/shape-diagonal-directed.png
.. |shape-diagonal| image:: ../../../../silx/resources/gui/icons/shape-diagonal.png
.. |shape-ellipse-solid| image:: ../../../../silx/resources/gui/icons/shape-ellipse-solid.png
.. |shape-ellipse| image:: ../../../../silx/resources/gui/icons/shape-ellipse.png
@@ -504,6 +554,9 @@ Available icons
.. |shape-square| image:: ../../../../silx/resources/gui/icons/shape-square.png
.. |shape-vertical| image:: ../../../../silx/resources/gui/icons/shape-vertical.png
.. |silx| image:: ../../../../silx/resources/gui/icons/silx.png
+.. |slice-cross| image:: ../../../../silx/resources/gui/icons/slice-cross.png
+.. |slice-horizontal| image:: ../../../../silx/resources/gui/icons/slice-horizontal.png
+.. |slice-vertical| image:: ../../../../silx/resources/gui/icons/slice-vertical.png
.. |sliders-off| image:: ../../../../silx/resources/gui/icons/sliders-off.png
.. |sliders-on| image:: ../../../../silx/resources/gui/icons/sliders-on.png
.. |spec| image:: ../../../../silx/resources/gui/icons/spec.png
@@ -513,6 +566,7 @@ Available icons
.. |stats-whole-items| image:: ../../../../silx/resources/gui/icons/stats-whole-items.png
.. |tree-collapse-all| image:: ../../../../silx/resources/gui/icons/tree-collapse-all.png
.. |tree-expand-all| image:: ../../../../silx/resources/gui/icons/tree-expand-all.png
+.. |tree-sort| image:: ../../../../silx/resources/gui/icons/tree-sort.png
.. |view-1d| image:: ../../../../silx/resources/gui/icons/view-1d.png
.. |view-2d-stack| image:: ../../../../silx/resources/gui/icons/view-2d-stack.png
.. |view-2d| image:: ../../../../silx/resources/gui/icons/view-2d.png
diff --git a/doc/source/modules/gui/plot/getting_started.rst b/doc/source/modules/gui/plot/getting_started.rst
index 899d262..c105395 100644
--- a/doc/source/modules/gui/plot/getting_started.rst
+++ b/doc/source/modules/gui/plot/getting_started.rst
@@ -20,7 +20,7 @@ For a complete description of the API, see :mod:`silx.gui.plot`.
Use :mod:`silx.gui.plot` from (I)Python console
-----------------------------------------------
-We recommend to use (I)Python 3.x and PyQt5.
+We recommend to use (I)Python >=3.5 and PyQt5.
From a Python or IPython interpreter, the simplest way is to import the :mod:`silx.sx` module:
@@ -48,10 +48,6 @@ the way silx loads Qt and the way IPython is doing it through the ``--gui`` opti
`%pylab <http://ipython.org/ipython-doc/stable/interactive/magics.html#magic-pylab>`_ magics.
In this case, IPython magics that initialize Qt might not work after importing modules from silx.gui.
-When using Python2.7 and PyQt4, there is another incompatibility to deal with as
-silx requires PyQt4 API version 2 (See note below for explanation).
-In this case, start IPython with the ``QT_API`` environment variable set to ``pyqt``.
-
On Linux and MacOS X, run from the command line::
QT_API=pyqt ipython
@@ -61,16 +57,6 @@ On Windows, run from the command line::
set QT_API=pyqt&&ipython
-.. note:: PyQt4 used from Python 2.x provides 2 incompatible versions of QString and QVariant:
-
- - version 1, the legacy version which is also the default, and
- - version 2, a more pythonic one, which is the only one supported by *silx*.
-
- All other configurations (i.e., PyQt4 on Python 3.x, PySide2, PyQt5, IPython QtConsole widget) uses version 2.
-
- For more information, see `IPython, PyQt and PySide <http://ipython.org/ipython-doc/stable/interactive/reference.html#pyqt-and-pyside>`_.
-
-
Plot functions
++++++++++++++
diff --git a/doc/source/modules/gui/plot/img/BasicGridStatsWidget.png b/doc/source/modules/gui/plot/img/BasicGridStatsWidget.png
index 53ddc0e..bc675f0 100644
--- a/doc/source/modules/gui/plot/img/BasicGridStatsWidget.png
+++ b/doc/source/modules/gui/plot/img/BasicGridStatsWidget.png
Binary files differ
diff --git a/doc/source/modules/gui/plot/img/BasicStatsWidget.png b/doc/source/modules/gui/plot/img/BasicStatsWidget.png
index c9ed2cd..b0d815d 100644
--- a/doc/source/modules/gui/plot/img/BasicStatsWidget.png
+++ b/doc/source/modules/gui/plot/img/BasicStatsWidget.png
Binary files differ
diff --git a/doc/source/modules/gui/plot/img/LimitsToolBar.png b/doc/source/modules/gui/plot/img/LimitsToolBar.png
index 54b6c2b..ede66c8 100644
--- a/doc/source/modules/gui/plot/img/LimitsToolBar.png
+++ b/doc/source/modules/gui/plot/img/LimitsToolBar.png
Binary files differ
diff --git a/doc/source/modules/gui/plot/img/logColorbar.png b/doc/source/modules/gui/plot/img/logColorbar.png
index c677a9b..cde1ad9 100644
--- a/doc/source/modules/gui/plot/img/logColorbar.png
+++ b/doc/source/modules/gui/plot/img/logColorbar.png
Binary files differ
diff --git a/doc/source/modules/gui/plot/index.rst b/doc/source/modules/gui/plot/index.rst
index 01cb29b..b6c2000 100644
--- a/doc/source/modules/gui/plot/index.rst
+++ b/doc/source/modules/gui/plot/index.rst
@@ -54,7 +54,7 @@ Additionnal plot tool widgets:
actions/index.rst
plottoolbuttons.rst
- tools.rst
+ tools/index.rst
profile.rst
roi.rst
printpreviewtoolbutton.rst
diff --git a/doc/source/modules/gui/plot/img/CurveLegendsWidget.png b/doc/source/modules/gui/plot/tools/img/CurveLegendsWidget.png
index e7fa9f8..e7fa9f8 100644
--- a/doc/source/modules/gui/plot/img/CurveLegendsWidget.png
+++ b/doc/source/modules/gui/plot/tools/img/CurveLegendsWidget.png
Binary files differ
diff --git a/doc/source/modules/gui/plot/img/linearColorbar.png b/doc/source/modules/gui/plot/tools/img/linearColorbar.png
index 26621ce..26621ce 100644
--- a/doc/source/modules/gui/plot/img/linearColorbar.png
+++ b/doc/source/modules/gui/plot/tools/img/linearColorbar.png
Binary files differ
diff --git a/doc/source/modules/gui/plot/tools.rst b/doc/source/modules/gui/plot/tools/index.rst
index d7c96b5..c75aea5 100644
--- a/doc/source/modules/gui/plot/tools.rst
+++ b/doc/source/modules/gui/plot/tools/index.rst
@@ -1,10 +1,23 @@
+.. currentmodule:: silx.gui.plot.tools
+
:mod:`~silx.gui.plot.tools`: Tool widgets for PlotWidget
========================================================
-.. currentmodule:: silx.gui.plot.tools
-
.. automodule:: silx.gui.plot.tools
+Tools API
+---------
+
+Tools are divided into the following sub-modules:
+
+.. toctree::
+ :maxdepth: 1
+
+ profile.rst
+
+Other tools API
+---------------
+
:class:`PositionInfo` class
---------------------------
@@ -91,21 +104,6 @@
.. autoclass:: RegionOfInterestTableWidget
:members:
-:mod:`~silx.gui.plot.tools.profile`: Profile Tools
---------------------------------------------------
-
-.. automodule:: silx.gui.plot.tools.profile
-
-.. currentmodule:: silx.gui.plot.tools.profile
-
-:class:`ScatterProfileToolBar`
-++++++++++++++++++++++++++++++
-
-.. autoclass:: ScatterProfileToolBar
- :members: sigProfileChanged, getProfilePoints, getProfileValues, getProfileTitle, getPlotWidget, isDefaultProfileWindowEnabled, setDefaultProfileWindowEnabled, getDefaultProfileWindow, getColor, setColor, clearProfile, getNPoints, setNPoints
-
-.. currentmodule:: silx.gui.plot
-
:mod:`ColorBar`: ColorBar Widget
================================
diff --git a/doc/source/modules/gui/plot/tools/profile.rst b/doc/source/modules/gui/plot/tools/profile.rst
new file mode 100644
index 0000000..32d8a26
--- /dev/null
+++ b/doc/source/modules/gui/plot/tools/profile.rst
@@ -0,0 +1,84 @@
+.. currentmodule:: silx.gui.plot.tools.profile
+
+:mod:`~silx.gui.plot.tools.profile`: Profile tool for PlotWidget
+================================================================
+
+.. automodule:: silx.gui.plot.tools.profile
+
+The profile package is divided into several sub-modules.
+
+:mod:`~silx.gui.plot.tools.profile.manager` module
+--------------------------------------------------
+
+.. automodule:: silx.gui.plot.tools.profile.manager
+
+:class:`ProfileManager` class
++++++++++++++++++++++++++++++
+
+.. autoclass:: ProfileManager
+ :show-inheritance:
+ :members:
+
+:class:`ProfileWindow` class
+++++++++++++++++++++++++++++
+
+.. autoclass:: ProfileWindow
+ :show-inheritance:
+ :members:
+
+:mod:`~silx.gui.plot.tools.profile.editors` module
+--------------------------------------------------
+
+.. automodule:: silx.gui.plot.tools.profile.editors
+
+:class:`ProfileRoiEditorAction` class
++++++++++++++++++++++++++++++++++++++
+
+.. autoclass:: ProfileRoiEditorAction
+ :show-inheritance:
+ :members:
+
+:mod:`~silx.gui.plot.tools.profile.core` module
+-----------------------------------------------
+
+.. automodule:: silx.gui.plot.tools.profile.core
+
+:class:`ProfileRoiMixIn` class
+++++++++++++++++++++++++++++++
+
+.. autoclass:: ProfileRoiMixIn
+ :show-inheritance:
+ :members:
+
+:class:`CurveProfileData` class
++++++++++++++++++++++++++++++++
+
+.. autoclass:: CurveProfileData
+ :show-inheritance:
+ :members:
+
+
+:class:`ImageProfileData` class
++++++++++++++++++++++++++++++++
+
+.. autoclass:: ImageProfileData
+ :show-inheritance:
+ :members:
+
+:mod:`~silx.gui.plot.tools.profile.ScatterProfileToolBar` module
+----------------------------------------------------------------
+
+.. automodule:: silx.gui.plot.tools.profile.ScatterProfileToolBar
+
+:class:`ScatterProfileToolBar`
+++++++++++++++++++++++++++++++
+
+.. autoclass:: ScatterProfileToolBar
+ :show-inheritance:
+ :members:
+
+:mod:`~silx.gui.plot.tools.profile.rois` module
+-----------------------------------------------
+
+.. automodule:: silx.gui.plot.tools.profile.rois
+
diff --git a/doc/source/modules/gui/widgets/img/FrameBrowser.png b/doc/source/modules/gui/widgets/img/FrameBrowser.png
index 3843e70..17b355a 100644
--- a/doc/source/modules/gui/widgets/img/FrameBrowser.png
+++ b/doc/source/modules/gui/widgets/img/FrameBrowser.png
Binary files differ
diff --git a/doc/source/modules/gui/widgets/img/HorizontalSliderWithBrowser.png b/doc/source/modules/gui/widgets/img/HorizontalSliderWithBrowser.png
index 8cfdb25..96edd3c 100644
--- a/doc/source/modules/gui/widgets/img/HorizontalSliderWithBrowser.png
+++ b/doc/source/modules/gui/widgets/img/HorizontalSliderWithBrowser.png
Binary files differ
diff --git a/doc/source/modules/gui/widgets/img/PeriodicCombo.png b/doc/source/modules/gui/widgets/img/PeriodicCombo.png
index 4a93e86..644e502 100644
--- a/doc/source/modules/gui/widgets/img/PeriodicCombo.png
+++ b/doc/source/modules/gui/widgets/img/PeriodicCombo.png
Binary files differ
diff --git a/doc/source/modules/gui/widgets/img/PeriodicList.png b/doc/source/modules/gui/widgets/img/PeriodicList.png
index 42f432e..5ec741f 100644
--- a/doc/source/modules/gui/widgets/img/PeriodicList.png
+++ b/doc/source/modules/gui/widgets/img/PeriodicList.png
Binary files differ
diff --git a/doc/source/modules/gui/widgets/img/PeriodicTable.png b/doc/source/modules/gui/widgets/img/PeriodicTable.png
index ce52262..a521bd7 100644
--- a/doc/source/modules/gui/widgets/img/PeriodicTable.png
+++ b/doc/source/modules/gui/widgets/img/PeriodicTable.png
Binary files differ
diff --git a/doc/source/modules/gui/widgets/img/RangeSlider.png b/doc/source/modules/gui/widgets/img/RangeSlider.png
index f552fb3..e7a1011 100644
--- a/doc/source/modules/gui/widgets/img/RangeSlider.png
+++ b/doc/source/modules/gui/widgets/img/RangeSlider.png
Binary files differ
diff --git a/doc/source/modules/gui/widgets/img/TableWidget.png b/doc/source/modules/gui/widgets/img/TableWidget.png
index b6a4965..de78687 100644
--- a/doc/source/modules/gui/widgets/img/TableWidget.png
+++ b/doc/source/modules/gui/widgets/img/TableWidget.png
Binary files differ
diff --git a/doc/source/modules/gui/widgets/img/ThreadPoolPushButton.png b/doc/source/modules/gui/widgets/img/ThreadPoolPushButton.png
index 18a7416..5bdebee 100644
--- a/doc/source/modules/gui/widgets/img/ThreadPoolPushButton.png
+++ b/doc/source/modules/gui/widgets/img/ThreadPoolPushButton.png
Binary files differ
diff --git a/doc/source/modules/gui/widgets/img/WaitingPushButton.png b/doc/source/modules/gui/widgets/img/WaitingPushButton.png
index 4fd3e3c..9bab0fa 100644
--- a/doc/source/modules/gui/widgets/img/WaitingPushButton.png
+++ b/doc/source/modules/gui/widgets/img/WaitingPushButton.png
Binary files differ
diff --git a/doc/source/overview.rst b/doc/source/overview.rst
index 9e4fc28..93db23a 100644
--- a/doc/source/overview.rst
+++ b/doc/source/overview.rst
@@ -9,17 +9,17 @@ ManyLinux1.
Debian packages of released versions are made available in the following places:
- `Wheels and source code on PyPi <https://pypi.python.org/pypi/silx>`_
-- `Debian 8 packages <http://www.silx.org/pub/debian/>`_
+- `Debian 9 packages <http://www.silx.org/pub/debian/>`_
- `Documentation on silx.org <http://www.silx.org/doc/silx/latest/>`_
- :doc:`changelog`
Nightly builds
--------------
-Debian 8 packages and documentation are automatically generated from the tip of
+Debian 9 packages and documentation are automatically generated from the tip of
the project's repository on a daily basis:
-- `Debian 8 packages <http://www.silx.org/pub/debian/>`_
+- `Debian 9 packages <http://www.silx.org/pub/debian/>`_
- `Documentation <http://www.silx.org/doc/silx/dev/>`_
Project
diff --git a/doc/source/sample_code/img/customSilxView.png b/doc/source/sample_code/img/customSilxView.png
new file mode 100644
index 0000000..5ffa457
--- /dev/null
+++ b/doc/source/sample_code/img/customSilxView.png
Binary files differ
diff --git a/doc/source/sample_code/img/imageStack.png b/doc/source/sample_code/img/imageStack.png
new file mode 100644
index 0000000..ac23703
--- /dev/null
+++ b/doc/source/sample_code/img/imageStack.png
Binary files differ
diff --git a/doc/source/sample_code/img/plotProfile.png b/doc/source/sample_code/img/plotProfile.png
new file mode 100644
index 0000000..2a74338
--- /dev/null
+++ b/doc/source/sample_code/img/plotProfile.png
Binary files differ
diff --git a/doc/source/sample_code/index.rst b/doc/source/sample_code/index.rst
index 082579b..15bd4c7 100644
--- a/doc/source/sample_code/index.rst
+++ b/doc/source/sample_code/index.rst
@@ -151,6 +151,15 @@ Widgets
--debug Set logging system in debug mode
--testdata Use synthetic images to test the application
--use-opengl-plot Use OpenGL for plots (instead of matplotlib)
+ * - :download:`imageStack.py <../../../examples/imageStack.py>`
+ - .. image:: img/imageStack.png
+ :width: 150px
+ - Simple example for using the ImageStack.
+
+ In this example we want to display images from different source: .h5, .edf
+ and .npy files.
+
+ To do so we simple reimplement the thread managing the loading of data.
:class:`silx.gui.plot.actions.PlotAction`
@@ -274,6 +283,11 @@ Sample code that adds specific tools or functions to :class:`~silx.gui.plot.Plot
.. note:: for now the possible types manged by the Stats are ('curve', 'image',
'scatter' and 'histogram')
+ * - :download:`plotProfile.py <../../../examples/plotProfile.py>`
+ - .. image:: img/plotProfile.png
+ :width: 150px
+ - Example illustrating the different profile tools.
+
:class:`~silx.gui.plot.PlotWidget` features
...........................................
@@ -346,7 +360,11 @@ Sample code that illustrates some functionalities of :class:`~silx.gui.plot.Plot
* - :download:`dropZones.py <../../../examples/dropZones.py>`
- .. image:: img/dropZones.png
:width: 150px
- - Example of drop zone supporting application/x-silx-uri
+ - Example of drop zone supporting application/x-silx-uri.
+
+ This example illustrates the support of drag&drop of silx URLs.
+ It provides 2 URLs (corresponding to 2 datasets) that can be dragged to
+ either a :class:`PlotWidget` or a QLable displaying the URL information.
* - :download:`exampleBaseline.py <../../../examples/exampleBaseline.py>`
- .. image:: img/exampleBaseline.png
:width: 150px
@@ -453,3 +471,18 @@ Sample code that illustrates some functionalities of :class:`~silx.gui.plot.Plot
.. note:: This module has an optional dependency with sci-kit image library.
You might need to install it if you don't already have it.
+
+:mod:`silx.app` sample code
++++++++++++++++++++++++++++
+
+.. list-table::
+ :widths: 1 1 4
+ :header-rows: 1
+
+ * - Source
+ - Screenshot
+ - Description
+ * - :download:`customSilxView.py <../../../examples/customSilxView.py>`
+ - .. image:: img/customSilxView.png
+ :width: 150px
+ - Sample code illustrating how to custom silx view into another application.
diff --git a/doc/source/troubleshooting.rst b/doc/source/troubleshooting.rst
index 95917eb..43cdea2 100644
--- a/doc/source/troubleshooting.rst
+++ b/doc/source/troubleshooting.rst
@@ -12,7 +12,7 @@ Some widgets in :mod:`silx.gui` are using OpenGL2.1:
When running applications based on OpenGL2.1 through ssh, there are a few situations that can prevent the display of OpenGL widgets:
- Make sure to use ``ssh -X`` to enable X11 forwarding.
-- OpenGL is disabled with X11 forwarding (the default on Debian 8 and 9). See `Enabling OpenGL forwarding`_.
+- OpenGL is disabled with X11 forwarding (the default on Debian). See `Enabling OpenGL forwarding`_.
- Unless the operating system is using `libglvnd <https://github.com/NVIDIA/libglvnd/releases>`_
(available from Debian 9 backports onward),
both the server and the client computers must have the same kind of GPU drivers
diff --git a/doc/source/virtualenv.rst b/doc/source/virtualenv.rst
index 3d86617..ccdd9b6 100644
--- a/doc/source/virtualenv.rst
+++ b/doc/source/virtualenv.rst
@@ -12,30 +12,12 @@ Prerequisites
This guide assumes that your system meets the following requirements:
- - a version of python compatible with *silx* is installed (python 2.7 or python >= 3.5)
+ - a version of python compatible with *silx* is installed (Python >= 3.5)
- the *pip* installer for python packages is installed
- - the Qt and PyQt libraries are installed (optional, required for using ``silx.gui``)
Installation procedure
----------------------
-
-Install vitrualenv
-******************
-
-.. code-block:: bash
-
- pip install virtualenv --user
-
-.. note::
-
- This step is not required for recent version of Python 3.
- Virtual environments are created using a builtin standard library,
- ``venv``.
- On Debian platforms, you might need to install the ``python3-venv``
- package.
-
-
Create a virtualenv
*******************
@@ -51,20 +33,12 @@ a virtual environment named ``silx_venv``
cd
mkdir -p venvs
cd venvs
- virtualenv silx_venv
-
+ python -m venv silx_venv
A virtualenv contains a copy of your default python interpreter with a few tools
to install packages (pip, setuptools).
-To use a different python interpreter, you can specify it on the command line.
-For example, to use python 3.4:
-
-.. code-block:: bash
-
- virtualenv -p /usr/bin/python3.4 silx_venv
-
-But for python 3 you should use the builtin ``venv`` module:
+Virtual environments are created using a builtin standard library, ``venv`` (Python3 only):
.. code-block:: bash
@@ -72,11 +46,20 @@ But for python 3 you should use the builtin ``venv`` module:
.. note::
+ On Debian platforms, you might need to install the ``python3-venv`` package.
+
If you don't need to start with a clean environment and you don't want
to install each required library one by one, you can use a command line
option to create a virtualenv with access to all system packages:
``--system-site-packages``
+To use a different python interpreter, use it to create the virtual environment.
+For example, to use python 3.5:
+
+.. code-block:: bash
+
+ /usr/bin/python3.5 -m venv silx_venv
+
Activate a virtualenv
*********************
@@ -128,64 +111,28 @@ install *silx*:
.. since 0.5, numpy is now automatically installed when doing `pip install silx`
-Install optional dependencies
-*****************************
-
-The following command installs libraries that are needed by various modules
-of *silx*:
-
-.. code-block:: bash
-
- pip install matplotlib fabio h5py
-
-The next command installs libraries that are used by the python modules
-handling parallel computing:
-
-.. code-block:: bash
-
- pip install pyopencl mako
-
-
-Install pyqt
+Install silx
************
-If your python version is 3.5 or newer, installing PyQt5 and all required packages
-is as simple as typing:
-
-.. code-block:: bash
-
- pip install PyQt5
-
-For previous versions of python, there are no PyQt wheels available, so the installation
-is not as simple.
-
-The simplest way, assuming that PyQt is installed on your system, is to use that
-system package directly. For this, you need to add a symbolic link to your virtualenv.
-
-If you want to use PyQt5 installed in ``/usr/lib/python2.7/dist-packages/``, type:
+To install silx with minimal dependencies, run:
.. code-block:: bash
- ln -s /usr/lib/python2.7/dist-packages/PyQt5 silx_venv/lib/python2.7/site-packages/
- ln -s /usr/lib/python2.7/dist-packages/sip.so silx_venv/lib/python2.7/site-packages/
-
+ pip install silx
-Install silx
-************
+To install silx with all dependencies, run:
.. code-block:: bash
- pip install silx
-
+ pip install silx[full]
-To test *silx*, open an interactive python console. If you managed to install PyQt5 or PySide2
-in your virtualenv, type:
+To test *silx*, open an interactive python console:
.. code-block:: bash
python
-If you don't have PyQt, use:
+If you don't have PyQt5 or PySide2, run:
.. code-block:: bash
@@ -195,9 +142,3 @@ Run the test suite using:
>>> import silx.test
>>> silx.test.run_tests()
-
-
-
-
-
-
diff --git a/examples/colormapDialog.py b/examples/colormapDialog.py
index 08e3fe8..c9e7c35 100644
--- a/examples/colormapDialog.py
+++ b/examples/colormapDialog.py
@@ -101,43 +101,46 @@ class ColormapDialogExample(qt.QMainWindow):
layout.addSpacing(10)
- button = qt.QPushButton("Set no histogram")
+ button = qt.QPushButton("No histogram")
button.clicked.connect(self.setNoHistogram)
layout.addWidget(button)
- button = qt.QPushButton("Set positive histogram")
+ button = qt.QPushButton("Positive histogram")
button.clicked.connect(self.setPositiveHistogram)
layout.addWidget(button)
- button = qt.QPushButton("Set neg-pos histogram")
+ button = qt.QPushButton("Neg-pos histogram")
button.clicked.connect(self.setNegPosHistogram)
layout.addWidget(button)
- button = qt.QPushButton("Set negative histogram")
+ button = qt.QPushButton("Negative histogram")
button.clicked.connect(self.setNegativeHistogram)
layout.addWidget(button)
layout.addSpacing(10)
- button = qt.QPushButton("Set no range")
+ button = qt.QPushButton("No range")
button.clicked.connect(self.setNoRange)
layout.addWidget(button)
- button = qt.QPushButton("Set positive range")
+ button = qt.QPushButton("Positive range")
button.clicked.connect(self.setPositiveRange)
layout.addWidget(button)
- button = qt.QPushButton("Set neg-pos range")
+ button = qt.QPushButton("Neg-pos range")
button.clicked.connect(self.setNegPosRange)
layout.addWidget(button)
- button = qt.QPushButton("Set negative range")
+ button = qt.QPushButton("Negative range")
button.clicked.connect(self.setNegativeRange)
layout.addWidget(button)
layout.addSpacing(10)
- button = qt.QPushButton("Set no data")
+ button = qt.QPushButton("No data")
button.clicked.connect(self.setNoData)
layout.addWidget(button)
- button = qt.QPushButton("Set shepp logan phantom")
+ button = qt.QPushButton("Zero to positive")
button.clicked.connect(self.setSheppLoganPhantom)
layout.addWidget(button)
- button = qt.QPushButton("Set data with non finite")
+ button = qt.QPushButton("Negative to positive")
+ button.clicked.connect(self.setDataFromNegToPos)
+ layout.addWidget(button)
+ button = qt.QPushButton("Only non finite values")
button.clicked.connect(self.setDataWithNonFinite)
layout.addWidget(button)
@@ -240,7 +243,14 @@ class ColormapDialogExample(qt.QMainWindow):
data = numpy.random.poisson(data)
self.data = data
for dialog in self.colorDialogs:
- dialog.setData(data)
+ dialog.setData(self.data)
+
+ def setDataFromNegToPos(self):
+ data = numpy.ones((50,50))
+ data = numpy.random.poisson(data)
+ self.data = data - 0.5
+ for dialog in self.colorDialogs:
+ dialog.setData(self.data)
def setDataWithNonFinite(self):
from silx.image import phantomgenerator
@@ -255,7 +265,7 @@ class ColormapDialogExample(qt.QMainWindow):
data[100] = float("-inf")
self.data = data
for dialog in self.colorDialogs:
- dialog.setData(data)
+ dialog.setData(self.data)
def main():
diff --git a/examples/customSilxView.py b/examples/customSilxView.py
new file mode 100644
index 0000000..c240280
--- /dev/null
+++ b/examples/customSilxView.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2020 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.
+#
+# ###########################################################################*/
+"""Sample code illustrating how to custom silx view into another application.
+"""
+
+import sys
+import numpy
+
+
+def createWindow(parent, settings):
+ # Local import to avoid early import (like h5py)
+ # SOme libraries have to be configured first properly
+ from silx.gui.plot.actions import PlotAction
+ from silx.app.view.Viewer import Viewer
+ from silx.app.view.ApplicationContext import ApplicationContext
+
+ class RandomColorAction(PlotAction):
+ def __init__(self, plot, parent=None):
+ super(RandomColorAction, self).__init__(
+ plot, icon="colormap", text='Color',
+ tooltip='Random plot background color',
+ triggered=self.__randomColor,
+ checkable=False, parent=parent)
+
+ def __randomColor(self):
+ color = "#%06X" % numpy.random.randint(0xFFFFFF)
+ self.plot.setBackgroundColor(color)
+
+ class MyApplicationContext(ApplicationContext):
+ """This class is shared to all the silx view application."""
+
+ def findPrintToolBar(self, plot):
+ # FIXME: It would be better to use the Qt API
+ return plot._outputToolBar
+
+ def viewWidgetCreated(self, view, widget):
+ """Called when the widget of the view was created.
+
+ So we can custom it.
+ """
+ from silx.gui.plot import Plot1D
+ if isinstance(widget, Plot1D):
+ toolBar = self.findPrintToolBar(widget)
+ action = RandomColorAction(widget, widget)
+ toolBar.addAction(action)
+
+ class MyViewer(Viewer):
+ def createApplicationContext(self, settings):
+ return MyApplicationContext(self, settings)
+
+ window = MyViewer(parent=parent, settings=settings)
+ window.setWindowTitle(window.windowTitle() + " [custom]")
+ return window
+
+
+def main(args):
+ from silx.app.view import main as silx_view_main
+ # Monkey patch the main window creation
+ silx_view_main.createWindow = createWindow
+ # Use the default launcher
+ silx_view_main.main(args)
+
+
+if __name__ == '__main__':
+ main(sys.argv)
diff --git a/examples/dropZones.py b/examples/dropZones.py
index 27d9df8..68d0a57 100644
--- a/examples/dropZones.py
+++ b/examples/dropZones.py
@@ -2,7 +2,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -24,7 +24,11 @@
#
# ###########################################################################*/
"""
-Example of drop zone supporting application/x-silx-uri
+Example of drop zone supporting application/x-silx-uri.
+
+This example illustrates the support of drag&drop of silx URLs.
+It provides 2 URLs (corresponding to 2 datasets) that can be dragged to
+either a :class:`PlotWidget` or a QLable displaying the URL information.
"""
from __future__ import absolute_import
@@ -34,6 +38,12 @@ __license__ = "MIT"
__date__ = "25/01/2019"
import logging
+import os
+import tempfile
+
+import h5py
+import numpy
+
import silx.io
from silx.gui import qt
from silx.gui.plot.PlotWidget import PlotWidget
@@ -43,6 +53,7 @@ logging.basicConfig()
class DropPlotWidget(PlotWidget):
+ """PlotWidget accepting drop of silx URLs"""
def __init__(self, parent=None, backend=None):
PlotWidget.__init__(self, parent=parent, backend=backend)
@@ -76,11 +87,30 @@ class DropPlotWidget(PlotWidget):
class DropLabel(qt.QLabel):
+ """Label widget accepting drop of silx URLs"""
- def __init__(self, parent=None, backend=None):
- qt.QLabel.__init__(self)
+ DEFAULT_TEXT = "Drop an URL here to display information"
+
+ def __init__(self, parent=None):
+ qt.QLabel.__init__(self, parent)
self.setAcceptDrops(True)
- self.setText("Drop something here")
+ self.setUrl(silx.io.url.DataUrl())
+
+ def setUrl(self, url):
+ template = ("<html>URL information (drop an URL here to parse its information):<ul>"
+ "<li><b>file_path</b>: {file_path}</li>"
+ "<li><b>data_path</b>: {data_path}</li>"
+ "<li><b>data_slice</b>: {data_slice}</li>"
+ "<li><b>scheme</b>: {scheme}</li>"
+ "</ul></html>"
+ )
+
+ text = template.format(
+ file_path=url.file_path(),
+ data_path=url.data_path(),
+ data_slice=url.data_slice(),
+ scheme=url.scheme())
+ self.setText(text)
def dragEnterEvent(self, event):
if event.mimeData().hasFormat("application/x-silx-uri"):
@@ -88,46 +118,67 @@ class DropLabel(qt.QLabel):
def dropEvent(self, event):
byteString = event.mimeData().data("application/x-silx-uri")
- silxUrl = byteString.data().decode("utf-8")
- url = silx.io.url.DataUrl(silxUrl)
- self.setText(url.path())
-
- toolTipTemplate = ("<html><ul>"
- "<li><b>file_path</b>: {file_path}</li>"
- "<li><b>data_path</b>: {data_path}</li>"
- "<li><b>data_slice</b>: {data_slice}</li>"
- "<li><b>scheme</b>: {scheme}</li>"
- "</html>"
- "</ul></html>"
- )
-
- toolTip = toolTipTemplate.format(
- file_path=url.file_path(),
- data_path=url.data_path(),
- data_slice=url.data_slice(),
- scheme=url.scheme())
-
- self.setToolTip(toolTip)
+ url = silx.io.url.DataUrl(byteString.data().decode("utf-8"))
+ self.setUrl(url)
event.acceptProposedAction()
-class DropExample(qt.QMainWindow):
+class DragLabel(qt.QLabel):
+ """Label widget providing a silx URL to drag"""
- def __init__(self, parent=None):
- super(DropExample, self).__init__(parent)
+ def __init__(self, parent=None, url=None):
+ self._url = url
+ qt.QLabel.__init__(self, parent)
+ self.setText('-' if url is None else "- " + self._url.path())
+
+ def mousePressEvent(self, event):
+ if event.button() == qt.Qt.LeftButton and self._url is not None:
+ mimeData = qt.QMimeData()
+ mimeData.setText(self._url.path())
+ mimeData.setData(
+ "application/x-silx-uri",
+ self._url.path().encode(encoding='utf-8'))
+ drag = qt.QDrag(self)
+ drag.setMimeData(mimeData)
+ dropAction = drag.exec_()
+
+
+class DragAndDropExample(qt.QMainWindow):
+ """Main window of the example"""
+
+ def __init__(self, parent=None, urls=()):
+ super(DragAndDropExample, self).__init__(parent)
centralWidget = qt.QWidget(self)
layout = qt.QVBoxLayout()
centralWidget.setLayout(layout)
+ layout.addWidget(qt.QLabel(
+ "Drag and drop one of the following URLs on the plot or on the URL information zone:",
+ self))
+ for url in urls:
+ layout.addWidget(DragLabel(parent=self, url=url))
+
layout.addWidget(DropPlotWidget(parent=self))
layout.addWidget(DropLabel(parent=self))
+
self.setCentralWidget(centralWidget)
def main():
app = qt.QApplication([])
- example = DropExample()
- example.show()
- app.exec_()
+ with tempfile.TemporaryDirectory() as tempdir:
+ # Create temporary file with datasets
+ filename = os.path.join(tempdir, "file.h5")
+ with h5py.File(filename, "w") as f:
+ f['image'] = numpy.arange(10000.).reshape(100, 100)
+ f['curve'] = numpy.sin(numpy.linspace(0, 2*numpy.pi, 1000))
+
+ # Create widgets
+ example = DragAndDropExample(urls=(
+ silx.io.url.DataUrl(file_path=filename, data_path='/image', scheme="silx"),
+ silx.io.url.DataUrl(file_path=filename, data_path='/curve', scheme="silx")))
+ example.setWindowTitle("Drag&Drop URLs sample code")
+ example.show()
+ app.exec_()
if __name__ == "__main__":
diff --git a/examples/fftPlotAction.py b/examples/fftPlotAction.py
index 877225f..bdea08d 100755
--- a/examples/fftPlotAction.py
+++ b/examples/fftPlotAction.py
@@ -2,7 +2,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -110,7 +110,7 @@ class FftAction(PlotAction):
for curve in allCurves:
x = curve.getXData()
y = curve.getYData()
- legend = curve.getLegend()
+ legend = curve.getName()
info = curve.getInfo()
if info is None:
info = {}
diff --git a/examples/findContours.py b/examples/findContours.py
index a7b5ac4..6199ba6 100644
--- a/examples/findContours.py
+++ b/examples/findContours.py
@@ -549,9 +549,10 @@ class FindContours(qt.QMainWindow):
self.__customValue = value
div = 12
delta = (self.__image.max() - self.__image.min()) / div
- self.__value.setValue(value)
- self.__value.setRange(self.__image.min() + delta,
- self.__image.min() + delta * (div - 1))
+ self.__value.setValue(int(numpy.round(value)))
+ minv = self.__image.min() + delta
+ maxv = self.__image.min() + delta * (div - 1)
+ self.__value.setRange(int(numpy.floor(minv)), int(numpy.ceil(maxv)))
self.updateCustomContours()
def generate(self):
diff --git a/examples/hdf5widget.py b/examples/hdf5widget.py
index 217eb7f..0d45b8f 100755
--- a/examples/hdf5widget.py
+++ b/examples/hdf5widget.py
@@ -488,7 +488,7 @@ def get_edf_with_100000_frames():
else:
header = fabio.fabioimage.OrderedDict()
header["frame_nb"] = framre_id
- fabiofile.appendFrame(fabio.edfimage.Frame(data, header, framre_id))
+ fabiofile.append_frame(fabio.edfimage.Frame(data, header, framre_id))
fabiofile.write(tmp.name)
_file_cache[ID] = tmp
diff --git a/examples/icons.py b/examples/icons.py
index 673ca6f..ae77630 100644
--- a/examples/icons.py
+++ b/examples/icons.py
@@ -146,7 +146,7 @@ class IconPreview(qt.QMainWindow):
for i, icon_info in enumerate(icons):
icon_name, icon_kind = icon_info
- col, line = i / 10, i % 10
+ col, line = i // 10, i % 10
if icon_kind == "anim":
tool = AnimatedToolButton(panel)
try:
diff --git a/examples/imageStack.py b/examples/imageStack.py
new file mode 100644
index 0000000..0437a6e
--- /dev/null
+++ b/examples/imageStack.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python
+# 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.
+#
+# ###########################################################################*/
+"""
+Simple example for using the ImageStack.
+
+In this example we want to display images from different source: .h5, .edf
+and .npy files.
+
+To do so we simple reimplement the thread managing the loading of data.
+"""
+
+import numpy
+import h5py
+import tempfile
+import logging
+import shutil
+import os
+import time
+from silx.io.url import DataUrl
+from silx.io.utils import get_data
+from silx.gui import qt
+from silx.gui.plot.ImageStack import ImageStack, UrlLoader
+import fabio
+
+
+logging.basicConfig()
+_logger = logging.getLogger("hdf5widget")
+"""Module logger"""
+
+
+def create_random_image():
+ """Create a simple image with random values"""
+ width = numpy.random.randint(100, 400)
+ height = numpy.random.randint(100, 400)
+ return numpy.random.random((width, height))
+
+
+def create_h5py_urls(n_url, file_name):
+ """ creates n urls based on h5py"""
+ res = []
+ with h5py.File(file_name, 'w') as h5f:
+ for i in range(n_url):
+ h5f[str(i)] = create_random_image()
+ res.append(DataUrl(file_path=file_name,
+ data_path=str(i),
+ scheme='silx'))
+ return res
+
+
+def create_numpy_url(file_name):
+ """ create a simple DataUrl with a .npy file """
+ numpy.save(file=file_name, arr=create_random_image())
+ return [DataUrl(file_path=file_name,
+ scheme='numpy'), ]
+
+
+def create_edf_url(file_name):
+ """ create a simple DataUrl with a .edf file"""
+ dsc = fabio.edfimage.EdfImage(data=create_random_image(), header={})
+ dsc.write(file_name)
+ return [DataUrl(file_path=file_name,
+ data_slice=(0,),
+ scheme='fabio'), ]
+
+
+def create_datasets(folder):
+ """create a set of DataUrl containing each one image"""
+ urls = []
+ file_ = os.path.join(folder, 'myh5file.h5')
+ urls.extend(create_h5py_urls(n_url=5, file_name=file_))
+ file_ = os.path.join(folder, 'secondH5file.h5')
+ urls.extend(create_h5py_urls(n_url=2, file_name=file_))
+ file_ = os.path.join(folder, 'firstnumpy_file.npy')
+ urls.extend(create_numpy_url(file_name=file_))
+ file_ = os.path.join(folder, 'secondnumpy_file.npy')
+ urls.extend(create_numpy_url(file_name=file_))
+ file_ = os.path.join(folder, 'single_edf_file.edf')
+ urls.extend(create_edf_url(file_name=file_))
+ file_ = os.path.join(folder, 'single_edf_file_2.edf')
+ urls.extend(create_edf_url(file_name=file_))
+ return urls
+
+
+class MyOwnUrlLoader(UrlLoader):
+ """
+ Thread use to load DataUrl
+ """
+ def __init__(self, parent, url):
+ super(MyOwnUrlLoader, self).__init__(parent=parent, url=url)
+ self.url = url
+ self.data = None
+
+ def run(self):
+ # just to see the waiting interface...
+ time.sleep(1.0)
+ if self.url.scheme() == 'numpy':
+ self.data = numpy.load(self.url.file_path())
+ else:
+ self.data = get_data(self.url)
+
+
+def main():
+ dataset_folder = tempfile.mkdtemp()
+
+ qapp = qt.QApplication([])
+ widget = ImageStack()
+ widget.setUrlLoaderClass(MyOwnUrlLoader)
+ widget.setNPrefetch(1)
+ urls = create_datasets(folder=dataset_folder)
+ widget.setUrls(urls=urls)
+ widget.show()
+ qapp.exec_()
+ widget.close()
+
+ shutil.rmtree(dataset_folder)
+
+
+if __name__ == '__main__':
+ main()
+ exit(0)
diff --git a/examples/plotCurveLegendWidget.py b/examples/plotCurveLegendWidget.py
index 9209c58..97c516a 100644
--- a/examples/plotCurveLegendWidget.py
+++ b/examples/plotCurveLegendWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2020 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
@@ -71,7 +71,7 @@ class MyCurveLegendsWidget(CurveLegendsWidget):
"""
plot = curve.getPlot()
plot.setActiveCurve(
- curve.getLegend() if curve != plot.getActiveCurve() else None)
+ curve.getName() if curve != plot.getActiveCurve() else None)
def _switchCurveVisibility(self, curve):
"""Toggle the visibility of a curve
@@ -86,7 +86,7 @@ class MyCurveLegendsWidget(CurveLegendsWidget):
:param silx.gui.plot.items.Curve curve:
"""
yaxis = curve.getYAxis()
- curve.setYAxis('left' if yaxis is 'right' else 'right')
+ curve.setYAxis('left' if yaxis == 'right' else 'right')
def _contextMenu(self, pos):
"""Create a show the context menu.
diff --git a/examples/plotInteractiveImageROI.py b/examples/plotInteractiveImageROI.py
index 8a4019f..c10bbf3 100644
--- a/examples/plotInteractiveImageROI.py
+++ b/examples/plotInteractiveImageROI.py
@@ -2,7 +2,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2020 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
@@ -61,6 +61,7 @@ if "--opengl" in sys.argv:
# Create the plot widget and add an image
plot = Plot2D(backend=backend)
plot.getDefaultColormap().setName('viridis')
+plot.setKeepDataAspectRatio(True)
plot.addImage(dummy_image())
# Create the object controlling the ROIs and set it up
@@ -74,14 +75,17 @@ def updateAddedRegionOfInterest(roi):
if roi.getName() == '':
roi.setName('ROI %d' % len(roiManager.getRois()))
if isinstance(roi, LineMixIn):
- roi.setLineWidth(2)
+ roi.setLineWidth(1)
roi.setLineStyle('--')
if isinstance(roi, SymbolMixIn):
roi.setSymbolSize(5)
+ roi.setSelectable(True)
+ roi.setEditable(True)
roiManager.sigRoiAdded.connect(updateAddedRegionOfInterest)
+
# Add a rectangular region of interest
roi = RectangleROI()
roi.setGeometry(origin=(50, 50), size=(200, 200))
diff --git a/examples/plotItemsSelector.py b/examples/plotItemsSelector.py
index 458bbeb..177489f 100755
--- a/examples/plotItemsSelector.py
+++ b/examples/plotItemsSelector.py
@@ -2,7 +2,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -49,7 +49,7 @@ isd.setItemsSelectionMode(qt.QTableWidget.ExtendedSelection)
result = isd.exec_()
if result:
for item in isd.getSelectedItems():
- print(item.getLegend(), type(item))
+ print(item.getName(), type(item))
else:
print("Selection cancelled")
diff --git a/examples/plotProfile.py b/examples/plotProfile.py
new file mode 100644
index 0000000..931f9b4
--- /dev/null
+++ b/examples/plotProfile.py
@@ -0,0 +1,207 @@
+#!/usr/bin/env python
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2020 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.
+#
+# ###########################################################################*/
+"""Example illustrating the different profile tools.
+"""
+
+import numpy
+import scipy.signal
+
+from silx.gui import qt
+from silx.gui.plot import Plot2D
+from silx.gui.plot import ScatterView
+from silx.gui.plot import StackView
+from silx.gui.plot.tools.profile import toolbar
+
+
+def createScatterData():
+ nbPoints = 200
+ nbX = int(numpy.sqrt(nbPoints))
+ nbY = nbPoints // nbX + 1
+
+ # Motor position
+ yy = numpy.atleast_2d(numpy.ones(nbY)).T
+ xx = numpy.atleast_2d(numpy.ones(nbX))
+
+ positionX = numpy.linspace(10, 50, nbX) * yy
+ positionX = positionX.reshape(nbX * nbY)
+ positionX = positionX + numpy.random.rand(len(positionX)) - 0.5
+
+ positionY = numpy.atleast_2d(numpy.linspace(20, 60, nbY)).T * xx
+ positionY = positionY.reshape(nbX * nbY)
+ positionY = positionY + numpy.random.rand(len(positionY)) - 0.5
+
+ # Diodes position
+ lut = scipy.signal.gaussian(max(nbX, nbY), std=8) * 10
+ yy, xx = numpy.ogrid[:nbY, :nbX]
+ signal = lut[yy] * lut[xx]
+ diode1 = numpy.random.poisson(signal * 10)
+ diode1 = diode1.reshape(nbX * nbY)
+ return positionX, positionY, diode1
+
+
+class Example(qt.QMainWindow):
+ def __init__(self, parent=None):
+ qt.QMainWindow.__init__(self, parent=parent)
+ self._createPlot2D()
+ self._createScatterView()
+ self._createStackView()
+
+ dataWidget = qt.QWidget(self)
+ dataLayout = qt.QStackedLayout(dataWidget)
+ dataLayout.addWidget(self.plot)
+ dataLayout.addWidget(self.scatter)
+ dataLayout.addWidget(self.stack)
+ dataLayout.setCurrentWidget(self.plot)
+ self.dataLayout = dataLayout
+
+ clearButton = qt.QPushButton(self)
+ clearButton.clicked.connect(self._clearData)
+ clearButton.setText("Clear")
+
+ imageButton = qt.QPushButton(self)
+ imageButton.clicked.connect(self._updateImage)
+ imageButton.setText("Intensity image")
+
+ imageRgbButton = qt.QPushButton(self)
+ imageRgbButton.clicked.connect(self._updateRgbImage)
+ imageRgbButton.setText("RGB image")
+
+ scatterButton = qt.QPushButton(self)
+ scatterButton.clicked.connect(self._updateScatter)
+ scatterButton.setText("Scatter")
+
+ stackButton = qt.QPushButton(self)
+ stackButton.clicked.connect(self._updateStack)
+ stackButton.setText("Stack")
+
+ options = qt.QWidget(self)
+ layout = qt.QHBoxLayout(options)
+ layout.addStretch()
+ layout.addWidget(clearButton)
+ layout.addWidget(imageButton)
+ layout.addWidget(imageRgbButton)
+ layout.addWidget(scatterButton)
+ layout.addWidget(stackButton)
+ layout.addStretch()
+
+ widget = qt.QWidget(self)
+ layout = qt.QVBoxLayout(widget)
+ layout.addWidget(dataWidget)
+ layout.addWidget(options)
+ self.setCentralWidget(widget)
+
+ self._updateImage()
+
+ def _createPlot2D(self):
+ plot = Plot2D(self)
+ self.plot = plot
+
+ toolBar = toolbar.ProfileToolBar(plot, plot)
+ toolBar.setScheme("image")
+ plot.addToolBar(toolBar)
+
+ toolBar = plot.getProfileToolbar()
+ toolBar.clear()
+
+ def _createScatterView(self):
+ plot = ScatterView(self)
+ self.scatter = plot
+
+ toolBar = toolbar.ProfileToolBar(plot, plot.getPlotWidget())
+ toolBar.setScheme("scatter")
+ plot.addToolBar(toolBar)
+
+ toolBar = plot.getScatterProfileToolBar()
+ toolBar.clear()
+
+ def _createStackView(self):
+ plot = StackView(self)
+ self.stack = plot
+
+ toolBar = toolbar.ProfileToolBar(plot, plot.getPlotWidget())
+ toolBar.setScheme("imagestack")
+ plot.addToolBar(toolBar)
+
+ toolBar = plot.getProfileToolbar()
+ toolBar.clear()
+
+ def _clearData(self):
+ image = self.plot.getActiveImage()
+ if image is not None:
+ self.plot.removeItem(image)
+ self.scatter.setData(None, None, None)
+ self.stack.clear()
+
+ def _updateImage(self):
+ x = numpy.outer(numpy.linspace(-10, 10, 200),
+ numpy.linspace(-5, 5, 150))
+ image = numpy.sin(x) / x
+ image = image * 10 + numpy.random.rand(*image.shape)
+
+ self.plot.addImage(image)
+ self.dataLayout.setCurrentWidget(self.plot)
+
+ def _updateRgbImage(self):
+ image = numpy.empty(shape=(200, 150, 3), dtype=numpy.uint8)
+ x = numpy.outer(numpy.linspace(-10, 10, 200),
+ numpy.linspace(-5, 5, 150))
+ r = numpy.sin(x) / x
+ g = numpy.cos(x/10) * numpy.sin(x/10)
+ b = x
+ image[..., 0] = 100 + 200 * (r / r.max())
+ image[..., 1] = 100 + 200 * (g / g.max())
+ image[..., 2] = 100 + 200 * (b / b.max())
+ image[...] = image + numpy.random.randint(0, 20, size=image.shape)
+
+ self.plot.addImage(image)
+ self.dataLayout.setCurrentWidget(self.plot)
+
+ def _updateScatter(self):
+ xx, yy, value = createScatterData()
+ self.scatter.setData(xx, yy, value)
+ self.dataLayout.setCurrentWidget(self.scatter)
+
+ def _updateStack(self):
+ a, b, c = numpy.meshgrid(numpy.linspace(-10, 10, 200),
+ numpy.linspace(-10, 5, 150),
+ numpy.linspace(-5, 10, 120),
+ indexing="ij")
+ raw = numpy.asarray(numpy.sin(a * b * c) / (a * b * c),
+ dtype='float32')
+ raw = numpy.abs(raw)
+ raw[numpy.isnan(raw)] = 0
+ data = raw + numpy.random.poisson(raw * 10)
+ self.stack.setStack(data)
+ self.dataLayout.setCurrentWidget(self.stack)
+
+def main():
+ app = qt.QApplication([])
+ widget = Example()
+ widget.show()
+ app.exec_()
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/simplewidget.py b/examples/simplewidget.py
index 88977b7..0e9038e 100755
--- a/examples/simplewidget.py
+++ b/examples/simplewidget.py
@@ -45,6 +45,7 @@ from silx.gui.widgets.WaitingPushButton import WaitingPushButton
from silx.gui.widgets.ThreadPoolPushButton import ThreadPoolPushButton
from silx.gui.widgets.RangeSlider import RangeSlider
from silx.gui.widgets.LegendIconWidget import LegendIconWidget
+from silx.gui.widgets.ElidedLabel import ElidedLabel
class SimpleWidgetExample(qt.QMainWindow):
@@ -74,6 +75,10 @@ class SimpleWidgetExample(qt.QMainWindow):
layout.addWidget(qt.QLabel("LegendIconWidget"))
layout.addWidget(panel)
+ panel = self.createElidedLabelPanel(self)
+ layout.addWidget(qt.QLabel("ElidedLabel"))
+ layout.addWidget(panel)
+
self.setCentralWidget(main_panel)
def createWaitingPushButton(self):
@@ -186,6 +191,25 @@ class SimpleWidgetExample(qt.QMainWindow):
return panel
+ def createElidedLabelPanel(self, parent):
+ panel = qt.QWidget(parent)
+ layout = qt.QVBoxLayout(panel)
+
+ label = ElidedLabel(parent)
+ label.setText("A very long text which is far too long.")
+ layout.addWidget(label)
+
+ label = ElidedLabel(parent)
+ label.setText("A very long text which is far too long.")
+ label.setElideMode(qt.Qt.ElideMiddle)
+ layout.addWidget(label)
+
+ label = ElidedLabel(parent)
+ label.setText("Basically nothing.")
+ layout.addWidget(label)
+
+ return panel
+
def main():
"""
diff --git a/examples/viewer3DVolume.py b/examples/viewer3DVolume.py
index 4de04f6..2193402 100644
--- a/examples/viewer3DVolume.py
+++ b/examples/viewer3DVolume.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -74,7 +74,7 @@ def load(filename):
filename, path = filename.split('::')
path, indices = path.split('#')[0], path.split('#')[1:]
- with h5py.File(filename) as f:
+ with h5py.File(filename, mode='r') as f:
data = f[path]
# Loop through indices along first dimensions
diff --git a/package/debian10/control b/package/debian10/control
index f16ccea..e98ce65 100644
--- a/package/debian10/control
+++ b/package/debian10/control
@@ -5,34 +5,14 @@ Uploaders: Jerome Kieffer <jerome.kieffer@esrf.fr>,
Alexandre Marie <alexandre.marie@synchrotron-soleil.fr>
Section: science
Priority: optional
-Build-Depends: cython (>= 0.23.2),
- cython3 (>= 0.23.2),
+Build-Depends: cython3 (>= 0.23.2),
debhelper (>= 10),
dh-python,
+ graphviz,
help2man,
- ipython,
- ipython-qtconsole,
ipython3,
ipython3-qtconsole,
- pandoc <!nodoc>,
- python-all-dev,
- python-concurrent.futures,
- python-fabio,
- python-h5py,
- python-mako,
- python-matplotlib,
- python-nbsphinx <!nodoc>,
- python-numpy,
- python-opengl,
- python-pil,
- python-pyopencl,
- python-pyqt5,
- python-pyqt5.qtopengl,
- python-pyqt5.qtsvg,
- python-scipy,
- python-setuptools,
- python-sphinx,
- python-sphinxcontrib.programoutput,
+ pandoc <!nodoc>,
python3-all-dev,
python3-dateutil,
python3-qtconsole,
@@ -91,32 +71,6 @@ Description: Toolbox for X-Ray data analysis - Executables
.
This uses the Python 3 version of the package.
-Package: python-silx
-Architecture: any
-Section: python
-Depends: ${misc:Depends}, ${python:Depends}, ${shlibs:Depends}
-Description: Toolbox for X-Ray data analysis - Python2 library
- The silx project aims at providing a collection of Python packages to
- support the development of data assessment, reduction and analysis
- applications at synchrotron radiation facilities. It aims at
- providing reading/writing different file formats, data reduction
- routines and a set of Qt widgets to browse and visualize data.
- .
- The current version provides :
- .
- * reading HDF5 file format (with support of SPEC file format)
- * histogramming
- * fitting
- * 1D and 2D visualization using multiple backends (matplotlib or OpenGL)
- * image plot widget with a set of associated tools (See changelog file).
- * Unified browser for HDF5, SPEC and image file formats supporting inspection
- and visualization of n-dimensional datasets.
- * Unified viewer (silx view filename) for HDF5, SPEC and image file formats
- * OpenGL-based widget to display 3D scalar field with
- isosurface and cutting plane.
- .
- This is the Python 2 version of the package.
-
Package: python3-silx
Architecture: any
diff --git a/package/debian10/rules b/package/debian10/rules
index b75711c..e56f801 100755
--- a/package/debian10/rules
+++ b/package/debian10/rules
@@ -24,7 +24,7 @@ ALL_PYX := $(call rwildcard,silx/,*.pyx)
PY3VER := $(shell py3versions -dv)
%:
- dh $@ --with python2,python3,sphinxdoc --buildsystem=pybuild
+ dh $@ --with python3,sphinxdoc --buildsystem=pybuild
override_dh_clean:
dh_clean
@@ -42,7 +42,6 @@ override_dh_auto_build:
dh_auto_build -- -s custom --build-args="env PYTHONPATH={build_dir} {interpreter} setup.py build_man"
override_dh_install:
- dh_numpy
dh_numpy3
# install scripts into silx
@@ -80,6 +79,6 @@ ifeq (,$(findstring nodocs, $(DEB_BUILD_OPTIONS)))
#mkdir -p $(POCL_CACHE_DIR) # create POCL cachedir in order to avoid an FTBFS in sbuild
mkdir -p -m 700 $(XDG_RUNTIME_DIR)
pybuild --build -s custom -p $(PY3VER) --build-args="cd doc && env PYTHONPATH={build_dir} http_proxy='127.0.0.1:9' xvfb-run -a --server-args=\"-screen 0 1024x768x24\" {interpreter} -m sphinx -N -bhtml source build/html"
- dh_installdocs "doc/build/html" -p python-silx-doc
+ dh_installdocs "doc/build/html" -p python-silx-doc --doc-main-package=python3-silx
dh_sphinxdoc -O--buildsystem=pybuild
endif
diff --git a/package/debian10/tests/control b/package/debian10/tests/control
index 1e5cddf..deb174c 100644
--- a/package/debian10/tests/control
+++ b/package/debian10/tests/control
@@ -1,20 +1,4 @@
Test-Command: set -efu
- ; for py in $(pyversions -r 2>/dev/null)
- ; do cd "$AUTOPKGTEST_TMP"
- ; echo "Testing with $py:"
- ; xvfb-run -a --server-args="-screen 0 1024x768x24" $py -c "import silx.test; silx.test.run_tests()" 2>&1
- ; done
-Depends: python-all, python-silx, xauth, xvfb
-
-Test-Command: set -efu
- ; for py in $(pyversions -r 2>/dev/null)
- ; do cd "$AUTOPKGTEST_TMP"
- ; echo "Testing with $py-dbg:"
- ; xvfb-run -a --server-args="-screen 0 1024x768x24" $py-dbg -c "import silx.test; silx.test.run_tests()" 2>&1
- ; done
-Depends: python-all-dbg, python-silx-dbg, xauth, xvfb
-
-Test-Command: set -efu
; for py in $(py3versions -r 2>/dev/null)
; do cd "$AUTOPKGTEST_TMP"
; echo "Testing with $py:"
diff --git a/package/debian11/changelog b/package/debian11/changelog
new file mode 100644
index 0000000..d0764f1
--- /dev/null
+++ b/package/debian11/changelog
@@ -0,0 +1,156 @@
+silx (0.11.0+dfsg-3) unstable; urgency=medium
+
+ * d/control: Build-Depends on python3-qtconsole(Closes: #946571).
+
+ -- Picca Frédéric-Emmanuel <picca@debian.org> Wed, 11 Dec 2019 09:10:31 +0100
+
+silx (0.11.0+dfsg-2) unstable; urgency=medium
+
+ * Use debhelper-compat instead of debian/compat.
+ * remove Python2 modules (Closes: #938481).
+ * Switched to compat level 12
+ - d/rules: Used --doc-main-package python3-silx.
+
+ -- Picca Frédéric-Emmanuel <picca@debian.org> Sat, 19 Oct 2019 13:19:44 +0200
+
+silx (0.11.0+dfsg-1) unstable; urgency=medium
+
+ [ Alexandre Marie ]
+ * Added test on openCL's use
+ * New upstream version 0.11.0+dfsg
+ * d/patches
+ - 0004-fix-missing-import.patches (Removed)
+ - 0005-fix-problem-with-sift-import (Removed)
+
+ -- Picca Frédéric-Emmanuel <picca@debian.org> Tue, 09 Jul 2019 15:26:55 +0200
+
+silx (0.10.1+dfsg-1~exp2) experimental; urgency=medium
+
+ * d/patchs
+ + 0005-fix-problem-with-sift-import.patch (Added)
+
+ -- Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> Tue, 28 May 2019 11:17:46 +0200
+
+silx (0.10.1+dfsg-1~exp1) experimental; urgency=medium
+
+ * New upstream version 0.10.1+dfsg
+ * d/patches
+ + 0004-fix-missing-import.patch (Added)
+
+ -- Picca Frédéric-Emmanuel <picca@debian.org> Tue, 28 May 2019 08:20:44 +0200
+
+silx (0.9.0+dfsg-3) unstable; urgency=medium
+
+ * d/rules: Do not run Qt test for now.
+ * d/t/control.autodep8: Fixed to run test for real.
+
+ -- Picca Frédéric-Emmanuel <picca@debian.org> Thu, 21 Feb 2019 11:22:03 +0100
+
+silx (0.9.0+dfsg-2) unstable; urgency=medium
+
+ * d/patches:
+ + 0004-fix-FTBFS-with-numpy-0.16.patch (Added)
+
+ -- Picca Frédéric-Emmanuel <picca@debian.org> Thu, 20 Dec 2018 16:21:18 +0100
+
+silx (0.9.0+dfsg-1) unstable; urgency=medium
+
+ [ Picca Frédéric-Emmanuel ]
+ * Fixed autopkgtests and use control.autodep8
+ * Used salsa-ci for continuous integration.
+ * Run autopkgtests via xvfb-run
+ * d/control: Removed Build-Depends: python-lxml[-dbg], python-enum34.
+
+ [ Alexandre Marie ]
+ * New upstream version 0.9.0+dfsg
+ * d/watch: uversionmangling to deal with rc|alpha|beta versions.
+
+ -- Alexandre Marie <alexandre.marie@synchrotron-soleil.fr> Mon, 17 Dec 2018 13:25:52 +0100
+
+silx (0.8.0+dfsg-1) unstable; urgency=medium
+
+ * New upstream version 0.8.0+dfsg
+ * Added the pub 4096R/26F8E116 key to keyring
+ 2016-04-11 Thomas Vincent <thomas.vincent@esrf.fr>
+ * d/control
+ - Build-Depends
+ + Added pandoc
+ - Removed obsolete X-Python[3]-Version
+ * d/rules
+ - Installed the QtDesgigner files only for Qt5.
+ - Override dh_python3 to deal with qtdesigner files.
+ - Run sphinx with xvfb in order to have the right silx.sx documentation.
+ - Avoid QT warnings by setting XDG_RUNTIME_DIR
+
+ -- Picca Frédéric-Emmanuel <picca@debian.org> Tue, 31 Jul 2018 16:24:57 +0200
+
+silx (0.7.0+dfsg-2) unstable; urgency=medium
+
+ * d/rules
+ - use py3versions to get the python3 default interpreter version.
+ This makes the package backportable.
+
+ -- Picca Frédéric-Emmanuel <picca@debian.org> Tue, 13 Mar 2018 20:04:20 +0100
+
+silx (0.7.0+dfsg-1) unstable; urgency=medium
+
+ * New upstream version 0.7.0+dfsg
+ * Bumped Strandards-Versions to 4.1.3 (nothing to do)
+ * d/control
+ - Build-Depends
+ + Added python[3]-nbsphinx, python-concurrent.futures
+ * d/copyright
+ remove the third_party _local files.
+ * d/patches
+ + 0003-do-not-modify-PYTHONPATH-from-setup.py.patch (added)
+ - 0005-slocale.h-is-removed-in-GLIBC-2.26.patch (obsolete)
+ - 0006-prefer-pyqt5-over-pyside.patch (obsolete)
+ * d/rules
+ - removed the jessie backports specific code
+ - compile extensions only once per interpreter.
+ - unactive for now the build time tests.
+ - build the doc only with python3.
+ * d/watch
+ - check the pgp signature
+
+ -- Picca Frédéric-Emmanuel <picca@debian.org> Tue, 13 Mar 2018 07:32:00 +0100
+
+silx (0.6.1+dfsg-2) unstable; urgency=medium
+
+ * d/control
+ - Bump Standrad-Version 4.1.1 (nothing to do)
+ * fixed glibc 2.26 FTBFS with upstream patch glib2.26 (Closes: #882881)
+ * d/patches
+ + 0005-slocale.h-is-removed-in-GLIBC-2.26.patch (Added)
+
+ -- Picca Frédéric-Emmanuel <picca@debian.org> Sun, 21 Jan 2018 09:32:38 +0100
+
+silx (0.6.1+dfsg-1) unstable; urgency=medium
+
+ * New upstream version 0.6.1+dfsg
+ * update watch file
+
+ -- Picca Frédéric-Emmanuel <picca@debian.org> Sat, 25 Nov 2017 17:02:19 +0100
+
+silx (0.6.0+dfsg-1) unstable; urgency=medium
+
+ * New upstream version 0.6.0+dfsg
+ * d/patches
+ - 0001-fix-the-build_man-target.patch (deleted)
+ - 0004-test-unactive-ressource-for-now.patch (deleted)
+ - 0005-fix-the-sift-removal.patch (deleted)
+
+ -- Picca Frédéric-Emmanuel <picca@debian.org> Sat, 07 Oct 2017 08:08:56 +0200
+
+silx (0.5.0+dfsg-2) unstable; urgency=medium
+
+ * d/control
+ - Added all the -dbg dependencies for the -dbg packages.
+
+ -- Picca Frédéric-Emmanuel <picca@debian.org> Fri, 01 Sep 2017 15:10:44 +0200
+
+silx (0.5.0+dfsg-1) unstable; urgency=medium
+
+ * Initial release (Closes: #871637)
+
+ -- Picca Frédéric-Emmanuel <picca@debian.org> Wed, 02 Aug 2017 11:00:20 +0100
diff --git a/package/debian11/control b/package/debian11/control
new file mode 100644
index 0000000..f2791e7
--- /dev/null
+++ b/package/debian11/control
@@ -0,0 +1,170 @@
+Source: silx
+Maintainer: Debian Science Maintainers <debian-science-maintainers@lists.alioth.debian.org>
+Uploaders: Jerome Kieffer <jerome.kieffer@esrf.fr>,
+ Picca Frédéric-Emmanuel <picca@debian.org>,
+ Alexandre Marie <alexandre.marie@synchrotron-soleil.fr>
+Section: science
+Priority: optional
+Build-Depends: cython3 (>= 0.23.2),
+ cython3-dbg (>= 0.23.2),
+ debhelper-compat (= 12),
+ dh-python,
+ graphviz,
+ help2man,
+ ipython3,
+ pandoc <!nodoc>,
+ python3-all-dbg,
+ python3-all-dev,
+ python3-fabio,
+ python3-fabio-dbg,
+ python3-h5py,
+ python3-h5py-dbg,
+ python3-mako,
+ python3-matplotlib,
+ python3-matplotlib-dbg,
+ python3-nbsphinx <!nodoc>,
+ python3-numpy,
+ python3-numpy-dbg,
+ python3-opengl,
+ python3-pil,
+ python3-pil-dbg,
+ python3-pyopencl,
+ python3-pyopencl-dbg,
+ python3-pyqt5-dbg,
+ python3-pyqt5.qtopengl,
+ python3-pyqt5.qtopengl-dbg,
+ python3-pyqt5.qtsvg,
+ python3-pyqt5.qtsvg-dbg,
+ python3-qtconsole,
+ python3-scipy,
+ python3-scipy-dbg,
+ python3-setuptools,
+ python3-six,
+ python3-sphinx,
+ python3-sphinxcontrib.programoutput,
+ xauth,
+ xvfb
+Standards-Version: 4.1.3
+Vcs-Browser: https://salsa.debian.org/science-team/silx
+Vcs-Git: https://salsa.debian.org/science-team/silx.git
+Homepage: https://github.com/silx-kit/silx
+
+Package: silx
+Architecture: all
+Depends: python3-silx (>= ${source:Version}),
+ ${misc:Depends},
+ ${python3:Depends},
+ ${shlibs:Depends}
+Description: Toolbox for X-Ray data analysis - Executables
+ The silx project aims at providing a collection of Python packages to
+ support the development of data assessment, reduction and analysis
+ applications at synchrotron radiation facilities. It aims at
+ providing reading/writing different file formats, data reduction
+ routines and a set of Qt widgets to browse and visualize data.
+ .
+ The current version provides :
+ .
+ * reading HDF5 file format (with support of SPEC file format)
+ * histogramming
+ * fitting
+ * 1D and 2D visualization using multiple backends (matplotlib or OpenGL)
+ * image plot widget with a set of associated tools (See changelog file).
+ * Unified browser for HDF5, SPEC and image file formats supporting inspection
+ and visualization of n-dimensional datasets.
+ * Unified viewer (silx view filename) for HDF5, SPEC and image file formats
+ * OpenGL-based widget to display 3D scalar field with
+ isosurface and cutting plane.
+ .
+ This uses the Python 3 version of the package.
+
+Package: python3-silx
+Architecture: any
+Section: python
+Depends: ${misc:Depends}, ${python3:Depends}, ${shlibs:Depends}
+Description: Toolbox for X-Ray data analysis - Python3
+ The silx project aims at providing a collection of Python packages to
+ support the development of data assessment, reduction and analysis
+ applications at synchrotron radiation facilities. It aims at
+ providing reading/writing different file formats, data reduction
+ routines and a set of Qt widgets to browse and visualize data.
+ .
+ The current version provides :
+ .
+ * reading HDF5 file format (with support of SPEC file format)
+ * histogramming
+ * fitting
+ * 1D and 2D visualization using multiple backends (matplotlib or OpenGL)
+ * image plot widget with a set of associated tools (See changelog file).
+ * Unified browser for HDF5, SPEC and image file formats supporting inspection
+ and visualization of n-dimensional datasets.
+ * Unified viewer (silx view filename) for HDF5, SPEC and image file formats
+ * OpenGL-based widget to display 3D scalar field with
+ isosurface and cutting plane.
+ .
+ This is the Python 3 version of the package.
+
+Package: python3-silx-dbg
+Architecture: any
+Section: debug
+Depends: python3-fabio-dbg,
+ python3-h5py-dbg,
+ python3-lxml-dbg,
+ python3-matplotlib-dbg,
+ python3-numpy-dbg,
+ python3-pil-dbg,
+ python3-pyopencl-dbg,
+ python3-pyqt5-dbg,
+ python3-pyqt5.qtopengl-dbg,
+ python3-pyqt5.qtsvg-dbg,
+ python3-scipy-dbg,
+ python3-silx (= ${binary:Version}),
+ ${misc:Depends},
+ ${python3:Depends},
+ ${shlibs:Depends}
+Description: Toolbox for X-Ray data analysis - Python3 debug
+ The silx project aims at providing a collection of Python packages to
+ support the development of data assessment, reduction and analysis
+ applications at synchrotron radiation facilities. It aims at
+ providing reading/writing different file formats, data reduction
+ routines and a set of Qt widgets to browse and visualize data.
+ .
+ The current version provides :
+ .
+ * reading HDF5 file format (with support of SPEC file format)
+ * histogramming
+ * fitting
+ * 1D and 2D visualization using multiple backends (matplotlib or OpenGL)
+ * image plot widget with a set of associated tools (See changelog file).
+ * Unified browser for HDF5, SPEC and image file formats supporting inspection
+ and visualization of n-dimensional datasets.
+ * Unified viewer (silx view filename) for HDF5, SPEC and image file formats
+ * OpenGL-based widget to display 3D scalar field with
+ isosurface and cutting plane.
+ .
+ This is the Python 3 debug version of the package.
+
+Package: python-silx-doc
+Architecture: all
+Section: doc
+Depends: libjs-mathjax, ${misc:Depends}, ${sphinxdoc:Depends}
+Description: Toolbox for X-Ray data analysis - Documentation
+ The silx project aims at providing a collection of Python packages to
+ support the development of data assessment, reduction and analysis
+ applications at synchrotron radiation facilities. It aims at
+ providing reading/writing different file formats, data reduction
+ routines and a set of Qt widgets to browse and visualize data.
+ .
+ The current version provides :
+ .
+ * reading HDF5 file format (with support of SPEC file format)
+ * histogramming
+ * fitting
+ * 1D and 2D visualization using multiple backends (matplotlib or OpenGL)
+ * image plot widget with a set of associated tools (See changelog file).
+ * Unified browser for HDF5, SPEC and image file formats supporting inspection
+ and visualization of n-dimensional datasets.
+ * Unified viewer (silx view filename) for HDF5, SPEC and image file formats
+ * OpenGL-based widget to display 3D scalar field with
+ isosurface and cutting plane.
+ .
+ This is the common documentation package.
diff --git a/package/debian11/gbp.conf b/package/debian11/gbp.conf
new file mode 100644
index 0000000..f68d262
--- /dev/null
+++ b/package/debian11/gbp.conf
@@ -0,0 +1,2 @@
+[DEFAULT]
+debian-branch = master \ No newline at end of file
diff --git a/package/debian11/patches/0002-use-the-system-mathjax-privacy-breach.patch b/package/debian11/patches/0002-use-the-system-mathjax-privacy-breach.patch
new file mode 100644
index 0000000..04deea7
--- /dev/null
+++ b/package/debian11/patches/0002-use-the-system-mathjax-privacy-breach.patch
@@ -0,0 +1,25 @@
+From: =?utf-8?q?Picca_Fr=C3=A9d=C3=A9ric-Emmanuel?=
+ <picca@synchrotron-soleil.fr>
+Date: Thu, 10 Aug 2017 10:19:39 +0200
+Subject: use the system mathjax (privacy breach)
+
+---
+ doc/source/conf.py | 5 +++++
+ 1 file changed, 5 insertions(+)
+
+diff --git a/doc/source/conf.py b/doc/source/conf.py
+index 86dbccf..18bfce2 100644
+--- a/doc/source/conf.py
++++ b/doc/source/conf.py
+@@ -143,6 +143,11 @@ pygments_style = 'sphinx'
+ # A list of ignored prefixes for module index sorting.
+ # modindex_common_prefix = []
+
++# -- Option for MathJax extension ----------------------------------------------
++
++# Override required in order to use Debian's system mathjax
++mathjax_path = 'file:///usr/share/javascript/mathjax/MathJax.js?config=TeX-AMS-MML_HTMLorMML'
++
+
+ # -- Options for HTML output ---------------------------------------------------
+
diff --git a/package/debian11/patches/0003-do-not-modify-PYTHONPATH-from-setup.py.patch b/package/debian11/patches/0003-do-not-modify-PYTHONPATH-from-setup.py.patch
new file mode 100644
index 0000000..4591cb6
--- /dev/null
+++ b/package/debian11/patches/0003-do-not-modify-PYTHONPATH-from-setup.py.patch
@@ -0,0 +1,22 @@
+From: =?utf-8?q?Picca_Fr=C3=A9d=C3=A9ric-Emmanuel?= <picca@debian.org>
+Date: Sun, 4 Mar 2018 16:36:35 +0100
+Subject: do not modify PYTHONPATH from setup.py
+
+---
+ setup.py | 3 ++-
+ 1 file changed, 2 insertions(+), 1 deletion(-)
+
+diff --git a/setup.py b/setup.py
+index 1029bf0..46d0bdf 100644
+--- a/setup.py
++++ b/setup.py
+@@ -260,7 +260,8 @@ class BuildMan(Command):
+ path.insert(0, os.path.abspath(build.build_lib))
+
+ env = dict((str(k), str(v)) for k, v in os.environ.items())
+- env["PYTHONPATH"] = os.pathsep.join(path)
++
++ # env["PYTHONPATH"] = os.pathsep.join(path)
+ if not os.path.isdir("build/man"):
+ os.makedirs("build/man")
+ import subprocess
diff --git a/package/debian11/patches/series b/package/debian11/patches/series
new file mode 100644
index 0000000..e3795b3
--- /dev/null
+++ b/package/debian11/patches/series
@@ -0,0 +1,2 @@
+0002-use-the-system-mathjax-privacy-breach.patch
+0003-do-not-modify-PYTHONPATH-from-setup.py.patch
diff --git a/package/debian11/py3dist-overrides b/package/debian11/py3dist-overrides
new file mode 100644
index 0000000..2c4ce13
--- /dev/null
+++ b/package/debian11/py3dist-overrides
@@ -0,0 +1 @@
+pyqt5 python3-pyqt5,python3-pyqt5.qtopengl,python3-pyqt5.qtsvg \ No newline at end of file
diff --git a/package/debian11/python-silx-doc.doc-base b/package/debian11/python-silx-doc.doc-base
new file mode 100644
index 0000000..c8efa7f
--- /dev/null
+++ b/package/debian11/python-silx-doc.doc-base
@@ -0,0 +1,9 @@
+Document: silx-manual
+Title: silx documentation manual
+Author: Jérôme Kieffer <jerome.kieffer@esrf.eu>
+Abstract: Toolbox for X-Ray data analysis
+Section: Science/Data Analysis
+
+Format: HTML
+Index: /usr/share/doc/python3-silx/html/index.html
+Files: /usr/share/doc/python3-silx/html/*
diff --git a/package/debian11/rules b/package/debian11/rules
new file mode 100755
index 0000000..d086f63
--- /dev/null
+++ b/package/debian11/rules
@@ -0,0 +1,82 @@
+#!/usr/bin/make -f
+
+# avoir bbuild FTBFS
+export HOME=$(CURDIR)/debian/tmp-home
+export XDG_RUNTIME_DIR=$(HOME)/runtime
+export POCL_CACHE_DIR=$(HOME)/.cache/
+
+export DEB_BUILD_MAINT_OPTIONS = hardening=+all
+export PYBUILD_AFTER_INSTALL=rm -rf {destdir}/usr/bin/
+export PYBUILD_NAME=silx
+export SPECFILE_USE_GNU_SOURCE=1
+export SILX_FULL_INSTALL_REQUIRES=1
+
+DEB_HOST_MULTIARCH ?= $(shell dpkg-architecture -qDEB_HOST_MULTIARCH)
+
+# Make does not offer a recursive wildcard function, so here's one:
+rwildcard=$(wildcard $1$2) $(foreach d,$(wildcard $1*),$(call rwildcard,$d/,$2))
+
+# How to recursively find all files with the same name in a given folder
+ALL_PYX := $(call rwildcard,silx/,*.pyx)
+#NOTA: No space before *
+
+# get the default python3 interpreter version
+PY3VER := $(shell py3versions -dv)
+
+%:
+ dh $@ --with python3,sphinxdoc --buildsystem=pybuild
+
+override_dh_clean:
+ dh_clean
+ # remove the cython generated file to force rebuild
+ rm -f $(patsubst %.pyx,%.cpp,${ALL_PYX})
+ rm -f $(patsubst %.pyx,%.c,${ALL_PYX})
+ rm -f $(patsubst %.pyx,%.html,${ALL_PYX})
+ rm -rf doc/build/html
+ rm -rf build/man
+ rm -rf *.egg-info
+
+override_dh_auto_build:
+ dh_auto_build
+ # build man pages
+ dh_auto_build -- -s custom --build-args="env PYTHONPATH={build_dir} {interpreter} setup.py build_man"
+
+override_dh_install:
+ dh_numpy3
+
+ # install scripts into silx
+ python3 setup.py install_scripts -d debian/silx/usr/bin
+ dh_install -p silx package/desktop/*.desktop usr/share/applications
+ dh_install -p silx package/desktop/silx.png usr/share/icons/hicolor/48x48/apps
+ dh_install -p silx package/desktop/silx.svg usr/share/icons/hicolor/scalable/apps
+ dh_install -p silx package/desktop/silx.xml usr/share/mime/packages
+
+ # install the qtdesigner files only for the python3 package
+ dh_install -p python3-silx qtdesigner_plugins/*.py /usr/lib/$(DEB_HOST_MULTIARCH)/qt5/plugins/designer/python
+
+ dh_install
+
+override_dh_python3:
+ dh_python3
+ dh_python3 -p python3-silx /usr/lib/$(DEB_HOST_MULTIARCH)/qt5/plugins/designer/python
+
+# WITH_QT_TEST=False to disable graphical tests
+# SILX_OPENCL=False to disable OpenCL tests
+# SILX_TEST_LOW_MEM=True to disable tests taking large amount of memory
+# GPU=False to disable the use of a GPU with OpenCL test
+# WITH_GL_TEST=False to disable tests using OpenGL
+override_dh_auto_test:
+ mkdir -p $(POCL_CACHE_DIR) # create POCL cachedir in order to avoid an FTBFS in sbuild
+ dh_auto_test -- -s custom --test-args="env PYTHONPATH={build_dir} GPU=False WITH_QT_TEST=False SILX_OPENCL=False SILX_TEST_LAW_MEM=True xvfb-run -a --server-args=\"-screen 0 1024x768x24\" {interpreter} run_tests.py -vv --installed"
+
+override_dh_installman:
+ dh_installman -p silx build/man/*.1
+
+override_dh_sphinxdoc:
+ifeq (,$(findstring nodocs, $(DEB_BUILD_OPTIONS)))
+ #mkdir -p $(POCL_CACHE_DIR) # create POCL cachedir in order to avoid an FTBFS in sbuild
+ mkdir -p -m 700 $(XDG_RUNTIME_DIR)
+ pybuild --build -s custom -p $(PY3VER) --build-args="cd doc && env PYTHONPATH={build_dir} http_proxy='127.0.0.1:9' xvfb-run -a --server-args=\"-screen 0 1024x768x24\" {interpreter} -m sphinx -N -bhtml source build/html"
+ dh_installdocs "doc/build/html" -p python-silx-doc --doc-main-package=python3-silx
+ dh_sphinxdoc -O--buildsystem=pybuild
+endif
diff --git a/package/debian11/source/format b/package/debian11/source/format
new file mode 100644
index 0000000..163aaf8
--- /dev/null
+++ b/package/debian11/source/format
@@ -0,0 +1 @@
+3.0 (quilt)
diff --git a/package/debian11/source/options b/package/debian11/source/options
new file mode 100644
index 0000000..6e88e49
--- /dev/null
+++ b/package/debian11/source/options
@@ -0,0 +1 @@
+extend-diff-ignore="^[^/]+\.egg-info/" \ No newline at end of file
diff --git a/package/debian11/tests/control b/package/debian11/tests/control
new file mode 100644
index 0000000..deb174c
--- /dev/null
+++ b/package/debian11/tests/control
@@ -0,0 +1,15 @@
+Test-Command: set -efu
+ ; for py in $(py3versions -r 2>/dev/null)
+ ; do cd "$AUTOPKGTEST_TMP"
+ ; echo "Testing with $py:"
+ ; xvfb-run -a --server-args="-screen 0 1024x768x24" $py -c "import silx.test; silx.test.run_tests()" 2>&1
+ ; done
+Depends: python3-all, python3-silx, xauth, xvfb
+
+Test-Command: set -efu
+ ; for py in $(py3versions -r 2>/dev/null)
+ ; do cd "$AUTOPKGTEST_TMP"
+ ; echo "Testing with $py-dbg:"
+ ; xvfb-run -a --server-args="-screen 0 1024x768x24" $py-dbg -c "import silx.test; silx.test.run_tests()" 2>&1
+ ; done
+Depends: python3-all-dbg, python3-silx-dbg, xauth, xvfb
diff --git a/package/debian11/watch b/package/debian11/watch
new file mode 100644
index 0000000..99444f9
--- /dev/null
+++ b/package/debian11/watch
@@ -0,0 +1,7 @@
+version=4
+opts=repacksuffix=+dfsg,\
+pgpsigurlmangle=s/$/.asc/,\
+dversionmangle=s/\+dfsg//,\
+uversionmangle=s/(rc|a|b|c)/~$1/ \
+https://pypi.python.org/packages/source/s/@PACKAGE@/ \
+ @PACKAGE@-@ANY_VERSION@@ARCHIVE_EXT@ debian uupdate
diff --git a/package/debian9/control b/package/debian9/control
index 4abe0fe..169e9b5 100644
--- a/package/debian9/control
+++ b/package/debian9/control
@@ -4,32 +4,11 @@ Uploaders: Jerome Kieffer <jerome.kieffer@esrf.fr>,
Picca Frédéric-Emmanuel <picca@debian.org>
Section: science
Priority: extra
-Build-Depends: cython,
- cython3,
+Build-Depends: cython3,
libstdc++-4.9-dev|libstdc++6,
debhelper (>=9.20150101+deb8u2),
dh-python,
- python-all-dev,
- python-numpy,
- python-fabio,
- python-h5py,
- python-pyopencl,
- python-mako,
- python-qtconsole,
- python-matplotlib,
- python-nbsphinx,
- python-dateutil,
- python-opengl,
- python-pyqt5,
- python-pyqt5.qtsvg,
- python-pyqt5.qtopengl,
- python-scipy,
- python-setuptools,
- python-sphinx,
- python-sphinxcontrib.programoutput,
- python-six,
- python-enum34,
- python-concurrent.futures,
+ graphviz,
python3-all-dev,
python3-numpy,
python3-fabio,
@@ -57,7 +36,6 @@ Standards-Version: 3.9.8
Vcs-Browser: https://anonscm.debian.org/cgit/debian-science/packages/silx.git
Vcs-Git: git://anonscm.debian.org/debian-science/packages/silx.git
Homepage: https://github.com/silx-kit/silx
-X-Python-Version: >= 2.7
X-Python3-Version: >= 3.4
Package: silx
@@ -71,36 +49,6 @@ Description: Toolbox for X-Ray data analysis - Executables
.
This uses the Python 3 version of the package.
-Package: python-silx
-Architecture: any
-Section: python
-Depends: ${misc:Depends},
- ${python:Depends},
- ${shlibs:Depends},
- libstdc++6,
- python-numpy,
- python-fabio,
- python-h5py,
- python-pyopencl,
- python-mako,
- python-qtconsole,
- python-matplotlib,
- python-dateutil,
- python-opengl,
- python-pyqt5,
- python-pyqt5.qtsvg,
- python-pyqt5.qtopengl,
- python-scipy,
- python-setuptools,
- python-six,
- python-enum34,
- python-concurrent.futures,
-# Recommends:
-Suggests: python-rfoo
-Description: Toolbox for X-Ray data analysis - Python2 library
- .
- This is the Python 2 version of the package.
-
Package: python3-silx
Architecture: any
diff --git a/package/debian9/rules b/package/debian9/rules
index 160147b..655e06b 100755
--- a/package/debian9/rules
+++ b/package/debian9/rules
@@ -11,7 +11,7 @@ ALL_PYX := $(call rwildcard,silx/,*.pyx)
#NOTA: No space before *
%:
- dh $@ --with python2,python3 --buildsystem=pybuild
+ dh $@ --with python3 --buildsystem=pybuild
override_dh_clean:
dh_clean
@@ -24,10 +24,9 @@ override_dh_clean:
override_dh_auto_build:
dh_auto_build
- python setup.py build build_man build_doc
+ python3 setup.py build build_man build_doc
override_dh_install:
- dh_numpy
dh_numpy3
# move the scripts to right package
@@ -36,7 +35,6 @@ override_dh_install:
dh_install -p silx package/desktop/silx.png usr/share/icons/hicolor/48x48/apps
dh_install -p silx package/desktop/silx.svg usr/share/icons/hicolor/scalable/apps
dh_install -p silx package/desktop/silx.xml usr/share/mime/packages
- rm -rf debian/python-silx/usr/bin
rm -rf debian/python3-silx/usr/bin
dh_install
@@ -48,5 +46,5 @@ override_dh_installman:
dh_installman -p silx build/man/*.1
override_dh_installdocs:
- dh_installdocs "build/sphinx/html" -p python-silx-doc
+ dh_installdocs "build/sphinx/html" -p python-silx-doc --doc-main-package=python3-silx
dh_installdocs
diff --git a/package/windows/README.rst b/package/windows/README.rst
index a7f3702..97c1d54 100644
--- a/package/windows/README.rst
+++ b/package/windows/README.rst
@@ -26,12 +26,12 @@ Pre-requisites
Procedure
---------
-- Go to the `package/windows` folder in the source directory
-- Run `pyinstaller pyinstaller.spec`.
- This generates a fat binary in `package/windows/dist/silx/` for the generic launcher `silx.exe`.
-- Run `pyinstaller pyinstaller-silx-view.spec`.
- This generates a fat binary in `package/windows/dist/silx-view/` for the silx view command `silx-view.exe`.
-- Copy `silx-view.exe` and `silx-view.exe.manifest` to `package/windows/dist/silx/`.
+- Go to the ``package/windows`` folder in the source directory
+- Run ``pyinstaller pyinstaller.spec``.
+ This generates a fat binary in ``package/windows/dist/silx/`` for the generic launcher ``silx.exe``.
+- Run ``pyinstaller pyinstaller-silx-view.spec``.
+ This generates a fat binary in ``package/windows/dist/silx-view/`` for the silx view command ``silx-view.exe``.
+- Copy ``silx-view.exe`` and ``silx-view.exe.manifest`` to ``package/windows/dist/silx/``.
This is a hack until PyInstaller supports multiple executables (see https://github.com/pyinstaller/pyinstaller/issues/1527).
-- Zip `package\windows\dist\silx` to make the application available as a single zip file.
+- Zip ``package\windows\dist\silx`` to make the application available as a single zip file.
diff --git a/package/windows/pyinstaller-silx-view.spec b/package/windows/pyinstaller-silx-view.spec
index 6f36128..cf01fd1 100644
--- a/package/windows/pyinstaller-silx-view.spec
+++ b/package/windows/pyinstaller-silx-view.spec
@@ -1,6 +1,6 @@
# -*- mode: python -*-
import os.path
-from PyInstaller.utils.hooks import collect_data_files
+from PyInstaller.utils.hooks import collect_data_files, collect_submodules
datas = []
@@ -8,9 +8,10 @@ PROJECT_PATH = os.path.abspath(os.path.join(SPECPATH, "..", ".."))
datas.append((os.path.join(PROJECT_PATH, "README.rst"), "."))
datas.append((os.path.join(PROJECT_PATH, "LICENSE"), "."))
datas.append((os.path.join(PROJECT_PATH, "copyright"), "."))
+datas += collect_data_files("silx.resources")
-datas += collect_data_files("silx.resources")
+hiddenimports = collect_submodules('fabio')
block_cipher = None
@@ -20,7 +21,7 @@ a = Analysis(['bootstrap-silx-view.py'],
pathex=[],
binaries=[],
datas=datas,
- hiddenimports=[],
+ hiddenimports=hiddenimports,
hookspath=[],
runtime_hooks=[],
excludes=[],
diff --git a/package/windows/pyinstaller.spec b/package/windows/pyinstaller.spec
index 74d6a0f..548e41a 100644
--- a/package/windows/pyinstaller.spec
+++ b/package/windows/pyinstaller.spec
@@ -1,6 +1,6 @@
# -*- mode: python -*-
import os.path
-from PyInstaller.utils.hooks import collect_data_files
+from PyInstaller.utils.hooks import collect_data_files, collect_submodules
datas = []
@@ -8,9 +8,10 @@ PROJECT_PATH = os.path.abspath(os.path.join(SPECPATH, "..", ".."))
datas.append((os.path.join(PROJECT_PATH, "README.rst"), "."))
datas.append((os.path.join(PROJECT_PATH, "LICENSE"), "."))
datas.append((os.path.join(PROJECT_PATH, "copyright"), "."))
+datas += collect_data_files("silx.resources")
-datas += collect_data_files("silx.resources")
+hiddenimports = collect_submodules('fabio')
block_cipher = None
@@ -20,7 +21,7 @@ a = Analysis(['bootstrap.py'],
pathex=[],
binaries=[],
datas=datas,
- hiddenimports=[],
+ hiddenimports=hiddenimports,
hookspath=[],
runtime_hooks=[],
excludes=[],
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 62ce91e..dac7fad 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -2,9 +2,7 @@
# Those ARE NOT required for installation, at runtime or to build from source (except for the doc)
-r requirements.txt
-setuptools # Advanced packaging tools
wheel # To build wheels
-Cython >= 0.21.1 # To regenerate .c/.cpp files from .pyx
Sphinx # To build the documentation in doc/
lxml # For test coverage in run_test.py
coverage # For test coverage in run_test.py
diff --git a/requirements.txt b/requirements.txt
index 989e5b3..fb5690d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,16 +3,15 @@
--trusted-host www.silx.org
--find-links http://www.silx.org/pub/wheelhouse/
---only-binary numpy,h5py,scipy,PyQt4,PyQt5
+--only-binary numpy,h5py,scipy,PySide2,PyQt5
-# Required dependencies (from setup.py install_requires)
+# Required dependencies (from setup.py setup_requires and install_requires)
numpy >= 1.12
setuptools
+Cython >= 0.21.1
h5py
-fabio >= 0.7
+fabio >= 0.9
six
-enum34; python_version == '2.7'
-futures; python_version == '2.7'
# Extra dependencies (from setup.py extra_requires 'full' target)
pyopencl; platform_machine in "i386, x86_64, AMD64" # For silx.opencl
@@ -23,13 +22,4 @@ PyOpenGL # For silx.gui.plot3d
python-dateutil # For silx.gui.plot
scipy # For silx.math.fit demo, silx.image.sift demo, silx.image.sift.test
Pillow # For silx.opencl.image.test
-Cython >= 0.21.1 # For silx.math, silx.io, silx.image
-
-# PyQt5, PySide2 or PyQt4 # For silx.gui
-# Try to install a Qt binding from a wheel
-# This is no available for all configurations
-
-# Require PyQt when wheel is available
-PyQt5; python_version >= '3.5'
-PyQt4; sys_platform == 'win32' and python_version == '2.7' # From silx.org
-PyQt4; sys_platform == 'darwin' and python_version == '2.7' # From silx.org
+PyQt5 # or PySide2 # For silx.gui
diff --git a/setup.py b/setup.py
index a7f3f2a..6771e24 100644
--- a/setup.py
+++ b/setup.py
@@ -1,8 +1,8 @@
-#!/usr/bin/python
+#!/usr/bin/env python3
# coding: utf8
# /*##########################################################################
#
-# Copyright (c) 2015-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2020 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -25,10 +25,9 @@
# ###########################################################################*/
__authors__ = ["Jérôme Kieffer", "Thomas Vincent"]
-__date__ = "12/02/2019"
+__date__ = "06/05/2020"
__license__ = "MIT"
-
import sys
import os
import platform
@@ -40,30 +39,35 @@ import glob
# The silx.io module seems to be loaded instead.
import io
-
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("silx.setup")
-
from distutils.command.clean import clean as Clean
from distutils.command.build import build as _build
try:
from setuptools import Command
from setuptools.command.build_py import build_py as _build_py
- from setuptools.command.build_ext import build_ext
from setuptools.command.sdist import sdist
- logger.info("Use setuptools")
+ try:
+ from Cython.Build import build_ext
+ logger.info("Use setuptools with cython")
+ except ImportError:
+ from setuptools.command.build_ext import build_ext
+ logger.info("Use setuptools, cython is missing")
except ImportError:
try:
from numpy.distutils.core import Command
except ImportError:
from distutils.core import Command
from distutils.command.build_py import build_py as _build_py
- from distutils.command.build_ext import build_ext
from distutils.command.sdist import sdist
- logger.info("Use distutils")
-
+ try:
+ from Cython.Build import build_ext
+ logger.info("Use distutils with cython")
+ except ImportError:
+ from distutils.command.build_ext import build_ext
+ logger.info("Use distutils, cython is missing")
try:
import sphinx
import sphinx.util.console
@@ -72,8 +76,9 @@ try:
except ImportError:
sphinx = None
-
PROJECT = "silx"
+if sys.version_info.major < 3:
+ logger.error(PROJECT + " no longer supports Python2")
if "LANG" not in os.environ and sys.platform == "darwin" and sys.version_info[0] > 2:
print("""WARNING: the LANG environment variable is not defined,
@@ -114,36 +119,33 @@ classifiers = ["Development Status :: 4 - Beta",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX",
"Programming Language :: Cython",
- "Programming Language :: Python :: 2.7",
- "Programming Language :: Python :: 3.5",
- "Programming Language :: Python :: 3.6",
- "Programming Language :: Python :: 3.7",
- "Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: 3",
"Programming Language :: Python :: Implementation :: CPython",
"Topic :: Scientific/Engineering :: Physics",
"Topic :: Software Development :: Libraries :: Python Modules",
]
-
# ########## #
# version.py #
# ########## #
+
class build_py(_build_py):
"""
Enhanced build_py which copies version.py to <PROJECT>._version.py
"""
+
def find_package_modules(self, package, package_dir):
modules = _build_py.find_package_modules(self, package, package_dir)
if package == PROJECT:
modules.append((PROJECT, '_version', 'version.py'))
return modules
-
########
# Test #
########
+
class PyTest(Command):
"""Command to start tests running the script: run_tests.py"""
user_options = []
@@ -162,12 +164,13 @@ class PyTest(Command):
if errno != 0:
raise SystemExit(errno)
-
# ################### #
# build_doc command #
# ################### #
+
if sphinx is None:
+
class SphinxExpectedCommand(Command):
"""Command to inform that sphinx is missing"""
user_options = []
@@ -255,6 +258,55 @@ class BuildMan(Command):
succeeded = succeeded and status == 0
return succeeded
+ @staticmethod
+ def _write_script(target_name, lst_lines=None):
+ """Write a script to a temporary file and return its name
+ :paran target_name: base of the script name
+ :param lst_lines: list of lines to be written in the script
+ :return: the actual filename of the script (for execution or removal)
+ """
+ import tempfile
+ import stat
+ script_fid, script_name = tempfile.mkstemp(prefix="%s_" % target_name, text=True)
+ with os.fdopen(script_fid, 'wt') as script:
+ for line in lst_lines:
+ if not line.endswith("\n"):
+ line += "\n"
+ script.write(line)
+ # make it executable
+ mode = os.stat(script_name).st_mode
+ os.chmod(script_name, mode + stat.S_IEXEC)
+ return script_name
+
+ def get_synopsis(self, module_name, env, log_output=False):
+ """Execute a script to retrieve the synopsis for help2man
+ :return: synopsis
+ :rtype: single line string
+ """
+ import subprocess
+ script_name = None
+ synopsis = None
+ script = ["#!%s\n" % sys.executable,
+ "import logging",
+ "logging.basicConfig(level=logging.ERROR)",
+ "import %s as app" % module_name,
+ "print(app.__doc__)"]
+ try:
+ script_name = self._write_script(module_name, script)
+ command_line = [sys.executable, script_name]
+ p = subprocess.Popen(command_line, env=env, stdout=subprocess.PIPE)
+ status = p.wait()
+ if status != 0:
+ logger.warning("Error while getting synopsis for module '%s'.", module_name)
+ synopsis = p.stdout.read().decode("utf-8").strip()
+ if synopsis == 'None':
+ synopsis = None
+ finally:
+ # clean up the script
+ if script_name is not None:
+ os.remove(script_name)
+ return synopsis
+
def run(self):
build = self.get_finalized_command('build')
path = sys.path
@@ -269,6 +321,7 @@ class BuildMan(Command):
import tempfile
import stat
script_name = None
+ workdir = tempfile.mkdtemp()
entry_points = self.entry_points_iterator()
for target_name, module_name, function_name in entry_points:
@@ -279,19 +332,22 @@ class BuildMan(Command):
py3 = sys.version_info >= (3, 0)
try:
# create a launcher using the right python interpreter
- script_fid, script_name = tempfile.mkstemp(prefix="%s_" % target_name, text=True)
- script = os.fdopen(script_fid, 'wt')
- script.write("#!%s\n" % sys.executable)
- script.write("import %s as app\n" % module_name)
- script.write("app.%s()\n" % function_name)
- script.close()
+ script_name = os.path.join(workdir, target_name)
+ with open(script_name, "wt") as script:
+ script.write("#!%s\n" % sys.executable)
+ script.write("import %s as app\n" % module_name)
+ script.write("app.%s()\n" % function_name)
# make it executable
mode = os.stat(script_name).st_mode
os.chmod(script_name, mode + stat.S_IEXEC)
# execute help2man
man_file = "build/man/%s.1" % target_name
- command_line = ["help2man", script_name, "-o", man_file]
+ command_line = ["help2man", "-N", script_name, "-o", man_file]
+
+ synopsis = self.get_synopsis(module_name, env)
+ if synopsis:
+ command_line += ["-n", synopsis]
if not py3:
# Before Python 3.4, ArgParser --version was using
# stderr to print the version
@@ -314,9 +370,11 @@ class BuildMan(Command):
# clean up the script
if script_name is not None:
os.remove(script_name)
+ os.rmdir(workdir)
if sphinx is not None:
+
class BuildDocCommand(BuildDoc):
"""Command to build documentation using sphinx.
@@ -352,6 +410,7 @@ if sphinx is not None:
sys.path.pop(0)
class BuildDocAndGenerateScreenshotCommand(BuildDocCommand):
+
def run(self):
old = os.environ.get('DIRECTIVE_SNAPSHOT_QT')
os.environ['DIRECTIVE_SNAPSHOT_QT'] = 'True'
@@ -365,17 +424,18 @@ else:
BuildDocCommand = SphinxExpectedCommand
BuildDocAndGenerateScreenshotCommand = SphinxExpectedCommand
-
# ################### #
# test_doc command #
# ################### #
if sphinx is not None:
+
class TestDocCommand(BuildDoc):
"""Command to test the documentation using sphynx doctest.
http://www.sphinx-doc.org/en/1.4.8/ext/doctest.html
"""
+
def run(self):
# make sure the python path is pointing to the newly built
# code so that the documentation is built on this and not a
@@ -395,11 +455,11 @@ if sphinx is not None:
else:
TestDocCommand = SphinxExpectedCommand
-
# ############################# #
# numpy.distutils Configuration #
# ############################# #
+
def configuration(parent_package='', top_path=None):
"""Recursive construction of package info to be used in setup().
@@ -530,10 +590,10 @@ class BuildExt(build_ext):
# Cytonize
from Cython.Build import cythonize
patched_exts = cythonize(
- [ext],
- compiler_directives={'embedsignature': True,
+ [ext],
+ compiler_directives={'embedsignature': True,
'language_level': 3},
- force=self.force_cython
+ force=self.force_cython
)
ext.sources = patched_exts[0].sources
@@ -635,7 +695,6 @@ class BuildExt(build_ext):
self.patch_extension(ext)
build_ext.build_extensions(self)
-
################################################################################
# Clean command
################################################################################
@@ -701,7 +760,6 @@ class CleanCommand(Clean):
except OSError:
pass
-
################################################################################
# Debian source tree
################################################################################
@@ -765,11 +823,11 @@ class sdist_debian(sdist):
self.archive_files = [debian_arch]
print("Building debian .orig.tar.gz in %s" % self.archive_files[0])
-
# ##### #
# setup #
# ##### #
+
def get_project_configuration(dry_run):
"""Returns project arguments for setup"""
# Use installed numpy version as minimal required version
@@ -788,7 +846,7 @@ def get_project_configuration(dry_run):
"setuptools",
# for io support
"h5py",
- "fabio>=0.7",
+ "fabio>=0.9",
# Python 2/3 compatibility
"six",
]
@@ -889,6 +947,7 @@ def get_project_configuration(dry_run):
package_data=package_data,
zip_safe=False,
entry_points=entry_points,
+ python_requires='>=3.5',
)
return setup_kwargs
diff --git a/silx.egg-info/PKG-INFO b/silx.egg-info/PKG-INFO
index 1c225b2..74f97d5 100644
--- a/silx.egg-info/PKG-INFO
+++ b/silx.egg-info/PKG-INFO
@@ -1,6 +1,6 @@
Metadata-Version: 2.1
Name: silx
-Version: 0.12.0
+Version: 0.13.1
Summary: Software library for X-ray data analysis
Home-page: http://www.silx.org/
Author: data analysis unit
@@ -129,12 +129,9 @@ Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Cython
-Classifier: Programming Language :: Python :: 2.7
-Classifier: Programming Language :: Python :: 3.5
-Classifier: Programming Language :: Python :: 3.6
-Classifier: Programming Language :: Python :: 3.7
-Classifier: Programming Language :: Python :: 3.8
+Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: Scientific/Engineering :: Physics
Classifier: Topic :: Software Development :: Libraries :: Python Modules
+Requires-Python: >=3.5
Provides-Extra: full
diff --git a/silx.egg-info/SOURCES.txt b/silx.egg-info/SOURCES.txt
index 30434a4..3e27a4d 100644
--- a/silx.egg-info/SOURCES.txt
+++ b/silx.egg-info/SOURCES.txt
@@ -137,7 +137,6 @@ doc/source/modules/gui/plot/roi.rst
doc/source/modules/gui/plot/scatterview.rst
doc/source/modules/gui/plot/stackview.rst
doc/source/modules/gui/plot/statswidget.rst
-doc/source/modules/gui/plot/tools.rst
doc/source/modules/gui/plot/utils.rst
doc/source/modules/gui/plot/actions/control.rst
doc/source/modules/gui/plot/actions/examples.rst
@@ -154,7 +153,6 @@ doc/source/modules/gui/plot/img/BasicGridStatsWidget.png
doc/source/modules/gui/plot/img/BasicStatsWidget.png
doc/source/modules/gui/plot/img/CompareImages.png
doc/source/modules/gui/plot/img/ComplexImageView.png
-doc/source/modules/gui/plot/img/CurveLegendsWidget.png
doc/source/modules/gui/plot/img/ImageView.png
doc/source/modules/gui/plot/img/LimitsToolBar.png
doc/source/modules/gui/plot/img/Plot1D.png
@@ -167,7 +165,6 @@ doc/source/modules/gui/plot/img/StackView.png
doc/source/modules/gui/plot/img/StackViewMainWindow.png
doc/source/modules/gui/plot/img/colorScale.png
doc/source/modules/gui/plot/img/colorScaleBar.png
-doc/source/modules/gui/plot/img/linearColorbar.png
doc/source/modules/gui/plot/img/logColorbar.png
doc/source/modules/gui/plot/img/netArea.png
doc/source/modules/gui/plot/img/netCounts.png
@@ -181,6 +178,10 @@ doc/source/modules/gui/plot/img/tickbar.png
doc/source/modules/gui/plot/stats/index.rst
doc/source/modules/gui/plot/stats/stats.rst
doc/source/modules/gui/plot/stats/statshandler.rst
+doc/source/modules/gui/plot/tools/index.rst
+doc/source/modules/gui/plot/tools/profile.rst
+doc/source/modules/gui/plot/tools/img/CurveLegendsWidget.png
+doc/source/modules/gui/plot/tools/img/linearColorbar.png
doc/source/modules/gui/plot3d/actions.rst
doc/source/modules/gui/plot3d/dev.rst
doc/source/modules/gui/plot3d/glutils.rst
@@ -281,6 +282,7 @@ doc/source/sample_code/img/compareImages.png
doc/source/sample_code/img/compositeline.png
doc/source/sample_code/img/customDataView.png
doc/source/sample_code/img/customHdf5TreeModel.png
+doc/source/sample_code/img/customSilxView.png
doc/source/sample_code/img/dropZones.png
doc/source/sample_code/img/exampleBaseline.png
doc/source/sample_code/img/fftPlotAction.png
@@ -288,6 +290,7 @@ doc/source/sample_code/img/fileDialog.png
doc/source/sample_code/img/findContours.png
doc/source/sample_code/img/hdf5widget.png
doc/source/sample_code/img/icons.png
+doc/source/sample_code/img/imageStack.png
doc/source/sample_code/img/imageview.png
doc/source/sample_code/img/periodicTable.png
doc/source/sample_code/img/plot3dContextMenu.png
@@ -299,6 +302,7 @@ doc/source/sample_code/img/plotCurveLegendWidget.png
doc/source/sample_code/img/plotInteractiveImageROI.png
doc/source/sample_code/img/plotItemsSelector.png
doc/source/sample_code/img/plotLimits.png
+doc/source/sample_code/img/plotProfile.png
doc/source/sample_code/img/plotStats.png
doc/source/sample_code/img/plotUpdateCurveFromThread.png
doc/source/sample_code/img/plotUpdateImageFromThread.png
@@ -318,6 +322,7 @@ examples/compareImages.py
examples/compositeline.py
examples/customDataView.py
examples/customHdf5TreeModel.py
+examples/customSilxView.py
examples/dropZones.py
examples/exampleBaseline.py
examples/fft.png
@@ -326,6 +331,7 @@ examples/fileDialog.py
examples/findContours.py
examples/hdf5widget.py
examples/icons.py
+examples/imageStack.py
examples/imageview.py
examples/periodicTable.py
examples/plot3dContextMenu.py
@@ -337,6 +343,7 @@ examples/plotCurveLegendWidget.py
examples/plotInteractiveImageROI.py
examples/plotItemsSelector.py
examples/plotLimits.py
+examples/plotProfile.py
examples/plotStats.py
examples/plotUpdateCurveFromThread.py
examples/plotUpdateImageFromThread.py
@@ -365,6 +372,19 @@ package/debian10/patches/series
package/debian10/source/format
package/debian10/source/options
package/debian10/tests/control
+package/debian11/changelog
+package/debian11/control
+package/debian11/gbp.conf
+package/debian11/py3dist-overrides
+package/debian11/python-silx-doc.doc-base
+package/debian11/rules
+package/debian11/watch
+package/debian11/patches/0002-use-the-system-mathjax-privacy-breach.patch
+package/debian11/patches/0003-do-not-modify-PYTHONPATH-from-setup.py.patch
+package/debian11/patches/series
+package/debian11/source/format
+package/debian11/source/options
+package/debian11/tests/control
package/debian9/changelog
package/debian9/clean
package/debian9/compat
@@ -447,6 +467,7 @@ silx/gui/data/NXdataWidgets.py
silx/gui/data/NumpyAxesSelector.py
silx/gui/data/RecordTableView.py
silx/gui/data/TextFormatter.py
+silx/gui/data/_RecordPlot.py
silx/gui/data/_VolumeWindow.py
silx/gui/data/__init__.py
silx/gui/data/setup.py
@@ -503,6 +524,7 @@ silx/gui/plot/Colors.py
silx/gui/plot/CompareImages.py
silx/gui/plot/ComplexImageView.py
silx/gui/plot/CurvesROIWidget.py
+silx/gui/plot/ImageStack.py
silx/gui/plot/ImageView.py
silx/gui/plot/Interaction.py
silx/gui/plot/ItemsSelectionDialog.py
@@ -580,6 +602,7 @@ silx/gui/plot/test/testColorBar.py
silx/gui/plot/test/testCompareImages.py
silx/gui/plot/test/testComplexImageView.py
silx/gui/plot/test/testCurvesROIWidget.py
+silx/gui/plot/test/testImageStack.py
silx/gui/plot/test/testImageView.py
silx/gui/plot/test/testInteraction.py
silx/gui/plot/test/testItem.py
@@ -591,7 +614,6 @@ silx/gui/plot/test/testPlotInteraction.py
silx/gui/plot/test/testPlotWidget.py
silx/gui/plot/test/testPlotWidgetNoBackend.py
silx/gui/plot/test/testPlotWindow.py
-silx/gui/plot/test/testProfile.py
silx/gui/plot/test/testSaveAction.py
silx/gui/plot/test/testScatterMaskToolsWidget.py
silx/gui/plot/test/testScatterView.py
@@ -606,15 +628,21 @@ silx/gui/plot/tools/__init__.py
silx/gui/plot/tools/roi.py
silx/gui/plot/tools/toolbars.py
silx/gui/plot/tools/profile/ScatterProfileToolBar.py
-silx/gui/plot/tools/profile/_BaseProfileToolBar.py
silx/gui/plot/tools/profile/__init__.py
+silx/gui/plot/tools/profile/core.py
+silx/gui/plot/tools/profile/editors.py
+silx/gui/plot/tools/profile/manager.py
+silx/gui/plot/tools/profile/rois.py
+silx/gui/plot/tools/profile/toolbar.py
silx/gui/plot/tools/test/__init__.py
silx/gui/plot/tools/test/testCurveLegendsWidget.py
+silx/gui/plot/tools/test/testProfile.py
silx/gui/plot/tools/test/testROI.py
silx/gui/plot/tools/test/testScatterProfileToolBar.py
silx/gui/plot/tools/test/testTools.py
silx/gui/plot/utils/__init__.py
silx/gui/plot/utils/axis.py
+silx/gui/plot/utils/intersections.py
silx/gui/plot3d/ParamTreeView.py
silx/gui/plot3d/Plot3DWidget.py
silx/gui/plot3d/Plot3DWindow.py
@@ -690,6 +718,7 @@ silx/gui/test/test_qt.py
silx/gui/test/utils.py
silx/gui/utils/__init__.py
silx/gui/utils/concurrent.py
+silx/gui/utils/glutils.py
silx/gui/utils/image.py
silx/gui/utils/projecturl.py
silx/gui/utils/qtutils.py
@@ -697,17 +726,20 @@ silx/gui/utils/testutils.py
silx/gui/utils/test/__init__.py
silx/gui/utils/test/test.py
silx/gui/utils/test/test_async.py
+silx/gui/utils/test/test_glutils.py
silx/gui/utils/test/test_image.py
silx/gui/utils/test/test_qtutils.py
silx/gui/utils/test/test_testutils.py
silx/gui/widgets/BoxLayoutDockWidget.py
silx/gui/widgets/ColormapNameComboBox.py
+silx/gui/widgets/ElidedLabel.py
silx/gui/widgets/FloatEdit.py
silx/gui/widgets/FlowLayout.py
silx/gui/widgets/FrameBrowser.py
silx/gui/widgets/HierarchicalTableView.py
silx/gui/widgets/LegendIconWidget.py
silx/gui/widgets/MedianFilterDialog.py
+silx/gui/widgets/MultiModeAction.py
silx/gui/widgets/PeriodicTable.py
silx/gui/widgets/PrintGeometryDialog.py
silx/gui/widgets/PrintPreview.py
@@ -720,6 +752,7 @@ silx/gui/widgets/__init__.py
silx/gui/widgets/setup.py
silx/gui/widgets/test/__init__.py
silx/gui/widgets/test/test_boxlayoutdockwidget.py
+silx/gui/widgets/test/test_elidedlabel.py
silx/gui/widgets/test/test_flowlayout.py
silx/gui/widgets/test/test_framebrowser.py
silx/gui/widgets/test/test_hierarchicaltableview.py
@@ -729,6 +762,7 @@ silx/gui/widgets/test/test_rangeslider.py
silx/gui/widgets/test/test_tablewidget.py
silx/gui/widgets/test/test_threadpoolpushbutton.py
silx/image/__init__.py
+silx/image/_boundingbox.py
silx/image/backprojection.py
silx/image/bilinear.pyx
silx/image/medianfilter.py
@@ -749,6 +783,7 @@ silx/image/marchingsquares/test/__init__.py
silx/image/marchingsquares/test/test_funcapi.py
silx/image/marchingsquares/test/test_mergeimpl.py
silx/image/test/__init__.py
+silx/image/test/test_bb.py
silx/image/test/test_bilinear.py
silx/image/test/test_medianfilter.py
silx/image/test/test_shapes.py
@@ -951,10 +986,18 @@ silx/resources/gui/icons/3d-plane-pan.png
silx/resources/gui/icons/3d-plane-pan.svg
silx/resources/gui/icons/3d-plane.png
silx/resources/gui/icons/3d-plane.svg
+silx/resources/gui/icons/add-range-horizontal.png
+silx/resources/gui/icons/add-range-horizontal.svg
silx/resources/gui/icons/add-shape-arc.png
silx/resources/gui/icons/add-shape-arc.svg
+silx/resources/gui/icons/add-shape-circle.png
+silx/resources/gui/icons/add-shape-circle.svg
+silx/resources/gui/icons/add-shape-cross.png
+silx/resources/gui/icons/add-shape-cross.svg
silx/resources/gui/icons/add-shape-diagonal.png
silx/resources/gui/icons/add-shape-diagonal.svg
+silx/resources/gui/icons/add-shape-ellipse.png
+silx/resources/gui/icons/add-shape-ellipse.svg
silx/resources/gui/icons/add-shape-horizontal.png
silx/resources/gui/icons/add-shape-horizontal.svg
silx/resources/gui/icons/add-shape-point.png
@@ -983,6 +1026,16 @@ silx/resources/gui/icons/colormap-histogram.png
silx/resources/gui/icons/colormap-histogram.svg
silx/resources/gui/icons/colormap-none.png
silx/resources/gui/icons/colormap-none.svg
+silx/resources/gui/icons/colormap-norm-arcsinh.png
+silx/resources/gui/icons/colormap-norm-arcsinh.svg
+silx/resources/gui/icons/colormap-norm-gamma.png
+silx/resources/gui/icons/colormap-norm-gamma.svg
+silx/resources/gui/icons/colormap-norm-linear.png
+silx/resources/gui/icons/colormap-norm-linear.svg
+silx/resources/gui/icons/colormap-norm-log.png
+silx/resources/gui/icons/colormap-norm-log.svg
+silx/resources/gui/icons/colormap-norm-sqrt.png
+silx/resources/gui/icons/colormap-norm-sqrt.svg
silx/resources/gui/icons/colormap-range.png
silx/resources/gui/icons/colormap-range.svg
silx/resources/gui/icons/colormap.png
@@ -1095,6 +1148,12 @@ silx/resources/gui/icons/last.png
silx/resources/gui/icons/last.svg
silx/resources/gui/icons/layer-nx.png
silx/resources/gui/icons/layer-nx.svg
+silx/resources/gui/icons/mask-clear-all.png
+silx/resources/gui/icons/mask-clear-all.svg
+silx/resources/gui/icons/mask-clear.png
+silx/resources/gui/icons/mask-clear.svg
+silx/resources/gui/icons/mask-invert.png
+silx/resources/gui/icons/mask-invert.svg
silx/resources/gui/icons/math-amplitude.png
silx/resources/gui/icons/math-amplitude.svg
silx/resources/gui/icons/math-average.png
@@ -1212,6 +1271,10 @@ silx/resources/gui/icons/shape-circle-solid.png
silx/resources/gui/icons/shape-circle-solid.svg
silx/resources/gui/icons/shape-circle.png
silx/resources/gui/icons/shape-circle.svg
+silx/resources/gui/icons/shape-cross.png
+silx/resources/gui/icons/shape-cross.svg
+silx/resources/gui/icons/shape-diagonal-directed.png
+silx/resources/gui/icons/shape-diagonal-directed.svg
silx/resources/gui/icons/shape-diagonal.png
silx/resources/gui/icons/shape-diagonal.svg
silx/resources/gui/icons/shape-ellipse-solid.png
@@ -1230,6 +1293,12 @@ silx/resources/gui/icons/shape-vertical.png
silx/resources/gui/icons/shape-vertical.svg
silx/resources/gui/icons/silx.png
silx/resources/gui/icons/silx.svg
+silx/resources/gui/icons/slice-cross.png
+silx/resources/gui/icons/slice-cross.svg
+silx/resources/gui/icons/slice-horizontal.png
+silx/resources/gui/icons/slice-horizontal.svg
+silx/resources/gui/icons/slice-vertical.png
+silx/resources/gui/icons/slice-vertical.svg
silx/resources/gui/icons/sliders-off.png
silx/resources/gui/icons/sliders-off.svg
silx/resources/gui/icons/sliders-on.png
@@ -1248,6 +1317,8 @@ silx/resources/gui/icons/tree-collapse-all.png
silx/resources/gui/icons/tree-collapse-all.svg
silx/resources/gui/icons/tree-expand-all.png
silx/resources/gui/icons/tree-expand-all.svg
+silx/resources/gui/icons/tree-sort.png
+silx/resources/gui/icons/tree-sort.svg
silx/resources/gui/icons/view-1d.png
silx/resources/gui/icons/view-1d.svg
silx/resources/gui/icons/view-2d-stack.png
diff --git a/silx.egg-info/requires.txt b/silx.egg-info/requires.txt
index 79c4d9c..4ed2690 100644
--- a/silx.egg-info/requires.txt
+++ b/silx.egg-info/requires.txt
@@ -1,7 +1,7 @@
numpy>=1.12.0
setuptools
h5py
-fabio>=0.7
+fabio>=0.9
six
[full]
diff --git a/silx/app/view/Viewer.py b/silx/app/view/Viewer.py
index 2daa2df..9503533 100644
--- a/silx/app/view/Viewer.py
+++ b/silx/app/view/Viewer.py
@@ -1,6 +1,6 @@
# coding: utf-8
# /*##########################################################################
-# Copyright (C) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (C) 2016-2020 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
@@ -65,7 +65,7 @@ class Viewer(qt.QMainWindow):
silxIcon = icons.getQIcon("silx")
self.setWindowIcon(silxIcon)
- self.__context = ApplicationContext(self, settings)
+ self.__context = self.createApplicationContext(settings)
self.__context.restoreLibrarySettings()
self.__dialogState = None
@@ -87,12 +87,12 @@ class Viewer(qt.QMainWindow):
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.__treeModelSorted = silx.gui.hdf5.NexusSortFilterProxyModel(self.__treeview)
+ self.__treeModelSorted.setSourceModel(treeModel)
+ self.__treeModelSorted.sort(0, qt.Qt.AscendingOrder)
+ self.__treeModelSorted.setSortCaseSensitivity(qt.Qt.CaseInsensitive)
- self.__treeview.setModel(treeModel2)
+ self.__treeview.setModel(self.__treeModelSorted)
rightPanel.addWidget(self.__treeWindow)
self.__customNxdata = CustomNxdataWidget(self)
@@ -147,6 +147,9 @@ class Viewer(qt.QMainWindow):
self.createMenus()
self.__context.restoreSettings()
+ def createApplicationContext(self, settings):
+ return ApplicationContext(self, settings)
+
def __createTreeWindow(self, treeView):
toolbar = qt.QToolBar(self)
toolbar.setIconSize(qt.QSize(16, 16))
@@ -199,6 +202,16 @@ class Viewer(qt.QMainWindow):
treeView.addAction(action)
self.__collapseAllAction = action
+ action = qt.QAction("&Sort file content", toolbar)
+ action.setIcon(icons.getQIcon("tree-sort"))
+ action.setToolTip("Toggle sorting of file content")
+ action.setCheckable(True)
+ action.setChecked(True)
+ action.triggered.connect(self.setContentSorted)
+ toolbar.addAction(action)
+ treeView.addAction(action)
+ self._sortContentAction = action
+
widget = qt.QWidget(self)
layout = qt.QVBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
@@ -379,12 +392,12 @@ class Viewer(qt.QMainWindow):
model = self.__treeview.model()
while len(indexes) > 0:
index = indexes.pop(0)
- if index.column() != 0:
- continue
if isinstance(index, tuple):
index, depth = index
else:
depth = 0
+ if index.column() != 0:
+ continue
if depth > 10:
# Avoid infinite loop with recursive links
@@ -407,12 +420,12 @@ class Viewer(qt.QMainWindow):
model = self.__treeview.model()
while len(indexes) > 0:
index = indexes.pop(0)
- if index.column() != 0:
- continue
if isinstance(index, tuple):
index, depth = index
else:
depth = 0
+ if index.column() != 0:
+ continue
if depth > 10:
# Avoid infinite loop with recursive links
@@ -487,6 +500,11 @@ class Viewer(qt.QMainWindow):
settings.setValue("custom-nxdata-window-visible", isVisible)
settings.endGroup()
+ settings.beginGroup("content")
+ isSorted = self._sortContentAction.isChecked()
+ settings.setValue("is-sorted", isSorted)
+ settings.endGroup()
+
if isFullScreen:
self.showFullScreen()
@@ -530,6 +548,16 @@ class Viewer(qt.QMainWindow):
settings.endGroup()
+ settings.beginGroup("content")
+ isSorted = settings.value("is-sorted", True)
+ try:
+ if not isinstance(isSorted, bool):
+ isSorted = utils.stringToBool(isSorted)
+ except ValueError:
+ isSorted = True
+ self.setContentSorted(isSorted)
+ settings.endGroup()
+
if not pos.isNull():
self.move(pos)
if not size.isNull():
@@ -554,6 +582,11 @@ class Viewer(qt.QMainWindow):
action.triggered.connect(self.open)
self._openRecentAction = action
+ action = qt.QAction("Close All", self)
+ action.setStatusTip("Close all opened files")
+ action.triggered.connect(self.closeAll)
+ self._closeAllAction = action
+
action = qt.QAction("&About", self)
action.setStatusTip("Show the application's About box")
action.triggered.connect(self.about)
@@ -710,6 +743,7 @@ class Viewer(qt.QMainWindow):
fileMenu = self.menuBar().addMenu("&File")
fileMenu.addAction(self._openAction)
fileMenu.addAction(self._openRecentAction)
+ fileMenu.addAction(self._closeAllAction)
fileMenu.addSeparator()
fileMenu.addAction(self._exitAction)
fileMenu.aboutToShow.connect(self.__updateFileMenu)
@@ -744,6 +778,11 @@ class Viewer(qt.QMainWindow):
for filename in filenames:
self.appendFile(filename)
+ def closeAll(self):
+ """Close all currently opened files"""
+ model = self.__treeview.findHdf5TreeModel()
+ model.clear()
+
def createFileDialog(self):
dialog = qt.QFileDialog(self)
dialog.setWindowTitle("Open")
@@ -785,6 +824,41 @@ class Viewer(qt.QMainWindow):
url = projecturl.getDocumentationUrl(subpath)
qt.QDesktopServices.openUrl(qt.QUrl(url))
+ def setContentSorted(self, sort):
+ """Set whether file content should be sorted or not.
+
+ :param bool sort:
+ """
+ sort = bool(sort)
+ if sort != self.isContentSorted():
+
+ # save expanded nodes
+ pathss = []
+ root = qt.QModelIndex()
+ model = self.__treeview.model()
+ for i in range(model.rowCount(root)):
+ index = model.index(i, 0, root)
+ paths = self.__getPathFromExpandedNodes(self.__treeview, index)
+ pathss.append(paths)
+
+ self.__treeview.setModel(
+ self.__treeModelSorted if sort else self.__treeModelSorted.sourceModel())
+ self._sortContentAction.setChecked(self.isContentSorted())
+
+ # restore expanded nodes
+ model = self.__treeview.model()
+ for i in range(model.rowCount(root)):
+ index = model.index(i, 0, root)
+ paths = pathss.pop(0)
+ self.__expandNodesFromPaths(self.__treeview, index, paths)
+
+ def isContentSorted(self):
+ """Returns whether the file content is sorted or not.
+
+ :rtype: bool
+ """
+ return self.__treeview.model() is self.__treeModelSorted
+
def __forcePlotImageDownward(self):
silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = "downward"
diff --git a/silx/app/view/main.py b/silx/app/view/main.py
index 8139175..c7afc19 100644
--- a/silx/app/view/main.py
+++ b/silx/app/view/main.py
@@ -71,6 +71,12 @@ def createParser():
return parser
+def createWindow(parent, settings):
+ from .Viewer import Viewer
+ window = Viewer(parent=None, settings=settings)
+ return window
+
+
def mainQt(options):
"""Part of the main depending on Qt"""
if options.debug:
@@ -124,8 +130,7 @@ def mainQt(options):
if options.fresh_preferences:
settings.clear()
- from .Viewer import Viewer
- window = Viewer(parent=None, settings=settings)
+ window = createWindow(parent=None, settings=settings)
window.setAttribute(qt.Qt.WA_DeleteOnClose, True)
if options.use_opengl_plot:
diff --git a/silx/app/view/test/test_view.py b/silx/app/view/test/test_view.py
index 6601dce..7ea5a2c 100644
--- a/silx/app/view/test/test_view.py
+++ b/silx/app/view/test/test_view.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -164,7 +164,7 @@ class TestDataPanel(TestCaseQt):
self.assertIs(widget.getCustomNxdataItem(), data)
def testRemoveDatasetsFrom(self):
- f = h5py.File(os.path.join(_tmpDirectory, "data.h5"))
+ f = h5py.File(os.path.join(_tmpDirectory, "data.h5"), mode='r')
try:
widget = DataPanel()
widget.setData(f["arrays/scalar"])
@@ -175,8 +175,8 @@ class TestDataPanel(TestCaseQt):
f.close()
def testReplaceDatasetsFrom(self):
- f = h5py.File(os.path.join(_tmpDirectory, "data.h5"))
- f2 = h5py.File(os.path.join(_tmpDirectory, "data2.h5"))
+ f = h5py.File(os.path.join(_tmpDirectory, "data.h5"), mode='r')
+ f2 = h5py.File(os.path.join(_tmpDirectory, "data2.h5"), mode='r')
try:
widget = DataPanel()
widget.setData(f["arrays/scalar"])
@@ -243,7 +243,7 @@ class TestCustomNxdataWidget(TestCaseQt):
self.assertFalse(item.isValid())
def testRemoveDatasetsFrom(self):
- f = h5py.File(os.path.join(_tmpDirectory, "data.h5"))
+ f = h5py.File(os.path.join(_tmpDirectory, "data.h5"), mode='r')
try:
widget = CustomNxdataWidget()
model = widget.model()
@@ -255,8 +255,8 @@ class TestCustomNxdataWidget(TestCaseQt):
f.close()
def testReplaceDatasetsFrom(self):
- f = h5py.File(os.path.join(_tmpDirectory, "data.h5"))
- f2 = h5py.File(os.path.join(_tmpDirectory, "data2.h5"))
+ f = h5py.File(os.path.join(_tmpDirectory, "data.h5"), mode='r')
+ f2 = h5py.File(os.path.join(_tmpDirectory, "data2.h5"), mode='r')
try:
widget = CustomNxdataWidget()
model = widget.model()
diff --git a/silx/gui/_glutils/OpenGLWidget.py b/silx/gui/_glutils/OpenGLWidget.py
index c5ece9c..1f7bfae 100644
--- a/silx/gui/_glutils/OpenGLWidget.py
+++ b/silx/gui/_glutils/OpenGLWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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,39 +37,25 @@ import logging
import sys
from .. import qt
+from ..utils.glutils import isOpenGLAvailable
from .._glutils import gl
_logger = logging.getLogger(__name__)
-# Probe OpenGL availability and widget
-ERROR = '' # Error message from probing Qt OpenGL support
-_BaseOpenGLWidget = None # Qt OpenGL widget to use
-
-if hasattr(qt, 'QOpenGLWidget'): # PyQt>=5.4
- _logger.info('Using QOpenGLWidget')
- _BaseOpenGLWidget = qt.QOpenGLWidget
-
-elif not qt.HAS_OPENGL: # QtOpenGL not installed
- ERROR = '%s.QtOpenGL not available' % qt.BINDING
-
-elif qt.QApplication.instance() and not qt.QGLFormat.hasOpenGL():
- # qt.QGLFormat.hasOpenGL MUST be called with a QApplication created
- # so this is only checked if the QApplication is already created
- ERROR = 'Qt reports OpenGL not available'
+if not hasattr(qt, 'QOpenGLWidget') and not hasattr(qt, 'QGLWidget'):
+ OpenGLWidget = None
else:
- _logger.info('Using QGLWidget')
- _BaseOpenGLWidget = qt.QGLWidget
-
+ if hasattr(qt, 'QOpenGLWidget'): # PyQt>=5.4
+ _logger.info('Using QOpenGLWidget')
+ _BaseOpenGLWidget = qt.QOpenGLWidget
-# Internal class wrapping Qt OpenGL widget
-if _BaseOpenGLWidget is None:
- _logger.error('OpenGL-based widget disabled: %s', ERROR)
- _OpenGLWidget = None
+ else:
+ _logger.info('Using QGLWidget')
+ _BaseOpenGLWidget = qt.QGLWidget
-else:
class _OpenGLWidget(_BaseOpenGLWidget):
"""Wrapper over QOpenGLWidget and QGLWidget"""
@@ -119,7 +105,6 @@ else:
# Enable receiving mouse move events when no buttons are pressed
self.setMouseTracking(True)
-
def getDevicePixelRatio(self):
"""Returns the ratio device-independent / device pixel size
@@ -285,9 +270,13 @@ class OpenGLWidget(qt.QWidget):
layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(layout)
- if _OpenGLWidget is None:
+ self.__context = None
+
+ _check = isOpenGLAvailable(version=version, runtimeCheck=False)
+ if _OpenGLWidget is None or not _check:
+ _logger.error('OpenGL-based widget disabled: %s', _check.error)
self.__openGLWidget = None
- label = self._createErrorQLabel(ERROR)
+ label = self._createErrorQLabel(_check.error)
self.layout().addWidget(label)
else:
@@ -370,7 +359,10 @@ class OpenGLWidget(qt.QWidget):
if self.__openGLWidget is None:
return None
else:
- return self.__openGLWidget.context()
+ # Keep a reference on QOpenGLContext to make
+ # else PyQt5 keeps creating a new one.
+ self.__context = self.__openGLWidget.context()
+ return self.__context
def defaultFramebufferObject(self):
"""Returns the framebuffer object handle.
diff --git a/silx/gui/_glutils/font.py b/silx/gui/_glutils/font.py
index 8403c5a..6a4c489 100644
--- a/silx/gui/_glutils/font.py
+++ b/silx/gui/_glutils/font.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -128,8 +128,8 @@ def rasterText(text, font,
width = bounds.width() * devicePixelRatio + 2
# align line size to 32 bits to ease conversion to numpy array
width = 4 * ((width + 3) // 4)
- image = qt.QImage(width,
- bounds.height() * devicePixelRatio + 2,
+ image = qt.QImage(int(width),
+ int(bounds.height() * devicePixelRatio + 2),
qt.QImage.Format_RGB888)
if (devicePixelRatio != 1.0 and
hasattr(image, 'setDevicePixelRatio')): # Qt 5
diff --git a/silx/gui/colors.py b/silx/gui/colors.py
index 365b569..4d750ba 100755
--- a/silx/gui/colors.py
+++ b/silx/gui/colors.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2020 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,9 +35,8 @@ import numpy
import logging
import collections
from silx.gui import qt
-from silx import config
from silx.math.combo import min_max
-from silx.math.colormap import cmap as _cmap
+from silx.math import colormap as _colormap
from silx.utils.exceptions import NotEditableError
from silx.utils import deprecation
from silx.resources import resource_filename as _resource_filename
@@ -91,16 +90,16 @@ _LUT_DESCRIPTION = collections.namedtuple("_LUT_DESCRIPTION", ["source", "cursor
_AVAILABLE_LUTS = collections.OrderedDict([
('gray', _LUT_DESCRIPTION('builtin', 'pink', True)),
('reversed gray', _LUT_DESCRIPTION('builtin', 'pink', True)),
- ('temperature', _LUT_DESCRIPTION('builtin', 'pink', True)),
('red', _LUT_DESCRIPTION('builtin', 'green', True)),
('green', _LUT_DESCRIPTION('builtin', 'pink', True)),
('blue', _LUT_DESCRIPTION('builtin', 'yellow', True)),
- ('jet', _LUT_DESCRIPTION('matplotlib', 'pink', True)),
('viridis', _LUT_DESCRIPTION('resource', 'pink', True)),
('cividis', _LUT_DESCRIPTION('resource', 'pink', True)),
('magma', _LUT_DESCRIPTION('resource', 'green', True)),
('inferno', _LUT_DESCRIPTION('resource', 'green', True)),
('plasma', _LUT_DESCRIPTION('resource', 'green', True)),
+ ('temperature', _LUT_DESCRIPTION('builtin', 'pink', True)),
+ ('jet', _LUT_DESCRIPTION('matplotlib', 'pink', True)),
('hsv', _LUT_DESCRIPTION('matplotlib', 'black', True)),
])
"""Description for internal porpose of all the default LUT provided by the library."""
@@ -110,10 +109,6 @@ 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"""
def rgba(color, colorDict=None):
@@ -331,6 +326,167 @@ def _getColormap(name):
return _COLORMAP_CACHE[name]
+# Normalizations
+
+class _NormalizationMixIn:
+ """Colormap normalization mix-in class"""
+
+ DEFAULT_RANGE = 0, 1
+ """Fallback for (vmin, vmax)"""
+
+ def isValid(self, value):
+ """Check if a value is in the valid range for this normalization.
+
+ Override in subclass.
+
+ :param Union[float,numpy.ndarray] value:
+ :rtype: Union[bool,numpy.ndarray]
+ """
+ if isinstance(value, collections.abc.Iterable):
+ return numpy.ones_like(value, dtype=numpy.bool_)
+ else:
+ return True
+
+ def autoscale(self, data, mode):
+ """Returns range for given data and autoscale mode.
+
+ :param Union[None,numpy.ndarray] data:
+ :param str mode: Autoscale mode, see :class:`Colormap`
+ :returns: Range as (min, max)
+ :rtype: Tuple[float,float]
+ """
+ data = None if data is None else numpy.array(data, copy=False)
+ if data is None or data.size == 0:
+ return self.DEFAULT_RANGE
+
+ if mode == Colormap.MINMAX:
+ vmin, vmax = self.autoscaleMinMax(data)
+ elif mode == Colormap.STDDEV3:
+ vmin, vmax = self.autoscaleMean3Std(data)
+ else:
+ raise ValueError('Unsupported mode: %s' % mode)
+
+ # Check returned range and handle fallbacks
+ if vmin is None or not numpy.isfinite(vmin):
+ vmin = self.DEFAULT_RANGE[0]
+ if vmax is None or not numpy.isfinite(vmax):
+ vmax = self.DEFAULT_RANGE[1]
+ if vmax < vmin:
+ vmax = vmin
+ return float(vmin), float(vmax)
+
+ def autoscaleMinMax(self, data):
+ """Autoscale using min/max
+
+ :param numpy.ndarray data:
+ :returns: (vmin, vmax)
+ :rtype: Tuple[float,float]
+ """
+ data = data[self.isValid(data)]
+ if data.size == 0:
+ return None, None
+ result = min_max(data, min_positive=False, finite=True)
+ return result.minimum, result.maximum
+
+ def autoscaleMean3Std(self, data):
+ """Autoscale using mean+/-3std
+
+ This implementation only works for normalization that do NOT
+ use the data range.
+ Override this method for normalization using the range.
+
+ :param numpy.ndarray data:
+ :returns: (vmin, vmax)
+ :rtype: Tuple[float,float]
+ """
+ # Use [0, 1] as data range for normalization not using range
+ normdata = self.apply(data, 0., 1.)
+ if normdata.dtype.kind == 'f': # Replaces inf by NaN
+ normdata[numpy.isfinite(normdata) == False] = numpy.nan
+ if normdata.size == 0: # Fallback
+ return None, None
+ mean, std = numpy.nanmean(normdata), numpy.nanstd(normdata)
+ return self.revert(mean - 3 * std, 0., 1.), self.revert(mean + 3 * std, 0., 1.)
+
+
+class _LinearNormalizationMixIn(_NormalizationMixIn):
+ """Colormap normalization mix-in class specific to autoscale taken from initial range"""
+
+ def autoscaleMean3Std(self, data):
+ """Autoscale using mean+/-3std
+
+ Do the autoscale on the data itself, not the normalized data.
+
+ :param numpy.ndarray data:
+ :returns: (vmin, vmax)
+ :rtype: Tuple[float,float]
+ """
+ if data.dtype.kind == 'f': # Replaces inf by NaN
+ data = numpy.array(data, copy=True) # Work on a copy
+ data[numpy.isfinite(data) == False] = numpy.nan
+ if data.size == 0: # Fallback
+ return None, None
+ mean, std = numpy.nanmean(data), numpy.nanstd(data)
+ return mean - 3 * std, mean + 3 * std
+
+
+class _LinearNormalization(_colormap.LinearNormalization, _LinearNormalizationMixIn):
+ """Linear normalization"""
+ def __init__(self):
+ _colormap.LinearNormalization.__init__(self)
+ _LinearNormalizationMixIn.__init__(self)
+
+
+class _LogarithmicNormalization(_colormap.LogarithmicNormalization, _NormalizationMixIn):
+ """Logarithm normalization"""
+
+ DEFAULT_RANGE = 1, 10
+
+ def __init__(self):
+ _colormap.LogarithmicNormalization.__init__(self)
+ _NormalizationMixIn.__init__(self)
+
+ def isValid(self, value):
+ return value > 0.
+
+ def autoscaleMinMax(self, data):
+ result = min_max(data, min_positive=True, finite=True)
+ return result.min_positive, result.maximum
+
+
+class _SqrtNormalization(_colormap.SqrtNormalization, _NormalizationMixIn):
+ """Square root normalization"""
+
+ DEFAULT_RANGE = 0, 1
+
+ def __init__(self):
+ _colormap.SqrtNormalization.__init__(self)
+ _NormalizationMixIn.__init__(self)
+
+ def isValid(self, value):
+ return value >= 0.
+
+
+class _GammaNormalization(_colormap.PowerNormalization, _LinearNormalizationMixIn):
+ """Gamma correction normalization:
+
+ Linear normalization to [0, 1] followed by power normalization.
+
+ :param gamma: Gamma correction factor
+ """
+ def __init__(self, gamma):
+ _colormap.PowerNormalization.__init__(self, gamma)
+ _LinearNormalizationMixIn.__init__(self)
+
+
+class _ArcsinhNormalization(_colormap.ArcsinhNormalization, _NormalizationMixIn):
+ """Inverse hyperbolic sine normalization"""
+
+ def __init__(self):
+ _colormap.ArcsinhNormalization.__init__(self)
+ _NormalizationMixIn.__init__(self)
+
+
class Colormap(qt.QObject):
"""Description of a colormap
@@ -342,10 +498,10 @@ class Colormap(qt.QObject):
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)
+ :param vmin: Lower bound of the colormap or None for autoscale (default)
+ :type vmin: Union[None, float]
+ :param vmax: Upper bounds of the colormap or None for autoscale (default)
+ :type vmax: Union[None, float]
"""
LINEAR = 'linear'
@@ -354,17 +510,46 @@ class Colormap(qt.QObject):
LOGARITHM = 'log'
"""constant for logarithmic normalization"""
- NORMALIZATIONS = (LINEAR, LOGARITHM)
+ SQRT = 'sqrt'
+ """constant for square root normalization"""
+
+ GAMMA = 'gamma'
+ """Constant for gamma correction normalization"""
+
+ ARCSINH = 'arcsinh'
+ """constant for inverse hyperbolic sine normalization"""
+
+ _BASIC_NORMALIZATIONS = {
+ LINEAR: _LinearNormalization(),
+ LOGARITHM: _LogarithmicNormalization(),
+ SQRT: _SqrtNormalization(),
+ ARCSINH: _ArcsinhNormalization(),
+ }
+ """Normalizations without parameters"""
+
+ NORMALIZATIONS = LINEAR, LOGARITHM, SQRT, GAMMA, ARCSINH
"""Tuple of managed normalizations"""
+ MINMAX = 'minmax'
+ """constant for autoscale using min/max data range"""
+
+ STDDEV3 = 'stddev3'
+ """constant for autoscale using mean +/- 3*std(data)"""
+
+ AUTOSCALE_MODES = (MINMAX, STDDEV3)
+ """Tuple of managed auto scale algorithms"""
+
sigChanged = qt.Signal()
"""Signal emitted when the colormap has changed."""
- def __init__(self, name=None, colors=None, normalization=LINEAR, vmin=None, vmax=None):
+ def __init__(self, name=None, colors=None, normalization=LINEAR, vmin=None, vmax=None, autoscaleMode=MINMAX):
qt.QObject.__init__(self)
self._editable = True
+ self.__gamma = 2.0
assert normalization in Colormap.NORMALIZATIONS
+ assert autoscaleMode in Colormap.AUTOSCALE_MODES
+
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."
@@ -395,6 +580,7 @@ class Colormap(qt.QObject):
self.setName("gray")
self._normalization = str(normalization)
+ self._autoscaleMode = str(autoscaleMode)
self._vmin = float(vmin) if vmin is not None else None
self._vmax = float(vmax) if vmax is not None else None
@@ -432,11 +618,12 @@ class Colormap(qt.QObject):
if nbColors is None:
return numpy.array(self._colors, copy=True)
else:
+ nbColors = int(nbColors)
colormap = self.copy()
colormap.setNormalization(Colormap.LINEAR)
- colormap.setVRange(vmin=None, vmax=None)
+ colormap.setVRange(vmin=0, vmax=nbColors - 1)
colors = colormap.applyToData(
- numpy.arange(int(nbColors), dtype=numpy.int))
+ numpy.arange(nbColors, dtype=numpy.int))
return colors
def getName(self):
@@ -503,7 +690,9 @@ class Colormap(qt.QObject):
self.sigChanged.emit()
def getNormalization(self):
- """Return the normalization of the colormap ('log' or 'linear')
+ """Return the normalization of the colormap.
+
+ See :meth:`setNormalization` for returned values.
:return: the normalization of the colormap
:rtype: str
@@ -511,15 +700,58 @@ class Colormap(qt.QObject):
return self._normalization
def setNormalization(self, norm):
- """Set the norm ('log', 'linear')
+ """Set the colormap normalization.
+
+ Accepted normalizations: 'log', 'linear', 'sqrt'
:param str norm: the norm to set
"""
+ assert norm in self.NORMALIZATIONS
if self.isEditable() is False:
raise NotEditableError('Colormap is not editable')
self._normalization = str(norm)
self.sigChanged.emit()
+ def setGammaNormalizationParameter(self, gamma: float) -> None:
+ """Set the gamma correction parameter.
+
+ Only used for gamma correction normalization.
+
+ :param float gamma:
+ :raise ValueError: If gamma is not valid
+ """
+ if gamma < 0. or not numpy.isfinite(gamma):
+ raise ValueError("Gamma value not supported")
+ if gamma != self.__gamma:
+ self.__gamma = gamma
+ self.sigChanged.emit()
+
+ def getGammaNormalizationParameter(self) -> float:
+ """Returns the gamma correction parameter value.
+
+ :rtype: float
+ """
+ return self.__gamma
+
+ def getAutoscaleMode(self):
+ """Return the autoscale mode of the colormap ('minmax' or 'stddev3')
+
+ :rtype: str
+ """
+ return self._autoscaleMode
+
+ def setAutoscaleMode(self, mode):
+ """Set the autoscale mode: either 'minmax' or 'stddev3'
+
+ :param str mode: the mode to set
+ """
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
+ assert mode in self.AUTOSCALE_MODES
+ if mode != self._autoscaleMode:
+ self._autoscaleMode = mode
+ self.sigChanged.emit()
+
def isAutoscale(self):
"""Return True if both min and max are in autoscale mode"""
return self._vmin is None and self._vmax is None
@@ -593,49 +825,57 @@ class Colormap(qt.QObject):
self._editable = editable
self.sigChanged.emit()
+ def _getNormalizer(self):
+ """Returns normalizer object"""
+ normalization = self.getNormalization()
+ if normalization == self.GAMMA:
+ return _GammaNormalization(self.getGammaNormalizationParameter())
+ else:
+ return self._BASIC_NORMALIZATIONS[normalization]
+
+ def _computeAutoscaleRange(self, data):
+ """Compute the data range which will be used in autoscale mode.
+
+ :param numpy.ndarray data: The data for which to compute the range
+ :return: (vmin, vmax) range
+ """
+ return self._getNormalizer().autoscale(
+ data, mode=self.getAutoscaleMode())
+
def getColormapRange(self, data=None):
- """Return (vmin, vmax)
+ """Return (vmin, vmax) the range of the colormap for the given data or item.
- :return: the tuple vmin, vmax fitting vmin, vmax, normalization and
- data if any given
+ :param Union[numpy.ndarray,~silx.gui.plot.items.ColormapMixIn] data:
+ The data or item to use for autoscale bounds.
+ :return: (vmin, vmax) corresponding to the colormap applied to data if provided.
: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
+ normalizer = self._getNormalizer()
+
+ # Handle invalid bounds as autoscale
+ if vmin is not None and not normalizer.isValid(vmin):
+ _logger.info(
+ 'Invalid vmin, switching to autoscale for lower bound')
+ vmin = None
+ if vmax is not None and not normalizer.isValid(vmax):
+ _logger.info(
+ 'Invalid vmax, switching to autoscale for upper bound')
+ 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()
+ from .plot.items.core import ColormapMixIn # avoid cyclic import
+ if isinstance(data, ColormapMixIn):
+ min_, max_ = data._getColormapAutoscaleRange(self)
+ # Make sure min_, max_ are not None
+ min_ = normalizer.DEFAULT_RANGE[0] if min_ is None else min_
+ max_ = normalizer.DEFAULT_RANGE[1] if max_ is None else max_
+ else:
+ min_, max_ = normalizer.autoscale(
+ data, mode=self.getAutoscaleMode())
if vmin is None: # Set vmin respecting provided vmax
vmin = min_ if vmax is None else min(min_, vmax)
@@ -645,6 +885,15 @@ class Colormap(qt.QObject):
return vmin, vmax
+ def getVRange(self):
+ """Get the bounds of the colormap
+
+ :rtype: Tuple(Union[float,None],Union[float,None])
+ :returns: A tuple of 2 values for min and max. Or None instead of float
+ for autoscale
+ """
+ return self.getVMin(), self.getVMax()
+
def setVRange(self, vmin, vmax):
"""Set the bounds of the colormap
@@ -681,6 +930,8 @@ class Colormap(qt.QObject):
return self.getVMax()
elif item == 'colors':
return self.getColormapLUT()
+ elif item == 'autoscaleMode':
+ return self.getAutoscaleMode()
else:
raise KeyError(item)
@@ -697,8 +948,9 @@ class Colormap(qt.QObject):
'vmin': self._vmin,
'vmax': self._vmax,
'autoscale': self.isAutoscale(),
- 'normalization': self._normalization
- }
+ 'normalization': self.getNormalization(),
+ 'autoscaleMode': self.getAutoscaleMode(),
+ }
def _setFromDict(self, dic):
"""Set values to the colormap from a dictionary
@@ -728,7 +980,12 @@ class Colormap(qt.QObject):
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
+ err = 'Given normalization is not recognized (%s)' % normalization
+ raise ValueError(err)
+
+ autoscaleMode = dic.get('autoscaleMode', Colormap.MINMAX)
+ if autoscaleMode not in Colormap.AUTOSCALE_MODES:
+ err = 'Given autoscale mode is not recognized (%s)' % autoscaleMode
raise ValueError(err)
# If autoscale, then set boundaries to None
@@ -743,6 +1000,7 @@ class Colormap(qt.QObject):
self._vmax = vmax
self._autoscale = True if (vmin is None and vmax is None) else False
self._normalization = normalization
+ self._autoscaleMode = autoscaleMode
self.sigChanged.emit()
@@ -757,20 +1015,33 @@ class Colormap(qt.QObject):
:rtype: silx.gui.colors.Colormap
"""
- return Colormap(name=self._name,
+ colormap = Colormap(name=self._name,
colors=self.getColormapLUT(),
vmin=self._vmin,
vmax=self._vmax,
- normalization=self._normalization)
+ normalization=self.getNormalization(),
+ autoscaleMode=self.getAutoscaleMode())
+ colormap.setGammaNormalizationParameter(
+ self.getGammaNormalizationParameter())
+ return colormap
- def applyToData(self, data):
+ def applyToData(self, data, reference=None):
"""Apply the colormap to the data
- :param numpy.ndarray data: The data to convert.
+ :param Union[numpy.ndarray,~silx.gui.plot.item.ColormapMixIn] data:
+ The data to convert or the item for which to apply the colormap.
+ :param Union[numpy.ndarray,~silx.gui.plot.item.ColormapMixIn,None] reference:
+ The data or item to use as reference to compute autoscale
"""
- vmin, vmax = self.getColormapRange(data)
- normalization = self.getNormalization()
- return _cmap(data, self._colors, vmin, vmax, normalization)
+ if reference is None:
+ reference = data
+ vmin, vmax = self.getColormapRange(reference)
+
+ if hasattr(data, "getColormappedData"): # Use item's data
+ data = data.getColormappedData()
+
+ return _colormap.cmap(
+ data, self._colors, vmin, vmax, self._getNormalizer())
@staticmethod
def getSupportedColormaps():
@@ -796,26 +1067,26 @@ class Colormap(qt.QObject):
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"""
if other is None:
return False
if not isinstance(other, Colormap):
return False
+ if self.getNormalization() != other.getNormalization():
+ return False
+ if self.getNormalization() == self.GAMMA:
+ delta = self.getGammaNormalizationParameter() - other.getGammaNormalizationParameter()
+ if abs(delta) > 0.001:
+ return False
return (self.getName() == other.getName() and
- self.getNormalization() == other.getNormalization() and
+ self.getAutoscaleMode() == other.getAutoscaleMode() and
self.getVMin() == other.getVMin() and
self.getVMax() == other.getVMax() and
numpy.array_equal(self.getColormapLUT(), other.getColormapLUT())
)
- _SERIAL_VERSION = 1
+ _SERIAL_VERSION = 2
def restoreState(self, byteArray):
"""
@@ -835,7 +1106,7 @@ class Colormap(qt.QObject):
return False
version = stream.readUInt32()
- if version != self._SERIAL_VERSION:
+ if version not in (1, self._SERIAL_VERSION):
_logger.warning("Serial version mismatch. Found %d." % version)
return False
@@ -850,14 +1121,27 @@ class Colormap(qt.QObject):
vmax = stream.readQVariant()
else:
vmax = None
+
normalization = stream.readQString()
+ if normalization == Colormap.GAMMA:
+ gamma = stream.readFloat()
+ else:
+ gamma = None
+
+ if version == 1:
+ autoscaleMode = Colormap.MINMAX
+ else:
+ autoscaleMode = stream.readQString()
# emit change event only once
old = self.blockSignals(True)
try:
self.setName(name)
self.setNormalization(normalization)
+ self.setAutoscaleMode(autoscaleMode)
self.setVRange(vmin, vmax)
+ if gamma is not None:
+ self.setGammaNormalizationParameter(gamma)
finally:
self.blockSignals(old)
self.sigChanged.emit()
@@ -882,6 +1166,9 @@ class Colormap(qt.QObject):
if self.getVMax() is not None:
stream.writeQVariant(self.getVMax())
stream.writeQString(self.getNormalization())
+ if self.getNormalization() == Colormap.GAMMA:
+ stream.writeFloat(self.getGammaNormalizationParameter())
+ stream.writeQString(self.getAutoscaleMode())
return data
diff --git a/silx/gui/data/DataViewer.py b/silx/gui/data/DataViewer.py
index bad4362..2e51439 100644
--- a/silx/gui/data/DataViewer.py
+++ b/silx/gui/data/DataViewer.py
@@ -27,10 +27,12 @@ view from the ones provided by silx.
"""
from __future__ import division
-from silx.gui.data import DataViews
-from silx.gui.data.DataViews import _normalizeData
import logging
+import os.path
+import collections
from silx.gui import qt
+from silx.gui.data import DataViews
+from silx.gui.data.DataViews import _normalizeData
from silx.gui.utils import blockSignals
from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector
@@ -43,6 +45,11 @@ __date__ = "12/02/2019"
_logger = logging.getLogger(__name__)
+DataSelection = collections.namedtuple("DataSelection",
+ ["filename", "datapath",
+ "slice", "permutation"])
+
+
class DataViewer(qt.QFrame):
"""Widget to display any kind of data
@@ -150,6 +157,7 @@ class DataViewer(qt.QFrame):
DataViews._Plot3dView,
DataViews._RawView,
DataViews._StackView,
+ DataViews._Plot2dRecordView,
]
views = []
for viewClass in viewClasses:
@@ -238,14 +246,39 @@ class DataViewer(qt.QFrame):
"""
if self.__useAxisSelection:
self.__displayedData = self.__numpySelection.selectedData()
+
+ permutation = self.__numpySelection.permutation()
+ normal = tuple(range(len(permutation)))
+ if permutation == normal:
+ permutation = None
+ slicing = self.__numpySelection.selection()
+ normal = tuple([slice(None)] * len(slicing))
+ if slicing == normal:
+ slicing = None
else:
self.__displayedData = self.__data
+ permutation = None
+ slicing = None
+
+ try:
+ filename = os.path.abspath(self.__data.file.filename)
+ except:
+ filename = None
+
+ try:
+ datapath = self.__data.name
+ except:
+ datapath = None
+
+ # FIXME: maybe use DataUrl, with added support of permutation
+ self.__displayedSelection = DataSelection(filename, datapath, slicing, permutation)
# TODO: would be good to avoid that, it should be synchonous
qt.QTimer.singleShot(10, self.__setDataInView)
def __setDataInView(self):
self.__currentView.setData(self.__displayedData)
+ self.__currentView.setDataSelection(self.__displayedSelection)
def setDisplayedView(self, view):
"""Set the displayed view.
@@ -468,6 +501,7 @@ class DataViewer(qt.QFrame):
self.__data = data
self._invalidateInfo()
self.__displayedData = None
+ self.__displayedSelection = None
self.__updateView()
self.__updateNumpySelectionAxis()
self.__updateDataInView()
diff --git a/silx/gui/data/DataViews.py b/silx/gui/data/DataViews.py
index eb635c4..f3b02b9 100644
--- a/silx/gui/data/DataViews.py
+++ b/silx/gui/data/DataViews.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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,6 +29,7 @@ from collections import OrderedDict
import logging
import numbers
import numpy
+import os
import silx.io
from silx.utils import deprecation
@@ -50,6 +51,7 @@ _logger = logging.getLogger(__name__)
# DataViewer modes
EMPTY_MODE = 0
PLOT1D_MODE = 10
+RECORD_PLOT_MODE = 15
IMAGE_MODE = 20
PLOT2D_MODE = 21
COMPLEX_IMAGE_MODE = 22
@@ -114,6 +116,7 @@ class DataInfo(object):
self.isRecord = False
self.hasNXdata = False
self.isInvalidNXdata = False
+ self.countNumericColumns = 0
self.shape = tuple()
self.dim = 0
self.size = 0
@@ -200,6 +203,12 @@ class DataInfo(object):
else:
self.size = 1
+ if hasattr(data, "dtype"):
+ if data.dtype.fields is not None:
+ for field in data.dtype.fields:
+ if numpy.issubdtype(data.dtype[field], numpy.number):
+ self.countNumericColumns += 1
+
def normalizeData(self, data):
"""Returns a normalized data if the embed a numpy or a dataset.
Else returns the data."""
@@ -223,6 +232,9 @@ class DataViewHooks(object):
"""Returns a color dialog for this view."""
return None
+ def viewWidgetCreated(self, view, plot):
+ """Called when the widget of the view was created"""
+ return
class DataView(object):
"""Holder for the data view."""
@@ -231,6 +243,12 @@ class DataView(object):
"""Priority returned when the requested data can't be displayed by the
view."""
+ TITLE_PATTERN = "{datapath}{slicing} {permuted}"
+ """Pattern used to format the title of the plot.
+
+ Supported fields: `{directory}`, `{filename}`, `{datapath}`, `{slicing}`, `{permuted}`.
+ """
+
def __init__(self, parent, modeId=None, icon=None, label=None):
"""Constructor
@@ -334,6 +352,9 @@ class DataView(object):
"""
if self.__widget is None:
self.__widget = self.createWidget(self.__parent)
+ hooks = self.getHooks()
+ if hooks is not None:
+ hooks.viewWidgetCreated(self, self.__widget)
return self.__widget
def createWidget(self, parent):
@@ -356,6 +377,70 @@ class DataView(object):
"""
return None
+ def __formatSlices(self, indices):
+ """Format an iterable of slice objects
+
+ :param indices: The slices to format
+ :type indices: Union[None,List[Union[slice,int]]]
+ :rtype: str
+ """
+ if indices is None:
+ return ''
+
+ def formatSlice(slice_):
+ start, stop, step = slice_.start, slice_.stop, slice_.step
+ string = ('' if start is None else str(start)) + ':'
+ if stop is not None:
+ string += str(stop)
+ if step not in (None, 1):
+ string += ':' + step
+ return string
+
+ return '[' + ', '.join(
+ formatSlice(index) if isinstance(index, slice) else str(index)
+ for index in indices) + ']'
+
+ def titleForSelection(self, selection):
+ """Build title from given selection information.
+
+ :param NamedTuple selection: Data selected
+ :rtype: str
+ """
+ if selection is None:
+ return None
+ else:
+ directory, filename = os.path.split(selection.filename)
+ try:
+ slicing = self.__formatSlices(selection.slice)
+ except Exception:
+ _logger.debug("Error while formatting slices", exc_info=True)
+ slicing = '[sliced]'
+
+ permuted = '(permuted)' if selection.permutation is not None else ''
+
+ try:
+ title = self.TITLE_PATTERN.format(
+ directory=directory,
+ filename=filename,
+ datapath=selection.datapath,
+ slicing=slicing,
+ permuted=permuted)
+ except Exception:
+ _logger.debug("Error while formatting title", exc_info=True)
+ title = selection.datapath + slicing
+
+ return title
+
+ def setDataSelection(self, selection):
+ """Set the data selection displayed by the view
+
+ If called, it have to be called directly after `setData`.
+
+ :param selection: Data selected
+ :type selection: NamedTuple
+ """
+ pass
+
def axesNames(self, data, info):
"""Returns names of the expected axes of the view, according to the
input data. A none value will disable the default axes selectior.
@@ -579,6 +664,11 @@ class SelectOneDataView(_CompositeDataView):
self.__updateDisplayedView()
self.__currentView.setData(data)
+ def setDataSelection(self, selection):
+ if self.__currentView is None:
+ return
+ self.__currentView.setDataSelection(selection)
+
def axesNames(self, data, info):
view = self.__getBestView(data, info)
self.__currentView = view
@@ -799,12 +889,18 @@ class _Plot1dView(DataView):
def setData(self, data):
data = self.normalizeData(data)
- self.getWidget().addCurve(legend="data",
- x=range(len(data)),
- y=data,
- resetzoom=self.__resetZoomNextTime)
+ plotWidget = self.getWidget()
+ legend = "data"
+ plotWidget.addCurve(legend=legend,
+ x=range(len(data)),
+ y=data,
+ resetzoom=self.__resetZoomNextTime)
+ plotWidget.setActiveCurve(legend)
self.__resetZoomNextTime = True
+ def setDataSelection(self, selection):
+ self.getWidget().setGraphTitle(self.titleForSelection(selection))
+
def axesNames(self, data, info):
return ["y"]
@@ -825,6 +921,107 @@ class _Plot1dView(DataView):
return 10
+class _Plot2dRecordView(DataView):
+ def __init__(self, parent):
+ super(_Plot2dRecordView, self).__init__(
+ parent=parent,
+ modeId=RECORD_PLOT_MODE,
+ label="Curve",
+ icon=icons.getQIcon("view-1d"))
+ self.__resetZoomNextTime = True
+ self._data = None
+ self._xAxisDropDown = None
+ self._yAxisDropDown = None
+ self.__fields = None
+
+ def createWidget(self, parent):
+ from ._RecordPlot import RecordPlot
+ return RecordPlot(parent=parent)
+
+ def clear(self):
+ self.getWidget().clear()
+ self.__resetZoomNextTime = True
+
+ def normalizeData(self, data):
+ data = DataView.normalizeData(self, data)
+ data = _normalizeComplex(data)
+ return data
+
+ def setData(self, data):
+ self._data = self.normalizeData(data)
+
+ all_fields = sorted(self._data.dtype.fields.items(), key=lambda e: e[1][1])
+ numeric_fields = [f[0] for f in all_fields if numpy.issubdtype(f[1][0], numpy.number)]
+ if numeric_fields == self.__fields: # Reuse previously selected fields
+ fieldNameX = self.getWidget().getXAxisFieldName()
+ fieldNameY = self.getWidget().getYAxisFieldName()
+ else:
+ self.__fields = numeric_fields
+
+ self.getWidget().setSelectableXAxisFieldNames(numeric_fields)
+ self.getWidget().setSelectableYAxisFieldNames(numeric_fields)
+ fieldNameX = None
+ fieldNameY = numeric_fields[0]
+
+ # If there is a field called time, use it for the x-axis by default
+ if "time" in numeric_fields:
+ fieldNameX = "time"
+ # Use the first field that is not "time" for the y-axis
+ if fieldNameY == "time" and len(numeric_fields) >= 2:
+ fieldNameY = numeric_fields[1]
+
+ self._plotData(fieldNameX, fieldNameY)
+
+ if not self._xAxisDropDown:
+ self._xAxisDropDown = self.getWidget().getAxesSelectionToolBar().getXAxisDropDown()
+ self._yAxisDropDown = self.getWidget().getAxesSelectionToolBar().getYAxisDropDown()
+ self._xAxisDropDown.activated.connect(self._onAxesSelectionChaned)
+ self._yAxisDropDown.activated.connect(self._onAxesSelectionChaned)
+
+ def setDataSelection(self, selection):
+ self.getWidget().setGraphTitle(self.titleForSelection(selection))
+
+ def _onAxesSelectionChaned(self):
+ fieldNameX = self._xAxisDropDown.currentData()
+ self._plotData(fieldNameX, self._yAxisDropDown.currentText())
+
+ def _plotData(self, fieldNameX, fieldNameY):
+ self.clear()
+ ydata = self._data[fieldNameY]
+ if fieldNameX is None:
+ xdata = numpy.arange(len(ydata))
+ else:
+ xdata = self._data[fieldNameX]
+ self.getWidget().addCurve(legend="data",
+ x=xdata,
+ y=ydata,
+ resetzoom=self.__resetZoomNextTime)
+ self.getWidget().setXAxisFieldName(fieldNameX)
+ self.getWidget().setYAxisFieldName(fieldNameY)
+ self.__resetZoomNextTime = True
+
+ def axesNames(self, data, info):
+ return ["data"]
+
+ def getDataPriority(self, data, info):
+ if info.size <= 0:
+ return DataView.UNSUPPORTED
+ if data is None or not info.isRecord:
+ return DataView.UNSUPPORTED
+ if info.dim < 1:
+ return DataView.UNSUPPORTED
+ if info.countNumericColumns < 2:
+ return DataView.UNSUPPORTED
+ if info.interpretation == "spectrum":
+ return 1000
+ if info.dim == 2 and info.shape[0] == 1:
+ return 210
+ if info.dim == 1:
+ return 40
+ else:
+ return 10
+
+
class _Plot2dView(DataView):
"""View displaying data using a 2d plot"""
@@ -863,6 +1060,9 @@ class _Plot2dView(DataView):
resetzoom=self.__resetZoomNextTime)
self.__resetZoomNextTime = False
+ def setDataSelection(self, selection):
+ self.getWidget().setGraphTitle(self.titleForSelection(selection))
+
def axesNames(self, data, info):
return ["y", "x"]
@@ -969,6 +1169,10 @@ class _ComplexImageView(DataView):
data = self.normalizeData(data)
self.getWidget().setData(data)
+ def setDataSelection(self, selection):
+ self.getWidget().getPlot().setGraphTitle(
+ self.titleForSelection(selection))
+
def axesNames(self, data, info):
return ["y", "x"]
@@ -1045,7 +1249,7 @@ class _StackView(DataView):
from silx.gui import plot
widget = plot.StackView(parent=parent)
widget.setColormap(self.defaultColormap())
- widget.getPlot().getColormapAction().setColorDialog(self.defaultColorDialog())
+ widget.getPlotWidget().getColormapAction().setColorDialog(self.defaultColorDialog())
widget.setKeepDataAspectRatio(True)
widget.setLabels(self.axesNames(None, None))
# hide default option panel
@@ -1068,6 +1272,11 @@ class _StackView(DataView):
self.getWidget().setColormap(self.defaultColormap())
self.__resetZoomNextTime = False
+ def setDataSelection(self, selection):
+ title = self.titleForSelection(selection)
+ self.getWidget().setTitleCallback(
+ lambda idx: "%s z=%d" % (title, idx))
+
def axesNames(self, data, info):
return ["depth", "y", "x"]
@@ -1337,12 +1546,26 @@ class _InvalidNXdataView(DataView):
return 100
-class _NXdataScalarView(DataView):
+class _NXdataBaseDataView(DataView):
+ """Base class for NXdata DataView"""
+
+ def __init__(self, *args, **kwargs):
+ DataView.__init__(self, *args, **kwargs)
+
+ def _updateColormap(self, nxdata):
+ """Update used colormap according to nxdata's SILX_style"""
+ cmap_norm = nxdata.plot_style.signal_scale_type
+ if cmap_norm is not None:
+ self.defaultColormap().setNormalization(
+ 'log' if cmap_norm == 'log' else 'linear')
+
+
+class _NXdataScalarView(_NXdataBaseDataView):
"""DataView using a table view for displaying NXdata scalars:
0-D signal or n-D signal with *@interpretation=scalar*"""
def __init__(self, parent):
- DataView.__init__(self, parent,
- modeId=NXDATA_SCALAR_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent, modeId=NXDATA_SCALAR_MODE)
def createWidget(self, parent):
from silx.gui.data.ArrayTableWidget import ArrayTableWidget
@@ -1375,7 +1598,7 @@ class _NXdataScalarView(DataView):
return DataView.UNSUPPORTED
-class _NXdataCurveView(DataView):
+class _NXdataCurveView(_NXdataBaseDataView):
"""DataView using a Plot1D for displaying NXdata curves:
1-D signal or n-D signal with *@interpretation=spectrum*.
@@ -1383,8 +1606,8 @@ class _NXdataCurveView(DataView):
a 1-D signal with one axis whose values are not monotonically increasing.
"""
def __init__(self, parent):
- DataView.__init__(self, parent,
- modeId=NXDATA_CURVE_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent, modeId=NXDATA_CURVE_MODE)
def createWidget(self, parent):
from silx.gui.data.NXdataWidgets import ArrayCurvePlot
@@ -1422,7 +1645,9 @@ class _NXdataCurveView(DataView):
self.getWidget().setCurvesData([nxd.signal] + nxd.auxiliary_signals, nxd.axes[-1],
yerror=nxd.errors, xerror=x_errors,
ylabels=signals_names, xlabel=nxd.axes_names[-1],
- title=nxd.title or signals_names[0])
+ title=nxd.title or signals_names[0],
+ xscale=nxd.plot_style.axes_scale_types[-1],
+ yscale=nxd.plot_style.signal_scale_type)
def getDataPriority(self, data, info):
data = self.normalizeData(data)
@@ -1432,16 +1657,19 @@ class _NXdataCurveView(DataView):
return DataView.UNSUPPORTED
-class _NXdataXYVScatterView(DataView):
+class _NXdataXYVScatterView(_NXdataBaseDataView):
"""DataView using a Plot1D for displaying NXdata 3D scatters as
a scatter of coloured points (1-D signal with 2 axes)"""
def __init__(self, parent):
- DataView.__init__(self, parent,
- modeId=NXDATA_XYVSCATTER_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent, modeId=NXDATA_XYVSCATTER_MODE)
def createWidget(self, parent):
from silx.gui.data.NXdataWidgets import XYVScatterPlot
widget = XYVScatterPlot(parent)
+ widget.getScatterView().setColormap(self.defaultColormap())
+ widget.getScatterView().getScatterToolBar().getColormapAction().setColorDialog(
+ self.defaultColorDialog())
return widget
def axesNames(self, data, info):
@@ -1472,11 +1700,15 @@ class _NXdataXYVScatterView(DataView):
else:
y_errors = None
+ self._updateColormap(nxd)
+
self.getWidget().setScattersData(y_axis, x_axis, values=[nxd.signal] + nxd.auxiliary_signals,
yerror=y_errors, xerror=x_errors,
ylabel=y_label, xlabel=x_label,
title=nxd.title,
- scatter_titles=[nxd.signal_name] + nxd.auxiliary_signals_names)
+ scatter_titles=[nxd.signal_name] + nxd.auxiliary_signals_names,
+ xscale=nxd.plot_style.axes_scale_types[-2],
+ yscale=nxd.plot_style.axes_scale_types[-1])
def getDataPriority(self, data, info):
data = self.normalizeData(data)
@@ -1488,12 +1720,12 @@ class _NXdataXYVScatterView(DataView):
return DataView.UNSUPPORTED
-class _NXdataImageView(DataView):
+class _NXdataImageView(_NXdataBaseDataView):
"""DataView using a Plot2D for displaying NXdata images:
2-D signal or n-D signals with *@interpretation=image*."""
def __init__(self, parent):
- DataView.__init__(self, parent,
- modeId=NXDATA_IMAGE_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent, modeId=NXDATA_IMAGE_MODE)
def createWidget(self, parent):
from silx.gui.data.NXdataWidgets import ArrayImagePlot
@@ -1514,17 +1746,21 @@ class _NXdataImageView(DataView):
nxd = nxdata.get_default(data, validate=False)
isRgba = nxd.interpretation == "rgba-image"
+ self._updateColormap(nxd)
+
# last two axes are Y & X
img_slicing = slice(-2, None) if not isRgba else slice(-3, -1)
y_axis, x_axis = nxd.axes[img_slicing]
y_label, x_label = nxd.axes_names[img_slicing]
+ y_scale, x_scale = nxd.plot_style.axes_scale_types[img_slicing]
self.getWidget().setImageData(
[nxd.signal] + nxd.auxiliary_signals,
x_axis=x_axis, y_axis=y_axis,
signals_names=[nxd.signal_name] + nxd.auxiliary_signals_names,
xlabel=x_label, ylabel=y_label,
- title=nxd.title, isRgba=isRgba)
+ title=nxd.title, isRgba=isRgba,
+ xscale=x_scale, yscale=y_scale)
def getDataPriority(self, data, info):
data = self.normalizeData(data)
@@ -1536,12 +1772,12 @@ class _NXdataImageView(DataView):
return DataView.UNSUPPORTED
-class _NXdataComplexImageView(DataView):
+class _NXdataComplexImageView(_NXdataBaseDataView):
"""DataView using a ComplexImageView for displaying NXdata complex images:
2-D signal or n-D signals with *@interpretation=image*."""
def __init__(self, parent):
- DataView.__init__(self, parent,
- modeId=NXDATA_IMAGE_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent, modeId=NXDATA_IMAGE_MODE)
def createWidget(self, parent):
from silx.gui.data.NXdataWidgets import ArrayComplexImagePlot
@@ -1556,6 +1792,8 @@ class _NXdataComplexImageView(DataView):
data = self.normalizeData(data)
nxd = nxdata.get_default(data, validate=False)
+ self._updateColormap(nxd)
+
# last two axes are Y & X
img_slicing = slice(-2, None)
y_axis, x_axis = nxd.axes[img_slicing]
@@ -1583,16 +1821,16 @@ class _NXdataComplexImageView(DataView):
return DataView.UNSUPPORTED
-class _NXdataStackView(DataView):
+class _NXdataStackView(_NXdataBaseDataView):
def __init__(self, parent):
- DataView.__init__(self, parent,
- modeId=NXDATA_STACK_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent, modeId=NXDATA_STACK_MODE)
def createWidget(self, parent):
from silx.gui.data.NXdataWidgets import ArrayStackPlot
widget = ArrayStackPlot(parent)
widget.getStackView().setColormap(self.defaultColormap())
- widget.getStackView().getPlot().getColormapAction().setColorDialog(self.defaultColorDialog())
+ widget.getStackView().getPlotWidget().getColormapAction().setColorDialog(self.defaultColorDialog())
return widget
def axesNames(self, data, info):
@@ -1610,6 +1848,8 @@ class _NXdataStackView(DataView):
z_label, y_label, x_label = nxd.axes_names[-3:]
title = nxd.title or signal_name
+ self._updateColormap(nxd)
+
widget = self.getWidget()
widget.setStackData(
nxd.signal, x_axis=x_axis, y_axis=y_axis, z_axis=z_axis,
@@ -1628,12 +1868,13 @@ class _NXdataStackView(DataView):
return DataView.UNSUPPORTED
-class _NXdataVolumeView(DataView):
+class _NXdataVolumeView(_NXdataBaseDataView):
def __init__(self, parent):
- DataView.__init__(self, parent,
- label="NXdata (3D)",
- icon=icons.getQIcon("view-nexus"),
- modeId=NXDATA_VOLUME_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent,
+ label="NXdata (3D)",
+ icon=icons.getQIcon("view-nexus"),
+ modeId=NXDATA_VOLUME_MODE)
try:
import silx.gui.plot3d # noqa
except ImportError:
@@ -1642,7 +1883,7 @@ class _NXdataVolumeView(DataView):
raise
def normalizeData(self, data):
- data = DataView.normalizeData(self, data)
+ data = super(_NXdataVolumeView, self).normalizeData(data)
data = _normalizeComplex(data)
return data
@@ -1682,18 +1923,19 @@ class _NXdataVolumeView(DataView):
return DataView.UNSUPPORTED
-class _NXdataVolumeAsStackView(DataView):
+class _NXdataVolumeAsStackView(_NXdataBaseDataView):
def __init__(self, parent):
- DataView.__init__(self, parent,
- label="NXdata (2D)",
- icon=icons.getQIcon("view-nexus"),
- modeId=NXDATA_VOLUME_AS_STACK_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent,
+ label="NXdata (2D)",
+ icon=icons.getQIcon("view-nexus"),
+ modeId=NXDATA_VOLUME_AS_STACK_MODE)
def createWidget(self, parent):
from silx.gui.data.NXdataWidgets import ArrayStackPlot
widget = ArrayStackPlot(parent)
widget.getStackView().setColormap(self.defaultColormap())
- widget.getStackView().getPlot().getColormapAction().setColorDialog(self.defaultColorDialog())
+ widget.getStackView().getPlotWidget().getColormapAction().setColorDialog(self.defaultColorDialog())
return widget
def axesNames(self, data, info):
@@ -1711,6 +1953,8 @@ class _NXdataVolumeAsStackView(DataView):
z_label, y_label, x_label = nxd.axes_names[-3:]
title = nxd.title or signal_name
+ self._updateColormap(nxd)
+
widget = self.getWidget()
widget.setStackData(
nxd.signal, x_axis=x_axis, y_axis=y_axis, z_axis=z_axis,
@@ -1730,12 +1974,13 @@ class _NXdataVolumeAsStackView(DataView):
return DataView.UNSUPPORTED
-class _NXdataComplexVolumeAsStackView(DataView):
+class _NXdataComplexVolumeAsStackView(_NXdataBaseDataView):
def __init__(self, parent):
- DataView.__init__(self, parent,
- label="NXdata (2D)",
- icon=icons.getQIcon("view-nexus"),
- modeId=NXDATA_VOLUME_AS_STACK_MODE)
+ _NXdataBaseDataView.__init__(
+ self, parent,
+ label="NXdata (2D)",
+ icon=icons.getQIcon("view-nexus"),
+ modeId=NXDATA_VOLUME_AS_STACK_MODE)
self._is_complex_data = False
def createWidget(self, parent):
@@ -1759,6 +2004,8 @@ class _NXdataComplexVolumeAsStackView(DataView):
z_label, y_label, x_label = nxd.axes_names[-3:]
title = nxd.title or signal_name
+ self._updateColormap(nxd)
+
self.getWidget().setImageData(
[nxd.signal] + nxd.auxiliary_signals,
x_axis=x_axis, y_axis=y_axis,
diff --git a/silx/gui/data/Hdf5TableView.py b/silx/gui/data/Hdf5TableView.py
index d7c33f3..57d6f7b 100644
--- a/silx/gui/data/Hdf5TableView.py
+++ b/silx/gui/data/Hdf5TableView.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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 @@ import functools
import os.path
import logging
import h5py
+import numpy
from silx.gui import qt
import silx.io
@@ -265,7 +266,7 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
return cell.span()
elif role == self.IsHeaderRole:
return cell.isHeader()
- elif role == qt.Qt.DisplayRole:
+ elif role in (qt.Qt.DisplayRole, qt.Qt.EditRole):
value = cell.value()
if callable(value):
try:
@@ -287,12 +288,6 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
return cell.data(role)
return None
- def flags(self, index):
- """QAbstractTableModel method to inform the view whether data
- is editable or not.
- """
- return qt.QAbstractTableModel.flags(self, index)
-
def isSupportedObject(self, h5pyObject):
"""
Returns true if the provided object can be modelized using this model.
@@ -349,6 +344,16 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
shape = self.__hdf5Formatter.humanReadableShape(dataset)
return u"%s = %s" % (shape, size)
+ def __formatChunks(self, dataset):
+ """Format the shape"""
+ chunks = dataset.chunks
+ if chunks is None:
+ return ""
+ shape = " \u00D7 ".join([str(i) for i in chunks])
+ sizes = numpy.product(chunks)
+ text = "%s = %s" % (shape, sizes)
+ return text
+
def __initProperties(self):
"""Initialize the list of available properties according to the defined
h5py-like object."""
@@ -418,7 +423,7 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
if hasattr(obj, "shape"):
self.__data.addHeaderValueRow("shape", self.__formatShape)
if hasattr(obj, "chunks") and obj.chunks is not None:
- self.__data.addHeaderValueRow("chunks", lambda x: x.chunks)
+ self.__data.addHeaderValueRow("chunks", self.__formatChunks)
# relative to compression
# h5py expose compression, compression_opts but are not initialized
@@ -438,8 +443,8 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
self.__data.addRow(pos, hdf5id, name, options, availability)
for index in range(dcpl.get_nfilters()):
filterId, name, options = self.__getFilterInfo(obj, index)
- pos = _CellData(value=index)
- hdf5id = _CellData(value=filterId)
+ pos = _CellData(value=str(index))
+ hdf5id = _CellData(value=str(filterId))
name = _CellData(value=name)
options = _CellData(value=options)
availability = _CellFilterAvailableData(filterId=filterId)
@@ -517,12 +522,42 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
self.reset()
+class Hdf5TableItemDelegate(HierarchicalTableView.HierarchicalItemDelegate):
+ """Item delegate the :class:`Hdf5TableView` with read-only text editor"""
+
+ def createEditor(self, parent, option, index):
+ """See :meth:`QStyledItemDelegate.createEditor`"""
+ editor = super().createEditor(parent, option, index)
+ if isinstance(editor, qt.QLineEdit):
+ editor.setReadOnly(True)
+ editor.deselect()
+ editor.textChanged.connect(self.__textChanged, qt.Qt.QueuedConnection)
+ self.installEventFilter(editor)
+ return editor
+
+ def __textChanged(self, text):
+ sender = self.sender()
+ if sender is not None:
+ sender.deselect()
+
+ def eventFilter(self, watched, event):
+ eventType = event.type()
+ if eventType == qt.QEvent.FocusIn:
+ watched.selectAll()
+ qt.QTimer.singleShot(0, watched.selectAll)
+ elif eventType == qt.QEvent.FocusOut:
+ watched.deselect()
+ return super().eventFilter(watched, event)
+
+
class Hdf5TableView(HierarchicalTableView.HierarchicalTableView):
"""A widget to display metadata about a HDF5 node using a table."""
def __init__(self, parent=None):
super(Hdf5TableView, self).__init__(parent)
self.setModel(Hdf5TableModel(self))
+ self.setItemDelegate(Hdf5TableItemDelegate(self))
+ self.setSelectionMode(qt.QAbstractItemView.NoSelection)
def isSupportedData(self, data):
"""
@@ -538,7 +573,9 @@ class Hdf5TableView(HierarchicalTableView.HierarchicalTableView):
`silx.gui.hdf5.H5Node` which is needed to display some local path
information.
"""
- self.model().setObject(data)
+ model = self.model()
+
+ model.setObject(data)
header = self.horizontalHeader()
if qt.qVersion() < "5.0":
setResizeMode = header.setResizeMode
@@ -550,3 +587,10 @@ class Hdf5TableView(HierarchicalTableView.HierarchicalTableView):
setResizeMode(3, qt.QHeaderView.ResizeToContents)
setResizeMode(4, qt.QHeaderView.ResizeToContents)
header.setStretchLastSection(False)
+
+ for row in range(model.rowCount()):
+ for column in range(model.columnCount()):
+ index = model.index(row, column)
+ if (index.isValid() and index.data(
+ HierarchicalTableView.HierarchicalTableModel.IsHeaderRole) is False):
+ self.openPersistentEditor(index)
diff --git a/silx/gui/data/NXdataWidgets.py b/silx/gui/data/NXdataWidgets.py
index c3aefd3..224f337 100644
--- a/silx/gui/data/NXdataWidgets.py
+++ b/silx/gui/data/NXdataWidgets.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -99,7 +99,8 @@ class ArrayCurvePlot(qt.QWidget):
def setCurvesData(self, ys, x=None,
yerror=None, xerror=None,
- ylabels=None, xlabel=None, title=None):
+ ylabels=None, xlabel=None, title=None,
+ xscale=None, yscale=None):
"""
:param List[ndarray] ys: List of arrays to be represented by the y (vertical) axis.
@@ -115,6 +116,8 @@ class ArrayCurvePlot(qt.QWidget):
:param str ylabels: Labels for each curve's Y axis
:param str xlabel: Label for X axis
:param str title: Graph title
+ :param str xscale: Scale of X axis in (None, 'linear', 'log')
+ :param str yscale: Scale of Y axis in (None, 'linear', 'log')
"""
self.__signals = ys
self.__signals_names = ylabels or (["Y"] * len(ys))
@@ -135,6 +138,12 @@ class ArrayCurvePlot(qt.QWidget):
self._selector.show()
self._plot.setGraphTitle(title or "")
+ if xscale is not None:
+ self._plot.getXAxis().setScale(
+ 'log' if xscale == 'log' else 'linear')
+ if yscale is not None:
+ self._plot.getYAxis().setScale(
+ 'log' if yscale == 'log' else 'linear')
self._updateCurve()
if not self.__selector_is_connected:
@@ -235,6 +244,13 @@ class XYVScatterPlot(qt.QWidget):
def _sliderIdxChanged(self, value):
self._updateScatter()
+ def getScatterView(self):
+ """Returns the :class:`ScatterView` used for the display
+
+ :rtype: ScatterView
+ """
+ return self._plot
+
def getPlot(self):
"""Returns the plot used for the display
@@ -245,7 +261,8 @@ class XYVScatterPlot(qt.QWidget):
def setScattersData(self, y, x, values,
yerror=None, xerror=None,
ylabel=None, xlabel=None,
- title="", scatter_titles=None):
+ title="", scatter_titles=None,
+ xscale=None, yscale=None):
"""
:param ndarray y: 1D array for y (vertical) coordinates.
@@ -260,6 +277,8 @@ class XYVScatterPlot(qt.QWidget):
:param str xlabel: Label for X axis
:param str title: Main graph title
:param List[str] scatter_titles: Subtitles (one per scatter)
+ :param str xscale: Scale of X axis in (None, 'linear', 'log')
+ :param str yscale: Scale of Y axis in (None, 'linear', 'log')
"""
self.__y_axis = y
self.__x_axis = x
@@ -281,6 +300,13 @@ class XYVScatterPlot(qt.QWidget):
self._slider.setValue(0)
self._slider.valueChanged[int].connect(self._sliderIdxChanged)
+ if xscale is not None:
+ self._plot.getXAxis().setScale(
+ 'log' if xscale == 'log' else 'linear')
+ if yscale is not None:
+ self._plot.getYAxis().setScale(
+ 'log' if yscale == 'log' else 'linear')
+
self._updateScatter()
def _updateScatter(self):
@@ -289,10 +315,13 @@ class XYVScatterPlot(qt.QWidget):
idx = self._slider.value()
- title = ""
if self.__graph_title:
- title += self.__graph_title + "\n" # main NXdata @title
- title += self.__scatter_titles[idx] # scatter dataset name
+ title = self.__graph_title # main NXdata @title
+ if len(self.__scatter_titles) > 1:
+ # Append dataset name only when there is many datasets
+ title += '\n' + self.__scatter_titles[idx]
+ else:
+ title = self.__scatter_titles[idx] # scatter dataset name
self._plot.setGraphTitle(title)
self._plot.setData(x, y, self.__values[idx],
@@ -374,7 +403,8 @@ class ArrayImagePlot(qt.QWidget):
x_axis=None, y_axis=None,
signals_names=None,
xlabel=None, ylabel=None,
- title=None, isRgba=False):
+ title=None, isRgba=False,
+ xscale=None, yscale=None):
"""
:param signals: list of n-D datasets, whose last 2 dimensions are used as the
@@ -390,6 +420,8 @@ class ArrayImagePlot(qt.QWidget):
:param ylabel: Label for Y axis
:param title: Graph title
:param isRgba: True if data is a 3D RGBA image
+ :param str xscale: Scale of X axis in (None, 'linear', 'log')
+ :param str yscale: Scale of Y axis in (None, 'linear', 'log')
"""
self._selector.selectionChanged.disconnect(self._updateImage)
self._auxSigSlider.valueChanged.disconnect(self._sliderIdxChanged)
@@ -423,6 +455,7 @@ class ArrayImagePlot(qt.QWidget):
self._auxSigSlider.hide()
self._auxSigSlider.setValue(0)
+ self._axis_scales = xscale, yscale
self._updateImage()
self._plot.resetZoom()
@@ -473,10 +506,21 @@ class ArrayImagePlot(qt.QWidget):
origin = (xorigin, yorigin)
scale = (xscale, yscale)
+ self._plot.getXAxis().setScale('linear')
+ self._plot.getYAxis().setScale('linear')
self._plot.addImage(image, legend=legend,
origin=origin, scale=scale,
replace=True)
else:
+ xaxisscale, yaxisscale = self._axis_scales
+
+ if xaxisscale is not None:
+ self._plot.getXAxis().setScale(
+ 'log' if xaxisscale == 'log' else 'linear')
+ if yaxisscale is not None:
+ self._plot.getYAxis().setScale(
+ 'log' if yaxisscale == 'log' else 'linear')
+
scatterx, scattery = numpy.meshgrid(x_axis, y_axis)
# fixme: i don't think this can handle "irregular" RGBA images
self._plot.addScatter(numpy.ravel(scatterx),
@@ -484,11 +528,13 @@ class ArrayImagePlot(qt.QWidget):
numpy.ravel(image),
legend=legend)
- title = ""
if self.__title:
- title += self.__title
- if not title.strip().endswith(self.__signals_names[auxSigIdx]):
- title += "\n" + self.__signals_names[auxSigIdx]
+ title = self.__title
+ if len(self.__signals_names) > 1:
+ # Append dataset name only when there is many datasets
+ title += '\n' + self.__signals_names[auxSigIdx]
+ else:
+ title = self.__signals_names[auxSigIdx]
self._plot.setGraphTitle(title)
self._plot.getXAxis().setLabel(self.__x_axis_name)
self._plot.getYAxis().setLabel(self.__y_axis_name)
@@ -672,11 +718,13 @@ class ArrayComplexImagePlot(qt.QWidget):
self._plot.setOrigin((xorigin, yorigin))
self._plot.setScale((xscale, yscale))
- title = ""
if self.__title:
- title += self.__title
- if not title.strip().endswith(self.__signals_names[auxSigIdx]):
- title += "\n" + self.__signals_names[auxSigIdx]
+ title = self.__title
+ if len(self.__signals_names) > 1:
+ # Append dataset name only when there is many datasets
+ title += '\n' + self.__signals_names[auxSigIdx]
+ else:
+ title = self.__signals_names[auxSigIdx]
self._plot.setGraphTitle(title)
self._plot.getXAxis().setLabel(self.__x_axis_name)
self._plot.getYAxis().setLabel(self.__y_axis_name)
@@ -785,8 +833,8 @@ class ArrayStackPlot(qt.QWidget):
self._stack_view.setGraphTitle(title or "")
# by default, the z axis is the image position (dimension not plotted)
- self._stack_view.getPlot().getXAxis().setLabel(self.__x_axis_name or "X")
- self._stack_view.getPlot().getYAxis().setLabel(self.__y_axis_name or "Y")
+ self._stack_view.getPlotWidget().getXAxis().setLabel(self.__x_axis_name or "X")
+ self._stack_view.getPlotWidget().getYAxis().setLabel(self.__y_axis_name or "Y")
self._updateStack()
diff --git a/silx/gui/data/_RecordPlot.py b/silx/gui/data/_RecordPlot.py
new file mode 100644
index 0000000..5be792f
--- /dev/null
+++ b/silx/gui/data/_RecordPlot.py
@@ -0,0 +1,92 @@
+from silx.gui.plot.PlotWindow import PlotWindow
+from silx.gui.plot.PlotWidget import PlotWidget
+from .. import qt
+
+
+class RecordPlot(PlotWindow):
+ def __init__(self, parent=None, backend=None):
+ super(RecordPlot, self).__init__(parent=parent, backend=backend,
+ resetzoom=True, autoScale=True,
+ logScale=True, grid=True,
+ curveStyle=True, colormap=False,
+ aspectRatio=False, yInverted=False,
+ copy=True, save=True, print_=True,
+ control=True, position=True,
+ roi=True, mask=False, fit=True)
+ if parent is None:
+ self.setWindowTitle('RecordPlot')
+ self._axesSelectionToolBar = AxesSelectionToolBar(parent=self, plot=self)
+ self.addToolBar(qt.Qt.BottomToolBarArea, self._axesSelectionToolBar)
+
+ def setXAxisFieldName(self, value):
+ """Set the current selected field for the X axis.
+
+ :param Union[str,None] value:
+ """
+ label = '' if value is None else value
+ index = self._axesSelectionToolBar.getXAxisDropDown().findData(value)
+
+ if index >= 0:
+ self.getXAxis().setLabel(label)
+ self._axesSelectionToolBar.getXAxisDropDown().setCurrentIndex(index)
+
+ def getXAxisFieldName(self):
+ """Returns currently selected field for the X axis or None.
+
+ rtype: Union[str,None]
+ """
+ return self._axesSelectionToolBar.getXAxisDropDown().currentData()
+
+ def setYAxisFieldName(self, value):
+ self.getYAxis().setLabel(value)
+ index = self._axesSelectionToolBar.getYAxisDropDown().findText(value)
+ if index >= 0:
+ self._axesSelectionToolBar.getYAxisDropDown().setCurrentIndex(index)
+
+ def getYAxisFieldName(self):
+ return self._axesSelectionToolBar.getYAxisDropDown().currentText()
+
+ def setSelectableXAxisFieldNames(self, fieldNames):
+ """Add list of field names to X axis
+
+ :param List[str] fieldNames:
+ """
+ comboBox = self._axesSelectionToolBar.getXAxisDropDown()
+ comboBox.clear()
+ comboBox.addItem('-', None)
+ comboBox.insertSeparator(1)
+ for name in fieldNames:
+ comboBox.addItem(name, name)
+
+ def setSelectableYAxisFieldNames(self, fieldNames):
+ self._axesSelectionToolBar.getYAxisDropDown().clear()
+ self._axesSelectionToolBar.getYAxisDropDown().addItems(fieldNames)
+
+ def getAxesSelectionToolBar(self):
+ return self._axesSelectionToolBar
+
+class AxesSelectionToolBar(qt.QToolBar):
+ def __init__(self, parent=None, plot=None, title='Plot Axes Selection'):
+ super(AxesSelectionToolBar, self).__init__(title, parent)
+
+ assert isinstance(plot, PlotWidget)
+
+ self.addWidget(qt.QLabel("Field selection: "))
+
+ self._labelXAxis = qt.QLabel(" X: ")
+ self.addWidget(self._labelXAxis)
+
+ self._selectXAxisDropDown = qt.QComboBox()
+ self.addWidget(self._selectXAxisDropDown)
+
+ self._labelYAxis = qt.QLabel(" Y: ")
+ self.addWidget(self._labelYAxis)
+
+ self._selectYAxisDropDown = qt.QComboBox()
+ self.addWidget(self._selectYAxisDropDown)
+
+ def getXAxisDropDown(self):
+ return self._selectXAxisDropDown
+
+ def getYAxisDropDown(self):
+ return self._selectYAxisDropDown \ No newline at end of file
diff --git a/silx/gui/data/test/test_arraywidget.py b/silx/gui/data/test/test_arraywidget.py
index 6bcbbd3..7785ac5 100644
--- a/silx/gui/data/test/test_arraywidget.py
+++ b/silx/gui/data/test/test_arraywidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -199,7 +199,7 @@ class TestH5pyArrayWidget(TestCaseQt):
# create an h5py file with a dataset
self.tempdir = tempfile.mkdtemp()
self.h5_fname = os.path.join(self.tempdir, "array.h5")
- h5f = h5py.File(self.h5_fname)
+ h5f = h5py.File(self.h5_fname, mode='w')
h5f["my_array"] = self.data
h5f["my_scalar"] = 3.14
h5f["my_1D_array"] = numpy.array(numpy.arange(1000))
diff --git a/silx/gui/dialog/ColormapDialog.py b/silx/gui/dialog/ColormapDialog.py
index dddec4c..6b5d83b 100644
--- a/silx/gui/dialog/ColormapDialog.py
+++ b/silx/gui/dialog/ColormapDialog.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2020 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
@@ -72,15 +72,20 @@ import logging
import numpy
from .. import qt
-from ..colors import Colormap, preferredColormaps
+from .. import utils
+from ..colors import Colormap
from ..plot import PlotWidget
from ..plot.items.axis import Axis
+from ..plot.items import BoundingRect
from silx.gui.widgets.FloatEdit import FloatEdit
import weakref
from silx.math.combo import min_max
+from silx.gui.plot import items
from silx.gui import icons
+from silx.gui.qt import inspect as qtinspect
from silx.gui.widgets.ColormapNameComboBox import ColormapNameComboBox
from silx.math.histogram import Histogramnd
+from silx.utils import deprecation
_logger = logging.getLogger(__name__)
@@ -88,13 +93,39 @@ _logger = logging.getLogger(__name__)
_colormapIconPreview = {}
+class _DataRefHolder(items.Item, items.ColormapMixIn):
+ """Holder for a weakref of a numpy array.
+
+ It provides features from `ColormapMixIn`.
+ """
+
+ def __init__(self, dataRef):
+ items.Item.__init__(self)
+ items.ColormapMixIn.__init__(self)
+ self.__dataRef = dataRef
+ self._updated(items.ItemChangedType.DATA)
+
+ def getColormappedData(self, copy=True):
+ return self.__dataRef()
+
+
class _BoundaryWidget(qt.QWidget):
- """Widget to edit a boundary of the colormap (vmin, vmax)"""
+ """Widget to edit a boundary of the colormap (vmin or vmax)"""
+
+ sigAutoScaleChanged = qt.Signal(object)
+ """Signal emitted when the autoscale was changed
+
+ True is sent as an argument if autoscale is set to true.
+ """
+
sigValueChanged = qt.Signal(object)
- """Signal emitted when value is changed"""
+ """Signal emitted when value is changed
+
+ The new value is sent as an argument.
+ """
def __init__(self, parent=None, value=0.0):
- qt.QWidget.__init__(self, parent=None)
+ qt.QWidget.__init__(self, parent=parent)
self.setLayout(qt.QHBoxLayout())
self.layout().setContentsMargins(0, 0, 0, 0)
self._numVal = FloatEdit(parent=self, value=value)
@@ -102,148 +133,428 @@ class _BoundaryWidget(qt.QWidget):
self._autoCB = qt.QCheckBox('auto', parent=self)
self.layout().addWidget(self._autoCB)
self._autoCB.setChecked(False)
+ self._autoCB.setVisible(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
+ self._numVal.textEdited.connect(self.__textEdited)
+ self._numVal.editingFinished.connect(self.__editingFinished)
+ self.setFocusProxy(self._numVal)
+
+ self.__textWasEdited = False
+ """True if the text was edited, in order to send an event
+ at the end of the user interaction"""
+
+ self.__realValue = None
+ """Store the real value set by setValue, to avoid
+ rounding of the widget"""
+
+ def __textEdited(self):
+ self.__textWasEdited = True
+
+ def __editingFinished(self):
+ if self.__textWasEdited:
+ value = self._numVal.value()
+ self.__realValue = value
+ with utils.blockSignals(self._numVal):
+ # Fix the formatting
+ self._numVal.setValue(self.__realValue)
+ self.sigValueChanged.emit(value)
+ self.__textWasEdited = False
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
+ """Returns the stored range. If autoscale is
+ enabled, this returns None.
+ """
+ if self._autoCB.isChecked():
+ return None
+ if self.__realValue is not None:
+ return self.__realValue
+ return self._numVal.value()
def _autoToggled(self, enabled):
self._numVal.setEnabled(not enabled)
self._updateDisplayedText()
+ self.sigAutoScaleChanged.emit(enabled)
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)
+ self.__textWasEdited = False
+ if self._autoCB.isChecked() and self.__realValue is not None:
+ with utils.blockSignals(self._numVal):
+ self._numVal.setValue(self.__realValue)
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()
+ """Set the value of the boundary.
+
+ :param float value: A finite value for the boundary
+ :param bool isAuto: If true, the finite value was automatically computed
+ from the data, else it is a fixed custom value.
+ """
+ assert value is not None
+ self._autoCB.setChecked(isAuto)
+ with utils.blockSignals(self._numVal):
+ if isAuto or self.__realValue != value:
+ if not self.__textWasEdited:
+ self._numVal.setValue(value)
+ self.__realValue = value
+ self._numVal.setEnabled(not isAuto)
+
+
+class _AutoscaleModeComboBox(qt.QComboBox):
+
+ DATA = {
+ Colormap.MINMAX: ("Min/max", "Use the data min/max"),
+ Colormap.STDDEV3: ("Mean ± 3 × stddev", "Use the data mean ± 3 × standard deviation"),
+ }
+
+ def __init__(self, parent: qt.QWidget):
+ super(_AutoscaleModeComboBox, self).__init__(parent=parent)
+ self.currentIndexChanged.connect(self.__updateTooltip)
+ self._init()
+
+ def _init(self):
+ for mode in Colormap.AUTOSCALE_MODES:
+ label, tooltip = self.DATA.get(mode, (mode, None))
+ self.addItem(label, mode)
+ if tooltip is not None:
+ self.setItemData(self.count() - 1, tooltip, qt.Qt.ToolTipRole)
+
+ def setCurrentIndex(self, index):
+ self.__updateTooltip(index)
+ super(_AutoscaleModeComboBox, self).setCurrentIndex(index)
+
+ def __updateTooltip(self, index):
+ if index > -1:
+ tooltip = self.itemData(index, qt.Qt.ToolTipRole)
+ else:
+ tooltip = ""
+ self.setToolTip(tooltip)
+
+ def currentMode(self):
+ index = self.currentIndex()
+ return self.itemData(index)
+
+ def setCurrentMode(self, mode):
+ for index in range(self.count()):
+ if mode == self.itemData(index):
+ self.setCurrentIndex(index)
+ return
+ if mode is None:
+ # If None was not a value
+ self.setCurrentIndex(-1)
+ return
+ self.addItem(mode, mode)
+ self.setCurrentIndex(self.count() - 1)
+
+
+class _AutoScaleButtons(qt.QWidget):
+
+ autoRangeChanged = qt.Signal(object)
+
+ def __init__(self, parent=None):
+ qt.QWidget.__init__(self, parent=parent)
+ layout = qt.QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ self.setFocusPolicy(qt.Qt.NoFocus)
+
+ self._bothAuto = qt.QPushButton(self)
+ self._bothAuto.setText("Autoscale")
+ self._bothAuto.setToolTip("Enable/disable the autoscale for both min and max")
+ self._bothAuto.setCheckable(True)
+ self._bothAuto.toggled[bool].connect(self.__bothToggled)
+ self._bothAuto.setFocusPolicy(qt.Qt.TabFocus)
+
+ self._minAuto = qt.QCheckBox(self)
+ self._minAuto.setText("")
+ self._minAuto.setToolTip("Enable/disable the autoscale for min")
+ self._minAuto.toggled[bool].connect(self.__minToggled)
+ self._minAuto.setFocusPolicy(qt.Qt.TabFocus)
+
+ self._maxAuto = qt.QCheckBox(self)
+ self._maxAuto.setText("")
+ self._maxAuto.setToolTip("Enable/disable the autoscale for max")
+ self._maxAuto.toggled[bool].connect(self.__maxToggled)
+ self._maxAuto.setFocusPolicy(qt.Qt.TabFocus)
+
+ layout.addStretch(1)
+ layout.addWidget(self._minAuto)
+ layout.addSpacing(20)
+ layout.addWidget(self._bothAuto)
+ layout.addSpacing(20)
+ layout.addWidget(self._maxAuto)
+ layout.addStretch(1)
+
+ def __bothToggled(self, checked):
+ autoRange = checked, checked
+ self.setAutoRange(autoRange)
+ self.autoRangeChanged.emit(autoRange)
+
+ def __minToggled(self, checked):
+ autoRange = self.getAutoRange()
+ self.setAutoRange(autoRange)
+ self.autoRangeChanged.emit(autoRange)
+
+ def __maxToggled(self, checked):
+ autoRange = self.getAutoRange()
+ self.setAutoRange(autoRange)
+ self.autoRangeChanged.emit(autoRange)
+
+ def setAutoRangeFromColormap(self, colormap):
+ vRange = colormap.getVRange()
+ autoRange = vRange[0] is None, vRange[1] is None
+ self.setAutoRange(autoRange)
+
+ def setAutoRange(self, autoRange):
+ if autoRange[0] == autoRange[1]:
+ with utils.blockSignals(self._bothAuto):
+ self._bothAuto.setChecked(autoRange[0])
+ else:
+ with utils.blockSignals(self._bothAuto):
+ self._bothAuto.setChecked(False)
+ with utils.blockSignals(self._minAuto):
+ self._minAuto.setChecked(autoRange[0])
+ with utils.blockSignals(self._maxAuto):
+ self._maxAuto.setChecked(autoRange[1])
+
+ def getAutoRange(self):
+ return self._minAuto.isChecked(), self._maxAuto.isChecked()
@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.
+class _ColormapHistogram(qt.QWidget):
+ """Display the colormap and the data as a plot."""
- :param parent: See :class:`QDialog`
- :param str title: The QDialog title
+ sigRangeMoving = qt.Signal(object, object)
+ """Emitted when a mouse interaction moves the location
+ of the colormap range in the plot.
+
+ This signal contains 2 elements:
+
+ - vmin: A float value if this range was moved, else None
+ - vmax: A float value if this range was moved, else None
"""
- visibleChanged = qt.Signal(bool)
- """This event is sent when the dialog visibility change"""
+ sigRangeMoved = qt.Signal(object, object)
+ """Emitted when a mouse interaction stop.
- def __init__(self, parent=None, title="Colormap Dialog"):
- qt.QDialog.__init__(self, parent)
- self.setWindowTitle(title)
+ This signal contains 2 elements:
- self._colormap = None
- self._data = None
+ - vmin: A float value if this range was moved, else None
+ - vmax: A float value if this range was moved, else None
+ """
+
+ def __init__(self, parent):
+ qt.QWidget.__init__(self, parent=parent)
self._dataInPlotMode = _DataInPlotMode.RANGE
+ self._finiteRange = None, None
+ self._initPlot()
- 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 = {}
+ """Histogram displayed in the plot"""
+
+ self._dragging = False, False
+ """True, if the min or the max handle is dragging"""
+
+ self._dataRange = {}
+ """Histogram displayed in the plot"""
+
+ self._invalidated = False
+
+ def paintEvent(self, event):
+ if self._invalidated:
+ self._updateDataInPlot()
+ self._invalidated = False
+ self._updateMarkerPosition()
+ return super(_ColormapHistogram, self).paintEvent(event)
+
+ def getFiniteRange(self):
+ """Returns the colormap range as displayed in the plot."""
+ return self._finiteRange
+
+ def setFiniteRange(self, vRange):
+ """Set the colormap range to use in the plot.
+
+ Here there is no concept of auto. The values should
+ not be None, except if there is no range or marker
+ to display.
"""
+ # Do not reset the limit for handle about to be dragged
+ if self._dragging[0]:
+ vRange = self._finiteRange[0], vRange[1]
+ if self._dragging[1]:
+ vRange = vRange[0], self._finiteRange[1]
- self.__displayInvalidated = False
- self._histogramData = None
- self._minMaxWasEdited = False
- self._initialRange = None
+ if vRange == self._finiteRange:
+ return
- self._dataRange = None
- """If defined 3-tuple containing information from a data:
- minimum, positive minimum, maximum"""
+ self._finiteRange = vRange
+ self.update()
- self._colormapStoredState = None
+ def getColormap(self):
+ return self.parent().getColormap()
- # Make the GUI
- vLayout = qt.QVBoxLayout(self)
+ def _getNormalizedHistogram(self):
+ """Return an histogram already normalized according to the colormap
+ normalization.
- formWidget = qt.QWidget(parent=self)
- vLayout.addWidget(formWidget)
- formLayout = qt.QFormLayout(formWidget)
- formLayout.setContentsMargins(10, 10, 10, 10)
- formLayout.setSpacing(0)
+ Returns a tuple edges, counts
+ """
+ norm = self._getNorm()
+ histogram = self._histogramData.get(norm, None)
+ if histogram is None:
+ histogram = self._computeNormalizedHistogram()
+ self._histogramData[norm] = histogram
+ return histogram
+
+ def _computeNormalizedHistogram(self):
+ colormap = self.getColormap()
+ if colormap is None:
+ norm = Colormap.LINEAR
+ else:
+ norm = colormap.getNormalization()
+
+ # Try to use the histogram defined in the dialog
+ histo = self.parent()._getHistogram()
+ if histo is not None:
+ counts, edges = histo
+ normalizer = Colormap(normalization=norm)._getNormalizer()
+ mask = normalizer.isValid(edges[:-1]) # Check lower bin edges only
+ firstValid = numpy.argmax(mask) # edges increases monotonically
+ if firstValid == 0: # Mask is all False or all True
+ return (counts, edges) if mask[0] else (None, None)
+ else: # Clip to valid values
+ return counts[firstValid:], edges[firstValid:]
+
+ data = self.parent()._getArray()
+ if data is None:
+ return None, None
+ dataRange = self._getNormalizedDataRange()
+ if dataRange[0] is None or dataRange[1] is None:
+ return None, None
+ counts, edges = self.parent().computeHistogram(data, scale=norm, dataRange=dataRange)
+ return counts, edges
- # Colormap row
- self._comboBoxColormap = ColormapNameComboBox(parent=formWidget)
- self._comboBoxColormap.currentIndexChanged[int].connect(self._updateLut)
- formLayout.addRow('Colormap:', self._comboBoxColormap)
+ def _getNormalizedDataRange(self):
+ """Return a data range already normalized according to the colormap
+ normalization.
- # Normalization row
- self._normButtonLinear = qt.QRadioButton('Linear')
- self._normButtonLinear.setChecked(True)
- self._normButtonLog = qt.QRadioButton('Log')
+ Returns a tuple with min and max
+ """
+ norm = self._getNorm()
+ dataRange = self._dataRange.get(norm, None)
+ if dataRange is None:
+ dataRange = self._computeNormalizedDataRange()
+ self._dataRange[norm] = dataRange
+ return dataRange
- normButtonGroup = qt.QButtonGroup(self)
- normButtonGroup.setExclusive(True)
- normButtonGroup.addButton(self._normButtonLinear)
- normButtonGroup.addButton(self._normButtonLog)
- normButtonGroup.buttonClicked[qt.QAbstractButton].connect(self._updateNormalization)
+ def _computeNormalizedDataRange(self):
+ colormap = self.getColormap()
+ if colormap is None:
+ norm = Colormap.LINEAR
+ else:
+ norm = colormap.getNormalization()
- normLayout = qt.QHBoxLayout()
- normLayout.setContentsMargins(0, 0, 0, 0)
- normLayout.setSpacing(10)
- normLayout.addWidget(self._normButtonLinear)
- normLayout.addWidget(self._normButtonLog)
+ # Try to use the one defined in the dialog
+ dataRange = self.parent()._getDataRange()
+ if dataRange is not None:
+ if norm in (Colormap.LINEAR, Colormap.GAMMA, Colormap.ARCSINH):
+ return dataRange[0], dataRange[2]
+ elif norm == Colormap.LOGARITHM:
+ return dataRange[1], dataRange[2]
+ elif norm == Colormap.SQRT:
+ return dataRange[1], dataRange[2]
+ else:
+ _logger.error("Undefined %s normalization", norm)
+
+ # Try to use the histogram defined in the dialog
+ histo = self.parent()._getHistogram()
+ if histo is not None:
+ _histo, edges = histo
+ normalizer = Colormap(normalization=norm)._getNormalizer()
+ edges = edges[normalizer.isValid(edges)]
+ if edges.size == 0:
+ return None, None
+ else:
+ dataRange = min_max(edges, finite=True)
+ return dataRange.minimum, dataRange.maximum
- formLayout.addRow('Normalization:', normLayout)
+ item = self.parent()._getItem()
+ if item is not None:
+ # Trick to reach data range using colormap cache
+ cm = Colormap()
+ cm.setVRange(None, None)
+ cm.setNormalization(norm)
+ dataRange = item._getColormapAutoscaleRange(cm)
+ return dataRange
- # 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)
+ # If there is no item, there is no data
+ return None, None
- # 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)
+ def _getDisplayableRange(self):
+ """Returns the selected min/max range to apply to the data,
+ according to the used scale.
+
+ One or both limits can be None in case it is not displayable in the
+ current axes scale.
+
+ :returns: Tuple{float, float}
+ """
+ scale = self._plot.getXAxis().getScale()
+ def isDisplayable(pos):
+ if pos is None:
+ return False
+ if scale == Axis.LOGARITHMIC:
+ return pos > 0.0
+ return True
+
+ posMin, posMax = self.getFiniteRange()
+ if not isDisplayable(posMin):
+ posMin = None
+ if not isDisplayable(posMax):
+ posMax = None
+
+ return posMin, posMax
+
+ def _initPlot(self):
+ """Init the plot to display the range and the values"""
+ self._plot = PlotWidget(self)
+ self._plot.setDataMargins(0.125, 0.125, 0.125, 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._plotEventReceived)
+ palette = self.palette()
+ color = palette.color(qt.QPalette.Normal, qt.QPalette.Window)
+ self._plot.setBackgroundColor(color)
+ self._plot.setDataBackgroundColor("white")
+
+ lut = numpy.arange(256)
+ lut.shape = 1, -1
+ self._plot.addImage(lut, legend='lut')
+ self._lutItem = self._plot._getItem("image", "lut")
+ self._lutItem.setVisible(False)
+
+ self._plot.addScatter(x=[], y=[], value=[], legend='lut2')
+ self._lutItem2 = self._plot._getItem("scatter", "lut2")
+ self._lutItem2.setVisible(False)
+ self.__lutY = numpy.array([-0.05] * 256)
+ self.__lutV = numpy.arange(256)
+
+ self._bound = BoundingRect()
+ self._plot.addItem(self._bound)
+ self._bound.setVisible(True)
# Add plot for histogram
self._plotToolbar = qt.QToolBar(self)
@@ -256,14 +567,6 @@ class ColormapDialog(qt.QDialog):
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'))
@@ -282,23 +585,342 @@ class ColormapDialog(qt.QDialog):
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)
+ self.setLayout(plotBoxLayout)
+
+ def _plotEventReceived(self, event):
+ """Handle events from the plot"""
+ kind = event['event']
+
+ if kind == 'markerMoving':
+ value = event['xdata']
+ if event['label'] == 'Min':
+ self._dragging = True, False
+ self._finiteRange = value, self._finiteRange[1]
+ self._last = value, None
+ self.sigRangeMoving.emit(*self._last)
+ elif event['label'] == 'Max':
+ self._dragging = False, True
+ self._finiteRange = self._finiteRange[0], value
+ self._last = None, value
+ self.sigRangeMoving.emit(*self._last)
+ self._updateLutItem(self._finiteRange)
+ elif kind == 'markerMoved':
+ self.sigRangeMoved.emit(*self._last)
+ self._plot.resetZoom()
+ self._dragging = False, False
+ else:
+ pass
+
+ def _updateMarkerPosition(self):
+ colormap = self.getColormap()
+ posMin, posMax = self._getDisplayableRange()
+
+ if colormap is None:
+ isDraggable = False
+ else:
+ isDraggable = colormap.isEditable()
+
+ with utils.blockSignals(self):
+ if posMin is not None and not self._dragging[0]:
+ self._plot.addXMarker(
+ posMin,
+ legend='Min',
+ text='Min',
+ draggable=isDraggable,
+ color="blue",
+ constraint=self._plotMinMarkerConstraint)
+ if posMax is not None and not self._dragging[1]:
+ self._plot.addXMarker(
+ posMax,
+ legend='Max',
+ text='Max',
+ draggable=isDraggable,
+ color="blue",
+ constraint=self._plotMaxMarkerConstraint)
+
+ self._updateLutItem((posMin, posMax))
+ self._plot.resetZoom()
+
+ def _updateLutItem(self, vRange):
+ colormap = self.getColormap()
+ if colormap is None:
+ return
+
+ if vRange is None:
+ posMin, posMax = self._getDisplayableRange()
+ else:
+ posMin, posMax = vRange
+ if posMin is None or posMax is None:
+ self._lutItem.setVisible(False)
+ pos = posMax if posMin is None else posMin
+ if pos is not None:
+ self._bound.setBounds((pos, pos, -0.1, 0))
+ else:
+ self._bound.setBounds((0, 0, -0.1, 0))
+ else:
+ norm = colormap.getNormalization()
+ normColormap = colormap.copy()
+ normColormap.setVRange(0, 255)
+ normColormap.setNormalization(Colormap.LINEAR)
+ if norm == Colormap.LINEAR:
+ scale = (posMax - posMin) / 256
+ self._lutItem.setColormap(normColormap)
+ self._lutItem.setOrigin((posMin, -0.09))
+ self._lutItem.setScale((scale, 0.08))
+ self._lutItem.setVisible(True)
+ self._lutItem2.setVisible(False)
+ elif norm == Colormap.LOGARITHM:
+ self._lutItem2.setVisible(False)
+ self._lutItem2.setColormap(normColormap)
+ xx = numpy.geomspace(posMin, posMax, 256)
+ self._lutItem2.setData(x=xx,
+ y=self.__lutY,
+ value=self.__lutV,
+ copy=False)
+ self._lutItem2.setSymbol("|")
+ self._lutItem2.setVisible(True)
+ self._lutItem.setVisible(False)
+ else:
+ # Fallback: Display with linear axis and applied normalization
+ self._lutItem2.setVisible(False)
+ normColormap.setNormalization(norm)
+ self._lutItem2.setColormap(normColormap)
+ xx = numpy.linspace(posMin, posMax, 256, endpoint=True)
+ self._lutItem2.setData(
+ x=xx,
+ y=self.__lutY,
+ value=self.__lutV,
+ copy=False)
+ self._lutItem2.setSymbol("|")
+ self._lutItem2.setVisible(True)
+ self._lutItem.setVisible(False)
+
+ self._bound.setBounds((posMin, posMax, -0.1, 1))
+
+ def _plotMinMarkerConstraint(self, x, y):
+ """Constraint of the min marker"""
+ _vmin, vmax = self.getFiniteRange()
+ if vmax is None:
+ return x, y
+ return min(x, vmax), y
+
+ def _plotMaxMarkerConstraint(self, x, y):
+ """Constraint of the max marker"""
+ vmin, _vmax = self.getFiniteRange()
+ if vmin is None:
+ return x, y
+ return max(x, vmin), y
+
+ 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 invalidateData(self):
+ self._histogramData = {}
+ self._dataRange = {}
+ self._invalidated = True
+ self.update()
+
+ def _updateDataInPlot(self):
+ mode = self._dataInPlotMode
+
+ norm = self._getNorm()
+ if norm == Colormap.LINEAR:
+ scale = Axis.LINEAR
+ elif norm == Colormap.LOGARITHM:
+ scale = Axis.LOGARITHMIC
+ else:
+ scale = Axis.LINEAR
+
+ axis = self._plot.getXAxis()
+ axis.setScale(scale)
+
+ if mode == _DataInPlotMode.RANGE:
+ dataRange = self._getNormalizedDataRange()
+ xmin, xmax = dataRange
+ if xmax is None or xmin is None:
+ self._plot.remove(legend='Data', kind='histogram')
+ else:
+ histogram = numpy.array([1])
+ bin_edges = numpy.array([xmin, xmax])
+ self._plot.addHistogram(histogram,
+ bin_edges,
+ legend="Data",
+ color='gray',
+ align='center',
+ fill=True,
+ z=1)
+
+ elif mode == _DataInPlotMode.HISTOGRAM:
+ histogram, bin_edges = self._getNormalizedHistogram()
+ if histogram is None or bin_edges is None:
+ self._plot.remove(legend='Data', kind='histogram')
+ else:
+ histogram = numpy.array(histogram, copy=True)
+ bin_edges = numpy.array(bin_edges, copy=True)
+ norm_histogram = histogram / max(histogram)
+ self._plot.addHistogram(norm_histogram,
+ bin_edges,
+ legend="Data",
+ color='gray',
+ align='center',
+ fill=True,
+ z=1)
+ else:
+ _logger.error("Mode unsupported")
+
+ def sizeHint(self):
+ return self.layout().minimumSize()
+
+ def updateLut(self):
+ self._updateLutItem(None)
+
+ def _getNorm(self):
+ colormap = self.getColormap()
+ if colormap is None:
+ return Axis.LINEAR
+ else:
+ norm = colormap.getNormalization()
+ return norm
+
+ def updateNormalization(self):
+ self._updateDataInPlot()
+ self.update()
+
+
+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.__aboutToDelete = False
+ self._colormap = None
+
+ self._data = None
+ """Weak ref to an external numpy array
+ """
+ self._itemHolder = None
+ """Hard ref to a private item (used as holder to the data)
+ This allow to reuse the item cache
+ """
+ self._item = None
+ """Weak ref to an external item"""
+
+ self._colormapChange = utils.LockReentrant()
+ """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.__colormapInvalidated = False
+ self.__dataInvalidated = False
+
+ self._histogramData = None
+
+ self._dataRange = None
+ """If defined 3-tuple containing information from a data:
+ minimum, positive minimum, maximum"""
+
+ self._colormapStoredState = None
+
+ # Colormap row
+ self._comboBoxColormap = ColormapNameComboBox(parent=self)
+ self._comboBoxColormap.currentIndexChanged[int].connect(self._comboBoxColormapUpdated)
+
+ # Normalization row
+ self._comboBoxNormalization = qt.QComboBox(parent=self)
+ normalizations = [
+ ('Linear', Colormap.LINEAR),
+ ('Gamma correction', Colormap.GAMMA),
+ ('Arcsinh', Colormap.ARCSINH),
+ ('Logarithmic', Colormap.LOGARITHM),
+ ('Square root', Colormap.SQRT)]
+ for name, userData in normalizations:
+ try:
+ icon = icons.getQIcon("colormap-norm-%s" % userData)
+ except:
+ icon = qt.QIcon()
+ self._comboBoxNormalization.addItem(icon, name, userData)
+ self._comboBoxNormalization.currentIndexChanged[int].connect(
+ self._normalizationUpdated)
+
+ self._gammaSpinBox = qt.QDoubleSpinBox(parent=self)
+ self._gammaSpinBox.setEnabled(False)
+ self._gammaSpinBox.setRange(0., 1000.)
+ self._gammaSpinBox.setDecimals(4)
+ if hasattr(qt.QDoubleSpinBox, "setStepType"):
+ # Introduced in Qt 5.12
+ self._gammaSpinBox.setStepType(qt.QDoubleSpinBox.AdaptiveDecimalStepType)
+ else:
+ self._gammaSpinBox.setSingleStep(0.1)
+ self._gammaSpinBox.valueChanged.connect(self._gammaUpdated)
+ self._gammaSpinBox.setValue(2.)
+
+ autoScaleCombo = _AutoscaleModeComboBox(self)
+ autoScaleCombo.currentIndexChanged.connect(self._autoscaleModeUpdated)
+ self._autoScaleCombo = autoScaleCombo
+
+ # Min row
+ self._minValue = _BoundaryWidget(parent=self, value=1.0)
+ self._minValue.sigAutoScaleChanged.connect(self._minAutoscaleUpdated)
+ self._minValue.sigValueChanged.connect(self._minValueUpdated)
+
+ # Max row
+ self._maxValue = _BoundaryWidget(parent=self, value=10.0)
+ self._maxValue.sigAutoScaleChanged.connect(self._maxAutoscaleUpdated)
+ self._maxValue.sigValueChanged.connect(self._maxValueUpdated)
+
+ self._autoButtons = _AutoScaleButtons(self)
+ self._autoButtons.autoRangeChanged.connect(self._autoRangeButtonsUpdated)
+
+ rangeLayout = qt.QGridLayout()
+ miniFont = qt.QFont(self.font())
+ miniFont.setPixelSize(8)
+ labelMin = qt.QLabel("Min", self)
+ labelMin.setFont(miniFont)
+ labelMin.setAlignment(qt.Qt.AlignHCenter)
+ labelMax = qt.QLabel("Max", self)
+ labelMax.setAlignment(qt.Qt.AlignHCenter)
+ labelMax.setFont(miniFont)
+ rangeLayout.addWidget(labelMin, 0, 0)
+ rangeLayout.addWidget(labelMax, 0, 1)
+ rangeLayout.addWidget(self._minValue, 1, 0)
+ rangeLayout.addWidget(self._maxValue, 1, 1)
+ rangeLayout.addWidget(self._autoButtons, 2, 0, 1, -1, qt.Qt.AlignCenter)
+
+ self._histoWidget = _ColormapHistogram(self)
+ self._histoWidget.sigRangeMoving.connect(self._histogramRangeMoving)
+ self._histoWidget.sigRangeMoved.connect(self._histogramRangeMoved)
# 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)
@@ -306,9 +928,14 @@ class ColormapDialog(qt.QDialog):
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)
+ button = self._buttonsNonModal.button(qt.QDialogButtonBox.Close)
+ button.clicked.connect(self.accept)
+ button.setDefault(True)
+ button = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset)
+ button.clicked.connect(self.resetColormap)
+
+ self._buttonsModal.setFocus(qt.Qt.OtherFocusReason)
+ self._buttonsNonModal.setFocus(qt.Qt.OtherFocusReason)
# Set the colormap to default values
self.setColormap(Colormap(name='gray', normalization='linear',
@@ -316,21 +943,62 @@ class ColormapDialog(qt.QDialog):
self.setModal(self.isModal())
- vLayout.setSizeConstraint(qt.QLayout.SetMinimumSize)
+ formLayout = qt.QFormLayout(self)
+ formLayout.setContentsMargins(10, 10, 10, 10)
+ formLayout.addRow('Colormap:', self._comboBoxColormap)
+ formLayout.addRow('Normalization:', self._comboBoxNormalization)
+ formLayout.addRow('Gamma:', self._gammaSpinBox)
+ formLayout.addRow(self._histoWidget)
+ formLayout.addRow(rangeLayout)
+ label = qt.QLabel('Mode:', self)
+ self._autoscaleModeLabel = label
+ label.setToolTip("Mode for autoscale. Algorithm used to find range in auto scale.")
+ formLayout.addItem(qt.QSpacerItem(1, 1, qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed))
+ formLayout.addRow(label, autoScaleCombo)
+ formLayout.addRow(self._buttonsModal)
+ formLayout.addRow(self._buttonsNonModal)
+ formLayout.setSizeConstraint(qt.QLayout.SetMinimumSize)
+
+ self.setTabOrder(self._comboBoxColormap, self._comboBoxNormalization)
+ self.setTabOrder(self._comboBoxNormalization, self._gammaSpinBox)
+ self.setTabOrder(self._gammaSpinBox, self._minValue)
+ self.setTabOrder(self._minValue, self._maxValue)
+ self.setTabOrder(self._maxValue, self._autoButtons)
+ self.setTabOrder(self._autoButtons, self._autoScaleCombo)
+ self.setTabOrder(self._autoScaleCombo, self._buttonsModal)
+ self.setTabOrder(self._buttonsModal, self._buttonsNonModal)
+
self.setFixedSize(self.sizeHint())
self._applyColormap()
- def _displayLater(self):
- self.__displayInvalidated = True
+ def _invalidateColormap(self):
+ if self.isVisible():
+ self._applyColormap()
+ else:
+ self.__colormapInvalidated = True
+
+ def _invalidateData(self):
+ if self.isVisible():
+ self._updateWidgetRange()
+ self._histoWidget.invalidateData()
+ else:
+ self.__dataInvalidated = True
+
+ def _validate(self):
+ if self.__colormapInvalidated:
+ self._applyColormap()
+ if self.__dataInvalidated:
+ self._histoWidget.invalidateData()
+ if self.__dataInvalidated or self.__colormapInvalidated:
+ self._updateWidgetRange()
+ self.__dataInvalidated = False
+ self.__colormapInvalidated = False
def showEvent(self, event):
self.visibleChanged.emit(True)
super(ColormapDialog, self).showEvent(event)
if self.isVisible():
- if self.__displayInvalidated:
- self._applyColormap()
- self._updateDataInPlot()
- self.__displayInvalidated = False
+ self._validate()
def closeEvent(self, event):
if not self.isModal():
@@ -351,179 +1019,32 @@ class ColormapDialog(qt.QDialog):
self._buttonsModal.setVisible(modal)
qt.QDialog.setModal(self, modal)
+ def event(self, event):
+ if event.type() == qt.QEvent.DeferredDelete:
+ self.__aboutToDelete = True
+ return super(ColormapDialog, self).event(event)
+
def exec_(self):
wasModal = self.isModal()
self.setModal(True)
result = super(ColormapDialog, self).exec_()
- self.setModal(wasModal)
+ if not self.__aboutToDelete:
+ 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 _computeView(self, dataMin, dataMax):
- """Compute the location of the view according to the bound of the data
-
- :rtype: Tuple(float, float)
- """
- marginRatio = 1.0 / 6.0
- scale = self._plot.getXAxis().getScale()
-
- if self._dataRange is not None:
- if scale == Axis.LOGARITHMIC:
- minRange = self._dataRange[1]
- else:
- minRange = self._dataRange[0]
- maxRange = self._dataRange[2]
- if minRange is not None:
- dataMin = min(dataMin, minRange)
- dataMax = max(dataMax, maxRange)
-
- if self._histogramData is not None:
- info = min_max(self._histogramData[1])
- if scale == Axis.LOGARITHMIC:
- minHisto = info.min_positive
- else:
- minHisto = info.minimum
- maxHisto = info.maximum
- if minHisto is not None:
- dataMin = min(dataMin, minHisto)
- dataMax = max(dataMax, maxHisto)
-
- if scale == Axis.LOGARITHMIC:
- epsilon = numpy.finfo(numpy.float32).eps
- if dataMin == 0:
- dataMin = epsilon
- if dataMax < dataMin:
- dataMax = dataMin + epsilon
- marge = marginRatio * abs(numpy.log10(dataMax) - numpy.log10(dataMin))
- viewMin = 10**(numpy.log10(dataMin) - marge)
- viewMax = 10**(numpy.log10(dataMax) + marge)
- else: # scale == Axis.LINEAR:
- marge = marginRatio * abs(dataMax - dataMin)
- if marge < 0.0001:
- # Smaller that the QLineEdit precision
- marge = 0.0001
- viewMin = dataMin - marge
- viewMax = dataMax + marge
-
- return viewMin, viewMax
-
- def _plotUpdate(self, updateMarkers=True):
- """Update the plot content
-
- :param bool updateMarkers: True to update markers, False otherwith
+ def _getFiniteColormapRange(self):
+ """Return a colormap range where auto ranges are fixed
+ according to the available data.
"""
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
-
- minView, maxView = self._computeView(minData, maxData)
-
- 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])
-
- if minView > minData:
- # Hide the min range
- minData = minView
- x = [minView, minData, maxData, maxView]
- y = [0, 0, 1, 1]
-
- self._plot.addCurve(x, y,
- legend="ConstrainedCurve",
- color='black',
- symbol='o',
- linestyle='-',
- z=2,
- resetzoom=False)
-
- scale = self._plot.getXAxis().getScale()
-
- if updateMarkers:
- posMin = self._minValue.getFiniteValue()
- posMax = self._maxValue.getFiniteValue()
-
- def isDisplayable(pos):
- if scale == Axis.LOGARITHMIC:
- return pos > 0.0
- return True
-
- if isDisplayable(posMin):
- minDraggable = (self._colormap().isEditable() and
- not self._minValue.isAutoChecked())
- self._plot.addXMarker(
- posMin,
- legend='Min',
- text='Min',
- draggable=minDraggable,
- color='blue',
- constraint=self._plotMinMarkerConstraint)
- if isDisplayable(posMax):
- maxDraggable = (self._colormap().isEditable() and
- not self._maxValue.isAutoChecked())
- self._plot.addXMarker(
- posMax,
- legend='Max',
- text='Max',
- draggable=maxDraggable,
- color='blue',
- constraint=self._plotMaxMarkerConstraint)
+ return 1, 10
- 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)
+ item = self._getItem()
+ if item is not None:
+ return colormap.getColormapRange(item)
+ # If there is not item, there is no data
+ return colormap.getColormapRange(None)
@staticmethod
def computeDataRange(data):
@@ -552,53 +1073,117 @@ class ColormapDialog(qt.QDialog):
return dataRange
@staticmethod
- def computeHistogram(data, scale=Axis.LINEAR):
+ def computeHistogram(data, scale=Axis.LINEAR, dataRange=None):
"""Compute the data histogram as used by :meth:`setHistogram`.
:param data: The data to process
+ :param dataRange: Optional range to compute the histogram, which is a
+ tuple of min, max
:rtype: Tuple(List(float),List(float)
"""
- _data = data
- if _data.ndim == 3: # RGB(A) images
+ # For compatibility
+ if scale == Axis.LOGARITHMIC:
+ scale = Colormap.LOGARITHM
+
+ if data is None:
+ return None, None
+
+ if len(data) == 0:
+ return None, None
+
+ 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)
+ data = (data[:, :, 0] * 0.299 +
+ data[:, :, 1] * 0.587 +
+ data[:, :, 2] * 0.114)
+
+ # bad hack: get 256 continuous bins in the case we have a B&W
+ normalizeData = True
+ if numpy.issubdtype(data.dtype, numpy.ubyte):
+ normalizeData = False
+ elif numpy.issubdtype(data.dtype, numpy.integer):
+ if dataRange is not None:
+ xmin, xmax = dataRange
+ if xmin is not None and xmax is not None:
+ normalizeData = (xmax - xmin) > 255
+
+ if normalizeData:
+ if scale == Colormap.LOGARITHM:
+ with numpy.errstate(divide='ignore', invalid='ignore'):
+ data = numpy.log10(data)
- if len(_data) == 0:
- return None, None
+ if dataRange is not None:
+ xmin, xmax = dataRange
+ if xmin is None:
+ return None, None
+ if normalizeData:
+ if scale == Colormap.LOGARITHM:
+ xmin, xmax = numpy.log10(xmin), numpy.log10(xmax)
+ else:
+ xmin, xmax = min_max(data, min_positive=False, finite=True)
- if scale == Axis.LOGARITHMIC:
- _data = numpy.log10(_data)
- xmin, xmax = min_max(_data, min_positive=False, finite=True)
if xmin is None:
return None, None
- nbins = min(256, int(numpy.sqrt(_data.size)))
+ 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 numpy.issubdtype(data.dtype, numpy.integer):
if nbins > xmax - xmin:
- nbins = xmax - xmin
+ nbins = int(xmax - xmin)
nbins = max(2, nbins)
- _data = _data.ravel().astype(numpy.float32)
+ data = data.ravel().astype(numpy.float32)
- histogram = Histogramnd(_data, n_bins=nbins, histo_range=data_range)
+ histogram = Histogramnd(data, n_bins=nbins, histo_range=data_range)
bins = histogram.edges[0]
- if scale == Axis.LOGARITHMIC:
- bins = 10**bins
+ if normalizeData:
+ if scale == Colormap.LOGARITHM:
+ bins = 10**bins
return histogram.histo, bins
+ def _getItem(self):
+ if self._itemHolder is not None:
+ return self._itemHolder
+ if self._item is None:
+ return None
+ return self._item()
+
+ def setItem(self, item):
+ """Store the plot item.
+
+ According to the state of the dialog, the item will be used to display
+ the data range or the histogram of the data using :meth:`setDataRange`
+ and :meth:`setHistogram`
+ """
+ # While event from items are not supported, we can't ignore dup items
+ # old = self._getItem()
+ # if old is item:
+ # return
+ self._data = None
+ self._itemHolder = None
+ try:
+ if item is None:
+ self._item = None
+ else:
+ if not isinstance(item, items.ColormapMixIn):
+ self._item = None
+ raise ValueError("Item %s is not supported" % item)
+ self._item = weakref.ref(item, self._itemAboutToFinalize)
+ finally:
+ self._dataRange = None
+ self._histogramData = None
+ self._invalidateData()
+
def _getData(self):
if self._data is None:
return None
return self._data()
def setData(self, data):
- """Store the data as a weakref.
+ """Store the data
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`
@@ -608,79 +1193,58 @@ class ColormapDialog(qt.QDialog):
if oldData is data:
return
+ self._item = None
if data is None:
self._data = None
+ self._itemHolder = None
else:
self._data = weakref.ref(data, self._dataAboutToFinalize)
+ self._itemHolder = _DataRefHolder(self._data)
- if self.isVisible():
- self._updateDataInPlot()
- else:
- self._displayLater()
-
- def _setDataInPlotMode(self, mode):
- if self._dataInPlotMode == mode:
- return
- self._dataInPlotMode = mode
- self._updateDataInPlot()
+ self._dataRange = None
+ self._histogramData = None
- def _displayDataInPlotModeChanged(self, action):
- mode = action.data()
- self._setDataInPlotMode(mode)
+ self._invalidateData()
- def _updateDataInPlot(self):
+ def _getArray(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, scale=self._plot.getXAxis().getScale())
- self.setHistogram(*result)
- self.setDataRange()
-
- def _invalidateHistogram(self):
- """Recompute the histogram if it is displayed"""
- if self._dataInPlotMode == _DataInPlotMode.HISTOGRAM:
- self._updateDataInPlot()
+ if data is not None:
+ return data
+ item = self._getItem()
+ if item is not None:
+ return item.getColormappedData(copy=False)
+ return None
def _colormapAboutToFinalize(self, weakrefColormap):
"""Callback when the data weakref is about to be finalized."""
- if self._colormap is weakrefColormap:
+ if self._colormap is weakrefColormap and qtinspect.isValid(self):
self.setColormap(None)
def _dataAboutToFinalize(self, weakrefData):
"""Callback when the data weakref is about to be finalized."""
- if self._data is weakrefData:
+ if self._data is weakrefData and qtinspect.isValid(self):
self.setData(None)
+ def _itemAboutToFinalize(self, weakref):
+ """Callback when the data weakref is about to be finalized."""
+ if self._item is weakref and qtinspect.isValid(self):
+ self.setItem(None)
+
+ @deprecation.deprecated(reason="It is private data", since_version="0.13")
def getHistogram(self):
- """Returns the counts and bin edges of the displayed histogram.
+ histo = self._getHistogram()
+ if histo is None:
+ return None
+ counts, bin_edges = histo
+ return numpy.array(counts, copy=True), numpy.array(bin_edges, copy=True)
+
+ def _getHistogram(self):
+ """Returns the histogram defined by the dialog as metadata
+ to describe the data in order to speed up the dialog.
: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)
+ return self._histogramData
def setHistogram(self, hist=None, bin_edges=None):
"""Set the histogram to display.
@@ -692,20 +1256,10 @@ class ColormapDialog(qt.QDialog):
"""
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,
- z=1)
- self._updateMinMaxData()
+ self._histogramData = numpy.array(hist), numpy.array(bin_edges)
+
+ self._invalidateData()
def getColormap(self):
"""Return the colormap description.
@@ -726,11 +1280,18 @@ class ColormapDialog(qt.QDialog):
colormap = self.getColormap()
if colormap is not None and self._colormapStoredState is not None:
if colormap != self._colormapStoredState:
- self._ignoreColormapChange = True
- colormap.setFromColormap(self._colormapStoredState)
- self._ignoreColormapChange = False
+ with self._colormapChange:
+ colormap.setFromColormap(self._colormapStoredState)
self._applyColormap()
+ def _getDataRange(self):
+ """Returns the data range defined by the dialog as metadata
+ to describe the data in order to speed up the dialog.
+
+ :return: (minimum, positiveMin, maximum)
+ :rtype: 3-tuple of floats or None"""
+ return self._dataRange
+
def setDataRange(self, minimum=None, positiveMin=None, maximum=None):
"""Set the range of data to use for the range of the histogram area.
@@ -738,62 +1299,37 @@ class ColormapDialog(qt.QDialog):
:param float positiveMin: The positive minimum of the data
:param float maximum: The maximum of the data
"""
- scale = self._plot.getXAxis().getScale()
- if scale == Axis.LOGARITHMIC:
- dataMin, dataMax = positiveMin, maximum
- else:
- dataMin, dataMax = minimum, maximum
+ self._dataRange = minimum, positiveMin, maximum
+ self._invalidateData()
- if dataMin is None or dataMax is None:
- self._dataRange = None
- self._plot.remove(legend='Range', kind='histogram')
- else:
- hist = numpy.array([1])
- bin_edges = numpy.array([dataMin, dataMax])
- self._plot.addHistogram(hist,
- bin_edges,
- legend="Range",
- color='gray',
- align='center',
- fill=True,
- z=1)
- 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."""
+ def _setColormapRange(self, xmin, xmax):
+ """Set a new range to the held colormap and update the
+ widget."""
colormap = self.getColormap()
+ if colormap is not None:
+ with self._colormapChange:
+ colormap.setVRange(xmin, xmax)
+ self._updateWidgetRange()
- 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])
+ def _updateWidgetRange(self):
+ """Update the colormap range displayed into the widget."""
+ xmin, xmax = self._getFiniteColormapRange()
+ colormap = self.getColormap()
+ if colormap is not None:
+ vRange = colormap.getVRange()
+ autoMin, autoMax = (r is None for r in vRange)
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()
+ autoMin, autoMax = False, False
+
+ with utils.blockSignals(self._minValue):
+ self._minValue.setValue(xmin, autoMin)
+ with utils.blockSignals(self._maxValue):
+ self._maxValue.setValue(xmax, autoMax)
+ with utils.blockSignals(self._histoWidget):
+ self._histoWidget.setFiniteRange((xmin, xmax))
+ with utils.blockSignals(self._autoButtons):
+ self._autoButtons.setAutoRange((autoMin, autoMax))
+ self._autoscaleModeLabel.setEnabled(autoMin or autoMax)
def accept(self):
self.storeCurrentState()
@@ -820,7 +1356,7 @@ class ColormapDialog(qt.QDialog):
:param ~silx.gui.colors.Colormap colormap: the colormap to edit
"""
assert colormap is None or isinstance(colormap, Colormap)
- if self._ignoreColormapChange is True:
+ if self._colormapChange.locked():
return
oldColormap = self.getColormap()
@@ -835,11 +1371,7 @@ class ColormapDialog(qt.QDialog):
self._colormap = colormap
self.storeCurrentState()
- if self.isVisible():
- self._applyColormap()
- else:
- self._updateResetButton()
- self._displayLater()
+ self._invalidateColormap()
def _updateResetButton(self):
resetButton = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset)
@@ -852,156 +1384,234 @@ class ColormapDialog(qt.QDialog):
def _applyColormap(self):
self._updateResetButton()
- if self._ignoreColormapChange is True:
+ if self._colormapChange.locked():
return
colormap = self.getColormap()
if colormap is None:
self._comboBoxColormap.setEnabled(False)
- self._normButtonLinear.setEnabled(False)
- self._normButtonLog.setEnabled(False)
+ self._comboBoxNormalization.setEnabled(False)
+ self._gammaSpinBox.setEnabled(False)
+ self._autoScaleCombo.setEnabled(False)
self._minValue.setEnabled(False)
self._maxValue.setEnabled(False)
+ self._autoButtons.setEnabled(False)
+ self._autoscaleModeLabel.setEnabled(False)
+ self._histoWidget.setVisible(False)
+ self._histoWidget.setFiniteRange((None, None))
else:
- self._ignoreColormapChange = True
- self._comboBoxColormap.setCurrentLut(colormap)
- self._comboBoxColormap.setEnabled(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(colormap.isEditable())
- self._normButtonLog.setEnabled(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(colormap.isEditable())
- self._maxValue.setEnabled(colormap.isEditable())
+ with utils.blockSignals(self._comboBoxColormap):
+ self._comboBoxColormap.setCurrentLut(colormap)
+ self._comboBoxColormap.setEnabled(colormap.isEditable())
+ with utils.blockSignals(self._comboBoxNormalization):
+ index = self._comboBoxNormalization.findData(
+ colormap.getNormalization())
+ if index < 0:
+ _logger.error('Unsupported normalization: %s' %
+ colormap.getNormalization())
+ else:
+ self._comboBoxNormalization.setCurrentIndex(index)
+ self._comboBoxNormalization.setEnabled(colormap.isEditable())
+ with utils.blockSignals(self._gammaSpinBox):
+ self._gammaSpinBox.setValue(
+ colormap.getGammaNormalizationParameter())
+ self._gammaSpinBox.setEnabled(
+ colormap.getNormalization() == 'gamma' and
+ colormap.isEditable())
+ with utils.blockSignals(self._autoScaleCombo):
+ self._autoScaleCombo.setCurrentMode(colormap.getAutoscaleMode())
+ self._autoScaleCombo.setEnabled(colormap.isEditable())
+ with utils.blockSignals(self._autoButtons):
+ self._autoButtons.setEnabled(colormap.isEditable())
+ self._autoButtons.setAutoRangeFromColormap(colormap)
+
+ vmin, vmax = colormap.getVRange()
+ if vmin is None or vmax is None:
+ # Compute it only if needed
+ dataRange = self._getFiniteColormapRange()
+ else:
+ dataRange = vmin, vmax
+
+ with utils.blockSignals(self._minValue):
+ self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None)
+ self._minValue.setEnabled(colormap.isEditable())
+ with utils.blockSignals(self._maxValue):
+ self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None)
+ self._maxValue.setEnabled(colormap.isEditable())
+ self._autoscaleModeLabel.setEnabled(vmin is None or vmax is None)
+
+ with utils.blockSignals(self._histoWidget):
+ self._histoWidget.setVisible(True)
+ self._histoWidget.setFiniteRange(dataRange)
+ self._histoWidget.updateNormalization()
+
+ def _comboBoxColormapUpdated(self):
+ """Callback executed when the combo box with the colormap LUT
+ is updated by user input.
+ """
+ colormap = self.getColormap()
+ if colormap is not None:
+ with self._colormapChange:
+ name = self._comboBoxColormap.getCurrentName()
+ if name is not None:
+ colormap.setName(name)
+ else:
+ lut = self._comboBoxColormap.getCurrentColors()
+ colormap.setColormapLUT(lut)
+ self._histoWidget.updateLut()
+
+ def _autoRangeButtonsUpdated(self, autoRange):
+ """Callback executed when the autoscale buttons widget
+ is updated by user input.
+ """
+ dataRange = self._getFiniteColormapRange()
- axis = self._plot.getXAxis()
- scale = axis.LINEAR if colormap.getNormalization() == Colormap.LINEAR else axis.LOGARITHMIC
- axis.setScale(scale)
+ # Final colormap range
+ vmin = (dataRange[0] if not autoRange[0] else None)
+ vmax = (dataRange[1] if not autoRange[1] else None)
- self._ignoreColormapChange = False
+ with self._colormapChange:
+ colormap = self.getColormap()
+ colormap.setVRange(vmin, vmax)
- self._plotUpdate()
+ with utils.blockSignals(self._minValue):
+ self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None)
+ with utils.blockSignals(self._maxValue):
+ self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None)
- def _updateMinMax(self):
- if self._ignoreColormapChange is True:
- return
+ self._updateWidgetRange()
- 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()
+ def _normalizationUpdated(self, index):
+ """Callback executed when the normalization widget
+ is updated by user input.
+ """
+ colormap = self.getColormap()
if colormap is not None:
- colormap.setVRange(vmin, vmax)
- self._ignoreColormapChange = False
- self._plotUpdate()
- self._updateResetButton()
+ normalization = self._comboBoxNormalization.itemData(index)
+ self._gammaSpinBox.setEnabled(normalization == 'gamma')
- def _updateLut(self):
- if self._ignoreColormapChange is True:
- return
+ with self._colormapChange:
+ colormap.setNormalization(normalization)
+ self._histoWidget.updateNormalization()
- colormap = self._colormap()
- if colormap is not None:
- self._ignoreColormapChange = True
- name = self._comboBoxColormap.getCurrentName()
- if name is not None:
- colormap.setName(name)
- else:
- lut = self._comboBoxColormap.getCurrentColors()
- colormap.setColormapLUT(lut)
- self._ignoreColormapChange = False
+ self._updateWidgetRange()
- def _updateNormalization(self, button):
- if self._ignoreColormapChange is True:
- return
- if not button.isChecked():
- return
+ def _gammaUpdated(self, value):
+ """Callback used to update the gamma normalization parameter"""
+ colormap = self.getColormap()
+ if colormap is not None:
+ colormap.setGammaNormalizationParameter(value)
- if button is self._normButtonLinear:
- norm = Colormap.LINEAR
- scale = Axis.LINEAR
- elif button is self._normButtonLog:
- norm = Colormap.LOGARITHM
- scale = Axis.LOGARITHMIC
- else:
- assert(False)
+ def _autoscaleModeUpdated(self):
+ """Callback executed when the autoscale mode widget
+ is updated by user input.
+ """
+ mode = self._autoScaleCombo.currentMode()
colormap = self.getColormap()
if colormap is not None:
- self._ignoreColormapChange = True
- colormap.setNormalization(norm)
- axis = self._plot.getXAxis()
- axis.setScale(scale)
- self._ignoreColormapChange = False
+ with self._colormapChange:
+ colormap.setAutoscaleMode(mode)
- self._invalidateHistogram()
- self._updateMinMaxData()
+ self._updateWidgetRange()
- def _minMaxTextEdited(self, text):
- """Handle _minValue and _maxValue textEdited signal"""
- self._minMaxWasEdited = True
-
- def _minEditingFinished(self):
- """Handle _minValue editingFinished signal
+ def _minAutoscaleUpdated(self, autoEnabled):
+ """Callback executed when the min autoscale from
+ the lineedit is updated by user input"""
+ colormap = self.getColormap()
+ xmin, xmax = colormap.getVRange()
+ if autoEnabled:
+ xmin = None
+ else:
+ xmin, _xmax = self._getFiniteColormapRange()
+ self._setColormapRange(xmin, xmax)
- Together with :meth:`_minMaxTextEdited`, this avoids to notify
- colormap change when the min and max value where not edited.
+ def _maxAutoscaleUpdated(self, autoEnabled):
+ """Callback executed when the max autoscale from
+ the lineedit is updated by user input"""
+ colormap = self.getColormap()
+ xmin, xmax = colormap.getVRange()
+ if autoEnabled:
+ xmax = None
+ else:
+ _xmin, xmax = self._getFiniteColormapRange()
+ self._setColormapRange(xmin, xmax)
+
+ def _minValueUpdated(self, value):
+ """Callback executed when the lineedit min value is
+ updated by user input"""
+ xmin = value
+ xmax = self._maxValue.getValue()
+ if xmax is not None and xmin > xmax:
+ # FIXME: This should be done in the widget itself
+ xmin = xmax
+ with utils.blockSignals(self._minValue):
+ self._minValue.setValue(xmin)
+ self._setColormapRange(xmin, xmax)
+
+ def _maxValueUpdated(self, value):
+ """Callback executed when the lineedit max value is
+ updated by user input"""
+ xmin = self._minValue.getValue()
+ xmax = value
+ if xmin is not None and xmin > xmax:
+ # FIXME: This should be done in the widget itself
+ xmax = xmin
+ with utils.blockSignals(self._maxValue):
+ self._maxValue.setValue(xmax)
+ self._setColormapRange(xmin, xmax)
+
+ def _histogramRangeMoving(self, vmin, vmax):
+ """Callback executed when for colormap range displayed in
+ the histogram widget is moving.
+
+ :param vmin: Update of the minimum range, else None
+ :param vmax: Update of the maximum range, else None
"""
- 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.
+ colormap = self.getColormap()
+ if vmin is not None:
+ with self._colormapChange:
+ colormap.setVMin(vmin)
+ self._minValue.setValue(vmin)
+ if vmax is not None:
+ with self._colormapChange:
+ colormap.setVMax(vmax)
+ self._maxValue.setValue(vmax)
+
+ def _histogramRangeMoved(self, vmin, vmax):
+ """Callback executed when for colormap range displayed in
+ the histogram widget has finished to move
"""
- 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()
+ xmin = self._minValue.getValue()
+ xmax = self._maxValue.getValue()
+ if vmin is None:
+ vmin = xmin
+ if vmax is None:
+ vmax = xmax
+ self._setColormapRange(vmin, vmax)
def keyPressEvent(self, event):
"""Override key handling.
It disables leaving the dialog when editing a text field.
+
+ But several press of Return key can be use to validate and close the
+ dialog.
"""
- if event.key() == qt.Qt.Key_Enter and (self._minValue.hasFocus() or
- self._maxValue.hasFocus()):
+ if event.key() in (qt.Qt.Key_Enter, qt.Qt.Key_Return):
# Bypass QDialog keyPressEvent
# To avoid leaving the dialog when pressing enter on a text field
- super(qt.QDialog, self).keyPressEvent(event)
+ if self._minValue.hasFocus():
+ nextFocus = self._maxValue
+ elif self._maxValue.hasFocus():
+ if self.isModal():
+ nextFocus = self._buttonsModal.button(qt.QDialogButtonBox.Apply)
+ else:
+ nextFocus = self._buttonsNonModal.button(qt.QDialogButtonBox.Close)
+ else:
+ nextFocus = None
+ if nextFocus is not None:
+ nextFocus.setFocus(qt.Qt.OtherFocusReason)
else:
- # Use QDialog keyPressEvent
super(ColormapDialog, self).keyPressEvent(event)
diff --git a/silx/gui/dialog/DataFileDialog.py b/silx/gui/dialog/DataFileDialog.py
index d2d76a3..84605d9 100644
--- a/silx/gui/dialog/DataFileDialog.py
+++ b/silx/gui/dialog/DataFileDialog.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -210,7 +210,7 @@ class DataFileDialog(AbstractDataFileDialog):
.. code-block:: python
url = dialog.selectedDataUrl()
- with h5py.File(url.file_path()) as h5:
+ with h5py.File(url.file_path(), mode="r") as h5:
data = h5[url.data_path()]
"""
diff --git a/silx/gui/dialog/test/test_colormapdialog.py b/silx/gui/dialog/test/test_colormapdialog.py
index 8dad196..61e6365 100644
--- a/silx/gui/dialog/test/test_colormapdialog.py
+++ b/silx/gui/dialog/test/test_colormapdialog.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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 @@ from silx.gui.utils.testutils import TestCaseQt
from silx.gui.colors import Colormap, preferredColormaps
from silx.utils.testutils import ParametricTestCase
from silx.gui.plot.PlotWindow import PlotWindow
+from silx.gui.plot.items.image import ImageData
import numpy.random
@@ -50,10 +51,16 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
normalization='linear')
self.colormapDiag = ColormapDialog.ColormapDialog()
- self.colormapDiag.setAttribute(qt.Qt.WA_DeleteOnClose)
def tearDown(self):
- del self.colormapDiag
+ self.qapp.processEvents()
+ colormapDiag = self.colormapDiag
+ self.colormapDiag = None
+ if colormapDiag is not None:
+ colormapDiag.close()
+ colormapDiag.deleteLater()
+ colormapDiag = None
+ self.qapp.processEvents()
ParametricTestCase.tearDown(self)
TestCaseQt.tearDown(self)
@@ -66,9 +73,11 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
colormapDiag2.show()
self.colormapDiag.setColormap(self.colormap)
self.colormapDiag.show()
+ self.qapp.processEvents()
self.colormapDiag._comboBoxColormap._setCurrentName('red')
- self.colormapDiag._normButtonLog.click()
+ self.colormapDiag._comboBoxNormalization.setCurrentIndex(
+ self.colormapDiag._comboBoxNormalization.findData(Colormap.LOGARITHM))
self.assertTrue(self.colormap.getName() == 'red')
self.assertTrue(self.colormapDiag.getColormap().getName() == 'red')
self.assertTrue(self.colormap.getNormalization() == 'log')
@@ -76,7 +85,8 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
self.assertTrue(self.colormap.getVMax() == 20)
# checked second colormap dialog
self.assertTrue(colormapDiag2._comboBoxColormap.getCurrentName() == 'red')
- self.assertTrue(colormapDiag2._normButtonLog.isChecked())
+ self.assertEqual(colormapDiag2._comboBoxNormalization.currentData(),
+ Colormap.LOGARITHM)
self.assertTrue(int(colormapDiag2._minValue.getValue()) == 10)
self.assertTrue(int(colormapDiag2._maxValue.getValue()) == 20)
colormapDiag2.close()
@@ -86,11 +96,12 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
assert self.colormap.isAutoscale() is False
self.colormapDiag.setModal(True)
self.colormapDiag.show()
+ self.qapp.processEvents()
self.colormapDiag.setColormap(self.colormap)
self.assertTrue(self.colormap.getVMin() is not None)
- self.colormapDiag._minValue.setValue(None)
+ self.colormapDiag._minValue.sigAutoScaleChanged.emit(True)
self.assertTrue(self.colormap.getVMin() is None)
- self.colormapDiag._maxValue.setValue(None)
+ self.colormapDiag._maxValue.sigAutoScaleChanged.emit(True)
self.mouseClick(
widget=self.colormapDiag._buttonsModal.button(qt.QDialogButtonBox.Ok),
button=qt.Qt.LeftButton
@@ -104,9 +115,10 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
assert self.colormap.isAutoscale() is False
self.colormapDiag.setModal(True)
self.colormapDiag.show()
+ self.qapp.processEvents()
self.colormapDiag.setColormap(self.colormap)
self.assertTrue(self.colormap.getVMin() is not None)
- self.colormapDiag._minValue.setValue(None)
+ self.colormapDiag._minValue.sigAutoScaleChanged.emit(True)
self.assertTrue(self.colormap.getVMin() is None)
self.mouseClick(
widget=self.colormapDiag._buttonsModal.button(qt.QDialogButtonBox.Cancel),
@@ -118,9 +130,10 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
assert self.colormap.isAutoscale() is False
self.colormapDiag.setModal(False)
self.colormapDiag.show()
+ self.qapp.processEvents()
self.colormapDiag.setColormap(self.colormap)
self.assertTrue(self.colormap.getVMin() is not None)
- self.colormapDiag._minValue.setValue(None)
+ self.colormapDiag._minValue.sigAutoScaleChanged.emit(True)
self.assertTrue(self.colormap.getVMin() is None)
self.mouseClick(
widget=self.colormapDiag._buttonsNonModal.button(qt.QDialogButtonBox.Close),
@@ -132,9 +145,10 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
assert self.colormap.isAutoscale() is False
self.colormapDiag.setModal(False)
self.colormapDiag.show()
+ self.qapp.processEvents()
self.colormapDiag.setColormap(self.colormap)
self.assertTrue(self.colormap.getVMin() is not None)
- self.colormapDiag._minValue.setValue(None)
+ self.colormapDiag._minValue.sigAutoScaleChanged.emit(True)
self.assertTrue(self.colormap.getVMin() is None)
self.mouseClick(
widget=self.colormapDiag._buttonsNonModal.button(qt.QDialogButtonBox.Reset),
@@ -147,17 +161,20 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
"""Make sure the colormap is modify if go through reject"""
assert self.colormap.isAutoscale() is False
self.colormapDiag.show()
+ self.qapp.processEvents()
self.colormapDiag.setColormap(self.colormap)
self.assertTrue(self.colormap.getVMin() is not None)
- self.colormapDiag._minValue.setValue(None)
+ self.colormapDiag._minValue.sigAutoScaleChanged.emit(True)
self.assertTrue(self.colormap.getVMin() is None)
self.colormapDiag.close()
+ self.qapp.processEvents()
self.assertTrue(self.colormap.getVMin() is None)
def testSetColormapIsCorrect(self):
"""Make sure the interface fir the colormap when set a new colormap"""
self.colormap.setName('red')
self.colormapDiag.show()
+ self.qapp.processEvents()
for norm in (Colormap.NORMALIZATIONS):
for autoscale in (True, False):
if autoscale is True:
@@ -167,8 +184,8 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
self.colormap.setNormalization(norm)
with self.subTest(colormap=self.colormap):
self.colormapDiag.setColormap(self.colormap)
- self.assertTrue(
- self.colormapDiag._normButtonLinear.isChecked() == (norm is Colormap.LINEAR))
+ self.assertEqual(
+ self.colormapDiag._comboBoxNormalization.currentData(), norm)
self.assertTrue(
self.colormapDiag._comboBoxColormap.getCurrentName() == 'red')
self.assertTrue(
@@ -189,6 +206,7 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
we make sure the colormap is still running and nothing more"""
self.colormapDiag.setColormap(self.colormap)
self.colormapDiag.show()
+ self.qapp.processEvents()
del self.colormap
self.assertTrue(self.colormapDiag.getColormap() is None)
self.colormapDiag._comboBoxColormap._setCurrentName('blue')
@@ -198,12 +216,14 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
outside"""
self.colormapDiag.setColormap(self.colormap)
self.colormapDiag.show()
+ self.qapp.processEvents()
self.colormap.setName('red')
self.assertTrue(
self.colormapDiag._comboBoxColormap.getCurrentName() == 'red')
self.colormap.setNormalization(Colormap.LOGARITHM)
- self.assertFalse(self.colormapDiag._normButtonLinear.isChecked())
+ self.assertEqual(self.colormapDiag._comboBoxNormalization.currentData(),
+ Colormap.LOGARITHM)
self.colormap.setVRange(11, 201)
self.assertTrue(self.colormapDiag._minValue.getValue() == 11)
self.assertTrue(self.colormapDiag._maxValue.getValue() == 201)
@@ -251,11 +271,12 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
colormap = Colormap(name=colormapName)
self.colormapDiag.setColormap(colormap)
self.colormapDiag.show()
+ self.qapp.processEvents()
cb = self.colormapDiag._comboBoxColormap
self.assertTrue(cb.getCurrentName() == colormapName)
cb.setCurrentIndex(0)
index = cb.findLutName(colormapName)
- assert index is not 0 # if 0 then the rest of the test has no sense
+ assert index != 0 # if 0 then the rest of the test has no sense
cb.setCurrentIndex(index)
self.assertTrue(cb.getCurrentName() == colormapName)
@@ -264,6 +285,7 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
colormap editable status"""
colormap = Colormap(normalization='linear', vmin=1.0, vmax=10.0)
self.colormapDiag.show()
+ self.qapp.processEvents()
self.colormapDiag.setColormap(colormap)
for editable in (True, False):
with self.subTest(editable=editable):
@@ -275,15 +297,14 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
self.assertTrue(
self.colormapDiag._maxValue.isEnabled() is editable)
self.assertTrue(
- self.colormapDiag._normButtonLinear.isEnabled() is editable)
- self.assertTrue(
- self.colormapDiag._normButtonLog.isEnabled() is editable)
+ self.colormapDiag._comboBoxNormalization.isEnabled() is editable)
# Make sure the reset button is also set to enable when edition mode is
# False
self.colormapDiag.setModal(False)
colormap.setEditable(True)
- self.colormapDiag._normButtonLog.click()
+ self.colormapDiag._comboBoxNormalization.setCurrentIndex(
+ self.colormapDiag._comboBoxNormalization.findData(Colormap.LOGARITHM))
resetButton = self.colormapDiag._buttonsNonModal.button(qt.QDialogButtonBox.Reset)
self.assertTrue(resetButton.isEnabled())
colormap.setEditable(False)
@@ -302,6 +323,60 @@ class TestColormapDialog(TestCaseQt, ParametricTestCase):
self.colormapDiag.setData(data)
self.colormapDiag.setData(None)
+ def testImageItem(self):
+ """Check that an ImageData plot item can be used"""
+ dialog = self.colormapDiag
+ colormap = Colormap(name='gray', vmin=None, vmax=None)
+ data = numpy.arange(3**2).reshape(3, 3)
+ item = ImageData()
+ item.setData(data, copy=False)
+
+ dialog.setColormap(colormap)
+ dialog.show()
+ self.qapp.processEvents()
+ dialog.setItem(item)
+ vrange = dialog._getFiniteColormapRange()
+ self.assertEqual(vrange, (0, 8))
+
+ def testItemDel(self):
+ """Check that the plot items are not hard linked to the dialog"""
+ dialog = self.colormapDiag
+ colormap = Colormap(name='gray', vmin=None, vmax=None)
+ data = numpy.arange(3**2).reshape(3, 3)
+ item = ImageData()
+ item.setData(data, copy=False)
+
+ dialog.setColormap(colormap)
+ dialog.show()
+ self.qapp.processEvents()
+ dialog.setItem(item)
+ previousRange = dialog._getFiniteColormapRange()
+ del item
+ vrange = dialog._getFiniteColormapRange()
+ self.assertNotEqual(vrange, previousRange)
+
+ def testDataDel(self):
+ """Check that the data are not hard linked to the dialog"""
+ dialog = self.colormapDiag
+ colormap = Colormap(name='gray', vmin=None, vmax=None)
+ data = numpy.arange(5)
+
+ dialog.setColormap(colormap)
+ dialog.show()
+ self.qapp.processEvents()
+ dialog.setData(data)
+ previousRange = dialog._getFiniteColormapRange()
+ del data
+ vrange = dialog._getFiniteColormapRange()
+ self.assertNotEqual(vrange, previousRange)
+
+ def testDeleteWhileExec(self):
+ colormapDiag = self.colormapDiag
+ self.colormapDiag = None
+ qt.QTimer.singleShot(1000, colormapDiag.deleteLater)
+ result = colormapDiag.exec_()
+ self.assertEqual(result, 0)
+
class TestColormapAction(TestCaseQt):
def setUp(self):
diff --git a/silx/gui/dialog/test/test_imagefiledialog.py b/silx/gui/dialog/test/test_imagefiledialog.py
index c019afb..3cbb492 100644
--- a/silx/gui/dialog/test/test_imagefiledialog.py
+++ b/silx/gui/dialog/test/test_imagefiledialog.py
@@ -61,8 +61,8 @@ def setUpModule():
filename = _tmpDirectory + "/multiframe.edf"
image = fabio.edfimage.EdfImage(data=data)
- image.appendFrame(data=data + 1)
- image.appendFrame(data=data + 2)
+ image.append_frame(data=data + 1)
+ image.append_frame(data=data + 2)
image.write(filename)
filename = _tmpDirectory + "/singleimage.msk"
diff --git a/silx/gui/fit/FitWidget.py b/silx/gui/fit/FitWidget.py
index c3804e1..7279cd9 100644
--- a/silx/gui/fit/FitWidget.py
+++ b/silx/gui/fit/FitWidget.py
@@ -43,7 +43,6 @@ __date__ = "17/07/2018"
import logging
import sys
import traceback
-import warnings
from silx.math.fit import fittheories
from silx.math.fit import fitmanager, functions
@@ -52,6 +51,7 @@ from .FitWidgets import (FitActionsButtons, FitStatusLines,
FitConfigWidget, ParametersTab)
from .FitConfig import getFitConfigDialog
from .BackgroundWidget import getBgDialog, BackgroundDialog
+from ...utils.deprecation import deprecated
QTVERSION = qt.qVersion()
DEBUG = 0
@@ -226,7 +226,9 @@ class FitWidget(qt.QWidget):
self.guibuttons = FitActionsButtons(self)
"""Widget with estimate, start fit and dismiss buttons"""
self.guibuttons.EstimateButton.clicked.connect(self.estimate)
+ self.guibuttons.EstimateButton.setEnabled(False)
self.guibuttons.StartFitButton.clicked.connect(self.startFit)
+ self.guibuttons.StartFitButton.setEnabled(False)
self.guibuttons.DismissButton.clicked.connect(self.dismiss)
layout.addWidget(self.guibuttons)
@@ -314,12 +316,11 @@ class FitWidget(qt.QWidget):
configuration.update(self.configure())
+ @deprecated(replacement='setData', since_version='0.3.0')
def setdata(self, x, y, sigmay=None, xmin=None, xmax=None):
- warnings.warn("Method renamed to setData",
- DeprecationWarning)
self.setData(x, y, sigmay, xmin, xmax)
- def setData(self, x, y, sigmay=None, xmin=None, xmax=None):
+ def setData(self, x=None, y=None, sigmay=None, xmin=None, xmax=None):
"""Set data to be fitted.
:param x: Abscissa data. If ``None``, :attr:`xdata`` is set to
@@ -335,11 +336,17 @@ class FitWidget(qt.QWidget):
:param xmin: Lower value of x values to use for fitting
:param xmax: Upper value of x values to use for fitting
"""
- self.fitmanager.setdata(x=x, y=y, sigmay=sigmay,
- xmin=xmin, xmax=xmax)
- for config_dialog in self.bgconfigdialogs.values():
- if isinstance(config_dialog, BackgroundDialog):
- config_dialog.setData(x, y, xmin=xmin, xmax=xmax)
+ if y is None:
+ self.guibuttons.EstimateButton.setEnabled(False)
+ self.guibuttons.StartFitButton.setEnabled(False)
+ else:
+ self.guibuttons.EstimateButton.setEnabled(True)
+ self.guibuttons.StartFitButton.setEnabled(True)
+ self.fitmanager.setdata(x=x, y=y, sigmay=sigmay,
+ xmin=xmin, xmax=xmax)
+ for config_dialog in self.bgconfigdialogs.values():
+ if isinstance(config_dialog, BackgroundDialog):
+ config_dialog.setData(x, y, xmin=xmin, xmax=xmax)
def associateConfigDialog(self, theory_name, config_widget,
theory_is_background=False):
@@ -505,10 +512,12 @@ class FitWidget(qt.QWidget):
msg.setWindowTitle('FitWidget Message')
msg.exec_()
return
- except: # noqa (we want to catch and report all errors)
+ except Exception as e: # noqa (we want to catch and report all errors)
+ _logger.warning('Estimate error: %s', traceback.format_exc())
msg = qt.QMessageBox(self)
msg.setIcon(qt.QMessageBox.Critical)
- msg.setText("Error on estimate: %s" % traceback.format_exc())
+ msg.setWindowTitle("Estimate Error")
+ msg.setText("Error on estimate: %s" % e)
msg.exec_()
ddict = {
'event': 'EstimateFailed',
@@ -524,9 +533,8 @@ class FitWidget(qt.QWidget):
'data': self.fitmanager.fit_results}
self._emitSignal(ddict)
+ @deprecated(replacement='startFit', since_version='0.3.0')
def startfit(self):
- warnings.warn("Method renamed to startFit",
- DeprecationWarning)
self.startFit()
def startFit(self):
@@ -548,10 +556,12 @@ class FitWidget(qt.QWidget):
'data': None}
self._emitSignal(ddict)
self.fitmanager.runfit(callback=self.fitStatus)
- except: # noqa (we want to catch and report all errors)
+ except Exception as e: # noqa (we want to catch and report all errors)
+ _logger.warning('Estimate error: %s', traceback.format_exc())
msg = qt.QMessageBox(self)
msg.setIcon(qt.QMessageBox.Critical)
- msg.setText("Error on Fit: %s" % traceback.format_exc())
+ msg.setWindowTitle("Fit Error")
+ msg.setText("Error on Fit: %s" % e)
msg.exec_()
ddict = {
'event': 'FitFailed',
diff --git a/silx/gui/hdf5/test/test_hdf5.py b/silx/gui/hdf5/test/test_hdf5.py
index 4bb43ff..5bd4223 100755
--- a/silx/gui/hdf5/test/test_hdf5.py
+++ b/silx/gui/hdf5/test/test_hdf5.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -179,7 +179,7 @@ class TestHdf5TreeModel(TestCaseQt):
def testSynchronizeObject(self):
filename = _tmpDirectory + "/data.h5"
- h5 = h5py.File(filename)
+ h5 = h5py.File(filename, mode="r")
model = hdf5.Hdf5TreeModel()
model.insertH5pyObject(h5)
self.assertEqual(model.rowCount(qt.QModelIndex()), 1)
@@ -256,7 +256,7 @@ class TestHdf5TreeModel(TestCaseQt):
internally."""
filename = _tmpDirectory + "/data.h5"
try:
- h5File = h5py.File(filename)
+ h5File = h5py.File(filename, mode="r")
model = hdf5.Hdf5TreeModel()
self.assertEqual(model.rowCount(qt.QModelIndex()), 0)
model.insertH5pyObject(h5File)
@@ -391,7 +391,7 @@ class TestHdf5TreeModelSignals(TestCaseQt):
TestCaseQt.setUp(self)
self.model = hdf5.Hdf5TreeModel()
filename = _tmpDirectory + "/data.h5"
- self.h5 = h5py.File(filename)
+ self.h5 = h5py.File(filename, mode='r')
self.model.insertH5pyObject(self.h5)
self.listener = SignalListener()
@@ -418,7 +418,7 @@ class TestHdf5TreeModelSignals(TestCaseQt):
def testInsert(self):
filename = _tmpDirectory + "/data.h5"
- h5 = h5py.File(filename)
+ h5 = h5py.File(filename, mode='r')
self.model.insertH5pyObject(h5)
self.assertEqual(self.listener.callCount(), 0)
diff --git a/silx/gui/plot/ColorBar.py b/silx/gui/plot/ColorBar.py
index fd4fdf8..2b4677b 100644
--- a/silx/gui/plot/ColorBar.py
+++ b/silx/gui/plot/ColorBar.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -166,8 +166,8 @@ class ColorBarWidget(qt.QWidget):
:param ~silx.gui.colors.Colormap colormap:
The colormap to apply on the ColorBarWidget
- :param numpy.ndarray data: the data to display, needed if the colormap
- require an autoscale
+ :param Union[numpy.ndarray,~silx.gui.plot.items.ColormapMixin] data:
+ The data to display or item, needed if the colormap require an autoscale
"""
self._data = data
self.getColorScaleBar().setColormap(colormap=colormap,
@@ -220,10 +220,10 @@ class ColorBarWidget(qt.QWidget):
return
# Sync with active scatter
- activeScatter = plot._getActiveItem(kind='scatter')
+ scatter = plot._getActiveItem(kind='scatter')
- self.setColormap(colormap=activeScatter.getColormap(),
- data=activeScatter.getValueData(copy=False))
+ self.setColormap(colormap=scatter.getColormap(),
+ data=scatter)
def _activeImageChanged(self, previous, legend):
"""Handle plot active image changed"""
@@ -236,18 +236,18 @@ class ColorBarWidget(qt.QWidget):
self._activeScatterChanged(None, activeScatterLegend)
else:
# Sync with active image
- image = plot.getActiveImage().getData(copy=False)
+ image = plot.getActiveImage()
# RGB(A) image, display default colormap
- if image.ndim != 2:
+ array = image.getData(copy=False)
+ if array.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)
+ self.setColormap(colormap=image.getColormap(), data=image)
def _defaultColormapChanged(self, event):
"""Handle plot default colormap changed"""
@@ -259,9 +259,9 @@ class ColorBarWidget(qt.QWidget):
# No active item, take default colormap update into account
self._syncWithDefaultColormap()
- def _syncWithDefaultColormap(self, data=None):
+ def _syncWithDefaultColormap(self):
"""Update colorbar according to plot default colormap"""
- self.setColormap(self.getPlot().getDefaultColormap(), data)
+ self.setColormap(self.getPlot().getDefaultColormap())
def getColorScaleBar(self):
"""
@@ -351,13 +351,14 @@ class ColorScaleBar(qt.QWidget):
margin=ColorScaleBar._TEXT_MARGIN)
if colormap:
vmin, vmax = colormap.getColormapRange(data)
+ normalizer = colormap._getNormalizer()
else:
vmin, vmax = colors.DEFAULT_MIN_LIN, colors.DEFAULT_MAX_LIN
+ normalizer = None
- norm = colormap.getNormalization() if colormap else colors.Colormap.LINEAR
self.tickbar = _TickBar(vmin=vmin,
vmax=vmax,
- norm=norm,
+ normalizer=normalizer,
parent=self,
displayValues=displayTicksValues,
margin=ColorScaleBar._TEXT_MARGIN)
@@ -408,21 +409,21 @@ class ColorScaleBar(qt.QWidget):
"""Set the new colormap to be displayed
:param Colormap colormap: the colormap to set
- :param numpy.ndarray data: the data to display, needed if the colormap
- require an autoscale
+ :param Union[numpy.ndarray,~silx.gui.plot.items.Item] data:
+ The data or item to display, needed if the colormap requires an autoscale
"""
self.colorScale.setColormap(colormap, data)
if colormap is not None:
vmin, vmax = colormap.getColormapRange(data)
- norm = colormap.getNormalization()
+ normalizer = colormap._getNormalizer()
else:
vmin, vmax = None, None
- norm = None
+ normalizer = None
self.tickbar.update(vmin=vmin,
vmax=vmax,
- norm=norm)
+ normalizer=normalizer)
self._setMinMaxLabels(vmin, vmax)
def setMinMaxVisible(self, val=True):
@@ -503,6 +504,8 @@ class _ColorScale(qt.QWidget):
:param colormap: the colormap to be displayed
:param parent: the Qt parent if any
:param int margin: the top and left margin to apply.
+ :param Union[None,numpy.ndarray,~silx.gui.plot.items.ColormapMixin] data:
+ The data or item to use for getting the range for autoscale colormap.
.. warning:: Value drawing will be
done at the center of ticks. So if no margin is done your values
@@ -531,7 +534,8 @@ class _ColorScale(qt.QWidget):
"""Set the new colormap to be displayed
:param dict colormap: the colormap to set
- :param data: Optional data for which to compute colormap range.
+ :param Union[None,numpy.ndarray,~silx.gui.plot.items.ColormapMixin] data:
+ Optional data for which to compute colormap range.
"""
self._colormap = colormap
self.setEnabled(colormap is not None)
@@ -580,8 +584,8 @@ class _ColorScale(qt.QWidget):
painter.drawRect(qt.QRect(
0,
self.margin,
- self.width() - 1.,
- self.height() - 2. * self.margin - 1.))
+ self.width() - 1,
+ self.height() - 2 * self.margin - 1))
def mouseMoveEvent(self, event):
tooltip = str(self.getValueFromRelativePosition(
@@ -606,19 +610,12 @@ class _ColorScale(qt.QWidget):
if colormap is None:
return
- value = max(0.0, value)
- value = min(value, 1.0)
+ value = numpy.clip(value, 0., 1.)
+ normalizer = colormap._getNormalizer()
+ normMin, normMax = normalizer.apply([self.vmin, self.vmax], self.vmin, self.vmax)
- vmin = self.vmin
- vmax = self.vmax
- if colormap.getNormalization() == colors.Colormap.LINEAR:
- return vmin + (vmax - vmin) * value
- elif colormap.getNormalization() == colors.Colormap.LOGARITHM:
- rpos = (numpy.log10(vmax) - numpy.log10(vmin)) * value + numpy.log10(vmin)
- return numpy.power(10., rpos)
- else:
- err = "normalization type (%s) is not managed by the _ColorScale Widget" % colormap['normalization']
- raise ValueError(err)
+ return normalizer.revert(
+ normMin + (normMax - normMin) * value, self.vmin, self.vmax)
def setMargin(self, margin):
"""Define the margin to fit with a TickBar object.
@@ -627,7 +624,7 @@ class _ColorScale(qt.QWidget):
:param int margin: the margin to apply on the top and bottom.
"""
- self.margin = margin
+ self.margin = int(margin)
self.update()
@@ -645,8 +642,7 @@ class _TickBar(qt.QWidget):
:param int vmin: smaller value of the range of values
:param int vmax: higher value of the range of values
- :param str norm: normalization type to be displayed. Valid values are
- 'linear' and 'log'
+ :param normalizer: Normalization object.
:param parent: the Qt parent if any
:param bool displayValues: if True display the values close to the tick,
Otherwise only signal it by '-'
@@ -666,7 +662,7 @@ class _TickBar(qt.QWidget):
DEFAULT_TICK_DENSITY = 0.015
- def __init__(self, vmin, vmax, norm, parent=None, displayValues=True,
+ def __init__(self, vmin, vmax, normalizer, parent=None, displayValues=True,
nticks=None, margin=5):
super(_TickBar, self).__init__(parent)
self.margin = margin
@@ -678,7 +674,7 @@ class _TickBar(qt.QWidget):
self._vmin = vmin
self._vmax = vmax
- self._norm = norm
+ self._normalizer = normalizer
self.displayValues = displayValues
self.setTicksNumber(nticks)
@@ -695,10 +691,10 @@ class _TickBar(qt.QWidget):
width = self._WIDTH_DISP_VAL if self.displayValues else self._WIDTH_NO_DISP_VAL
self.setFixedWidth(width)
- def update(self, vmin, vmax, norm):
+ def update(self, vmin, vmax, normalizer):
self._vmin = vmin
self._vmax = vmax
- self._norm = norm
+ self._normalizer = normalizer
self.computeTicks()
qt.QWidget.update(self)
@@ -742,13 +738,11 @@ class _TickBar(qt.QWidget):
# No range: no ticks
self.ticks = ()
self.subTicks = ()
- elif self._norm == colors.Colormap.LOGARITHM:
+ elif isinstance(self._normalizer, colors._LogarithmicNormalization):
self._computeTicksLog(nticks)
- elif self._norm == colors.Colormap.LINEAR:
+ else: # Fallback: use linear
self._computeTicksLin(nticks)
- else:
- err = 'TickBar - Wrong normalization %s' % self._norm
- raise ValueError(err)
+
# update the form
font = qt.QFont()
font.setPixelSize(_TickBar._FONT_SIZE)
@@ -801,12 +795,17 @@ class _TickBar(qt.QWidget):
def _getRelativePosition(self, val):
"""Return the relative position of val according to min and max value
"""
- if self._norm == colors.Colormap.LINEAR:
- return 1 - (val - self._vmin) / (self._vmax - self._vmin)
- elif self._norm == colors.Colormap.LOGARITHM:
- return 1 - (numpy.log10(val) - numpy.log10(self._vmin)) / (numpy.log10(self._vmax) - numpy.log10(self._vmin))
+ if self._normalizer is None:
+ return 0.
+ normMin, normMax, normVal = self._normalizer.apply(
+ [self._vmin, self._vmax, val],
+ self._vmin,
+ self._vmax)
+
+ if normMin == normMax:
+ return 0.
else:
- raise ValueError('Norm is not recognized')
+ return 1. - (normVal - normMin) / (normMax - normMin)
def _paintTick(self, val, painter, majorTick=True):
"""
@@ -817,19 +816,18 @@ class _TickBar(qt.QWidget):
fm = qt.QFontMetrics(painter.font())
viewportHeight = self.rect().height() - self.margin * 2 - 1
relativePos = self._getRelativePosition(val)
- height = viewportHeight * relativePos
- height += self.margin
+ height = int(viewportHeight * relativePos + self.margin)
lineWidth = _TickBar._LINE_WIDTH
if majorTick is False:
lineWidth /= 2
- painter.drawLine(qt.QLine(self.width() - lineWidth,
+ painter.drawLine(qt.QLine(int(self.width() - lineWidth),
height,
self.width(),
height))
if self.displayValues and majorTick is True:
- painter.drawText(qt.QPoint(0.0, height + (fm.height() / 2)),
+ painter.drawText(qt.QPoint(0, int(height + fm.height() / 2)),
self.form.format(val))
def setDisplayType(self, disType):
@@ -853,9 +851,9 @@ class _TickBar(qt.QWidget):
def _getFormat(self, font):
if self._forcedDisplayType is None:
return self._guessType(font)
- elif self._forcedDisplayType is 'std':
+ elif self._forcedDisplayType == 'std':
return self._getStandardFormat()
- elif self._forcedDisplayType is 'e':
+ elif self._forcedDisplayType == 'e':
return self._getScientificForm()
else:
err = 'Forced type for display %s is not recognized' % self._forcedDisplayType
diff --git a/silx/gui/plot/ComplexImageView.py b/silx/gui/plot/ComplexImageView.py
index c8470ab..cd891cc 100644
--- a/silx/gui/plot/ComplexImageView.py
+++ b/silx/gui/plot/ComplexImageView.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -287,10 +287,10 @@ class ComplexImageView(qt.QWidget):
# Create and add image to the plot
self._plotImage = ImageComplexData()
- self._plotImage._setLegend('__ComplexImageView__complex_image__')
+ self._plotImage.setName('__ComplexImageView__complex_image__')
self._plotImage.sigItemChanged.connect(self._itemChanged)
- self._plot2D._add(self._plotImage)
- self._plot2D.setActiveImage(self._plotImage.getLegend())
+ self._plot2D.addItem(self._plotImage)
+ self._plot2D.setActiveImage(self._plotImage.getName())
toolBar = qt.QToolBar('Complex', self)
toolBar.addWidget(
diff --git a/silx/gui/plot/CurvesROIWidget.py b/silx/gui/plot/CurvesROIWidget.py
index 4508c60..4865b8e 100644
--- a/silx/gui/plot/CurvesROIWidget.py
+++ b/silx/gui/plot/CurvesROIWidget.py
@@ -410,7 +410,7 @@ class CurvesROIWidget(qt.QWidget):
"""
if visible:
# if no ROI existing yet, add the default one
- if self.roiTable.rowCount() is 0:
+ if self.roiTable.rowCount() == 0:
old = self.blockSignals(True) # avoid several sigROISignal emission
self._add()
self.blockSignals(old)
@@ -703,7 +703,7 @@ class ROITable(TableWidget):
remove the current active roi
"""
activeItems = self.selectedItems()
- if len(activeItems) is 0:
+ if len(activeItems) == 0:
return
old = self.blockSignals(True) # avoid several emission of sigROISignal
roiToRm = set()
@@ -1069,7 +1069,8 @@ class ROI(_RegionOfInterestBase):
"""Signal emitted when the ROI is edited"""
def __init__(self, name, fromdata=None, todata=None, type_=None):
- _RegionOfInterestBase.__init__(self, name=name)
+ _RegionOfInterestBase.__init__(self)
+ self.setName(name)
global _indexNextROI
self._id = _indexNextROI
_indexNextROI += 1
@@ -1255,7 +1256,7 @@ class ROI(_RegionOfInterestBase):
y = y[(x >= self._fromdata) & (x <= self._todata)]
x = x[(x >= self._fromdata) & (x <= self._todata)]
- if x.size is 0:
+ if x.size == 0:
return 0.0, 0.0
rawArea = numpy.trapz(y, x=x)
@@ -1268,6 +1269,10 @@ class ROI(_RegionOfInterestBase):
netArea = rawArea - background
return rawArea, netArea
+ @docstring(_RegionOfInterestBase)
+ def contains(self, position):
+ return self._fromdata <= position[0] <= self._todata
+
class _RoiMarkerManager(object):
"""
diff --git a/silx/gui/plot/ImageStack.py b/silx/gui/plot/ImageStack.py
new file mode 100644
index 0000000..c620d6d
--- /dev/null
+++ b/silx/gui/plot/ImageStack.py
@@ -0,0 +1,586 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2020 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.
+#
+# ###########################################################################*/
+"""Image stack view with data prefetch capabilty."""
+
+__authors__ = ["H. Payno"]
+__license__ = "MIT"
+__date__ = "04/03/2019"
+
+
+from silx.gui import icons, qt
+from silx.gui.plot import Plot2D
+from silx.gui.utils import concurrent
+from silx.io.url import DataUrl
+from silx.io.utils import get_data
+from collections import OrderedDict
+from silx.gui.widgets.FrameBrowser import HorizontalSliderWithBrowser
+import time
+import threading
+import typing
+import logging
+
+_logger = logging.getLogger(__file__)
+
+
+class _PlotWithWaitingLabel(qt.QWidget):
+ """Image plot widget with an overlay 'waiting' status.
+ """
+
+ class AnimationThread(threading.Thread):
+ def __init__(self, label):
+ self.running = True
+ self._label = label
+ self.animated_icon = icons.getWaitIcon()
+ self.animated_icon.register(self._label)
+ super(_PlotWithWaitingLabel.AnimationThread, self).__init__()
+
+ def run(self):
+ while self.running:
+ time.sleep(0.05)
+ icon = self.animated_icon.currentIcon()
+ self.future_result = concurrent.submitToQtMainThread(
+ self._label.setPixmap, icon.pixmap(30, state=qt.QIcon.On))
+
+ def stop(self):
+ """Stop the update thread"""
+ self.animated_icon.unregister(self._label)
+ self.running = False
+ self.join(2)
+
+ def __init__(self, parent):
+ super(_PlotWithWaitingLabel, self).__init__(parent=parent)
+ layout = qt.QStackedLayout(self)
+ layout.setStackingMode(qt.QStackedLayout.StackAll)
+
+ self._waiting_label = qt.QLabel(parent=self)
+ self._waiting_label.setAlignment(qt.Qt.AlignHCenter | qt.Qt.AlignVCenter)
+ layout.addWidget(self._waiting_label)
+
+ self._plot = Plot2D(parent=self)
+ layout.addWidget(self._plot)
+
+ self.updateThread = _PlotWithWaitingLabel.AnimationThread(self._waiting_label)
+ self.updateThread.start()
+
+ def close(self) -> bool:
+ super(_PlotWithWaitingLabel, self).close()
+ self.updateThread.stop()
+
+ def setWaiting(self, activate=True):
+ if activate is True:
+ self._plot.clear()
+ self._waiting_label.show()
+ else:
+ self._waiting_label.hide()
+
+ def setData(self, data):
+ self.setWaiting(activate=False)
+ self._plot.addImage(data=data)
+
+ def clear(self):
+ self._plot.clear()
+ self.setWaiting(False)
+
+ def getPlotWidget(self):
+ return self._plot
+
+
+class _HorizontalSlider(HorizontalSliderWithBrowser):
+
+ sigCurrentUrlIndexChanged = qt.Signal(int)
+
+ def __init__(self, parent):
+ super(_HorizontalSlider, self).__init__(parent=parent)
+ # connect signal / slot
+ self.valueChanged.connect(self._urlChanged)
+
+ def setUrlIndex(self, index):
+ self.setValue(index)
+ self.sigCurrentUrlIndexChanged.emit(index)
+
+ def _urlChanged(self, value):
+ self.sigCurrentUrlIndexChanged.emit(value)
+
+
+class UrlList(qt.QWidget):
+ """List of URLs the user to select an URL"""
+
+ sigCurrentUrlChanged = qt.Signal(str)
+ """Signal emitted when the active/current url change"""
+
+ def __init__(self, parent=None):
+ super(UrlList, self).__init__(parent)
+ self.setLayout(qt.QVBoxLayout())
+ self.layout().setSpacing(0)
+ self.layout().setContentsMargins(0, 0, 0, 0)
+ self._listWidget = qt.QListWidget(parent=self)
+ self.layout().addWidget(self._listWidget)
+
+ # connect signal / Slot
+ self._listWidget.currentItemChanged.connect(self._notifyCurrentUrlChanged)
+
+ # expose API
+ self.currentItem = self._listWidget.currentItem
+
+ def setUrls(self, urls: list) -> None:
+ url_names = []
+ [url_names.append(url.path()) for url in urls]
+ self._listWidget.addItems(url_names)
+
+ def _notifyCurrentUrlChanged(self, current, previous):
+ self.sigCurrentUrlChanged.emit(current.text())
+
+ def setUrl(self, url: DataUrl) -> None:
+ assert isinstance(url, DataUrl)
+ sel_items = self._listWidget.findItems(url.path(), qt.Qt.MatchExactly)
+ if sel_items is None:
+ _logger.warning(url.path(), ' is not registered in the list.')
+ else:
+ assert len(sel_items) == 1
+ item = sel_items[0]
+ self._listWidget.setCurrentItem(item)
+ self.sigCurrentUrlChanged.emit(item.text())
+
+
+class _ToggleableUrlSelectionTable(qt.QWidget):
+
+ _BUTTON_ICON = qt.QStyle.SP_ToolBarHorizontalExtensionButton # noqa
+
+ sigCurrentUrlChanged = qt.Signal(str)
+ """Signal emitted when the active/current url change"""
+
+ def __init__(self, parent=None) -> None:
+ qt.QWidget.__init__(self, parent)
+ self.setLayout(qt.QGridLayout())
+ self._toggleButton = qt.QPushButton(parent=self)
+ self.layout().addWidget(self._toggleButton, 0, 2, 1, 1)
+ self._toggleButton.setSizePolicy(qt.QSizePolicy.Fixed,
+ qt.QSizePolicy.Fixed)
+
+ self._urlsTable = UrlList(parent=self)
+ self.layout().addWidget(self._urlsTable, 1, 1, 1, 2)
+
+ # set up
+ self._setButtonIcon(show=True)
+
+ # Signal / slot connection
+ self._toggleButton.clicked.connect(self.toggleUrlSelectionTable)
+ self._urlsTable.sigCurrentUrlChanged.connect(self._propagateSignal)
+
+ # expose API
+ self.setUrls = self._urlsTable.setUrls
+ self.setUrl = self._urlsTable.setUrl
+ self.currentItem = self._urlsTable.currentItem
+
+ def toggleUrlSelectionTable(self):
+ visible = not self.urlSelectionTableIsVisible()
+ self._setButtonIcon(show=visible)
+ self._urlsTable.setVisible(visible)
+
+ def _setButtonIcon(self, show):
+ style = qt.QApplication.instance().style()
+ # return a QIcon
+ icon = style.standardIcon(self._BUTTON_ICON)
+ if show is False:
+ pixmap = icon.pixmap(32, 32).transformed(qt.QTransform().scale(-1, 1))
+ icon = qt.QIcon(pixmap)
+ self._toggleButton.setIcon(icon)
+
+ def urlSelectionTableIsVisible(self):
+ return self._urlsTable.isVisible()
+
+ def _propagateSignal(self, url):
+ self.sigCurrentUrlChanged.emit(url)
+
+
+class UrlLoader(qt.QThread):
+ """
+ Thread use to load DataUrl
+ """
+ def __init__(self, parent, url):
+ super(UrlLoader, self).__init__(parent=parent)
+ assert isinstance(url, DataUrl)
+ self.url = url
+ self.data = None
+
+ def run(self):
+ try:
+ self.data = get_data(self.url)
+ except IOError:
+ self.data = None
+
+
+class ImageStack(qt.QMainWindow):
+ """Widget loading on the fly images contained the given urls.
+
+ It prefetches images close to the displayed one.
+ """
+
+ N_PRELOAD = 10
+
+ sigLoaded = qt.Signal(str)
+ """Signal emitted when new data is available"""
+
+ sigCurrentUrlChanged = qt.Signal(str)
+ """Signal emitted when the current url change"""
+
+ def __init__(self, parent=None) -> None:
+ super(ImageStack, self).__init__(parent)
+ self.__n_prefetch = ImageStack.N_PRELOAD
+ self._loadingThreads = []
+ self.setWindowFlags(qt.Qt.Widget)
+ self._current_url = None
+ self._url_loader = UrlLoader
+ "class to instantiate for loading urls"
+
+ # main widget
+ self._plot = _PlotWithWaitingLabel(parent=self)
+ self._plot.setAttribute(qt.Qt.WA_DeleteOnClose, True)
+ self.setWindowTitle("Image stack")
+ self.setCentralWidget(self._plot)
+
+ # dock widget: url table
+ self._tableDockWidget = qt.QDockWidget(parent=self)
+ self._urlsTable = _ToggleableUrlSelectionTable(parent=self)
+ self._tableDockWidget.setWidget(self._urlsTable)
+ self._tableDockWidget.setFeatures(qt.QDockWidget.DockWidgetMovable)
+ self.addDockWidget(qt.Qt.RightDockWidgetArea, self._tableDockWidget)
+ # dock widget: qslider
+ self._sliderDockWidget = qt.QDockWidget(parent=self)
+ self._slider = _HorizontalSlider(parent=self)
+ self._sliderDockWidget.setWidget(self._slider)
+ self.addDockWidget(qt.Qt.BottomDockWidgetArea, self._sliderDockWidget)
+ self._sliderDockWidget.setFeatures(qt.QDockWidget.DockWidgetMovable)
+
+ self.reset()
+
+ # connect signal / slot
+ self._urlsTable.sigCurrentUrlChanged.connect(self.setCurrentUrl)
+ self._slider.sigCurrentUrlIndexChanged.connect(self.setCurrentUrlIndex)
+
+ def close(self) -> bool:
+ self._freeLoadingThreads()
+ self._plot.close()
+ super(ImageStack, self).close()
+
+ def setUrlLoaderClass(self, urlLoader: typing.Type[UrlLoader]) -> None:
+ """
+
+ :param urlLoader: define the class to call for loading urls.
+ warning: this should be a class object and not a
+ class instance.
+ """
+ assert isinstance(urlLoader, type(UrlLoader))
+ self._url_loader = urlLoader
+
+ def getUrlLoaderClass(self):
+ """
+
+ :return: class to instantiate for loading urls
+ :rtype: typing.Type[UrlLoader]
+ """
+ return self._url_loader
+
+ def _freeLoadingThreads(self):
+ for thread in self._loadingThreads:
+ thread.blockSignals(True)
+ thread.wait(5)
+ self._loadingThreads.clear()
+
+ def getPlotWidget(self) -> Plot2D:
+ """
+ Returns the PlotWidget contained in this window
+
+ :return: PlotWidget contained in this window
+ :rtype: Plot2D
+ """
+ return self._plot.getPlotWidget()
+
+ def reset(self) -> None:
+ """Clear the plot and remove any link to url"""
+ self._freeLoadingThreads()
+ self._urls = None
+ self._urlIndexes = None
+ self._urlData = OrderedDict({})
+ self._current_url = None
+ self._plot.clear()
+
+ def _preFetch(self, urls: list) -> None:
+ """Pre-fetch the given urls if necessary
+
+ :param urls: list of DataUrl to prefetch
+ :type: list
+ """
+ for url in urls:
+ if url.path() not in self._urlData:
+ self._load(url)
+
+ def _load(self, url):
+ """
+ Launch background load of a DataUrl
+
+ :param url:
+ :type: DataUrl
+ """
+ assert isinstance(url, DataUrl)
+ url_path = url.path()
+ assert url_path in self._urlIndexes
+ loader = self._url_loader(parent=self, url=url)
+ loader.finished.connect(self._urlLoaded, qt.Qt.QueuedConnection)
+ self._loadingThreads.append(loader)
+ loader.start()
+
+ def _urlLoaded(self) -> None:
+ """
+
+ :param url: restul of DataUrl.path() function
+ :return:
+ """
+ sender = self.sender()
+ assert isinstance(sender, UrlLoader)
+ url = sender.url.path()
+ if url in self._urlIndexes:
+ self._urlData[url] = sender.data
+ if self.getCurrentUrl().path() == url:
+ self._plot.setData(self._urlData[url])
+ if sender in self._loadingThreads:
+ self._loadingThreads.remove(sender)
+ self.sigLoaded.emit(url)
+
+ def setNPrefetch(self, n: int) -> None:
+ """
+ Define the number of url to prefetch around
+
+ :param int n: number of url to prefetch on left and right sides.
+ In total n*2 DataUrl will be prefetch
+ """
+ self.__n_prefetch = n
+ current_url = self.getCurrentUrl()
+ if current_url is not None:
+ self.setCurrentUrl(current_url)
+
+ def getNPrefetch(self) -> int:
+ """
+
+ :return: number of url to prefetch on left and right sides. In total
+ will load 2* NPrefetch DataUrls
+ """
+ return self.__n_prefetch
+
+ def setUrls(self, urls: list) -> None:
+ """list of urls within an index. Warning: urls should contain an image
+ compatible with the silx.gui.plot.Plot class
+
+ :param urls: urls we want to set in the stack. Key is the index
+ (position in the stack), value is the DataUrl
+ :type: list
+ """
+ def createUrlIndexes():
+ indexes = OrderedDict()
+ for index, url in enumerate(urls):
+ indexes[index] = url
+ return indexes
+
+ urls_with_indexes = createUrlIndexes()
+ urlsToIndex = self._urlsToIndex(urls_with_indexes)
+ self.reset()
+ self._urls = urls_with_indexes
+ self._urlIndexes = urlsToIndex
+
+ old_url_table = self._urlsTable.blockSignals(True)
+ self._urlsTable.setUrls(urls=list(self._urls.values()))
+ self._urlsTable.blockSignals(old_url_table)
+
+ old_slider = self._slider.blockSignals(True)
+ self._slider.setMaximum(len(self._urls) - 1)
+ self._slider.blockSignals(old_slider)
+
+ if self.getCurrentUrl() in self._urls:
+ self.setCurrentUrl(self.getCurrentUrl())
+ else:
+ first_url = self._urls[list(self._urls.keys())[0]]
+ self.setCurrentUrl(first_url)
+
+ def getUrls(self) -> tuple:
+ """
+
+ :return: tuple of urls
+ :rtype: tuple
+ """
+ return tuple(self._urlIndexes.keys())
+
+ def _getNextUrl(self, url: DataUrl) -> typing.Union[None, DataUrl]:
+ """
+ return the next url in the stack
+
+ :param url: url for which we want the next url
+ :type: DataUrl
+ :return: next url in the stack or None if `url` is the last one
+ :rtype: Union[None, DataUrl]
+ """
+ assert isinstance(url, DataUrl)
+ if self._urls is None:
+ return None
+ else:
+ index = self._urlIndexes[url.path()]
+ indexes = list(self._urls.keys())
+ res = list(filter(lambda x: x > index, indexes))
+ if len(res) == 0:
+ return None
+ else:
+ return self._urls[res[0]]
+
+ def _getPreviousUrl(self, url: DataUrl) -> typing.Union[None, DataUrl]:
+ """
+ return the previous url in the stack
+
+ :param url: url for which we want the previous url
+ :type: DataUrl
+ :return: next url in the stack or None if `url` is the last one
+ :rtype: Union[None, DataUrl]
+ """
+ if self._urls is None:
+ return None
+ else:
+ index = self._urlIndexes[url.path()]
+ indexes = list(self._urls.keys())
+ res = list(filter(lambda x: x < index, indexes))
+ if len(res) == 0:
+ return None
+ else:
+ return self._urls[res[-1]]
+
+ def _getNNextUrls(self, n: int, url: DataUrl) -> list:
+ """
+ Deduce the next urls in the stack after `url`
+
+ :param n: the number of url store after `url`
+ :type: int
+ :param url: url for which we want n next url
+ :type: DataUrl
+ :return: list of next urls.
+ :rtype: list
+ """
+ res = []
+ next_free = self._getNextUrl(url=url)
+ while len(res) < n and next_free is not None:
+ assert isinstance(next_free, DataUrl)
+ res.append(next_free)
+ next_free = self._getNextUrl(res[-1])
+ return res
+
+ def _getNPreviousUrls(self, n: int, url: DataUrl):
+ """
+ Deduce the previous urls in the stack after `url`
+
+ :param n: the number of url store after `url`
+ :type: int
+ :param url: url for which we want n previous url
+ :type: DataUrl
+ :return: list of previous urls.
+ :rtype: list
+ """
+ res = []
+ next_free = self._getPreviousUrl(url=url)
+ while len(res) < n and next_free is not None:
+ res.insert(0, next_free)
+ next_free = self._getPreviousUrl(res[0])
+ return res
+
+ def setCurrentUrlIndex(self, index: int):
+ """
+ Define the url to be displayed
+
+ :param index: url to be displayed
+ :type: int
+ """
+ if index >= len(self._urls):
+ raise ValueError('requested index out of bounds')
+ else:
+ return self.setCurrentUrl(self._urls[index])
+
+ def setCurrentUrl(self, url: typing.Union[DataUrl, str]) -> None:
+ """
+ Define the url to be displayed
+
+ :param url: url to be displayed
+ :type: DataUrl
+ """
+ assert isinstance(url, (DataUrl, str))
+ if isinstance(url, str):
+ url = DataUrl(path=url)
+ if url != self._current_url:
+ self._current_url = url
+ self.sigCurrentUrlChanged.emit(url.path())
+
+ old_url_table = self._urlsTable.blockSignals(True)
+ old_slider = self._slider.blockSignals(True)
+
+ self._urlsTable.setUrl(url)
+ self._slider.setUrlIndex(self._urlIndexes[url.path()])
+ if self._current_url is None:
+ self._plot.clear()
+ else:
+ if self._current_url.path() in self._urlData:
+ self._plot.setData(self._urlData[url.path()])
+ else:
+ self._load(url)
+ self._notifyLoading()
+ self._preFetch(self._getNNextUrls(self.__n_prefetch, url))
+ self._preFetch(self._getNPreviousUrls(self.__n_prefetch, url))
+ self._urlsTable.blockSignals(old_url_table)
+ self._slider.blockSignals(old_slider)
+
+ def getCurrentUrl(self) -> typing.Union[None, DataUrl]:
+ """
+
+ :return: url currently displayed
+ :rtype: Union[None, DataUrl]
+ """
+ return self._current_url
+
+ def getCurrentUrlIndex(self) -> typing.Union[None, int]:
+ """
+
+ :return: index of the url currently displayed
+ :rtype: Union[None, int]
+ """
+ if self._current_url is None:
+ return None
+ else:
+ return self._urlIndexes[self._current_url.path()]
+
+ @staticmethod
+ def _urlsToIndex(urls):
+ """util, return a dictionary with url as key and index as value"""
+ res = {}
+ for index, url in urls.items():
+ res[url.path()] = index
+ return res
+
+ def _notifyLoading(self):
+ """display a simple image of loading..."""
+ self._plot.setWaiting(activate=True)
+
diff --git a/silx/gui/plot/ImageView.py b/silx/gui/plot/ImageView.py
index eba9bc6..fafd49f 100644
--- a/silx/gui/plot/ImageView.py
+++ b/silx/gui/plot/ImageView.py
@@ -832,6 +832,7 @@ class ImageViewMainWindow(ImageView):
menu = self.menuBar().addMenu('Profile')
menu.addAction(self.profile.hLineAction)
menu.addAction(self.profile.vLineAction)
+ menu.addAction(self.profile.crossAction)
menu.addAction(self.profile.lineAction)
menu.addAction(self.profile.clearAction)
diff --git a/silx/gui/plot/Interaction.py b/silx/gui/plot/Interaction.py
index 358af74..6213889 100644
--- a/silx/gui/plot/Interaction.py
+++ b/silx/gui/plot/Interaction.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2020 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
@@ -137,6 +137,11 @@ class State(object):
"""
pass
+ def validate(self):
+ """Called externally to validate the current interaction in case of a
+ creation.
+ """
+ pass
class StateMachine(object):
"""State machine controller.
@@ -191,6 +196,12 @@ class StateMachine(object):
if handler is not None:
return handler(*args, **kwargs)
+ def validate(self):
+ """Called externally to validate the current interaction in case of a
+ creation.
+ """
+ self.state.validate()
+
# clickOrDrag #################################################################
@@ -209,92 +220,131 @@ class ClickOrDrag(StateMachine):
It is intended to be used through subclassing by overriding
:meth:`click`, :meth:`beginDrag`, :meth:`drag` and :meth:`endDrag`.
+
+ :param Set[str] clickButtons: Set of buttons that provides click interaction
+ :param Set[str] dragButtons: Set of buttons that provides drag interaction
"""
DRAG_THRESHOLD_SQUARE_DIST = 5 ** 2
class Idle(State):
def onPress(self, x, y, btn):
- if btn == LEFT_BTN:
- self.goto('clickOrDrag', x, y)
+ if btn in self.machine.dragButtons:
+ self.goto('clickOrDrag', x, y, btn)
return True
- elif btn == RIGHT_BTN:
- self.goto('rightClick', x, y)
+ elif btn in self.machine.clickButtons:
+ self.goto('click', x, y, btn)
return True
- class RightClick(State):
+ class Click(State):
+ def enterState(self, x, y, btn):
+ self.initPos = x, y
+ self.button = btn
+
def onMove(self, x, y):
- self.goto('idle')
+ dx2 = (x - self.initPos[0]) ** 2
+ dy2 = (y - self.initPos[1]) ** 2
+ if (dx2 + dy2) >= self.machine.DRAG_THRESHOLD_SQUARE_DIST:
+ self.goto('idle')
def onRelease(self, x, y, btn):
- if btn == RIGHT_BTN:
+ if btn == self.button:
self.machine.click(x, y, btn)
self.goto('idle')
class ClickOrDrag(State):
- def enterState(self, x, y):
+ def enterState(self, x, y, btn):
self.initPos = x, y
+ self.button = btn
def onMove(self, x, y):
dx2 = (x - self.initPos[0]) ** 2
dy2 = (y - self.initPos[1]) ** 2
if (dx2 + dy2) >= self.machine.DRAG_THRESHOLD_SQUARE_DIST:
- self.goto('drag', self.initPos, (x, y))
+ self.goto('drag', self.initPos, (x, y), self.button)
def onRelease(self, x, y, btn):
- if btn == LEFT_BTN:
- self.machine.click(x, y, btn)
+ if btn == self.button:
+ if btn in self.machine.clickButtons:
+ self.machine.click(x, y, btn)
self.goto('idle')
class Drag(State):
- def enterState(self, initPos, curPos):
+ def enterState(self, initPos, curPos, btn):
self.initPos = initPos
- self.machine.beginDrag(*initPos)
- self.machine.drag(*curPos)
+ self.button = btn
+ self.machine.beginDrag(*initPos, btn)
+ self.machine.drag(*curPos, btn)
def onMove(self, x, y):
- self.machine.drag(x, y)
+ self.machine.drag(x, y, self.button)
def onRelease(self, x, y, btn):
- if btn == LEFT_BTN:
- self.machine.endDrag(self.initPos, (x, y))
+ if btn == self.button:
+ self.machine.endDrag(self.initPos, (x, y), btn)
self.goto('idle')
- def __init__(self):
+ def __init__(self,
+ clickButtons=(LEFT_BTN, RIGHT_BTN),
+ dragButtons=(LEFT_BTN,)):
states = {
- 'idle': ClickOrDrag.Idle,
- 'rightClick': ClickOrDrag.RightClick,
- 'clickOrDrag': ClickOrDrag.ClickOrDrag,
- 'drag': ClickOrDrag.Drag
+ 'idle': self.Idle,
+ 'click': self.Click,
+ 'clickOrDrag': self.ClickOrDrag,
+ 'drag': self.Drag
}
+ self.__clickButtons = set(clickButtons)
+ self.__dragButtons = set(dragButtons)
super(ClickOrDrag, self).__init__(states, 'idle')
+ clickButtons = property(lambda self: self.__clickButtons,
+ doc="Buttons with click interaction (Set[int])")
+
+ dragButtons = property(lambda self: self.__dragButtons,
+ doc="Buttons with drag interaction (Set[int])")
+
def click(self, x, y, btn):
- """Called upon a left or right button click.
+ """Called upon a button supporting click.
- To override in a subclass.
+ Override in subclass.
+
+ :param int x: X mouse position in pixels.
+ :param int y: Y mouse position in pixels.
+ :param str btn: The mouse button which was clicked.
"""
pass
- def beginDrag(self, x, y):
- """Called at the beginning of a drag gesture with left button
- pressed.
+ def beginDrag(self, x, y, btn):
+ """Called at the beginning of a drag gesture with mouse button pressed.
+
+ Override in subclass.
- To override in a subclass.
+ :param int x: X mouse position in pixels.
+ :param int y: Y mouse position in pixels.
+ :param str btn: The mouse button for which a drag is starting.
"""
pass
- def drag(self, x, y):
+ def drag(self, x, y, btn):
"""Called on mouse moved during a drag gesture.
- To override in a subclass.
+ Override in subclass.
+
+ :param int x: X mouse position in pixels.
+ :param int y: Y mouse position in pixels.
+ :param str btn: The mouse button for which a drag is in progress.
"""
pass
- def endDrag(self, startPoint, endPoint):
- """Called at the end of a drag gesture when the left button is
- released.
+ def endDrag(self, startPoint, endPoint, btn):
+ """Called at the end of a drag gesture when the mouse button is released.
+
+ Override in subclass.
- To override in a subclass.
+ :param List[int] startPoint:
+ (x, y) mouse position in pixels at the beginning of the drag.
+ :param List[int] endPoint:
+ (x, y) mouse position in pixels at the end of the drag.
+ :param str btn: The mouse button for which a drag is done.
"""
pass
diff --git a/silx/gui/plot/ItemsSelectionDialog.py b/silx/gui/plot/ItemsSelectionDialog.py
index acb287a..ebd1c64 100644
--- a/silx/gui/plot/ItemsSelectionDialog.py
+++ b/silx/gui/plot/ItemsSelectionDialog.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -141,20 +141,22 @@ class PlotItemsSelector(qt.QTableWidget):
def updatePlotItems(self):
self._clear()
- nrows = len(self.plot._getItems(kind=self.plot_item_kinds,
- just_legend=True))
- self.setRowCount(nrows)
-
# respect order of kinds as set in method setKindsFilter
- i = 0
+ itemsAndKind = []
for kind in self.plot_item_kinds:
- for plot_item in self.plot._getItems(kind=kind):
- legend_twitem = qt.QTableWidgetItem(plot_item.getLegend())
- self.setItem(i, 0, legend_twitem)
+ itemClasses = self.plot._KIND_TO_CLASSES[kind]
+ for item in self.plot.getItems():
+ if isinstance(item, itemClasses) and item.isVisible():
+ itemsAndKind.append((item, kind))
+
+ self.setRowCount(len(itemsAndKind))
+
+ for index, (item, kind) in enumerate(itemsAndKind):
+ legend_twitem = qt.QTableWidgetItem(item.getName())
+ self.setItem(index, 0, legend_twitem)
- kind_twitem = qt.QTableWidgetItem(kind)
- self.setItem(i, 1, kind_twitem)
- i += 1
+ kind_twitem = qt.QTableWidgetItem(kind)
+ self.setItem(index, 1, kind_twitem)
@property
def selectedPlotItems(self):
@@ -167,7 +169,9 @@ class PlotItemsSelector(qt.QTableWidget):
for row in selected_rows:
legend = self.item(row, 0).text()
kind = self.item(row, 1).text()
- items.append(self.plot._getItem(kind, legend))
+ item = self.plot._getItem(kind, legend)
+ if item is not None:
+ items.append(item)
return items
@@ -192,7 +196,7 @@ class ItemsSelectionDialog(qt.QDialog):
result = isd.exec_()
if result:
for item in isd.getSelectedItems():
- print(item.getLegend(), type(item))
+ print(item.getName(), type(item))
else:
print("Selection cancelled")
"""
diff --git a/silx/gui/plot/LegendSelector.py b/silx/gui/plot/LegendSelector.py
index a9d89db..0ea0fc8 100755
--- a/silx/gui/plot/LegendSelector.py
+++ b/silx/gui/plot/LegendSelector.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2020 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
@@ -925,7 +925,7 @@ class LegendsDockWidget(qt.QDockWidget):
yaxis = 'right' if ddict['event'] == 'mapToRight' else 'left'
self.plot.addCurve(x=curve.getXData(copy=False),
y=curve.getYData(copy=False),
- legend=curve.getLegend(),
+ legend=curve.getName(),
info=curve.getInfo(),
yaxis=yaxis)
@@ -935,7 +935,7 @@ class LegendsDockWidget(qt.QDockWidget):
symbol = ddict['symbol'] if ddict['points'] else ''
self.plot.addCurve(x=curve.getXData(copy=False),
y=curve.getYData(copy=False),
- legend=curve.getLegend(),
+ legend=curve.getName(),
info=curve.getInfo(),
symbol=symbol)
@@ -945,7 +945,7 @@ class LegendsDockWidget(qt.QDockWidget):
linestyle = ddict['linestyle'] if ddict['line'] else ''
self.plot.addCurve(x=curve.getXData(copy=False),
y=curve.getYData(copy=False),
- legend=curve.getLegend(),
+ legend=curve.getName(),
info=curve.getInfo(),
linestyle=linestyle)
@@ -957,7 +957,7 @@ class LegendsDockWidget(qt.QDockWidget):
"""
legendList = []
for curve in self.plot.getAllCurves(withhidden=True):
- legend = curve.getLegend()
+ legend = curve.getName()
# Use active color if curve is active
isActive = legend == self.plot.getActiveCurve(just_legend=True)
style = curve.getCurrentStyle()
diff --git a/silx/gui/plot/MaskToolsWidget.py b/silx/gui/plot/MaskToolsWidget.py
index 9d727e7..a95e277 100644
--- a/silx/gui/plot/MaskToolsWidget.py
+++ b/silx/gui/plot/MaskToolsWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -187,6 +187,8 @@ class ImageMask(BaseMask):
:param bool mask: True to mask (default), False to unmask.
"""
assert 0 < level < 256
+ if row + height <= 0 or col + width <= 0:
+ return # Rectangle outside image, avoid negative indices
selection = self._mask[max(0, row):row + height + 1,
max(0, col):col + width + 1]
if mask:
@@ -319,7 +321,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
# ensure all mask attributes are synchronized with the active image
# and connect listener
activeImage = self.plot.getActiveImage()
- if activeImage is not None and activeImage.getLegend() != self._maskName:
+ if activeImage is not None and activeImage.getName() != self._maskName:
self._activeImageChanged()
self.plot.sigActiveImageChanged.connect(self._activeImageChanged)
@@ -351,7 +353,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
mustBeAdded = maskItem is None
if mustBeAdded:
maskItem = items.MaskImageData()
- maskItem._setLegend(self._maskName)
+ maskItem.setName(self._maskName)
# update the items
maskItem.setData(mask, copy=False)
maskItem.setColormap(self._colormap)
@@ -360,7 +362,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
maskItem.setZValue(self._z)
if mustBeAdded:
- self.plot._add(maskItem)
+ self.plot.addItem(maskItem)
elif self.plot.getImage(self._maskName):
self.plot.remove(self._maskName, kind='image')
@@ -407,7 +409,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
removed, otherwise it is adjusted to origin, scale and z.
"""
activeImage = self.plot.getActiveImage()
- if activeImage is None or activeImage.getLegend() == self._maskName:
+ if activeImage is None or activeImage.getName() == self._maskName:
# No active image or active image is the mask...
self._data = numpy.zeros((0, 0), dtype=numpy.uint8)
self._mask.setDataItem(None)
@@ -443,7 +445,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
def _activeImageChanged(self, *args):
"""Update widget and mask according to active image changes"""
activeImage = self.plot.getActiveImage()
- if (activeImage is None or activeImage.getLegend() == self._maskName or
+ if (activeImage is None or activeImage.getName() == self._maskName or
activeImage.getData(copy=False).size == 0):
# No active image or active image is the mask or image has no data...
self.setEnabled(False)
@@ -770,7 +772,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
"""Set range from active image colormap range"""
activeImage = self.plot.getActiveImage()
if (isinstance(activeImage, items.ColormapMixIn) and
- activeImage.getLegend() != self._maskName):
+ activeImage.getName() != self._maskName):
# Update thresholds according to colormap
colormap = activeImage.getColormap()
if colormap['autoscale']:
diff --git a/silx/gui/plot/PlotInteraction.py b/silx/gui/plot/PlotInteraction.py
index abfcf79..d182a49 100644
--- a/silx/gui/plot/PlotInteraction.py
+++ b/silx/gui/plot/PlotInteraction.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2020 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,7 +37,7 @@ import weakref
from .. import colors
from .. import qt
from . import items
-from .Interaction import (ClickOrDrag, LEFT_BTN, RIGHT_BTN,
+from .Interaction import (ClickOrDrag, LEFT_BTN, RIGHT_BTN, MIDDLE_BTN,
State, StateMachine)
from .PlotEvents import (prepareCurveSignal, prepareDrawingSignal,
prepareHoverSignal, prepareImageSignal,
@@ -102,11 +102,11 @@ class _PlotInteraction(object):
else:
color2 = "black"
- self.plot.addItem(points[:, 0], points[:, 1], legend=legend,
- replace=False,
- shape=shape, fill=fill,
- color=color, linebgcolor=color2, linestyle="--",
- overlay=True)
+ self.plot.addShape(points[:, 0], points[:, 1], legend=legend,
+ replace=False,
+ shape=shape, fill=fill,
+ color=color, linebgcolor=color2, linestyle="--",
+ overlay=True)
self._selectionAreas.add(legend)
@@ -127,7 +127,7 @@ class _ZoomOnWheel(ClickOrDrag, _PlotInteraction):
_DOUBLE_CLICK_TIMEOUT = 0.4
- class ZoomIdle(ClickOrDrag.Idle):
+ class Idle(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))
@@ -170,23 +170,16 @@ class _ZoomOnWheel(ClickOrDrag, _PlotInteraction):
x, y)
self.plot.notify(**eventDict)
- def __init__(self, plot):
+ def __init__(self, plot, **kwargs):
"""Init.
:param plot: The plot to apply modifications to.
"""
- _PlotInteraction.__init__(self, plot)
-
- states = {
- 'idle': _ZoomOnWheel.ZoomIdle,
- 'rightClick': ClickOrDrag.RightClick,
- 'clickOrDrag': ClickOrDrag.ClickOrDrag,
- 'drag': ClickOrDrag.Drag
- }
- StateMachine.__init__(self, states, 'idle')
-
self._lastClick = 0., None
+ _PlotInteraction.__init__(self, plot)
+ ClickOrDrag.__init__(self, **kwargs)
+
# Pan #########################################################################
@@ -198,10 +191,10 @@ class Pan(_ZoomOnWheel):
_, y2Data = self.plot.pixelToData(x, y, axis='right')
return xData, yData, y2Data
- def beginDrag(self, x, y):
+ def beginDrag(self, x, y, btn):
self._previousDataPos = self._pixelToData(x, y)
- def drag(self, x, y):
+ def drag(self, x, y, btn):
xData, yData, y2Data = self._pixelToData(x, y)
lastX, lastY, lastY2 = self._previousDataPos
@@ -266,7 +259,7 @@ class Pan(_ZoomOnWheel):
self._previousDataPos = self._pixelToData(x, y)
- def endDrag(self, startPos, endPos):
+ def endDrag(self, startPos, endPos, btn):
del self._previousDataPos
def cancel(self):
@@ -315,12 +308,12 @@ class Zoom(_ZoomOnWheel):
return areaX0, areaY0, areaX1, areaY1
- def beginDrag(self, x, y):
+ def beginDrag(self, x, y, btn):
dataPos = self.plot.pixelToData(x, y)
assert dataPos is not None
self.x0, self.y0 = x, y
- def drag(self, x1, y1):
+ def drag(self, x1, y1, btn):
if self.color is None:
return # Do not draw zoom area
@@ -388,7 +381,7 @@ class Zoom(_ZoomOnWheel):
self.plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max)
- def endDrag(self, startPos, endPos):
+ def endDrag(self, startPos, endPos, btn):
x0, y0 = startPos
x1, y1 = endPos
@@ -475,6 +468,24 @@ class SelectPolygon(Select):
self.machine.parameters)
self.machine.plot.notify(**eventDict)
+ def validate(self):
+ if len(self.points) > 2:
+ self.closePolygon()
+ else:
+ # It would be nice to have a cancel event.
+ # The plot is not aware that the interaction was cancelled
+ self.machine.cancel()
+
+ def closePolygon(self):
+ self.machine.resetSelectionArea()
+ self.points[-1] = self.points[0]
+ eventDict = prepareDrawingSignal('drawingFinished',
+ 'polygon',
+ self.points,
+ self.machine.parameters)
+ self.machine.plot.notify(**eventDict)
+ self.goto('idle')
+
def onWheel(self, x, y, angle):
self.machine.onWheel(x, y, angle)
self.updateFirstPoint()
@@ -491,16 +502,7 @@ class SelectPolygon(Select):
# Only allow to close polygon after first point
if len(self.points) > 2 and dx <= threshold and dy <= threshold:
- self.machine.resetSelectionArea()
-
- self.points[-1] = self.points[0]
-
- eventDict = prepareDrawingSignal('drawingFinished',
- 'polygon',
- self.points,
- self.machine.parameters)
- self.machine.plot.notify(**eventDict)
- self.goto('idle')
+ self.closePolygon()
return False
# Update polygon last point not too close to previous one
@@ -1023,13 +1025,13 @@ class SelectFreeLine(ClickOrDrag, _PlotInteraction):
if btn == LEFT_BTN:
self._processEvent(x, y, isLast=True)
- def beginDrag(self, x, y):
+ def beginDrag(self, x, y, btn):
self._processEvent(x, y, isLast=False)
- def drag(self, x, y):
+ def drag(self, x, y, btn):
self._processEvent(x, y, isLast=False)
- def endDrag(self, startPos, endPos):
+ def endDrag(self, startPos, endPos, btn):
x, y = endPos
self._processEvent(x, y, isLast=True)
@@ -1079,15 +1081,13 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
applyZoomToPlot(self.machine.plot, scaleF, (x, y))
def onMove(self, x, y):
- result = self.machine.plot._pickTopMost(
- x, y, lambda item: isinstance(item, items.MarkerBase))
- marker = result.getItem() if result is not None else None
+ marker = self.machine.plot._getMarkerAt(x, y)
if marker is not None:
dataPos = self.machine.plot.pixelToData(x, y)
assert dataPos is not None
eventDict = prepareHoverSignal(
- marker.getLegend(), 'marker',
+ marker.getName(), 'marker',
dataPos, (x, y),
marker.isDraggable(),
marker.isSelectable())
@@ -1109,19 +1109,18 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
elif marker.isSelectable():
self.machine.plot.setGraphCursorShape(CURSOR_POINTING)
+ else:
+ self.machine.plot.setGraphCursorShape()
return True
def __init__(self, plot):
- _PlotInteraction.__init__(self, plot)
+ self._pan = Pan(plot)
- states = {
- 'idle': ItemsInteraction.Idle,
- 'rightClick': ClickOrDrag.RightClick,
- 'clickOrDrag': ClickOrDrag.ClickOrDrag,
- 'drag': ClickOrDrag.Drag
- }
- StateMachine.__init__(self, states, 'idle')
+ _PlotInteraction.__init__(self, plot)
+ ClickOrDrag.__init__(self,
+ clickButtons=(LEFT_BTN, RIGHT_BTN),
+ dragButtons=(LEFT_BTN, MIDDLE_BTN))
def click(self, x, y, btn):
"""Handle mouse click
@@ -1169,7 +1168,7 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
eventDict = prepareMarkerSignal('markerClicked',
'left',
- item.getLegend(),
+ item.getName(),
'marker',
item.isDraggable(),
item.isSelectable(),
@@ -1186,7 +1185,7 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
indices = result.getIndices(copy=False)
eventDict = prepareCurveSignal('left',
- item.getLegend(),
+ item.getName(),
'curve',
xData[indices],
yData[indices],
@@ -1201,7 +1200,7 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
indices = result.getIndices(copy=False)
row, column = indices[0][0], indices[1][0]
eventDict = prepareImageSignal('left',
- item.getLegend(),
+ item.getName(),
'image',
column, row,
dataPos[0], dataPos[1],
@@ -1224,7 +1223,7 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
eventDict = prepareMarkerSignal(eventType,
'left',
- marker.getLegend(),
+ marker.getName(),
'marker',
marker.isDraggable(),
marker.isSelectable(),
@@ -1242,65 +1241,79 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
self.plot.setGraphCursorShape()
self.draggedItemRef = None
- def beginDrag(self, x, y):
+ def beginDrag(self, x, y, btn):
"""Handle begining of drag interaction
:param x: X position of the mouse in pixels
:param y: Y position of the mouse in pixels
+ :param str btn: The mouse button for which a drag is starting.
:return: True if drag is catched by an item, False otherwise
"""
- self._lastPos = self.plot.pixelToData(x, y)
- assert self._lastPos is not None
+ if btn == LEFT_BTN:
+ self._lastPos = self.plot.pixelToData(x, y)
+ assert self._lastPos is not None
- result = self.plot._pickTopMost(x, y, self.__isDraggableItem)
- item = result.getItem() if result is not None else None
+ result = self.plot._pickTopMost(x, y, self.__isDraggableItem)
+ item = result.getItem() if result is not None else None
- self.draggedItemRef = None if item is None else weakref.ref(item)
+ self.draggedItemRef = None if item is None else weakref.ref(item)
- if item is None:
- self.__terminateDrag()
- return False
+ if item is None:
+ self.__terminateDrag()
+ return False
- if isinstance(item, items.MarkerBase):
- self._signalMarkerMovingEvent('markerMoving', item, x, y)
+ if isinstance(item, items.MarkerBase):
+ self._signalMarkerMovingEvent('markerMoving', item, x, y)
+ item._startDrag()
- return True
+ return True
+ elif btn == MIDDLE_BTN:
+ self._pan.beginDrag(x, y, btn)
+ return True
- def drag(self, x, y):
- dataPos = self.plot.pixelToData(x, y)
- assert dataPos is not None
+ def drag(self, x, y, btn):
+ if btn == LEFT_BTN:
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
- item = None if self.draggedItemRef is None else self.draggedItemRef()
- if item is not None:
- item.drag(self._lastPos, dataPos)
+ item = None if self.draggedItemRef is None else self.draggedItemRef()
+ if item is not None:
+ item.drag(self._lastPos, dataPos)
- if isinstance(item, items.MarkerBase):
- self._signalMarkerMovingEvent('markerMoving', item, x, y)
+ if isinstance(item, items.MarkerBase):
+ self._signalMarkerMovingEvent('markerMoving', item, x, y)
- self._lastPos = dataPos
-
- def endDrag(self, startPos, endPos):
- item = None if self.draggedItemRef is None else self.draggedItemRef()
- if item is not None and isinstance(item, items.MarkerBase):
- posData = list(item.getPosition())
- if posData[0] is None:
- posData[0] = 1.
- if posData[1] is None:
- posData[1] = 1.
-
- eventDict = prepareMarkerSignal(
- 'markerMoved',
- 'left',
- item.getLegend(),
- 'marker',
- item.isDraggable(),
- item.isSelectable(),
- posData)
- self.plot.notify(**eventDict)
+ self._lastPos = dataPos
+ elif btn == MIDDLE_BTN:
+ self._pan.drag(x, y, btn)
- self.__terminateDrag()
+ def endDrag(self, startPos, endPos, btn):
+ if btn == LEFT_BTN:
+ item = None if self.draggedItemRef is None else self.draggedItemRef()
+ if isinstance(item, items.MarkerBase):
+ posData = list(item.getPosition())
+ if posData[0] is None:
+ posData[0] = 1.
+ if posData[1] is None:
+ posData[1] = 1.
+
+ eventDict = prepareMarkerSignal(
+ 'markerMoved',
+ 'left',
+ item.getLegend(),
+ 'marker',
+ item.isDraggable(),
+ item.isSelectable(),
+ posData)
+ self.plot.notify(**eventDict)
+ item._endDrag()
+
+ self.__terminateDrag()
+ elif btn == MIDDLE_BTN:
+ self._pan.endDrag(startPos, endPos, btn)
def cancel(self):
+ self._pan.cancel()
self.__terminateDrag()
@@ -1319,25 +1332,12 @@ class ItemsInteractionForCombo(ItemsInteraction):
result = self.machine.plot._pickTopMost(
x, y, self.__isItemSelectableOrDraggable)
if result is not None: # Request focus and handle interaction
- self.goto('clickOrDrag', x, y)
+ self.goto('clickOrDrag', x, y, btn)
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')
+ else:
+ return super().onPress(x, y, btn)
# FocusManager ################################################################
@@ -1350,11 +1350,12 @@ class FocusManager(StateMachine):
"""
class Idle(State):
def onPress(self, x, y, btn):
- for eventHandler in self.machine.eventHandlers:
- requestFocus = eventHandler.handleEvent('press', x, y, btn)
- if requestFocus:
- self.goto('focus', eventHandler, btn)
- break
+ if btn == LEFT_BTN:
+ for eventHandler in self.machine.eventHandlers:
+ requestFocus = eventHandler.handleEvent('press', x, y, btn)
+ if requestFocus:
+ self.goto('focus', eventHandler, btn)
+ break
def _processEvent(self, *args):
for eventHandler in self.machine.eventHandlers:
@@ -1366,7 +1367,8 @@ class FocusManager(StateMachine):
self._processEvent('move', x, y)
def onRelease(self, x, y, btn):
- self._processEvent('release', x, y, btn)
+ if btn == LEFT_BTN:
+ self._processEvent('release', x, y, btn)
def onWheel(self, x, y, angle):
self._processEvent('wheel', x, y, angle)
@@ -1376,18 +1378,24 @@ class FocusManager(StateMachine):
self.eventHandler = eventHandler
self.focusBtns = {btn}
+ def validate(self):
+ self.eventHandler.validate()
+ self.goto('idle')
+
def onPress(self, x, y, btn):
- self.focusBtns.add(btn)
- self.eventHandler.handleEvent('press', x, y, btn)
+ if btn == LEFT_BTN:
+ self.focusBtns.add(btn)
+ self.eventHandler.handleEvent('press', x, y, btn)
def onMove(self, x, y):
self.eventHandler.handleEvent('move', x, y)
def onRelease(self, x, y, btn):
- self.focusBtns.discard(btn)
- requestFocus = self.eventHandler.handleEvent('release', x, y, btn)
- if len(self.focusBtns) == 0 and not requestFocus:
- self.goto('idle')
+ if btn == LEFT_BTN:
+ self.focusBtns.discard(btn)
+ requestFocus = self.eventHandler.handleEvent('release', x, y, btn)
+ if len(self.focusBtns) == 0 and not requestFocus:
+ self.goto('idle')
def onWheel(self, x, y, angleInDegrees):
self.eventHandler.handleEvent('wheel', x, y, angleInDegrees)
@@ -1447,37 +1455,40 @@ class ZoomAndSelect(ItemsInteraction):
else:
self._zoom.click(x, y, btn)
- def beginDrag(self, x, y):
+ def beginDrag(self, x, y, btn):
"""Handle start drag and switching between zoom and item drag.
:param x: X position in pixels
:param y: Y position in pixels
+ :param str btn: The mouse button for which a drag is starting.
"""
- self._doZoom = not super(ZoomAndSelect, self).beginDrag(x, y)
+ self._doZoom = not super(ZoomAndSelect, self).beginDrag(x, y, btn)
if self._doZoom:
- self._zoom.beginDrag(x, y)
+ self._zoom.beginDrag(x, y, btn)
- def drag(self, x, y):
+ def drag(self, x, y, btn):
"""Handle drag, eventually forwarding to zoom.
:param x: X position in pixels
:param y: Y position in pixels
+ :param str btn: The mouse button for which a drag is in progress.
"""
if self._doZoom:
- return self._zoom.drag(x, y)
+ return self._zoom.drag(x, y, btn)
else:
- return super(ZoomAndSelect, self).drag(x, y)
+ return super(ZoomAndSelect, self).drag(x, y, btn)
- def endDrag(self, startPos, endPos):
+ def endDrag(self, startPos, endPos, btn):
"""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
+ :param str btn: The mouse button for which a drag is done.
"""
if self._doZoom:
- return self._zoom.endDrag(startPos, endPos)
+ return self._zoom.endDrag(startPos, endPos, btn)
else:
- return super(ZoomAndSelect, self).endDrag(startPos, endPos)
+ return super(ZoomAndSelect, self).endDrag(startPos, endPos, btn)
class PanAndSelect(ItemsInteraction):
@@ -1515,41 +1526,101 @@ class PanAndSelect(ItemsInteraction):
else:
self._pan.click(x, y, btn)
- def beginDrag(self, x, y):
+ def beginDrag(self, x, y, btn):
"""Handle start drag and switching between zoom and item drag.
:param x: X position in pixels
:param y: Y position in pixels
+ :param str btn: The mouse button for which a drag is starting.
"""
- self._doPan = not super(PanAndSelect, self).beginDrag(x, y)
+ self._doPan = not super(PanAndSelect, self).beginDrag(x, y, btn)
if self._doPan:
- self._pan.beginDrag(x, y)
+ self._pan.beginDrag(x, y, btn)
- def drag(self, x, y):
+ def drag(self, x, y, btn):
"""Handle drag, eventually forwarding to zoom.
:param x: X position in pixels
:param y: Y position in pixels
+ :param str btn: The mouse button for which a drag is in progress.
"""
if self._doPan:
- return self._pan.drag(x, y)
+ return self._pan.drag(x, y, btn)
else:
- return super(PanAndSelect, self).drag(x, y)
+ return super(PanAndSelect, self).drag(x, y, btn)
- def endDrag(self, startPos, endPos):
+ def endDrag(self, startPos, endPos, btn):
"""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
+ :param str btn: The mouse button for which a drag is done.
"""
if self._doPan:
- return self._pan.endDrag(startPos, endPos)
+ return self._pan.endDrag(startPos, endPos, btn)
else:
- return super(PanAndSelect, self).endDrag(startPos, endPos)
+ return super(PanAndSelect, self).endDrag(startPos, endPos, btn)
# Interaction mode control ####################################################
+# Mapping of draw modes: event handler
+_DRAW_MODES = {
+ 'polygon': SelectPolygon,
+ 'rectangle': SelectRectangle,
+ 'ellipse': SelectEllipse,
+ 'line': SelectLine,
+ 'vline': SelectVLine,
+ 'hline': SelectHLine,
+ 'polylines': SelectFreeLine,
+ 'pencil': DrawFreeHand,
+ }
+
+
+class DrawMode(FocusManager):
+ """Interactive mode for draw and select"""
+
+ def __init__(self, plot, shape, label, color, width):
+ eventHandlerClass = _DRAW_MODES[shape]
+ parameters = {
+ 'shape': shape,
+ 'label': label,
+ 'color': color,
+ 'width': width,
+ }
+ super().__init__((
+ Pan(plot, clickButtons=(), dragButtons=(MIDDLE_BTN,)),
+ eventHandlerClass(plot, parameters)))
+
+ def getDescription(self):
+ """Returns the dict describing this interactive mode"""
+ params = self.eventHandlers[1].parameters.copy()
+ params['mode'] = 'draw'
+ return params
+
+
+class DrawSelectMode(FocusManager):
+ """Interactive mode for draw and select"""
+
+ def __init__(self, plot, shape, label, color, width):
+ eventHandlerClass = _DRAW_MODES[shape]
+ parameters = {
+ 'shape': shape,
+ 'label': label,
+ 'color': color,
+ 'width': width,
+ }
+ super().__init__((
+ ItemsInteractionForCombo(plot),
+ eventHandlerClass(plot, parameters)))
+
+ def getDescription(self):
+ """Returns the dict describing this interactive mode"""
+ params = self.eventHandlers[1].parameters.copy()
+ params['mode'] = 'select-draw'
+ return params
+
+
class PlotInteraction(object):
"""Proxy to currently use state machine for interaction.
@@ -1582,26 +1653,15 @@ class PlotInteraction(object):
"""Returns the current interactive mode as a dict.
The returned dict contains at least the key 'mode'.
- Mode can be: 'draw', 'pan', 'select', 'zoom'.
+ Mode can be: 'draw', 'pan', 'select', 'select-draw', 'zoom'.
It can also contains extra keys (e.g., 'color') specific to a mode
as provided to :meth:`setInteractiveMode`.
"""
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, (DrawMode, DrawSelectMode)):
+ return self._eventHandler.getDescription()
elif isinstance(self._eventHandler, PanAndSelect):
return {'mode': 'pan'}
@@ -1609,6 +1669,13 @@ class PlotInteraction(object):
else:
return {'mode': 'select'}
+ def validate(self):
+ """Validate the current interaction if possible
+
+ If was designed to close the polygon interaction.
+ """
+ self._eventHandler.validate()
+
def setInteractiveMode(self, mode, color='black',
shape='polygon', label=None, width=None):
"""Switch the interactive mode.
@@ -1636,24 +1703,9 @@ class PlotInteraction(object):
color = colors.rgba(color)
if mode in ('draw', 'select-draw'):
- assert shape in self._DRAW_MODES
- eventHandlerClass = self._DRAW_MODES[shape]
- parameters = {
- 'shape': shape,
- 'label': label,
- 'color': color,
- 'width': width,
- }
- eventHandler = eventHandlerClass(plot, parameters)
-
self._eventHandler.cancel()
-
- if mode == 'draw':
- self._eventHandler = eventHandler
-
- else: # mode == 'select-draw'
- self._eventHandler = FocusManager(
- (ItemsInteractionForCombo(plot), eventHandler))
+ handlerClass = DrawMode if mode == 'draw' else DrawSelectMode
+ self._eventHandler = handlerClass(plot, shape, label, color, width)
elif mode == 'pan':
# Ignores color, shape and label
diff --git a/silx/gui/plot/PlotToolButtons.py b/silx/gui/plot/PlotToolButtons.py
index cd1a43f..3970896 100644
--- a/silx/gui/plot/PlotToolButtons.py
+++ b/silx/gui/plot/PlotToolButtons.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2020 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
@@ -261,17 +261,21 @@ class ProfileOptionToolButton(PlotToolButton):
self.sumAction = self._createAction('sum')
self.sumAction.triggered.connect(self.setSum)
self.sumAction.setIconVisibleInMenu(True)
+ self.sumAction.setCheckable(True)
+ self.sumAction.setChecked(True)
self.meanAction = self._createAction('mean')
self.meanAction.triggered.connect(self.setMean)
self.meanAction.setIconVisibleInMenu(True)
+ self.meanAction.setCheckable(True)
menu = qt.QMenu(self)
menu.addAction(self.sumAction)
menu.addAction(self.meanAction)
self.setMenu(menu)
self.setPopupMode(qt.QToolButton.InstantPopup)
- self.setMean()
+ self._method = 'mean'
+ self._update()
def _createAction(self, method):
icon = self.STATE[method, "icon"]
@@ -279,22 +283,39 @@ class ProfileOptionToolButton(PlotToolButton):
return qt.QAction(icon, text, self)
def setSum(self):
- """Configure the plot to use y-axis upward"""
- self._method = 'sum'
- self.sigMethodChanged.emit(self._method)
- self._update()
+ self.setMethod('sum')
def _update(self):
icon = self.STATE[self._method, "icon"]
toolTip = self.STATE[self._method, "state"]
self.setIcon(icon)
self.setToolTip(toolTip)
+ self.sumAction.setChecked(self._method == "sum")
+ self.meanAction.setChecked(self._method == "mean")
def setMean(self):
- """Configure the plot to use y-axis downward"""
- self._method = 'mean'
- self.sigMethodChanged.emit(self._method)
- self._update()
+ self.setMethod('mean')
+
+ def setMethod(self, method):
+ """Set the method to use.
+
+ :param str method: Either 'sum' or 'mean'
+ """
+ if method != self._method:
+ if method in ('sum', 'mean'):
+ self._method = method
+ self.sigMethodChanged.emit(self._method)
+ self._update()
+ else:
+ _logger.warning(
+ "Unsupported method '%s'. Setting ignored.", method)
+
+ def getMethod(self):
+ """Returns the current method in use (See :meth:`setMethod`).
+
+ :rtype: str
+ """
+ return self._method
class ProfileToolButton(PlotToolButton):
@@ -319,13 +340,20 @@ class ProfileToolButton(PlotToolButton):
super(ProfileToolButton, self).__init__(parent=parent, plot=plot)
+ self._dimension = 1
+
profile1DAction = self._createAction(1)
profile1DAction.triggered.connect(self.computeProfileIn1D)
profile1DAction.setIconVisibleInMenu(True)
+ profile1DAction.setCheckable(True)
+ profile1DAction.setChecked(True)
+ self._profile1DAction = profile1DAction
profile2DAction = self._createAction(2)
profile2DAction.triggered.connect(self.computeProfileIn2D)
profile2DAction.setIconVisibleInMenu(True)
+ profile2DAction.setCheckable(True)
+ self._profile2DAction = profile2DAction
menu = qt.QMenu(self)
menu.addAction(profile1DAction)
@@ -333,6 +361,7 @@ class ProfileToolButton(PlotToolButton):
self.setMenu(menu)
self.setPopupMode(qt.QToolButton.InstantPopup)
menu.setTitle('Select profile dimension')
+ self.computeProfileIn1D()
def _createAction(self, profileDimension):
icon = self.STATE[profileDimension, "icon"]
@@ -343,7 +372,10 @@ class ProfileToolButton(PlotToolButton):
"""Update icon in toolbar, emit number of dimensions for profile"""
self.setIcon(self.STATE[profileDimension, "icon"])
self.setToolTip(self.STATE[profileDimension, "state"])
+ self._dimension = profileDimension
self.sigDimensionChanged.emit(profileDimension)
+ self._profile1DAction.setChecked(profileDimension == 1)
+ self._profile2DAction.setChecked(profileDimension == 2)
def computeProfileIn1D(self):
self._profileDimensionChanged(1)
@@ -351,6 +383,24 @@ class ProfileToolButton(PlotToolButton):
def computeProfileIn2D(self):
self._profileDimensionChanged(2)
+ def setDimension(self, dimension):
+ """Set the selected dimension"""
+ assert dimension in [1, 2]
+ if self._dimension == dimension:
+ return
+ if dimension == 1:
+ self.computeProfileIn1D()
+ elif dimension == 2:
+ self.computeProfileIn2D()
+ else:
+ _logger.warning("Unsupported dimension '%s'. Setting ignored.", dimension)
+
+ def getDimension(self):
+ """Get the selected dimension.
+
+ :rtype: int (1 or 2)
+ """
+ return self._dimension
class _SymbolToolButtonBase(PlotToolButton):
@@ -370,7 +420,7 @@ class _SymbolToolButtonBase(PlotToolButton):
"""
slider = qt.QSlider(qt.Qt.Horizontal)
slider.setRange(1, 20)
- slider.setValue(config.DEFAULT_PLOT_SYMBOL_SIZE)
+ slider.setValue(int(config.DEFAULT_PLOT_SYMBOL_SIZE))
slider.setTracking(False)
slider.valueChanged.connect(self._sizeChanged)
widgetAction = qt.QWidgetAction(menu)
@@ -399,7 +449,7 @@ class _SymbolToolButtonBase(PlotToolButton):
if plot is None:
return
- for item in plot._getItems(withhidden=True):
+ for item in plot.getItems():
if isinstance(item, SymbolMixIn):
item.setSymbolSize(value)
@@ -412,7 +462,7 @@ class _SymbolToolButtonBase(PlotToolButton):
if plot is None:
return
- for item in plot._getItems(withhidden=True):
+ for item in plot.getItems():
if isinstance(item, SymbolMixIn):
item.setSymbol(marker)
@@ -459,12 +509,40 @@ class ScatterVisualizationToolButton(_SymbolToolButtonBase):
# Add visualization modes
for mode in Scatter.supportedVisualizations():
- name = mode.value.capitalize()
- action = qt.QAction(name, menu)
- action.setCheckable(False)
- action.triggered.connect(
- functools.partial(self._visualizationChanged, mode))
- menu.addAction(action)
+ if mode is not Scatter.Visualization.BINNED_STATISTIC:
+ name = mode.value.capitalize()
+ action = qt.QAction(name, menu)
+ action.setCheckable(False)
+ action.triggered.connect(
+ functools.partial(self._visualizationChanged, mode, None))
+ menu.addAction(action)
+
+ if Scatter.Visualization.BINNED_STATISTIC in Scatter.supportedVisualizations():
+ reductions = Scatter.supportedVisualizationParameterValues(
+ Scatter.VisualizationParameter.BINNED_STATISTIC_FUNCTION)
+ if reductions:
+ submenu = menu.addMenu('Binned Statistic')
+ for reduction in reductions:
+ name = reduction.capitalize()
+ action = qt.QAction(name, menu)
+ action.setCheckable(False)
+ action.triggered.connect(functools.partial(
+ self._visualizationChanged,
+ Scatter.Visualization.BINNED_STATISTIC,
+ {Scatter.VisualizationParameter.BINNED_STATISTIC_FUNCTION: reduction}))
+ submenu.addAction(action)
+
+ submenu.addSeparator()
+ binsmenu = submenu.addMenu('N Bins')
+
+ slider = qt.QSlider(qt.Qt.Horizontal)
+ slider.setRange(10, 1000)
+ slider.setValue(100)
+ slider.setTracking(False)
+ slider.valueChanged.connect(self._binningChanged)
+ widgetAction = qt.QWidgetAction(binsmenu)
+ widgetAction.setDefaultWidget(slider)
+ binsmenu.addAction(widgetAction)
menu.addSeparator()
@@ -477,16 +555,38 @@ class ScatterVisualizationToolButton(_SymbolToolButtonBase):
self.setMenu(menu)
self.setPopupMode(qt.QToolButton.InstantPopup)
- def _visualizationChanged(self, mode):
+ def _visualizationChanged(self, mode, parameters=None):
"""Handle change of visualization mode.
:param ScatterVisualizationMixIn.Visualization mode:
The visualization mode to use for scatter
+ :param Union[dict,None] parameters:
+ Dict of VisualizationParameter: parameter_value to set
+ with the visualization.
"""
plot = self.plot()
if plot is None:
return
- for item in plot._getItems(withhidden=True):
+ for item in plot.getItems():
if isinstance(item, Scatter):
+ if parameters:
+ for parameter, value in parameters.items():
+ item.setVisualizationParameter(parameter, value)
item.setVisualization(mode)
+
+ def _binningChanged(self, value):
+ """Handle change of binning.
+
+ :param int value: The number of bin on each dimension.
+ """
+ plot = self.plot()
+ if plot is None:
+ return
+
+ for item in plot.getItems():
+ if isinstance(item, Scatter):
+ item.setVisualizationParameter(
+ Scatter.VisualizationParameter.BINNED_STATISTIC_SHAPE,
+ (value, value))
+ item.setVisualization(Scatter.Visualization.BINNED_STATISTIC)
diff --git a/silx/gui/plot/PlotWidget.py b/silx/gui/plot/PlotWidget.py
index e47249e..9f9f846 100755
--- a/silx/gui/plot/PlotWidget.py
+++ b/silx/gui/plot/PlotWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2020 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -49,7 +49,7 @@ import numpy
import silx
from silx.utils.weakref import WeakMethodProxy
from silx.utils.property import classproperty
-from silx.utils.deprecation import deprecated
+from silx.utils.deprecation import deprecated, deprecated_warning
try:
# Import matplotlib now to init matplotlib our way
from . import matplotlib
@@ -192,6 +192,12 @@ class PlotWidget(qt.QMainWindow):
It provides the item that will be removed.
"""
+ sigItemRemoved = qt.Signal(items.Item)
+ """Signal emitted right after an item was removed from the plot.
+
+ It provides the item that was removed.
+ """
+
sigVisibilityChanged = qt.Signal(bool)
"""Signal emitted when the widget becomes visible (or invisible).
This happens when the widget is hidden or shown.
@@ -215,8 +221,10 @@ class PlotWidget(qt.QMainWindow):
else:
self.setWindowTitle('PlotWidget')
- self._backend = None
- self._setBackend(backend)
+ # Init the backend
+ if backend is None:
+ backend = silx.config.DEFAULT_PLOT_BACKEND
+ self._backend = self.__getBackendClass(backend)(self, self)
self.setCallback() # set _callback
@@ -266,6 +274,7 @@ class PlotWidget(qt.QMainWindow):
self._eventHandler = PlotInteraction.PlotInteraction(self)
self._eventHandler.setInteractiveMode('zoom', color=(0., 0., 0., 1.))
+ self._previousDefaultMode = "zoom", True
self._pressedButtons = [] # Currently pressed mouse buttons
@@ -299,7 +308,7 @@ class PlotWidget(qt.QMainWindow):
If multiple backends are provided, the first available one is used.
- :param Union[str,BackendBase,Iterable] backend:
+ :param Union[str,BackendBase,List[Union[str,BackendBase]]] backend:
The name of the backend or its class or an iterable of those.
:rtype: BackendBase
:raise ValueError: In case the backend is not supported
@@ -316,15 +325,22 @@ class PlotWidget(qt.QMainWindow):
BackendMatplotlibQt as backendClass
except ImportError:
_logger.debug("Backtrace", exc_info=True)
- raise ImportError("matplotlib backend is not available")
+ raise RuntimeError("matplotlib backend is not available")
elif backend in ('gl', 'opengl'):
+ from ..utils.glutils import isOpenGLAvailable
+ checkOpenGL = isOpenGLAvailable(version=(2, 1), runtimeCheck=False)
+ if not checkOpenGL:
+ _logger.debug("OpenGL check failed")
+ raise RuntimeError(
+ "OpenGL backend is not available: %s" % checkOpenGL.error)
+
try:
from .backends.BackendOpenGL import \
BackendOpenGL as backendClass
except ImportError:
_logger.debug("Backtrace", exc_info=True)
- raise ImportError("OpenGL backend is not available")
+ raise RuntimeError("OpenGL backend is not available")
elif backend == 'none':
from .backends.BackendBase import BackendBase as backendClass
@@ -338,25 +354,13 @@ class PlotWidget(qt.QMainWindow):
for b in backend:
try:
return self.__getBackendClass(b)
- except ImportError:
+ except RuntimeError:
pass
else: # No backend was found
- raise ValueError("No supported backend was found")
+ raise RuntimeError("None of the request backends are available")
raise ValueError("Backend not supported %s" % str(backend))
- def _setBackend(self, backend):
- """Setup a new backend
-
- :param backend: Either a str defining the backend to use
- """
- assert(self._backend is None)
-
- if backend is None:
- backend = silx.config.DEFAULT_PLOT_BACKEND
-
- self._backend = self.__getBackendClass(backend)(self, self)
-
# TODO: Can be removed for silx 0.10
@staticmethod
@deprecated(replacement="silx.config.DEFAULT_PLOT_BACKEND", since_version="0.8", skip_backtrace_count=2)
@@ -384,6 +388,27 @@ class PlotWidget(qt.QMainWindow):
"""
return self._dirty
+ # Default Qt context menu
+
+ def contextMenuEvent(self, event):
+ """Override QWidget.contextMenuEvent to implement the context menu"""
+ menu = qt.QMenu(self)
+ from .actions.control import ZoomBackAction # Avoid cyclic import
+ zoomBackAction = ZoomBackAction(plot=self, parent=menu)
+ menu.addAction(zoomBackAction)
+
+ mode = self.getInteractiveMode()
+ if "shape" in mode and mode["shape"] == "polygon":
+ from .actions.control import ClosePolygonInteractionAction # Avoid cyclic import
+ action = ClosePolygonInteractionAction(plot=self, parent=menu)
+ menu.addAction(action)
+
+ # Make sure the plot is updated, especially when the plot is in
+ # draw interaction mode
+ menu.aboutToHide.connect(self.__simulateMouseMove)
+
+ menu.exec_(event.globalPos())
+
def _setDirtyPlot(self, overlayOnly=False):
"""Mark the plot as needing redraw
@@ -537,7 +562,7 @@ class PlotWidget(qt.QMainWindow):
xMin = yMinLeft = yMinRight = float('nan')
xMax = yMaxLeft = yMaxRight = float('nan')
- for item in self._content.values():
+ for item in self.getItems():
if item.isVisible():
bounds = item.getBounds()
if bounds is not None:
@@ -586,43 +611,70 @@ class PlotWidget(qt.QMainWindow):
# Content management
- @staticmethod
- def _itemKey(item):
- """Build the key of given :class:`Item` in the plot
-
- :param Item item: The item to make the key from
- :return: (legend, kind)
- :rtype: (str, str)
- """
- if isinstance(item, items.Curve):
- kind = 'curve'
- elif isinstance(item, items.ImageBase):
- kind = 'image'
- elif isinstance(item, items.Scatter):
- kind = 'scatter'
- elif isinstance(item, (items.Marker,
- items.XMarker, items.YMarker)):
- kind = 'marker'
- elif isinstance(item, (items.Shape, items.BoundingRect)):
- kind = 'item'
- elif isinstance(item, items.Histogram):
- kind = 'histogram'
- else:
- raise ValueError('Unsupported item type %s' % type(item))
+ _KIND_TO_CLASSES = {
+ 'curve': (items.Curve,),
+ 'image': (items.ImageBase,),
+ 'scatter': (items.Scatter,),
+ 'marker': (items.MarkerBase,),
+ 'item': (items.Shape,
+ items.BoundingRect,
+ items.XAxisExtent,
+ items.YAxisExtent),
+ 'histogram': (items.Histogram,),
+ }
+ """Mapping kind to item classes of this kind"""
+
+ @classmethod
+ def _itemKind(cls, item):
+ """Returns the "kind" of a given item
+
+ :param Item item: The item get the kind
+ :rtype: str
+ """
+ for kind, itemClasses in cls._KIND_TO_CLASSES.items():
+ if isinstance(item, itemClasses):
+ return kind
+ raise ValueError('Unsupported item type %s' % type(item))
- return item.getLegend(), kind
+ def _notifyContentChanged(self, item):
+ self.notify('contentChanged', action='add',
+ kind=self._itemKind(item), legend=item.getName())
- def _add(self, item):
- """Add the given :class:`Item` to the plot.
+ def _itemRequiresUpdate(self, item):
+ """Called by items in the plot for asynchronous update
- :param Item item: The item to append to the plot content
+ :param Item item: The item that required update
"""
- key = self._itemKey(item)
- if key in self._content:
- raise RuntimeError('Item already in the plot')
+ assert item.getPlot() == self
+ # Put item at the end of the list
+ if item in self._contentToUpdate:
+ self._contentToUpdate.remove(item)
+ self._contentToUpdate.append(item)
+ self._setDirtyPlot(overlayOnly=item.isOverlay())
+
+ def addItem(self, item=None, *args, **kwargs):
+ """Add an item to the plot content.
+
+ :param ~silx.gui.plot.items.Item item: The item to add.
+ :raises ValueError: If item is already in the plot.
+ """
+ if not isinstance(item, items.Item):
+ deprecated_warning(
+ 'Function',
+ 'addItem',
+ replacement='addShape',
+ since_version='0.13')
+ if item is None and not args: # Only kwargs
+ return self.addShape(**kwargs)
+ else:
+ return self.addShape(item, *args, **kwargs)
+
+ assert not args and not kwargs
+ if item in self.getItems():
+ raise ValueError('Item already in the plot')
# Add item to plot
- self._content[key] = item
+ self._content[(item.getName(), self._itemKind(item))] = item
item._setPlot(self)
self._itemRequiresUpdate(item)
if isinstance(item, items.DATA_ITEMS):
@@ -631,22 +683,29 @@ class PlotWidget(qt.QMainWindow):
self._notifyContentChanged(item)
self.sigItemAdded.emit(item)
- def _notifyContentChanged(self, item):
- legend, kind = self._itemKey(item)
- self.notify('contentChanged', action='add', kind=kind, legend=legend)
-
- def _remove(self, item):
- """Remove the given :class:`Item` from the plot.
+ def removeItem(self, item):
+ """Remove the item from the plot.
- :param Item item: The item to remove from the plot content
+ :param ~silx.gui.plot.items.Item item: Item to remove from the plot.
+ :raises ValueError: If item is not in the plot.
"""
- key = self._itemKey(item)
- if key not in self._content:
- raise RuntimeError('Item not in the plot')
+ if not isinstance(item, items.Item): # Previous method usage
+ deprecated_warning(
+ 'Function',
+ 'removeItem',
+ replacement='remove(legend, kind="item")',
+ since_version='0.13')
+ if item is None:
+ return
+ self.remove(item, kind='item')
+ return
+
+ if item not in self.getItems():
+ raise ValueError('Item not in the plot')
self.sigItemAboutToBeRemoved.emit(item)
- legend, kind = key
+ kind = self._itemKind(item)
if kind in self._ACTIVE_ITEM_KINDS:
if self._getActiveItem(kind) == item:
@@ -654,7 +713,7 @@ class PlotWidget(qt.QMainWindow):
self._setActiveItem(kind, None)
# Remove item from plot
- self._content.pop(key)
+ self._content.pop((item.getName(), kind))
if item in self._contentToUpdate:
self._contentToUpdate.remove(item)
if item.isVisible():
@@ -668,20 +727,25 @@ class PlotWidget(qt.QMainWindow):
withhidden=True)):
self._resetColorAndStyle()
+ self.sigItemRemoved.emit(item)
+
self.notify('contentChanged', action='remove',
- kind=kind, legend=legend)
+ kind=kind, legend=item.getName())
- def _itemRequiresUpdate(self, item):
- """Called by items in the plot for asynchronous update
+ @deprecated(replacement='addItem', since_version='0.13')
+ def _add(self, item):
+ return self.addItem(item)
- :param Item item: The item that required update
+ @deprecated(replacement='removeItem', since_version='0.13')
+ def _remove(self, item):
+ return self.removeItem(item)
+
+ def getItems(self):
+ """Returns the list of items in the plot
+
+ :rtype: List[silx.gui.plot.items.Item]
"""
- assert item.getPlot() == self
- # Pu item at the end of the list
- if item in self._contentToUpdate:
- self._contentToUpdate.remove(item)
- self._contentToUpdate.append(item)
- self._setDirtyPlot(overlayOnly=item.isOverlay())
+ return tuple(self._content.values())
@contextmanager
def _muteActiveItemChangedSignal(self):
@@ -838,7 +902,7 @@ class PlotWidget(qt.QMainWindow):
if curve is None:
# No previous curve, create a default one and add it to the plot
curve = items.Curve() if histogram is None else items.Histogram()
- curve._setLegend(legend)
+ curve.setName(legend)
# Set default color, linestyle and symbol
default_color, default_linestyle = self._getColorAndStyle()
curve.setColor(default_color)
@@ -892,21 +956,21 @@ class PlotWidget(qt.QMainWindow):
if replace: # Then remove all other curves
for c in self.getAllCurves(withhidden=True):
if c is not curve:
- self._remove(c)
+ self.removeItem(c)
if mustBeAdded:
- self._add(curve)
+ self.addItem(curve)
else:
self._notifyContentChanged(curve)
if wasActive:
- self.setActiveCurve(curve.getLegend())
+ self.setActiveCurve(curve.getName())
elif self.getActiveCurveSelectionMode() == "legacy":
if self.getActiveCurve(just_legend=True) is None:
if len(self.getAllCurves(just_legend=True,
withhidden=False)) == 1:
if curve.isVisible():
- self.setActiveCurve(curve.getLegend())
+ self.setActiveCurve(curve.getName())
if resetzoom:
# We ask for a zoom reset in order to handle the plot scaling
@@ -971,7 +1035,7 @@ class PlotWidget(qt.QMainWindow):
# No previous histogram, create a default one and
# add it to the plot
histo = items.Histogram()
- histo._setLegend(legend)
+ histo.setName(legend)
histo.setColor(self._getColorAndStyle()[0])
# Override previous/default values with provided ones
@@ -987,7 +1051,7 @@ class PlotWidget(qt.QMainWindow):
align=align, copy=copy)
if mustBeAdded:
- self._add(histo)
+ self.addItem(histo)
else:
self._notifyContentChanged(histo)
@@ -1071,7 +1135,7 @@ class PlotWidget(qt.QMainWindow):
# Update a data image with RGBA image or the other way around:
# Remove previous image
# In this case, we don't retrieve defaults from the previous image
- self._remove(image)
+ self.removeItem(image)
image = None
mustBeAdded = image is None
@@ -1082,7 +1146,7 @@ class PlotWidget(qt.QMainWindow):
image.setColormap(self.getDefaultColormap())
else:
image = items.ImageRgba()
- image._setLegend(legend)
+ image.setName(legend)
# Do not emit sigActiveImageChanged,
# it will be sent once with _setActiveItem
@@ -1121,10 +1185,10 @@ class PlotWidget(qt.QMainWindow):
if replace:
for img in self.getAllImages():
if img is not image:
- self._remove(img)
+ self.removeItem(img)
if mustBeAdded:
- self._add(image)
+ self.addItem(image)
else:
self._notifyContentChanged(image)
@@ -1198,7 +1262,7 @@ class PlotWidget(qt.QMainWindow):
if scatter is None:
# No previous scatter, create a default one and add it to the plot
scatter = items.Scatter()
- scatter._setLegend(legend)
+ scatter.setName(legend)
scatter.setColormap(self.getDefaultColormap())
# Do not emit sigActiveScatterChanged,
@@ -1231,16 +1295,18 @@ class PlotWidget(qt.QMainWindow):
scatter.setData(x, y, value, xerror, yerror, copy=copy)
if mustBeAdded:
- self._add(scatter)
+ self.addItem(scatter)
else:
self._notifyContentChanged(scatter)
- if len(self._getItems(kind="scatter")) == 1 or wasActive:
- self._setActiveItem('scatter', scatter.getLegend())
+ scatters = [item for item in self.getItems()
+ if isinstance(item, items.Scatter) and item.isVisible()]
+ if len(scatters) == 1 or wasActive:
+ self._setActiveItem('scatter', scatter.getName())
return legend
- def addItem(self, xdata, ydata, legend=None, info=None,
+ def addShape(self, xdata, ydata, legend=None, info=None,
replace=False,
shape="polygon", color='black', fill=True,
overlay=False, z=None, linestyle="-", linewidth=1.0,
@@ -1295,7 +1361,7 @@ class PlotWidget(qt.QMainWindow):
self.remove(legend, kind='item')
item = items.Shape(shape)
- item._setLegend(legend)
+ item.setName(legend)
item.setInfo(info)
item.setColor(color)
item.setFill(fill)
@@ -1306,7 +1372,7 @@ class PlotWidget(qt.QMainWindow):
item.setLineWidth(linewidth)
item.setLineBgColor(linebgcolor)
- self._add(item)
+ self.addItem(item)
return legend
@@ -1324,8 +1390,8 @@ class PlotWidget(qt.QMainWindow):
:meth:`addXMarker` without legend argument adds two markers with
different identifying legends.
- :param float x: Position of the marker on the X axis in data
- coordinates
+ :param x: Position of the marker on the X axis in data coordinates
+ :type x: Union[None, float]
:param str legend: Legend associated to the marker to identify it
:param str text: Text to display on the marker.
:param str color: Color of the marker, e.g., 'blue', 'b', '#FF0000'
@@ -1467,7 +1533,8 @@ class PlotWidget(qt.QMainWindow):
assert (x, y) != (None, None)
if legend is None: # Find an unused legend
- markerLegends = self._getAllMarkers(just_legend=True)
+ markerLegends = [item.getName() for item in self.getItems()
+ if isinstance(item, items.MarkerBase)]
for index in itertools.count():
legend = "Unnamed Marker %d" % index
if legend not in markerLegends:
@@ -1486,14 +1553,14 @@ class PlotWidget(qt.QMainWindow):
if marker is not None and not isinstance(marker, markerClass):
_logger.warning('Adding marker with same legend'
' but different type replaces it')
- self._remove(marker)
+ self.removeItem(marker)
marker = None
mustBeAdded = marker is None
if marker is None:
# No previous marker, create one
marker = markerClass()
- marker._setLegend(legend)
+ marker.setName(legend)
if text is not None:
marker.setText(text)
@@ -1514,7 +1581,7 @@ class PlotWidget(qt.QMainWindow):
marker.setPosition(x, y)
if mustBeAdded:
- self._add(marker)
+ self.addItem(marker)
else:
self._notifyContentChanged(marker)
@@ -1577,7 +1644,7 @@ class PlotWidget(qt.QMainWindow):
By default, it removes all kind of elements.
:type kind: str or tuple of str to specify multiple kinds.
"""
- if kind is 'all': # Replace all by tuple of all kinds
+ if kind == 'all': # Replace all by tuple of all kinds
kind = self.ITEM_KINDS
if kind in self.ITEM_KINDS: # Kind is a str, make it a tuple
@@ -1589,16 +1656,17 @@ class PlotWidget(qt.QMainWindow):
if legend is None: # This is a clear
# Clear each given kind
for aKind in kind:
- for legend in self._getItems(
- kind=aKind, just_legend=True, withhidden=True):
- self.remove(legend=legend, kind=aKind)
+ for item in self.getItems():
+ if (isinstance(item, self._KIND_TO_CLASSES[aKind]) and
+ item.getPlot() is self): # Make sure item is still in the plot
+ self.removeItem(item)
else: # This is removing a single element
# Remove each given kind
for aKind in kind:
item = self._getItem(aKind, legend)
if item is not None:
- self._remove(item)
+ self.removeItem(item)
def removeCurve(self, legend):
"""Remove the curve associated to legend from the graph.
@@ -1618,15 +1686,6 @@ class PlotWidget(qt.QMainWindow):
return
self.remove(legend, kind='image')
- def removeItem(self, legend):
- """Remove the item associated to legend from the graph.
-
- :param str legend: The legend associated to the item to be deleted
- """
- if legend is None:
- return
- self.remove(legend, kind='item')
-
def removeMarker(self, legend):
"""Remove the marker associated to legend from the graph.
@@ -1640,7 +1699,9 @@ class PlotWidget(qt.QMainWindow):
def clear(self):
"""Remove everything from the plot."""
- self.remove()
+ for item in self.getItems():
+ if item.getPlot() is self: # Make sure item is still in the plot
+ self.removeItem(item)
def clearCurves(self):
"""Remove all the curves from the plot."""
@@ -1855,7 +1916,7 @@ class PlotWidget(qt.QMainWindow):
withhidden=False)
if len(curves) == 1:
if curves[0].isVisible():
- self.setActiveCurve(curves[0].getLegend())
+ self.setActiveCurve(curves[0].getName())
def getActiveCurveSelectionMode(self):
"""Returns the current selection mode.
@@ -1888,6 +1949,27 @@ class PlotWidget(qt.QMainWindow):
"""
return self._setActiveItem(kind='image', legend=legend)
+ def getActiveScatter(self, just_legend=False):
+ """Returns the currently active scatter.
+
+ It returns None in case of not having an active scatter.
+
+ :param bool just_legend: True to get the legend of the scatter,
+ False (the default) to get the scatter data
+ and info.
+ :return: Active scatter's legend or corresponding scatter object
+ :rtype: str, :class:`.items.Scatter` or None
+ """
+ return self._getActiveItem(kind='scatter', just_legend=just_legend)
+
+ def setActiveScatter(self, legend):
+ """Make the scatter associated to legend the active scatter.
+
+ :param str legend: The legend associated to the scatter
+ or None to have no active scatter.
+ """
+ return self._setActiveItem(kind='scatter', legend=legend)
+
def _getActiveItem(self, kind, just_legend=False):
"""Return the currently active item of that kind if any
@@ -1901,14 +1983,11 @@ class PlotWidget(qt.QMainWindow):
if self._activeLegend[kind] is None:
return None
- if (self._activeLegend[kind], kind) not in self._content:
- self._activeLegend[kind] = None
+ item = self._getItem(kind, self._activeLegend[kind])
+ if item is None:
return None
- if just_legend:
- return self._activeLegend[kind]
- else:
- return self._getItem(kind, self._activeLegend[kind])
+ return item.getName() if just_legend else item
def _setActiveItem(self, kind, legend):
"""Make the curve associated to legend the active curve.
@@ -1974,7 +2053,7 @@ class PlotWidget(qt.QMainWindow):
if oldActiveItem is None:
oldActiveLegend = None
else:
- oldActiveLegend = oldActiveItem.getLegend()
+ oldActiveLegend = oldActiveItem.getName()
self.notify(
'active' + kind[0].upper() + kind[1:] + 'Changed',
updated=oldActiveLegend != activeLegend,
@@ -1991,22 +2070,15 @@ class PlotWidget(qt.QMainWindow):
if not self.__muteActiveItemChanged:
item = self.sender()
if item is not None:
- legend, kind = self._itemKey(item)
+ kind = self._itemKind(item)
self.notify(
'active' + kind[0].upper() + kind[1:] + 'Changed',
updated=False,
- previous=legend,
- legend=legend)
+ previous=item.getName(),
+ legend=item.getName())
# Getters
- def getItems(self):
- """Returns the list of items in the plot
-
- :rtype: List[silx.gui.plot.items.Item]
- """
- return tuple(self._content.values())
-
def getAllCurves(self, just_legend=False, withhidden=False):
"""Returns all curves legend or info and data.
@@ -2023,9 +2095,10 @@ class PlotWidget(qt.QMainWindow):
:return: list of curves' legend or :class:`.items.Curve`
:rtype: list of str or list of :class:`.items.Curve`
"""
- return self._getItems(kind='curve',
- just_legend=just_legend,
- withhidden=withhidden)
+ curves = [item for item in self.getItems() if
+ isinstance(item, items.Curve) and
+ (withhidden or item.isVisible())]
+ return [curve.getName() for curve in curves] if just_legend else curves
def getCurve(self, legend=None):
"""Get the object describing a specific curve.
@@ -2056,9 +2129,9 @@ class PlotWidget(qt.QMainWindow):
:return: list of images' legend or :class:`.items.ImageBase`
:rtype: list of str or list of :class:`.items.ImageBase`
"""
- return self._getItems(kind='image',
- just_legend=just_legend,
- withhidden=True)
+ images = [item for item in self.getItems()
+ if isinstance(item, items.ImageBase)]
+ return [image.getName() for image in images] if just_legend else images
def getImage(self, legend=None):
"""Get the object describing a specific image.
@@ -2101,6 +2174,7 @@ class PlotWidget(qt.QMainWindow):
"""
return self._getItem(kind='histogram', legend=legend)
+ @deprecated(replacement='getItems', since_version='0.13')
def _getItems(self, kind=ITEM_KINDS, just_legend=False, withhidden=False):
"""Retrieve all items of a kind in the plot
@@ -2115,7 +2189,7 @@ class PlotWidget(qt.QMainWindow):
:param bool withhidden: False (default) to skip hidden curves.
:return: list of legends or item objects
"""
- if kind is 'all': # Replace all by tuple of all kinds
+ if kind == 'all': # Replace all by tuple of all kinds
kind = self.ITEM_KINDS
if kind in self.ITEM_KINDS: # Kind is a str, make it a tuple
@@ -2125,9 +2199,10 @@ class PlotWidget(qt.QMainWindow):
assert aKind in self.ITEM_KINDS
output = []
- for (legend, type_), item in self._content.items():
+ for item in self.getItems():
+ type_ = self._itemKind(item)
if type_ in kind and (withhidden or item.isVisible()):
- output.append(legend if just_legend else item)
+ output.append(item.getName() if just_legend else item)
return output
def _getItem(self, kind, legend=None):
@@ -2151,8 +2226,9 @@ class PlotWidget(qt.QMainWindow):
if item is not None: # Return active item if available
return item
# Return last visible item if any
- allItems = self._getItems(
- kind=kind, just_legend=False, withhidden=False)
+ itemClasses = self._KIND_TO_CLASSES[kind]
+ allItems = [item for item in self.getItems()
+ if isinstance(item, itemClasses) and item.isVisible()]
return allItems[-1] if allItems else None
# Limits
@@ -2911,16 +2987,35 @@ class PlotWidget(qt.QMainWindow):
"""
self._backend.setGraphCursorShape(cursor)
+ @deprecated(replacement='getItems', since_version='0.13')
def _getAllMarkers(self, just_legend=False):
- """Returns all markers' legend or objects
+ markers = [item for item in self.getItems() if isinstance(item, items.MarkerBase)]
+ if just_legend:
+ return [marker.getName() for marker in markers]
+ else:
+ return markers
- :param bool just_legend: True to get the legend of the markers,
- False (the default) to get marker objects.
- :return: list of legend of list of marker objects
- :rtype: list of str or list of marker objects
+ def _getMarkerAt(self, x, y):
+ """Return the most interactive marker at a location, else None
+
+ :param float x: X position in pixels
+ :param float y: Y position in pixels
+ :rtype: None of marker object
"""
- return self._getItems(
- kind='marker', just_legend=just_legend, withhidden=True)
+ def checkDraggable(item):
+ return isinstance(item, items.MarkerBase) and item.isDraggable()
+ def checkSelectable(item):
+ return isinstance(item, items.MarkerBase) and item.isSelectable()
+ def check(item):
+ return isinstance(item, items.MarkerBase)
+
+ result = self._pickTopMost(x, y, checkDraggable)
+ if not result:
+ result = self._pickTopMost(x, y, checkSelectable)
+ if not result:
+ result = self._pickTopMost(x, y, check)
+ marker = result.getItem() if result is not None else None
+ return marker
def _getMarker(self, legend=None):
"""Get the object describing a specific marker.
@@ -3061,12 +3156,21 @@ class PlotWidget(qt.QMainWindow):
"""Returns the current interactive mode as a dict.
The returned dict contains at least the key 'mode'.
- Mode can be: 'draw', 'pan', 'select', 'zoom'.
+ Mode can be: 'draw', 'pan', 'select', 'select-draw', 'zoom'.
It can also contains extra keys (e.g., 'color') specific to a mode
as provided to :meth:`setInteractiveMode`.
"""
return self._eventHandler.getInteractiveMode()
+ def resetInteractiveMode(self):
+ """Reset the interactive mode to use the previous basic interactive
+ mode used.
+
+ It can be one of "zoom" or "pan".
+ """
+ mode, zoomOnWheel = self._previousDefaultMode
+ self.setInteractiveMode(mode=mode, zoomOnWheel=zoomOnWheel)
+
def setInteractiveMode(self, mode, color='black',
shape='polygon', label=None,
zoomOnWheel=True, source=None, width=None):
@@ -3092,6 +3196,8 @@ class PlotWidget(qt.QMainWindow):
"""
self._eventHandler.setInteractiveMode(mode, color, shape, label, width)
self._eventHandler.zoomOnWheel = zoomOnWheel
+ if mode in ["pan", "zoom"]:
+ self._previousDefaultMode = mode, zoomOnWheel
self.notify(
'interactiveModeChanged', source=source)
@@ -3133,6 +3239,16 @@ class PlotWidget(qt.QMainWindow):
qt.Qt.Key_Down: 'down'
}
+ def __simulateMouseMove(self):
+ qapp = qt.QApplication.instance()
+ event = qt.QMouseEvent(
+ qt.QEvent.MouseMove,
+ self.getWidgetHandle().mapFromGlobal(qt.QCursor.pos()),
+ qt.Qt.NoButton,
+ qapp.mouseButtons(),
+ qapp.keyboardModifiers())
+ qapp.sendEvent(self.getWidgetHandle(), event)
+
def keyPressEvent(self, event):
"""Key event handler handling panning on arrow keys.
@@ -3145,15 +3261,7 @@ class PlotWidget(qt.QMainWindow):
# Send a mouse move event to the plot widget to take into account
# that even if mouse didn't move on the screen, it moved relative
# to the plotted data.
- qapp = qt.QApplication.instance()
- event = qt.QMouseEvent(
- qt.QEvent.MouseMove,
- self.getWidgetHandle().mapFromGlobal(qt.QCursor.pos()),
- qt.Qt.NoButton,
- qapp.mouseButtons(),
- qapp.keyboardModifiers())
- qapp.sendEvent(self.getWidgetHandle(), event)
-
+ self.__simulateMouseMove()
else:
# Only call base class implementation when key is not handled.
# See QWidget.keyPressEvent for details.
diff --git a/silx/gui/plot/PlotWindow.py b/silx/gui/plot/PlotWindow.py
index 0196050..a3b70c6 100644
--- a/silx/gui/plot/PlotWindow.py
+++ b/silx/gui/plot/PlotWindow.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2020 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
@@ -464,7 +464,7 @@ class PlotWindow(PlotWidget):
"""Add a dock widget as a new tab if there are already dock widgets
in the plot. When the first tab is added, the area is chosen
depending on the plot geometry:
- it the window is much wider than it is high, the right dock area
+ if the window is much wider than it is high, the right dock area
is used, else the bottom dock area is used.
:param dock_widget: Instance of :class:`QDockWidget` to be added.
@@ -485,6 +485,17 @@ class PlotWindow(PlotWidget):
self.tabifyDockWidget(self._dockWidgets[0],
dock_widget)
+ def removeDockWidget(self, dockwidget):
+ """Removes the *dockwidget* from the main window layout and hides it.
+
+ Note that the *dockwidget* is *not* deleted.
+
+ :param QDockWidget dockwidget:
+ """
+ if dockwidget in self._dockWidgets:
+ self._dockWidgets.remove(dockwidget)
+ super(PlotWindow, self).removeDockWidget(dockwidget)
+
def __handleFirstDockWidgetShow(self, visible):
"""Handle QDockWidget.visibilityChanged
@@ -828,6 +839,9 @@ class Plot1D(PlotWindow):
self.setWindowTitle('Plot1D')
self.getXAxis().setLabel('X')
self.getYAxis().setLabel('Y')
+ action = self.getFitAction()
+ action.setXRangeUpdatedOnZoom(True)
+ action.setFittedItemUpdatedFromActiveCurve(True)
class Plot2D(PlotWindow):
@@ -916,37 +930,30 @@ class Plot2D(PlotWindow):
:param float y: Y position in plot coordinates
:return: The value at that point or '-'
"""
- value = '-'
- valueZ = -float('inf')
- mask = 0
- maskZ = -float('inf')
-
- for image in self.getAllImages():
- data = image.getData(copy=False)
- isMask = isinstance(image, items.MaskImageData)
- if isMask:
- zIndex = maskZ
+ pickedMask = None
+ for picked in self.pickItems(
+ *self.dataToPixel(x, y, check=False),
+ lambda item: isinstance(item, items.ImageBase)):
+ if isinstance(picked.getItem(), items.MaskImageData):
+ if pickedMask is None: # Use top-most if many masks
+ pickedMask = picked
else:
- zIndex = valueZ
- if image.getZValue() >= zIndex:
- # This image is over the previous one
- ox, oy = image.getOrigin()
- sx, sy = image.getScale()
- row, col = (y - oy) / sy, (x - ox) / sx
- if row >= 0 and col >= 0:
- # Test positive before cast otherwise issue with int(-0.5) = 0
- row, col = int(row), int(col)
- if (row < data.shape[0] and col < data.shape[1]):
- v, z = data[row, col], image.getZValue()
- if not isMask:
- value = v
- valueZ = z
- else:
- mask = v
- maskZ = z
- if maskZ > valueZ and mask > 0:
- return value, "Masked"
- return value
+ image = picked.getItem()
+
+ indices = picked.getIndices(copy=False)
+ if indices is not None:
+ row, col = indices[0][0], indices[1][0]
+ value = image.getData(copy=False)[row, col]
+
+ if pickedMask is not None: # Check if masked
+ maskItem = pickedMask.getItem()
+ indices = pickedMask.getIndices()
+ row, col = indices[0][0], indices[1][0]
+ if maskItem.getData(copy=False)[row, col] != 0:
+ return value, "Masked"
+ return value
+
+ return '-' # No image picked
def _getImageDims(self, *args):
activeImage = self.getActiveImage()
diff --git a/silx/gui/plot/Profile.py b/silx/gui/plot/Profile.py
index e2aa5a7..8abddbe 100644
--- a/silx/gui/plot/Profile.py
+++ b/silx/gui/plot/Profile.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2020 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,334 +33,45 @@ __date__ = "12/04/2019"
import weakref
-import numpy
-
-from silx.image.bilinear import BilinearImage
-
-from .. import icons
from .. import qt
-from . import items
-from ..colors import cursorColorForColormap
from . import actions
-from .PlotToolButtons import ProfileToolButton, ProfileOptionToolButton
-from .ProfileMainWindow import ProfileMainWindow
+from .tools.profile import core
+from .tools.profile import manager
+from .tools.profile import rois
+from silx.gui.widgets.MultiModeAction import MultiModeAction
from silx.utils.deprecation import deprecated
+from silx.utils.deprecation import deprecated_warning
+from .tools import roi as roi_mdl
+from silx.gui.plot import items
-def _alignedFullProfile(data, origin, scale, position, roiWidth, axis, method):
- """Get a profile along one axis on a stack of images
-
- :param numpy.ndarray data: 3D volume (stack of 2D images)
- The first dimension is the image index.
- :param origin: Origin of image in plot (ox, oy)
- :param scale: Scale of image in plot (sx, sy)
- :param float position: Position of profile line in plot coords
- on the axis orthogonal to the profile direction.
- :param int roiWidth: Width of the profile in image pixels.
- :param int axis: 0 for horizontal profile, 1 for vertical.
- :param str method: method to compute the profile. Can be 'mean' or 'sum'
- :return: profile image + effective ROI area corners in plot coords
- """
- assert axis in (0, 1)
- assert len(data.shape) == 3
- assert method in ('mean', 'sum')
-
- # Convert from plot to image coords
- imgPos = int((position - origin[1 - axis]) / scale[1 - axis])
-
- if axis == 1: # Vertical profile
- # Transpose image to always do a horizontal profile
- data = numpy.transpose(data, (0, 2, 1))
-
- nimages, height, width = data.shape
-
- roiWidth = min(height, roiWidth) # Clip roi width to image size
-
- # Get [start, end[ coords of the roi in the data
- start = int(int(imgPos) + 0.5 - roiWidth / 2.)
- start = min(max(0, start), height - roiWidth)
- end = start + roiWidth
-
- if start < height and end > 0:
- if method == 'mean':
- _fct = numpy.mean
- elif method == 'sum':
- _fct = numpy.sum
- else:
- raise ValueError('method not managed')
- profile = _fct(data[:, max(0, start):min(end, height), :], axis=1).astype(numpy.float32)
- else:
- profile = numpy.zeros((nimages, width), dtype=numpy.float32)
-
- # Compute effective ROI in plot coords
- profileBounds = numpy.array(
- (0, width, width, 0),
- dtype=numpy.float32) * scale[axis] + origin[axis]
- roiBounds = numpy.array(
- (start, start, end, end),
- dtype=numpy.float32) * scale[1 - axis] + origin[1 - axis]
-
- if axis == 0: # Horizontal profile
- area = profileBounds, roiBounds
- else: # vertical profile
- area = roiBounds, profileBounds
-
- return profile, area
-
-
-def _alignedPartialProfile(data, rowRange, colRange, axis, method):
- """Mean of a rectangular region (ROI) of a stack of images
- along a given axis.
-
- Returned values and all parameters are in image coordinates.
-
- :param numpy.ndarray data: 3D volume (stack of 2D images)
- The first dimension is the image index.
- :param rowRange: [min, max[ of ROI rows (upper bound excluded).
- :type rowRange: 2-tuple of int (min, max) with min < max
- :param colRange: [min, max[ of ROI columns (upper bound excluded).
- :type colRange: 2-tuple of int (min, max) with min < max
- :param int axis: The axis along which to take the profile of the ROI.
- 0: Sum rows along columns.
- 1: Sum columns along rows.
- :param str method: method to compute the profile. Can be 'mean' or 'sum'
- :return: Profile image along the ROI as the mean of the intersection
- of the ROI and the image.
- """
- assert axis in (0, 1)
- assert len(data.shape) == 3
- assert rowRange[0] < rowRange[1]
- assert colRange[0] < colRange[1]
- assert method in ('mean', 'sum')
-
- nimages, height, width = data.shape
-
- # Range aligned with the integration direction
- profileRange = colRange if axis == 0 else rowRange
-
- profileLength = abs(profileRange[1] - profileRange[0])
-
- # Subset of the image to use as intersection of ROI and image
- rowStart = min(max(0, rowRange[0]), height)
- rowEnd = min(max(0, rowRange[1]), height)
- colStart = min(max(0, colRange[0]), width)
- colEnd = min(max(0, colRange[1]), width)
-
- if method == 'mean':
- _fct = numpy.mean
- elif method == 'sum':
- _fct = numpy.sum
- else:
- raise ValueError('method not managed')
-
- imgProfile = _fct(data[:, rowStart:rowEnd, colStart:colEnd], axis=axis + 1,
- dtype=numpy.float32)
-
- # Profile including out of bound area
- profile = numpy.zeros((nimages, profileLength), dtype=numpy.float32)
+@deprecated(replacement="silx.gui.plot.tools.profile.createProfile", since_version="0.13.0")
+def createProfile(roiInfo, currentData, origin, scale, lineWidth, method):
+ return core.createProfile(roiInfo, currentData, origin,
+ scale, lineWidth, method)
- # Place imgProfile in full profile
- offset = - min(0, profileRange[0])
- profile[:, offset:offset + imgProfile.shape[1]] = imgProfile
- return profile
+class _CustomProfileManager(manager.ProfileManager):
+ """This custom profile manager uses a single predefined profile window
+ if it is specified. Else the behavior is the same as the default
+ ProfileManager """
+ def setProfileWindow(self, profileWindow):
+ self.__profileWindow = profileWindow
-def createProfile(roiInfo, currentData, origin, scale, lineWidth, method):
- """Create the profile line for the the given image.
-
- :param roiInfo: information about the ROI: start point, end point and
- type ("X", "Y", "D")
- :param numpy.ndarray currentData: the 2D image or the 3D stack of images
- on which we compute the profile.
- :param origin: (ox, oy) the offset from origin
- :type origin: 2-tuple of float
- :param scale: (sx, sy) the scale to use
- :type scale: 2-tuple of float
- :param int lineWidth: width of the profile line
- :param str method: method to compute the profile. Can be 'mean' or 'sum'
- :return: `coords, profile, area, profileName, xLabel`, where:
- - coords is the X coordinate to use to display the profile
- - profile is a 2D array of the profiles of the stack of images.
- For a single image, the profile is a curve, so this parameter
- has a shape *(1, len(curve))*
- - area is a tuple of two 1D arrays with 4 values each. They represent
- the effective ROI area corners in plot coords.
- - profileName is a string describing the ROI, meant to be used as
- title of the profile plot
- - xLabel the label for X in the profile window
-
- :rtype: tuple(ndarray,ndarray,(ndarray,ndarray),str)
- """
- if currentData is None or roiInfo is None or lineWidth is None:
- raise ValueError("createProfile called with invalide arguments")
-
- # force 3D data (stack of images)
- if len(currentData.shape) == 2:
- currentData3D = currentData.reshape((1,) + currentData.shape)
- elif len(currentData.shape) == 3:
- currentData3D = currentData
-
- roiWidth = max(1, lineWidth)
- roiStart, roiEnd, lineProjectionMode = roiInfo
-
- if lineProjectionMode == 'X': # Horizontal profile on the whole image
- profile, area = _alignedFullProfile(currentData3D,
- origin, scale,
- roiStart[1], roiWidth,
- axis=0,
- method=method)
-
- coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
- coords = coords * scale[0] + origin[0]
-
- yMin, yMax = min(area[1]), max(area[1]) - 1
- if roiWidth <= 1:
- profileName = 'Y = %g' % yMin
+ def createProfileWindow(self, plot, roi):
+ if self.__profileWindow is not None:
+ return self.__profileWindow
else:
- profileName = 'Y = [%g, %g]' % (yMin, yMax)
- xLabel = 'X'
-
- elif lineProjectionMode == 'Y': # Vertical profile on the whole image
- profile, area = _alignedFullProfile(currentData3D,
- origin, scale,
- roiStart[0], roiWidth,
- axis=1,
- method=method)
-
- coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
- coords = coords * scale[1] + origin[1]
-
- xMin, xMax = min(area[0]), max(area[0]) - 1
- if roiWidth <= 1:
- profileName = 'X = %g' % xMin
- else:
- profileName = 'X = [%g, %g]' % (xMin, xMax)
- xLabel = 'Y'
-
- else: # Free line profile
-
- # Convert start and end points in image coords as (row, col)
- startPt = ((roiStart[1] - origin[1]) / scale[1],
- (roiStart[0] - origin[0]) / scale[0])
- endPt = ((roiEnd[1] - origin[1]) / scale[1],
- (roiEnd[0] - origin[0]) / scale[0])
-
- if (int(startPt[0]) == int(endPt[0]) or
- int(startPt[1]) == int(endPt[1])):
- # Profile is aligned with one of the axes
-
- # Convert to int
- startPt = int(startPt[0]), int(startPt[1])
- endPt = int(endPt[0]), int(endPt[1])
-
- # Ensure startPt <= endPt
- if startPt[0] > endPt[0] or startPt[1] > endPt[1]:
- startPt, endPt = endPt, startPt
-
- if startPt[0] == endPt[0]: # Row aligned
- rowRange = (int(startPt[0] + 0.5 - 0.5 * roiWidth),
- int(startPt[0] + 0.5 + 0.5 * roiWidth))
- colRange = startPt[1], endPt[1] + 1
- profile = _alignedPartialProfile(currentData3D,
- rowRange, colRange,
- axis=0,
- method=method)
-
- else: # Column aligned
- rowRange = startPt[0], endPt[0] + 1
- colRange = (int(startPt[1] + 0.5 - 0.5 * roiWidth),
- int(startPt[1] + 0.5 + 0.5 * roiWidth))
- profile = _alignedPartialProfile(currentData3D,
- rowRange, colRange,
- axis=1,
- method=method)
-
- # Convert ranges to plot coords to draw ROI area
- area = (
- numpy.array(
- (colRange[0], colRange[1], colRange[1], colRange[0]),
- dtype=numpy.float32) * scale[0] + origin[0],
- numpy.array(
- (rowRange[0], rowRange[0], rowRange[1], rowRange[1]),
- dtype=numpy.float32) * scale[1] + origin[1])
-
- else: # General case: use bilinear interpolation
-
- # Ensure startPt <= endPt
- if (startPt[1] > endPt[1] or (
- startPt[1] == endPt[1] and startPt[0] > endPt[0])):
- startPt, endPt = endPt, startPt
-
- profile = []
- for slice_idx in range(currentData3D.shape[0]):
- bilinear = BilinearImage(currentData3D[slice_idx, :, :])
-
- profile.append(bilinear.profile_line(
- (startPt[0] - 0.5, startPt[1] - 0.5),
- (endPt[0] - 0.5, endPt[1] - 0.5),
- roiWidth,
- method=method))
- profile = numpy.array(profile)
-
- # Extend ROI with half a pixel on each end, and
- # Convert back to plot coords (x, y)
- length = numpy.sqrt((endPt[0] - startPt[0]) ** 2 +
- (endPt[1] - startPt[1]) ** 2)
- dRow = (endPt[0] - startPt[0]) / length
- dCol = (endPt[1] - startPt[1]) / length
-
- # Extend ROI with half a pixel on each end
- roiStartPt = startPt[0] - 0.5 * dRow, startPt[1] - 0.5 * dCol
- roiEndPt = endPt[0] + 0.5 * dRow, endPt[1] + 0.5 * dCol
-
- # Rotate deltas by 90 degrees to apply line width
- dRow, dCol = dCol, -dRow
-
- area = (
- numpy.array((roiStartPt[1] - 0.5 * roiWidth * dCol,
- roiStartPt[1] + 0.5 * roiWidth * dCol,
- roiEndPt[1] + 0.5 * roiWidth * dCol,
- roiEndPt[1] - 0.5 * roiWidth * dCol),
- dtype=numpy.float32) * scale[0] + origin[0],
- numpy.array((roiStartPt[0] - 0.5 * roiWidth * dRow,
- roiStartPt[0] + 0.5 * roiWidth * dRow,
- roiEndPt[0] + 0.5 * roiWidth * dRow,
- roiEndPt[0] - 0.5 * roiWidth * dRow),
- dtype=numpy.float32) * scale[1] + origin[1])
-
- # Convert start and end points back to plot coords
- y0 = startPt[0] * scale[1] + origin[1]
- x0 = startPt[1] * scale[0] + origin[0]
- y1 = endPt[0] * scale[1] + origin[1]
- x1 = endPt[1] * scale[0] + origin[0]
-
- if startPt[1] == endPt[1]:
- profileName = 'X = %g; Y = [%g, %g]' % (x0, y0, y1)
- coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
- coords = coords * scale[1] + y0
- xLabel = 'Y'
-
- elif startPt[0] == endPt[0]:
- profileName = 'Y = %g; X = [%g, %g]' % (y0, x0, x1)
- coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
- coords = coords * scale[0] + x0
- xLabel = 'X'
+ return super(_CustomProfileManager, self).createProfileWindow(plot, roi)
+ def clearProfileWindow(self, profileWindow):
+ if self.__profileWindow is not None:
+ self.__profileWindow.setProfile(None)
else:
- m = (y1 - y0) / (x1 - x0)
- b = y0 - m * x0
- profileName = 'y = %g * x %+g ; width=%d' % (m, b, roiWidth)
- coords = numpy.linspace(x0, x1, len(profile[0]),
- endpoint=True,
- dtype=numpy.float32)
- xLabel = 'X'
-
- return coords, profile, area, profileName, xLabel
-
+ return super(_CustomProfileManager, self).clearProfileWindow(profileWindow)
-# ProfileToolBar ##############################################################
class ProfileToolBar(qt.QToolBar):
"""QToolBar providing profile tools operating on a :class:`PlotWindow`.
@@ -387,76 +98,36 @@ class ProfileToolBar(qt.QToolBar):
:param str title: See :class:`QToolBar`.
:param parent: See :class:`QToolBar`.
"""
- # TODO Make it a QActionGroup instead of a QToolBar
-
- _POLYGON_LEGEND = '__ProfileToolBar_ROI_Polygon'
-
- DEFAULT_PROF_METHOD = 'mean'
def __init__(self, parent=None, plot=None, profileWindow=None,
- title='Profile Selection'):
+ title=None):
super(ProfileToolBar, self).__init__(title, parent)
assert plot is not None
- self._plotRef = weakref.ref(plot)
- self._overlayColor = None
- self._defaultOverlayColor = 'red' # update when active image change
- self._method = self.DEFAULT_PROF_METHOD
+ if title is not None:
+ deprecated_warning("Attribute",
+ name="title",
+ reason="removed",
+ since_version="0.13.0",
+ only_once=True,
+ skip_backtrace_count=1)
- self._roiInfo = None # Store start and end points and type of ROI
-
- self._profileWindow = profileWindow
- """User provided plot widget in which the profile curve is plotted.
- None if no custom profile plot was provided."""
-
- self._profileMainWindow = None
- """Main window providing 2 profile plot widgets for 1D or 2D profiles.
- The window provides two public methods
- - :meth:`setProfileDimensions`
- - :meth:`getPlot`: return handle on the actual plot widget
- currently being used
- None if the user specified a custom profile plot window.
- """
+ self._plotRef = weakref.ref(plot)
- if self._profileWindow is None:
- backend = type(plot._backend)
- self._profileMainWindow = ProfileMainWindow(self, backend=backend)
+ # If a profileWindow is defined,
+ # It will be used to display all the profiles
+ self._manager = _CustomProfileManager(self, plot)
+ self._manager.setProfileWindow(profileWindow)
+ self._manager.setDefaultColorFromCursorColor(True)
+ self._manager.setItemType(image=True)
+ self._manager.setActiveItemTracking(True)
# Actions
- self._browseAction = actions.mode.ZoomModeAction(self.plot, parent=self)
+ self._browseAction = actions.mode.ZoomModeAction(plot, parent=self)
self._browseAction.setVisible(False)
-
- self.hLineAction = qt.QAction(icons.getQIcon('shape-horizontal'),
- 'Horizontal Profile Mode',
- self)
- self.hLineAction.setToolTip(
- 'Enables horizontal profile selection mode')
- self.hLineAction.setCheckable(True)
- self.hLineAction.toggled[bool].connect(self._hLineActionToggled)
-
- self.vLineAction = qt.QAction(icons.getQIcon('shape-vertical'),
- 'Vertical Profile Mode',
- self)
- self.vLineAction.setToolTip(
- 'Enables vertical profile selection mode')
- self.vLineAction.setCheckable(True)
- self.vLineAction.toggled[bool].connect(self._vLineActionToggled)
-
- self.lineAction = qt.QAction(icons.getQIcon('shape-diagonal'),
- 'Free Line Profile Mode',
- self)
- self.lineAction.setToolTip(
- 'Enables line profile selection mode')
- self.lineAction.setCheckable(True)
- self.lineAction.toggled[bool].connect(self._lineActionToggled)
-
- self.clearAction = qt.QAction(icons.getQIcon('profile-clear'),
- 'Clear Profile',
- self)
- self.clearAction.setToolTip(
- 'Clear the profile Region of interest')
- self.clearAction.setCheckable(False)
- self.clearAction.triggered.connect(self.clearProfile)
+ self.freeLineAction = None
+ self._createProfileActions()
+ self._editor = self._manager.createEditorAction(self)
# ActionGroup
self.actionGroup = qt.QActionGroup(self)
@@ -464,44 +135,62 @@ class ProfileToolBar(qt.QToolBar):
self.actionGroup.addAction(self.hLineAction)
self.actionGroup.addAction(self.vLineAction)
self.actionGroup.addAction(self.lineAction)
+ self.actionGroup.addAction(self._editor)
+
+ modes = MultiModeAction(self)
+ modes.addAction(self.hLineAction)
+ modes.addAction(self.vLineAction)
+ modes.addAction(self.lineAction)
+ if self.freeLineAction is not None:
+ modes.addAction(self.freeLineAction)
+ modes.addAction(self.crossAction)
+ self.__multiAction = modes
# Add actions to ToolBar
self.addAction(self._browseAction)
- self.addAction(self.hLineAction)
- self.addAction(self.vLineAction)
- self.addAction(self.lineAction)
+ self.addAction(modes)
+ self.addAction(self._editor)
self.addAction(self.clearAction)
- # Add width spin box to toolbar
- self.addWidget(qt.QLabel('W:'))
- self.lineWidthSpinBox = qt.QSpinBox(self)
- self.lineWidthSpinBox.setRange(1, 1000)
- self.lineWidthSpinBox.setValue(1)
- self.lineWidthSpinBox.valueChanged[int].connect(
- self._lineWidthSpinBoxValueChangedSlot)
- self.addWidget(self.lineWidthSpinBox)
-
- self.methodsButton = ProfileOptionToolButton(parent=self, plot=self)
- self.__profileOptionToolAction = self.addWidget(self.methodsButton)
- # TODO: add connection with the signal
- self.methodsButton.sigMethodChanged.connect(self.setProfileMethod)
-
- self.plot.sigInteractiveModeChanged.connect(
- self._interactiveModeChanged)
+ plot.sigActiveImageChanged.connect(self._activeImageChanged)
+ self._activeImageChanged()
- # Enable toolbar only if there is an active image
- self.setEnabled(self.plot.getActiveImage(just_legend=True) is not None)
- self.plot.sigActiveImageChanged.connect(
- self._activeImageChanged)
+ def _createProfileActions(self):
+ self.hLineAction = self._manager.createProfileAction(rois.ProfileImageHorizontalLineROI, self)
+ self.vLineAction = self._manager.createProfileAction(rois.ProfileImageVerticalLineROI, self)
+ self.lineAction = self._manager.createProfileAction(rois.ProfileImageLineROI, self)
+ self.freeLineAction = self._manager.createProfileAction(rois.ProfileImageDirectedLineROI, self)
+ self.crossAction = self._manager.createProfileAction(rois.ProfileImageCrossROI, self)
+ self.clearAction = self._manager.createClearAction(self)
- # listen to the profile window signals to clear profile polygon on close
- if self.getProfileMainWindow() is not None:
- self.getProfileMainWindow().sigClose.connect(self.clearProfile)
+ def getPlotWidget(self):
+ """The :class:`.PlotWidget` associated to the toolbar."""
+ return self._plotRef()
@property
+ @deprecated(since_version="0.13.0", replacement="getPlotWidget()")
def plot(self):
- """The :class:`.PlotWidget` associated to the toolbar."""
- return self._plotRef()
+ return self.getPlotWidget()
+
+ def _setRoiActionEnabled(self, itemKind, enabled):
+ for action in self.__multiAction.getMenu().actions():
+ if not isinstance(action, roi_mdl.CreateRoiModeAction):
+ continue
+ roiClass = action.getRoiClass()
+ if issubclass(itemKind, roiClass.ITEM_KIND):
+ action.setEnabled(enabled)
+
+ def _activeImageChanged(self, previous=None, legend=None):
+ """Handle active image change to toggle actions"""
+ if legend is None:
+ self._setRoiActionEnabled(items.ImageStack, False)
+ self._setRoiActionEnabled(items.ImageBase, False)
+ else:
+ plot = self.getPlotWidget()
+ image = plot.getActiveImage()
+ # Disable for empty image
+ enabled = image.getData(copy=False).size > 0
+ self._setRoiActionEnabled(type(image), enabled)
@property
@deprecated(since_version="0.6.0")
@@ -513,253 +202,96 @@ class ProfileToolBar(qt.QToolBar):
def profileWindow(self):
return self.getProfilePlot()
+ def getProfileManager(self):
+ """Return the manager of the profiles.
+
+ :rtype: ProfileManager
+ """
+ return self._manager
+
+ @deprecated(since_version="0.13.0")
def getProfilePlot(self):
"""Return plot widget in which the profile curve or the
profile image is plotted.
"""
- if self.getProfileMainWindow() is not None:
- return self.getProfileMainWindow().getPlot()
-
- # in case the user provided a custom plot for profiles
- return self._profileWindow
+ window = self.getProfileMainWindow()
+ if window is None:
+ return None
+ return window.getCurrentPlotWidget()
+ @deprecated(replacement="getProfileManager().getCurrentRoi().getProfileWindow()", since_version="0.13.0")
def getProfileMainWindow(self):
"""Return window containing the profile curve widget.
- This can return *None* if a custom profile plot window was
- specified in the constructor.
- """
- return self._profileMainWindow
-
- def _activeImageChanged(self, previous, legend):
- """Handle active image change: toggle enabled toolbar, update curve"""
- if legend is None:
- self.setEnabled(False)
- else:
- activeImage = self.plot.getActiveImage()
-
- # Disable for empty image
- self.setEnabled(activeImage.getData(copy=False).size > 0)
-
- # Update default profile color
- if isinstance(activeImage, items.ColormapMixIn):
- self._defaultOverlayColor = cursorColorForColormap(
- activeImage.getColormap()['name'])
- else:
- self._defaultOverlayColor = 'black'
-
- self.updateProfile()
- def _lineWidthSpinBoxValueChangedSlot(self, value):
- """Listen to ROI width widget to refresh ROI and profile"""
- self.updateProfile()
-
- def _interactiveModeChanged(self, source):
- """Handle plot interactive mode changed:
-
- If changed from elsewhere, disable drawing tool
+ This can return None if no profile was computed.
"""
- if source is not self:
- self.clearProfile()
-
- # Uncheck all drawing profile modes
- self.hLineAction.setChecked(False)
- self.vLineAction.setChecked(False)
- self.lineAction.setChecked(False)
-
- if self.getProfileMainWindow() is not None:
- self.getProfileMainWindow().hide()
-
- def _hLineActionToggled(self, checked):
- """Handle horizontal line profile action toggle"""
- if checked:
- self.plot.setInteractiveMode('draw', shape='hline',
- color=None, source=self)
- self.plot.sigPlotSignal.connect(self._plotWindowSlot)
- else:
- self.plot.sigPlotSignal.disconnect(self._plotWindowSlot)
-
- def _vLineActionToggled(self, checked):
- """Handle vertical line profile action toggle"""
- if checked:
- self.plot.setInteractiveMode('draw', shape='vline',
- color=None, source=self)
- self.plot.sigPlotSignal.connect(self._plotWindowSlot)
- else:
- self.plot.sigPlotSignal.disconnect(self._plotWindowSlot)
-
- def _lineActionToggled(self, checked):
- """Handle line profile action toggle"""
- if checked:
- self.plot.setInteractiveMode('draw', shape='line',
- color=None, source=self)
- self.plot.sigPlotSignal.connect(self._plotWindowSlot)
- else:
- self.plot.sigPlotSignal.disconnect(self._plotWindowSlot)
-
- def _plotWindowSlot(self, event):
- """Listen to Plot to handle drawing events to refresh ROI and profile.
- """
- if event['event'] not in ('drawingProgress', 'drawingFinished'):
- return
-
- checkedAction = self.actionGroup.checkedAction()
- if checkedAction == self.hLineAction:
- lineProjectionMode = 'X'
- elif checkedAction == self.vLineAction:
- lineProjectionMode = 'Y'
- elif checkedAction == self.lineAction:
- lineProjectionMode = 'D'
- else:
- return
-
- roiStart, roiEnd = event['points'][0], event['points'][1]
-
- self._roiInfo = roiStart, roiEnd, lineProjectionMode
- self.updateProfile()
+ roi = self._manager.getCurrentRoi()
+ if roi is None:
+ return None
+ return roi.getProfileWindow()
@property
+ @deprecated(since_version="0.13.0")
def overlayColor(self):
- """The color to use for the ROI.
+ """This method does nothing anymore. But could be implemented if needed.
+
+ It was used to set color to use for the ROI.
If set to None (the default), the overlay color is adapted to the
active image colormap and changes if the active image colormap changes.
"""
- return self._overlayColor or self._defaultOverlayColor
+ pass
@overlayColor.setter
+ @deprecated(since_version="0.13.0")
def overlayColor(self, color):
- self._overlayColor = color
- self.updateProfile()
+ """This method does nothing anymore. But could be implemented if needed.
+ """
+ pass
def clearProfile(self):
"""Remove profile curve and profile area."""
- self._roiInfo = None
- self.updateProfile()
+ self._manager.clearProfile()
+ @deprecated(since_version="0.13.0")
def updateProfile(self):
- """Update the displayed profile and profile ROI.
+ """This method does nothing anymore. But could be implemented if needed.
+
+ It was used to update the displayed profile and profile ROI.
This uses the current active image of the plot and the current ROI.
"""
- image = self.plot.getActiveImage()
- if image is None:
- return
-
- # Clean previous profile area, and previous curve
- self.plot.remove(self._POLYGON_LEGEND, kind='item')
- self.getProfilePlot().clear()
- self.getProfilePlot().setGraphTitle('')
- self.getProfilePlot().getXAxis().setLabel('X')
- self.getProfilePlot().getYAxis().setLabel('Y')
-
- self._createProfile(currentData=image.getData(copy=False),
- origin=image.getOrigin(),
- scale=image.getScale(),
- colormap=None, # Not used for 2D data
- z=image.getZValue(),
- method=self.getProfileMethod())
-
- def _createProfile(self, currentData, origin, scale, colormap, z, method):
- """Create the profile line for the the given image.
-
- :param numpy.ndarray currentData: the image or the stack of images
- on which we compute the profile
- :param origin: (ox, oy) the offset from origin
- :type origin: 2-tuple of float
- :param scale: (sx, sy) the scale to use
- :type scale: 2-tuple of float
- :param dict colormap: The colormap to use
- :param int z: The z layer of the image
- """
- if self._roiInfo is None:
- return
-
- coords, profile, area, profileName, xLabel = createProfile(
- roiInfo=self._roiInfo,
- currentData=currentData,
- origin=origin,
- scale=scale,
- lineWidth=self.lineWidthSpinBox.value(),
- method=method)
-
- profilePlot = self.getProfilePlot()
-
- profilePlot.setGraphTitle(profileName)
- profilePlot.getXAxis().setLabel(xLabel)
-
- dataIs3D = len(currentData.shape) > 2
- if dataIs3D:
- profileScale = (coords[-1] - coords[0]) / profile.shape[1], 1
- profilePlot.addImage(profile,
- legend=profileName,
- colormap=colormap,
- origin=(coords[0], 0),
- scale=profileScale)
- profilePlot.getYAxis().setLabel("Frame index (depth)")
- else:
- profilePlot.addCurve(coords,
- profile[0],
- legend=profileName,
- color=self.overlayColor)
-
- self.plot.addItem(area[0], area[1],
- legend=self._POLYGON_LEGEND,
- color=self.overlayColor,
- shape='polygon', fill=True,
- replace=False, z=z + 1)
-
- self._showProfileMainWindow()
-
- def _showProfileMainWindow(self):
- """If profile window was created by this toolbar,
- try to avoid overlapping with the toolbar's parent window.
- """
- profileMainWindow = self.getProfileMainWindow()
- if profileMainWindow is not None:
- winGeom = self.window().frameGeometry()
- qapp = qt.QApplication.instance()
- screenGeom = qapp.desktop().availableGeometry(self)
- spaceOnLeftSide = winGeom.left()
- spaceOnRightSide = screenGeom.width() - winGeom.right()
-
- profileWindowWidth = profileMainWindow.frameGeometry().width()
- if (profileWindowWidth < spaceOnRightSide):
- # Place profile on the right
- profileMainWindow.move(winGeom.right(), winGeom.top())
- elif(profileWindowWidth < spaceOnLeftSide):
- # Place profile on the left
- profileMainWindow.move(
- max(0, winGeom.left() - profileWindowWidth), winGeom.top())
-
- profileMainWindow.show()
- profileMainWindow.raise_()
- else:
- self.getProfilePlot().show()
- self.getProfilePlot().raise_()
+ pass
+ @deprecated(replacement="clearProfile()", since_version="0.13.0")
def hideProfileWindow(self):
"""Hide profile window.
"""
- # this method is currently only used by StackView when the perspective
- # is changed
- if self.getProfileMainWindow() is not None:
- self.getProfileMainWindow().hide()
+ self.clearProfile()
+ @deprecated(since_version="0.13.0")
def setProfileMethod(self, method):
assert method in ('sum', 'mean')
- self._method = method
- self.updateProfile()
+ roi = self._manager.getCurrentRoi()
+ if roi is None:
+ raise RuntimeError("No profile ROI selected")
+ roi.setProfileMethod(method)
+ @deprecated(since_version="0.13.0")
def getProfileMethod(self):
- return self._method
+ roi = self._manager.getCurrentRoi()
+ if roi is None:
+ raise RuntimeError("No profile ROI selected")
+ return roi.getProfileMethod()
+ @deprecated(since_version="0.13.0")
def getProfileOptionToolAction(self):
- return self.__profileOptionToolAction
+ return self._editor
class Profile3DToolBar(ProfileToolBar):
def __init__(self, parent=None, stackview=None,
- title='Profile Selection'):
+ title=None):
"""QToolBar providing profile tools for an image or a stack of images.
:param parent: the parent QWidget
@@ -769,66 +301,22 @@ class Profile3DToolBar(ProfileToolBar):
"""
# TODO: add param profileWindow (specify the plot used for profiles)
super(Profile3DToolBar, self).__init__(parent=parent,
- plot=stackview.getPlot(),
- title=title)
- self.stackView = stackview
- """:class:`StackView` instance"""
-
- self.profile3dAction = ProfileToolButton(
- parent=self, plot=self.plot)
- self.profile3dAction.computeProfileIn2D()
- self.profile3dAction.setVisible(True)
- self.addWidget(self.profile3dAction)
- self.profile3dAction.sigDimensionChanged.connect(self._setProfileType)
-
- # create the 3D toolbar
- self._profileType = None
- self._setProfileType(2)
- self._method3D = 'sum'
-
- def _setProfileType(self, dimensions):
- """Set the profile type: "1D" for a curve (profile on a single image)
- or "2D" for an image (profile on a stack of images).
+ plot=stackview.getPlotWidget())
- :param int dimensions: 1 for a "1D" profile or 2 for a "2D" profile
- """
- # fixme this assumes that we created _profileMainWindow
- self._profileType = "1D" if dimensions == 1 else "2D"
- self.getProfileMainWindow().setProfileType(self._profileType)
- self.updateProfile()
-
- def updateProfile(self):
- """Method overloaded from :class:`ProfileToolBar`,
- to pass the stack of images instead of just the active image.
-
- In 1D profile mode, use the regular parent method.
- """
- if self._profileType == "1D":
- super(Profile3DToolBar, self).updateProfile()
- elif self._profileType == "2D":
- stackData = self.stackView.getCurrentView(copy=False,
- returnNumpyArray=True)
- if stackData is None:
- return
- self.plot.remove(self._POLYGON_LEGEND, kind='item')
- self.getProfilePlot().clear()
- self.getProfilePlot().setGraphTitle('')
- self.getProfilePlot().getXAxis().setLabel('X')
- self.getProfilePlot().getYAxis().setLabel('Y')
- self._createProfile(currentData=stackData[0],
- origin=stackData[1]['origin'],
- scale=stackData[1]['scale'],
- colormap=stackData[1]['colormap'],
- z=stackData[1]['z'],
- method=self.getProfileMethod())
- else:
- raise ValueError(
- "Profile type must be 1D or 2D, not %s" % self._profileType)
+ if title is not None:
+ deprecated_warning("Attribute",
+ name="title",
+ reason="removed",
+ since_version="0.13.0",
+ only_once=True,
+ skip_backtrace_count=1)
- def setProfileMethod(self, method):
- assert method in ('sum', 'mean')
- self._method3D = method
- self.updateProfile()
+ self.stackView = stackview
+ """:class:`StackView` instance"""
- def getProfileMethod(self):
- return self._method3D
+ def _createProfileActions(self):
+ self.hLineAction = self._manager.createProfileAction(rois.ProfileImageStackHorizontalLineROI, self)
+ self.vLineAction = self._manager.createProfileAction(rois.ProfileImageStackVerticalLineROI, self)
+ self.lineAction = self._manager.createProfileAction(rois.ProfileImageStackLineROI, self)
+ self.crossAction = self._manager.createProfileAction(rois.ProfileImageStackCrossROI, self)
+ self.clearAction = self._manager.createClearAction(self)
diff --git a/silx/gui/plot/ProfileMainWindow.py b/silx/gui/plot/ProfileMainWindow.py
index aaedd1c..ce56cfd 100644
--- a/silx/gui/plot/ProfileMainWindow.py
+++ b/silx/gui/plot/ProfileMainWindow.py
@@ -24,15 +24,24 @@
# ###########################################################################*/
"""This module contains a QMainWindow class used to display profile plots.
"""
-from silx.gui import qt
-
__authors__ = ["P. Knobel"]
__license__ = "MIT"
__date__ = "21/02/2017"
+import silx.utils.deprecation
+from silx.gui import qt
+from .tools.profile.manager import ProfileWindow
+
+silx.utils.deprecation.deprecated_warning("Module",
+ name="silx.gui.plot.ProfileMainWindow",
+ reason="moved",
+ replacement="silx.gui.plot.tools.profile.manager.ProfileWindow",
+ since_version="0.13.0",
+ only_once=True,
+ skip_backtrace_count=1)
-class ProfileMainWindow(qt.QMainWindow):
+class ProfileMainWindow(ProfileWindow):
"""QMainWindow providing 2 plot widgets specialized in
1D and 2D plotting, with different toolbars.
@@ -48,73 +57,50 @@ class ProfileMainWindow(qt.QMainWindow):
"""This signal is emitted when :meth:`setProfileDimensions` is called.
It carries the number of dimensions for the profile data (1 or 2).
It can be used to be notified that the profile plot widget has changed.
- """
- sigClose = qt.Signal()
- """Emitted by :meth:`closeEvent` (e.g. when the window is closed
- through the window manager's close icon)."""
+ Note: This signal should be removed.
+ """
sigProfileMethodChanged = qt.Signal(str)
"""Emitted when the method to compute the profile changed (for now can be
- sum or mean)"""
+ sum or mean)
- def __init__(self, parent=None, backend=None):
- qt.QMainWindow.__init__(self, parent=parent)
+ Note: This signal should be removed.
+ """
- self.setWindowTitle('Profile window')
- # plots are created on demand, in self.setProfileDimensions()
- self._plot1D = None
- self._plot2D = None
- self._backend = backend
+ def __init__(self, parent=None, backend=None):
+ ProfileWindow.__init__(self, parent=parent, backend=backend)
# by default, profile is assumed to be a 1D curve
self._profileType = None
- self.setProfileType("1D")
- self.setProfileMethod('sum')
def setProfileType(self, profileType):
"""Set which profile plot widget (1D or 2D) is to be used
+ Note: This method should be removed.
+
:param str profileType: Type of profile data,
"1D" for a curve or "2D" for an image
"""
- # import here to avoid circular import
- from .PlotWindow import Plot1D, Plot2D # noqa
self._profileType = profileType
if self._profileType == "1D":
- if self._plot2D is not None:
- self._plot2D.setParent(None) # necessary to avoid widget destruction
- if self._plot1D is None:
- self._plot1D = Plot1D(backend=self._backend)
- self._plot1D.setDataMargins(yMinMargin=0.1, yMaxMargin=0.1)
- self._plot1D.setGraphYLabel('Profile')
- self._plot1D.setGraphXLabel('')
- self.setCentralWidget(self._plot1D)
+ self._showPlot1D()
elif self._profileType == "2D":
- if self._plot1D is not None:
- self._plot1D.setParent(None) # necessary to avoid widget destruction
- if self._plot2D is None:
- self._plot2D = Plot2D(backend=self._backend)
- self.setCentralWidget(self._plot2D)
+ self._showPlot2D()
else:
raise ValueError("Profile type must be '1D' or '2D'")
-
self.sigProfileDimensionsChanged.emit(profileType)
def getPlot(self):
"""Return the profile plot widget which is currently in use.
This can be the 2D profile plot or the 1D profile plot.
- """
- if self._profileType == "2D":
- return self._plot2D
- else:
- return self._plot1D
- def closeEvent(self, qCloseEvent):
- self.sigClose.emit()
- qCloseEvent.accept()
+ Note: This method should be removed.
+ """
+ return self.getCurrentPlotWidget()
def setProfileMethod(self, method):
"""
+ Note: This method should be removed.
:param str method: method to manage the 'width' in the profile
(computing mean or sum).
diff --git a/silx/gui/plot/ScatterMaskToolsWidget.py b/silx/gui/plot/ScatterMaskToolsWidget.py
index 9a15763..8ff2483 100644
--- a/silx/gui/plot/ScatterMaskToolsWidget.py
+++ b/silx/gui/plot/ScatterMaskToolsWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2020 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
@@ -48,6 +48,7 @@ from .. import qt
from ...math.combo import min_max
from ...image import shapes
+from .items import ItemChangedType, Scatter
from ._BaseMaskToolsWidget import BaseMask, BaseMaskToolsWidget, BaseMaskToolsDockWidget
from ..colors import cursorColorForColormap, rgba
@@ -260,10 +261,17 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
legend=self._maskName)
self._mask_scatter.setSymbolSize(
self._data_scatter.getSymbolSize() + 2.0)
+ self._mask_scatter.sigItemChanged.connect(self.__maskScatterChanged)
elif self.plot._getItem(kind="scatter",
legend=self._maskName) is not None:
self.plot.remove(self._maskName, kind='scatter')
+ def __maskScatterChanged(self, event):
+ """Handles update of mask scatter"""
+ if (event is ItemChangedType.VISUALIZATION_MODE and
+ self._mask_scatter is not None):
+ self._mask_scatter.setVisualization(Scatter.Visualization.POINTS)
+
# track widget visibility and plot active image changes
def showEvent(self, event):
@@ -318,7 +326,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
# check that content changed was the active scatter
activeScatter = self.plot._getActiveItem(kind="scatter")
- if activeScatter is None or activeScatter.getLegend() == self._maskName:
+ if activeScatter is None or activeScatter.getName() == self._maskName:
# No active scatter or active scatter is the mask...
self.plot.sigActiveScatterChanged.disconnect(
self._activeScatterChangedAfterCare)
@@ -347,7 +355,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
"""Update widget and mask according to active scatter changes"""
activeScatter = self.plot._getActiveItem(kind="scatter")
- if activeScatter is None or activeScatter.getLegend() == self._maskName:
+ if activeScatter is None or activeScatter.getName() == self._maskName:
# No active scatter or active scatter is the mask...
self.setEnabled(False)
diff --git a/silx/gui/plot/ScatterView.py b/silx/gui/plot/ScatterView.py
index bdbf3ab..0423648 100644
--- a/silx/gui/plot/ScatterView.py
+++ b/silx/gui/plot/ScatterView.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2020 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
@@ -41,6 +41,7 @@ import numpy
from . import items
from . import PlotWidget
from . import tools
+from .actions import histogram as actions_histogram
from .tools.profile import ScatterProfileToolBar
from .ColorBar import ColorBarWidget
from .ScatterMaskToolsWidget import ScatterMaskToolsWidget
@@ -124,6 +125,8 @@ class ScatterView(qt.QMainWindow):
self._maskAction.setIcon(icons.getQIcon('image-mask'))
self._maskAction.setToolTip("Display/hide mask tools")
+ self._intensityHistoAction = actions_histogram.PixelIntensitiesHistoAction(plot=plot, parent=self)
+
# Create toolbars
self._interactiveModeToolBar = tools.InteractiveModeToolBar(
parent=self, plot=plot)
@@ -131,6 +134,7 @@ class ScatterView(qt.QMainWindow):
self._scatterToolBar = tools.ScatterToolBar(
parent=self, plot=plot)
self._scatterToolBar.addAction(self._maskAction)
+ self._scatterToolBar.addAction(self._intensityHistoAction)
self._profileToolBar = ScatterProfileToolBar(parent=self, plot=plot)
@@ -181,10 +185,17 @@ class ScatterView(qt.QMainWindow):
pixelPos[0], pixelPos[1],
lambda item: isinstance(item, items.Scatter))
if result is not None:
- # Get last index
- # with matplotlib it should be the top-most point
- dataIndex = result.getIndices(copy=False)[-1]
item = result.getItem()
+ if item.getVisualization() is items.Scatter.Visualization.BINNED_STATISTIC:
+ # Get highest index of closest points
+ selected = result.getIndices(copy=False)[::-1]
+ dataIndex = selected[numpy.argmin(
+ (item.getXData(copy=False)[selected] - x)**2 +
+ (item.getYData(copy=False)[selected] - y)**2)]
+ else:
+ # Get last index
+ # with matplotlib it should be the top-most point
+ dataIndex = result.getIndices(copy=False)[-1]
self.__pickingCache = (
dataIndex,
item.getXData(copy=False)[dataIndex],
diff --git a/silx/gui/plot/StackView.py b/silx/gui/plot/StackView.py
index 7e4c389..cb7ece1 100644
--- a/silx/gui/plot/StackView.py
+++ b/silx/gui/plot/StackView.py
@@ -78,6 +78,7 @@ import silx
from silx.gui import qt
from .. import icons
from . import items, PlotWindow, actions
+from .items.image import ImageStack
from ..colors import Colormap
from ..colors import cursorColorForColormap
from .tools import LimitsToolBar
@@ -90,6 +91,7 @@ from silx.io.nxdata import save_NXdata
from silx.utils.array_like import DatasetView, ListOfImages
from silx.math import calibration
from silx.utils.deprecation import deprecated_warning
+from silx.utils.deprecation import deprecated
import h5py
from silx.io.utils import is_dataset
@@ -185,7 +187,11 @@ class StackView(qt.QMainWindow):
self._perspective = 0
"""Orthogonal dimension (depth) in :attr:`_stack`"""
- self.__imageLegend = '__StackView__image' + str(id(self))
+ self._stackItem = ImageStack()
+ """Hold the item displaying the stack"""
+ imageLegend = '__StackView__image' + str(id(self))
+ self._stackItem.setName(imageLegend)
+
self.__autoscaleCmap = False
"""Flag to disable/enable colormap auto-scaling
based on the min/max values of the entire 3D volume"""
@@ -215,6 +221,7 @@ class StackView(qt.QMainWindow):
copy=copy, save=save, print_=print_,
control=control, position=position,
roi=False, mask=mask)
+ self._plot.addItem(self._stackItem)
self._plot.getIntensityHistogramAction().setVisible(True)
self.sigInteractiveModeChanged = self._plot.sigInteractiveModeChanged
self.sigActiveImageChanged = self._plot.sigActiveImageChanged
@@ -225,9 +232,9 @@ class StackView(qt.QMainWindow):
self._addColorBarAction()
- self._plot.profile = Profile3DToolBar(parent=self._plot,
- stackview=self)
- self._plot.addToolBar(self._plot.profile)
+ self._profileToolBar = Profile3DToolBar(parent=self._plot,
+ stackview=self)
+ self._plot.addToolBar(self._profileToolBar)
self._plot.getXAxis().setLabel('Columns')
self._plot.getYAxis().setLabel('Rows')
self._plot.sigPlotSignal.connect(self._plotCallback)
@@ -255,9 +262,7 @@ class StackView(qt.QMainWindow):
# clear profile lines when the perspective changes (plane browsed changed)
self.__planeSelection.sigPlaneSelectionChanged.connect(
- self._plot.profile.getProfilePlot().clear)
- self.__planeSelection.sigPlaneSelectionChanged.connect(
- self._plot.profile.clearProfile)
+ self._profileToolBar.clearProfile)
def _saveImageStack(self, plot, filename, nameFilter):
"""Save all images from the stack into a volume.
@@ -293,7 +298,7 @@ class StackView(qt.QMainWindow):
Emit :attr:`valueChanged` signal, with (x, y, value) tuple of the
cursor location in the plot."""
if eventDict['event'] == 'mouseMoved':
- activeImage = self._plot.getActiveImage()
+ activeImage = self.getActiveImage()
if activeImage is not None:
data = activeImage.getData()
height, width = data.shape
@@ -386,6 +391,12 @@ class StackView(qt.QMainWindow):
self._browser.setRange(0, self.__transposed_view.shape[0] - 1)
self._browser.setValue(0)
+ # Update the item structure
+ self._stackItem.setStackData(self.__transposed_view, 0, copy=False)
+ self._stackItem.setColormap(self.getColormap())
+ self._stackItem.setOrigin(self._getImageOrigin())
+ self._stackItem.setScale(self._getImageScale())
+
def __updateFrameNumber(self, index):
"""Update the current image.
@@ -394,11 +405,9 @@ class StackView(qt.QMainWindow):
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)
+
+ self._stackItem.setStackPosition(index)
+
self._updateTitle()
self.sigFrameChanged.emit(index)
@@ -506,7 +515,7 @@ class StackView(qt.QMainWindow):
:param calibrations: Sequence of 3 calibration objects for each axis.
These objects can be a subclass of :class:`AbstractCalibration`,
or 2-tuples *(a, b)* where *a* is the y-intercept and *b* is the
- slope of a linear calibration (:math:`x \mapsto a + b x`)
+ slope of a linear calibration (:math:`x \\mapsto a + b x`)
"""
if stack is None:
self.clear()
@@ -550,14 +559,18 @@ class StackView(qt.QMainWindow):
self.setColormap(colormap=colormap)
# init plot
- self._plot.addImage(self.__transposed_view[0, :, :],
- legend=self.__imageLegend,
- colormap=self.getColormap(),
- origin=self._getImageOrigin(),
- scale=self._getImageScale(),
- replace=True,
- resetzoom=False)
- self._plot.setActiveImage(self.__imageLegend)
+ self._stackItem.setStackData(self.__transposed_view, 0, copy=False)
+ self._stackItem.setColormap(self.getColormap())
+ self._stackItem.setOrigin(self._getImageOrigin())
+ self._stackItem.setScale(self._getImageScale())
+ self._stackItem.setVisible(True)
+
+ # Put back the item in the plot in case it was cleared
+ exists = self._plot.getImage(self._stackItem.getName())
+ if exists is None:
+ self._plot.addItem(self._stackItem)
+
+ self._plot.setActiveImage(self._stackItem.getName())
self.__updatePlotLabels()
self._updateTitle()
@@ -586,14 +599,11 @@ class StackView(qt.QMainWindow):
:return: 3D stack and parameters.
:rtype: (numpy.ndarray, dict)
"""
- image = self._plot.getActiveImage()
- if image is None:
+ if self._stack is None:
return None
- if isinstance(image, items.ColormapMixIn):
- colormap = image.getColormap()
- else:
- colormap = None
+ image = self._stackItem
+ colormap = image.getColormap()
params = {
'info': image.getInfo(),
@@ -637,7 +647,7 @@ class StackView(qt.QMainWindow):
:return: 3D stack and parameters.
:rtype: (numpy.ndarray, dict)
"""
- image = self._plot.getActiveImage()
+ image = self.getActiveImage()
if image is None:
return None
@@ -882,11 +892,15 @@ class StackView(qt.QMainWindow):
self._plot.setDefaultColormap(_colormap)
# Update active image colormap
- activeImage = self._plot.getActiveImage()
+ activeImage = self.getActiveImage()
if isinstance(activeImage, items.ColormapMixIn):
activeImage.setColormap(self.getColormap())
+ @deprecated(replacement="getPlotWidget", since_version="0.13")
def getPlot(self):
+ return self.getPlotWidget()
+
+ def getPlotWidget(self):
"""Return the :class:`PlotWidget`.
This gives access to advanced plot configuration options.
@@ -898,20 +912,6 @@ class StackView(qt.QMainWindow):
"""
return self._plot
- def getProfileWindow1D(self):
- """Plot window used to display 1D profile curve.
-
- :return: :class:`Plot1D`
- """
- return self._plot.profile.getProfileWindow1D()
-
- def getProfileWindow2D(self):
- """Plot window used to display 2D profile image.
-
- :return: :class:`Plot2D`
- """
- return self._plot.profile.getProfileWindow2D()
-
def setOptionVisible(self, isVisible):
"""
Set the visibility of the browsing options.
@@ -924,10 +924,8 @@ class StackView(qt.QMainWindow):
# proxies to PlotWidget or PlotWindow methods
def getProfileToolbar(self):
"""Profile tools attached to this plot
-
- See :class:`silx.gui.plot.Profile.Profile3DToolBar`
"""
- return self._plot.profile
+ return self._profileToolBar
def getGraphTitle(self):
"""Return the plot main title as a str.
@@ -1046,23 +1044,11 @@ class StackView(qt.QMainWindow):
# kind of private methods, but needed by Profile
def getActiveImage(self, just_legend=False):
- """Returns the currently active image object.
-
- It returns None in case of not having an active image.
-
- This method is a simple proxy to the legacy :class:`PlotWidget` method
- of the same name. Using the object oriented approach is now
- preferred::
-
- stackview.getPlot().getActiveImage()
-
- :param bool just_legend: True to get the legend of the image,
- False (the default) to get the image data and info.
- Note: :class:`StackView` uses the same legend for all frames.
- :return: legend or image object
- :rtype: str or list or None
+ """Returns the stack image object.
"""
- return self._plot.getActiveImage(just_legend=just_legend)
+ if just_legend:
+ return self._stackItem.getName()
+ return self._stackItem
def getColorBarAction(self):
"""Returns the action managing the visibility of the colorbar.
@@ -1085,11 +1071,15 @@ class StackView(qt.QMainWindow):
"""
self._plot.setInteractiveMode(*args, **kwargs)
+ @deprecated(replacement="addShape", since_version="0.13")
def addItem(self, *args, **kwargs):
+ self.addShape(*args, **kwargs)
+
+ def addShape(self, *args, **kwargs):
"""
- See :meth:`Plot.Plot.addItem`
+ See :meth:`Plot.Plot.addShape`
"""
- self._plot.addItem(*args, **kwargs)
+ self._plot.addShape(*args, **kwargs)
class PlanesWidget(qt.QWidget):
@@ -1210,13 +1200,15 @@ class StackViewMainWindow(StackView):
menu.addAction(actions.control.YAxisInvertedAction(self._plot, self))
menu = self.menuBar().addMenu('Profile')
- menu.addAction(self._plot.profile.hLineAction)
- menu.addAction(self._plot.profile.vLineAction)
- menu.addAction(self._plot.profile.lineAction)
+ profileToolBar = self._profileToolBar
+ menu.addAction(profileToolBar.hLineAction)
+ menu.addAction(profileToolBar.vLineAction)
+ menu.addAction(profileToolBar.lineAction)
+ menu.addAction(profileToolBar.crossAction)
+ menu.addSeparator()
+ menu.addAction(profileToolBar._editor)
menu.addSeparator()
- menu.addAction(self._plot.profile.clearAction)
- self._plot.profile.profile3dAction.computeProfileIn2D()
- menu.addMenu(self._plot.profile.profile3dAction.menu())
+ menu.addAction(profileToolBar.clearAction)
# Connect to StackView's signal
self.valueChanged.connect(self._statusBarSlot)
diff --git a/silx/gui/plot/StatsWidget.py b/silx/gui/plot/StatsWidget.py
index 52b7e5c..6b92ea0 100644
--- a/silx/gui/plot/StatsWidget.py
+++ b/silx/gui/plot/StatsWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -180,7 +180,10 @@ class _PlotWidgetWrapper(_Wrapper):
def getItems(self):
plot = self.getPlot()
- return () if plot is None else plot._getItems()
+ if plot is None:
+ return ()
+ else:
+ return [item for item in plot.getItems() if item.isVisible()]
def getSelectedItems(self):
plot = self.getPlot()
@@ -198,10 +201,10 @@ class _PlotWidgetWrapper(_Wrapper):
kind = self.getKind(item)
if kind in plot._ACTIVE_ITEM_KINDS:
if plot._getActiveItem(kind) != item:
- plot._setActiveItem(kind, item.getLegend())
+ plot._setActiveItem(kind, item.getName())
def getLabel(self, item):
- return item.getLegend()
+ return item.getName()
def getKind(self, item):
if isinstance(item, plotitems.Curve):
@@ -1371,7 +1374,7 @@ class _BaseLineStatsWidget(_StatsWidgetBase, qt.QWidget):
return self._plotWrapper.getKind(_item) == self.getKind()
items = list(filter(kind_filter, _items))
assert len(items) in (0, 1)
- if len(items) is 1:
+ if len(items) == 1:
self._setItem(items[0])
def setKind(self, kind):
@@ -1413,7 +1416,7 @@ class _BaseLineStatsWidget(_StatsWidgetBase, qt.QWidget):
return self._plotWrapper.getKind(_item) == self.getKind()
items = list(filter(kind_filter, _items))
assert len(items) in (0, 1)
- _item = items[0] if len(items) is 1 else None
+ _item = items[0] if len(items) == 1 else None
self._setItem(_item)
def _updateCurrentItem(self):
diff --git a/silx/gui/plot/_BaseMaskToolsWidget.py b/silx/gui/plot/_BaseMaskToolsWidget.py
index d8e9fb5..aa4921c 100644
--- a/silx/gui/plot/_BaseMaskToolsWidget.py
+++ b/silx/gui/plot/_BaseMaskToolsWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -456,8 +456,8 @@ class BaseMaskToolsWidget(qt.QWidget):
assert mode in ('exclusive', 'single')
if mode != self._multipleMasks:
self._multipleMasks = mode
- self.levelWidget.setVisible(self._multipleMasks != 'single')
- self.clearAllBtn.setVisible(self._multipleMasks != 'single')
+ self._levelWidget.setVisible(self._multipleMasks != 'single')
+ self._clearAllBtn.setVisible(self._multipleMasks != 'single')
@property
def maskFileDir(self):
@@ -545,62 +545,109 @@ class BaseMaskToolsWidget(qt.QWidget):
'Choose which mask level is edited.\n'
'A mask can have up to 255 non-overlapping levels.')
self.levelSpinBox.valueChanged[int].connect(self._updateColors)
- self.levelWidget = self._hboxWidget(qt.QLabel('Mask level:'),
+ self._levelWidget = self._hboxWidget(qt.QLabel('Mask level:'),
self.levelSpinBox)
# Transparency
- self.transparencyWidget = self._initTransparencyWidget()
+ self._transparencyWidget = self._initTransparencyWidget()
+
+ style = qt.QApplication.style()
+
+ def getIcon(*identifiyers):
+ for i in identifiyers:
+ if isinstance(i, str):
+ if qt.QIcon.hasThemeIcon(i):
+ return qt.QIcon.fromTheme(i)
+ elif isinstance(i, qt.QIcon):
+ return i
+ else:
+ return style.standardIcon(i)
+ return qt.QIcon()
+
+ undoAction = qt.QAction(self)
+ undoAction.setText('Undo')
+ icon = getIcon("edit-undo", qt.QStyle.SP_ArrowBack)
+ undoAction.setIcon(icon)
+ undoAction.setShortcut(qt.QKeySequence.Undo)
+ undoAction.setToolTip('Undo last mask change <b>%s</b>' %
+ undoAction.shortcut().toString())
+ self._mask.sigUndoable.connect(undoAction.setEnabled)
+ undoAction.triggered.connect(self._mask.undo)
+
+ redoAction = qt.QAction(self)
+ redoAction.setText('Redo')
+ icon = getIcon("edit-redo", qt.QStyle.SP_ArrowForward)
+ redoAction.setIcon(icon)
+ redoAction.setShortcut(qt.QKeySequence.Redo)
+ redoAction.setToolTip('Redo last undone mask change <b>%s</b>' %
+ redoAction.shortcut().toString())
+ self._mask.sigRedoable.connect(redoAction.setEnabled)
+ redoAction.triggered.connect(self._mask.redo)
+
+ loadAction = qt.QAction(self)
+ loadAction.setText('Load...')
+ icon = icons.getQIcon("document-open")
+ loadAction.setIcon(icon)
+ loadAction.setToolTip('Load mask from file')
+ loadAction.triggered.connect(self._loadMask)
+
+ saveAction = qt.QAction(self)
+ saveAction.setText('Save...')
+ icon = icons.getQIcon("document-save")
+ saveAction.setIcon(icon)
+ saveAction.setToolTip('Save mask to file')
+ saveAction.triggered.connect(self._saveMask)
+
+ invertAction = qt.QAction(self)
+ invertAction.setText('Invert')
+ icon = icons.getQIcon("mask-invert")
+ invertAction.setIcon(icon)
+ invertAction.setShortcut(qt.Qt.CTRL + qt.Qt.Key_I)
+ invertAction.setToolTip('Invert current mask <b>%s</b>' %
+ invertAction.shortcut().toString())
+ invertAction.triggered.connect(self._handleInvertMask)
+
+ clearAction = qt.QAction(self)
+ clearAction.setText('Clear')
+ icon = icons.getQIcon("mask-clear")
+ clearAction.setIcon(icon)
+ clearAction.setShortcut(qt.QKeySequence.Delete)
+ clearAction.setToolTip('Clear current mask level <b>%s</b>' %
+ clearAction.shortcut().toString())
+ clearAction.triggered.connect(self._handleClearMask)
+
+ clearAllAction = qt.QAction(self)
+ clearAllAction.setText('Clear all')
+ icon = icons.getQIcon("mask-clear-all")
+ clearAllAction.setIcon(icon)
+ clearAllAction.setToolTip('Clear all mask levels')
+ clearAllAction.triggered.connect(self.resetSelectionMask)
# Buttons group
- invertBtn = qt.QPushButton('Invert')
- invertBtn.setShortcut(qt.Qt.CTRL + qt.Qt.Key_I)
- invertBtn.setToolTip('Invert current mask <b>%s</b>' %
- invertBtn.shortcut().toString())
- invertBtn.clicked.connect(self._handleInvertMask)
-
- clearBtn = qt.QPushButton('Clear')
- clearBtn.setShortcut(qt.QKeySequence.Delete)
- clearBtn.setToolTip('Clear current mask level <b>%s</b>' %
- clearBtn.shortcut().toString())
- clearBtn.clicked.connect(self._handleClearMask)
-
- invertClearWidget = self._hboxWidget(
- invertBtn, clearBtn, stretch=False)
-
- undoBtn = qt.QPushButton('Undo')
- undoBtn.setShortcut(qt.QKeySequence.Undo)
- undoBtn.setToolTip('Undo last mask change <b>%s</b>' %
- undoBtn.shortcut().toString())
- self._mask.sigUndoable.connect(undoBtn.setEnabled)
- undoBtn.clicked.connect(self._mask.undo)
-
- redoBtn = qt.QPushButton('Redo')
- redoBtn.setShortcut(qt.QKeySequence.Redo)
- redoBtn.setToolTip('Redo last undone mask change <b>%s</b>' %
- redoBtn.shortcut().toString())
- self._mask.sigRedoable.connect(redoBtn.setEnabled)
- redoBtn.clicked.connect(self._mask.redo)
-
- undoRedoWidget = self._hboxWidget(undoBtn, redoBtn, stretch=False)
-
- self.clearAllBtn = qt.QPushButton('Clear all')
- self.clearAllBtn.setToolTip('Clear all mask levels')
- self.clearAllBtn.clicked.connect(self.resetSelectionMask)
-
- loadBtn = qt.QPushButton('Load...')
- loadBtn.clicked.connect(self._loadMask)
-
- saveBtn = qt.QPushButton('Save...')
- saveBtn.clicked.connect(self._saveMask)
-
- self.loadSaveWidget = self._hboxWidget(loadBtn, saveBtn, stretch=False)
+ margin1 = qt.QWidget(self)
+ margin1.setMinimumWidth(6)
+ margin2 = qt.QWidget(self)
+ margin2.setMinimumWidth(6)
+
+ actions = (loadAction, saveAction, margin1,
+ undoAction, redoAction, margin2,
+ invertAction, clearAction, clearAllAction)
+ widgets = []
+ for action in actions:
+ if isinstance(action, qt.QWidget):
+ widgets.append(action)
+ continue
+ btn = qt.QToolButton()
+ btn.setDefaultAction(action)
+ widgets.append(btn)
+ if action is clearAllAction:
+ self._clearAllBtn = btn
+ container = self._hboxWidget(*widgets)
+ container.layout().setSpacing(1)
layout = qt.QVBoxLayout()
- layout.addWidget(self.levelWidget)
- layout.addWidget(self.transparencyWidget)
- layout.addWidget(invertClearWidget)
- layout.addWidget(undoRedoWidget)
- layout.addWidget(self.clearAllBtn)
- layout.addWidget(self.loadSaveWidget)
+ layout.addWidget(container)
+ layout.addWidget(self._levelWidget)
+ layout.addWidget(self._transparencyWidget)
layout.addStretch(1)
maskGroup = qt.QGroupBox('Mask')
@@ -813,6 +860,7 @@ class BaseMaskToolsWidget(qt.QWidget):
layout.addWidget(toolBar)
layout.addLayout(config)
layout.addWidget(self.applyMaskBtn)
+ layout.addStretch(1)
self.thresholdGroup = qt.QGroupBox('Threshold')
self.thresholdGroup.setLayout(layout)
@@ -830,6 +878,7 @@ class BaseMaskToolsWidget(qt.QWidget):
self.maskNanBtn.setToolTip('Mask Not a Number and infinite values')
self.maskNanBtn.clicked.connect(self._maskNotFiniteBtnClicked)
layout.addWidget(self.maskNanBtn)
+ layout.addStretch(1)
self.otherToolGroup = qt.QGroupBox('Other tools')
self.otherToolGroup.setLayout(layout)
diff --git a/silx/gui/plot/actions/PlotToolAction.py b/silx/gui/plot/actions/PlotToolAction.py
index 77e8be2..fbb0b0f 100644
--- a/silx/gui/plot/actions/PlotToolAction.py
+++ b/silx/gui/plot/actions/PlotToolAction.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2020 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
@@ -131,7 +131,7 @@ class PlotToolAction(PlotAction):
return PlotAction.eventFilter(self, qobject, event)
def _getToolWindow(self):
- """Returns the window containg tohe tool.
+ """Returns the window containing the tool.
It uses lazy loading to create this tool..
"""
diff --git a/silx/gui/plot/actions/control.py b/silx/gui/plot/actions/control.py
index e2fa6b1..ba69748 100755
--- a/silx/gui/plot/actions/control.py
+++ b/silx/gui/plot/actions/control.py
@@ -391,9 +391,8 @@ class ColormapAction(PlotAction):
elif isinstance(image, items.ColormapMixIn):
# Set dialog from active image
colormap = image.getColormap()
- data = image.getData(copy=False)
# Set histogram and range if any
- self._dialog.setData(data)
+ self._dialog.setItem(image)
else:
# No active image or active image is RGBA,
@@ -401,8 +400,7 @@ class ColormapAction(PlotAction):
scatter = self.plot._getActiveItem(kind='scatter')
if scatter is not None:
colormap = scatter.getColormap()
- data = scatter.getValueData(copy=False)
- self._dialog.setData(data)
+ self._dialog.setItem(scatter)
else:
# No active data image nor scatter,
@@ -605,3 +603,32 @@ class ShowAxisAction(PlotAction):
def _actionTriggered(self, checked=False):
self.plot.setAxesDisplayed(checked)
+
+class ClosePolygonInteractionAction(PlotAction):
+ """QAction controlling closure of a polygon in draw interaction mode
+ if the :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ tooltip = 'Close the current polygon drawn'
+ PlotAction.__init__(self,
+ plot,
+ icon='add-shape-polygon',
+ text='Close the polygon',
+ tooltip=tooltip,
+ triggered=self._actionTriggered,
+ checkable=True,
+ parent=parent)
+ self.plot.sigInteractiveModeChanged.connect(self._modeChanged)
+ self._modeChanged(None)
+
+ def _modeChanged(self, source):
+ mode = self.plot.getInteractiveMode()
+ enabled = "shape" in mode and mode["shape"] == "polygon"
+ self.setEnabled(enabled)
+
+ def _actionTriggered(self, checked=False):
+ self.plot._eventHandler.validate()
diff --git a/silx/gui/plot/actions/fit.py b/silx/gui/plot/actions/fit.py
index 6fc5c75..f3c9e1c 100644
--- a/silx/gui/plot/actions/fit.py
+++ b/silx/gui/plot/actions/fit.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2020 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
@@ -38,52 +38,43 @@ __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
__date__ = "10/10/2018"
-from .PlotToolAction import PlotToolAction
import logging
+
+import numpy
+
+from .PlotToolAction import PlotToolAction
+from .. import items
+from ....utils.deprecation import deprecated
from silx.gui import qt
from silx.gui.plot.ItemsSelectionDialog import ItemsSelectionDialog
-from silx.gui.plot.items import Curve, Histogram
_logger = logging.getLogger(__name__)
-def _getUniqueCurve(plt):
- """Get a single curve from the plot.
- Get the active curve if any, else if a single curve is plotted
- get it, else return None.
+def _getUniqueCurveOrHistogram(plot):
+ """Returns unique :class:`Curve` or :class:`Histogram` in a `PlotWidget`.
- :param plt: :class:`.PlotWidget` instance on which to operate
+ If there is an active curve, returns it, else return curve or histogram
+ only if alone in the plot.
- :return: return value of plt.getActiveCurve(), or plt.getAllCurves()[0],
- or None
+ :param PlotWidget plot:
+ :rtype: Union[None,~silx.gui.plot.items.Curve,~silx.gui.plot.items.Histogram]
"""
- curve = plt.getActiveCurve()
+ curve = plot.getActiveCurve()
if curve is not None:
return curve
- curves = plt.getAllCurves()
- if len(curves) == 0:
- return None
+ histograms = [item for item in plot.getItems()
+ if isinstance(item, items.Histogram) and item.isVisible()]
+ curves = [item for item in plot.getItems()
+ if isinstance(item, items.Curve) and item.isVisible()]
- if len(curves) == 1 and len(plt._getItems(kind='histogram')) == 0:
+ if len(histograms) == 1 and len(curves) == 0:
+ return histograms[0]
+ elif len(curves) == 1 and len(histograms) == 0:
return curves[0]
-
- return None
-
-
-def _getUniqueHistogram(plt):
- """Return the histogram if there is a single histogram and no curve in
- the plot. In all other cases, return None.
-
- :param plt: :class:`.PlotWidget` instance on which to operate
- :return: histogram or None
- """
- histograms = plt._getItems(kind='histogram')
- if len(histograms) != 1:
- return None
- if plt.getAllCurves(just_legend=True):
+ else:
return None
- return histograms[0]
class FitAction(PlotToolAction):
@@ -93,78 +84,303 @@ class FitAction(PlotToolAction):
:param plot: :class:`.PlotWidget` instance on which to operate
:param parent: See :class:`QAction`
"""
+
def __init__(self, plot, parent=None):
+ self.__item = None
+ self.__activeCurveSynchroEnabled = False
+ self.__range = 0, 1
+ self.__rangeAutoUpdate = False
+ self.__x, self.__y = None, None # Data to fit
+ self.__curveParams = {} # Store curve parameters to use for fit result
+ self.__legend = None
+
super(FitAction, self).__init__(
plot, icon='math-fit', text='Fit curve',
tooltip='Open a fit dialog',
parent=parent)
+ @property
+ @deprecated(replacement='getXRange()[0]', since_version='0.13.0')
+ def xmin(self):
+ return self.getXRange()[0]
+
+ @property
+ @deprecated(replacement='getXRange()[1]', since_version='0.13.0')
+ def xmax(self):
+ return self.getXRange()[1]
+
+ @property
+ @deprecated(replacement='getXData()', since_version='0.13.0')
+ def x(self):
+ return self.getXData()
+
+ @property
+ @deprecated(replacement='getYData()', since_version='0.13.0')
+ def y(self):
+ return self.getYData()
+
+ @property
+ @deprecated(since_version='0.13.0')
+ def xlabel(self):
+ return self.__curveParams.get('xlabel', None)
+
+ @property
+ @deprecated(since_version='0.13.0')
+ def ylabel(self):
+ return self.__curveParams.get('ylabel', None)
+
+ @property
+ @deprecated(since_version='0.13.0')
+ def legend(self):
+ return self.__legend
+
def _createToolWindow(self):
# import done here rather than at module level to avoid circular import
# FitWidget -> BackgroundWidget -> PlotWindow -> actions -> fit -> FitWidget
from ...fit.FitWidget import FitWidget
window = FitWidget(parent=self.plot)
- window.setWindowFlags(qt.Qt.Window)
+ window.setWindowFlags(qt.Qt.Dialog)
window.sigFitWidgetSignal.connect(self.handle_signal)
return window
def _connectPlot(self, window):
- # Wait for the next iteration, else the plot is not yet initialized
- # No curve available
- qt.QTimer.singleShot(10, lambda: self._initFit(window))
+ if self.isXRangeUpdatedOnZoom():
+ self.__setAutoXRangeEnabled(True)
+ else:
+ plot = self.plot
+ if plot is None:
+ _logger.error("No associated PlotWidget")
+ return
+ self._setXRange(*plot.getXAxis().getLimits())
- def _initFit(self, window):
- plot = self.plot
- self.xlabel = plot.getXAxis().getLabel()
- self.ylabel = plot.getYAxis().getLabel()
- self.xmin, self.xmax = plot.getXAxis().getLimits()
+ if self.isFittedItemUpdatedFromActiveCurve():
+ self.__setFittedItemAutoUpdateEnabled(True)
+ else:
+ # Wait for the next iteration, else the plot is not yet initialized
+ # No curve available
+ qt.QTimer.singleShot(10, self._initFit)
- histo = _getUniqueHistogram(self.plot)
- curve = _getUniqueCurve(self.plot)
+ def _disconnectPlot(self, window):
+ if self.isXRangeUpdatedOnZoom():
+ self.__setAutoXRangeEnabled(False)
- if histo is None and curve is None:
+ if self.isFittedItemUpdatedFromActiveCurve():
+ self.__setFittedItemAutoUpdateEnabled(False)
+
+ def _initFit(self):
+ plot = self.plot
+ if plot is None:
+ _logger.error("No associated PlotWidget")
+ return
+
+ item = _getUniqueCurveOrHistogram(plot)
+ if item is None:
# ambiguous case, we need to ask which plot item to fit
- isd = ItemsSelectionDialog(parent=plot, plot=self.plot)
+ isd = ItemsSelectionDialog(parent=plot, plot=plot)
isd.setWindowTitle("Select item to be fitted")
isd.setItemsSelectionMode(qt.QTableWidget.SingleSelection)
isd.setAvailableKinds(["curve", "histogram"])
isd.selectAllKinds()
- result = isd.exec_()
- if result and len(isd.getSelectedItems()) == 1:
- item = isd.getSelectedItems()[0]
+ if not isd.exec_(): # Cancel
+ self._getToolWindow().setVisible(False)
else:
- return
- elif histo is not None:
- # presence of a unique histo and no curve
- item = histo
- elif curve is not None:
- # presence of a unique or active curve
- item = curve
+ selectedItems = isd.getSelectedItems()
+ item = selectedItems[0] if len(selectedItems) == 1 else None
+
+ self._setXRange(*plot.getXAxis().getLimits())
+ self._setFittedItem(item)
+
+ def __updateFitWidget(self):
+ """Update the data/range used by the FitWidget"""
+ fitWidget = self._getToolWindow()
+
+ item = self._getFittedItem()
+ xdata = self.getXData(copy=False)
+ ydata = self.getYData(copy=False)
+ if item is None or xdata is None or ydata is None:
+ fitWidget.setData(y=None)
+ fitWidget.setWindowTitle("No curve selected")
+
+ else:
+ xmin, xmax = self.getXRange()
+ fitWidget.setData(
+ xdata, ydata, xmin=xmin, xmax=xmax)
+ fitWidget.setWindowTitle(
+ "Fitting " + item.getName() +
+ " on x range %f-%f" % (xmin, xmax))
+
+ # X Range management
+
+ def getXRange(self):
+ """Returns the range on the X axis on which to perform the fit."""
+ return self.__range
+
+ def _setXRange(self, xmin, xmax):
+ """Set the range on which the fit is done.
+
+ :param float xmin:
+ :param float xmax:
+ """
+ range_ = float(xmin), float(xmax)
+ if self.__range != range_:
+ self.__range = range_
+ self.__updateFitWidget()
+
+ def __setAutoXRangeEnabled(self, enabled):
+ """Implement the change of update mode of the X range.
+
+ :param bool enabled:
+ """
+ plot = self.plot
+ if plot is None:
+ _logger.error("No associated PlotWidget")
+ return
- self.legend = item.getLegend()
+ if enabled:
+ self._setXRange(*plot.getXAxis().getLimits())
+ plot.getXAxis().sigLimitsChanged.connect(self._setXRange)
+ else:
+ plot.getXAxis().sigLimitsChanged.disconnect(self._setXRange)
- if isinstance(item, Histogram):
+ def setXRangeUpdatedOnZoom(self, enabled):
+ """Set whether or not to update the X range on zoom change.
+
+ :param bool enabled:
+ """
+ if enabled != self.__rangeAutoUpdate:
+ self.__rangeAutoUpdate = enabled
+ if self._getToolWindow().isVisible():
+ self.__setAutoXRangeEnabled(enabled)
+
+ def isXRangeUpdatedOnZoom(self):
+ """Returns the current mode of fitted data X range update.
+
+ :rtype: bool
+ """
+ return self.__rangeAutoUpdate
+
+ # Fitted item update
+
+ def getXData(self, copy=True):
+ """Returns the X data used for the fit or None if undefined.
+
+ :param bool copy:
+ True to get a copy of the data, False to get the internal data.
+ :rtype: Union[numpy.ndarray,None]
+ """
+ return None if self.__x is None else numpy.array(self.__x, copy=copy)
+
+ def getYData(self, copy=True):
+ """Returns the Y data used for the fit or None if undefined.
+
+ :param bool copy:
+ True to get a copy of the data, False to get the internal data.
+ :rtype: Union[numpy.ndarray,None]
+ """
+ return None if self.__y is None else numpy.array(self.__y, copy=copy)
+
+ def _getFittedItem(self):
+ """Returns the current item used for the fit
+
+ :rtype: Union[~silx.gui.plot.items.Curve,~silx.gui.plot.items.Histogram,None]
+ """
+ return self.__item
+
+ def _setFittedItem(self, item):
+ """Set the curve to use for fitting.
+
+ :param Union[~silx.gui.plot.items.Curve,~silx.gui.plot.items.Histogram,None] item:
+ """
+ plot = self.plot
+ if plot is None:
+ _logger.error("No associated PlotWidget")
+
+ if plot is None or item is None:
+ self.__item = None
+ self.__curveParams = {}
+ self.__updateFitWidget()
+ return
+
+ axis = item.getYAxis() if isinstance(item, items.YAxisMixIn) else 'left'
+ self.__curveParams = {
+ 'yaxis': axis,
+ 'xlabel': plot.getXAxis().getLabel(),
+ 'ylabel': plot.getYAxis(axis).getLabel(),
+ }
+ self.__legend = item.getName()
+
+ if isinstance(item, items.Histogram):
bin_edges = item.getBinEdgesData(copy=False)
# take the middle coordinate between adjacent bin edges
- self.x = (bin_edges[1:] + bin_edges[:-1]) / 2
- self.y = item.getValueData(copy=False)
+ self.__x = (bin_edges[1:] + bin_edges[:-1]) / 2
+ self.__y = item.getValueData(copy=False)
# else take the active curve, or else the unique curve
- elif isinstance(item, Curve):
- self.x = item.getXData(copy=False)
- self.y = item.getYData(copy=False)
+ elif isinstance(item, items.Curve):
+ self.__x = item.getXData(copy=False)
+ self.__y = item.getYData(copy=False)
+
+ self.__item = item
+ self.__updateFitWidget()
+
+ def __activeCurveChanged(self, previous, current):
+ """Handle change of active curve in the PlotWidget
+ """
+ if current is None:
+ self._setFittedItem(None)
+ else:
+ item = self.plot.getCurve(current)
+ self._setFittedItem(item)
+
+ def __setFittedItemAutoUpdateEnabled(self, enabled):
+ """Implement the change of fitted item update mode
+
+ :param bool enabled:
+ """
+ plot = self.plot
+ if plot is None:
+ _logger.error("No associated PlotWidget")
+ return
+
+ if enabled:
+ self._setFittedItem(plot.getActiveCurve())
+ plot.sigActiveCurveChanged.connect(self.__activeCurveChanged)
+
+ else:
+ plot.sigActiveCurveChanged.disconnect(
+ self.__activeCurveChanged)
+
+ def setFittedItemUpdatedFromActiveCurve(self, enabled):
+ """Toggle fitted data synchronization with plot active curve.
+
+ :param bool enabled:
+ """
+ enabled = bool(enabled)
+ if enabled != self.__activeCurveSynchroEnabled:
+ self.__activeCurveSynchroEnabled = enabled
+ if self._getToolWindow().isVisible():
+ self.__setFittedItemAutoUpdateEnabled(enabled)
+
+ def isFittedItemUpdatedFromActiveCurve(self):
+ """Returns True if fitted data is synchronized with plot.
+
+ :rtype: bool
+ """
+ return self.__activeCurveSynchroEnabled
- window.setData(self.x, self.y,
- xmin=self.xmin, xmax=self.xmax)
- window.setWindowTitle(
- "Fitting " + self.legend +
- " on x range %f-%f" % (self.xmin, self.xmax))
+ # Handle fit completed
def handle_signal(self, ddict):
- x_fit = self.x[self.xmin <= self.x]
- x_fit = x_fit[x_fit <= self.xmax]
- fit_legend = "Fit <%s>" % self.legend
+ xdata = self.getXData(copy=False)
+ if xdata is None:
+ _logger.error("No reference data to display fit result for")
+ return
+
+ xmin, xmax = self.getXRange()
+ x_fit = xdata[xmin <= xdata]
+ x_fit = x_fit[x_fit <= xmax]
+ fit_legend = "Fit <%s>" % self.__legend
fit_curve = self.plot.getCurve(fit_legend)
if ddict["event"] == "FitFinished":
@@ -175,11 +391,12 @@ class FitAction(PlotToolAction):
if fit_curve is None:
self.plot.addCurve(x_fit, y_fit,
fit_legend,
- xlabel=self.xlabel, ylabel=self.ylabel,
- resetzoom=False)
+ resetzoom=False,
+ **self.__curveParams)
else:
fit_curve.setData(x_fit, y_fit)
fit_curve.setVisible(True)
+ fit_curve.setYAxis(self.__curveParams.get('yaxis', 'left'))
if ddict["event"] in ["FitStarted", "FitFailed"]:
if fit_curve is not None:
diff --git a/silx/gui/plot/actions/histogram.py b/silx/gui/plot/actions/histogram.py
index 3bb3e6a..f3e6370 100644
--- a/silx/gui/plot/actions/histogram.py
+++ b/silx/gui/plot/actions/histogram.py
@@ -37,16 +37,83 @@ __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__date__ = "10/10/2018"
__license__ = "MIT"
+import numpy
+import logging
+import weakref
+
from .PlotToolAction import PlotToolAction
from silx.math.histogram import Histogramnd
from silx.math.combo import min_max
-import numpy
-import logging
from silx.gui import qt
+from silx.gui.plot import items
_logger = logging.getLogger(__name__)
+class _LastActiveItem(qt.QObject):
+
+ sigActiveItemChanged = qt.Signal(object, object)
+ """Emitted when the active plot item have changed"""
+
+ def __init__(self, parent, plot):
+ assert plot is not None
+ super(_LastActiveItem, self).__init__(parent=parent)
+ self.__plot = weakref.ref(plot)
+ self.__item = None
+ item = self.__findActiveItem()
+ self.setActiveItem(item)
+ plot.sigActiveImageChanged.connect(self._activeImageChanged)
+ plot.sigActiveScatterChanged.connect(self._activeScatterChanged)
+
+ def getPlotWidget(self):
+ return self.__plot()
+
+ def __findActiveItem(self):
+ plot = self.getPlotWidget()
+ image = plot.getActiveImage()
+ if image is not None:
+ return image
+ scatter = plot.getActiveScatter()
+ if scatter is not None:
+ return scatter
+
+ def getActiveItem(self):
+ if self.__item is None:
+ return None
+ item = self.__item()
+ if item is None:
+ self.__item = None
+ return item
+
+ def setActiveItem(self, item):
+ previous = self.getActiveItem()
+ if previous is item:
+ return
+ if item is None:
+ self.__item = None
+ else:
+ self.__item = weakref.ref(item)
+ self.sigActiveItemChanged.emit(previous, item)
+
+ def _activeImageChanged(self, previous, current):
+ """Handle active image change"""
+ plot = self.getPlotWidget()
+ item = plot.getImage(current)
+ if item is None:
+ self.setActiveItem(None)
+ elif isinstance(item, items.ImageBase):
+ self.setActiveItem(item)
+ else:
+ # Do not touch anything, which is consistent with silx v0.12 behavior
+ pass
+
+ def _activeScatterChanged(self, previous, current):
+ """Handle active scatter change"""
+ plot = self.getPlotWidget()
+ item = plot.getScatter(current)
+ self.setActiveItem(item)
+
+
class PixelIntensitiesHistoAction(PlotToolAction):
"""QAction to plot the pixels intensities diagram
@@ -61,66 +128,110 @@ class PixelIntensitiesHistoAction(PlotToolAction):
text='pixels intensity',
tooltip='Compute image intensity distribution',
parent=parent)
- self._connectedToActiveImage = False
+ self._lastItemFilter = _LastActiveItem(self, plot)
self._histo = None
+ self._item = None
def _connectPlot(self, window):
- if not self._connectedToActiveImage:
- self.plot.sigActiveImageChanged.connect(
- self._activeImageChanged)
- self._connectedToActiveImage = True
- self.computeIntensityDistribution()
+ self._lastItemFilter.sigActiveItemChanged.connect(self._activeItemChanged)
+ item = self._lastItemFilter.getActiveItem()
+ self._setSelectedItem(item)
PlotToolAction._connectPlot(self, window)
def _disconnectPlot(self, window):
- if self._connectedToActiveImage:
- self.plot.sigActiveImageChanged.disconnect(
- self._activeImageChanged)
- self._connectedToActiveImage = False
+ self._lastItemFilter.sigActiveItemChanged.disconnect(self._activeItemChanged)
PlotToolAction._disconnectPlot(self, window)
+ self._setSelectedItem(None)
- def _activeImageChanged(self, previous, legend):
- """Handle active image change: toggle enabled toolbar, update curve"""
+ def _getSelectedItem(self):
+ item = self._item
+ if item is None:
+ return None
+ else:
+ return item()
+
+ def _activeItemChanged(self, previous, current):
if self._isWindowInUse():
+ self._setSelectedItem(current)
+
+ def _setSelectedItem(self, item):
+ if item is not None:
+ if not isinstance(item, (items.ImageBase, items.Scatter)):
+ # Filter out other things
+ return
+
+ old = self._getSelectedItem()
+ if item is old:
+ return
+ if old is not None:
+ old.sigItemChanged.disconnect(self._itemUpdated)
+ if item is None:
+ self._item = None
+ else:
+ self._item = weakref.ref(item)
+ item.sigItemChanged.connect(self._itemUpdated)
+ self.computeIntensityDistribution()
+
+ def _itemUpdated(self, event):
+ if event == items.ItemChangedType.DATA:
self.computeIntensityDistribution()
+ def _cleanUp(self):
+ plot = self.getHistogramPlotWidget()
+ try:
+ plot.remove('pixel intensity', kind='item')
+ except Exception:
+ pass
+
def computeIntensityDistribution(self):
"""Get the active image and compute the image intensity distribution
"""
- activeImage = self.plot.getActiveImage()
+ item = self._getSelectedItem()
- if activeImage is not None:
- image = activeImage.getData(copy=False)
- if image.ndim == 3: # RGB(A) images
+ if item is None:
+ self._cleanUp()
+ return
+
+ if isinstance(item, items.ImageBase):
+ array = item.getData(copy=False)
+ if array.ndim == 3: # RGB(A) images
_logger.info('Converting current image from RGB(A) to grayscale\
in order to compute the intensity distribution')
- image = (image[:, :, 0] * 0.299 +
- image[:, :, 1] * 0.587 +
- image[:, :, 2] * 0.114)
-
- xmin, xmax = min_max(image, min_positive=False, finite=True)
- nbins = min(1024, int(numpy.sqrt(image.size)))
- data_range = xmin, xmax
-
- # bad hack: get 256 bins in the case we have a B&W
- if numpy.issubdtype(image.dtype, numpy.integer):
- if nbins > xmax - xmin:
- nbins = xmax - xmin
-
- nbins = max(2, nbins)
-
- data = image.ravel().astype(numpy.float32)
- histogram = Histogramnd(data, n_bins=nbins, histo_range=data_range)
- assert len(histogram.edges) == 1
- self._histo = histogram.histo
- edges = histogram.edges[0]
- plot = self.getHistogramPlotWidget()
- plot.addHistogram(histogram=self._histo,
- edges=edges,
- legend='pixel intensity',
- fill=True,
- color='#66aad7')
- plot.resetZoom()
+ array = (array[:, :, 0] * 0.299 +
+ array[:, :, 1] * 0.587 +
+ array[:, :, 2] * 0.114)
+ elif isinstance(item, items.Scatter):
+ array = item.getValueData(copy=False)
+ else:
+ assert(False)
+
+ if array.size == 0:
+ self._cleanUp()
+ return
+
+ xmin, xmax = min_max(array, min_positive=False, finite=True)
+ nbins = min(1024, int(numpy.sqrt(array.size)))
+ data_range = xmin, xmax
+
+ # bad hack: get 256 bins in the case we have a B&W
+ if numpy.issubdtype(array.dtype, numpy.integer):
+ if nbins > xmax - xmin:
+ nbins = xmax - xmin
+
+ nbins = max(2, nbins)
+
+ data = array.ravel().astype(numpy.float32)
+ histogram = Histogramnd(data, n_bins=nbins, histo_range=data_range)
+ assert len(histogram.edges) == 1
+ self._histo = histogram.histo
+ edges = histogram.edges[0]
+ plot = self.getHistogramPlotWidget()
+ plot.addHistogram(histogram=self._histo,
+ edges=edges,
+ legend='pixel intensity',
+ fill=True,
+ color='#66aad7')
+ plot.resetZoom()
def getHistogramPlotWidget(self):
"""Create the plot histogram if needed, otherwise create it
diff --git a/silx/gui/plot/actions/medfilt.py b/silx/gui/plot/actions/medfilt.py
index 276f970..f86a377 100644
--- a/silx/gui/plot/actions/medfilt.py
+++ b/silx/gui/plot/actions/medfilt.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2020 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
@@ -89,7 +89,7 @@ class MedianFilterAction(PlotToolAction):
self._legend = None
else:
self._originalImage = self.plot.getImage(self._activeImageLegend).getData(copy=False)
- self._legend = self.plot.getImage(self._activeImageLegend).getLegend()
+ self._legend = self.plot.getImage(self._activeImageLegend).getName()
def _updateFilter(self, kernelWidth, conditional=False):
if self._originalImage is None:
diff --git a/silx/gui/plot/backends/BackendBase.py b/silx/gui/plot/backends/BackendBase.py
index 75d999b..bcc93a5 100755
--- a/silx/gui/plot/backends/BackendBase.py
+++ b/silx/gui/plot/backends/BackendBase.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2020 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
@@ -63,8 +63,6 @@ class BackendBase(object):
# Store a weakref to get access to the plot state.
self._setPlot(plot)
- self.__zoomBackAction = None
-
@property
def _plot(self):
"""The plot this backend is attached to."""
@@ -83,24 +81,12 @@ class BackendBase(object):
"""
self._plotRef = weakref.ref(plot)
- # Default Qt context menu
-
- def contextMenuEvent(self, event):
- """Override QWidget.contextMenuEvent to implement the context menu"""
- if self.__zoomBackAction is None:
- from ..actions.control import ZoomBackAction # Avoid cyclic import
- self.__zoomBackAction = ZoomBackAction(plot=self._plot,
- parent=self._plot)
- menu = qt.QMenu(self)
- menu.addAction(self.__zoomBackAction)
- menu.exec_(event.globalPos())
-
# Add methods
def addCurve(self, x, y,
color, symbol, linewidth, linestyle,
yaxis,
- xerror, yerror, z,
+ xerror, yerror,
fill, alpha, symbolsize, baseline):
"""Add a 1D curve given by x an y to the graph.
@@ -134,7 +120,6 @@ class BackendBase(object):
:type xerror: numpy.ndarray or None
:param yerror: Values with the uncertainties on the y values
:type yerror: numpy.ndarray or None
- :param int z: Layer on which to draw the cuve
:param bool fill: True to fill the curve, False otherwise
:param float alpha: Curve opacity, as a float in [0., 1.]
:param float symbolsize: Size of the symbol (if any) drawn
@@ -144,7 +129,7 @@ class BackendBase(object):
return object()
def addImage(self, data,
- origin, scale, z,
+ origin, scale,
colormap, alpha):
"""Add an image to the plot.
@@ -156,7 +141,6 @@ class BackendBase(object):
:param scale: (scale X, scale Y) of the data.
Default: (1., 1.)
:type scale: 2-tuple of float
- :param int z: Layer on which to draw the image
:param ~silx.gui.colors.Colormap colormap: Colormap object to use.
Ignored if data is RGB(A).
:param float alpha: Opacity of the image, as a float in range [0, 1].
@@ -165,7 +149,7 @@ class BackendBase(object):
return object()
def addTriangles(self, x, y, triangles,
- color, z, alpha):
+ color, alpha):
"""Add a set of triangles.
:param numpy.ndarray x: The data corresponding to the x axis
@@ -173,14 +157,13 @@ class BackendBase(object):
:param numpy.ndarray triangles: The indices to make triangles
as a (Ntriangle, 3) array
:param numpy.ndarray color: color(s) as (npoints, 4) array
- :param int z: Layer on which to draw the cuve
:param float alpha: Opacity as a float in [0., 1.]
:returns: The triangles' unique identifier used by the backend
"""
return object()
- def addItem(self, x, y, shape, color, fill, overlay, z,
- linestyle, linewidth, linebgcolor):
+ def addShape(self, x, y, shape, color, fill, overlay,
+ linestyle, linewidth, linebgcolor):
"""Add an item (i.e. a shape) to the plot.
:param numpy.ndarray x: The X coords of the points of the shape
@@ -190,7 +173,6 @@ class BackendBase(object):
:param str color: Color of the item
:param bool fill: True to fill the shape
:param bool overlay: True if item is an overlay, False otherwise
- :param int z: Layer on which to draw the item
:param str linestyle: Style of the line.
Only relevant for line markers where X or Y is None.
Value in:
@@ -545,7 +527,7 @@ class BackendBase(object):
"""
raise NotImplementedError()
- def pixelToData(self, x, y, axis, check):
+ def pixelToData(self, x, y, axis):
"""Convert a position in pixels in the widget to a position in
the data space.
@@ -553,8 +535,6 @@ class BackendBase(object):
:param float y: The Y coordinate in pixels.
:param str axis: The Y axis to use for the conversion
('left' or 'right').
- :param bool check: True to check if the coordinates are in the
- plot area.
:returns: The corresponding position in data space or
None if the pixel position is not in the plot area.
:rtype: A tuple of 2 floats: (xData, yData) or None.
diff --git a/silx/gui/plot/backends/BackendMatplotlib.py b/silx/gui/plot/backends/BackendMatplotlib.py
index 2336494..036e630 100755
--- a/silx/gui/plot/backends/BackendMatplotlib.py
+++ b/silx/gui/plot/backends/BackendMatplotlib.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2020 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
@@ -52,6 +52,7 @@ from matplotlib.patches import Rectangle, Polygon
from matplotlib.image import AxesImage
from matplotlib.backend_bases import MouseEvent
from matplotlib.lines import Line2D
+from matplotlib.text import Text
from matplotlib.collections import PathCollection, LineCollection
from matplotlib.ticker import Formatter, ScalarFormatter, Locator
from matplotlib.tri import Triangulation
@@ -252,6 +253,60 @@ class _PickableContainer(Container):
return False, {}
+class _TextWithOffset(Text):
+ """Text object which can be displayed at a specific position
+ of the plot, but with a pixel offset"""
+
+ def __init__(self, *args, **kwargs):
+ Text.__init__(self, *args, **kwargs)
+ self.pixel_offset = (0, 0)
+ self.__cache = None
+
+ def draw(self, renderer):
+ self.__cache = None
+ return Text.draw(self, renderer)
+
+ def __get_xy(self):
+ if self.__cache is not None:
+ return self.__cache
+
+ align = self.get_horizontalalignment()
+ if align == "left":
+ xoffset = self.pixel_offset[0]
+ elif align == "right":
+ xoffset = -self.pixel_offset[0]
+ else:
+ xoffset = 0
+
+ align = self.get_verticalalignment()
+ if align == "top":
+ yoffset = -self.pixel_offset[1]
+ elif align == "bottom":
+ yoffset = self.pixel_offset[1]
+ else:
+ yoffset = 0
+
+ trans = self.get_transform()
+ invtrans = self.get_transform().inverted()
+
+ x = super(_TextWithOffset, self).convert_xunits(self._x)
+ y = super(_TextWithOffset, self).convert_xunits(self._y)
+ pos = x, y
+ proj = trans.transform_point(pos)
+ proj = proj + numpy.array((xoffset, yoffset))
+ pos = invtrans.transform_point(proj)
+ self.__cache = pos
+ return pos
+
+ def convert_xunits(self, x):
+ """Return the pixel position of the annotated point."""
+ return self.__get_xy()[0]
+
+ def convert_yunits(self, y):
+ """Return the pixel position of the annotated point."""
+ return self.__get_xy()[1]
+
+
class _MarkerContainer(_PickableContainer):
"""Marker artists container supporting draw/remove and text position update
@@ -263,9 +318,10 @@ class _MarkerContainer(_PickableContainer):
:param y: Y coordinate of the marker (None for vertical lines)
"""
- def __init__(self, artists, x, y, yAxis):
+ def __init__(self, artists, symbol, x, y, yAxis):
self.line = artists[0]
self.text = artists[1] if len(artists) > 1 else None
+ self.symbol = symbol
self.x = x
self.y = y
self.yAxis = yAxis
@@ -278,27 +334,39 @@ class _MarkerContainer(_PickableContainer):
if self.text is not None:
self.text.draw(*args, **kwargs)
- def updateMarkerText(self, xmin, xmax, ymin, ymax):
+ def updateMarkerText(self, xmin, xmax, ymin, ymax, yinverted):
"""Update marker text position and visibility according to plot limits
:param xmin: X axis lower limit
:param xmax: X axis upper limit
:param ymin: Y axis lower limit
- :param ymax: Y axis upprt limit
+ :param ymax: Y axis upper limit
+ :param yinverted: True if the y axis is inverted
"""
if self.text is not None:
visible = ((self.x is None or xmin <= self.x <= xmax) and
(self.y is None or ymin <= self.y <= ymax))
self.text.set_visible(visible)
- if self.x is not None and self.y is None: # vertical line
- delta = abs(ymax - ymin)
- if ymin > ymax:
- ymax = ymin
- ymax -= 0.005 * delta
- self.text.set_y(ymax)
-
- if self.x is None and self.y is not None: # Horizontal line
+ if self.x is not None and self.y is not None:
+ if self.symbol is None:
+ valign = 'baseline'
+ else:
+ if yinverted:
+ valign = 'bottom'
+ else:
+ valign = 'top'
+ self.text.set_verticalalignment(valign)
+
+ elif self.y is None: # vertical line
+ # Always display it on top
+ center = (ymax + ymin) * 0.5
+ pos = (ymax - ymin) * 0.5 * 0.99
+ if yinverted:
+ pos = -pos
+ self.text.set_y(center + pos)
+
+ elif self.x is None: # Horizontal line
delta = abs(xmax - xmin)
if xmin > xmax:
xmax = xmin
@@ -354,9 +422,35 @@ class _DoubleColoredLinePatch(matplotlib.patches.Patch):
class Image(AxesImage):
- """An AxesImage with a fast path for uint8 RGBA images"""
+ """An AxesImage with a fast path for uint8 RGBA images.
+
+ :param List[float] silx_origin: (ox, oy) Offset of the image.
+ :param List[float] silx_scale: (sx, sy) Scale of the image.
+ """
+
+ def __init__(self, *args,
+ silx_origin=(0., 0.),
+ silx_scale=(1., 1.),
+ **kwargs):
+ super().__init__(*args, **kwargs)
+ self.__silx_origin = silx_origin
+ self.__silx_scale = silx_scale
+
+ def contains(self, mouseevent):
+ """Overridden to fill 'ind' with row and column"""
+ inside, info = super().contains(mouseevent)
+ if inside:
+ x, y = mouseevent.xdata, mouseevent.ydata
+ ox, oy = self.__silx_origin
+ sx, sy = self.__silx_scale
+ height, width = self.get_size()
+ column = numpy.clip(int((x - ox) / sx), 0, width - 1)
+ row = numpy.clip(int((y - oy) / sy), 0, height - 1)
+ info['ind'] = (row,), (column,)
+ return inside, info
def set_data(self, A):
+ """Overridden to add a fast path for RGBA unit8 images"""
A = numpy.array(A, copy=False)
if A.ndim != 3 or A.shape[2] != 4 or A.dtype != numpy.uint8:
super(Image, self).set_data(A)
@@ -402,10 +496,15 @@ class BackendMatplotlib(BackendBase.BackendBase):
# disable the use of offsets
try:
- self.ax.get_yaxis().get_major_formatter().set_useOffset(False)
- self.ax.get_xaxis().get_major_formatter().set_useOffset(False)
- self.ax2.get_yaxis().get_major_formatter().set_useOffset(False)
- self.ax2.get_xaxis().get_major_formatter().set_useOffset(False)
+ axes = [
+ self.ax.get_yaxis().get_major_formatter(),
+ self.ax.get_xaxis().get_major_formatter(),
+ self.ax2.get_yaxis().get_major_formatter(),
+ self.ax2.get_xaxis().get_major_formatter(),
+ ]
+ for axis in axes:
+ axis.set_useOffset(False)
+ axis.set_scientific(False)
except:
_logger.warning('Cannot disabled axes offsets in %s '
% matplotlib.__version__)
@@ -485,10 +584,10 @@ class BackendMatplotlib(BackendBase.BackendBase):
def addCurve(self, x, y,
color, symbol, linewidth, linestyle,
yaxis,
- xerror, yerror, z,
+ xerror, yerror,
fill, alpha, symbolsize, baseline):
for parameter in (x, y, color, symbol, linewidth, linestyle,
- yaxis, z, fill, alpha, symbolsize):
+ yaxis, fill, alpha, symbolsize):
assert parameter is not None
assert yaxis in ('left', 'right')
@@ -584,12 +683,12 @@ class BackendMatplotlib(BackendBase.BackendBase):
return _PickableContainer(artists)
- def addImage(self, data, origin, scale, z, colormap, alpha):
+ def addImage(self, data, origin, scale, colormap, alpha):
# Non-uniform image
# http://wiki.scipy.org/Cookbook/Histograms
# Non-linear axes
# http://stackoverflow.com/questions/11488800/non-linear-axes-for-imshow-in-matplotlib
- for parameter in (data, origin, scale, z):
+ for parameter in (data, origin, scale):
assert parameter is not None
origin = float(origin[0]), float(origin[1])
@@ -600,7 +699,9 @@ class BackendMatplotlib(BackendBase.BackendBase):
image = Image(self.ax,
interpolation='nearest',
picker=True,
- origin='lower')
+ origin='lower',
+ silx_origin=origin,
+ silx_scale=scale)
if alpha < 1:
image.set_alpha(alpha)
@@ -627,13 +728,17 @@ class BackendMatplotlib(BackendBase.BackendBase):
if data.ndim == 2: # Data image, convert to RGBA image
data = colormap.applyToData(data)
-
+ elif data.dtype == numpy.uint16:
+ # Normalize uint16 data to have a similar behavior as opengl backend
+ data = data.astype(numpy.float32)
+ data /= 65535
+
image.set_data(data)
self.ax.add_artist(image)
return image
- def addTriangles(self, x, y, triangles, color, z, alpha):
- for parameter in (x, y, triangles, color, z, alpha):
+ def addTriangles(self, x, y, triangles, color, alpha):
+ for parameter in (x, y, triangles, color, alpha):
assert parameter is not None
color = numpy.array(color, copy=False)
@@ -651,8 +756,8 @@ class BackendMatplotlib(BackendBase.BackendBase):
return collection
- def addItem(self, x, y, shape, color, fill, overlay, z,
- linestyle, linewidth, linebgcolor):
+ def addShape(self, x, y, shape, color, fill, overlay,
+ linestyle, linewidth, linebgcolor):
if (linebgcolor is not None and
shape not in ('rectangle', 'polygon', 'polylines')):
_logger.warning(
@@ -755,17 +860,11 @@ class BackendMatplotlib(BackendBase.BackendBase):
markersize=10.)[-1]
if text is not None:
- if symbol is None:
- valign = 'baseline'
- else:
- valign = 'top'
- text = " " + text
-
- textArtist = ax.text(x, y, text,
- color=color,
- horizontalalignment='left',
- verticalalignment=valign)
-
+ textArtist = _TextWithOffset(x, y, text,
+ color=color,
+ horizontalalignment='left')
+ if symbol is not None:
+ textArtist.pixel_offset = 10, 3
elif x is not None:
line = ax.axvline(x,
color=color,
@@ -773,11 +872,11 @@ class BackendMatplotlib(BackendBase.BackendBase):
linestyle=linestyle)
if text is not None:
# Y position will be updated in updateMarkerText call
- textArtist = ax.text(x, 1., " " + text,
- color=color,
- horizontalalignment='left',
- verticalalignment='top')
-
+ textArtist = _TextWithOffset(x, 1., text,
+ color=color,
+ horizontalalignment='left',
+ verticalalignment='top')
+ textArtist.pixel_offset = 5, 3
elif y is not None:
line = ax.axhline(y,
color=color,
@@ -786,11 +885,11 @@ class BackendMatplotlib(BackendBase.BackendBase):
if text is not None:
# X position will be updated in updateMarkerText call
- textArtist = ax.text(1., y, " " + text,
- color=color,
- horizontalalignment='right',
- verticalalignment='top')
-
+ textArtist = _TextWithOffset(1., y, text,
+ color=color,
+ horizontalalignment='right',
+ verticalalignment='top')
+ textArtist.pixel_offset = 5, 3
else:
raise RuntimeError('A marker must at least have one coordinate')
@@ -799,11 +898,12 @@ class BackendMatplotlib(BackendBase.BackendBase):
# All markers are overlays
line.set_animated(True)
if textArtist is not None:
+ ax.add_artist(textArtist)
textArtist.set_animated(True)
artists = [line] if textArtist is None else [line, textArtist]
- container = _MarkerContainer(artists, x, y, yaxis)
- container.updateMarkerText(xmin, xmax, ymin, ymax)
+ container = _MarkerContainer(artists, symbol, x, y, yaxis)
+ container.updateMarkerText(xmin, xmax, ymin, ymax, self.isYAxisInverted())
return container
@@ -811,12 +911,13 @@ class BackendMatplotlib(BackendBase.BackendBase):
xmin, xmax = self.ax.get_xbound()
ymin1, ymax1 = self.ax.get_ybound()
ymin2, ymax2 = self.ax2.get_ybound()
+ yinverted = self.isYAxisInverted()
for item in self._overlayItems():
if isinstance(item, _MarkerContainer):
if item.yAxis == 'left':
- item.updateMarkerText(xmin, xmax, ymin1, ymax1)
+ item.updateMarkerText(xmin, xmax, ymin1, ymax1, yinverted)
else:
- item.updateMarkerText(xmin, xmax, ymin2, ymax2)
+ item.updateMarkerText(xmin, xmax, ymin2, ymax2, yinverted)
# Remove methods
@@ -1076,6 +1177,7 @@ class BackendMatplotlib(BackendBase.BackendBase):
def setYAxisInverted(self, flag):
if self.ax.yaxis_inverted() != bool(flag):
self.ax.invert_yaxis()
+ self._updateMarkers()
def isYAxisInverted(self):
return self.ax.yaxis_inverted()
@@ -1143,15 +1245,15 @@ class BackendMatplotlib(BackendBase.BackendBase):
BackendBase.BackendBase.setAxesDisplayed(self, displayed)
if displayed:
# show axes and viewbox rect
- self.ax.set_axis_on()
- self.ax2.set_axis_on()
+ self.ax.set_frame_on(True)
+ self.ax2.set_frame_on(True)
# set the default margins
self.ax.set_position([.15, .15, .75, .75])
self.ax2.set_position([.15, .15, .75, .75])
else:
# hide axes and viewbox rect
- self.ax.set_axis_off()
- self.ax2.set_axis_off()
+ self.ax.set_frame_on(False)
+ self.ax2.set_frame_on(False)
# remove external margins
self.ax.set_position([0, 0, 1, 1])
self.ax2.set_position([0, 0, 1, 1])
@@ -1168,7 +1270,7 @@ class BackendMatplotlib(BackendBase.BackendBase):
else:
dataBackgroundColor = backgroundColor
- if self.ax.axison:
+ if self.ax.get_frame_on():
self.fig.patch.set_facecolor(backgroundColor)
if self._matplotlibVersion < _parse_version('2'):
self.ax.set_axis_bgcolor(dataBackgroundColor)
@@ -1187,7 +1289,7 @@ class BackendMatplotlib(BackendBase.BackendBase):
gridColor = foregroundColor
for axes in (self.ax, self.ax2):
- if axes.axison:
+ if axes.get_frame_on():
axes.spines['bottom'].set_color(foregroundColor)
axes.spines['top'].set_color(foregroundColor)
axes.spines['right'].set_color(foregroundColor)
@@ -1244,11 +1346,6 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
self.mpl_connect('motion_notify_event', self._onMouseMove)
self.mpl_connect('scroll_event', self._onMouseWheel)
- def contextMenuEvent(self, event):
- """Override QWidget.contextMenuEvent to implement the context menu"""
- # Makes sure it is overridden (issue with PySide)
- BackendBase.BackendBase.contextMenuEvent(self, event)
-
def postRedisplay(self):
self._sigPostRedisplay.emit()
@@ -1265,17 +1362,22 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
def _onMouseMove(self, event):
if self._graphCursor:
+ position = self._plot.pixelToData(
+ event.x,
+ self._mplQtYAxisCoordConversion(event.y),
+ axis='left',
+ check=True)
lineh, linev = self._graphCursor
- if event.inaxes not in (self.ax, self.ax2) and lineh.get_visible():
- lineh.set_visible(False)
- linev.set_visible(False)
- self._plot._setDirtyPlot(overlayOnly=True)
- else:
+ if position is not None:
linev.set_visible(True)
- linev.set_xdata((event.xdata, event.xdata))
+ linev.set_xdata((position[0], position[0]))
lineh.set_visible(True)
- lineh.set_ydata((event.ydata, event.ydata))
+ lineh.set_ydata((position[1], position[1]))
self._plot._setDirtyPlot(overlayOnly=True)
+ elif lineh.get_visible():
+ lineh.set_visible(False)
+ linev.set_visible(False)
+ self._plot._setDirtyPlot(overlayOnly=True)
# onMouseMove must trigger replot if dirty flag is raised
self._plot.onMouseMove(
@@ -1294,14 +1396,22 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
def leaveEvent(self, event):
"""QWidget event handler"""
- self._plot.onMouseLeaveWidget()
+ try:
+ plot = self._plot
+ except RuntimeError:
+ pass
+ else:
+ plot.onMouseLeaveWidget()
# picking
def pickItem(self, x, y, item):
mouseEvent = MouseEvent(
'button_press_event', self, x, self._mplQtYAxisCoordConversion(y))
+ # Override axes and data position with the axes
mouseEvent.inaxes = item.axes
+ mouseEvent.xdata, mouseEvent.ydata = self.pixelToData(
+ x, y, axis='left' if item.axes is self.ax else 'right')
picked, info = item.contains(mouseEvent)
if not picked:
diff --git a/silx/gui/plot/backends/BackendOpenGL.py b/silx/gui/plot/backends/BackendOpenGL.py
index 27f3894..cf1da31 100755
--- a/silx/gui/plot/backends/BackendOpenGL.py
+++ b/silx/gui/plot/backends/BackendOpenGL.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2020 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -31,7 +31,6 @@ __license__ = "MIT"
__date__ = "21/12/2018"
import logging
-import warnings
import weakref
import numpy
@@ -62,7 +61,7 @@ _logger = logging.getLogger(__name__)
# Content #####################################################################
class _ShapeItem(dict):
- def __init__(self, x, y, shape, color, fill, overlay, z,
+ def __init__(self, x, y, shape, color, fill, overlay,
linestyle, linewidth, linebgcolor):
super(_ShapeItem, self).__init__()
@@ -249,11 +248,6 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
_MOUSE_BTNS = {1: 'left', 2: 'right', 4: 'middle'}
- def contextMenuEvent(self, event):
- """Override QWidget.contextMenuEvent to implement the context menu"""
- # Makes sure it is overridden (issue with PySide)
- BackendBase.BackendBase.contextMenuEvent(self, event)
-
def sizeHint(self):
return qt.QSize(8 * 80, 6 * 80) # Mimic MatplotlibBackend
@@ -431,6 +425,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:]
isXLog = self._plotFrame.xAxis.isLog
isYLog = self._plotFrame.yAxis.isLog
+ isYInverted = self._plotFrame.isYAxisInverted
# Used by marker rendering
labels = []
@@ -572,13 +567,20 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
# Do not render markers outside visible plot area
continue
+ if isYInverted:
+ valign = BOTTOM
+ vPixelOffset = -pixelOffset
+ else:
+ valign = TOP
+ vPixelOffset = pixelOffset
+
if item['text'] is not None:
x = pixelPos[0] + pixelOffset
- y = pixelPos[1] + pixelOffset
+ y = pixelPos[1] + vPixelOffset
label = Text2D(item['text'], x, y,
color=item['color'],
bgColor=(1., 1., 1., 0.5),
- align=LEFT, valign=TOP)
+ align=LEFT, valign=valign)
labels.append(label)
# For now simple implementation: using a curve for each marker
@@ -726,10 +728,10 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
def addCurve(self, x, y,
color, symbol, linewidth, linestyle,
yaxis,
- xerror, yerror, z,
+ xerror, yerror,
fill, alpha, symbolsize, baseline):
for parameter in (x, y, color, symbol, linewidth, linestyle,
- yaxis, z, fill, symbolsize):
+ yaxis, fill, symbolsize):
assert parameter is not None
assert yaxis in ('left', 'right')
@@ -767,8 +769,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
xErrorMinus, xErrorPlus = xerror[0], xerror[1]
else:
xErrorMinus, xErrorPlus = xerror, xerror
- with warnings.catch_warnings():
- warnings.simplefilter('ignore', category=RuntimeWarning)
+ with numpy.errstate(divide='ignore', invalid='ignore'):
# Ignore divide by zero, invalid value encountered in log10
xErrorMinus = logX - numpy.log10(x - xErrorMinus)
xErrorPlus = numpy.log10(x + xErrorPlus) - logX
@@ -790,8 +791,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
yErrorMinus, yErrorPlus = yerror[0], yerror[1]
else:
yErrorMinus, yErrorPlus = yerror, yerror
- with warnings.catch_warnings():
- warnings.simplefilter('ignore', category=RuntimeWarning)
+ with numpy.errstate(divide='ignore', invalid='ignore'):
# Ignore divide by zero, invalid value encountered in log10
yErrorMinus = logY - numpy.log10(y - yErrorMinus)
yErrorPlus = numpy.log10(y + yErrorPlus) - logY
@@ -846,9 +846,9 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
return curve
def addImage(self, data,
- origin, scale, z,
+ origin, scale,
colormap, alpha):
- for parameter in (data, origin, scale, z):
+ for parameter in (data, origin, scale):
assert parameter is not None
if data.ndim == 2:
@@ -860,17 +860,25 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
'addImage: Convert %s data to float32', str(data.dtype))
data = numpy.array(data, dtype=numpy.float32, order='C')
- colormapIsLog = colormap.getNormalization() == 'log'
- cmapRange = colormap.getColormapRange(data=data)
- colormapLut = colormap.getNColors(nbColors=256)
-
- image = GLPlotColormap(data,
- origin,
- scale,
- colormapLut,
- colormapIsLog,
- cmapRange,
- alpha)
+ normalization = colormap.getNormalization()
+ if normalization in GLPlotColormap.SUPPORTED_NORMALIZATIONS:
+ # Fast path applying colormap on the GPU
+ cmapRange = colormap.getColormapRange(data=data)
+ colormapLut = colormap.getNColors(nbColors=256)
+ gamma = colormap.getGammaNormalizationParameter()
+
+ image = GLPlotColormap(data,
+ origin,
+ scale,
+ colormapLut,
+ normalization,
+ gamma,
+ cmapRange,
+ alpha)
+
+ else: # Fallback applying colormap on CPU
+ rgba = colormap.applyToData(data)
+ image = GLPlotRGBAImage(rgba, origin, scale, alpha)
elif len(data.shape) == 3:
# For RGB, RGBA data
@@ -878,6 +886,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
if numpy.issubdtype(data.dtype, numpy.floating):
data = numpy.array(data, dtype=numpy.float32, copy=False)
+ elif data.dtype in [numpy.uint8, numpy.uint16]:
+ pass
elif numpy.issubdtype(data.dtype, numpy.integer):
data = numpy.array(data, dtype=numpy.uint8, copy=False)
else:
@@ -899,7 +909,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
return image
def addTriangles(self, x, y, triangles,
- color, z, alpha):
+ color, alpha):
# Handle axes log scale: convert data
if self._plotFrame.xAxis.isLog:
x = numpy.log10(x)
@@ -910,8 +920,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
return triangles
- def addItem(self, x, y, shape, color, fill, overlay, z,
- linestyle, linewidth, linebgcolor):
+ def addShape(self, x, y, shape, color, fill, overlay,
+ linestyle, linewidth, linebgcolor):
x = numpy.array(x, copy=False)
y = numpy.array(y, copy=False)
@@ -923,7 +933,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
raise RuntimeError(
'Cannot add item with Y <= 0 with Y axis log scale')
- return _ShapeItem(x, y, shape, color, fill, overlay, z,
+ return _ShapeItem(x, y, shape, color, fill, overlay,
linestyle, linewidth, linebgcolor)
def addMarker(self, x, y, text, color,
@@ -971,7 +981,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
super(BackendOpenGL, self).setCursor(qt.QCursor(cursor))
def setGraphCursor(self, flag, color, linewidth, linestyle):
- if linestyle is not '-':
+ if linestyle != '-':
_logger.warning(
"BackendOpenGL.setGraphCursor linestyle parameter ignored")
diff --git a/silx/gui/plot/backends/glutils/GLPlotCurve.py b/silx/gui/plot/backends/glutils/GLPlotCurve.py
index 3a0ebac..9ab85fd 100644
--- a/silx/gui/plot/backends/glutils/GLPlotCurve.py
+++ b/silx/gui/plot/backends/glutils/GLPlotCurve.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2020 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,6 @@ __date__ = "03/04/2017"
import math
import logging
-import warnings
import numpy
@@ -1129,11 +1128,9 @@ class GLPlotCurve2D(object):
_baseline = numpy.repeat(_baseline,
len(self.xData))
if isYLog is True:
- with warnings.catch_warnings(): # Ignore NaN comparison warnings
- warnings.simplefilter('ignore',
- category=RuntimeWarning)
+ with numpy.errstate(divide='ignore', invalid='ignore'):
log_val = numpy.log10(_baseline)
- _baseline = numpy.where(_baseline>0.0, log_val, -38)
+ _baseline = numpy.where(_baseline>0.0, log_val, -38)
return _baseline
_baseline = deduce_baseline(baseline)
@@ -1277,8 +1274,7 @@ class GLPlotCurve2D(object):
if self.lineStyle is not None:
# Using Cohen-Sutherland algorithm for line clipping
- with warnings.catch_warnings(): # Ignore NaN comparison warnings
- warnings.simplefilter('ignore', category=RuntimeWarning)
+ with numpy.errstate(invalid='ignore'): # Ignore NaN comparison warnings
codes = ((self.yData > yPickMax) << 3) | \
((self.yData < yPickMin) << 2) | \
((self.xData > xPickMax) << 1) | \
@@ -1335,8 +1331,7 @@ class GLPlotCurve2D(object):
indices.sort()
else:
- with warnings.catch_warnings(): # Ignore NaN comparison warnings
- warnings.simplefilter('ignore', category=RuntimeWarning)
+ with numpy.errstate(invalid='ignore'): # Ignore NaN comparison warnings
indices = numpy.nonzero((self.xData >= xPickMin) &
(self.xData <= xPickMax) &
(self.yData >= yPickMin) &
diff --git a/silx/gui/plot/backends/glutils/GLPlotImage.py b/silx/gui/plot/backends/glutils/GLPlotImage.py
index 5d79023..e985a3d 100644
--- a/silx/gui/plot/backends/glutils/GLPlotImage.py
+++ b/silx/gui/plot/backends/glutils/GLPlotImage.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2020 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
@@ -56,7 +56,7 @@ class _GLPlotData2D(object):
sx, sy = self.scale
col = int((x - ox) / sx)
row = int((y - oy) / sy)
- return ((row, col),)
+ return (row,), (col,)
else:
return None
@@ -141,10 +141,8 @@ class GLPlotColormap(_GLPlotData2D):
""",
'fragTransform': """
uniform bvec2 isLog;
- uniform struct {
- vec2 oneOverRange;
- vec2 originOverRange;
- } bounds;
+ uniform vec2 bounds_oneOverRange;
+ uniform vec2 bounds_originOverRange;
vec2 textureCoords(void) {
vec2 pos = coords;
@@ -154,7 +152,7 @@ class GLPlotColormap(_GLPlotData2D):
if (isLog.y) {
pos.y = pow(10., coords.y);
}
- return pos * bounds.oneOverRange - bounds.originOverRange;
+ return pos * bounds_oneOverRange - bounds_originOverRange;
// TODO texture coords in range different from [0, 1]
}
"""},
@@ -163,12 +161,11 @@ class GLPlotColormap(_GLPlotData2D):
#version 120
uniform sampler2D data;
- uniform struct {
- sampler2D texture;
- bool isLog;
- float min;
- float oneOverRange;
- } cmap;
+ uniform sampler2D cmap_texture;
+ uniform int cmap_normalization;
+ uniform float cmap_parameter;
+ uniform float cmap_min;
+ uniform float cmap_oneOverRange;
uniform float alpha;
varying vec2 coords;
@@ -179,19 +176,33 @@ class GLPlotColormap(_GLPlotData2D):
void main(void) {
float value = texture2D(data, textureCoords()).r;
- if (cmap.isLog) {
+ if (cmap_normalization == 1) { /*Logarithm mapping*/
if (value > 0.) {
- value = clamp(cmap.oneOverRange *
- (oneOverLog10 * log(value) - cmap.min),
+ value = clamp(cmap_oneOverRange *
+ (oneOverLog10 * log(value) - cmap_min),
0., 1.);
} else {
value = 0.;
}
- } else { /*Linear mapping*/
- value = clamp(cmap.oneOverRange * (value - cmap.min), 0., 1.);
+ } else if (cmap_normalization == 2) { /*Square root mapping*/
+ if (value >= 0.) {
+ value = clamp(cmap_oneOverRange * (sqrt(value) - cmap_min),
+ 0., 1.);
+ } else {
+ value = 0.;
+ }
+ } else if (cmap_normalization == 3) { /*Gamma correction mapping*/
+ value = pow(
+ clamp(cmap_oneOverRange * (value - cmap_min), 0., 1.),
+ cmap_parameter);
+ } else if (cmap_normalization == 4) { /* arcsinh mapping */
+ /* asinh = log(x + sqrt(x*x + 1) for compatibility with GLSL 1.20 */
+ value = clamp(cmap_oneOverRange * (log(value + sqrt(value*value + 1.0)) - cmap_min), 0., 1.);
+ } else { /*Linear mapping and fallback*/
+ value = clamp(cmap_oneOverRange * (value - cmap_min), 0., 1.);
}
- gl_FragColor = texture2D(cmap.texture, vec2(value, 0.5));
+ gl_FragColor = texture2D(cmap_texture, vec2(value, 0.5));
gl_FragColor.a *= alpha;
}
"""
@@ -217,8 +228,10 @@ class GLPlotColormap(_GLPlotData2D):
_SHADERS['log']['fragTransform'],
attrib0='position')
+ SUPPORTED_NORMALIZATIONS = 'linear', 'log', 'sqrt', 'gamma', 'arcsinh'
+
def __init__(self, data, origin, scale,
- colormap, cmapIsLog=False, cmapRange=None,
+ colormap, normalization='linear', gamma=0., cmapRange=None,
alpha=1.0):
"""Create a 2D colormap
@@ -231,7 +244,9 @@ class GLPlotColormap(_GLPlotData2D):
:type scale: 2-tuple of floats.
:param str colormap: Name of the colormap to use
TODO: Accept a 1D scalar array as the colormap
- :param bool cmapIsLog: If True, uses log10 of the data value
+ :param str normalization: The colormap normalization.
+ One of: 'linear', 'log', 'sqrt', 'gamma'
+ ;param float gamma: The gamma parameter (for 'gamma' normalization)
:param cmapRange: The range of colormap or None for autoscale colormap
For logarithmic colormap, the range is in the untransformed data
TODO: check consistency with matplotlib
@@ -239,10 +254,12 @@ class GLPlotColormap(_GLPlotData2D):
:param float alpha: Opacity from 0 (transparent) to 1 (opaque)
"""
assert data.dtype in self._INTERNAL_FORMATS
+ assert normalization in self.SUPPORTED_NORMALIZATIONS
super(GLPlotColormap, self).__init__(data, origin, scale)
self.colormap = numpy.array(colormap, copy=False)
- self.cmapIsLog = cmapIsLog
+ self.normalization = normalization
+ self.gamma = gamma
self._cmapRange = (1., 10.) # Colormap range
self.cmapRange = cmapRange # Update _cmapRange
self._alpha = numpy.clip(alpha, 0., 1.)
@@ -263,8 +280,10 @@ class GLPlotColormap(_GLPlotData2D):
@property
def cmapRange(self):
- if self.cmapIsLog:
+ if self.normalization == 'log':
assert self._cmapRange[0] > 0. and self._cmapRange[1] > 0.
+ elif self.normalization == 'sqrt':
+ assert self._cmapRange[0] >= 0. and self._cmapRange[1] > 0.
return self._cmapRange
@cmapRange.setter
@@ -319,6 +338,7 @@ class GLPlotColormap(_GLPlotData2D):
def _setCMap(self, prog):
dataMin, dataMax = self.cmapRange # If log, it is stricly positive
+ param = 0.
if self.data.dtype in (numpy.uint16, numpy.uint8):
# Using unsigned int as normalized integer in OpenGL
@@ -326,19 +346,35 @@ class GLPlotColormap(_GLPlotData2D):
maxInt = float(numpy.iinfo(self.data.dtype).max)
dataMin, dataMax = dataMin / maxInt, dataMax / maxInt
- if self.cmapIsLog:
+ if self.normalization == 'log':
dataMin = math.log10(dataMin)
dataMax = math.log10(dataMax)
-
- gl.glUniform1i(prog.uniforms['cmap.texture'],
+ normID = 1
+ elif self.normalization == 'sqrt':
+ dataMin = math.sqrt(dataMin)
+ dataMax = math.sqrt(dataMax)
+ normID = 2
+ elif self.normalization == 'gamma':
+ # Keep dataMin, dataMax as is
+ param = self.gamma
+ normID = 3
+ elif self.normalization == 'arcsinh':
+ dataMin = numpy.arcsinh(dataMin)
+ dataMax = numpy.arcsinh(dataMax)
+ normID = 4
+ else: # Linear and fallback
+ normID = 0
+
+ gl.glUniform1i(prog.uniforms['cmap_texture'],
self._cmap_texture.texUnit)
- gl.glUniform1i(prog.uniforms['cmap.isLog'], self.cmapIsLog)
- gl.glUniform1f(prog.uniforms['cmap.min'], dataMin)
+ gl.glUniform1i(prog.uniforms['cmap_normalization'], normID)
+ gl.glUniform1f(prog.uniforms['cmap_parameter'], param)
+ gl.glUniform1f(prog.uniforms['cmap_min'], dataMin)
if dataMax > dataMin:
oneOverRange = 1. / (dataMax - dataMin)
else:
oneOverRange = 0. # Fall-back
- gl.glUniform1f(prog.uniforms['cmap.oneOverRange'], oneOverRange)
+ gl.glUniform1f(prog.uniforms['cmap_oneOverRange'], oneOverRange)
self._cmap_texture.bind()
@@ -393,9 +429,9 @@ class GLPlotColormap(_GLPlotData2D):
xOneOverRange = 1. / (ex - ox)
yOneOverRange = 1. / (ey - oy)
- gl.glUniform2f(prog.uniforms['bounds.originOverRange'],
+ gl.glUniform2f(prog.uniforms['bounds_originOverRange'],
ox * xOneOverRange, oy * yOneOverRange)
- gl.glUniform2f(prog.uniforms['bounds.oneOverRange'],
+ gl.glUniform2f(prog.uniforms['bounds_oneOverRange'],
xOneOverRange, yOneOverRange)
gl.glUniform1f(prog.uniforms['alpha'], self.alpha)
@@ -500,10 +536,8 @@ class GLPlotRGBAImage(_GLPlotData2D):
uniform sampler2D tex;
uniform bvec2 isLog;
- uniform struct {
- vec2 oneOverRange;
- vec2 originOverRange;
- } bounds;
+ uniform vec2 bounds_oneOverRange;
+ uniform vec2 bounds_originOverRange;
uniform float alpha;
varying vec2 coords;
@@ -516,7 +550,7 @@ class GLPlotRGBAImage(_GLPlotData2D):
if (isLog.y) {
pos.y = pow(10., coords.y);
}
- return pos * bounds.oneOverRange - bounds.originOverRange;
+ return pos * bounds_oneOverRange - bounds_originOverRange;
// TODO texture coords in range different from [0, 1]
}
@@ -530,7 +564,8 @@ class GLPlotRGBAImage(_GLPlotData2D):
_DATA_TEX_UNIT = 0
_SUPPORTED_DTYPES = (numpy.dtype(numpy.float32),
- numpy.dtype(numpy.uint8))
+ numpy.dtype(numpy.uint8),
+ numpy.dtype(numpy.uint16))
_linearProgram = Program(_SHADERS['linear']['vertex'],
_SHADERS['linear']['fragment'],
@@ -582,9 +617,14 @@ class GLPlotRGBAImage(_GLPlotData2D):
def prepare(self):
if self._texture is None:
- format_ = gl.GL_RGBA if self.data.shape[2] == 4 else gl.GL_RGB
+ formatName = 'GL_RGBA' if self.data.shape[2] == 4 else 'GL_RGB'
+ format_ = getattr(gl, formatName)
- self._texture = Image(format_,
+ if self.data.dtype == numpy.uint16:
+ formatName += '16' # Use sized internal format for uint16
+ internalFormat = getattr(gl, formatName)
+
+ self._texture = Image(internalFormat,
self.data,
format_=format_,
texUnit=self._DATA_TEX_UNIT)
@@ -639,9 +679,9 @@ class GLPlotRGBAImage(_GLPlotData2D):
xOneOverRange = 1. / (ex - ox)
yOneOverRange = 1. / (ey - oy)
- gl.glUniform2f(prog.uniforms['bounds.originOverRange'],
+ gl.glUniform2f(prog.uniforms['bounds_originOverRange'],
ox * xOneOverRange, oy * yOneOverRange)
- gl.glUniform2f(prog.uniforms['bounds.oneOverRange'],
+ gl.glUniform2f(prog.uniforms['bounds_oneOverRange'],
xOneOverRange, yOneOverRange)
try:
diff --git a/silx/gui/plot/backends/glutils/PlotImageFile.py b/silx/gui/plot/backends/glutils/PlotImageFile.py
index 83c7ae0..5fb6853 100644
--- a/silx/gui/plot/backends/glutils/PlotImageFile.py
+++ b/silx/gui/plot/backends/glutils/PlotImageFile.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2020 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
@@ -59,7 +59,7 @@ def convertRGBDataToPNG(data):
0, 0, interlace)
# Add filter 'None' before each scanline
- preparedData = b'\x00' + b'\x00'.join(line.tostring() for line in data)
+ preparedData = b'\x00' + b'\x00'.join(line.tobytes() for line in data)
compressedData = zlib.compress(preparedData, 8)
IDATdata = struct.pack("cccc", b'I', b'D', b'A', b'T')
@@ -134,7 +134,7 @@ def saveImageToFile(data, fileNameOrObj, fileFormat):
fileObj.write(b'P6\n')
fileObj.write(b'%d %d\n' % (width, height))
fileObj.write(b'255\n')
- fileObj.write(data.tostring())
+ fileObj.write(data.tobytes())
elif fileFormat == 'png':
fileObj.write(convertRGBDataToPNG(data))
diff --git a/silx/gui/plot/items/__init__.py b/silx/gui/plot/items/__init__.py
index 7eff1d0..4d4eac0 100644
--- a/silx/gui/plot/items/__init__.py
+++ b/silx/gui/plot/items/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -39,12 +39,13 @@ from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, # noqa
from .complex import ImageComplexData # noqa
from .curve import Curve, CurveStyle # noqa
from .histogram import Histogram # noqa
-from .image import ImageBase, ImageData, ImageRgba, MaskImageData # noqa
-from .shape import Shape, BoundingRect # noqa
+from .image import ImageBase, ImageData, ImageRgba, ImageStack, MaskImageData # noqa
+from .shape import Shape, BoundingRect, XAxisExtent, YAxisExtent # noqa
from .scatter import Scatter # noqa
from .marker import MarkerBase, Marker, XMarker, YMarker # noqa
from .axis import Axis, XAxis, YAxis, YRightAxis
-DATA_ITEMS = ImageComplexData, Curve, Histogram, ImageBase, Scatter, BoundingRect
+DATA_ITEMS = (ImageComplexData, Curve, Histogram, ImageBase, Scatter,
+ BoundingRect, XAxisExtent, YAxisExtent)
"""Classes of items representing data and to consider to compute data bounds.
"""
diff --git a/silx/gui/plot/items/_pick.py b/silx/gui/plot/items/_pick.py
index 14078fd..4ddf4f6 100644
--- a/silx/gui/plot/items/_pick.py
+++ b/silx/gui/plot/items/_pick.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2019 European Synchrotron Radiation Facility
+# Copyright (c) 2019-2020 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
@@ -47,7 +47,9 @@ class PickingResult(object):
if indices is None or len(indices) == 0:
self._indices = None
else:
- self._indices = numpy.array(indices, copy=False, dtype=numpy.int)
+ # Indices is set to None if indices array is empty
+ indices = numpy.array(indices, copy=False, dtype=numpy.int)
+ self._indices = None if indices.size == 0 else indices
def getItem(self):
"""Returns the item this results corresponds to."""
diff --git a/silx/gui/plot/items/axis.py b/silx/gui/plot/items/axis.py
index 8ea5c7a..be85e6a 100644
--- a/silx/gui/plot/items/axis.py
+++ b/silx/gui/plot/items/axis.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -239,7 +239,7 @@ class Axis(qt.QObject):
# TODO hackish way of forcing update of curves and images
plot = self._getPlot()
- for item in plot._getItems(withhidden=True):
+ for item in plot.getItems():
item._updated()
plot._invalidateDataRange()
diff --git a/silx/gui/plot/items/complex.py b/silx/gui/plot/items/complex.py
index 988022a..8f0694d 100644
--- a/silx/gui/plot/items/complex.py
+++ b/silx/gui/plot/items/complex.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -165,6 +165,11 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn):
data = self.getRgbaImageData(copy=False)
else:
colormap = self.getColormap()
+ if colormap.isAutoscale():
+ # Avoid backend to compute autoscale: use item cache
+ colormap = colormap.copy()
+ colormap.setVRange(*colormap.getColormapRange(self))
+
data = self.getData(copy=False)
if data.size == 0:
@@ -173,7 +178,6 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn):
return backend.addImage(data,
origin=self.getOrigin(),
scale=self.getScale(),
- z=self.getZValue(),
colormap=colormap,
alpha=self.getAlpha())
@@ -191,6 +195,8 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn):
colormap = self._colormaps[self.getComplexMode()]
if colormap is not super(ImageComplexData, self).getColormap():
super(ImageComplexData, self).setColormap(colormap)
+
+ self._setColormappedData(self.getData(copy=False), copy=False)
return changed
def _setAmplitudeRangeInfo(self, max_=None, delta=2):
@@ -260,6 +266,7 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn):
self._data = data
self._dataByModesCache = {}
+ self._setColormappedData(self.getData(copy=False), copy=False)
# TODO hackish data range implementation
if self.isVisible():
diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py
index 6d6575b..9426a13 100644
--- a/silx/gui/plot/items/core.py
+++ b/silx/gui/plot/items/core.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -43,6 +43,7 @@ import weakref
import numpy
import six
+from ....utils.deprecation import deprecated
from ....utils.enum import Enum as _Enum
from ... import qt
from ... import colors
@@ -143,6 +144,9 @@ class ItemChangedType(enum.Enum):
EDITABLE = 'editableChanged'
"""Item's editable state changed flags."""
+ SELECTABLE = 'selectableChanged'
+ """Item's selectable state changed flags."""
+
class Item(qt.QObject):
"""Description of an item of the plot"""
@@ -150,9 +154,6 @@ class Item(qt.QObject):
_DEFAULT_Z_LAYER = 0
"""Default layer for overlay rendering"""
- _DEFAULT_LEGEND = ''
- """Default legend of items"""
-
_DEFAULT_SELECTABLE = False
"""Default selectable state of items"""
@@ -168,19 +169,19 @@ class Item(qt.QObject):
self._dirty = True
self._plotRef = None
self._visible = True
- self._legend = self._DEFAULT_LEGEND
self._selectable = self._DEFAULT_SELECTABLE
self._z = self._DEFAULT_Z_LAYER
self._info = None
self._xlabel = None
self._ylabel = None
+ self.__name = ''
self._backendRenderer = None
def getPlot(self):
- """Returns Plot this item belongs to.
+ """Returns the ~silx.gui.plot.PlotWidget this item belongs to.
- :rtype: Plot or None
+ :rtype: Union[~silx.gui.plot.PlotWidget,None]
"""
return None if self._plotRef is None else self._plotRef()
@@ -189,7 +190,7 @@ class Item(qt.QObject):
WARNING: This should only be called from the Plot.
- :param Plot plot: The Plot instance.
+ :param Union[~silx.gui.plot.PlotWidget,None] plot: The Plot instance.
"""
if plot is not None and self._plotRef is not None:
raise RuntimeError('Trying to add a node at two places.')
@@ -234,19 +235,35 @@ class Item(qt.QObject):
"""
return False
- def getLegend(self):
- """Returns the legend of this item (str)"""
- return self._legend
+ def getName(self):
+ """Returns the name of the item which is used as legend.
- def _setLegend(self, legend):
- """Set the legend.
+ :rtype: str
+ """
+ return self.__name
- This is private as it is used by the plot as an identifier
+ def setName(self, name):
+ """Set the name of the item which is used as legend.
- :param str legend: Item legend
+ :param str name: New name of the item
+ :raises RuntimeError: If item belongs to a PlotWidget.
"""
- legend = str(legend) if legend is not None else self._DEFAULT_LEGEND
- self._legend = legend
+ name = str(name)
+ if self.__name != name:
+ if self.getPlot() is not None:
+ raise RuntimeError(
+ "Cannot change name while item is in a PlotWidget")
+
+ self.__name = name
+ self._updated(ItemChangedType.NAME)
+
+ def getLegend(self): # Replaced by getName for API consistency
+ return self.getName()
+
+ @deprecated(replacement='setName', since_version='0.13')
+ def _setLegend(self, legend):
+ legend = str(legend) if legend is not None else ''
+ self.setName(legend)
def isSelectable(self):
"""Returns true if item is selectable (bool)"""
@@ -355,12 +372,12 @@ class Item(qt.QObject):
if indices is None:
return None
else:
- return PickingResult(self, indices if len(indices) != 0 else None)
+ return PickingResult(self, indices)
# Mix-in classes ##############################################################
-class ItemMixInBase(qt.QObject):
+class ItemMixInBase(object):
"""Base class for Item mix-in"""
def _updated(self, event=None, checkVisibility=True):
@@ -454,6 +471,8 @@ class ColormapMixIn(ItemMixInBase):
def __init__(self):
self._colormap = Colormap()
self._colormap.sigChanged.connect(self._colormapChanged)
+ self.__data = None
+ self.__cacheColormapRange = {} # Store {normalization: range}
def getColormap(self):
"""Return the used colormap"""
@@ -480,6 +499,70 @@ class ColormapMixIn(ItemMixInBase):
"""Handle updates of the colormap"""
self._updated(ItemChangedType.COLORMAP)
+ def _setColormappedData(self, data, copy=True,
+ min_=None, minPositive=None, max_=None):
+ """Set the data used to compute the colormapped display.
+
+ It also resets the cache of data ranges.
+
+ This method MUST be called by inheriting classes when data is updated.
+
+ :param Union[None,numpy.ndarray] data:
+ :param Union[None,float] min_: Minimum value of the data
+ :param Union[None,float] minPositive:
+ Minimum of strictly positive values of the data
+ :param Union[None,float] max_: Maximum value of the data
+ """
+ self.__data = None if data is None else numpy.array(data, copy=copy)
+ self.__cacheColormapRange = {} # Reset cache
+
+ # Fill-up colormap range cache if values are provided
+ if max_ is not None and numpy.isfinite(max_):
+ if min_ is not None and numpy.isfinite(min_):
+ self.__cacheColormapRange[Colormap.LINEAR, Colormap.MINMAX] = min_, max_
+ if minPositive is not None and numpy.isfinite(minPositive):
+ self.__cacheColormapRange[Colormap.LOGARITHM, Colormap.MINMAX] = minPositive, max_
+
+ colormap = self.getColormap()
+ if None in (colormap.getVMin(), colormap.getVMax()):
+ self._colormapChanged()
+
+ def getColormappedData(self, copy=True):
+ """Returns the data used to compute the displayed colors
+
+ :param bool copy: True to get a copy,
+ False to get internal data (do not modify!).
+ :rtype: Union[None,numpy.ndarray]
+ """
+ if self.__data is None:
+ return None
+ else:
+ return numpy.array(self.__data, copy=copy)
+
+ def _getColormapAutoscaleRange(self, colormap=None):
+ """Returns the autoscale range for current data and colormap.
+
+ :param Union[None,~silx.gui.colors.Colormap] colormap:
+ The colormap for which to compute the autoscale range.
+ If None, the default, the colormap of the item is used
+ :return: (vmin, vmax) range (vmin and /or vmax might be `None`)
+ """
+ if colormap is None:
+ colormap = self.getColormap()
+
+ data = self.getColormappedData(copy=False)
+ if colormap is None or data is None:
+ return None, None
+
+ normalization = colormap.getNormalization()
+ autoscaleMode = colormap.getAutoscaleMode()
+ key = normalization, autoscaleMode
+ vRange = self.__cacheColormapRange.get(key, None)
+ if vRange is None:
+ vRange = colormap._computeAutoscaleRange(data)
+ self.__cacheColormapRange[key] = vRange
+ return vRange
+
class SymbolMixIn(ItemMixInBase):
"""Mix-in class for items with symbol type"""
@@ -712,6 +795,8 @@ class ColorMixIn(ItemMixInBase):
"""
if isinstance(color, six.string_types):
color = colors.rgba(color)
+ elif isinstance(color, qt.QColor):
+ color = colors.rgba(color)
else:
color = numpy.array(color, copy=copy)
# TODO more checks + improve color array support
@@ -941,6 +1026,10 @@ class ScatterVisualizationMixIn(ItemMixInBase):
(either all lines from left to right or all from right to left).
"""
+ BINNED_STATISTIC = 'binned_statistic'
+ """Display scatter plot as 2D binned statistic (i.e., generalized histogram).
+ """
+
@enum.unique
class VisualizationParameter(_Enum):
"""Different parameter names for scatter plot visualizations"""
@@ -967,10 +1056,30 @@ class ScatterVisualizationMixIn(ItemMixInBase):
in which case the grid is not fully filled.
"""
+ BINNED_STATISTIC_SHAPE = 'binned_statistic_shape'
+ """The number of bins in each dimension (height, width).
+ """
+
+ BINNED_STATISTIC_FUNCTION = 'binned_statistic_function'
+ """The reduction function to apply to each bin (str).
+
+ Available reduction functions are: 'mean' (default), 'count', 'sum'.
+ """
+
+ _SUPPORTED_VISUALIZATION_PARAMETER_VALUES = {
+ VisualizationParameter.GRID_MAJOR_ORDER: ('row', 'column'),
+ VisualizationParameter.BINNED_STATISTIC_FUNCTION: ('mean', 'count', 'sum'),
+ }
+ """Supported visualization parameter values.
+
+ Defined for parameters with a set of acceptable values.
+ """
+
def __init__(self):
self.__visualization = self.Visualization.POINTS
self.__parameters = dict( # Init parameters to None
(parameter, None) for parameter in self.VisualizationParameter)
+ self.__parameters[self.VisualizationParameter.BINNED_STATISTIC_FUNCTION] = 'mean'
@classmethod
def supportedVisualizations(cls):
@@ -985,6 +1094,20 @@ class ScatterVisualizationMixIn(ItemMixInBase):
else:
return cls._SUPPORTED_SCATTER_VISUALIZATION
+ @classmethod
+ def supportedVisualizationParameterValues(cls, parameter):
+ """Returns the list of supported scatter visualization modes.
+
+ See :meth:`VisualizationParameters`
+
+ :param VisualizationParameter parameter:
+ This parameter for which to retrieve the supported values.
+ :returns: tuple of supported of values or None if not defined.
+ """
+ parameter = cls.VisualizationParameter(parameter)
+ return cls._SUPPORTED_VISUALIZATION_PARAMETER_VALUES.get(
+ parameter, None)
+
def setVisualization(self, mode):
"""Set the scatter plot visualization mode to use.
@@ -1024,10 +1147,15 @@ class ScatterVisualizationMixIn(ItemMixInBase):
:raises ValueError: If parameter is not supported
:return: True if parameter was set, False if is was already set
:rtype: bool
+ :raise ValueError: If value is not supported
"""
parameter = self.VisualizationParameter.from_value(parameter)
if self.__parameters[parameter] != value:
+ validValues = self.supportedVisualizationParameterValues(parameter)
+ if validValues is not None and value not in validValues:
+ raise ValueError("Unsupported parameter value: %s" % str(value))
+
self.__parameters[parameter] = value
self._updated(ItemChangedType.VISUALIZATION_MODE)
return True
@@ -1151,14 +1279,12 @@ class PointsBase(Item, SymbolMixIn, AlphaMixIn):
if xPositive:
x = self.getXData(copy=False)
- with warnings.catch_warnings(): # Ignore NaN warnings
- warnings.simplefilter('ignore', category=RuntimeWarning)
+ with numpy.errstate(invalid='ignore'): # Ignore NaN warnings
xclipped = x <= 0
if yPositive:
y = self.getYData(copy=False)
- with warnings.catch_warnings(): # Ignore NaN warnings
- warnings.simplefilter('ignore', category=RuntimeWarning)
+ with numpy.errstate(invalid='ignore'): # Ignore NaN warnings
yclipped = y <= 0
self._clippedCache[(xPositive, yPositive)] = \
@@ -1386,3 +1512,54 @@ class BaselineMixIn(object):
return numpy.array(self._baseline, copy=True)
else:
return self._baseline
+
+
+class _Style:
+ """Object which store styles"""
+
+
+class HighlightedMixIn(ItemMixInBase):
+
+ def __init__(self):
+ self._highlightStyle = self._DEFAULT_HIGHLIGHT_STYLE
+ self._highlighted = False
+
+ def isHighlighted(self):
+ """Returns True if curve is highlighted.
+
+ :rtype: bool
+ """
+ return self._highlighted
+
+ def setHighlighted(self, highlighted):
+ """Set the highlight state of the curve
+
+ :param bool highlighted:
+ """
+ highlighted = bool(highlighted)
+ if highlighted != self._highlighted:
+ self._highlighted = highlighted
+ # TODO inefficient: better to use backend's setCurveColor
+ self._updated(ItemChangedType.HIGHLIGHTED)
+
+ def getHighlightedStyle(self):
+ """Returns the highlighted style in use
+
+ :rtype: CurveStyle
+ """
+ return self._highlightStyle
+
+ def setHighlightedStyle(self, style):
+ """Set the style to use for highlighting
+
+ :param CurveStyle style: New style to use
+ """
+ previous = self.getHighlightedStyle()
+ if style != previous:
+ assert isinstance(style, _Style)
+ self._highlightStyle = style
+ self._updated(ItemChangedType.HIGHLIGHTED_STYLE)
+
+ # Backward compatibility event
+ if previous.getColor() != style.getColor():
+ self._updated(ItemChangedType.HIGHLIGHTED_COLOR)
diff --git a/silx/gui/plot/items/curve.py b/silx/gui/plot/items/curve.py
index 5853ef5..7922fa1 100644
--- a/silx/gui/plot/items/curve.py
+++ b/silx/gui/plot/items/curve.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -39,13 +39,13 @@ from ....utils.deprecation import deprecated
from ... import colors
from .core import (PointsBase, LabelsMixIn, ColorMixIn, YAxisMixIn,
FillMixIn, LineMixIn, SymbolMixIn, ItemChangedType,
- BaselineMixIn)
+ BaselineMixIn, HighlightedMixIn, _Style)
_logger = logging.getLogger(__name__)
-class CurveStyle(object):
+class CurveStyle(_Style):
"""Object storing the style of a curve.
Set a value to None to use the default
@@ -153,7 +153,7 @@ class CurveStyle(object):
class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn,
- LineMixIn, BaselineMixIn):
+ LineMixIn, BaselineMixIn, HighlightedMixIn):
"""Description of a curve"""
_DEFAULT_Z_LAYER = 1
@@ -181,9 +181,8 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn,
LabelsMixIn.__init__(self)
LineMixIn.__init__(self)
BaselineMixIn.__init__(self)
+ HighlightedMixIn.__init__(self)
- self._highlightStyle = self._DEFAULT_HIGHLIGHT_STYLE
- self._highlighted = False
self._setBaseline(Curve._DEFAULT_BASELINE)
self.sigItemChanged.connect(self.__itemChanged)
@@ -214,7 +213,6 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn,
yaxis=self.getYAxis(),
xerror=xerror,
yerror=yerror,
- z=self.getZValue(),
fill=self.isFill(),
alpha=self.getAlpha(),
symbolsize=style.getSymbolSize(),
@@ -229,7 +227,7 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn,
elif item == 1:
return self.getYData(copy=False)
elif item == 2:
- return self.getLegend()
+ return self.getName()
elif item == 3:
info = self.getInfo(copy=False)
return {} if info is None else info
@@ -267,46 +265,6 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn,
super(Curve, self).setVisible(visible)
- def isHighlighted(self):
- """Returns True if curve is highlighted.
-
- :rtype: bool
- """
- return self._highlighted
-
- def setHighlighted(self, highlighted):
- """Set the highlight state of the curve
-
- :param bool highlighted:
- """
- highlighted = bool(highlighted)
- if highlighted != self._highlighted:
- self._highlighted = highlighted
- # TODO inefficient: better to use backend's setCurveColor
- self._updated(ItemChangedType.HIGHLIGHTED)
-
- def getHighlightedStyle(self):
- """Returns the highlighted style in use
-
- :rtype: CurveStyle
- """
- return self._highlightStyle
-
- def setHighlightedStyle(self, style):
- """Set the style to use for highlighting
-
- :param CurveStyle style: New style to use
- """
- previous = self.getHighlightedStyle()
- if style != previous:
- assert isinstance(style, CurveStyle)
- self._highlightStyle = style
- self._updated(ItemChangedType.HIGHLIGHTED_STYLE)
-
- # Backward compatibility event
- if previous.getColor() != style.getColor():
- self._updated(ItemChangedType.HIGHLIGHTED_COLOR)
-
@deprecated(replacement='Curve.getHighlightedStyle().getColor()',
since_version='0.9.0')
def getHighlightedColor(self):
@@ -350,11 +308,11 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn,
symbolsize=self.getSymbolSize() if symbolsize is None else symbolsize)
else:
- return CurveStyle(color=self.getColor(),
- linestyle=self.getLineStyle(),
- linewidth=self.getLineWidth(),
- symbol=self.getSymbol(),
- symbolsize=self.getSymbolSize())
+ return CurveStyle(color=self.getColor(),
+ linestyle=self.getLineStyle(),
+ linewidth=self.getLineWidth(),
+ symbol=self.getSymbol(),
+ symbolsize=self.getSymbolSize())
@deprecated(replacement='Curve.getCurrentStyle()',
since_version='0.9.0')
diff --git a/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py
index 993c0f0..935f8d5 100644
--- a/silx/gui/plot/items/histogram.py
+++ b/silx/gui/plot/items/histogram.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -60,17 +60,17 @@ def _computeEdges(x, histogramType):
"""
# for now we consider that the spaces between xs are constant
edges = x.copy()
- if histogramType is 'left':
+ if histogramType == 'left':
width = 1
if len(x) > 1:
width = x[1] - x[0]
edges = numpy.append(x[0] - width, edges)
- if histogramType is 'center':
+ if histogramType == 'center':
edges = _computeEdges(edges, 'right')
widths = (edges[1:] - edges[0:-1]) / 2.0
widths = numpy.append(widths, widths[-1])
edges = edges - widths
- if histogramType is 'right':
+ if histogramType == 'right':
width = 1
if len(x) > 1:
width = x[-1] - x[-2]
@@ -170,7 +170,6 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
yaxis=self.getYAxis(),
xerror=None,
yerror=None,
- z=self.getZValue(),
fill=self.isFill(),
alpha=self.getAlpha(),
baseline=baseline,
@@ -213,6 +212,8 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
numpy.nanmax(values))
else: # No log scale on y axis, include 0 in bounds
+ if numpy.all(numpy.isnan(values)):
+ return None
return (numpy.nanmin(edges),
numpy.nanmax(edges),
min(0, numpy.nanmin(values)),
@@ -236,7 +237,7 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
:param copy: True (Default) to get a copy,
False to use internal representation (do not modify!)
- :returns: The bin edges of the histogram
+ :returns: The values of the histogram
:rtype: numpy.ndarray
"""
return numpy.array(self._histogram, copy=copy)
@@ -298,6 +299,7 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
# Check that bin edges are monotonic
edgesDiff = numpy.diff(edges)
+ edgesDiff = edgesDiff[numpy.logical_not(numpy.isnan(edgesDiff))]
assert numpy.all(edgesDiff >= 0) or numpy.all(edgesDiff <= 0)
# manage baseline
if (isinstance(baseline, abc.Iterable)):
@@ -342,11 +344,11 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
"""
# for now we consider that the spaces between xs are constant
edges = x.copy()
- if histogramType is 'left':
+ if histogramType == 'left':
return edges[1:]
- if histogramType is 'center':
+ if histogramType == 'center':
edges = (edges[1:] + edges[:-1]) / 2.0
- if histogramType is 'right':
+ if histogramType == 'right':
width = 1
if len(x) > 1:
width = x[-1] + x[-2]
diff --git a/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py
index 44cb70f..91c051d 100644
--- a/silx/gui/plot/items/image.py
+++ b/silx/gui/plot/items/image.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -42,7 +42,6 @@ import numpy
from ....utils.proxy import docstring
from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn,
AlphaMixIn, ItemChangedType)
-from ._pick import PickingResult
_logger = logging.getLogger(__name__)
@@ -108,7 +107,7 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn):
elif item == 0:
return self.getData(copy=False)
elif item == 1:
- return self.getLegend()
+ return self.getName()
elif item == 2:
info = self.getInfo(copy=False)
return {} if info is None else info
@@ -143,25 +142,6 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn):
plot._invalidateDataRange()
super(ImageBase, self).setVisible(visible)
- @docstring(Item)
- def pick(self, x, y):
- if super(ImageBase, self).pick(x, y) is not None:
- plot = self.getPlot()
- if plot is None:
- return None
-
- dataPos = plot.pixelToData(x, y)
- if dataPos is None:
- return None
-
- origin = self.getOrigin()
- scale = self.getScale()
- column = int((dataPos[0] - origin[0]) / float(scale[0]))
- row = int((dataPos[1] - origin[1]) / float(scale[1]))
- return PickingResult(self, ([row], [column]))
-
- return None
-
def _isPlotLinear(self, plot):
"""Return True if plot only uses linear scale for both of x and y
axes."""
@@ -301,11 +281,16 @@ class ImageData(ImageBase, ColormapMixIn):
if dataToUse.size == 0:
return None # No data to display
+ colormap = self.getColormap()
+ if colormap.isAutoscale():
+ # Avoid backend to compute autoscale: use item cache
+ colormap = colormap.copy()
+ colormap.setVRange(*colormap.getColormapRange(self))
+
return backend.addImage(dataToUse,
origin=self.getOrigin(),
scale=self.getScale(),
- z=self.getZValue(),
- colormap=self.getColormap(),
+ colormap=colormap,
alpha=self.getAlpha())
def __getitem__(self, item):
@@ -331,7 +316,7 @@ class ImageData(ImageBase, ColormapMixIn):
else:
# Apply colormap, in this case an new array is always returned
colormap = self.getColormap()
- image = colormap.applyToData(self.getData(copy=False))
+ image = colormap.applyToData(self)
alphaImage = self.getAlphaData(copy=False)
if alphaImage is not None:
# Apply transparency
@@ -386,6 +371,7 @@ class ImageData(ImageBase, ColormapMixIn):
'Converting complex image to absolute value to plot it.')
data = numpy.absolute(data)
self._data = data
+ self._setColormappedData(data, copy=False)
if alternative is not None:
alternative = numpy.array(alternative, copy=copy)
@@ -434,7 +420,6 @@ class ImageRgba(ImageBase):
return backend.addImage(data,
origin=self.getOrigin(),
scale=self.getScale(),
- z=self.getZValue(),
colormap=None,
alpha=self.getAlpha())
@@ -473,3 +458,82 @@ class MaskImageData(ImageData):
internal silx widgets.
"""
pass
+
+
+class ImageStack(ImageData):
+ """Item to store a stack of images and to show it in the plot as one
+ of the images of the stack.
+
+ The stack is a 3D array ordered this way: `frame id, y, x`.
+ So the first image of the stack can be reached this way: `stack[0, :, :]`
+ """
+
+ def __init__(self):
+ ImageData.__init__(self)
+ self.__stack = None
+ """A 3D numpy array (or a mimic one, see ListOfImages)"""
+ self.__stackPosition = None
+ """Displayed position in the cube"""
+
+ def setStackData(self, stack, position=None, copy=True):
+ """Set the stack data
+
+ :param stack: A 3D numpy array like
+ :param int position: The position of the displayed image in the stack
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ """
+ if self.__stack is stack:
+ return
+ if copy:
+ stack = numpy.array(stack)
+ assert stack.ndim == 3
+ self.__stack = stack
+ if position is not None:
+ self.__stackPosition = position
+ if self.__stackPosition is None:
+ self.__stackPosition = 0
+ self.__updateDisplayedData()
+
+ def getStackData(self, copy=True):
+ """Get the stored stack array.
+
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :rtype: A 3D numpy array, or numpy array like
+ """
+ if copy:
+ return numpy.array(self.__stack)
+ else:
+ return self.__stack
+
+ def setStackPosition(self, pos):
+ """Set the displayed position on the stack.
+
+ This function will clamp the stack position according to
+ the real size of the first axis of the stack.
+
+ :param int pos: A position on the first axis of the stack.
+ """
+ if self.__stackPosition == pos:
+ return
+ self.__stackPosition = pos
+ self.__updateDisplayedData()
+
+ def getStackPosition(self):
+ """Get the displayed position of the stack.
+
+ :rtype: int
+ """
+ return self.__stackPosition
+
+ def __updateDisplayedData(self):
+ """Update the displayed frame whenever the stack or the stack
+ position are updated."""
+ if self.__stack is None or self.__stackPosition is None:
+ empty = numpy.array([]).reshape(0, 0)
+ self.setData(empty, copy=False)
+ return
+ size = len(self.__stack)
+ self.__stackPosition = numpy.clip(self.__stackPosition, 0, size)
+ self.setData(self.__stack[self.__stackPosition], copy=False)
diff --git a/silx/gui/plot/items/marker.py b/silx/gui/plot/items/marker.py
index f5a1689..50d070c 100755
--- a/silx/gui/plot/items/marker.py
+++ b/silx/gui/plot/items/marker.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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 @@ import logging
from ....utils.proxy import docstring
from .core import (Item, DraggableMixIn, ColorMixIn, LineMixIn, SymbolMixIn,
ItemChangedType, YAxisMixIn)
-
+from silx.gui import qt
_logger = logging.getLogger(__name__)
@@ -43,6 +43,11 @@ _logger = logging.getLogger(__name__)
class MarkerBase(Item, DraggableMixIn, ColorMixIn, YAxisMixIn):
"""Base class for markers"""
+ sigDragStarted = qt.Signal()
+ """Signal emitted when the marker is pressed"""
+ sigDragFinished = qt.Signal()
+ """Signal emitted when the marker is released"""
+
_DEFAULT_COLOR = (0., 0., 0., 1.)
"""Default color of the markers"""
@@ -56,6 +61,7 @@ class MarkerBase(Item, DraggableMixIn, ColorMixIn, YAxisMixIn):
self._x = None
self._y = None
self._constraint = self._defaultConstraint
+ self.__isBeingDragged = False
def _addRendererCall(self, backend,
symbol=None, linestyle='-', linewidth=1):
@@ -167,6 +173,18 @@ class MarkerBase(Item, DraggableMixIn, ColorMixIn, YAxisMixIn):
"""Default constraint not doing anything"""
return args
+ def _startDrag(self):
+ self.__isBeingDragged = True
+ self.sigDragStarted.emit()
+
+ def _endDrag(self):
+ self.__isBeingDragged = False
+ self.sigDragFinished.emit()
+
+ def isBeingDragged(self) -> bool:
+ """Returns whether the marker is currently dragged by the user."""
+ return self.__isBeingDragged
+
class Marker(MarkerBase, SymbolMixIn):
"""Description of a marker"""
diff --git a/silx/gui/plot/items/roi.py b/silx/gui/plot/items/roi.py
index dcad943..ff73fe6 100644
--- a/silx/gui/plot/items/roi.py
+++ b/silx/gui/plot/items/roi.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2020 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -23,6 +23,10 @@
#
# ###########################################################################*/
"""This module provides ROI item for the :class:`~silx.gui.plot.PlotWidget`.
+
+.. inheritance-diagram::
+ silx.gui.plot.items.roi
+ :parts: 1
"""
__authors__ = ["T. Vincent"]
@@ -30,18 +34,21 @@ __license__ = "MIT"
__date__ = "28/06/2018"
-import functools
-import itertools
import logging
-import collections
import numpy
+import weakref
+from silx.image.shapes import Polygon
from ....utils.weakref import WeakList
from ... import qt
+from ... import utils
from .. import items
+from ..items import core
from ...colors import rgba
import silx.utils.deprecation
-from silx.utils.proxy import docstring
+from silx.image._boundingbox import _BoundingBox
+from ....utils.proxy import docstring
+from ..utils.intersections import segments_intersection
logger = logging.getLogger(__name__)
@@ -54,6 +61,9 @@ class _RegionOfInterestBase(qt.QObject):
:param str name: The name of the ROI
"""
+ sigAboutToBeRemoved = qt.Signal()
+ """Signal emitted just before this ROI is removed from its manager."""
+
sigItemChanged = qt.Signal(object)
"""Signal emitted when item has changed.
@@ -61,9 +71,9 @@ class _RegionOfInterestBase(qt.QObject):
See :class:`ItemChangedType` for flags description.
"""
- def __init__(self, parent=None, name=''):
- qt.QObject.__init__(self)
- self.__name = str(name)
+ def __init__(self, parent=None):
+ qt.QObject.__init__(self, parent=parent)
+ self.__name = ''
def getName(self):
"""Returns the name of the ROI
@@ -81,18 +91,44 @@ class _RegionOfInterestBase(qt.QObject):
name = str(name)
if self.__name != name:
self.__name = name
- self.sigItemChanged.emit(items.ItemChangedType.NAME)
+ self._updated(items.ItemChangedType.NAME)
+
+ def _updated(self, event=None, checkVisibility=True):
+ """Implement Item mix-in update method by updating the plot items
+
+ See :class:`~silx.gui.plot.items.Item._updated`
+ """
+ self.sigItemChanged.emit(event)
+ def contains(self, position):
+ """Returns True if the `position` is in this ROI.
-class RegionOfInterest(_RegionOfInterestBase):
+ :param tuple[float,float] position: position to check
+ :return: True if the value / point is consider to be in the region of
+ interest.
+ :rtype: bool
+ """
+ raise NotImplementedError("Base class")
+
+
+class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn):
"""Object describing a region of interest in a plot.
:param QObject parent:
The RegionOfInterestManager that created this object
"""
- _kind = None
- """Label for this kind of ROI.
+ _DEFAULT_LINEWIDTH = 1.
+ """Default line width of the curve"""
+
+ _DEFAULT_LINESTYLE = '-'
+ """Default line style of the curve"""
+
+ _DEFAULT_HIGHLIGHT_STYLE = items.CurveStyle(linewidth=2)
+ """Default highlight style of the item"""
+
+ ICON, NAME, SHORT_NAME = None, None, None
+ """Metadata to describe the ROI in labels, tooltips and widgets
Should be set by inherited classes to custom the ROI manager widget.
"""
@@ -100,50 +136,125 @@ class RegionOfInterest(_RegionOfInterestBase):
sigRegionChanged = qt.Signal()
"""Signal emitted everytime the shape or position of the ROI changes"""
+ sigEditingStarted = qt.Signal()
+ """Signal emitted when the user start editing the roi"""
+
+ sigEditingFinished = qt.Signal()
+ """Signal emitted when the region edition is finished. During edition
+ sigEditionChanged will be emitted several times and
+ sigRegionEditionFinished only at end"""
+
def __init__(self, parent=None):
- # Avoid circular dependancy
+ # Avoid circular dependency
from ..tools import roi as roi_tools
assert parent is None or isinstance(parent, roi_tools.RegionOfInterestManager)
- _RegionOfInterestBase.__init__(self, parent, '')
+ _RegionOfInterestBase.__init__(self, parent)
+ core.HighlightedMixIn.__init__(self)
self._color = rgba('red')
- self._items = WeakList()
- self._editAnchors = WeakList()
- self._points = None
- self._labelItem = None
self._editable = False
+ self._selectable = False
+ self._focusProxy = None
self._visible = True
- self.sigItemChanged.connect(self.__itemChanged)
-
- def __itemChanged(self, event):
- """Handle name change"""
- if event == items.ItemChangedType.NAME:
- self._updateLabelItem(self.getName())
-
- def __del__(self):
- # Clean-up plot items
- self._removePlotItems()
+ self._child = WeakList()
+
+ def _connectToPlot(self, plot):
+ """Called after connection to a plot"""
+ for item in self.getItems():
+ # This hack is needed to avoid reentrant call from _disconnectFromPlot
+ # to the ROI manager. It also speed up the item tests in _itemRemoved
+ item._roiGroup = True
+ plot.addItem(item)
+
+ def _disconnectFromPlot(self, plot):
+ """Called before disconnection from a plot"""
+ for item in self.getItems():
+ # The item could be already be removed by the plot
+ if item.getPlot() is not None:
+ del item._roiGroup
+ plot.removeItem(item)
+
+ def _setItemName(self, item):
+ """Helper to generate a unique id to a plot item"""
+ legend = "__ROI-%d__%d" % (id(self), id(item))
+ item.setName(legend)
def setParent(self, parent):
"""Set the parent of the RegionOfInterest
- :param Union[None,RegionOfInterestManager] parent:
+ :param Union[None,RegionOfInterestManager] parent: The new parent
"""
- # Avoid circular dependancy
+ # Avoid circular dependency
from ..tools import roi as roi_tools
if (parent is not None and not isinstance(parent, roi_tools.RegionOfInterestManager)):
raise ValueError('Unsupported parent')
- self._removePlotItems()
+ previousParent = self.parent()
+ if previousParent is not None:
+ previousPlot = previousParent.parent()
+ if previousPlot is not None:
+ self._disconnectFromPlot(previousPlot)
super(RegionOfInterest, self).setParent(parent)
- self._createPlotItems()
+ if parent is not None:
+ plot = parent.parent()
+ if plot is not None:
+ self._connectToPlot(plot)
+
+ def addItem(self, item):
+ """Add an item to the set of this ROI children.
+
+ This item will be added and removed to the plot used by the ROI.
+
+ If the ROI is already part of a plot, the item will also be added to
+ the plot.
+
+ It the item do not have a name already, a unique one is generated to
+ avoid item collision in the plot.
+
+ :param silx.gui.plot.items.Item item: A plot item
+ """
+ assert item is not None
+ self._child.append(item)
+ if item.getName() == '':
+ self._setItemName(item)
+ manager = self.parent()
+ if manager is not None:
+ plot = manager.parent()
+ if plot is not None:
+ item._roiGroup = True
+ plot.addItem(item)
+
+ def removeItem(self, item):
+ """Remove an item from this ROI children.
+
+ If the item is part of a plot it will be removed too.
+
+ :param silx.gui.plot.items.Item item: A plot item
+ """
+ assert item is not None
+ self._child.remove(item)
+ plot = item.getPlot()
+ if plot is not None:
+ del item._roiGroup
+ plot.removeItem(item)
+
+ def getItems(self):
+ """Returns the list of PlotWidget items of this RegionOfInterest.
+
+ :rtype: List[~silx.gui.plot.items.Item]
+ """
+ return tuple(self._child)
@classmethod
- def _getKind(cls):
+ def _getShortName(cls):
"""Return an human readable kind of ROI
:rtype: str
"""
- return cls._kind
+ if hasattr(cls, "SHORT_NAME"):
+ name = cls.SHORT_NAME
+ if name is None:
+ name = cls.__name__
+ return name
def getColor(self):
"""Returns the color of this ROI
@@ -152,14 +263,6 @@ class RegionOfInterest(_RegionOfInterestBase):
"""
return qt.QColor.fromRgbF(*self._color)
- def _getAnchorColor(self, color):
- """Returns the anchor color from the base ROI color
-
- :param Union[numpy.array,Tuple,List]: color
- :rtype: Union[numpy.array,Tuple,List]
- """
- return color[:3] + (0.5,)
-
def setColor(self, color):
"""Set the color used for this ROI.
@@ -169,22 +272,7 @@ class RegionOfInterest(_RegionOfInterestBase):
color = rgba(color)
if color != self._color:
self._color = color
-
- # Update color of shape items in the plot
- rgbaColor = rgba(color)
- for item in list(self._items):
- if isinstance(item, items.ColorMixIn):
- item.setColor(rgbaColor)
- item = self._getLabelItem()
- if isinstance(item, items.ColorMixIn):
- item.setColor(rgbaColor)
-
- rgbaColor = self._getAnchorColor(rgbaColor)
- for item in list(self._editAnchors):
- if isinstance(item, items.ColorMixIn):
- item.setColor(rgbaColor)
-
- self.sigItemChanged.emit(items.ItemChangedType.COLOR)
+ self._updated(items.ItemChangedType.COLOR)
@silx.utils.deprecation.deprecated(reason='API modification',
replacement='getName()',
@@ -222,10 +310,50 @@ class RegionOfInterest(_RegionOfInterestBase):
editable = bool(editable)
if self._editable != editable:
self._editable = editable
- # Recreate plot items
- # This can be avoided once marker.setDraggable is public
- self._createPlotItems()
- self.sigItemChanged.emit(items.ItemChangedType.EDITABLE)
+ self._updated(items.ItemChangedType.EDITABLE)
+
+ def isSelectable(self):
+ """Returns whether the ROI is selectable by the user or not.
+
+ :rtype: bool
+ """
+ return self._selectable
+
+ def setSelectable(self, selectable):
+ """Set whether the ROI can be selected interactively.
+
+ :param bool selectable: True to allow selection by the user,
+ False to disable.
+ """
+ selectable = bool(selectable)
+ if self._selectable != selectable:
+ self._selectable = selectable
+ self._updated(items.ItemChangedType.SELECTABLE)
+
+ def getFocusProxy(self):
+ """Returns the ROI which have to be selected when this ROI is selected,
+ else None if no proxy specified.
+
+ :rtype: RegionOfInterest
+ """
+ proxy = self._focusProxy
+ if proxy is None:
+ return None
+ proxy = proxy()
+ if proxy is None:
+ self._focusProxy = None
+ return proxy
+
+ def setFocusProxy(self, roi):
+ """Set the real ROI which will be selected when this ROI is selected,
+ else None to remove the proxy already specified.
+
+ :param RegionOfInterest roi: A ROI
+ """
+ if roi is not None:
+ self._focusProxy = weakref.ref(roi)
+ else:
+ self._focusProxy = None
def isVisible(self):
"""Returns whether the ROI is visible in the plot.
@@ -249,21 +377,7 @@ class RegionOfInterest(_RegionOfInterestBase):
visible = bool(visible)
if self._visible != visible:
self._visible = visible
- if self._labelItem is not None:
- self._labelItem.setVisible(visible)
- for item in self._items + self._editAnchors:
- item.setVisible(visible)
- self.sigItemChanged.emit(items.ItemChangedType.VISIBLE)
-
- def _getControlPoints(self):
- """Returns the current ROI control points.
-
- It returns an empty tuple if there is currently no ROI.
-
- :return: Array of (x, y) position in plot coordinates
- :rtype: numpy.ndarray
- """
- return None if self._points is None else numpy.array(self._points)
+ self._updated(items.ItemChangedType.VISIBLE)
@classmethod
def showFirstInteractionShape(cls):
@@ -272,7 +386,7 @@ class RegionOfInterest(_RegionOfInterestBase):
:rtype: bool
"""
- return True
+ return False
@classmethod
def getFirstInteractionShape(cls):
@@ -291,226 +405,369 @@ class RegionOfInterest(_RegionOfInterestBase):
This interaction is constrained by the plot API and only supports few
shapes.
"""
- points = self._createControlPointsFromFirstShape(points)
- self._setControlPoints(points)
-
- def _createControlPointsFromFirstShape(self, points):
- """Returns the list of control points from the very first shape
- provided.
+ raise NotImplementedError()
- This shape is provided by the plot interaction and constained by the
- class of the ROI itself.
+ def creationStarted(self):
+ """"Called when the ROI creation interaction was started.
"""
- return points
+ pass
- def _setControlPoints(self, points):
- """Set this ROI control points.
+ @docstring(_RegionOfInterestBase)
+ def contains(self, position):
+ raise NotImplementedError("Base class")
- :param points: Iterable of (x, y) control points
+ def creationFinalized(self):
+ """"Called when the ROI creation interaction was finalized.
"""
- points = numpy.array(points)
+ pass
- nbPointsChanged = (self._points is None or
- points.shape != self._points.shape)
+ def _updateItemProperty(self, event, source, destination):
+ """Update the item property of a destination from an item source.
- if nbPointsChanged or not numpy.all(numpy.equal(points, self._points)):
- self._points = points
-
- self._updateShape()
- if self._items and not nbPointsChanged: # Update plot items
- item = self._getLabelItem()
- if item is not None:
- markerPos = self._getLabelPosition()
- item.setPosition(*markerPos)
-
- if self._editAnchors: # Update anchors
- for anchor, point in zip(self._editAnchors, points):
- old = anchor.blockSignals(True)
- anchor.setPosition(*point)
- anchor.blockSignals(old)
-
- else: # No items or new point added
- # re-create plot items
- self._createPlotItems()
-
- self.sigRegionChanged.emit()
-
- def _updateShape(self):
- """Called when shape must be updated.
-
- Must be reimplemented if a shape item have to be updated.
+ :param items.ItemChangedType event: Property type to update
+ :param silx.gui.plot.items.Item source: The reference for the data
+ :param event Union[Item,List[Item]] destination: The item(s) to update
"""
- return
-
- def _getLabelPosition(self):
- """Compute position of the label
+ if not isinstance(destination, (list, tuple)):
+ destination = [destination]
+ if event == items.ItemChangedType.NAME:
+ value = source.getName()
+ for d in destination:
+ d.setName(value)
+ elif event == items.ItemChangedType.EDITABLE:
+ value = source.isEditable()
+ for d in destination:
+ d.setEditable(value)
+ elif event == items.ItemChangedType.SELECTABLE:
+ value = source.isSelectable()
+ for d in destination:
+ d._setSelectable(value)
+ elif event == items.ItemChangedType.COLOR:
+ value = rgba(source.getColor())
+ for d in destination:
+ d.setColor(value)
+ elif event == items.ItemChangedType.LINE_STYLE:
+ value = self.getLineStyle()
+ for d in destination:
+ d.setLineStyle(value)
+ elif event == items.ItemChangedType.LINE_WIDTH:
+ value = self.getLineWidth()
+ for d in destination:
+ d.setLineWidth(value)
+ elif event == items.ItemChangedType.SYMBOL:
+ value = self.getSymbol()
+ for d in destination:
+ d.setSymbol(value)
+ elif event == items.ItemChangedType.SYMBOL_SIZE:
+ value = self.getSymbolSize()
+ for d in destination:
+ d.setSymbolSize(value)
+ elif event == items.ItemChangedType.VISIBLE:
+ value = self.isVisible()
+ for d in destination:
+ d.setVisible(value)
+ else:
+ assert False
- :return: (x, y) position of the marker
+ def _updated(self, event=None, checkVisibility=True):
+ if event == items.ItemChangedType.HIGHLIGHTED:
+ style = self.getCurrentStyle()
+ self._updatedStyle(event, style)
+ else:
+ hilighted = self.isHighlighted()
+ if hilighted:
+ if event == items.ItemChangedType.HIGHLIGHTED_STYLE:
+ style = self.getCurrentStyle()
+ self._updatedStyle(event, style)
+ else:
+ if event in [items.ItemChangedType.COLOR,
+ items.ItemChangedType.LINE_STYLE,
+ items.ItemChangedType.LINE_WIDTH,
+ items.ItemChangedType.SYMBOL,
+ items.ItemChangedType.SYMBOL_SIZE]:
+ style = self.getCurrentStyle()
+ self._updatedStyle(event, style)
+ super(RegionOfInterest, self)._updated(event, checkVisibility)
+
+ def _updatedStyle(self, event, style):
+ """Called when the current displayed style of the ROI was changed.
+
+ :param event: The event responsible of the change of the style
+ :param items.CurveStyle style: The current style
"""
- return None
+ pass
+
+ def getCurrentStyle(self):
+ """Returns the current curve style.
- def _createPlotItems(self):
- """Create items displaying the ROI in the plot.
+ Curve style depends on curve highlighting
- It first removes any existing plot items.
+ :rtype: CurveStyle
"""
- roiManager = self.parent()
- if roiManager is None:
- return
- plot = roiManager.parent()
+ baseColor = rgba(self.getColor())
+ if isinstance(self, core.LineMixIn):
+ baseLinestyle = self.getLineStyle()
+ baseLinewidth = self.getLineWidth()
+ else:
+ baseLinestyle = self._DEFAULT_LINESTYLE
+ baseLinewidth = self._DEFAULT_LINEWIDTH
+ if isinstance(self, core.SymbolMixIn):
+ baseSymbol = self.getSymbol()
+ baseSymbolsize = self.getSymbolSize()
+ else:
+ baseSymbol = 'o'
+ baseSymbolsize = 1
+
+ if self.isHighlighted():
+ style = self.getHighlightedStyle()
+ color = style.getColor()
+ linestyle = style.getLineStyle()
+ linewidth = style.getLineWidth()
+ symbol = style.getSymbol()
+ symbolsize = style.getSymbolSize()
+
+ return items.CurveStyle(
+ color=baseColor if color is None else color,
+ linestyle=baseLinestyle if linestyle is None else linestyle,
+ linewidth=baseLinewidth if linewidth is None else linewidth,
+ symbol=baseSymbol if symbol is None else symbol,
+ symbolsize=baseSymbolsize if symbolsize is None else symbolsize)
+ else:
+ return items.CurveStyle(color=baseColor,
+ linestyle=baseLinestyle,
+ linewidth=baseLinewidth,
+ symbol=baseSymbol,
+ symbolsize=baseSymbolsize)
- self._removePlotItems()
+ def _editingStarted(self):
+ assert self._editable is True
+ self.sigEditingStarted.emit()
- legendPrefix = "__RegionOfInterest-%d__" % id(self)
- itemIndex = 0
+ def _editingFinished(self):
+ self.sigEditingFinished.emit()
- controlPoints = self._getControlPoints()
- if self._labelItem is None:
- self._labelItem = self._createLabelItem()
- if self._labelItem is not None:
- self._labelItem._setLegend(legendPrefix + "label")
- plot._add(self._labelItem)
- self._labelItem.setVisible(self.isVisible())
+class HandleBasedROI(RegionOfInterest):
+ """Manage a ROI based on a set of handles"""
- self._items = WeakList()
- plotItems = self._createShapeItems(controlPoints)
- for item in plotItems:
- item._setLegend(legendPrefix + str(itemIndex))
- plot._add(item)
- item.setVisible(self.isVisible())
- self._items.append(item)
- itemIndex += 1
+ def __init__(self, parent=None):
+ RegionOfInterest.__init__(self, parent=parent)
+ self._handles = []
+ self._posOrigin = None
+ self._posPrevious = None
- self._editAnchors = WeakList()
- if self.isEditable():
- plotItems = self._createAnchorItems(controlPoints)
- color = rgba(self.getColor())
- color = self._getAnchorColor(color)
- for index, item in enumerate(plotItems):
- item._setLegend(legendPrefix + str(itemIndex))
- item.setColor(color)
- item.setVisible(self.isVisible())
- plot._add(item)
- item.sigItemChanged.connect(functools.partial(
- self._controlPointAnchorChanged, index))
- self._editAnchors.append(item)
- itemIndex += 1
+ def addUserHandle(self, item=None):
+ """
+ Add a new free handle to the ROI.
- def _updateLabelItem(self, label):
- """Update the marker displaying the label.
+ This handle do nothing. It have to be managed by the ROI
+ implementing this class.
- Inherite this method to custom the way the ROI display the label.
+ :param Union[None,silx.gui.plot.items.Marker] item: The new marker to
+ add, else None to create a default marker.
+ :rtype: silx.gui.plot.items.Marker
+ """
+ return self.addHandle(item, role="user")
- :param str label: The new label to use
+ def addLabelHandle(self, item=None):
"""
- item = self._getLabelItem()
- if item is not None:
- item.setText(label)
+ Add a new label handle to the ROI.
- def _createLabelItem(self):
- """Returns a created marker which will be used to dipslay the label of
- this ROI.
+ This handle is not draggable nor selectable.
- Inherite this method to return nothing if no new items have to be
- created, or your own marker.
+ It is displayed without symbol, but it is always visible anyway
+ the ROI is editable, in order to display text.
- :rtype: Union[None,Marker]
+ :param Union[None,silx.gui.plot.items.Marker] item: The new marker to
+ add, else None to create a default marker.
+ :rtype: silx.gui.plot.items.Marker
"""
- # Add label marker
- markerPos = self._getLabelPosition()
- marker = items.Marker()
- marker.setPosition(*markerPos)
- marker.setText(self.getName())
- marker.setColor(rgba(self.getColor()))
- marker.setSymbol('')
- marker._setDraggable(False)
- return marker
+ return self.addHandle(item, role="label")
- def _getLabelItem(self):
- """Returns the marker displaying the label of this ROI.
-
- Inherite this method to choose your own item. In case this item is also
- a control point.
+ def addTranslateHandle(self, item=None):
"""
- return self._labelItem
+ Add a new translate handle to the ROI.
- def _createShapeItems(self, points):
- """Create shape items from the current control points.
+ Dragging translate handles affect the position position of the ROI
+ but not the shape itself.
- :rtype: List[PlotItem]
+ :param Union[None,silx.gui.plot.items.Marker] item: The new marker to
+ add, else None to create a default marker.
+ :rtype: silx.gui.plot.items.Marker
"""
- return []
-
- def _createAnchorItems(self, points):
- """Create anchor items from the current control points.
+ return self.addHandle(item, role="translate")
- :rtype: List[Marker]
+ def addHandle(self, item=None, role="default"):
"""
- return []
+ Add a new handle to the ROI.
- def _controlPointAnchorChanged(self, index, event):
- """Handle update of position of an edition anchor
+ Dragging handles while affect the position or the shape of the
+ ROI.
- :param int index: Index of the anchor
- :param ItemChangedType event: Event type
+ :param Union[None,silx.gui.plot.items.Marker] item: The new marker to
+ add, else None to create a default marker.
+ :rtype: silx.gui.plot.items.Marker
+ """
+ if item is None:
+ item = items.Marker()
+ color = rgba(self.getColor())
+ color = self._computeHandleColor(color)
+ item.setColor(color)
+ if role == "default":
+ item.setSymbol("s")
+ elif role == "user":
+ pass
+ elif role == "translate":
+ item.setSymbol("+")
+ elif role == "label":
+ item.setSymbol("")
+
+ if role == "user":
+ pass
+ elif role == "label":
+ item._setSelectable(False)
+ item._setDraggable(False)
+ item.setVisible(True)
+ else:
+ self.__updateEditable(item, self.isEditable(), remove=False)
+ item._setSelectable(False)
+
+ self._handles.append((item, role))
+ self.addItem(item)
+ return item
+
+ def removeHandle(self, handle):
+ data = [d for d in self._handles if d[0] is handle][0]
+ self._handles.remove(data)
+ role = data[1]
+ if role not in ["user", "label"]:
+ if self.isEditable():
+ self.__updateEditable(handle, False)
+ self.removeItem(handle)
+
+ def getHandles(self):
+ """Returns the list of handles of this HandleBasedROI.
+
+ :rtype: List[~silx.gui.plot.items.Marker]
"""
- if event == items.ItemChangedType.POSITION:
- anchor = self._editAnchors[index]
- previous = self._points[index].copy()
- current = anchor.getPosition()
- self._controlPointAnchorPositionChanged(index, current, previous)
+ return tuple(data[0] for data in self._handles)
- def _controlPointAnchorPositionChanged(self, index, current, previous):
- """Called when an anchor is manually edited.
+ def _updated(self, event=None, checkVisibility=True):
+ """Implement Item mix-in update method by updating the plot items
- This function have to be inherited to change the behaviours of the
- control points. This function have to call :meth:`_getControlPoints` to
- reach the previous state of the control points. Updated the positions
- of the changed control points. Then call :meth:`_setControlPoints` to
- update the anchors and send signals.
+ See :class:`~silx.gui.plot.items.Item._updated`
"""
- points = self._getControlPoints()
- points[index] = current
- self._setControlPoints(points)
+ if event == items.ItemChangedType.NAME:
+ self._updateText(self.getName())
+ elif event == items.ItemChangedType.VISIBLE:
+ for item, role in self._handles:
+ visible = self.isVisible()
+ editionVisible = visible and self.isEditable()
+ if role not in ["user", "label"]:
+ item.setVisible(editionVisible)
+ else:
+ item.setVisible(visible)
+ elif event == items.ItemChangedType.EDITABLE:
+ for item, role in self._handles:
+ editable = self.isEditable()
+ if role not in ["user", "label"]:
+ self.__updateEditable(item, editable)
+ super(HandleBasedROI, self)._updated(event, checkVisibility)
+
+ def _updatedStyle(self, event, style):
+ super(HandleBasedROI, self)._updatedStyle(event, style)
+
+ # Update color of shape items in the plot
+ color = rgba(self.getColor())
+ handleColor = self._computeHandleColor(color)
+ for item, role in self._handles:
+ if role == 'user':
+ pass
+ elif role == 'label':
+ item.setColor(color)
+ else:
+ item.setColor(handleColor)
+
+ def __updateEditable(self, handle, editable, remove=True):
+ # NOTE: visibility change emit a position update event
+ handle.setVisible(editable and self.isVisible())
+ handle._setDraggable(editable)
+ if editable:
+ handle.sigDragStarted.connect(self._handleEditingStarted)
+ handle.sigItemChanged.connect(self._handleEditingUpdated)
+ handle.sigDragFinished.connect(self._handleEditingFinished)
+ else:
+ if remove:
+ handle.sigDragStarted.disconnect(self._handleEditingStarted)
+ handle.sigItemChanged.disconnect(self._handleEditingUpdated)
+ handle.sigDragFinished.disconnect(self._handleEditingFinished)
+
+ def _handleEditingStarted(self):
+ super(HandleBasedROI, self)._editingStarted()
+ handle = self.sender()
+ self._posOrigin = numpy.array(handle.getPosition())
+ self._posPrevious = numpy.array(self._posOrigin)
+ self.handleDragStarted(handle, self._posOrigin)
+
+ def _handleEditingUpdated(self):
+ if self._posOrigin is None:
+ # Avoid to handle events when visibility change
+ return
+ handle = self.sender()
+ current = numpy.array(handle.getPosition())
+ self.handleDragUpdated(handle, self._posOrigin, self._posPrevious, current)
+ self._posPrevious = current
+
+ def _handleEditingFinished(self):
+ handle = self.sender()
+ current = numpy.array(handle.getPosition())
+ self.handleDragFinished(handle, self._posOrigin, current)
+ self._posPrevious = None
+ self._posOrigin = None
+ super(HandleBasedROI, self)._editingFinished()
+
+ def isHandleBeingDragged(self):
+ """Returns True if one of the handles is currently being dragged.
- def _removePlotItems(self):
- """Remove items from their plot."""
- for item in itertools.chain(list(self._items),
- list(self._editAnchors)):
+ :rtype: bool
+ """
+ return self._posOrigin is not None
- plot = item.getPlot()
- if plot is not None:
- plot._remove(item)
- self._items = WeakList()
- self._editAnchors = WeakList()
+ def handleDragStarted(self, handle, origin):
+ """Called when an handler drag started"""
+ pass
- if self._labelItem is not None:
- item = self._labelItem
- plot = item.getPlot()
- if plot is not None:
- plot._remove(item)
- self._labelItem = None
+ def handleDragUpdated(self, handle, origin, previous, current):
+ """Called when an handle drag position changed"""
+ pass
- def _updated(self, event=None, checkVisibility=True):
- """Implement Item mix-in update method by updating the plot items
+ def handleDragFinished(self, handle, origin, current):
+ """Called when an handle drag finished"""
+ pass
- See :class:`~silx.gui.plot.items.Item._updated`
+ def _computeHandleColor(self, color):
+ """Returns the anchor color from the base ROI color
+
+ :param Union[numpy.array,Tuple,List]: color
+ :rtype: Union[numpy.array,Tuple,List]
"""
- self._createPlotItems()
+ return color[:3] + (0.5,)
- def __str__(self):
- """Returns parameters of the ROI as a string."""
- points = self._getControlPoints()
- params = '; '.join('(%f; %f)' % (pt[0], pt[1]) for pt in points)
- return "%s(%s)" % (self.__class__.__name__, params)
+ def _updateText(self, text):
+ """Update the text displayed by this ROI
+
+ :param str text: A text
+ """
+ pass
class PointROI(RegionOfInterest, items.SymbolMixIn):
"""A ROI identifying a point in a 2D plot."""
- _kind = "Point"
- """Label for this kind of ROI"""
+ ICON = 'add-shape-point'
+ NAME = 'point markers'
+ SHORT_NAME = "point"
+ """Metadata for this kind of ROI"""
_plotShape = "point"
"""Plot shape which is used for the first interaction"""
@@ -522,82 +779,186 @@ class PointROI(RegionOfInterest, items.SymbolMixIn):
"""
def __init__(self, parent=None):
- items.SymbolMixIn.__init__(self)
RegionOfInterest.__init__(self, parent=parent)
+ items.SymbolMixIn.__init__(self)
+ self._marker = items.Marker()
+ self._marker.sigItemChanged.connect(self._pointPositionChanged)
+ self._marker.setSymbol(self._DEFAULT_SYMBOL)
+ self._marker.sigDragStarted.connect(self._editingStarted)
+ self._marker.sigDragFinished.connect(self._editingFinished)
+ self.addItem(self._marker)
+
+ def setFirstShapePoints(self, points):
+ self.setPosition(points[0])
+
+ def _updated(self, event=None, checkVisibility=True):
+ if event == items.ItemChangedType.NAME:
+ label = self.getName()
+ self._marker.setText(label)
+ elif event == items.ItemChangedType.EDITABLE:
+ self._marker._setDraggable(self.isEditable())
+ elif event in [items.ItemChangedType.VISIBLE,
+ items.ItemChangedType.SELECTABLE]:
+ self._updateItemProperty(event, self, self._marker)
+ super(PointROI, self)._updated(event, checkVisibility)
+
+ def _updatedStyle(self, event, style):
+ self._marker.setColor(style.getColor())
def getPosition(self):
"""Returns the position of this ROI
:rtype: numpy.ndarray
"""
- return self._points[0].copy()
+ return self._marker.getPosition()
def setPosition(self, pos):
"""Set the position of this ROI
:param numpy.ndarray pos: 2d-coordinate of this point
"""
- controlPoints = numpy.array([pos])
- self._setControlPoints(controlPoints)
-
- def _createLabelItem(self):
- return None
+ self._marker.setPosition(*pos)
- def _updateLabelItem(self, label):
- self._items[0].setText(label)
-
- def _updateShape(self):
- if len(self._items) > 0:
- controlPoints = self._getControlPoints()
- item = self._items[0]
- item.setPosition(*controlPoints[0])
+ @docstring(_RegionOfInterestBase)
+ def contains(self, position):
+ raise NotImplementedError('Base class')
- def __positionChanged(self, event):
+ def _pointPositionChanged(self, event):
"""Handle position changed events of the marker"""
if event is items.ItemChangedType.POSITION:
- marker = self.sender()
- if isinstance(marker, items.Marker):
- self.setPosition(marker.getPosition())
-
- def _createShapeItems(self, points):
- marker = items.Marker()
- marker.setPosition(points[0][0], points[0][1])
- marker.setText(self.getName())
- marker.setSymbol(self.getSymbol())
- marker.setSymbolSize(self.getSymbolSize())
- marker.setColor(rgba(self.getColor()))
- marker._setDraggable(self.isEditable())
- if self.isEditable():
- marker.sigItemChanged.connect(self.__positionChanged)
- return [marker]
+ self.sigRegionChanged.emit()
def __str__(self):
- points = self._getControlPoints()
- params = '%f %f' % (points[0, 0], points[0, 1])
+ params = '%f %f' % self.getPosition()
return "%s(%s)" % (self.__class__.__name__, params)
-class LineROI(RegionOfInterest, items.LineMixIn):
+class CrossROI(HandleBasedROI, items.LineMixIn):
+ """A ROI identifying a point in a 2D plot and displayed as a cross
+ """
+
+ ICON = 'add-shape-cross'
+ NAME = 'cross marker'
+ SHORT_NAME = "cross"
+ """Metadata for this kind of ROI"""
+
+ _plotShape = "point"
+ """Plot shape which is used for the first interaction"""
+
+ def __init__(self, parent=None):
+ HandleBasedROI.__init__(self, parent=parent)
+ items.LineMixIn.__init__(self)
+ self._handle = self.addHandle()
+ self._handle.sigItemChanged.connect(self._handlePositionChanged)
+ self._handleLabel = self.addLabelHandle()
+ self._vmarker = self.addUserHandle(items.YMarker())
+ self._vmarker._setSelectable(False)
+ self._vmarker._setDraggable(False)
+ self._vmarker.setPosition(*self.getPosition())
+ self._hmarker = self.addUserHandle(items.XMarker())
+ self._hmarker._setSelectable(False)
+ self._hmarker._setDraggable(False)
+ self._hmarker.setPosition(*self.getPosition())
+
+ def _updated(self, event=None, checkVisibility=True):
+ if event in [items.ItemChangedType.VISIBLE]:
+ markers = (self._vmarker, self._hmarker)
+ self._updateItemProperty(event, self, markers)
+ super(CrossROI, self)._updated(event, checkVisibility)
+
+ def _updateText(self, text):
+ self._handleLabel.setText(text)
+
+ def _updatedStyle(self, event, style):
+ super(CrossROI, self)._updatedStyle(event, style)
+ for marker in [self._vmarker, self._hmarker]:
+ marker.setColor(style.getColor())
+ marker.setLineStyle(style.getLineStyle())
+ marker.setLineWidth(style.getLineWidth())
+
+ def setFirstShapePoints(self, points):
+ pos = points[0]
+ self.setPosition(pos)
+
+ def getPosition(self):
+ """Returns the position of this ROI
+
+ :rtype: numpy.ndarray
+ """
+ return self._handle.getPosition()
+
+ def setPosition(self, pos):
+ """Set the position of this ROI
+
+ :param numpy.ndarray pos: 2d-coordinate of this point
+ """
+ self._handle.setPosition(*pos)
+
+ def _handlePositionChanged(self, event):
+ """Handle center marker position updates"""
+ if event is items.ItemChangedType.POSITION:
+ position = self.getPosition()
+ self._handleLabel.setPosition(*position)
+ self._vmarker.setPosition(*position)
+ self._hmarker.setPosition(*position)
+ self.sigRegionChanged.emit()
+
+ @docstring(HandleBasedROI)
+ def contains(self, position):
+ roiPos = self.getPosition()
+ return position[0] == roiPos[0] or position[1] == roiPos[1]
+
+
+class LineROI(HandleBasedROI, items.LineMixIn):
"""A ROI identifying a line in a 2D plot.
This ROI provides 1 anchor for each boundary of the line, plus an center
in the center to translate the full ROI.
"""
- _kind = "Line"
- """Label for this kind of ROI"""
+ ICON = 'add-shape-diagonal'
+ NAME = 'line ROI'
+ SHORT_NAME = "line"
+ """Metadata for this kind of ROI"""
_plotShape = "line"
"""Plot shape which is used for the first interaction"""
def __init__(self, parent=None):
+ HandleBasedROI.__init__(self, parent=parent)
items.LineMixIn.__init__(self)
- RegionOfInterest.__init__(self, parent=parent)
+ self._handleStart = self.addHandle()
+ self._handleEnd = self.addHandle()
+ self._handleCenter = self.addTranslateHandle()
+ self._handleLabel = self.addLabelHandle()
+
+ shape = items.Shape("polylines")
+ shape.setPoints([[0, 0], [0, 0]])
+ shape.setColor(rgba(self.getColor()))
+ shape.setFill(False)
+ shape.setOverlay(True)
+ shape.setLineStyle(self.getLineStyle())
+ shape.setLineWidth(self.getLineWidth())
+ self.__shape = shape
+ self.addItem(shape)
+
+ def _updated(self, event=None, checkVisibility=True):
+ if event == items.ItemChangedType.VISIBLE:
+ self._updateItemProperty(event, self, self.__shape)
+ super(LineROI, self)._updated(event, checkVisibility)
- def _createControlPointsFromFirstShape(self, points):
- center = numpy.mean(points, axis=0)
- controlPoints = numpy.array([points[0], points[1], center])
- return controlPoints
+ def _updatedStyle(self, event, style):
+ super(LineROI, self)._updatedStyle(event, style)
+ self.__shape.setColor(style.getColor())
+ self.__shape.setLineStyle(style.getLineStyle())
+ self.__shape.setLineWidth(style.getLineWidth())
+
+ def setFirstShapePoints(self, points):
+ assert len(points) == 2
+ self.setEndPoints(points[0], points[1])
+
+ def _updateText(self, text):
+ self._handleLabel.setText(text)
def setEndPoints(self, startPoint, endPoint):
"""Set this line location using the ending points
@@ -605,88 +966,83 @@ class LineROI(RegionOfInterest, items.LineMixIn):
:param numpy.ndarray startPoint: Staring bounding point of the line
:param numpy.ndarray endPoint: Ending bounding point of the line
"""
- assert(startPoint.shape == (2,) and endPoint.shape == (2,))
- shapePoints = numpy.array([startPoint, endPoint])
- controlPoints = self._createControlPointsFromFirstShape(shapePoints)
- self._setControlPoints(controlPoints)
+ if not numpy.array_equal((startPoint, endPoint), self.getEndPoints()):
+ self.__updateEndPoints(startPoint, endPoint)
+
+ def __updateEndPoints(self, startPoint, endPoint):
+ """Update marker and shape to match given end points
+
+ :param numpy.ndarray startPoint: Staring bounding point of the line
+ :param numpy.ndarray endPoint: Ending bounding point of the line
+ """
+ startPoint = numpy.array(startPoint)
+ endPoint = numpy.array(endPoint)
+ center = (startPoint + endPoint) * 0.5
+
+ with utils.blockSignals(self._handleStart):
+ self._handleStart.setPosition(startPoint[0], startPoint[1])
+ with utils.blockSignals(self._handleEnd):
+ self._handleEnd.setPosition(endPoint[0], endPoint[1])
+ with utils.blockSignals(self._handleCenter):
+ self._handleCenter.setPosition(center[0], center[1])
+ with utils.blockSignals(self._handleLabel):
+ self._handleLabel.setPosition(center[0], center[1])
+
+ line = numpy.array((startPoint, endPoint))
+ self.__shape.setPoints(line)
+ self.sigRegionChanged.emit()
def getEndPoints(self):
"""Returns bounding points of this ROI.
:rtype: Tuple(numpy.ndarray,numpy.ndarray)
"""
- startPoint = self._points[0].copy()
- endPoint = self._points[1].copy()
+ startPoint = numpy.array(self._handleStart.getPosition())
+ endPoint = numpy.array(self._handleEnd.getPosition())
return (startPoint, endPoint)
- def _getLabelPosition(self):
- points = self._getControlPoints()
- return points[-1]
-
- def _updateShape(self):
- if len(self._items) == 0:
- return
- shape = self._items[0]
- points = self._getControlPoints()
- points = self._getShapeFromControlPoints(points)
- shape.setPoints(points)
-
- def _getShapeFromControlPoints(self, points):
- # Remove the center from the control points
- return points[0:2]
-
- def _createShapeItems(self, points):
- shapePoints = self._getShapeFromControlPoints(points)
- item = items.Shape("polylines")
- item.setPoints(shapePoints)
- item.setColor(rgba(self.getColor()))
- item.setFill(False)
- item.setOverlay(True)
- item.setLineStyle(self.getLineStyle())
- item.setLineWidth(self.getLineWidth())
- return [item]
-
- def _createAnchorItems(self, points):
- anchors = []
- for point in points[0:-1]:
- anchor = items.Marker()
- anchor.setPosition(*point)
- anchor.setText('')
- anchor.setSymbol('s')
- anchor._setDraggable(True)
- anchors.append(anchor)
-
- # Add an anchor to the center of the rectangle
- center = numpy.mean(points, axis=0)
- anchor = items.Marker()
- anchor.setPosition(*center)
- anchor.setText('')
- anchor.setSymbol('+')
- anchor._setDraggable(True)
- anchors.append(anchor)
-
- return anchors
-
- def _controlPointAnchorPositionChanged(self, index, current, previous):
- if index == len(self._editAnchors) - 1:
- # It is the center anchor
- points = self._getControlPoints()
- center = numpy.mean(points[0:-1], axis=0)
- offset = current - previous
- points[-1] = current
- points[0:-1] = points[0:-1] + offset
- self._setControlPoints(points)
- else:
- # Update the center
- points = self._getControlPoints()
- points[index] = current
- center = numpy.mean(points[0:-1], axis=0)
- points[-1] = center
- self._setControlPoints(points)
+ def handleDragUpdated(self, handle, origin, previous, current):
+ if handle is self._handleStart:
+ _start, end = self.getEndPoints()
+ self.__updateEndPoints(current, end)
+ elif handle is self._handleEnd:
+ start, _end = self.getEndPoints()
+ self.__updateEndPoints(start, current)
+ elif handle is self._handleCenter:
+ start, end = self.getEndPoints()
+ delta = current - previous
+ start += delta
+ end += delta
+ self.setEndPoints(start, end)
+
+ @docstring(_RegionOfInterestBase)
+ def contains(self, position):
+ bottom_left = position[0], position[1]
+ bottom_right = position[0] + 1, position[1]
+ top_left = position[0], position[1] + 1
+ top_right = position[0] + 1, position[1] + 1
+
+ line_pt1 = self._points[0]
+ line_pt2 = self._points[1]
+
+ bb1 = _BoundingBox.from_points(self._points)
+ if bb1.contains(position) is False:
+ return False
+
+ return (
+ segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2,
+ seg2_start_pt=bottom_left, seg2_end_pt=bottom_right) or
+ segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2,
+ seg2_start_pt=bottom_right, seg2_end_pt=top_right) or
+ segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2,
+ seg2_start_pt=top_right, seg2_end_pt=top_left) or
+ segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2,
+ seg2_start_pt=top_left, seg2_end_pt=bottom_left)
+ )
def __str__(self):
- points = self._getControlPoints()
- params = points[0][0], points[0][1], points[1][0], points[1][1]
+ start, end = self.getEndPoints()
+ params = start[0], start[1], end[0], end[1]
params = 'start: %f %f; end: %f %f' % params
return "%s(%s)" % (self.__class__.__name__, params)
@@ -694,199 +1050,230 @@ class LineROI(RegionOfInterest, items.LineMixIn):
class HorizontalLineROI(RegionOfInterest, items.LineMixIn):
"""A ROI identifying an horizontal line in a 2D plot."""
- _kind = "HLine"
- """Label for this kind of ROI"""
+ ICON = 'add-shape-horizontal'
+ NAME = 'horizontal line ROI'
+ SHORT_NAME = "hline"
+ """Metadata for this kind of ROI"""
_plotShape = "hline"
"""Plot shape which is used for the first interaction"""
def __init__(self, parent=None):
- items.LineMixIn.__init__(self)
RegionOfInterest.__init__(self, parent=parent)
+ items.LineMixIn.__init__(self)
+ self._marker = items.YMarker()
+ self._marker.sigItemChanged.connect(self._linePositionChanged)
+ self._marker.sigDragStarted.connect(self._editingStarted)
+ self._marker.sigDragFinished.connect(self._editingFinished)
+ self.addItem(self._marker)
- def _createControlPointsFromFirstShape(self, points):
- points = numpy.array([(float('nan'), points[0, 1])],
- dtype=numpy.float64)
- return points
+ def _updated(self, event=None, checkVisibility=True):
+ if event == items.ItemChangedType.NAME:
+ label = self.getName()
+ self._marker.setText(label)
+ elif event == items.ItemChangedType.EDITABLE:
+ self._marker._setDraggable(self.isEditable())
+ elif event in [items.ItemChangedType.VISIBLE,
+ items.ItemChangedType.SELECTABLE]:
+ self._updateItemProperty(event, self, self._marker)
+ super(HorizontalLineROI, self)._updated(event, checkVisibility)
+
+ def _updatedStyle(self, event, style):
+ self._marker.setColor(style.getColor())
+ self._marker.setLineStyle(style.getLineStyle())
+ self._marker.setLineWidth(style.getLineWidth())
+
+ def setFirstShapePoints(self, points):
+ pos = points[0, 1]
+ if pos == self.getPosition():
+ return
+ self.setPosition(pos)
def getPosition(self):
"""Returns the position of this line if the horizontal axis
:rtype: float
"""
- return self._points[0, 1]
+ pos = self._marker.getPosition()
+ return pos[1]
def setPosition(self, pos):
"""Set the position of this ROI
:param float pos: Horizontal position of this line
"""
- controlPoints = numpy.array([[float('nan'), pos]])
- self._setControlPoints(controlPoints)
-
- def _createLabelItem(self):
- return None
-
- def _updateLabelItem(self, label):
- self._items[0].setText(label)
+ self._marker.setPosition(0, pos)
- def _updateShape(self):
- if len(self._items) > 0:
- controlPoints = self._getControlPoints()
- item = self._items[0]
- item.setPosition(*controlPoints[0])
+ @docstring(_RegionOfInterestBase)
+ def contains(self, position):
+ return position[1] == self.getPosition()[1]
- def __positionChanged(self, event):
+ def _linePositionChanged(self, event):
"""Handle position changed events of the marker"""
if event is items.ItemChangedType.POSITION:
- marker = self.sender()
- if isinstance(marker, items.YMarker):
- self.setPosition(marker.getYPosition())
-
- def _createShapeItems(self, points):
- marker = items.YMarker()
- marker.setPosition(points[0][0], points[0][1])
- marker.setText(self.getName())
- marker.setColor(rgba(self.getColor()))
- marker.setLineWidth(self.getLineWidth())
- marker.setLineStyle(self.getLineStyle())
- marker._setDraggable(self.isEditable())
- if self.isEditable():
- marker.sigItemChanged.connect(self.__positionChanged)
- return [marker]
+ self.sigRegionChanged.emit()
def __str__(self):
- points = self._getControlPoints()
- params = 'y: %f' % points[0, 1]
+ params = 'y: %f' % self.getPosition()
return "%s(%s)" % (self.__class__.__name__, params)
class VerticalLineROI(RegionOfInterest, items.LineMixIn):
"""A ROI identifying a vertical line in a 2D plot."""
- _kind = "VLine"
- """Label for this kind of ROI"""
+ ICON = 'add-shape-vertical'
+ NAME = 'vertical line ROI'
+ SHORT_NAME = "vline"
+ """Metadata for this kind of ROI"""
_plotShape = "vline"
"""Plot shape which is used for the first interaction"""
def __init__(self, parent=None):
- items.LineMixIn.__init__(self)
RegionOfInterest.__init__(self, parent=parent)
+ items.LineMixIn.__init__(self)
+ self._marker = items.XMarker()
+ self._marker.sigItemChanged.connect(self._linePositionChanged)
+ self._marker.sigDragStarted.connect(self._editingStarted)
+ self._marker.sigDragFinished.connect(self._editingFinished)
+ self.addItem(self._marker)
- def _createControlPointsFromFirstShape(self, points):
- points = numpy.array([(points[0, 0], float('nan'))],
- dtype=numpy.float64)
- return points
+ def _updated(self, event=None, checkVisibility=True):
+ if event == items.ItemChangedType.NAME:
+ label = self.getName()
+ self._marker.setText(label)
+ elif event == items.ItemChangedType.EDITABLE:
+ self._marker._setDraggable(self.isEditable())
+ elif event in [items.ItemChangedType.VISIBLE,
+ items.ItemChangedType.SELECTABLE]:
+ self._updateItemProperty(event, self, self._marker)
+ super(VerticalLineROI, self)._updated(event, checkVisibility)
+
+ def _updatedStyle(self, event, style):
+ self._marker.setColor(style.getColor())
+ self._marker.setLineStyle(style.getLineStyle())
+ self._marker.setLineWidth(style.getLineWidth())
+
+ def setFirstShapePoints(self, points):
+ pos = points[0, 0]
+ self.setPosition(pos)
def getPosition(self):
"""Returns the position of this line if the horizontal axis
:rtype: float
"""
- return self._points[0, 0]
+ pos = self._marker.getPosition()
+ return pos[0]
def setPosition(self, pos):
"""Set the position of this ROI
:param float pos: Horizontal position of this line
"""
- controlPoints = numpy.array([[pos, float('nan')]])
- self._setControlPoints(controlPoints)
-
- def _createLabelItem(self):
- return None
+ self._marker.setPosition(pos, 0)
- def _updateLabelItem(self, label):
- self._items[0].setText(label)
+ @docstring(RegionOfInterest)
+ def contains(self, position):
+ return position[0] == self.getPosition()[0]
- def _updateShape(self):
- if len(self._items) > 0:
- controlPoints = self._getControlPoints()
- item = self._items[0]
- item.setPosition(*controlPoints[0])
-
- def __positionChanged(self, event):
+ def _linePositionChanged(self, event):
"""Handle position changed events of the marker"""
if event is items.ItemChangedType.POSITION:
- marker = self.sender()
- if isinstance(marker, items.XMarker):
- self.setPosition(marker.getXPosition())
-
- def _createShapeItems(self, points):
- marker = items.XMarker()
- marker.setPosition(points[0][0], points[0][1])
- marker.setText(self.getName())
- marker.setColor(rgba(self.getColor()))
- marker.setLineWidth(self.getLineWidth())
- marker.setLineStyle(self.getLineStyle())
- marker._setDraggable(self.isEditable())
- if self.isEditable():
- marker.sigItemChanged.connect(self.__positionChanged)
- return [marker]
+ self.sigRegionChanged.emit()
def __str__(self):
- points = self._getControlPoints()
- params = 'x: %f' % points[0, 0]
+ params = 'x: %f' % self.getPosition()
return "%s(%s)" % (self.__class__.__name__, params)
-class RectangleROI(RegionOfInterest, items.LineMixIn):
+class RectangleROI(HandleBasedROI, items.LineMixIn):
"""A ROI identifying a rectangle in a 2D plot.
This ROI provides 1 anchor for each corner, plus an anchor in the
center to translate the full ROI.
"""
- _kind = "Rectangle"
- """Label for this kind of ROI"""
+ ICON = 'add-shape-rectangle'
+ NAME = 'rectangle ROI'
+ SHORT_NAME = "rectangle"
+ """Metadata for this kind of ROI"""
_plotShape = "rectangle"
"""Plot shape which is used for the first interaction"""
def __init__(self, parent=None):
+ HandleBasedROI.__init__(self, parent=parent)
items.LineMixIn.__init__(self)
- RegionOfInterest.__init__(self, parent=parent)
+ self._handleTopLeft = self.addHandle()
+ self._handleTopRight = self.addHandle()
+ self._handleBottomLeft = self.addHandle()
+ self._handleBottomRight = self.addHandle()
+ self._handleCenter = self.addTranslateHandle()
+ self._handleLabel = self.addLabelHandle()
+
+ shape = items.Shape("rectangle")
+ shape.setPoints([[0, 0], [0, 0]])
+ shape.setFill(False)
+ shape.setOverlay(True)
+ shape.setLineStyle(self.getLineStyle())
+ shape.setLineWidth(self.getLineWidth())
+ shape.setColor(rgba(self.getColor()))
+ self.__shape = shape
+ self.addItem(shape)
- def _createControlPointsFromFirstShape(self, points):
- point0 = points[0]
- point1 = points[1]
+ def _updated(self, event=None, checkVisibility=True):
+ if event in [items.ItemChangedType.VISIBLE]:
+ self._updateItemProperty(event, self, self.__shape)
+ super(RectangleROI, self)._updated(event, checkVisibility)
+
+ def _updatedStyle(self, event, style):
+ super(RectangleROI, self)._updatedStyle(event, style)
+ self.__shape.setColor(style.getColor())
+ self.__shape.setLineStyle(style.getLineStyle())
+ self.__shape.setLineWidth(style.getLineWidth())
+
+ def setFirstShapePoints(self, points):
+ assert len(points) == 2
+ self._setBound(points)
- # 4 corners
- controlPoints = numpy.array([
- point0[0], point0[1],
- point0[0], point1[1],
- point1[0], point1[1],
- point1[0], point0[1],
- ])
- # Central
- center = numpy.mean(points, axis=0)
- controlPoints = numpy.append(controlPoints, center)
- controlPoints.shape = -1, 2
- return controlPoints
+ def _setBound(self, points):
+ """Initialize the rectangle from a bunch of points"""
+ top = max(points[:, 1])
+ bottom = min(points[:, 1])
+ left = min(points[:, 0])
+ right = max(points[:, 0])
+ size = right - left, top - bottom
+ self._updateGeometry(origin=(left, bottom), size=size)
+
+ def _updateText(self, text):
+ self._handleLabel.setText(text)
def getCenter(self):
"""Returns the central point of this rectangle
:rtype: numpy.ndarray([float,float])
"""
- return numpy.mean(self._points, axis=0)
+ pos = self._handleCenter.getPosition()
+ return numpy.array(pos)
def getOrigin(self):
"""Returns the corner point with the smaller coordinates
:rtype: numpy.ndarray([float,float])
"""
- return numpy.min(self._points, axis=0)
+ pos = self._handleBottomLeft.getPosition()
+ return numpy.array(pos)
def getSize(self):
"""Returns the size of this rectangle
:rtype: numpy.ndarray([float,float])
"""
- minPoint = numpy.min(self._points, axis=0)
- maxPoint = numpy.max(self._points, axis=0)
- return maxPoint - minPoint
+ vmin = self._handleBottomLeft.getPosition()
+ vmax = self._handleTopRight.getPosition()
+ vmin, vmax = numpy.array(vmin), numpy.array(vmax)
+ return vmax - vmin
def setOrigin(self, position):
"""Set the origin position of this ROI
@@ -915,93 +1302,80 @@ class RectangleROI(RegionOfInterest, items.LineMixIn):
def setGeometry(self, origin=None, size=None, center=None):
"""Set the geometry of the ROI
"""
+ if ((origin is None or numpy.array_equal(origin, self.getOrigin())) and
+ (center is None or numpy.array_equal(center, self.getCenter())) and
+ numpy.array_equal(size, self.getSize())):
+ return # Nothing has changed
+
+ self._updateGeometry(origin, size, center)
+
+ def _updateGeometry(self, origin=None, size=None, center=None):
+ """Forced update of the geometry of the ROI"""
if origin is not None:
origin = numpy.array(origin)
size = numpy.array(size)
points = numpy.array([origin, origin + size])
- controlPoints = self._createControlPointsFromFirstShape(points)
+ center = origin + size * 0.5
elif center is not None:
center = numpy.array(center)
size = numpy.array(size)
points = numpy.array([center - size * 0.5, center + size * 0.5])
- controlPoints = self._createControlPointsFromFirstShape(points)
else:
- raise ValueError("Origin or cengter expected")
- self._setControlPoints(controlPoints)
-
- def _getLabelPosition(self):
- points = self._getControlPoints()
- return points.min(axis=0)
-
- def _updateShape(self):
- if len(self._items) == 0:
- return
- shape = self._items[0]
- points = self._getControlPoints()
- points = self._getShapeFromControlPoints(points)
- shape.setPoints(points)
-
- def _getShapeFromControlPoints(self, points):
- minPoint = points.min(axis=0)
- maxPoint = points.max(axis=0)
- return numpy.array([minPoint, maxPoint])
-
- def _createShapeItems(self, points):
- shapePoints = self._getShapeFromControlPoints(points)
- item = items.Shape("rectangle")
- item.setPoints(shapePoints)
- item.setColor(rgba(self.getColor()))
- item.setFill(False)
- item.setOverlay(True)
- item.setLineStyle(self.getLineStyle())
- item.setLineWidth(self.getLineWidth())
- return [item]
-
- def _createAnchorItems(self, points):
- # Remove the center control point
- points = points[0:-1]
-
- anchors = []
- for point in points:
- anchor = items.Marker()
- anchor.setPosition(*point)
- anchor.setText('')
- anchor.setSymbol('s')
- anchor._setDraggable(True)
- anchors.append(anchor)
-
- # Add an anchor to the center of the rectangle
- center = numpy.mean(points, axis=0)
- anchor = items.Marker()
- anchor.setPosition(*center)
- anchor.setText('')
- anchor.setSymbol('+')
- anchor._setDraggable(True)
- anchors.append(anchor)
-
- return anchors
-
- def _controlPointAnchorPositionChanged(self, index, current, previous):
- if index == len(self._editAnchors) - 1:
+ raise ValueError("Origin or center expected")
+
+ with utils.blockSignals(self._handleBottomLeft):
+ self._handleBottomLeft.setPosition(points[0, 0], points[0, 1])
+ with utils.blockSignals(self._handleBottomRight):
+ self._handleBottomRight.setPosition(points[1, 0], points[0, 1])
+ with utils.blockSignals(self._handleTopLeft):
+ self._handleTopLeft.setPosition(points[0, 0], points[1, 1])
+ with utils.blockSignals(self._handleTopRight):
+ self._handleTopRight.setPosition(points[1, 0], points[1, 1])
+ with utils.blockSignals(self._handleCenter):
+ self._handleCenter.setPosition(center[0], center[1])
+ with utils.blockSignals(self._handleLabel):
+ self._handleLabel.setPosition(points[0, 0], points[0, 1])
+
+ self.__shape.setPoints(points)
+ self.sigRegionChanged.emit()
+
+ @docstring(HandleBasedROI)
+ def contains(self, position):
+ assert isinstance(position, (tuple, list, numpy.array))
+ points = self.__shape.getPoints()
+ bb1 = _BoundingBox.from_points(points)
+ return bb1.contains(position)
+
+ def handleDragUpdated(self, handle, origin, previous, current):
+ if handle is self._handleCenter:
# It is the center anchor
- points = self._getControlPoints()
- center = numpy.mean(points[0:-1], axis=0)
- offset = current - previous
- points[-1] = current
- points[0:-1] = points[0:-1] + offset
- self._setControlPoints(points)
+ size = self.getSize()
+ self._updateGeometry(center=current, size=size)
else:
- # Fix other corners
- constrains = [(1, 3), (0, 2), (3, 1), (2, 0)]
- constrains = constrains[index]
- points = self._getControlPoints()
- points[index] = current
- points[constrains[0]][0] = current[0]
- points[constrains[1]][1] = current[1]
- # Update the center
- center = numpy.mean(points[0:-1], axis=0)
- points[-1] = center
- self._setControlPoints(points)
+ opposed = {
+ self._handleBottomLeft: self._handleTopRight,
+ self._handleTopRight: self._handleBottomLeft,
+ self._handleBottomRight: self._handleTopLeft,
+ self._handleTopLeft: self._handleBottomRight,
+ }
+ handle2 = opposed[handle]
+ current2 = handle2.getPosition()
+ points = numpy.array([current, current2])
+
+ # Switch handles if they were crossed by interaction
+ if self._handleBottomLeft.getXPosition() > self._handleBottomRight.getXPosition():
+ self._handleBottomLeft, self._handleBottomRight = self._handleBottomRight, self._handleBottomLeft
+
+ if self._handleTopLeft.getXPosition() > self._handleTopRight.getXPosition():
+ self._handleTopLeft, self._handleTopRight = self._handleTopRight, self._handleTopLeft
+
+ if self._handleBottomLeft.getYPosition() > self._handleTopLeft.getYPosition():
+ self._handleBottomLeft, self._handleTopLeft = self._handleTopLeft, self._handleBottomLeft
+
+ if self._handleBottomRight.getYPosition() > self._handleTopRight.getYPosition():
+ self._handleBottomRight, self._handleTopRight = self._handleTopRight, self._handleBottomRight
+
+ self._setBound(points)
def __str__(self):
origin = self.getOrigin()
@@ -1011,21 +1385,504 @@ class RectangleROI(RegionOfInterest, items.LineMixIn):
return "%s(%s)" % (self.__class__.__name__, params)
-class PolygonROI(RegionOfInterest, items.LineMixIn):
+class CircleROI(HandleBasedROI, items.LineMixIn):
+ """A ROI identifying a circle in a 2D plot.
+
+ This ROI provides 1 anchor at the center to translate the circle,
+ and one anchor on the perimeter to change the radius.
+ """
+
+ ICON = 'add-shape-circle'
+ NAME = 'circle ROI'
+ SHORT_NAME = "circle"
+ """Metadata for this kind of ROI"""
+
+ _kind = "Circle"
+ """Label for this kind of ROI"""
+
+ _plotShape = "line"
+ """Plot shape which is used for the first interaction"""
+
+ def __init__(self, parent=None):
+ items.LineMixIn.__init__(self)
+ HandleBasedROI.__init__(self, parent=parent)
+ self._handlePerimeter = self.addHandle()
+ self._handleCenter = self.addTranslateHandle()
+ self._handleCenter.sigItemChanged.connect(self._centerPositionChanged)
+ self._handleLabel = self.addLabelHandle()
+
+ shape = items.Shape("polygon")
+ shape.setPoints([[0, 0], [0, 0]])
+ shape.setColor(rgba(self.getColor()))
+ shape.setFill(False)
+ shape.setOverlay(True)
+ shape.setLineStyle(self.getLineStyle())
+ shape.setLineWidth(self.getLineWidth())
+ self.__shape = shape
+ self.addItem(shape)
+
+ self.__radius = 0
+
+ def _updated(self, event=None, checkVisibility=True):
+ if event == items.ItemChangedType.VISIBLE:
+ self._updateItemProperty(event, self, self.__shape)
+ super(CircleROI, self)._updated(event, checkVisibility)
+
+ def _updatedStyle(self, event, style):
+ super(CircleROI, self)._updatedStyle(event, style)
+ self.__shape.setColor(style.getColor())
+ self.__shape.setLineStyle(style.getLineStyle())
+ self.__shape.setLineWidth(style.getLineWidth())
+
+ def setFirstShapePoints(self, points):
+ assert len(points) == 2
+ self._setRay(points)
+
+ def _setRay(self, points):
+ """Initialize the circle from the center point and a
+ perimeter point."""
+ center = points[0]
+ radius = numpy.linalg.norm(points[0] - points[1])
+ self.setGeometry(center=center, radius=radius)
+
+ def _updateText(self, text):
+ self._handleLabel.setText(text)
+
+ def getCenter(self):
+ """Returns the central point of this rectangle
+
+ :rtype: numpy.ndarray([float,float])
+ """
+ pos = self._handleCenter.getPosition()
+ return numpy.array(pos)
+
+ def getRadius(self):
+ """Returns the radius of this circle
+
+ :rtype: float
+ """
+ return self.__radius
+
+ def setCenter(self, position):
+ """Set the center point of this ROI
+
+ :param numpy.ndarray position: Location of the center of the circle
+ """
+ self._handleCenter.setPosition(*position)
+
+ def setRadius(self, radius):
+ """Set the size of this ROI
+
+ :param float size: Radius of the circle
+ """
+ radius = float(radius)
+ if radius != self.__radius:
+ self.__radius = radius
+ self._updateGeometry()
+
+ def setGeometry(self, center, radius):
+ """Set the geometry of the ROI
+ """
+ if numpy.array_equal(center, self.getCenter()):
+ self.setRadius(radius)
+ else:
+ self.__radius = float(radius) # Update radius directly
+ self.setCenter(center) # Calls _updateGeometry
+
+ def _updateGeometry(self):
+ """Update the handles and shape according to given parameters"""
+ center = self.getCenter()
+ perimeter_point = numpy.array([center[0] + self.__radius, center[1]])
+
+ self._handlePerimeter.setPosition(perimeter_point[0], perimeter_point[1])
+ self._handleLabel.setPosition(center[0], center[1])
+
+ nbpoints = 27
+ angles = numpy.arange(nbpoints) * 2.0 * numpy.pi / nbpoints
+ circleShape = numpy.array((numpy.cos(angles) * self.__radius,
+ numpy.sin(angles) * self.__radius)).T
+ circleShape += center
+ self.__shape.setPoints(circleShape)
+ self.sigRegionChanged.emit()
+
+ def _centerPositionChanged(self, event):
+ """Handle position changed events of the center marker"""
+ if event is items.ItemChangedType.POSITION:
+ self._updateGeometry()
+
+ def handleDragUpdated(self, handle, origin, previous, current):
+ if handle is self._handlePerimeter:
+ center = self.getCenter()
+ self.setRadius(numpy.linalg.norm(center - current))
+
+ def __str__(self):
+ center = self.getCenter()
+ radius = self.getRadius()
+ params = center[0], center[1], radius
+ params = 'center: %f %f; radius: %f;' % params
+ return "%s(%s)" % (self.__class__.__name__, params)
+
+
+class EllipseROI(HandleBasedROI, items.LineMixIn):
+ """A ROI identifying an oriented ellipse in a 2D plot.
+
+ This ROI provides 1 anchor at the center to translate the circle,
+ and two anchors on the perimeter to modify the major-radius and
+ minor-radius. These two anchors also allow to change the orientation.
+ """
+
+ ICON = 'add-shape-ellipse'
+ NAME = 'ellipse ROI'
+ SHORT_NAME = "ellipse"
+ """Metadata for this kind of ROI"""
+
+ _plotShape = "line"
+ """Plot shape which is used for the first interaction"""
+
+ def __init__(self, parent=None):
+ items.LineMixIn.__init__(self)
+ HandleBasedROI.__init__(self, parent=parent)
+ self._handleAxis0 = self.addHandle()
+ self._handleAxis1 = self.addHandle()
+ self._handleCenter = self.addTranslateHandle()
+ self._handleCenter.sigItemChanged.connect(self._centerPositionChanged)
+ self._handleLabel = self.addLabelHandle()
+
+ shape = items.Shape("polygon")
+ shape.setPoints([[0, 0], [0, 0]])
+ shape.setColor(rgba(self.getColor()))
+ shape.setFill(False)
+ shape.setOverlay(True)
+ shape.setLineStyle(self.getLineStyle())
+ shape.setLineWidth(self.getLineWidth())
+ self.__shape = shape
+ self.addItem(shape)
+
+ self._radius = 0., 0.
+ self._orientation = 0. # angle in radians between the X-axis and the _handleAxis0
+
+ def _updated(self, event=None, checkVisibility=True):
+ if event == items.ItemChangedType.VISIBLE:
+ self._updateItemProperty(event, self, self.__shape)
+ super(EllipseROI, self)._updated(event, checkVisibility)
+
+ def _updatedStyle(self, event, style):
+ super(EllipseROI, self)._updatedStyle(event, style)
+ self.__shape.setColor(style.getColor())
+ self.__shape.setLineStyle(style.getLineStyle())
+ self.__shape.setLineWidth(style.getLineWidth())
+
+ def setFirstShapePoints(self, points):
+ assert len(points) == 2
+ self._setRay(points)
+
+ @staticmethod
+ def _calculateOrientation(p0, p1):
+ """return angle in radians between the vector p0-p1
+ and the X axis
+
+ :param p0: first point coordinates (x, y)
+ :param p1: second point coordinates
+ :return:
+ """
+ vector = (p1[0] - p0[0], p1[1] - p0[1])
+ x_unit_vector = (1, 0)
+ norm = numpy.linalg.norm(vector)
+ if norm != 0:
+ theta = numpy.arccos(numpy.dot(vector, x_unit_vector) / norm)
+ else:
+ theta = 0
+ if vector[1] < 0:
+ # arccos always returns values in range [0, pi]
+ theta = 2 * numpy.pi - theta
+ return theta
+
+ def _setRay(self, points):
+ """Initialize the circle from the center point and a
+ perimeter point."""
+ center = points[0]
+ radius = numpy.linalg.norm(points[0] - points[1])
+ orientation = self._calculateOrientation(points[0], points[1])
+ self.setGeometry(center=center,
+ radius=(radius, radius),
+ orientation=orientation)
+
+ def _updateText(self, text):
+ self._handleLabel.setText(text)
+
+ def getCenter(self):
+ """Returns the central point of this rectangle
+
+ :rtype: numpy.ndarray([float,float])
+ """
+ pos = self._handleCenter.getPosition()
+ return numpy.array(pos)
+
+ def getMajorRadius(self):
+ """Returns the half-diameter of the major axis.
+
+ :rtype: float
+ """
+ return max(self._radius)
+
+ def getMinorRadius(self):
+ """Returns the half-diameter of the minor axis.
+
+ :rtype: float
+ """
+ return min(self._radius)
+
+ def getOrientation(self):
+ """Return angle in radians between the horizontal (X) axis
+ and the major axis of the ellipse in [0, 2*pi[
+
+ :rtype: float:
+ """
+ return self._orientation
+
+ def setCenter(self, center):
+ """Set the center point of this ROI
+
+ :param numpy.ndarray position: Coordinates (X, Y) of the center
+ of the ellipse
+ """
+ self._handleCenter.setPosition(*center)
+
+ def setMajorRadius(self, radius):
+ """Set the half-diameter of the major axis of the ellipse.
+
+ :param float radius:
+ Major radius of the ellipsis. Must be a positive value.
+ """
+ if self._radius[0] > self._radius[1]:
+ newRadius = radius, self._radius[1]
+ else:
+ newRadius = self._radius[0], radius
+ self.setGeometry(radius=newRadius)
+
+ def setMinorRadius(self, radius):
+ """Set the half-diameter of the minor axis of the ellipse.
+
+ :param float radius:
+ Minor radius of the ellipsis. Must be a positive value.
+ """
+ if self._radius[0] > self._radius[1]:
+ newRadius = self._radius[0], radius
+ else:
+ newRadius = radius, self._radius[1]
+ self.setGeometry(radius=newRadius)
+
+ def setOrientation(self, orientation):
+ """Rotate the ellipse
+
+ :param float orientation: Angle in radians between the horizontal and
+ the major axis.
+ :return:
+ """
+ self.setGeometry(orientation=orientation)
+
+ def setGeometry(self, center=None, radius=None, orientation=None):
+ """
+
+ :param center: (X, Y) coordinates
+ :param float majorRadius:
+ :param float minorRadius:
+ :param float orientation: angle in radians between the major axis and the
+ horizontal
+ :return:
+ """
+ if center is None:
+ center = self.getCenter()
+
+ if radius is None:
+ radius = self._radius
+ else:
+ radius = float(radius[0]), float(radius[1])
+
+ if orientation is None:
+ orientation = self._orientation
+ else:
+ # ensure that we store the orientation in range [0, 2*pi
+ orientation = numpy.mod(orientation, 2 * numpy.pi)
+
+ if (numpy.array_equal(center, self.getCenter()) or
+ radius != self._radius or
+ orientation != self._orientation):
+
+ # Update parameters directly
+ self._radius = radius
+ self._orientation = orientation
+
+ if numpy.array_equal(center, self.getCenter()):
+ self._updateGeometry()
+ else:
+ # This will call _updateGeometry
+ self.setCenter(center)
+
+ def _updateGeometry(self):
+ """Update shape and markers"""
+ center = self.getCenter()
+
+ orientation = self.getOrientation()
+ if self._radius[1] > self._radius[0]:
+ # _handleAxis1 is the major axis
+ orientation -= numpy.pi/2
+
+ point0 = numpy.array([center[0] + self._radius[0] * numpy.cos(orientation),
+ center[1] + self._radius[0] * numpy.sin(orientation)])
+ point1 = numpy.array([center[0] - self._radius[1] * numpy.sin(orientation),
+ center[1] + self._radius[1] * numpy.cos(orientation)])
+ with utils.blockSignals(self._handleAxis0):
+ self._handleAxis0.setPosition(*point0)
+ with utils.blockSignals(self._handleAxis1):
+ self._handleAxis1.setPosition(*point1)
+ with utils.blockSignals(self._handleLabel):
+ self._handleLabel.setPosition(*center)
+
+ nbpoints = 27
+ angles = numpy.arange(nbpoints) * 2.0 * numpy.pi / nbpoints
+ X = (self._radius[0] * numpy.cos(angles) * numpy.cos(orientation)
+ - self._radius[1] * numpy.sin(angles) * numpy.sin(orientation))
+ Y = (self._radius[0] * numpy.cos(angles) * numpy.sin(orientation)
+ + self._radius[1] * numpy.sin(angles) * numpy.cos(orientation))
+
+ ellipseShape = numpy.array((X, Y)).T
+ ellipseShape += center
+ self.__shape.setPoints(ellipseShape)
+ self.sigRegionChanged.emit()
+
+ def handleDragUpdated(self, handle, origin, previous, current):
+ if handle in (self._handleAxis0, self._handleAxis1):
+ center = self.getCenter()
+ orientation = self._calculateOrientation(center, current)
+ distance = numpy.linalg.norm(center - current)
+
+ if handle is self._handleAxis1:
+ if self._radius[0] > distance:
+ # _handleAxis1 is not the major axis, rotate -90 degrees
+ orientation -= numpy.pi/2
+ radius = self._radius[0], distance
+
+ else: # _handleAxis0
+ if self._radius[1] > distance:
+ # _handleAxis0 is not the major axis, rotate +90 degrees
+ orientation += numpy.pi/2
+ radius = distance, self._radius[1]
+
+ self.setGeometry(radius=radius, orientation=orientation)
+
+ def _centerPositionChanged(self, event):
+ """Handle position changed events of the center marker"""
+ if event is items.ItemChangedType.POSITION:
+ self._updateGeometry()
+
+ def __str__(self):
+ center = self.getCenter()
+ major = self.getMajorRadius()
+ minor = self.getMinorRadius()
+ orientation = self.getOrientation()
+ params = center[0], center[1], major, minor, orientation
+ params = 'center: %f %f; major radius: %f: minor radius: %f; orientation: %f' % params
+ return "%s(%s)" % (self.__class__.__name__, params)
+
+
+class PolygonROI(HandleBasedROI, items.LineMixIn):
"""A ROI identifying a closed polygon in a 2D plot.
This ROI provides 1 anchor for each point of the polygon.
"""
- _kind = "Polygon"
- """Label for this kind of ROI"""
+ ICON = 'add-shape-polygon'
+ NAME = 'polygon ROI'
+ SHORT_NAME = "polygon"
+ """Metadata for this kind of ROI"""
_plotShape = "polygon"
"""Plot shape which is used for the first interaction"""
def __init__(self, parent=None):
+ HandleBasedROI.__init__(self, parent=parent)
items.LineMixIn.__init__(self)
- RegionOfInterest.__init__(self, parent=parent)
+ self._handleLabel = self.addLabelHandle()
+ self._handleCenter = self.addTranslateHandle()
+ self._handlePoints = []
+ self._points = numpy.empty((0, 2))
+ self._handleClose = None
+
+ self._polygon_shape = None
+ shape = self.__createShape()
+ self.__shape = shape
+ self.addItem(shape)
+
+ def _updated(self, event=None, checkVisibility=True):
+ if event in [items.ItemChangedType.VISIBLE]:
+ self._updateItemProperty(event, self, self.__shape)
+ super(PolygonROI, self)._updated(event, checkVisibility)
+
+ def _updatedStyle(self, event, style):
+ super(PolygonROI, self)._updatedStyle(event, style)
+ self.__shape.setColor(style.getColor())
+ self.__shape.setLineStyle(style.getLineStyle())
+ self.__shape.setLineWidth(style.getLineWidth())
+ if self._handleClose is not None:
+ color = self._computeHandleColor(style.getColor())
+ self._handleClose.setColor(color)
+
+ def __createShape(self, interaction=False):
+ kind = "polygon" if not interaction else "polylines"
+ shape = items.Shape(kind)
+ shape.setPoints([[0, 0], [0, 0]])
+ shape.setFill(False)
+ shape.setOverlay(True)
+ style = self.getCurrentStyle()
+ shape.setLineStyle(style.getLineStyle())
+ shape.setLineWidth(style.getLineWidth())
+ shape.setColor(rgba(style.getColor()))
+ return shape
+
+ def setFirstShapePoints(self, points):
+ if self._handleClose is not None:
+ self._handleClose.setPosition(*points[0])
+ self.setPoints(points)
+
+ def creationStarted(self):
+ """"Called when the ROI creation interaction was started.
+ """
+ # Handle to see where to close the polygon
+ self._handleClose = self.addUserHandle()
+ self._handleClose.setSymbol("o")
+ color = self._computeHandleColor(rgba(self.getColor()))
+ self._handleClose.setColor(color)
+
+ # Hide the center while creating the first shape
+ self._handleCenter.setSymbol("")
+
+ # In interaction replace the polygon by a line, to display something unclosed
+ self.removeItem(self.__shape)
+ self.__shape = self.__createShape(interaction=True)
+ self.__shape.setPoints(self._points)
+ self.addItem(self.__shape)
+
+ def isBeingCreated(self):
+ """Returns true if the ROI is in creation step"""
+ return self._handleClose is not None
+
+ def creationFinalized(self):
+ """"Called when the ROI creation interaction was finalized.
+ """
+ self.removeHandle(self._handleClose)
+ self._handleClose = None
+ self.removeItem(self.__shape)
+ self.__shape = self.__createShape()
+ self.__shape.setPoints(self._points)
+ self.addItem(self.__shape)
+ # Hide the center while creating the first shape
+ self._handleCenter.setSymbol("+")
+ for handle in self._handlePoints:
+ handle.setSymbol("s")
+
+ def _updateText(self, text):
+ self._handleLabel.setText(text)
def getPoints(self):
"""Returns the list of the points of this polygon.
@@ -1040,213 +1897,484 @@ class PolygonROI(RegionOfInterest, items.LineMixIn):
:param numpy.ndarray pos: 2d-coordinate of this point
"""
assert(len(points.shape) == 2 and points.shape[1] == 2)
- if len(points) > 0:
- controlPoints = numpy.array(points)
- else:
- controlPoints = numpy.empty((0, 2))
- self._setControlPoints(controlPoints)
- def _getLabelPosition(self):
- points = self._getControlPoints()
- if len(points) == 0:
- # FIXME: we should return none, this polygon have no location
- return numpy.array([0, 0])
- return points[numpy.argmin(points[:, 1])]
+ if numpy.array_equal(points, self._points):
+ return # Nothing has changed
- def _updateShape(self):
- if len(self._items) == 0:
- return
- shape = self._items[0]
- points = self._getControlPoints()
- shape.setPoints(points)
+ self._polygon_shape = None
+
+ # Update the needed handles
+ while len(self._handlePoints) != len(points):
+ if len(self._handlePoints) < len(points):
+ handle = self.addHandle()
+ self._handlePoints.append(handle)
+ if self.isBeingCreated():
+ handle.setSymbol("")
+ else:
+ handle = self._handlePoints.pop(-1)
+ self.removeHandle(handle)
+
+ for handle, position in zip(self._handlePoints, points):
+ with utils.blockSignals(handle):
+ handle.setPosition(position[0], position[1])
+
+ if len(points) > 0:
+ if not self.isHandleBeingDragged():
+ vmin = numpy.min(points, axis=0)
+ vmax = numpy.max(points, axis=0)
+ center = (vmax + vmin) * 0.5
+ with utils.blockSignals(self._handleCenter):
+ self._handleCenter.setPosition(center[0], center[1])
+
+ num = numpy.argmin(points[:, 1])
+ pos = points[num]
+ with utils.blockSignals(self._handleLabel):
+ self._handleLabel.setPosition(pos[0], pos[1])
- def _createShapeItems(self, points):
if len(points) == 0:
- return []
+ self._points = numpy.empty((0, 2))
+ else:
+ self._points = points
+ self.__shape.setPoints(self._points)
+ self.sigRegionChanged.emit()
+
+ def translate(self, x, y):
+ points = self.getPoints()
+ delta = numpy.array([x, y])
+ self.setPoints(points)
+ self.setPoints(points + delta)
+
+ def handleDragUpdated(self, handle, origin, previous, current):
+ if handle is self._handleCenter:
+ delta = current - previous
+ self.translate(delta[0], delta[1])
else:
- item = items.Shape("polygon")
- item.setPoints(points)
- item.setColor(rgba(self.getColor()))
- item.setFill(False)
- item.setOverlay(True)
- item.setLineStyle(self.getLineStyle())
- item.setLineWidth(self.getLineWidth())
- return [item]
-
- def _createAnchorItems(self, points):
- anchors = []
- for point in points:
- anchor = items.Marker()
- anchor.setPosition(*point)
- anchor.setText('')
- anchor.setSymbol('s')
- anchor._setDraggable(True)
- anchors.append(anchor)
- return anchors
+ points = self.getPoints()
+ num = self._handlePoints.index(handle)
+ points[num] = current
+ self.setPoints(points)
+
+ def handleDragFinished(self, handle, origin, current):
+ points = self._points
+ if len(points) > 0:
+ # Only update the center at the end
+ # To avoid to disturb the interaction
+ vmin = numpy.min(points, axis=0)
+ vmax = numpy.max(points, axis=0)
+ center = (vmax + vmin) * 0.5
+ with utils.blockSignals(self._handleCenter):
+ self._handleCenter.setPosition(center[0], center[1])
def __str__(self):
- points = self._getControlPoints()
+ points = self._points
params = '; '.join('%f %f' % (pt[0], pt[1]) for pt in points)
return "%s(%s)" % (self.__class__.__name__, params)
+ @docstring(HandleBasedROI)
+ def contains(self, position):
+ bb1 = _BoundingBox.from_points(self.getPoints())
+ if bb1.contains(position) is False:
+ return False
+
+ if self._polygon_shape is None:
+ self._polygon_shape = Polygon(vertices=self.getPoints())
+
+ # warning: both the polygon and the value are inverted
+ return self._polygon_shape.is_inside(row=position[0], col=position[1])
+
+ def _setControlPoints(self, points):
+ RegionOfInterest._setControlPoints(self, points=points)
+ self._polygon_shape = None
-class ArcROI(RegionOfInterest, items.LineMixIn):
+
+class ArcROI(HandleBasedROI, items.LineMixIn):
"""A ROI identifying an arc of a circle with a width.
- This ROI provides 3 anchors to control the curvature, 1 anchor to control
- the weigth, and 1 anchor to translate the shape.
+ This ROI provides
+ - 3 handle to control the curvature
+ - 1 handle to control the weight
+ - 1 anchor to translate the shape.
"""
- _kind = "Arc"
- """Label for this kind of ROI"""
+ ICON = 'add-shape-arc'
+ NAME = 'arc ROI'
+ SHORT_NAME = "arc"
+ """Metadata for this kind of ROI"""
_plotShape = "line"
"""Plot shape which is used for the first interaction"""
- _ArcGeometry = collections.namedtuple('ArcGeometry', ['center',
- 'startPoint', 'endPoint',
- 'radius', 'weight',
- 'startAngle', 'endAngle'])
+ class _Geometry:
+ def __init__(self):
+ self.center = None
+ self.startPoint = None
+ self.endPoint = None
+ self.radius = None
+ self.weight = None
+ self.startAngle = None
+ self.endAngle = None
+ self._closed = None
+
+ @classmethod
+ def createEmpty(cls):
+ zero = numpy.array([0, 0])
+ return cls.create(zero, zero.copy(), zero.copy(), 0, 0, 0, 0)
+
+ @classmethod
+ def createRect(cls, startPoint, endPoint, weight):
+ return cls.create(None, startPoint, endPoint, None, weight, None, None, False)
+
+ @classmethod
+ def createCircle(cls, center, startPoint, endPoint, radius,
+ weight, startAngle, endAngle):
+ return cls.create(center, startPoint, endPoint, radius,
+ weight, startAngle, endAngle, True)
+
+ @classmethod
+ def create(cls, center, startPoint, endPoint, radius,
+ weight, startAngle, endAngle, closed=False):
+ g = cls()
+ g.center = center
+ g.startPoint = startPoint
+ g.endPoint = endPoint
+ g.radius = radius
+ g.weight = weight
+ g.startAngle = startAngle
+ g.endAngle = endAngle
+ g._closed = closed
+ return g
+
+ def withWeight(self, weight):
+ """Create a new geometry with another weight
+ """
+ return self.create(self.center, self.startPoint, self.endPoint,
+ self.radius, weight,
+ self.startAngle, self.endAngle, self._closed)
+
+ def withRadius(self, radius):
+ """Create a new geometry with another radius.
+
+ The weight and the center is conserved.
+ """
+ startPoint = self.center + (self.startPoint - self.center) / self.radius * radius
+ endPoint = self.center + (self.endPoint - self.center) / self.radius * radius
+ return self.create(self.center, startPoint, endPoint,
+ radius, self.weight,
+ self.startAngle, self.endAngle, self._closed)
+
+ def translated(self, x, y):
+ delta = numpy.array([x, y])
+ center = None if self.center is None else self.center + delta
+ startPoint = None if self.startPoint is None else self.startPoint + delta
+ endPoint = None if self.endPoint is None else self.endPoint + delta
+ return self.create(center, startPoint, endPoint,
+ self.radius, self.weight,
+ self.startAngle, self.endAngle, self._closed)
+
+ def getKind(self):
+ """Returns the kind of shape defined"""
+ if self.center is None:
+ return "rect"
+ elif numpy.isnan(self.startAngle):
+ return "point"
+ elif self.isClosed():
+ if self.weight <= 0 or self.weight * 0.5 >= self.radius:
+ return "circle"
+ else:
+ return "donut"
+ else:
+ if self.weight * 0.5 < self.radius:
+ return "arc"
+ else:
+ return "camembert"
+
+ def isClosed(self):
+ """Returns True if the geometry is a circle like"""
+ if self._closed is not None:
+ return self._closed
+ delta = numpy.abs(self.endAngle - self.startAngle)
+ self._closed = numpy.isclose(delta, numpy.pi * 2)
+ return self._closed
+
+ def __str__(self):
+ return str((self.center,
+ self.startPoint,
+ self.endPoint,
+ self.radius,
+ self.weight,
+ self.startAngle,
+ self.endAngle,
+ self._closed))
def __init__(self, parent=None):
+ HandleBasedROI.__init__(self, parent=parent)
items.LineMixIn.__init__(self)
- RegionOfInterest.__init__(self, parent=parent)
- self._geometry = None
+ self._geometry = self._Geometry.createEmpty()
+ self._handleLabel = self.addLabelHandle()
+
+ self._handleStart = self.addHandle()
+ self._handleStart.setSymbol("o")
+ self._handleMid = self.addHandle()
+ self._handleMid.setSymbol("o")
+ self._handleEnd = self.addHandle()
+ self._handleEnd.setSymbol("o")
+ self._handleWeight = self.addHandle()
+ self._handleWeight._setConstraint(self._arcCurvatureMarkerConstraint)
+ self._handleMove = self.addTranslateHandle()
+
+ shape = items.Shape("polygon")
+ shape.setPoints([[0, 0], [0, 0]])
+ shape.setColor(rgba(self.getColor()))
+ shape.setFill(False)
+ shape.setOverlay(True)
+ shape.setLineStyle(self.getLineStyle())
+ shape.setLineWidth(self.getLineWidth())
+ self.__shape = shape
+ self.addItem(shape)
+
+ def _updated(self, event=None, checkVisibility=True):
+ if event == items.ItemChangedType.VISIBLE:
+ self._updateItemProperty(event, self, self.__shape)
+ super(ArcROI, self)._updated(event, checkVisibility)
- def _getInternalGeometry(self):
- """Returns the object storing the internal geometry of this ROI.
+ def _updatedStyle(self, event, style):
+ super(ArcROI, self)._updatedStyle(event, style)
+ self.__shape.setColor(style.getColor())
+ self.__shape.setLineStyle(style.getLineStyle())
+ self.__shape.setLineWidth(style.getLineWidth())
- This geometry is derived from the control points and cached for
- efficiency. Calling :meth:`_setControlPoints` invalidate the cache.
+ def setFirstShapePoints(self, points):
+ """"Initialize the ROI using the points from the first interaction.
+
+ This interaction is constrained by the plot API and only supports few
+ shapes.
"""
- if self._geometry is None:
- controlPoints = self._getControlPoints()
- self._geometry = self._createGeometryFromControlPoint(controlPoints)
- return self._geometry
+ # The first shape is a line
+ point0 = points[0]
+ point1 = points[1]
- @classmethod
- def showFirstInteractionShape(cls):
- return False
+ # Compute a non collinear point for the curvature
+ center = (point1 + point0) * 0.5
+ normal = point1 - center
+ normal = numpy.array((normal[1], -normal[0]))
+ defaultCurvature = numpy.pi / 5.0
+ weightCoef = 0.20
+ mid = center - normal * defaultCurvature
+ distance = numpy.linalg.norm(point0 - point1)
+ weight = distance * weightCoef
- def _getLabelPosition(self):
- points = self._getControlPoints()
- return points.min(axis=0)
+ geometry = self._createGeometryFromControlPoints(point0, mid, point1, weight)
+ self._geometry = geometry
+ self._updateHandles()
- def _updateShape(self):
- if len(self._items) == 0:
- return
- shape = self._items[0]
- points = self._getControlPoints()
- points = self._getShapeFromControlPoints(points)
- shape.setPoints(points)
-
- def _controlPointAnchorPositionChanged(self, index, current, previous):
- controlPoints = self._getControlPoints()
- currentWeigth = numpy.linalg.norm(controlPoints[3] - controlPoints[1]) * 2
-
- if index in [0, 2]:
- # Moving start or end will maintain the same curvature
- # Then we have to custom the curvature control point
- startPoint = controlPoints[0]
- endPoint = controlPoints[2]
- center = (startPoint + endPoint) * 0.5
- normal = (endPoint - startPoint)
- normal = numpy.array((normal[1], -normal[0]))
- distance = numpy.linalg.norm(normal)
- # Compute the coeficient which have to be constrained
- if distance != 0:
- normal /= distance
- midVector = controlPoints[1] - center
- constainedCoef = numpy.dot(midVector, normal) / distance
+ def _updateText(self, text):
+ self._handleLabel.setText(text)
+
+ def _updateMidHandle(self):
+ """Keep the same geometry, but update the location of the control
+ points.
+
+ So calling this function do not trigger sigRegionChanged.
+ """
+ geometry = self._geometry
+
+ if geometry.isClosed():
+ start = numpy.array(self._handleStart.getPosition())
+ geometry.endPoint = start
+ with utils.blockSignals(self._handleEnd):
+ self._handleEnd.setPosition(*start)
+ midPos = geometry.center + geometry.center - start
+ else:
+ if geometry.center is None:
+ midPos = geometry.startPoint * 0.66 + geometry.endPoint * 0.34
else:
- constainedCoef = 1.0
-
- # Compute the location of the curvature point
- controlPoints[index] = current
- startPoint = controlPoints[0]
- endPoint = controlPoints[2]
- center = (startPoint + endPoint) * 0.5
- normal = (endPoint - startPoint)
+ midAngle = geometry.startAngle * 0.66 + geometry.endAngle * 0.34
+ vector = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)])
+ midPos = geometry.center + geometry.radius * vector
+
+ with utils.blockSignals(self._handleMid):
+ self._handleMid.setPosition(*midPos)
+
+ def _updateWeightHandle(self):
+ geometry = self._geometry
+ if geometry.center is None:
+ # rectangle
+ center = (geometry.startPoint + geometry.endPoint) * 0.5
+ normal = geometry.endPoint - geometry.startPoint
normal = numpy.array((normal[1], -normal[0]))
distance = numpy.linalg.norm(normal)
if distance != 0:
- # BTW we dont need to divide by the distance here
- # Cause we compute normal * distance after all
- normal /= distance
- midPoint = center + normal * constainedCoef * distance
- controlPoints[1] = midPoint
-
- # The weight have to be fixed
- self._updateWeightControlPoint(controlPoints, currentWeigth)
- self._setControlPoints(controlPoints)
-
- elif index == 1:
- # The weight have to be fixed
- controlPoints[index] = current
- self._updateWeightControlPoint(controlPoints, currentWeigth)
- self._setControlPoints(controlPoints)
+ normal = normal / distance
+ weightPos = center + normal * geometry.weight * 0.5
else:
- super(ArcROI, self)._controlPointAnchorPositionChanged(index, current, previous)
+ if geometry.isClosed():
+ midAngle = geometry.startAngle + numpy.pi * 0.5
+ elif geometry.center is not None:
+ midAngle = (geometry.startAngle + geometry.endAngle) * 0.5
+ vector = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)])
+ weightPos = geometry.center + (geometry.radius + geometry.weight * 0.5) * vector
+
+ with utils.blockSignals(self._handleWeight):
+ self._handleWeight.setPosition(*weightPos)
+
+ def _getWeightFromHandle(self, weightPos):
+ geometry = self._geometry
+ if geometry.center is None:
+ # rectangle
+ center = (geometry.startPoint + geometry.endPoint) * 0.5
+ return numpy.linalg.norm(center - weightPos) * 2
+ else:
+ distance = numpy.linalg.norm(geometry.center - weightPos)
+ return abs(distance - geometry.radius) * 2
- def _updateWeightControlPoint(self, controlPoints, weigth):
- startPoint = controlPoints[0]
- midPoint = controlPoints[1]
- endPoint = controlPoints[2]
- normal = (endPoint - startPoint)
- normal = numpy.array((normal[1], -normal[0]))
- distance = numpy.linalg.norm(normal)
- if distance != 0:
- normal /= distance
- controlPoints[3] = midPoint + normal * weigth * 0.5
+ def _updateHandles(self):
+ geometry = self._geometry
+ with utils.blockSignals(self._handleStart):
+ self._handleStart.setPosition(*geometry.startPoint)
+ with utils.blockSignals(self._handleEnd):
+ self._handleEnd.setPosition(*geometry.endPoint)
+
+ self._updateMidHandle()
+ self._updateWeightHandle()
- def _createGeometryFromControlPoint(self, controlPoints):
+ self._updateShape()
+
+ def _updateCurvature(self, start, mid, end, updateCurveHandles, checkClosed=False):
+ """Update the curvature using 3 control points in the curve
+
+ :param bool updateCurveHandles: If False curve handles are already at
+ the right location
+ """
+ if updateCurveHandles:
+ with utils.blockSignals(self._handleStart):
+ self._handleStart.setPosition(*start)
+ with utils.blockSignals(self._handleMid):
+ self._handleMid.setPosition(*mid)
+ with utils.blockSignals(self._handleEnd):
+ self._handleEnd.setPosition(*end)
+
+ if checkClosed:
+ closed = self._isCloseInPixel(start, end)
+ else:
+ closed = self._geometry.isClosed()
+
+ weight = self._geometry.weight
+ geometry = self._createGeometryFromControlPoints(start, mid, end, weight, closed=closed)
+ self._geometry = geometry
+
+ self._updateWeightHandle()
+ self._updateShape()
+
+ def handleDragUpdated(self, handle, origin, previous, current):
+ if handle is self._handleStart:
+ mid = numpy.array(self._handleMid.getPosition())
+ end = numpy.array(self._handleEnd.getPosition())
+ self._updateCurvature(current, mid, end,
+ checkClosed=True, updateCurveHandles=False)
+ elif handle is self._handleMid:
+ if self._geometry.isClosed():
+ radius = numpy.linalg.norm(self._geometry.center - current)
+ self._geometry = self._geometry.withRadius(radius)
+ self._updateHandles()
+ else:
+ start = numpy.array(self._handleStart.getPosition())
+ end = numpy.array(self._handleEnd.getPosition())
+ self._updateCurvature(start, current, end, updateCurveHandles=False)
+ elif handle is self._handleEnd:
+ start = numpy.array(self._handleStart.getPosition())
+ mid = numpy.array(self._handleMid.getPosition())
+ self._updateCurvature(start, mid, current,
+ checkClosed=True, updateCurveHandles=False)
+ elif handle is self._handleWeight:
+ weight = self._getWeightFromHandle(current)
+ self._geometry = self._geometry.withWeight(weight)
+ self._updateShape()
+ elif handle is self._handleMove:
+ delta = current - previous
+ self.translate(*delta)
+
+ def _isCloseInPixel(self, point1, point2):
+ manager = self.parent()
+ if manager is None:
+ return False
+ plot = manager.parent()
+ if plot is None:
+ return False
+ point1 = plot.dataToPixel(*point1)
+ if point1 is None:
+ return False
+ point2 = plot.dataToPixel(*point2)
+ if point2 is None:
+ return False
+ return abs(point1[0] - point2[0]) + abs(point1[1] - point2[1]) < 15
+
+ def _normalizeGeometry(self):
+ """Keep the same phisical geometry, but with normalized parameters.
+ """
+ geometry = self._geometry
+ if geometry.weight * 0.5 >= geometry.radius:
+ radius = (geometry.weight * 0.5 + geometry.radius) * 0.5
+ geometry = geometry.withRadius(radius)
+ geometry = geometry.withWeight(radius * 2)
+ self._geometry = geometry
+ return True
+ return False
+
+ def handleDragFinished(self, handle, origin, current):
+ if handle in [self._handleStart, self._handleMid, self._handleEnd]:
+ if self._normalizeGeometry():
+ self._updateHandles()
+ else:
+ self._updateMidHandle()
+ if self._geometry.isClosed():
+ self._handleStart.setSymbol("x")
+ self._handleEnd.setSymbol("x")
+ else:
+ self._handleStart.setSymbol("o")
+ self._handleEnd.setSymbol("o")
+
+ def _createGeometryFromControlPoints(self, start, mid, end, weight, closed=None):
"""Returns the geometry of the object"""
- weigth = numpy.linalg.norm(controlPoints[3] - controlPoints[1]) * 2
- if numpy.allclose(controlPoints[0], controlPoints[2]):
+ if closed or (closed is None and numpy.allclose(start, end)):
# Special arc: It's a closed circle
- center = (controlPoints[0] + controlPoints[1]) * 0.5
- radius = numpy.linalg.norm(controlPoints[0] - center)
- v = controlPoints[0] - center
+ center = (start + mid) * 0.5
+ radius = numpy.linalg.norm(start - center)
+ v = start - center
startAngle = numpy.angle(complex(v[0], v[1]))
endAngle = startAngle + numpy.pi * 2.0
- return self._ArcGeometry(center, controlPoints[0], controlPoints[2],
- radius, weigth, startAngle, endAngle)
+ return self._Geometry.createCircle(center, start, end, radius,
+ weight, startAngle, endAngle)
- elif numpy.linalg.norm(
- numpy.cross(controlPoints[1] - controlPoints[0],
- controlPoints[2] - controlPoints[0])) < 1e-5:
+ elif numpy.linalg.norm(numpy.cross(mid - start, end - start)) < 1e-5:
# Degenerated arc, it's a rectangle
- return self._ArcGeometry(None, controlPoints[0], controlPoints[2],
- None, weigth, None, None)
+ return self._Geometry.createRect(start, end, weight)
else:
- center, radius = self._circleEquation(*controlPoints[:3])
- v = controlPoints[0] - center
+ center, radius = self._circleEquation(start, mid, end)
+ v = start - center
startAngle = numpy.angle(complex(v[0], v[1]))
- v = controlPoints[1] - center
+ v = mid - center
midAngle = numpy.angle(complex(v[0], v[1]))
- v = controlPoints[2] - center
+ v = end - center
endAngle = numpy.angle(complex(v[0], v[1]))
+
# Is it clockwise or anticlockwise
- if (midAngle - startAngle + 2 * numpy.pi) % (2 * numpy.pi) <= numpy.pi:
+ relativeMid = (endAngle - midAngle + 2 * numpy.pi) % (2 * numpy.pi)
+ relativeEnd = (endAngle - startAngle + 2 * numpy.pi) % (2 * numpy.pi)
+ if relativeMid < relativeEnd:
if endAngle < startAngle:
endAngle += 2 * numpy.pi
else:
if endAngle > startAngle:
endAngle -= 2 * numpy.pi
- return self._ArcGeometry(center, controlPoints[0], controlPoints[2],
- radius, weigth, startAngle, endAngle)
+ return self._Geometry.create(center, start, end,
+ radius, weight, startAngle, endAngle)
- def _isCircle(self, geometry):
- """Returns True if the geometry is a closed circle"""
- delta = numpy.abs(geometry.endAngle - geometry.startAngle)
- return numpy.isclose(delta, numpy.pi * 2)
-
- def _getShapeFromControlPoints(self, controlPoints):
- geometry = self._createGeometryFromControlPoint(controlPoints)
- if geometry.center is None:
+ def _createShapeFromGeometry(self, geometry):
+ kind = geometry.getKind()
+ if kind == "rect":
# It is not an arc
- # but we can display it as an the intermediat shape
+ # but we can display it as an intermediate shape
normal = (geometry.endPoint - geometry.startPoint)
normal = numpy.array((normal[1], -normal[0]))
distance = numpy.linalg.norm(normal)
@@ -1257,15 +2385,40 @@ class ArcROI(RegionOfInterest, items.LineMixIn):
geometry.endPoint + normal * geometry.weight * 0.5,
geometry.endPoint - normal * geometry.weight * 0.5,
geometry.startPoint - normal * geometry.weight * 0.5])
+ elif kind == "point":
+ # It is not an arc
+ # but we can display it as an intermediate shape
+ # NOTE: At least 2 points are expected
+ points = numpy.array([geometry.startPoint, geometry.startPoint])
+ elif kind == "circle":
+ outerRadius = geometry.radius + geometry.weight * 0.5
+ angles = numpy.arange(0, 2 * numpy.pi, 0.1)
+ # It's a circle
+ points = []
+ numpy.append(angles, angles[-1])
+ for angle in angles:
+ direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
+ points.append(geometry.center + direction * outerRadius)
+ points = numpy.array(points)
+ elif kind == "donut":
+ innerRadius = geometry.radius - geometry.weight * 0.5
+ outerRadius = geometry.radius + geometry.weight * 0.5
+ angles = numpy.arange(0, 2 * numpy.pi, 0.1)
+ # It's a donut
+ points = []
+ # NOTE: NaN value allow to create 2 separated circle shapes
+ # using a single plot item. It's a kind of cheat
+ points.append(numpy.array([float("nan"), float("nan")]))
+ for angle in angles:
+ direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
+ points.insert(0, geometry.center + direction * innerRadius)
+ points.append(geometry.center + direction * outerRadius)
+ points.append(numpy.array([float("nan"), float("nan")]))
+ points = numpy.array(points)
else:
innerRadius = geometry.radius - geometry.weight * 0.5
outerRadius = geometry.radius + geometry.weight * 0.5
- if numpy.isnan(geometry.startAngle):
- # Degenerated, it's a point
- # At least 2 points are expected
- return numpy.array([geometry.startPoint, geometry.startPoint])
-
delta = 0.1 if geometry.endAngle >= geometry.startAngle else -0.1
if geometry.startAngle == geometry.endAngle:
# Degenerated, it's a line (single radius)
@@ -1280,57 +2433,58 @@ class ArcROI(RegionOfInterest, items.LineMixIn):
if angles[-1] != geometry.endAngle:
angles = numpy.append(angles, geometry.endAngle)
- isCircle = self._isCircle(geometry)
-
- if isCircle:
- if innerRadius <= 0:
- # It's a circle
- points = []
- numpy.append(angles, angles[-1])
- for angle in angles:
- direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
- points.append(geometry.center + direction * outerRadius)
- else:
- # It's a donut
- points = []
- # NOTE: NaN value allow to create 2 separated circle shapes
- # using a single plot item. It's a kind of cheat
- points.append(numpy.array([float("nan"), float("nan")]))
- for angle in angles:
- direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
- points.insert(0, geometry.center + direction * innerRadius)
- points.append(geometry.center + direction * outerRadius)
- points.append(numpy.array([float("nan"), float("nan")]))
+ if kind == "camembert":
+ # It's a part of camembert
+ points = []
+ points.append(geometry.center)
+ points.append(geometry.startPoint)
+ delta = 0.1 if geometry.endAngle >= geometry.startAngle else -0.1
+ for angle in angles:
+ direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
+ points.append(geometry.center + direction * outerRadius)
+ points.append(geometry.endPoint)
+ points.append(geometry.center)
+ elif kind == "arc":
+ # It's a part of donut
+ points = []
+ points.append(geometry.startPoint)
+ for angle in angles:
+ direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
+ points.insert(0, geometry.center + direction * innerRadius)
+ points.append(geometry.center + direction * outerRadius)
+ points.insert(0, geometry.endPoint)
+ points.append(geometry.endPoint)
else:
- if innerRadius <= 0:
- # It's a part of camembert
- points = []
- points.append(geometry.center)
- points.append(geometry.startPoint)
- delta = 0.1 if geometry.endAngle >= geometry.startAngle else -0.1
- for angle in angles:
- direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
- points.append(geometry.center + direction * outerRadius)
- points.append(geometry.endPoint)
- points.append(geometry.center)
- else:
- # It's a part of donut
- points = []
- points.append(geometry.startPoint)
- for angle in angles:
- direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
- points.insert(0, geometry.center + direction * innerRadius)
- points.append(geometry.center + direction * outerRadius)
- points.insert(0, geometry.endPoint)
- points.append(geometry.endPoint)
+ assert False
+
points = numpy.array(points)
return points
- def _setControlPoints(self, points):
- # Invalidate the geometry
- self._geometry = None
- RegionOfInterest._setControlPoints(self, points)
+ def _updateShape(self):
+ geometry = self._geometry
+ points = self._createShapeFromGeometry(geometry)
+ self.__shape.setPoints(points)
+
+ index = numpy.nanargmin(points[:, 1])
+ pos = points[index]
+ with utils.blockSignals(self._handleLabel):
+ self._handleLabel.setPosition(pos[0], pos[1])
+
+ if geometry.center is None:
+ movePos = geometry.startPoint * 0.34 + geometry.endPoint * 0.66
+ elif (geometry.isClosed()
+ or abs(geometry.endAngle - geometry.startAngle) > numpy.pi * 0.7):
+ movePos = geometry.center
+ else:
+ moveAngle = geometry.startAngle * 0.34 + geometry.endAngle * 0.66
+ vector = numpy.array([numpy.cos(moveAngle), numpy.sin(moveAngle)])
+ movePos = geometry.center + geometry.radius * vector
+
+ with utils.blockSignals(self._handleMove):
+ self._handleMove.setPosition(*movePos)
+
+ self.sigRegionChanged.emit()
def getGeometry(self):
"""Returns a tuple containing the geometry of this ROI
@@ -1344,7 +2498,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn):
:raise ValueError: In case the ROI can't be represented as section of
a circle
"""
- geometry = self._getInternalGeometry()
+ geometry = self._geometry
if geometry.center is None:
raise ValueError("This ROI can't be represented as a section of circle")
return geometry.center, self.getInnerRadius(), self.getOuterRadius(), geometry.startAngle, geometry.endAngle
@@ -1354,8 +2508,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn):
:rtype: bool
"""
- geometry = self._getInternalGeometry()
- return self._isCircle(geometry)
+ return self._geometry.isClosed()
def getCenter(self):
"""Returns the center of the circle used to draw arcs of this ROI.
@@ -1364,8 +2517,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn):
:rtype: numpy.ndarray
"""
- geometry = self._getInternalGeometry()
- return geometry.center
+ return self._geometry.center
def getStartAngle(self):
"""Returns the angle of the start of the section of this ROI (in radian).
@@ -1375,8 +2527,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn):
:rtype: float
"""
- geometry = self._getInternalGeometry()
- return geometry.startAngle
+ return self._geometry.startAngle
def getEndAngle(self):
"""Returns the angle of the end of the section of this ROI (in radian).
@@ -1386,15 +2537,14 @@ class ArcROI(RegionOfInterest, items.LineMixIn):
:rtype: float
"""
- geometry = self._getInternalGeometry()
- return geometry.endAngle
+ return self._geometry.endAngle
def getInnerRadius(self):
"""Returns the radius of the smaller arc used to draw this ROI.
:rtype: float
"""
- geometry = self._getInternalGeometry()
+ geometry = self._geometry
radius = geometry.radius - geometry.weight * 0.5
if radius < 0:
radius = 0
@@ -1405,7 +2555,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn):
:rtype: float
"""
- geometry = self._getInternalGeometry()
+ geometry = self._geometry
radius = geometry.radius + geometry.weight * 0.5
return radius
@@ -1427,96 +2577,67 @@ class ArcROI(RegionOfInterest, items.LineMixIn):
center = numpy.array(center)
radius = (innerRadius + outerRadius) * 0.5
weight = outerRadius - innerRadius
- geometry = self._ArcGeometry(center, None, None, radius, weight, startAngle, endAngle)
- controlPoints = self._createControlPointsFromGeometry(geometry)
- self._setControlPoints(controlPoints)
-
- def _createControlPointsFromGeometry(self, geometry):
- if geometry.startPoint or geometry.endPoint:
- # Duplication with the angles
- raise NotImplementedError("This general case is not implemented")
-
- angle = geometry.startAngle
- direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
- startPoint = geometry.center + direction * geometry.radius
- angle = geometry.endAngle
- direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
- endPoint = geometry.center + direction * geometry.radius
-
- angle = (geometry.startAngle + geometry.endAngle) * 0.5
- direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
- curvaturePoint = geometry.center + direction * geometry.radius
- weightPoint = curvaturePoint + direction * geometry.weight * 0.5
-
- return numpy.array([startPoint, curvaturePoint, endPoint, weightPoint])
-
- def _createControlPointsFromFirstShape(self, points):
- # The first shape is a line
- point0 = points[0]
- point1 = points[1]
+ vector = numpy.array([numpy.cos(startAngle), numpy.sin(startAngle)])
+ startPoint = center + vector * radius
+ vector = numpy.array([numpy.cos(endAngle), numpy.sin(endAngle)])
+ endPoint = center + vector * radius
+
+ geometry = self._Geometry.create(center, startPoint, endPoint,
+ radius, weight,
+ startAngle, endAngle, closed=None)
+ self._geometry = geometry
+ self._updateHandles()
+
+ @docstring(HandleBasedROI)
+ def contains(self, position):
+ # first check distance, fastest
+ center = self.getCenter()
+ distance = numpy.sqrt((position[1] - center[1]) ** 2 + ((position[0] - center[0])) ** 2)
+ is_in_distance = self.getInnerRadius() <= distance <= self.getOuterRadius()
+ if not is_in_distance:
+ return False
+ rel_pos = position[1] - center[1], position[0] - center[0]
+ angle = numpy.arctan2(*rel_pos)
+ start_angle = self.getStartAngle()
+ end_angle = self.getEndAngle()
+
+ if start_angle < end_angle:
+ # I never succeed to find a condition where start_angle < end_angle
+ # so this is untested
+ is_in_angle = start_angle <= angle <= end_angle
+ else:
+ if end_angle < -numpy.pi and angle > 0:
+ angle = angle - (numpy.pi *2.0)
+ is_in_angle = end_angle <= angle <= start_angle
+ return is_in_angle
- # Compute a non colineate point for the curvature
- center = (point1 + point0) * 0.5
- normal = point1 - center
- normal = numpy.array((normal[1], -normal[0]))
- defaultCurvature = numpy.pi / 5.0
- defaultWeight = 0.20 # percentage
- curvaturePoint = center - normal * defaultCurvature
- weightPoint = center - normal * defaultCurvature * (1.0 + defaultWeight)
-
- # 3 corners
- controlPoints = numpy.array([
- point0,
- curvaturePoint,
- point1,
- weightPoint
- ])
- return controlPoints
-
- def _createShapeItems(self, points):
- shapePoints = self._getShapeFromControlPoints(points)
- item = items.Shape("polygon")
- item.setPoints(shapePoints)
- item.setColor(rgba(self.getColor()))
- item.setFill(False)
- item.setOverlay(True)
- item.setLineStyle(self.getLineStyle())
- item.setLineWidth(self.getLineWidth())
- return [item]
-
- def _createAnchorItems(self, points):
- anchors = []
- symbols = ['o', 'o', 'o', 's']
-
- for index, point in enumerate(points):
- if index in [1, 3]:
- constraint = self._arcCurvatureMarkerConstraint
- else:
- constraint = None
- anchor = items.Marker()
- anchor.setPosition(*point)
- anchor.setText('')
- anchor.setSymbol(symbols[index])
- anchor._setDraggable(True)
- if constraint is not None:
- anchor._setConstraint(constraint)
- anchors.append(anchor)
-
- return anchors
+ def translate(self, x, y):
+ self._geometry = self._geometry.translated(x, y)
+ self._updateHandles()
def _arcCurvatureMarkerConstraint(self, x, y):
- """Curvature marker remains on "mediatrice" """
- start = self._points[0]
- end = self._points[2]
- midPoint = (start + end) / 2.
- normal = (end - start)
- normal = numpy.array((normal[1], -normal[0]))
- distance = numpy.linalg.norm(normal)
- if distance != 0:
- normal /= distance
- v = numpy.dot(normal, (numpy.array((x, y)) - midPoint))
- x, y = midPoint + v * normal
+ """Curvature marker remains on perpendicular bisector"""
+ geometry = self._geometry
+ if geometry.center is None:
+ center = (geometry.startPoint + geometry.endPoint) * 0.5
+ vector = geometry.startPoint - geometry.endPoint
+ vector = numpy.array((vector[1], -vector[0]))
+ vdist = numpy.linalg.norm(vector)
+ if vdist != 0:
+ normal = numpy.array((vector[1], -vector[0])) / vdist
+ else:
+ normal = numpy.array((0, 0))
+ else:
+ if geometry.isClosed():
+ midAngle = geometry.startAngle + numpy.pi * 0.5
+ else:
+ midAngle = (geometry.startAngle + geometry.endAngle) * 0.5
+ normal = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)])
+ center = geometry.center
+ dist = numpy.dot(normal, (numpy.array((x, y)) - center))
+ dist = numpy.clip(dist, geometry.radius, geometry.radius * 2)
+ x, y = center + dist * normal
return x, y
@staticmethod
@@ -1530,7 +2651,7 @@ class ArcROI(RegionOfInterest, items.LineMixIn):
w = z - x
w /= y - x
c = (x - y) * (w - abs(w) ** 2) / 2j / w.imag - x
- return ((-c.real, -c.imag), abs(c + x))
+ return numpy.array((-c.real, -c.imag)), abs(c + x)
def __str__(self):
try:
@@ -1540,3 +2661,221 @@ class ArcROI(RegionOfInterest, items.LineMixIn):
except ValueError:
params = "invalid"
return "%s(%s)" % (self.__class__.__name__, params)
+
+
+class HorizontalRangeROI(RegionOfInterest, items.LineMixIn):
+ """A ROI identifying an horizontal range in a 1D plot."""
+
+ ICON = 'add-range-horizontal'
+ NAME = 'horizontal range ROI'
+ SHORT_NAME = "hrange"
+
+ _plotShape = "line"
+ """Plot shape which is used for the first interaction"""
+
+ def __init__(self, parent=None):
+ RegionOfInterest.__init__(self, parent=parent)
+ items.LineMixIn.__init__(self)
+ self._markerMin = items.XMarker()
+ self._markerMax = items.XMarker()
+ self._markerCen = items.XMarker()
+ self._markerCen.setLineStyle(" ")
+ self._markerMin._setConstraint(self.__positionMinConstraint)
+ self._markerMax._setConstraint(self.__positionMaxConstraint)
+ self._markerMin.sigDragStarted.connect(self._editingStarted)
+ self._markerMin.sigDragFinished.connect(self._editingFinished)
+ self._markerMax.sigDragStarted.connect(self._editingStarted)
+ self._markerMax.sigDragFinished.connect(self._editingFinished)
+ self._markerCen.sigDragStarted.connect(self._editingStarted)
+ self._markerCen.sigDragFinished.connect(self._editingFinished)
+ self.addItem(self._markerCen)
+ self.addItem(self._markerMin)
+ self.addItem(self._markerMax)
+ self.__filterReentrant = utils.LockReentrant()
+
+ def setFirstShapePoints(self, points):
+ vmin = min(points[:, 0])
+ vmax = max(points[:, 0])
+ self._updatePos(vmin, vmax)
+
+ def _updated(self, event=None, checkVisibility=True):
+ if event == items.ItemChangedType.NAME:
+ self._updateText()
+ elif event == items.ItemChangedType.EDITABLE:
+ self._updateEditable()
+ self._updateText()
+ elif event == items.ItemChangedType.LINE_STYLE:
+ markers = [self._markerMin, self._markerMax]
+ self._updateItemProperty(event, self, markers)
+ elif event in [items.ItemChangedType.VISIBLE,
+ items.ItemChangedType.SELECTABLE]:
+ markers = [self._markerMin, self._markerMax, self._markerCen]
+ self._updateItemProperty(event, self, markers)
+ super(HorizontalRangeROI, self)._updated(event, checkVisibility)
+
+ def _updatedStyle(self, event, style):
+ markers = [self._markerMin, self._markerMax, self._markerCen]
+ for m in markers:
+ m.setColor(style.getColor())
+ m.setLineWidth(style.getLineWidth())
+
+ def _updateText(self):
+ text = self.getName()
+ if self.isEditable():
+ self._markerMin.setText("")
+ self._markerCen.setText(text)
+ else:
+ self._markerMin.setText(text)
+ self._markerCen.setText("")
+
+ def _updateEditable(self):
+ editable = self.isEditable()
+ self._markerMin._setDraggable(editable)
+ self._markerMax._setDraggable(editable)
+ self._markerCen._setDraggable(editable)
+ if self.isEditable():
+ self._markerMin.sigItemChanged.connect(self._minPositionChanged)
+ self._markerMax.sigItemChanged.connect(self._maxPositionChanged)
+ self._markerCen.sigItemChanged.connect(self._cenPositionChanged)
+ self._markerCen.setLineStyle(":")
+ else:
+ self._markerMin.sigItemChanged.disconnect(self._minPositionChanged)
+ self._markerMax.sigItemChanged.disconnect(self._maxPositionChanged)
+ self._markerCen.sigItemChanged.disconnect(self._cenPositionChanged)
+ self._markerCen.setLineStyle(" ")
+
+ def _updatePos(self, vmin, vmax, force=False):
+ """Update marker position and emit signal.
+
+ :param float vmin:
+ :param float vmax:
+ :param bool force:
+ True to update even if already at the right position.
+ """
+ if not force and numpy.array_equal((vmin, vmax), self.getRange()):
+ return # Nothing has changed
+
+ center = (vmin + vmax) * 0.5
+ with self.__filterReentrant:
+ with utils.blockSignals(self._markerMin):
+ self._markerMin.setPosition(vmin, 0)
+ with utils.blockSignals(self._markerCen):
+ self._markerCen.setPosition(center, 0)
+ with utils.blockSignals(self._markerMax):
+ self._markerMax.setPosition(vmax, 0)
+ self.sigRegionChanged.emit()
+
+ def setRange(self, vmin, vmax):
+ """Set the range of this ROI.
+
+ :param float vmin: Staring location of the range
+ :param float vmax: Ending location of the range
+ """
+ if vmin is None or vmax is None:
+ err = "Can't set vmin or vmax to None"
+ raise ValueError(err)
+ if vmin > vmax:
+ err = "Can't set vmin and vmax because vmin >= vmax " \
+ "vmin = %s, vmax = %s" % (vmin, vmax)
+ raise ValueError(err)
+ self._updatePos(vmin, vmax)
+
+ def getRange(self):
+ """Returns the range of this ROI.
+
+ :rtype: Tuple[float,float]
+ """
+ vmin = self.getMin()
+ vmax = self.getMax()
+ return vmin, vmax
+
+ def setMin(self, vmin):
+ """Set the min of this ROI.
+
+ :param float vmin: New min
+ """
+ vmax = self.getMax()
+ self._updatePos(vmin, vmax)
+
+ def getMin(self):
+ """Returns the min value of this ROI.
+
+ :rtype: float
+ """
+ return self._markerMin.getPosition()[0]
+
+ def setMax(self, vmax):
+ """Set the max of this ROI.
+
+ :param float vmax: New max
+ """
+ vmin = self.getMin()
+ self._updatePos(vmin, vmax)
+
+ def getMax(self):
+ """Returns the max value of this ROI.
+
+ :rtype: float
+ """
+ return self._markerMax.getPosition()[0]
+
+ def setCenter(self, center):
+ """Set the center of this ROI.
+
+ :param float center: New center
+ """
+ vmin, vmax = self.getRange()
+ previousCenter = (vmin + vmax) * 0.5
+ delta = center - previousCenter
+ self._updatePos(vmin + delta, vmax + delta)
+
+ def getCenter(self):
+ """Returns the center location of this ROI.
+
+ :rtype: float
+ """
+ vmin, vmax = self.getRange()
+ return (vmin + vmax) * 0.5
+
+ def __positionMinConstraint(self, x, y):
+ """Constraint of the min marker"""
+ if self.__filterReentrant.locked():
+ # Ignore the constraint when we set an explicit value
+ return x, y
+ vmax = self.getMax()
+ if vmax is None:
+ return x, y
+ return min(x, vmax), y
+
+ def __positionMaxConstraint(self, x, y):
+ """Constraint of the max marker"""
+ if self.__filterReentrant.locked():
+ # Ignore the constraint when we set an explicit value
+ return x, y
+ vmin = self.getMin()
+ if vmin is None:
+ return x, y
+ return max(x, vmin), y
+
+ def _minPositionChanged(self, event):
+ """Handle position changed events of the marker"""
+ if event is items.ItemChangedType.POSITION:
+ marker = self.sender()
+ self._updatePos(marker.getXPosition(), self.getMax(), force=True)
+
+ def _maxPositionChanged(self, event):
+ """Handle position changed events of the marker"""
+ if event is items.ItemChangedType.POSITION:
+ marker = self.sender()
+ self._updatePos(self.getMin(), marker.getXPosition(), force=True)
+
+ def _cenPositionChanged(self, event):
+ """Handle position changed events of the marker"""
+ if event is items.ItemChangedType.POSITION:
+ marker = self.sender()
+ self.setCenter(marker.getXPosition())
+
+ def __str__(self):
+ vrange = self.getRange()
+ params = 'min: %f; max: %f' % vrange
+ return "%s(%s)" % (self.__class__.__name__, params)
diff --git a/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py
index 50cc694..5e7d65b 100644
--- a/silx/gui/plot/items/scatter.py
+++ b/silx/gui/plot/items/scatter.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -43,6 +43,7 @@ from concurrent.futures import ThreadPoolExecutor, CancelledError
from ....utils.proxy import docstring
from ....math.combo import min_max
+from ....math.histogram import Histogramnd
from ....utils.weakref import WeakList
from .._utils.delaunay import delaunay
from .core import PointsBase, ColormapMixIn, ScatterVisualizationMixIn
@@ -142,12 +143,13 @@ def is_monotonic(array):
:rtype: int
"""
diff = numpy.diff(numpy.ravel(array))
- if numpy.all(diff >= 0):
- return 1
- elif numpy.all(diff <= 0):
- return -1
- else:
- return 0
+ with numpy.errstate(invalid='ignore'):
+ if numpy.all(diff >= 0):
+ return 1
+ elif numpy.all(diff <= 0):
+ return -1
+ else:
+ return 0
def _guess_grid(x, y):
@@ -264,6 +266,10 @@ _RegularGridInfo = namedtuple(
'_RegularGridInfo', ['bounds', 'origin', 'scale', 'shape', 'order'])
+_HistogramInfo = namedtuple(
+ '_HistogramInfo', ['mean', 'count', 'sum', 'origin', 'scale', 'shape'])
+
+
class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
"""Description of a scatter"""
@@ -275,6 +281,7 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
ScatterVisualizationMixIn.Visualization.SOLID,
ScatterVisualizationMixIn.Visualization.REGULAR_GRID,
ScatterVisualizationMixIn.Visualization.IRREGULAR_GRID,
+ ScatterVisualizationMixIn.Visualization.BINNED_STATISTIC,
)
"""Overrides supported Visualizations"""
@@ -293,17 +300,53 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
# Cache triangles: x, y, indices
self.__cacheTriangles = None, None, None
- # Cache regular grid info
+ # Cache regular grid and histogram info
self.__cacheRegularGridInfo = None
+ self.__cacheHistogramInfo = None
+
+ def _updateColormappedData(self):
+ """Update the colormapped data, to be called when changed"""
+ if self.getVisualization() is self.Visualization.BINNED_STATISTIC:
+ histoInfo = self.__getHistogramInfo()
+ if histoInfo is None:
+ data = None
+ else:
+ data = getattr(
+ histoInfo,
+ self.getVisualizationParameter(
+ self.VisualizationParameter.BINNED_STATISTIC_FUNCTION))
+ else:
+ data = self.getValueData(copy=False)
+ self._setColormappedData(data, copy=False)
+
+ @docstring(ScatterVisualizationMixIn)
+ def setVisualization(self, mode):
+ previous = self.getVisualization()
+ if super().setVisualization(mode):
+ if (bool(mode is self.Visualization.BINNED_STATISTIC) ^
+ bool(previous is self.Visualization.BINNED_STATISTIC)):
+ self._updateColormappedData()
+ return True
+ else:
+ return False
@docstring(ScatterVisualizationMixIn)
def setVisualizationParameter(self, parameter, value):
- changed = super(Scatter, self).setVisualizationParameter(parameter, value)
- if changed and parameter in (self.VisualizationParameter.GRID_BOUNDS,
- self.VisualizationParameter.GRID_MAJOR_ORDER,
- self.VisualizationParameter.GRID_SHAPE):
- self.__cacheRegularGridInfo = None
- return changed
+ if super(Scatter, self).setVisualizationParameter(parameter, value):
+ if parameter in (self.VisualizationParameter.GRID_BOUNDS,
+ self.VisualizationParameter.GRID_MAJOR_ORDER,
+ self.VisualizationParameter.GRID_SHAPE):
+ self.__cacheRegularGridInfo = None
+
+ if parameter in (self.VisualizationParameter.BINNED_STATISTIC_SHAPE,
+ self.VisualizationParameter.BINNED_STATISTIC_FUNCTION):
+ if parameter == self.VisualizationParameter.BINNED_STATISTIC_SHAPE:
+ self.__cacheHistogramInfo = None # Clean-up cache
+ if self.getVisualization() is self.Visualization.BINNED_STATISTIC:
+ self._updateColormappedData()
+ return True
+ else:
+ return False
@docstring(ScatterVisualizationMixIn)
def getCurrentVisualizationParameter(self, parameter):
@@ -323,6 +366,10 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
grid = self.__getRegularGridInfo()
return None if grid is None else grid.shape
+ elif parameter is self.VisualizationParameter.BINNED_STATISTIC_SHAPE:
+ info = self.__getHistogramInfo()
+ return None if info is None else info.shape
+
else:
raise NotImplementedError()
@@ -345,6 +392,18 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
if order is None:
order = guess[0]
+ nbpoints = len(self.getXData(copy=False))
+ if nbpoints > shape[0] * shape[1]:
+ # More data points that provided grid shape: enlarge grid
+ _logger.warning(
+ "More data points than provided grid shape size: extends grid")
+ dim0, dim1 = shape
+ if order == 'row': # keep dim1, enlarge dim0
+ dim0 = nbpoints // dim1 + (1 if nbpoints % dim1 else 0)
+ else: # keep dim0, enlarge dim1
+ dim1 = nbpoints // dim0 + (1 if nbpoints % dim0 else 0)
+ shape = dim0, dim1
+
bounds = self.getVisualizationParameter(
self.VisualizationParameter.GRID_BOUNDS)
if bounds is None:
@@ -372,6 +431,47 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
return self.__cacheRegularGridInfo
+ def __getHistogramInfo(self):
+ """Get histogram info"""
+ if self.__cacheHistogramInfo is None:
+ shape = self.getVisualizationParameter(
+ self.VisualizationParameter.BINNED_STATISTIC_SHAPE)
+ if shape is None:
+ shape = 100, 100 # TODO compute auto shape
+
+ x, y, values = self.getData(copy=False)[:3]
+ if len(x) == 0: # No histogram
+ return None
+
+ if not numpy.issubdtype(x.dtype, numpy.floating):
+ x = x.astype(numpy.float64)
+ if not numpy.issubdtype(y.dtype, numpy.floating):
+ y = y.astype(numpy.float64)
+ if not numpy.issubdtype(values.dtype, numpy.floating):
+ values = values.astype(numpy.float64)
+
+ ranges = (tuple(min_max(y, finite=True)),
+ tuple(min_max(x, finite=True)))
+ points = numpy.transpose(numpy.array((y, x)))
+ counts, sums, bin_edges = Histogramnd(
+ points,
+ histo_range=ranges,
+ n_bins=shape,
+ weights=values)
+ yEdges, xEdges = bin_edges
+ origin = xEdges[0], yEdges[0]
+ scale = ((xEdges[-1] - xEdges[0]) / (len(xEdges) - 1),
+ (yEdges[-1] - yEdges[0]) / (len(yEdges) - 1))
+
+ with numpy.errstate(divide='ignore', invalid='ignore'):
+ histo = sums / counts
+
+ self.__cacheHistogramInfo = _HistogramInfo(
+ mean=histo, count=counts, sum=sums,
+ origin=origin, scale=scale, shape=shape)
+
+ return self.__cacheHistogramInfo
+
def _addBackendRenderer(self, backend):
"""Update backend renderer"""
# Filter-out values <= 0
@@ -386,28 +486,47 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
if len(xFiltered) == 0:
return None # No data to display, do not add renderer to backend
+ visualization = self.getVisualization()
+
+ if visualization is self.Visualization.BINNED_STATISTIC:
+ plot = self.getPlot()
+ if (plot is None or
+ plot.getXAxis().getScale() != Axis.LINEAR or
+ plot.getYAxis().getScale() != Axis.LINEAR):
+ # Those visualizations are not available with log scaled axes
+ return None
+
+ histoInfo = self.__getHistogramInfo()
+ if histoInfo is None:
+ return None
+ data = getattr(histoInfo, self.getVisualizationParameter(
+ self.VisualizationParameter.BINNED_STATISTIC_FUNCTION))
+
+ return backend.addImage(
+ data=data,
+ origin=histoInfo.origin,
+ scale=histoInfo.scale,
+ colormap=self.getColormap(),
+ alpha=self.getAlpha())
+
# Compute colors
cmap = self.getColormap()
- rgbacolors = cmap.applyToData(self._value)
+ rgbacolors = cmap.applyToData(self)
if self.__alpha is not None:
rgbacolors[:, -1] = (rgbacolors[:, -1] * self.__alpha).astype(numpy.uint8)
- # Apply mask to colors
- rgbacolors = rgbacolors[mask]
-
visualization = self.getVisualization()
if visualization is self.Visualization.POINTS:
return backend.addCurve(xFiltered, yFiltered,
- color=rgbacolors,
+ color=rgbacolors[mask],
symbol=self.getSymbol(),
linewidth=0,
linestyle="",
yaxis='left',
xerror=xerror,
yerror=yerror,
- z=self.getZValue(),
fill=False,
alpha=self.getAlpha(),
symbolsize=self.getSymbolSize(),
@@ -432,8 +551,7 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
return backend.addTriangles(xFiltered,
yFiltered,
triangles,
- color=rgbacolors,
- z=self.getZValue(),
+ color=rgbacolors[mask],
alpha=self.getAlpha())
elif visualization is self.Visualization.REGULAR_GRID:
@@ -461,7 +579,6 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
data=image,
origin=gridInfo.origin,
scale=gridInfo.scale,
- z=self.getZValue(),
colormap=None,
alpha=self.getAlpha())
@@ -474,31 +591,89 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
if shape is None: # No shape, no display
return None
- # clip shape to fully filled lines
- if len(xFiltered) != numpy.prod(shape):
- if gridInfo.order == 'row':
- shape = len(xFiltered) // shape[1], shape[1]
+ nbpoints = len(xFiltered)
+ if nbpoints == 1:
+ # single point, render as a square points
+ return backend.addCurve(xFiltered, yFiltered,
+ color=rgbacolors[mask],
+ symbol='s',
+ linewidth=0,
+ linestyle="",
+ yaxis='left',
+ xerror=None,
+ yerror=None,
+ fill=False,
+ alpha=self.getAlpha(),
+ symbolsize=7,
+ baseline=None)
+
+ # Make shape include all points
+ gridOrder = gridInfo.order
+ if nbpoints != numpy.prod(shape):
+ if gridOrder == 'row':
+ shape = int(numpy.ceil(nbpoints / shape[1])), shape[1]
else: # column-major order
- shape = shape[0], len(xFiltered) // shape[0]
- if shape[0] < 2 or shape[1] < 2: # Not enough points
- return None
-
- nbpoints = numpy.prod(shape)
- if gridInfo.order == 'row':
- points = numpy.transpose((xFiltered[:nbpoints], yFiltered[:nbpoints]))
- points = points.reshape(shape[0], shape[1], 2)
+ shape = shape[0], int(numpy.ceil(nbpoints / shape[0]))
+
+ if shape[0] < 2 or shape[1] < 2: # Single line, at least 2 points
+ points = numpy.ones((2, nbpoints, 2), dtype=numpy.float64)
+ # Use row/column major depending on shape, not on info value
+ gridOrder = 'row' if shape[0] == 1 else 'column'
+
+ if gridOrder == 'row':
+ points[0, :, 0] = xFiltered
+ points[0, :, 1] = yFiltered
+ else: # column-major order
+ points[0, :, 0] = yFiltered
+ points[0, :, 1] = xFiltered
+
+ # Add a second line that will be clipped in the end
+ points[1, :-1] = points[0, :-1] + numpy.cross(
+ points[0, 1:] - points[0, :-1], (0., 0., 1.))[:, :2]
+ points[1, -1] = points[0, -1] + numpy.cross(
+ points[0, -1] - points[0, -2], (0., 0., 1.))[:2]
+
+ points.shape = 2, nbpoints, 2 # Use same shape for both orders
+ coords, indices = _quadrilateral_grid_as_triangles(points)
+
+ elif gridOrder == 'row': # row-major order
+ if nbpoints != numpy.prod(shape):
+ points = numpy.empty((numpy.prod(shape), 2), dtype=numpy.float64)
+ points[:nbpoints, 0] = xFiltered
+ points[:nbpoints, 1] = yFiltered
+ # Index of last element of last fully filled row
+ index = (nbpoints // shape[1]) * shape[1]
+ points[nbpoints:, 0] = xFiltered[index - (numpy.prod(shape) - nbpoints):index]
+ points[nbpoints:, 1] = yFiltered[-1]
+ else:
+ points = numpy.transpose((xFiltered, yFiltered))
+ points.shape = shape[0], shape[1], 2
else: # column-major order
- points = numpy.transpose((yFiltered[:nbpoints], xFiltered[:nbpoints]))
- points = points.reshape(shape[1], shape[0], 2)
+ if nbpoints != numpy.prod(shape):
+ points = numpy.empty((numpy.prod(shape), 2), dtype=numpy.float64)
+ points[:nbpoints, 0] = yFiltered
+ points[:nbpoints, 1] = xFiltered
+ # Index of last element of last fully filled column
+ index = (nbpoints // shape[0]) * shape[0]
+ points[nbpoints:, 0] = yFiltered[index - (numpy.prod(shape) - nbpoints):index]
+ points[nbpoints:, 1] = xFiltered[-1]
+ else:
+ points = numpy.transpose((yFiltered, xFiltered))
+ points.shape = shape[1], shape[0], 2
coords, indices = _quadrilateral_grid_as_triangles(points)
- if gridInfo.order == 'row':
+ # Remove unused extra triangles
+ coords = coords[:4*nbpoints]
+ indices = indices[:2*nbpoints]
+
+ if gridOrder == 'row':
x, y = coords[:, 0], coords[:, 1]
else: # column-major order
y, x = coords[:, 0], coords[:, 1]
+ rgbacolors = rgbacolors[mask] # Filter-out not finite points
gridcolors = numpy.empty(
(4 * nbpoints, rgbacolors.shape[-1]), dtype=rgbacolors.dtype)
for first in range(4):
@@ -508,8 +683,8 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
y,
indices,
color=gridcolors,
- z=self.getZValue(),
alpha=self.getAlpha())
+
else:
_logger.error("Unhandled visualization %s", visualization)
return None
@@ -528,23 +703,15 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
elif visualization is self.Visualization.REGULAR_GRID:
# Specific handling of picking for the regular grid mode
- plot = self.getPlot()
- if plot is None:
- return None
-
- dataPos = plot.pixelToData(x, y)
- if dataPos is None:
+ picked = result.getIndices(copy=False)
+ if picked is None:
return None
+ row, column = picked[0][0], picked[1][0]
gridInfo = self.__getRegularGridInfo()
if gridInfo is None:
return None
- origin = gridInfo.origin
- scale = gridInfo.scale
- column = int((dataPos[0] - origin[0]) / scale[0])
- row = int((dataPos[1] - origin[1]) / scale[1])
-
if gridInfo.order == 'row':
index = row * gridInfo.shape[1] + column
else:
@@ -554,6 +721,23 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
result = PickingResult(self, (index,))
+ elif visualization is self.Visualization.BINNED_STATISTIC:
+ picked = result.getIndices(copy=False)
+ if picked is None or len(picked) == 0 or len(picked[0]) == 0:
+ return None
+ row, col = picked[0][0], picked[1][0]
+ histoInfo = self.__getHistogramInfo()
+ if histoInfo is None:
+ return None
+ sx, sy = histoInfo.scale
+ ox, oy = histoInfo.origin
+ xdata = self.getXData(copy=False)
+ ydata = self.getYData(copy=False)
+ indices = numpy.nonzero(numpy.logical_and(
+ numpy.logical_and(xdata >= ox + sx * col, xdata < ox + sx * (col + 1)),
+ numpy.logical_and(ydata >= oy + sy * row, ydata < oy + sy * (row + 1))))[0]
+ result = None if len(indices) == 0 else PickingResult(self, indices)
+
return result
def __getExecutor(self):
@@ -750,8 +934,10 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
# Data changed, this needs update
self.__cacheRegularGridInfo = None
+ self.__cacheHistogramInfo = None
self._value = value
+ self._updateColormappedData()
if alpha is not None:
# Make sure alpha is an array of float in [0, 1]
diff --git a/silx/gui/plot/items/shape.py b/silx/gui/plot/items/shape.py
index 8176be1..26aa03b 100644
--- a/silx/gui/plot/items/shape.py
+++ b/silx/gui/plot/items/shape.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -68,16 +68,15 @@ class Shape(Item, ColorMixIn, FillMixIn, LineMixIn):
"""Update backend renderer"""
points = self.getPoints(copy=False)
x, y = points.T[0], points.T[1]
- return backend.addItem(x,
- y,
- shape=self.getType(),
- color=self.getColor(),
- fill=self.isFill(),
- overlay=self.isOverlay(),
- z=self.getZValue(),
- linestyle=self.getLineStyle(),
- linewidth=self.getLineWidth(),
- linebgcolor=self.getLineBgColor())
+ return backend.addShape(x,
+ y,
+ shape=self.getType(),
+ color=self.getColor(),
+ fill=self.isFill(),
+ overlay=self.isOverlay(),
+ linestyle=self.getLineStyle(),
+ linewidth=self.getLineWidth(),
+ linebgcolor=self.getLineBgColor())
def isOverlay(self):
"""Return true if shape is drawn as an overlay
@@ -216,3 +215,91 @@ class BoundingRect(Item, YAxisMixIn):
return tuple(bounds)
return self.__bounds
+
+
+class _BaseExtent(Item):
+ """Base class for :class:`XAxisExtent` and :class:`YAxisExtent`.
+
+ :param str axis: Either 'x' or 'y'.
+ """
+
+ def __init__(self, axis='x'):
+ assert axis in ('x', 'y')
+ Item.__init__(self)
+ self.__axis = axis
+ self.__range = 1., 100.
+
+ def _updated(self, event=None, checkVisibility=True):
+ if event in (ItemChangedType.VISIBLE,
+ ItemChangedType.DATA):
+ # TODO hackish data range implementation
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
+
+ super(_BaseExtent, self)._updated(event, checkVisibility)
+
+ def setRange(self, min_, max_):
+ """Set the range of the extent of this item in data coordinates.
+
+ :param float min_: Lower bound of the extent
+ :param float max_: Upper bound of the extent
+ :raises ValueError: If min > max or not finite bounds
+ """
+ range_ = float(min_), float(max_)
+ if not numpy.all(numpy.isfinite(range_)):
+ raise ValueError("min_ and max_ must be finite numbers.")
+ if range_[0] > range_[1]:
+ raise ValueError("min_ must be lesser or equal to max_")
+
+ if range_ != self.__range:
+ self.__range = range_
+ self._updated(ItemChangedType.DATA)
+
+ def getRange(self):
+ """Returns the range (min, max) of the extent in data coordinates.
+
+ :rtype: List[float]
+ """
+ return self.__range
+
+ def _getBounds(self):
+ min_, max_ = self.getRange()
+
+ plot = self.getPlot()
+ if plot is not None:
+ axis = plot.getXAxis() if self.__axis == 'x' else plot.getYAxis()
+ if axis._isLogarithmic():
+ if max_ <= 0:
+ return None
+ if min_ <= 0:
+ min_ = max_
+
+ if self.__axis == 'x':
+ return min_, max_, float('nan'), float('nan')
+ else:
+ return float('nan'), float('nan'), min_, max_
+
+
+class XAxisExtent(_BaseExtent):
+ """Invisible item with a settable horizontal data extent.
+
+ This item do not display anything, but it behaves as a data
+ item with a horizontal extent regarding plot data bounds, i.e.,
+ :meth:`PlotWidget.resetZoom` will take this horizontal extent into account.
+ """
+ def __init__(self):
+ _BaseExtent.__init__(self, axis='x')
+
+
+class YAxisExtent(_BaseExtent, YAxisMixIn):
+ """Invisible item with a settable vertical data extent.
+
+ This item do not display anything, but it behaves as a data
+ item with a vertical extent regarding plot data bounds, i.e.,
+ :meth:`PlotWidget.resetZoom` will take this vertical extent into account.
+ """
+
+ def __init__(self):
+ _BaseExtent.__init__(self, axis='y')
+ YAxisMixIn.__init__(self)
diff --git a/silx/gui/plot/matplotlib/Colormap.py b/silx/gui/plot/matplotlib/Colormap.py
index 38f3b55..dc432b2 100644
--- a/silx/gui/plot/matplotlib/Colormap.py
+++ b/silx/gui/plot/matplotlib/Colormap.py
@@ -1,6 +1,6 @@
# coding: utf-8
# /*##########################################################################
-# Copyright (C) 2017-2018 European Synchrotron Radiation Facility
+# Copyright (C) 2017-2020 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
@@ -183,10 +183,13 @@ def getScalarMappable(colormap, data=None):
cmap = matplotlib.colors.ListedColormap(colors)
vmin, vmax = colormap.getColormapRange(data)
- if colormap.getNormalization().startswith('log'):
+ normalization = colormap.getNormalization()
+ if normalization == colormap.LOGARITHM:
norm = matplotlib.colors.LogNorm(vmin, vmax)
- else: # Linear normalization
+ elif normalization == colormap.LINEAR:
norm = matplotlib.colors.Normalize(vmin, vmax)
+ else:
+ raise RuntimeError("Unsupported normalization: %s" % normalization)
return matplotlib.cm.ScalarMappable(norm=norm, cmap=cmap)
diff --git a/silx/gui/plot/matplotlib/__init__.py b/silx/gui/plot/matplotlib/__init__.py
index 7298866..f42bf53 100644
--- a/silx/gui/plot/matplotlib/__init__.py
+++ b/silx/gui/plot/matplotlib/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -44,7 +44,7 @@ import matplotlib
from ... import qt
-def _matplotlib_use(backend, warn, force):
+def _matplotlib_use(backend, force):
"""Wrapper of `matplotlib.use` to set-up backend.
It adds extra initialization for PySide and PySide2 with matplotlib < 2.2.
@@ -56,15 +56,15 @@ def _matplotlib_use(backend, warn, force):
if qt.BINDING == 'PySide2':
matplotlib.rcParams['backend.qt5'] = 'PySide2'
- matplotlib.use(backend, warn=warn, force=force)
+ matplotlib.use(backend, force=force)
if qt.BINDING in ('PyQt4', 'PySide'):
- _matplotlib_use('Qt4Agg', warn=True, force=False)
+ _matplotlib_use('Qt4Agg', force=False)
from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg # noqa
elif qt.BINDING in ('PyQt5', 'PySide2'):
- _matplotlib_use('Qt5Agg', warn=True, force=False)
+ _matplotlib_use('Qt5Agg', force=False)
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg # noqa
else:
diff --git a/silx/gui/plot/test/__init__.py b/silx/gui/plot/test/__init__.py
index 89c10c6..0477e2a 100644
--- a/silx/gui/plot/test/__init__.py
+++ b/silx/gui/plot/test/__init__.py
@@ -42,8 +42,8 @@ from . import testPlotInteraction
from . import testPlotWidgetNoBackend
from . import testPlotWidget
from . import testPlotWindow
-from . import testProfile
from . import testStackView
+from . import testImageStack
from . import testItem
from . import testUtilsAxis
from . import testLimitConstraints
@@ -75,8 +75,8 @@ def suite():
testPlotWidgetNoBackend.suite(),
testPlotWidget.suite(),
testPlotWindow.suite(),
- testProfile.suite(),
testStackView.suite(),
+ testImageStack.suite(),
testItem.suite(),
testUtilsAxis.suite(),
testLimitConstraints.suite(),
@@ -85,6 +85,6 @@ def suite():
testSaveAction.suite(),
testScatterView.suite(),
testPixelIntensityHistoAction.suite(),
- testCompareImages.suite()
- ])
+ testCompareImages.suite(),
+ ])
return test_suite
diff --git a/silx/gui/plot/test/testColorBar.py b/silx/gui/plot/test/testColorBar.py
index 9a02e04..a6f141c 100644
--- a/silx/gui/plot/test/testColorBar.py
+++ b/silx/gui/plot/test/testColorBar.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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,6 +33,7 @@ from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot.ColorBar import _ColorScale
from silx.gui.plot.ColorBar import ColorBarWidget
from silx.gui.colors import Colormap
+from silx.gui import colors
from silx.gui.plot import Plot2D
from silx.gui import qt
import numpy
@@ -94,10 +95,10 @@ class TestColorScale(TestCaseQt):
self.colorScaleWidget.setColormap(self.colorMapLog1)
val = self.colorScaleWidget.getValueFromRelativePosition(1.0)
- self.assertTrue(val == 100.0)
+ self.assertAlmostEqual(val, 100.0)
val = self.colorScaleWidget.getValueFromRelativePosition(0.5)
- self.assertTrue(val == 10.0)
+ self.assertAlmostEqual(val, 10.0)
val = self.colorScaleWidget.getValueFromRelativePosition(0.0)
self.assertTrue(val == 1.0)
@@ -149,7 +150,7 @@ class TestNoAutoscale(TestCaseQt):
# test ColorScale
val = self.colorScale.getValueFromRelativePosition(1.0)
- self.assertTrue(val == 100.0)
+ self.assertAlmostEqual(val, 100.0)
val = self.colorScale.getValueFromRelativePosition(0.0)
self.assertTrue(val == 1.0)
@@ -315,8 +316,9 @@ class TestColorBarUpdate(TestCaseQt):
self.colorBar.getColorScaleBar().getTickBar()._vmin == 0)
self.assertTrue(
self.colorBar.getColorScaleBar().getTickBar()._vmax == 1)
- self.assertTrue(
- self.colorBar.getColorScaleBar().getTickBar()._norm == "linear")
+ self.assertIsInstance(
+ self.colorBar.getColorScaleBar().getTickBar()._normalizer,
+ colors._LinearNormalization)
# update colormap
colormap.setVMin(0.5)
@@ -330,8 +332,9 @@ class TestColorBarUpdate(TestCaseQt):
self.colorBar.getColorScaleBar().getTickBar()._vmax == 0.8)
colormap.setNormalization('log')
- self.assertTrue(
- self.colorBar.getColorScaleBar().getTickBar()._norm == 'log')
+ self.assertIsInstance(
+ self.colorBar.getColorScaleBar().getTickBar()._normalizer,
+ colors._LogarithmicNormalization)
# TODO : should also check that if the colormap is changing then values (especially in log scale)
# should be coherent if in autoscale
diff --git a/silx/gui/plot/test/testCurvesROIWidget.py b/silx/gui/plot/test/testCurvesROIWidget.py
index 5886456..77c53a8 100644
--- a/silx/gui/plot/test/testCurvesROIWidget.py
+++ b/silx/gui/plot/test/testCurvesROIWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -36,6 +36,7 @@ from collections import OrderedDict
import numpy
from silx.gui import qt
+from silx.gui.plot import items
from silx.gui.plot import Plot1D
from silx.test.utils import temp_dir
from silx.gui.utils.testutils import TestCaseQt, SignalListener
@@ -110,21 +111,20 @@ class TestCurvesROIWidget(TestCaseQt):
# Save ROIs
self.widget.roiWidget.save(self.tmpFile)
self.assertTrue(os.path.isfile(self.tmpFile))
- self.assertTrue(len(self.widget.getRois()) is 2)
+ self.assertEqual(len(self.widget.getRois()), 2)
# Reset ROIs
self.mouseClick(self.widget.roiWidget.resetButton,
qt.Qt.LeftButton)
self.qWait(200)
rois = self.widget.getRois()
- self.assertTrue(len(rois) is 1)
- print(rois)
+ self.assertEqual(len(rois), 1)
roiID = list(rois.keys())[0]
- self.assertTrue(rois[roiID].getName() == 'ICR')
+ self.assertEqual(rois[roiID].getName(), 'ICR')
# Load ROIs
self.widget.roiWidget.load(self.tmpFile)
- self.assertTrue(len(self.widget.getRois()) is 2)
+ self.assertEqual(len(self.widget.getRois()), 2)
del self.tmpFile
@@ -223,16 +223,16 @@ class TestCurvesROIWidget(TestCaseQt):
roiWidget = self.plot.getCurvesRoiDockWidget().roiWidget
self.plot.getCurvesRoiDockWidget().setRois(roisDefs)
- self.assertTrue(len(roiWidget.getRois()) is len(roisDefs))
+ self.assertEqual(len(roiWidget.getRois()), len(roisDefs))
self.plot.getCurvesRoiDockWidget().setVisible(True)
- self.assertTrue(len(roiWidget.getRois()) is len(roisDefs))
+ self.assertEqual(len(roiWidget.getRois()), len(roisDefs))
def testDictCompatibility(self):
"""Test that ROI api is valid with dict and not information is lost"""
roiDict = {'from': 20, 'to': 200, 'type': 'energy', 'comment': 'no',
'name': 'myROI', 'calibration': [1, 2, 3]}
roi = CurvesROIWidget.ROI._fromDict(roiDict)
- self.assertTrue(roi.toDict() == roiDict)
+ self.assertEqual(roi.toDict(), roiDict)
def testShowAllROI(self):
"""Test the show allROI action"""
@@ -254,29 +254,33 @@ class TestCurvesROIWidget(TestCaseQt):
self.widget.roiWidget.showAllMarkers(True)
roiWidget = self.plot.getCurvesRoiDockWidget().roiWidget
roiWidget.setRois(roisDefsDict)
- self.assertTrue(len(self.plot._getAllMarkers()) is 2*3)
+ markers = [item for item in self.plot.getItems()
+ if isinstance(item, items.MarkerBase)]
+ self.assertEqual(len(markers), 2*3)
markersHandler = self.widget.roiWidget.roiTable._markersHandler
roiWidget.showAllMarkers(True)
ICRROI = markersHandler.getVisibleRois()
- self.assertTrue(len(ICRROI) is 2)
+ self.assertEqual(len(ICRROI), 2)
roiWidget.showAllMarkers(False)
ICRROI = markersHandler.getVisibleRois()
- self.assertTrue(len(ICRROI) is 1)
+ self.assertEqual(len(ICRROI), 1)
roiWidget.setRois(roisDefsObj)
self.qapp.processEvents()
- self.assertTrue(len(self.plot._getAllMarkers()) is 2*3)
+ markers = [item for item in self.plot.getItems()
+ if isinstance(item, items.MarkerBase)]
+ self.assertEqual(len(markers), 2*3)
markersHandler = self.widget.roiWidget.roiTable._markersHandler
roiWidget.showAllMarkers(True)
ICRROI = markersHandler.getVisibleRois()
- self.assertTrue(len(ICRROI) is 2)
+ self.assertEqual(len(ICRROI), 2)
roiWidget.showAllMarkers(False)
ICRROI = markersHandler.getVisibleRois()
- self.assertTrue(len(ICRROI) is 1)
+ self.assertEqual(len(ICRROI), 1)
def testRoiEdition(self):
"""Make sure if the ROI object is edited the ROITable will be updated
@@ -331,7 +335,7 @@ class TestCurvesROIWidget(TestCaseQt):
self.widget.show()
self.qapp.processEvents()
self.assertEqual(signalListener.callCount(), 0)
- self.assertTrue(self.widget.roiWidget.roiTable.activeRoi is roi)
+ self.assertIs(self.widget.roiWidget.roiTable.activeRoi, roi)
roi.setFrom(0.0)
self.qapp.processEvents()
self.assertEqual(signalListener.callCount(), 0)
@@ -367,7 +371,6 @@ class TestRoiWidgetSignals(TestCaseQt):
def testSigROISignalAddRmRois(self):
"""Test SigROISignal when adding and removing ROIS"""
- print(self.listener.callCount())
self.assertEqual(self.listener.callCount(), 1)
self.listener.clear()
diff --git a/silx/gui/plot/test/testImageStack.py b/silx/gui/plot/test/testImageStack.py
new file mode 100644
index 0000000..9c21469
--- /dev/null
+++ b/silx/gui/plot/test/testImageStack.py
@@ -0,0 +1,197 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2020 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Basic tests for ImageStack"""
+
+__authors__ = ["H. Payno"]
+__license__ = "MIT"
+__date__ = "15/01/2020"
+
+
+import unittest
+import tempfile
+import numpy
+import h5py
+
+from silx.gui import qt
+from silx.gui.utils.testutils import TestCaseQt
+from silx.io.url import DataUrl
+from silx.gui.plot.ImageStack import ImageStack
+from silx.gui.utils.testutils import SignalListener
+from collections import OrderedDict
+import os
+import time
+import shutil
+
+
+class TestImageStack(TestCaseQt):
+ """Simple test of the Image stack"""
+
+ def setUp(self):
+ TestCaseQt.setUp(self)
+ self.urls = OrderedDict()
+ self._raw_data = {}
+ self._folder = tempfile.mkdtemp()
+ self._n_urls = 10
+ file_name = os.path.join(self._folder, 'test_inage_stack_file.h5')
+ with h5py.File(file_name, 'w') as h5f:
+ for i in range(self._n_urls):
+ width = numpy.random.randint(10, 40)
+ height = numpy.random.randint(10, 40)
+ raw_data = numpy.random.random((width, height))
+ self._raw_data[i] = raw_data
+ h5f[str(i)] = raw_data
+ self.urls[i] = DataUrl(file_path=file_name,
+ data_path=str(i),
+ scheme='silx')
+ self.widget = ImageStack()
+
+ self.urlLoadedListener = SignalListener()
+ self.widget.sigLoaded.connect(self.urlLoadedListener)
+
+ self.currentUrlChangedListener = SignalListener()
+ self.widget.sigCurrentUrlChanged.connect(self.currentUrlChangedListener)
+
+ def tearDown(self):
+ shutil.rmtree(self._folder)
+ self.widget.setAttribute(qt.Qt.WA_DeleteOnClose, True)
+ self.widget.close()
+ TestCaseQt.setUp(self)
+
+ def testControls(self):
+ """Test that selection using the url table and the slider are working
+ """
+ self.widget.show()
+ self.assertEqual(self.widget.getCurrentUrl(), None)
+ self.assertEqual(self.widget.getCurrentUrlIndex(), None)
+ self.widget.setUrls(list(self.urls.values()))
+
+ # wait for image to be loaded
+ self._waitUntilUrlLoaded()
+
+ self.assertEqual(self.widget.getCurrentUrl(), self.urls[0])
+
+ # make sure all image are loaded
+ self.assertEqual(self.urlLoadedListener.callCount(), self._n_urls)
+ numpy.testing.assert_array_equal(
+ self.widget.getPlotWidget().getActiveImage(just_legend=False).getData(),
+ self._raw_data[0])
+ self.assertEqual(self.widget._slider.value(), 0)
+
+ self.widget._urlsTable.setUrl(self.urls[4])
+ numpy.testing.assert_array_equal(
+ self.widget.getPlotWidget().getActiveImage(just_legend=False).getData(),
+ self._raw_data[4])
+ self.assertEqual(self.widget._slider.value(), 4)
+ self.assertEqual(self.widget.getCurrentUrl(), self.urls[4])
+ self.assertEqual(self.widget.getCurrentUrlIndex(), 4)
+
+ self.widget._slider.setUrlIndex(6)
+ numpy.testing.assert_array_equal(
+ self.widget.getPlotWidget().getActiveImage(just_legend=False).getData(),
+ self._raw_data[6])
+ self.assertEqual(self.widget._urlsTable.currentItem().text(),
+ self.urls[6].path())
+
+ def testCurrentUrlSignals(self):
+ """Test emission of 'currentUrlChangedListener'"""
+ # check initialization
+ self.assertEqual(self.currentUrlChangedListener.callCount(), 0)
+ self.widget.setUrls(list(self.urls.values()))
+ self.qapp.processEvents()
+ time.sleep(0.5)
+ self.qapp.processEvents()
+ # once loaded the two signals should have been sended
+ self.assertEqual(self.currentUrlChangedListener.callCount(), 1)
+ # if the slider is stuck to the same position no signal should be
+ # emitted
+ self.qapp.processEvents()
+ time.sleep(0.5)
+ self.qapp.processEvents()
+ self.assertEqual(self.widget._slider.value(), 0)
+ self.assertEqual(self.currentUrlChangedListener.callCount(), 1)
+ # if slider position is changed, one of each signal should have been
+ # emitted
+ self.widget._urlsTable.setUrl(self.urls[4])
+ self.qapp.processEvents()
+ time.sleep(1.5)
+ self.qapp.processEvents()
+ self.assertEqual(self.currentUrlChangedListener.callCount(), 2)
+
+ def testUtils(self):
+ """Test that some utils functions are working"""
+ self.widget.show()
+ self.widget.setUrls(list(self.urls.values()))
+ self.assertEqual(len(self.widget.getUrls()), len(self.urls))
+
+ # wait for image to be loaded
+ self._waitUntilUrlLoaded()
+
+ urls_values = list(self.urls.values())
+ self.assertEqual(urls_values[0], self.urls[0])
+ self.assertEqual(urls_values[7], self.urls[7])
+
+ self.assertEqual(self.widget._getNextUrl(urls_values[2]).path(),
+ urls_values[3].path())
+ self.assertEqual(self.widget._getPreviousUrl(urls_values[0]), None)
+ self.assertEqual(self.widget._getPreviousUrl(urls_values[6]).path(),
+ urls_values[5].path())
+
+ self.assertEqual(self.widget._getNNextUrls(2, urls_values[0]),
+ urls_values[1:3])
+ self.assertEqual(self.widget._getNNextUrls(5, urls_values[7]),
+ urls_values[8:])
+ self.assertEqual(self.widget._getNPreviousUrls(3, urls_values[2]),
+ urls_values[:2])
+ self.assertEqual(self.widget._getNPreviousUrls(5, urls_values[8]),
+ urls_values[3:8])
+
+ def _waitUntilUrlLoaded(self, timeout=2.0):
+ """Wait until all image urls are loaded"""
+ loop_duration = 0.2
+ remaining_duration = timeout
+ while(len(self.widget._loadingThreads) > 0 and remaining_duration > 0):
+ remaining_duration -= loop_duration
+ time.sleep(loop_duration)
+ self.qapp.processEvents()
+
+ if remaining_duration <= 0.0:
+ remaining_urls = []
+ for thread_ in self.widget._loadingThreads:
+ remaining_urls.append(thread_.url.path())
+ mess = 'All images are not loaded after the time out. ' \
+ 'Remaining urls are: ' + str(remaining_urls)
+ raise TimeoutError(mess)
+ return True
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestImageStack))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testInteraction.py b/silx/gui/plot/test/testInteraction.py
index 074a7cd..a47337e 100644
--- a/silx/gui/plot/test/testInteraction.py
+++ b/silx/gui/plot/test/testInteraction.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -43,14 +43,14 @@ class TestInteraction(unittest.TestCase):
def click(self, x, y, btn):
events.append(('click', x, y, btn))
- def beginDrag(self, x, y):
- events.append(('beginDrag', x, y))
+ def beginDrag(self, x, y, btn):
+ events.append(('beginDrag', x, y, btn))
- def drag(self, x, y):
- events.append(('drag', x, y))
+ def drag(self, x, y, btn):
+ events.append(('drag', x, y, btn))
- def endDrag(self, x, y):
- events.append(('endDrag', x, y))
+ def endDrag(self, start, end, btn):
+ events.append(('endDrag', start, end, btn))
clickOrDrag = TestClickOrDrag()
@@ -68,14 +68,14 @@ class TestInteraction(unittest.TestCase):
self.assertEqual(len(events), 0)
clickOrDrag.handleEvent('move', 15, 10)
self.assertEqual(len(events), 2) # Received beginDrag and drag
- self.assertEqual(events[0], ('beginDrag', 10, 10))
- self.assertEqual(events[1], ('drag', 15, 10))
+ self.assertEqual(events[0], ('beginDrag', 10, 10, Interaction.LEFT_BTN))
+ self.assertEqual(events[1], ('drag', 15, 10, Interaction.LEFT_BTN))
clickOrDrag.handleEvent('move', 20, 10)
self.assertEqual(len(events), 3)
- self.assertEqual(events[-1], ('drag', 20, 10))
+ self.assertEqual(events[-1], ('drag', 20, 10, Interaction.LEFT_BTN))
clickOrDrag.handleEvent('release', 20, 10, Interaction.LEFT_BTN)
self.assertEqual(len(events), 4)
- self.assertEqual(events[-1], ('endDrag', (10, 10), (20, 10)))
+ self.assertEqual(events[-1], ('endDrag', (10, 10), (20, 10), Interaction.LEFT_BTN))
def suite():
diff --git a/silx/gui/plot/test/testItem.py b/silx/gui/plot/test/testItem.py
index c864545..ad739a2 100644
--- a/silx/gui/plot/test/testItem.py
+++ b/silx/gui/plot/test/testItem.py
@@ -131,6 +131,7 @@ class TestSigItemChangedSignal(PlotWidgetTestCase):
ItemChangedType.COLORMAP,
ItemChangedType.POSITION,
ItemChangedType.SCALE,
+ ItemChangedType.COLORMAP,
ItemChangedType.DATA])
def testImageRgbaChanged(self):
@@ -203,13 +204,14 @@ class TestSigItemChangedSignal(PlotWidgetTestCase):
self.assertEqual(listener.arguments(),
[(ItemChangedType.COLORMAP,),
+ (ItemChangedType.COLORMAP,),
(ItemChangedType.DATA,),
(ItemChangedType.VISUALIZATION_MODE,)])
def testShapeChanged(self):
"""Test sigItemChanged for shape"""
data = numpy.array((1., 10.))
- self.plot.addItem(data, data, legend='test', shape='rectangle')
+ self.plot.addShape(data, data, legend='test', shape='rectangle')
shape = self.plot._getItem(kind='item', legend='test')
listener = SignalListener()
diff --git a/silx/gui/plot/test/testPixelIntensityHistoAction.py b/silx/gui/plot/test/testPixelIntensityHistoAction.py
index 20d1ea2..882f496 100644
--- a/silx/gui/plot/test/testPixelIntensityHistoAction.py
+++ b/silx/gui/plot/test/testPixelIntensityHistoAction.py
@@ -43,7 +43,7 @@ class TestPixelIntensitiesHisto(TestCaseQt, ParametricTestCase):
def setUp(self):
super(TestPixelIntensitiesHisto, self).setUp()
- self.image = numpy.random.rand(100, 100)
+ self.image = numpy.random.rand(10, 10)
self.plotImage = Plot2D()
self.plotImage.getIntensityHistogramAction().setVisible(True)
@@ -91,6 +91,59 @@ class TestPixelIntensitiesHisto(TestCaseQt, ParametricTestCase):
self.plotImage.addImage(self.image.astype(typeToTest),
origin=(0, 0), legend='sino')
+ def testScatter(self):
+ """Test that an histogram from a scatter is displayed"""
+ xx = numpy.arange(10)
+ yy = numpy.arange(10)
+ value = numpy.sin(xx)
+ self.plotImage.addScatter(xx, yy, value)
+ self.plotImage.show()
+
+ histoAction = self.plotImage.getIntensityHistogramAction()
+
+ # test the pixel intensity diagram is showing
+ button = getQToolButtonFromAction(histoAction)
+ self.assertIsNot(button, None)
+ self.mouseMove(button)
+ self.mouseClick(button, qt.Qt.LeftButton)
+ self.qapp.processEvents()
+
+ plot = histoAction.getHistogramPlotWidget()
+ self.assertTrue(plot.isVisible())
+ items = plot.getItems()
+ self.assertEqual(len(items), 1)
+
+ def testChangeItem(self):
+ """Test that histogram changes it the item changes"""
+ xx = numpy.arange(10)
+ yy = numpy.arange(10)
+ value = numpy.sin(xx)
+ self.plotImage.addScatter(xx, yy, value)
+ self.plotImage.show()
+
+ histoAction = self.plotImage.getIntensityHistogramAction()
+
+ # test the pixel intensity diagram is showing
+ button = getQToolButtonFromAction(histoAction)
+ self.assertIsNot(button, None)
+ self.mouseMove(button)
+ self.mouseClick(button, qt.Qt.LeftButton)
+ self.qapp.processEvents()
+
+ # Reach histogram from the first item
+ plot = histoAction.getHistogramPlotWidget()
+ self.assertTrue(plot.isVisible())
+ items = plot.getItems()
+ data1 = items[0].getValueData(copy=False)
+
+ # Set another item to the plot
+ self.plotImage.addImage(self.image, origin=(0, 0), legend='sino')
+ self.qapp.processEvents()
+ data2 = items[0].getValueData(copy=False)
+
+ # Histogram is not the same
+ self.assertFalse(numpy.array_equal(data1, data2))
+
def suite():
test_suite = unittest.TestSuite()
diff --git a/silx/gui/plot/test/testPlotWidget.py b/silx/gui/plot/test/testPlotWidget.py
index 9724ec6..4ef6a72 100755
--- a/silx/gui/plot/test/testPlotWidget.py
+++ b/silx/gui/plot/test/testPlotWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -43,7 +43,7 @@ from silx.test.utils import test_options
from silx.gui import qt
from silx.gui.plot import PlotWidget
from silx.gui.plot.items.curve import CurveStyle
-from silx.gui.plot.items.shape import BoundingRect
+from silx.gui.plot.items import BoundingRect, XAxisExtent, YAxisExtent
from silx.gui.colors import Colormap
from .utils import PlotWidgetTestCase
@@ -186,7 +186,7 @@ class TestPlotWidget(PlotWidgetTestCase, ParametricTestCase):
self.plot.addMarker(*marker_pos)
marker_x = 6
self.plot.addXMarker(marker_x)
- self.plot.addItem((0, 5), (2, 10), shape='rectangle')
+ self.plot.addShape((0, 5), (2, 10), shape='rectangle')
items = self.plot.getItems()
self.assertEqual(len(items), 6)
@@ -273,11 +273,20 @@ class TestPlotImage(PlotWidgetTestCase, ParametricTestCase):
rgb = numpy.array(
(((0, 0, 0), (128, 0, 0), (255, 0, 0)),
- ((0, 128, 0), (0, 128, 128), (0, 128, 256))),
+ ((0, 128, 0), (0, 128, 128), (0, 128, 255))),
dtype=numpy.uint8)
- self.plot.addImage(rgb, legend="rgb",
- origin=(0, 0), scale=(10, 10),
+ self.plot.addImage(rgb, legend="rgb_uint8",
+ origin=(0, 0), scale=(1, 1),
+ resetzoom=False)
+
+ rgb = numpy.array(
+ (((0, 0, 0), (32768, 0, 0), (65535, 0, 0)),
+ ((0, 32768, 0), (0, 32768, 32768), (0, 32768, 65535))),
+ dtype=numpy.uint16)
+
+ self.plot.addImage(rgb, legend="rgb_uint16",
+ origin=(3, 2), scale=(2, 2),
resetzoom=False)
rgba = numpy.array(
@@ -285,8 +294,8 @@ class TestPlotImage(PlotWidgetTestCase, ParametricTestCase):
((0, .5, 0, 1), (0, .5, .5, 1), (0, 1, 1, .5))),
dtype=numpy.float32)
- self.plot.addImage(rgba, legend="rgba",
- origin=(5, 5), scale=(10, 10),
+ self.plot.addImage(rgba, legend="rgba_float32",
+ origin=(9, 6), scale=(1, 1),
resetzoom=False)
self.plot.resetZoom()
@@ -561,9 +570,13 @@ class TestPlotScatter(PlotWidgetTestCase, ParametricTestCase):
for visualization in ('solid',
'points',
'regular_grid',
+ 'irregular_grid',
+ 'binned_statistic',
scatter.Visualization.SOLID,
scatter.Visualization.POINTS,
- scatter.Visualization.REGULAR_GRID):
+ scatter.Visualization.REGULAR_GRID,
+ scatter.Visualization.IRREGULAR_GRID,
+ scatter.Visualization.BINNED_STATISTIC):
with self.subTest(visualization=visualization):
scatter.setVisualization(visualization)
self.qapp.processEvents()
@@ -622,13 +635,37 @@ class TestPlotScatter(PlotWidgetTestCase, ParametricTestCase):
for index, position in enumerate(zip(x, y)):
xpixel, ypixel = self.plot.dataToPixel(*position)
result = scatter.pick(xpixel, ypixel)
- if (visualization is scatter.Visualization.IRREGULAR_GRID and
- (shape[0] < 2 or shape[1] < 2)):
- self.assertIsNone(result)
- else:
- self.assertIsNotNone(result)
- self.assertIs(result.getItem(), scatter)
- self.assertEqual(result.getIndices()[0], (index,))
+ self.assertIsNotNone(result)
+ self.assertIs(result.getItem(), scatter)
+ self.assertEqual(result.getIndices(), (index,))
+
+ def testBinnedStatisticVisualization(self):
+ """Test binned display"""
+ self.plot.addScatter((), (), ())
+ scatter = self.plot.getItems()[0]
+ scatter.setVisualization(scatter.Visualization.BINNED_STATISTIC)
+ self.assertIs(scatter.getVisualization(),
+ scatter.Visualization.BINNED_STATISTIC)
+ self.assertEqual(
+ scatter.getVisualizationParameter(
+ scatter.VisualizationParameter.BINNED_STATISTIC_FUNCTION),
+ 'mean')
+
+ self.qapp.processEvents()
+
+ scatter.setData(*numpy.random.random(3000).reshape(3, -1))
+
+ for reduction in ('count', 'sum', 'mean'):
+ with self.subTest(reduction=reduction):
+ scatter.setVisualizationParameter(
+ scatter.VisualizationParameter.BINNED_STATISTIC_FUNCTION,
+ reduction)
+ self.assertEqual(
+ scatter.getVisualizationParameter(
+ scatter.VisualizationParameter.BINNED_STATISTIC_FUNCTION),
+ reduction)
+
+ self.qapp.processEvents()
class TestPlotMarker(PlotWidgetTestCase):
@@ -799,36 +836,36 @@ class TestPlotItem(PlotWidgetTestCase):
self.plot.setGraphTitle('Item Fill')
for legend, xList, yList, color in self.polygons:
- self.plot.addItem(xList, yList, legend=legend,
- replace=False,
- shape="polygon", fill=True, color=color)
+ self.plot.addShape(xList, yList, legend=legend,
+ replace=False,
+ shape="polygon", fill=True, color=color)
self.plot.resetZoom()
def testPlotItemPolygonNoFill(self):
self.plot.setGraphTitle('Item No Fill')
for legend, xList, yList, color in self.polygons:
- self.plot.addItem(xList, yList, legend=legend,
- replace=False,
- shape="polygon", fill=False, color=color)
+ self.plot.addShape(xList, yList, legend=legend,
+ replace=False,
+ shape="polygon", fill=False, color=color)
self.plot.resetZoom()
def testPlotItemRectangleFill(self):
self.plot.setGraphTitle('Rectangle Fill')
for legend, xList, yList, color in self.rectangles:
- self.plot.addItem(xList, yList, legend=legend,
- replace=False,
- shape="rectangle", fill=True, color=color)
+ self.plot.addShape(xList, yList, legend=legend,
+ replace=False,
+ shape="rectangle", fill=True, color=color)
self.plot.resetZoom()
def testPlotItemRectangleNoFill(self):
self.plot.setGraphTitle('Rectangle No Fill')
for legend, xList, yList, color in self.rectangles:
- self.plot.addItem(xList, yList, legend=legend,
- replace=False,
- shape="rectangle", fill=False, color=color)
+ self.plot.addShape(xList, yList, legend=legend,
+ replace=False,
+ shape="rectangle", fill=False, color=color)
self.plot.resetZoom()
@@ -1350,7 +1387,7 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
def testBoundingRectItem(self):
item = BoundingRect()
item.setBounds((-1000, 1000, -2000, 2000))
- self.plot._add(item)
+ self.plot.addItem(item)
self.plot.resetZoom()
limits = numpy.array(self.plot.getXAxis().getLimits())
numpy.testing.assert_almost_equal(limits, numpy.array([-1000, 1000]))
@@ -1361,7 +1398,7 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
item = BoundingRect()
item.setYAxis("right")
item.setBounds((-1000, 1000, -2000, 2000))
- self.plot._add(item)
+ self.plot.addItem(item)
self.plot.resetZoom()
limits = numpy.array(self.plot.getXAxis().getLimits())
numpy.testing.assert_almost_equal(limits, numpy.array([-1000, 1000]))
@@ -1377,7 +1414,7 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
def testBoundingRectWithLog(self):
item = BoundingRect()
- self.plot._add(item)
+ self.plot.addItem(item)
item.setBounds((-1000, 1000, -2000, 2000))
self.plot.getXAxis()._setLogarithmic(True)
@@ -1394,6 +1431,28 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
self.plot.getYAxis()._setLogarithmic(False)
self.assertIsNone(item.getBounds())
+ def testAxisExtent(self):
+ """Test XAxisExtent and yAxisExtent"""
+ for cls, axis in ((XAxisExtent, self.plot.getXAxis()),
+ (YAxisExtent, self.plot.getYAxis())):
+ for range_, logRange in (((2, 3), (2, 3)),
+ ((-2, -1), (1, 100)),
+ ((-1, 3), (3. * 0.9, 3. * 1.1))):
+ extent = cls()
+ extent.setRange(*range_)
+ self.plot.addItem(extent)
+
+ for isLog, plotRange in ((False, range_), (True, logRange)):
+ with self.subTest(
+ cls=cls.__name__, range=range_, isLog=isLog):
+ axis._setLogarithmic(isLog)
+ self.plot.resetZoom()
+ self.qapp.processEvents()
+ self.assertEqual(axis.getLimits(), plotRange)
+
+ axis._setLogarithmic(False)
+ self.plot.clear()
+
class TestPlotCurveLog(PlotWidgetTestCase, ParametricTestCase):
"""Basic tests for addCurve with log scale axes"""
@@ -1736,36 +1795,36 @@ class TestPlotItemLog(PlotWidgetTestCase):
self.plot.setGraphTitle('Item Fill Log')
for legend, xList, yList, color in self.polygons:
- self.plot.addItem(xList, yList, legend=legend,
- replace=False,
- shape="polygon", fill=True, color=color)
+ self.plot.addShape(xList, yList, legend=legend,
+ replace=False,
+ shape="polygon", fill=True, color=color)
self.plot.resetZoom()
def testPlotItemPolygonLogNoFill(self):
self.plot.setGraphTitle('Item No Fill Log')
for legend, xList, yList, color in self.polygons:
- self.plot.addItem(xList, yList, legend=legend,
- replace=False,
- shape="polygon", fill=False, color=color)
+ self.plot.addShape(xList, yList, legend=legend,
+ replace=False,
+ shape="polygon", fill=False, color=color)
self.plot.resetZoom()
def testPlotItemRectangleLogFill(self):
self.plot.setGraphTitle('Rectangle Fill Log')
for legend, xList, yList, color in self.rectangles:
- self.plot.addItem(xList, yList, legend=legend,
- replace=False,
- shape="rectangle", fill=True, color=color)
+ self.plot.addShape(xList, yList, legend=legend,
+ replace=False,
+ shape="rectangle", fill=True, color=color)
self.plot.resetZoom()
def testPlotItemRectangleLogNoFill(self):
self.plot.setGraphTitle('Rectangle No Fill Log')
for legend, xList, yList, color in self.rectangles:
- self.plot.addItem(xList, yList, legend=legend,
- replace=False,
- shape="rectangle", fill=False, color=color)
+ self.plot.addShape(xList, yList, legend=legend,
+ replace=False,
+ shape="rectangle", fill=False, color=color)
self.plot.resetZoom()
diff --git a/silx/gui/plot/test/testPlotWidgetNoBackend.py b/silx/gui/plot/test/testPlotWidgetNoBackend.py
index cd7cbb3..edd3cd7 100644
--- a/silx/gui/plot/test/testPlotWidgetNoBackend.py
+++ b/silx/gui/plot/test/testPlotWidgetNoBackend.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -62,8 +62,9 @@ class TestPlot(unittest.TestCase):
plot = PlotWidget(backend='none')
plot.addCurve(x=(1, 2, 3), y=(3, 2, 1))
plot.addImage(numpy.arange(100.).reshape(10, -1))
- plot.addItem(
- numpy.array((1., 10.)), numpy.array((10., 10.)), shape="rectangle")
+ plot.addShape(numpy.array((1., 10.)),
+ numpy.array((10., 10.)),
+ shape="rectangle")
plot.addXMarker(10.)
@@ -400,21 +401,21 @@ class TestPlotGetCurveImage(unittest.TestCase):
# Active curve
active = plot.getActiveCurve()
- self.assertEqual(active.getLegend(), 'curve 0')
+ self.assertEqual(active.getName(), 'curve 0')
curve = plot.getCurve()
- self.assertEqual(curve.getLegend(), 'curve 0')
+ self.assertEqual(curve.getName(), 'curve 0')
# No active curve and curves
plot.setActiveCurveHandling(False)
active = plot.getActiveCurve()
self.assertIsNone(active) # No active curve
curve = plot.getCurve()
- self.assertEqual(curve.getLegend(), 'curve 2') # Last added curve
+ self.assertEqual(curve.getName(), 'curve 2') # Last added curve
# Last curve hidden
plot.hideCurve('curve 2', True)
curve = plot.getCurve()
- self.assertEqual(curve.getLegend(), 'curve 1') # Last added curve
+ self.assertEqual(curve.getName(), 'curve 1') # Last added curve
# All curves hidden
plot.hideCurve('curve 1', True)
@@ -465,9 +466,9 @@ class TestPlotGetCurveImage(unittest.TestCase):
# Active image
active = plot.getActiveImage()
- self.assertEqual(active.getLegend(), 'image 0')
+ self.assertEqual(active.getName(), 'image 0')
image = plot.getImage()
- self.assertEqual(image.getLegend(), 'image 0')
+ self.assertEqual(image.getName(), 'image 0')
# No active image
plot.addImage(((0, 1), (2, 3)), legend='image 2')
@@ -475,14 +476,14 @@ class TestPlotGetCurveImage(unittest.TestCase):
active = plot.getActiveImage()
self.assertIsNone(active)
image = plot.getImage()
- self.assertEqual(image.getLegend(), 'image 2')
+ self.assertEqual(image.getName(), 'image 2')
# Active image
plot.setActiveImage('image 1')
active = plot.getActiveImage()
- self.assertEqual(active.getLegend(), 'image 1')
+ self.assertEqual(active.getName(), 'image 1')
image = plot.getImage()
- self.assertEqual(image.getLegend(), 'image 1')
+ self.assertEqual(image.getName(), 'image 1')
def testGetImageOldApi(self):
"""PlotWidget.getImage and PlotWidget.getActiveImage old API tests"""
@@ -521,8 +522,8 @@ class TestPlotGetCurveImage(unittest.TestCase):
self.assertEqual(list(images), ['1', '2'])
images = plot.getAllImages(just_legend=False)
self.assertEqual(len(images), 2)
- self.assertEqual(images[0].getLegend(), '1')
- self.assertEqual(images[1].getLegend(), '2')
+ self.assertEqual(images[0].getName(), '1')
+ self.assertEqual(images[1].getName(), '2')
class TestPlotAddScatter(unittest.TestCase):
@@ -543,7 +544,7 @@ class TestPlotAddScatter(unittest.TestCase):
# Active scatter
active = plot._getActiveItem(kind='scatter')
- self.assertEqual(active.getLegend(), 'scatter 0')
+ self.assertEqual(active.getName(), 'scatter 0')
# check default values
self.assertAlmostEqual(active.getSymbolSize(), active._DEFAULT_SYMBOL_SIZE)
@@ -562,28 +563,25 @@ class TestPlotAddScatter(unittest.TestCase):
self.assertAlmostEqual(s0.getAlpha(), 0.777)
scatter1 = plot._getItem(kind='scatter', legend='scatter 1')
- self.assertEqual(scatter1.getLegend(), 'scatter 1')
+ self.assertEqual(scatter1.getName(), 'scatter 1')
def testGetAllScatters(self):
"""PlotWidget.getAllImages test"""
plot = PlotWidget(backend='none')
- scatters = plot._getItems(kind='scatter')
- self.assertEqual(len(scatters), 0)
+ items = plot.getItems()
+ self.assertEqual(len(items), 0)
plot.addScatter(x=(0, 1), y=(0, 1), value=(0, 1), legend='scatter 0')
plot.addScatter(x=(0, 1), y=(0, 1), value=(0, 1), legend='scatter 1')
plot.addScatter(x=(0, 1), y=(0, 1), value=(0, 1), legend='scatter 2')
- scatters = plot._getItems(kind='scatter')
- self.assertEqual(len(scatters), 3)
- self.assertEqual(scatters[0].getLegend(), 'scatter 0')
- self.assertEqual(scatters[2].getLegend(), 'scatter 2')
-
- scatters = plot._getItems(kind='scatter', just_legend=True)
- self.assertEqual(len(scatters), 3)
- self.assertEqual(list(scatters), ['scatter 0', 'scatter 1', 'scatter 2'])
+ items = plot.getItems()
+ self.assertEqual(len(items), 3)
+ self.assertEqual(items[0].getName(), 'scatter 0')
+ self.assertEqual(items[1].getName(), 'scatter 1')
+ self.assertEqual(items[2].getName(), 'scatter 2')
class TestPlotHistogram(unittest.TestCase):
diff --git a/silx/gui/plot/test/testPlotWindow.py b/silx/gui/plot/test/testPlotWindow.py
index 0a7d108..8e7b35c 100644
--- a/silx/gui/plot/test/testPlotWindow.py
+++ b/silx/gui/plot/test/testPlotWindow.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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,13 +29,14 @@ __license__ = "MIT"
__date__ = "27/06/2017"
-import doctest
import unittest
+import numpy
from silx.gui.utils.testutils import TestCaseQt, getQToolButtonFromAction
from silx.gui import qt
from silx.gui.plot import PlotWindow
+from silx.gui.colors import Colormap
class TestPlotWindow(TestCaseQt):
@@ -85,6 +86,29 @@ class TestPlotWindow(TestCaseQt):
self.assertIsNot(toolButton, None)
self.mouseClick(toolButton, qt.Qt.LeftButton)
+ def testDockWidgets(self):
+ """Test add/remove dock widgets"""
+ dock1 = qt.QDockWidget('Test 1')
+ dock1.setWidget(qt.QLabel('Test 1'))
+
+ self.plot.addTabbedDockWidget(dock1)
+ self.qapp.processEvents()
+
+ self.plot.removeDockWidget(dock1)
+ self.qapp.processEvents()
+
+ dock2 = qt.QDockWidget('Test 2')
+ dock2.setWidget(qt.QLabel('Test 2'))
+
+ self.plot.addTabbedDockWidget(dock2)
+ self.qapp.processEvents()
+
+ if qt.BINDING != 'PySide2':
+ # Weird bug with PySide2 later upon gc.collect() when getting the layout
+ self.assertNotEqual(self.plot.layout().indexOf(dock2),
+ -1,
+ "dock2 not properly displayed")
+
def testToolAspectRatio(self):
self.plot.toolBar()
self.plot.keepDataAspectRatioButton.keepDataAspectRatio()
@@ -99,6 +123,37 @@ class TestPlotWindow(TestCaseQt):
self.plot.yAxisInvertedButton.setYAxisDownward()
self.assertTrue(self.plot.getYAxis().isInverted())
+ def testColormapAutoscaleCache(self):
+ # Test that the min/max cache is not computed twice
+
+ old = Colormap._computeAutoscaleRange
+ self._count = 0
+ def _computeAutoscaleRange(colormap, data):
+ self._count = self._count + 1
+ return 10, 20
+ Colormap._computeAutoscaleRange = _computeAutoscaleRange
+ try:
+ colormap = Colormap(name='red')
+ self.plot.setVisible(True)
+
+ # Add an image
+ data = numpy.arange(8**2).reshape(8, 8)
+ self.plot.addImage(data, legend="foo", colormap=colormap)
+ self.plot.setActiveImage("foo")
+
+ # Use the colorbar
+ self.plot.getColorBarWidget().setVisible(True)
+ self.qWait(50)
+
+ # Remove and add again the same item
+ image = self.plot.getImage("foo")
+ self.plot.removeImage("foo")
+ self.plot.addItem(image)
+ self.qWait(50)
+ finally:
+ Colormap._computeAutoscaleRange = old
+ self.assertEqual(self._count, 1)
+ del self._count
def suite():
test_suite = unittest.TestSuite()
diff --git a/silx/gui/plot/test/testProfile.py b/silx/gui/plot/test/testProfile.py
deleted file mode 100644
index cf40f76..0000000
--- a/silx/gui/plot/test/testProfile.py
+++ /dev/null
@@ -1,287 +0,0 @@
-# coding: utf-8
-# /*##########################################################################
-#
-# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-#
-# ###########################################################################*/
-"""Basic tests for Profile"""
-
-__authors__ = ["T. Vincent", "P. Knobel"]
-__license__ = "MIT"
-__date__ = "17/01/2018"
-
-import numpy
-import unittest
-
-from silx.utils.testutils import ParametricTestCase
-from silx.gui.utils.testutils import (
- TestCaseQt, getQToolButtonFromAction)
-from silx.gui import qt
-from silx.gui.plot import PlotWindow, Plot1D, Plot2D, Profile
-from silx.gui.plot.StackView import StackView
-
-
-class TestProfileToolBar(TestCaseQt, ParametricTestCase):
- """Tests for ProfileToolBar widget."""
-
- def setUp(self):
- super(TestProfileToolBar, self).setUp()
- profileWindow = PlotWindow()
- self.plot = PlotWindow()
- self.toolBar = Profile.ProfileToolBar(
- plot=self.plot, profileWindow=profileWindow)
- self.plot.addToolBar(self.toolBar)
-
- self.plot.show()
- self.qWaitForWindowExposed(self.plot)
- profileWindow.show()
- self.qWaitForWindowExposed(profileWindow)
-
- self.mouseMove(self.plot) # Move to center
- self.qapp.processEvents()
-
- def tearDown(self):
- self.qapp.processEvents()
- self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
- self.plot.close()
- del self.plot
- del self.toolBar
-
- super(TestProfileToolBar, self).tearDown()
-
- def testAlignedProfile(self):
- """Test horizontal and vertical profile, without and with image"""
- # Use Plot backend widget to submit mouse events
- widget = self.plot.getWidgetHandle()
- for method in ('sum', 'mean'):
- with self.subTest(method=method):
- # 2 positions to use for mouse events
- pos1 = widget.width() * 0.4, widget.height() * 0.4
- pos2 = widget.width() * 0.6, widget.height() * 0.6
-
- for action in (self.toolBar.hLineAction, self.toolBar.vLineAction):
- with self.subTest(mode=action.text()):
- # Trigger tool button for mode
- toolButton = getQToolButtonFromAction(action)
- self.assertIsNot(toolButton, None)
- self.mouseMove(toolButton)
- self.mouseClick(toolButton, qt.Qt.LeftButton)
-
- # Without image
- self.mouseMove(widget, pos=pos1)
- self.mouseClick(widget, qt.Qt.LeftButton, pos=pos1)
-
- # with image
- self.plot.addImage(
- numpy.arange(100 * 100).reshape(100, -1))
- self.mousePress(widget, qt.Qt.LeftButton, pos=pos1)
- self.mouseMove(widget, pos=pos2)
- self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2)
-
- self.mouseMove(widget)
- self.mouseClick(widget, qt.Qt.LeftButton)
-
- def testDiagonalProfile(self):
- """Test diagonal profile, without and with image"""
- # Use Plot backend widget to submit mouse events
- widget = self.plot.getWidgetHandle()
-
- for method in ('sum', 'mean'):
- with self.subTest(method=method):
- self.toolBar.setProfileMethod(method)
-
- # 2 positions to use for mouse events
- pos1 = widget.width() * 0.4, widget.height() * 0.4
- pos2 = widget.width() * 0.6, widget.height() * 0.6
-
- for image in (False, True):
- with self.subTest(image=image):
- if image:
- self.plot.addImage(
- numpy.arange(100 * 100).reshape(100, -1))
-
- # Trigger tool button for diagonal profile mode
- toolButton = getQToolButtonFromAction(
- self.toolBar.lineAction)
- self.assertIsNot(toolButton, None)
- self.mouseMove(toolButton)
- self.mouseClick(toolButton, qt.Qt.LeftButton)
- self.toolBar.lineWidthSpinBox.setValue(3)
-
- # draw profile line
- self.mouseMove(widget, pos=pos1)
- self.mousePress(widget, qt.Qt.LeftButton, pos=pos1)
- self.mouseMove(widget, pos=pos2)
- self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2)
-
- if image is True:
- profileCurve = self.toolBar.getProfilePlot().getAllCurves()[0]
- if method == 'sum':
- self.assertTrue(profileCurve.getData()[1].max() > 10000)
- elif method == 'mean':
- self.assertTrue(profileCurve.getData()[1].max() < 10000)
- self.plot.clear()
-
-
-class TestProfile3DToolBar(TestCaseQt):
- """Tests for Profile3DToolBar widget.
- """
- def setUp(self):
- super(TestProfile3DToolBar, self).setUp()
- self.plot = StackView()
- self.plot.show()
- self.qWaitForWindowExposed(self.plot)
-
- self.plot.setStack(numpy.array([
- [[0, 1, 2], [3, 4, 5]],
- [[6, 7, 8], [9, 10, 11]],
- [[12, 13, 14], [15, 16, 17]]
- ]))
-
- def tearDown(self):
- self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
- self.plot.close()
- self.plot = None
-
- super(TestProfile3DToolBar, self).tearDown()
-
- def testMethodProfile1DAnd2D(self):
- """Test that the profile can have a different method if we want to
- compute then in 1D or in 2D"""
-
- _3DProfileToolbar = self.plot.getProfileToolbar()
- _2DProfilePlot = _3DProfileToolbar.getProfilePlot()
- self.plot.getProfileToolbar().setProfileMethod('mean')
- self.plot.getProfileToolbar().lineWidthSpinBox.setValue(3)
- self.assertTrue(_3DProfileToolbar.getProfileMethod() == 'mean')
-
- # check 2D 'mean' profile
- _3DProfileToolbar.profile3dAction.computeProfileIn2D()
- toolButton = getQToolButtonFromAction(_3DProfileToolbar.vLineAction)
- self.assertIsNot(toolButton, None)
- self.mouseMove(toolButton)
- self.mouseClick(toolButton, qt.Qt.LeftButton)
- plot2D = self.plot.getPlot().getWidgetHandle()
- pos1 = plot2D.width() * 0.5, plot2D.height() * 0.5
- self.mouseClick(plot2D, qt.Qt.LeftButton, pos=pos1)
- self.assertTrue(numpy.array_equal(
- _2DProfilePlot.getActiveImage().getData(),
- numpy.array([[1, 4], [7, 10], [13, 16]])
- ))
-
- # check 1D 'sum' profile
- _2DProfileToolbar = _2DProfilePlot.getProfileToolbar()
- _2DProfileToolbar.setProfileMethod('sum')
- self.assertTrue(_2DProfileToolbar.getProfileMethod() == 'sum')
- _1DProfilePlot = _2DProfileToolbar.getProfilePlot()
-
- _2DProfileToolbar.lineWidthSpinBox.setValue(3)
- toolButton = getQToolButtonFromAction(_2DProfileToolbar.vLineAction)
- self.assertIsNot(toolButton, None)
- self.mouseMove(toolButton)
- self.mouseClick(toolButton, qt.Qt.LeftButton)
- plot1D = _2DProfilePlot.getWidgetHandle()
- pos1 = plot1D.width() * 0.5, plot1D.height() * 0.5
- self.mouseClick(plot1D, qt.Qt.LeftButton, pos=pos1)
- self.assertTrue(numpy.array_equal(
- _1DProfilePlot.getAllCurves()[0].getData()[1],
- numpy.array([5, 17, 29])
- ))
-
- def testMethodSumLine(self):
- """Simple interaction test to make sure the sum is correctly computed
- """
- _3DProfileToolbar = self.plot.getProfileToolbar()
- _2DProfilePlot = _3DProfileToolbar.getProfilePlot()
- self.plot.getProfileToolbar().setProfileMethod('sum')
- self.plot.getProfileToolbar().lineWidthSpinBox.setValue(3)
- self.assertTrue(_3DProfileToolbar.getProfileMethod() == 'sum')
-
- # check 2D 'mean' profile
- _3DProfileToolbar.profile3dAction.computeProfileIn2D()
- toolButton = getQToolButtonFromAction(_3DProfileToolbar.lineAction)
- self.assertIsNot(toolButton, None)
- self.mouseMove(toolButton)
- self.mouseClick(toolButton, qt.Qt.LeftButton)
- plot2D = self.plot.getPlot().getWidgetHandle()
- pos1 = plot2D.width() * 0.5, plot2D.height() * 0.2
- pos2 = plot2D.width() * 0.5, plot2D.height() * 0.8
-
- self.mouseMove(plot2D, pos=pos1)
- self.mousePress(plot2D, qt.Qt.LeftButton, pos=pos1)
- self.mouseMove(plot2D, pos=pos2)
- self.mouseRelease(plot2D, qt.Qt.LeftButton, pos=pos2)
- self.assertTrue(numpy.array_equal(
- _2DProfilePlot.getActiveImage().getData(),
- numpy.array([[3, 12], [21, 30], [39, 48]])
- ))
-
-
-class TestGetProfilePlot(TestCaseQt):
-
- def testProfile1D(self):
- plot = Plot2D()
- plot.show()
- self.qWaitForWindowExposed(plot)
- plot.addImage([[0, 1], [2, 3]])
- self.assertIsInstance(plot.getProfileToolbar().getProfileMainWindow(),
- qt.QMainWindow)
- self.assertIsInstance(plot.getProfilePlot(),
- Plot1D)
- plot.setAttribute(qt.Qt.WA_DeleteOnClose)
- plot.close()
- del plot
-
- def testProfile2D(self):
- """Test that the profile plot associated to a stack view is either a
- Plot1D or a plot 2D instance."""
- plot = StackView()
- plot.show()
- self.qWaitForWindowExposed(plot)
-
- plot.setStack(numpy.array([[[0, 1], [2, 3]],
- [[4, 5], [6, 7]]]))
-
- self.assertIsInstance(plot.getProfileToolbar().getProfileMainWindow(),
- qt.QMainWindow)
-
- self.assertIsInstance(plot.getProfileToolbar().getProfilePlot(),
- Plot2D)
- plot.getProfileToolbar().profile3dAction.computeProfileIn1D()
- self.assertIsInstance(plot.getProfileToolbar().getProfilePlot(),
- Plot1D)
-
- plot.setAttribute(qt.Qt.WA_DeleteOnClose)
- plot.close()
- del plot
-
-
-def suite():
- test_suite = unittest.TestSuite()
- for testClass in (TestProfileToolBar, TestGetProfilePlot,
- TestProfile3DToolBar):
- test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(
- testClass))
- return test_suite
-
-
-if __name__ == '__main__':
- unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testStats.py b/silx/gui/plot/test/testStats.py
index 185e79c..8db8cc9 100644
--- a/silx/gui/plot/test/testStats.py
+++ b/silx/gui/plot/test/testStats.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -56,10 +56,14 @@ class TestStats(TestCaseQt):
def tearDown(self):
self.plot1d.setAttribute(qt.Qt.WA_DeleteOnClose)
self.plot1d.close()
+ del self.plot1d
self.plot2d.setAttribute(qt.Qt.WA_DeleteOnClose)
self.plot2d.close()
+ del self.plot2d
self.scatterPlot.setAttribute(qt.Qt.WA_DeleteOnClose)
self.scatterPlot.close()
+ del self.scatterPlot
+ TestCaseQt.tearDown(self)
def createCurveContext(self):
self.plot1d = Plot1D()
@@ -242,6 +246,7 @@ class TestStats(TestCaseQt):
class TestStatsFormatter(TestCaseQt):
"""Simple test to check usage of the :class:`StatsFormatter`"""
def setUp(self):
+ TestCaseQt.setUp(self)
self.plot1d = Plot1D()
x = range(20)
y = range(20)
@@ -257,6 +262,8 @@ class TestStatsFormatter(TestCaseQt):
def tearDown(self):
self.plot1d.setAttribute(qt.Qt.WA_DeleteOnClose)
self.plot1d.close()
+ del self.plot1d
+ TestCaseQt.tearDown(self)
def testEmptyFormatter(self):
"""Make sure a formatter with no formatter definition will return a
@@ -402,10 +409,10 @@ class TestStatsWidgetWithCurves(TestCaseQt, ParametricTestCase):
def check_display_only_active_item(only_active):
# check internal value
- self.assertTrue(widget._statsTable._displayOnlyActItem is only_active)
+ self.assertIs(widget._statsTable._displayOnlyActItem, only_active)
# self.assertTrue(table._displayOnlyActItem is only_active)
# check gui display
- self.assertTrue(widget._options.isActiveItemMode() is only_active)
+ self.assertEqual(widget._options.isActiveItemMode(), only_active)
for displayOnlyActiveItems in (True, False):
with self.subTest(displayOnlyActiveItems=displayOnlyActiveItems):
@@ -479,7 +486,7 @@ class TestStatsWidgetWithCurves(TestCaseQt, ParametricTestCase):
self.widget.setUpdateMode(StatsWidget.UpdateMode.AUTO)
update_stats_action = self.widget._options.getUpdateStatsAction()
# test from api
- self.assertTrue(self.widget.getUpdateMode() is StatsWidget.UpdateMode.AUTO)
+ self.assertEqual(self.widget.getUpdateMode(), StatsWidget.UpdateMode.AUTO)
self.widget.show()
# check stats change in auto mode
self.plot.getCurve('curve0').setData(x=range(4), y=range(-1, 3))
@@ -497,7 +504,7 @@ class TestStatsWidgetWithCurves(TestCaseQt, ParametricTestCase):
# check stats change in manual mode only if requested
self.widget.setUpdateMode(StatsWidget.UpdateMode.MANUAL)
- self.assertTrue(self.widget.getUpdateMode() is StatsWidget.UpdateMode.MANUAL)
+ self.assertEqual(self.widget.getUpdateMode(), StatsWidget.UpdateMode.MANUAL)
self.plot.getCurve('curve0').setData(x=range(4), y=range(2, 6))
self.qapp.processEvents()
@@ -710,7 +717,7 @@ class TestLineWidget(TestCaseQt):
self.qapp.processEvents()
self.assertTrue(self.widget._lineStatsWidget._statQlineEdit['min'].text() == '14.000')
self.plot.setActiveCurve(None)
- self.assertTrue(self.plot.getActiveCurve() is None)
+ self.assertIsNone(self.plot.getActiveCurve())
self.widget.setStatsOnVisibleData(False)
self.qapp.processEvents()
self.assertFalse(self.widget._lineStatsWidget._statQlineEdit['min'].text() == '14.000')
@@ -780,23 +787,23 @@ class TestUpdateModeWidget(TestCaseQt):
self.widget.sigUpdateModeChanged.connect(modeChangedListener)
self.widget.sigUpdateRequested.connect(manualUpdateListener)
self.widget.setUpdateMode(StatsWidget.UpdateMode.AUTO)
- self.assertTrue(self.widget.getUpdateMode() is StatsWidget.UpdateMode.AUTO)
- self.assertTrue(modeChangedListener.callCount() is 0)
+ self.assertEqual(self.widget.getUpdateMode(), StatsWidget.UpdateMode.AUTO)
+ self.assertEqual(modeChangedListener.callCount(), 0)
self.qapp.processEvents()
self.widget.setUpdateMode(StatsWidget.UpdateMode.MANUAL)
- self.assertTrue(self.widget.getUpdateMode() is StatsWidget.UpdateMode.MANUAL)
+ self.assertEqual(self.widget.getUpdateMode(), StatsWidget.UpdateMode.MANUAL)
self.qapp.processEvents()
- self.assertTrue(modeChangedListener.callCount() is 1)
- self.assertTrue(manualUpdateListener.callCount() is 0)
+ self.assertEqual(modeChangedListener.callCount(), 1)
+ self.assertEqual(manualUpdateListener.callCount(), 0)
self.widget._updatePB.click()
self.widget._updatePB.click()
- self.assertTrue(manualUpdateListener.callCount() is 2)
+ self.assertEqual(manualUpdateListener.callCount(), 2)
self.widget._autoRB.setChecked(True)
- self.assertTrue(modeChangedListener.callCount() is 2)
+ self.assertEqual(modeChangedListener.callCount(), 2)
self.widget._updatePB.click()
- self.assertTrue(manualUpdateListener.callCount() is 2)
+ self.assertEqual(manualUpdateListener.callCount(), 2)
def suite():
diff --git a/silx/gui/plot/tools/CurveLegendsWidget.py b/silx/gui/plot/tools/CurveLegendsWidget.py
index 7b63b29..4a517dd 100644
--- a/silx/gui/plot/tools/CurveLegendsWidget.py
+++ b/silx/gui/plot/tools/CurveLegendsWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2020 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
@@ -62,7 +62,7 @@ class _LegendWidget(qt.QWidget):
icon = _LegendIcon(curve=curve)
layout.addWidget(icon)
- label = qt.QLabel(curve.getLegend())
+ label = qt.QLabel(curve.getName())
label.setAlignment(qt.Qt.AlignLeft | qt.Qt.AlignVCenter)
layout.addWidget(label)
@@ -184,12 +184,12 @@ class CurveLegendsWidget(qt.QWidget):
def _itemAdded(self, item):
"""Handle item added to the plot content"""
if isinstance(item, items.Curve):
- self._addLegend(item.getLegend())
+ self._addLegend(item.getName())
def _itemRemoved(self, item):
"""Handle item removed from the plot content"""
if isinstance(item, items.Curve):
- self._removeLegend(item.getLegend())
+ self._removeLegend(item.getName())
def _addLegend(self, legend):
"""Add a curve to the legends
diff --git a/silx/gui/plot/tools/PositionInfo.py b/silx/gui/plot/tools/PositionInfo.py
index fef11dd..4b63cdb 100644
--- a/silx/gui/plot/tools/PositionInfo.py
+++ b/silx/gui/plot/tools/PositionInfo.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -212,10 +212,11 @@ class PositionInfo(qt.QWidget):
else:
kinds = []
if snappingMode & self.SNAPPING_CURVE:
- kinds.append('curve')
+ kinds.append(items.Curve)
if snappingMode & self.SNAPPING_SCATTER:
- kinds.append('scatter')
- selectedItems = plot._getItems(kind=kinds)
+ kinds.append(items.Scatter)
+ selectedItems = [item for item in plot.getItems()
+ if isinstance(item, kinds) and item.isVisible()]
# Compute distance threshold
if qt.BINDING in ('PyQt5', 'PySide2'):
diff --git a/silx/gui/plot/tools/profile/ScatterProfileToolBar.py b/silx/gui/plot/tools/profile/ScatterProfileToolBar.py
index 0d30651..44187ef 100644
--- a/silx/gui/plot/tools/profile/ScatterProfileToolBar.py
+++ b/silx/gui/plot/tools/profile/ScatterProfileToolBar.py
@@ -30,20 +30,11 @@ __license__ = "MIT"
__date__ = "28/06/2018"
-import logging
-import weakref
+from silx.utils import deprecation
+from . import toolbar
-import numpy
-from ._BaseProfileToolBar import _BaseProfileToolBar
-from ... import items
-from ....utils.concurrent import submitToQtMainThread
-
-
-_logger = logging.getLogger(__name__)
-
-
-class ScatterProfileToolBar(_BaseProfileToolBar):
+class ScatterProfileToolBar(toolbar.ProfileToolBar):
"""QToolBar providing scatter plot profiling tools
:param parent: See :class:`QToolBar`.
@@ -51,157 +42,13 @@ class ScatterProfileToolBar(_BaseProfileToolBar):
:param str title: See :class:`QToolBar`.
"""
- def __init__(self, parent=None, plot=None, title='Scatter Profile'):
- super(ScatterProfileToolBar, self).__init__(parent, plot, title)
-
- self.__nPoints = 1024
- self.__scatterRef = None
- self.__futureInterpolator = None
-
- plot = self.getPlotWidget()
- if plot is not None:
- self._setScatterItem(plot._getActiveItem(kind='scatter'))
- plot.sigActiveScatterChanged.connect(self.__activeScatterChanged)
-
- def __activeScatterChanged(self, previous, legend):
- """Handle change of active scatter
-
- :param Union[str,None] previous:
- :param Union[str,None] legend:
- """
- plot = self.getPlotWidget()
- if plot is None or legend is None:
- scatter = None
- else:
- scatter = plot.getScatter(legend)
- self._setScatterItem(scatter)
-
- def _getScatterItem(self):
- """Returns the scatter item currently handled by this tool.
-
- :rtype: ~silx.gui.plot.items.Scatter
- """
- return None if self.__scatterRef is None else self.__scatterRef()
-
- def _setScatterItem(self, scatter):
- """Set the scatter tracked by this tool
-
- :param Union[None,silx.gui.plot.items.Scatter] scatter:
- """
- self.__futureInterpolator = None # Reset currently expected future
-
- previousScatter = self._getScatterItem()
- if previousScatter is not None:
- previousScatter.sigItemChanged.disconnect(
- self.__scatterItemChanged)
-
- if scatter is None:
- self.__scatterRef = None
- else:
- self.__scatterRef = weakref.ref(scatter)
- scatter.sigItemChanged.connect(self.__scatterItemChanged)
-
- # Refresh profile
- self.updateProfile()
-
- def __scatterItemChanged(self, event):
- """Handle update of active scatter plot item
-
- :param ItemChangedType event:
- """
- if event == items.ItemChangedType.DATA:
- self.updateProfile() # Refresh profile
-
- def hasPendingOperations(self):
- """Returns True if waiting for an interpolator to be ready
-
- :rtype: bool
- """
- return (self.__futureInterpolator is not None and
- not self.__futureInterpolator.done())
-
- # Number of points
-
- def getNPoints(self):
- """Returns the number of points of the profiles
-
- :rtype: int
- """
- return self.__nPoints
-
- def setNPoints(self, npoints):
- """Set the number of points of the profiles
-
- :param int npoints:
- """
- npoints = int(npoints)
- if npoints < 1:
- raise ValueError("Unsupported number of points: %d" % npoints)
- elif npoints != self.__nPoints:
- self.__nPoints = npoints
- self.updateProfile()
-
- # Overridden methods
-
- def computeProfileTitle(self, x0, y0, x1, y1):
- """Compute corresponding plot title
-
- :param float x0: Profile start point X coord
- :param float y0: Profile start point Y coord
- :param float x1: Profile end point X coord
- :param float y1: Profile end point Y coord
- :return: Title to use
- :rtype: str
- """
- if self.hasPendingOperations():
- return 'Pre-processing data...'
- else:
- return super(ScatterProfileToolBar, self).computeProfileTitle(
- x0, y0, x1, y1)
-
- def __futureDone(self, future):
- """Handle completion of the interpolator creation"""
- if future is self.__futureInterpolator:
- # Only handle future callbacks for the current one
- submitToQtMainThread(self.updateProfile)
-
- def computeProfile(self, x0, y0, x1, y1):
- """Compute corresponding profile
-
- :param float x0: Profile start point X coord
- :param float y0: Profile start point Y coord
- :param float x1: Profile end point X coord
- :param float y1: Profile end point Y coord
- :return: (points, values) profile data or None
- """
- scatter = self._getScatterItem()
- if scatter is None or self.hasPendingOperations():
- return None
-
- # Lazy async request of the interpolator
- future = scatter._getInterpolator()
- if future is not self.__futureInterpolator:
- # First time we request this interpolator
- self.__futureInterpolator = future
- if not future.done():
- future.add_done_callback(self.__futureDone)
- return None
-
- if future.cancelled() or future.exception() is not None:
- return None # Something went wrong
-
- interpolator = future.result()
- if interpolator is None:
- return None # Cannot init an interpolator
-
- nPoints = self.getNPoints()
- points = numpy.transpose((
- numpy.linspace(x0, x1, nPoints, endpoint=True),
- numpy.linspace(y0, y1, nPoints, endpoint=True)))
-
- values = interpolator(points)
-
- if not numpy.any(numpy.isfinite(values)):
- return None # Profile outside convex hull
-
- return points, values
+ def __init__(self, parent=None, plot=None, title=None):
+ super(ScatterProfileToolBar, self).__init__(parent, plot)
+ if title is not None:
+ deprecation.deprecated_warning("Attribute",
+ name="title",
+ reason="removed",
+ since_version="0.13.0",
+ only_once=True,
+ skip_backtrace_count=1)
+ self.setScheme("scatter")
diff --git a/silx/gui/plot/tools/profile/_BaseProfileToolBar.py b/silx/gui/plot/tools/profile/_BaseProfileToolBar.py
deleted file mode 100644
index 75bb4c6..0000000
--- a/silx/gui/plot/tools/profile/_BaseProfileToolBar.py
+++ /dev/null
@@ -1,430 +0,0 @@
-# coding: utf-8
-# /*##########################################################################
-#
-# Copyright (c) 2018-2019 European Synchrotron Radiation Facility
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-#
-# ###########################################################################*/
-"""This module provides the base class for profile toolbars."""
-
-__authors__ = ["T. Vincent"]
-__license__ = "MIT"
-__date__ = "28/06/2018"
-
-
-import logging
-import weakref
-
-import numpy
-
-from silx.utils.weakref import WeakMethodProxy
-from silx.gui import qt, icons, colors
-from silx.gui.plot import PlotWidget, items
-from silx.gui.plot.ProfileMainWindow import ProfileMainWindow
-from silx.gui.plot.tools.roi import RegionOfInterestManager
-from silx.gui.plot.items import roi as roi_items
-
-
-_logger = logging.getLogger(__name__)
-
-
-class _BaseProfileToolBar(qt.QToolBar):
- """Base class for QToolBar plot profiling tools
-
- :param parent: See :class:`QToolBar`.
- :param plot: :class:`~silx.gui.plot.PlotWidget` on which to operate.
- :param str title: See :class:`QToolBar`.
- """
-
- sigProfileChanged = qt.Signal()
- """Signal emitted when the profile has changed"""
-
- def __init__(self, parent=None, plot=None, title=''):
- super(_BaseProfileToolBar, self).__init__(title, parent)
-
- self.__profile = None
- self.__profileTitle = ''
-
- assert isinstance(plot, PlotWidget)
- self._plotRef = weakref.ref(
- plot, WeakMethodProxy(self.__plotDestroyed))
-
- self._profileWindow = None
-
- # Set-up interaction manager
- roiManager = RegionOfInterestManager(plot)
- self._roiManagerRef = weakref.ref(roiManager)
-
- roiManager.sigInteractiveModeFinished.connect(self.__interactionFinished)
- roiManager.sigRoiChanged.connect(self.updateProfile)
- roiManager.sigRoiAdded.connect(self.__roiAdded)
-
- # Add interactive mode actions
- for kind, icon, tooltip in (
- (roi_items.HorizontalLineROI, 'shape-horizontal',
- 'Enables horizontal line profile selection mode'),
- (roi_items.VerticalLineROI, 'shape-vertical',
- 'Enables vertical line profile selection mode'),
- (roi_items.LineROI, 'shape-diagonal',
- 'Enables line profile selection mode')):
- action = roiManager.getInteractionModeAction(kind)
- action.setIcon(icons.getQIcon(icon))
- action.setToolTip(tooltip)
- self.addAction(action)
-
- # Add clear action
- action = qt.QAction(icons.getQIcon('profile-clear'),
- 'Clear Profile', self)
- action.setToolTip('Clear the profile')
- action.setCheckable(False)
- action.triggered.connect(self.clearProfile)
- self.addAction(action)
-
- # Initialize color
- self._color = None
- self.setColor('red')
-
- # Listen to plot limits changed
- plot.getXAxis().sigLimitsChanged.connect(self.updateProfile)
- plot.getYAxis().sigLimitsChanged.connect(self.updateProfile)
-
- # Listen to plot scale
- plot.getXAxis().sigScaleChanged.connect(self.__plotAxisScaleChanged)
- plot.getYAxis().sigScaleChanged.connect(self.__plotAxisScaleChanged)
-
- self.setDefaultProfileWindowEnabled(True)
-
- def getProfilePoints(self, copy=True):
- """Returns the profile sampling points as (x, y) or None
-
- :param bool copy: True to get a copy,
- False to get internal arrays (do not modify)
- :rtype: Union[numpy.ndarray,None]
- """
- if self.__profile is None:
- return None
- else:
- return numpy.array(self.__profile[0], copy=copy)
-
- def getProfileValues(self, copy=True):
- """Returns the values of the profile or None
-
- :param bool copy: True to get a copy,
- False to get internal arrays (do not modify)
- :rtype: Union[numpy.ndarray,None]
- """
- if self.__profile is None:
- return None
- else:
- return numpy.array(self.__profile[1], copy=copy)
-
- def getProfileTitle(self):
- """Returns the profile title
-
- :rtype: str
- """
- return self.__profileTitle
-
- # Handle plot reference
-
- def __plotDestroyed(self, ref):
- """Handle finalization of PlotWidget
-
- :param ref: weakref to the plot
- """
- self._plotRef = None
- self.setEnabled(False) # Profile is pointless
- for action in self.actions(): # TODO useful?
- self.removeAction(action)
-
- def getPlotWidget(self):
- """The :class:`~silx.gui.plot.PlotWidget` associated to the toolbar.
-
- :rtype: Union[~silx.gui.plot.PlotWidget,None]
- """
- return None if self._plotRef is None else self._plotRef()
-
- def _getRoiManager(self):
- """Returns the used ROI manager
-
- :rtype: RegionOfInterestManager
- """
- return self._roiManagerRef()
-
- # Profile Plot
-
- def isDefaultProfileWindowEnabled(self):
- """Returns True if the default floating profile window is used
-
- :rtype: bool
- """
- return self.getDefaultProfileWindow() is not None
-
- def setDefaultProfileWindowEnabled(self, enabled):
- """Set whether to use or not the default floating profile window.
-
- :param bool enabled: True to use, False to disable
- """
- if self.isDefaultProfileWindowEnabled() != enabled:
- if enabled:
- self._profileWindow = ProfileMainWindow(self)
- self._profileWindow.sigClose.connect(self.clearProfile)
- self.sigProfileChanged.connect(self.__updateDefaultProfilePlot)
-
- else:
- self.sigProfileChanged.disconnect(self.__updateDefaultProfilePlot)
- self._profileWindow.sigClose.disconnect(self.clearProfile)
- self._profileWindow.close()
- self._profileWindow = None
-
- def getDefaultProfileWindow(self):
- """Returns the default floating profile window if in use else None.
-
- See :meth:`isDefaultProfileWindowEnabled`
-
- :rtype: Union[ProfileMainWindow,None]
- """
- return self._profileWindow
-
- def __updateDefaultProfilePlot(self):
- """Update the plot of the default profile window"""
- profileWindow = self.getDefaultProfileWindow()
- if profileWindow is None:
- return
-
- profilePlot = profileWindow.getPlot()
- if profilePlot is None:
- return
-
- profilePlot.clear()
- profilePlot.setGraphTitle(self.getProfileTitle())
-
- points = self.getProfilePoints(copy=False)
- values = self.getProfileValues(copy=False)
-
- if points is not None and values is not None:
- if (numpy.abs(points[-1, 0] - points[0, 0]) >
- numpy.abs(points[-1, 1] - points[0, 1])):
- xProfile = points[:, 0]
- profilePlot.getXAxis().setLabel('X')
- else:
- xProfile = points[:, 1]
- profilePlot.getXAxis().setLabel('Y')
-
- profilePlot.addCurve(
- xProfile, values, legend='Profile', color=self._color)
-
- self._showDefaultProfileWindow()
-
- def _showDefaultProfileWindow(self):
- """If profile window was created by this toolbar,
- try to avoid overlapping with the toolbar's parent window.
- """
- profileWindow = self.getDefaultProfileWindow()
- roiManager = self._getRoiManager()
- if profileWindow is None or roiManager is None:
- return
-
- if roiManager.isStarted() and not profileWindow.isVisible():
- profileWindow.show()
- profileWindow.raise_()
-
- window = self.window()
- winGeom = window.frameGeometry()
- qapp = qt.QApplication.instance()
- desktop = qapp.desktop()
- screenGeom = desktop.availableGeometry(self)
- spaceOnLeftSide = winGeom.left()
- spaceOnRightSide = screenGeom.width() - winGeom.right()
-
- frameGeometry = profileWindow.frameGeometry()
- profileWindowWidth = frameGeometry.width()
- if profileWindowWidth < spaceOnRightSide:
- # Place profile on the right
- profileWindow.move(winGeom.right(), winGeom.top())
- elif profileWindowWidth < spaceOnLeftSide:
- # Place profile on the left
- profileWindow.move(
- max(0, winGeom.left() - profileWindowWidth), winGeom.top())
-
- # Handle plot in log scale
-
- def __plotAxisScaleChanged(self, scale):
- """Handle change of axis scale in the plot widget"""
- plot = self.getPlotWidget()
- if plot is None:
- return
-
- xScale = plot.getXAxis().getScale()
- yScale = plot.getYAxis().getScale()
-
- if xScale == items.Axis.LINEAR and yScale == items.Axis.LINEAR:
- self.setEnabled(True)
-
- else:
- roiManager = self._getRoiManager()
- if roiManager is not None:
- roiManager.stop() # Stop interactive mode
-
- self.clearProfile()
- self.setEnabled(False)
-
- # Profile color
-
- def getColor(self):
- """Returns the color used for the profile and ROI
-
- :rtype: QColor
- """
- return qt.QColor.fromRgbF(*self._color)
-
- def setColor(self, color):
- """Set the color to use for ROI and profile.
-
- :param color:
- Either a color name, a QColor, a list of uint8 or float in [0, 1].
- """
- self._color = colors.rgba(color)
- roiManager = self._getRoiManager()
- if roiManager is not None:
- roiManager.setColor(self._color)
- for roi in roiManager.getRois():
- roi.setColor(self._color)
- self.updateProfile()
-
- # Handle ROI manager
-
- def __interactionFinished(self):
- """Handle end of interactive mode"""
- self.clearProfile()
-
- profileWindow = self.getDefaultProfileWindow()
- if profileWindow is not None:
- profileWindow.hide()
-
- def __roiAdded(self, roi):
- """Handle new ROI"""
- roi.setName('Profile')
- roi.setEditable(True)
-
- # Remove any other ROI
- roiManager = self._getRoiManager()
- if roiManager is not None:
- for regionOfInterest in list(roiManager.getRois()):
- if regionOfInterest is not roi:
- roiManager.removeRoi(regionOfInterest)
-
- def computeProfile(self, x0, y0, x1, y1):
- """Compute corresponding profile
-
- Override in subclass to compute profile
-
- :param float x0: Profile start point X coord
- :param float y0: Profile start point Y coord
- :param float x1: Profile end point X coord
- :param float y1: Profile end point Y coord
- :return: (points, values) profile data or None
- """
- return None
-
- def computeProfileTitle(self, x0, y0, x1, y1):
- """Compute corresponding plot title
-
- This can be overridden to change title behavior.
-
- :param float x0: Profile start point X coord
- :param float y0: Profile start point Y coord
- :param float x1: Profile end point X coord
- :param float y1: Profile end point Y coord
- :return: Title to use
- :rtype: str
- """
- if x0 == x1:
- title = 'X = %g; Y = [%g, %g]' % (x0, y0, y1)
- elif y0 == y1:
- title = 'Y = %g; X = [%g, %g]' % (y0, x0, x1)
- else:
- m = (y1 - y0) / (x1 - x0)
- b = y0 - m * x0
- title = 'Y = %g * X %+g' % (m, b)
-
- return title
-
- def updateProfile(self):
- """Update profile according to current ROI"""
- roiManager = self._getRoiManager()
- if roiManager is None:
- roi = None
- else:
- rois = roiManager.getRois()
- roi = None if len(rois) == 0 else rois[0]
-
- if roi is None:
- self._setProfile(profile=None, title='')
- return
-
- # Get end points
- if isinstance(roi, roi_items.LineROI):
- points = roi.getEndPoints()
- x0, y0 = points[0]
- x1, y1 = points[1]
- elif isinstance(roi, (roi_items.VerticalLineROI, roi_items.HorizontalLineROI)):
- plot = self.getPlotWidget()
- if plot is None:
- self._setProfile(profile=None, title='')
- return
-
- elif isinstance(roi, roi_items.HorizontalLineROI):
- x0, x1 = plot.getXAxis().getLimits()
- y0 = y1 = roi.getPosition()
-
- elif isinstance(roi, roi_items.VerticalLineROI):
- x0 = x1 = roi.getPosition()
- y0, y1 = plot.getYAxis().getLimits()
-
- else:
- raise RuntimeError('Unsupported ROI for profile: {}'.format(roi.__class__))
-
- if x1 < x0 or (x1 == x0 and y1 < y0):
- # Invert points
- x0, y0, x1, y1 = x1, y1, x0, y0
-
- profile = self.computeProfile(x0, y0, x1, y1)
- title = self.computeProfileTitle(x0, y0, x1, y1)
- self._setProfile(profile=profile, title=title)
-
- def _setProfile(self, profile=None, title=''):
- """Set profile data and emit signal.
-
- :param profile: points and profile values
- :param str title:
- """
- self.__profile = profile
- self.__profileTitle = title
-
- self.sigProfileChanged.emit()
-
- def clearProfile(self):
- """Clear the current line ROI and associated profile"""
- roiManager = self._getRoiManager()
- if roiManager is not None:
- roiManager.clear()
-
- self._setProfile(profile=None, title='')
diff --git a/silx/gui/plot/tools/profile/core.py b/silx/gui/plot/tools/profile/core.py
new file mode 100644
index 0000000..1f883dc
--- /dev/null
+++ b/silx/gui/plot/tools/profile/core.py
@@ -0,0 +1,522 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018-2020 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module define core objects for profile tools.
+"""
+
+__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel", "H. Payno", "V. Valls"]
+__license__ = "MIT"
+__date__ = "17/04/2020"
+
+import collections
+import numpy
+import weakref
+
+from silx.image.bilinear import BilinearImage
+from silx.gui import qt
+
+
+CurveProfileData = collections.namedtuple(
+ 'CurveProfileData', [
+ "coords",
+ "profile",
+ "title",
+ "xLabel",
+ "yLabel",
+ ])
+
+RgbaProfileData = collections.namedtuple(
+ 'RgbaProfileData', [
+ "coords",
+ "profile",
+ "profile_r",
+ "profile_g",
+ "profile_b",
+ "profile_a",
+ "title",
+ "xLabel",
+ "yLabel",
+ ])
+
+ImageProfileData = collections.namedtuple(
+ 'ImageProfileData', [
+ 'coords',
+ 'profile',
+ 'title',
+ 'xLabel',
+ 'yLabel',
+ 'colormap',
+ ])
+
+
+class ProfileRoiMixIn:
+ """Base mix-in for ROI which can be used to select a profile.
+
+ This mix-in have to be applied to a :class:`~silx.gui.plot.items.roi.RegionOfInterest`
+ in order to be usable by a :class:`~silx.gui.plot.tools.profile.manager.ProfileManager`.
+ """
+
+ ITEM_KIND = None
+ """Define the plot item which can be used with this profile ROI"""
+
+ sigProfilePropertyChanged = qt.Signal()
+ """Emitted when a property of this profile have changed"""
+
+ sigPlotItemChanged = qt.Signal()
+ """Emitted when the plot item linked to this profile have changed"""
+
+ def __init__(self, parent=None):
+ self.__profileWindow = None
+ self.__profileManager = None
+ self.__plotItem = None
+ self.setName("Profile")
+ self.setEditable(True)
+ self.setSelectable(True)
+
+ def invalidateProfile(self):
+ """Must be called by the implementation when the profile have to be
+ recomputed."""
+ profileManager = self.getProfileManager()
+ if profileManager is not None:
+ profileManager.requestUpdateProfile(self)
+
+ def invalidateProperties(self):
+ """Must be called when a property of the profile have changed."""
+ self.sigProfilePropertyChanged.emit()
+
+ def _setPlotItem(self, plotItem):
+ """Specify the plot item to use with this profile
+
+ :param `~silx.gui.plot.items.item.Item` plotItem: A plot item
+ """
+ previousPlotItem = self.getPlotItem()
+ if previousPlotItem is plotItem:
+ return
+ self.__plotItem = weakref.ref(plotItem)
+ self.sigPlotItemChanged.emit()
+
+ def getPlotItem(self):
+ """Returns the plot item used by this profile
+
+ :rtype: `~silx.gui.plot.items.item.Item`
+ """
+ if self.__plotItem is None:
+ return None
+ plotItem = self.__plotItem()
+ if plotItem is None:
+ self.__plotItem = None
+ return plotItem
+
+ def _setProfileManager(self, profileManager):
+ self.__profileManager = profileManager
+
+ def getProfileManager(self):
+ """
+ Returns the profile manager connected to this ROI.
+
+ :rtype: ~silx.gui.plot.tools.profile.manager.ProfileManager
+ """
+ return self.__profileManager
+
+ def getProfileWindow(self):
+ """
+ Returns the windows associated to this ROI, else None.
+
+ :rtype: ProfileWindow
+ """
+ return self.__profileWindow
+
+ def setProfileWindow(self, profileWindow):
+ """
+ Associate a window to this ROI. Can be None.
+
+ :param ProfileWindow profileWindow: A main window
+ to display the profile.
+ """
+ if profileWindow is self.__profileWindow:
+ return
+ if self.__profileWindow is not None:
+ self.__profileWindow.sigClose.disconnect(self.__profileWindowAboutToClose)
+ self.__profileWindow.setRoiProfile(None)
+ self.__profileWindow = profileWindow
+ if self.__profileWindow is not None:
+ self.__profileWindow.sigClose.connect(self.__profileWindowAboutToClose)
+ self.__profileWindow.setRoiProfile(self)
+
+ def __profileWindowAboutToClose(self):
+ profileManager = self.getProfileManager()
+ roiManager = profileManager.getRoiManager()
+ roiManager.removeRoi(self)
+
+ def computeProfile(self, item):
+ """
+ Compute the profile which will be displayed.
+
+ This method is not called from the main Qt thread, but from a thread
+ pool.
+
+ :param ~silx.gui.plot.items.Item item: A plot item
+ :rtype: Union[CurveProfileData,ImageProfileData]
+ """
+ raise NotImplementedError()
+
+
+def _alignedFullProfile(data, origin, scale, position, roiWidth, axis, method):
+ """Get a profile along one axis on a stack of images
+
+ :param numpy.ndarray data: 3D volume (stack of 2D images)
+ The first dimension is the image index.
+ :param origin: Origin of image in plot (ox, oy)
+ :param scale: Scale of image in plot (sx, sy)
+ :param float position: Position of profile line in plot coords
+ on the axis orthogonal to the profile direction.
+ :param int roiWidth: Width of the profile in image pixels.
+ :param int axis: 0 for horizontal profile, 1 for vertical.
+ :param str method: method to compute the profile. Can be 'mean' or 'sum' or
+ 'none'
+ :return: profile image + effective ROI area corners in plot coords
+ """
+ assert axis in (0, 1)
+ assert len(data.shape) == 3
+ assert method in ('mean', 'sum', 'none')
+
+ # Convert from plot to image coords
+ imgPos = int((position - origin[1 - axis]) / scale[1 - axis])
+
+ if axis == 1: # Vertical profile
+ # Transpose image to always do a horizontal profile
+ data = numpy.transpose(data, (0, 2, 1))
+
+ nimages, height, width = data.shape
+
+ roiWidth = min(height, roiWidth) # Clip roi width to image size
+
+ # Get [start, end[ coords of the roi in the data
+ start = int(int(imgPos) + 0.5 - roiWidth / 2.)
+ start = min(max(0, start), height - roiWidth)
+ end = start + roiWidth
+
+ if method == 'none':
+ profile = None
+ else:
+ if start < height and end > 0:
+ if method == 'mean':
+ fct = numpy.mean
+ elif method == 'sum':
+ fct = numpy.sum
+ else:
+ raise ValueError('method not managed')
+ profile = fct(data[:, max(0, start):min(end, height), :], axis=1).astype(numpy.float32)
+ else:
+ profile = numpy.zeros((nimages, width), dtype=numpy.float32)
+
+ # Compute effective ROI in plot coords
+ profileBounds = numpy.array(
+ (0, width, width, 0),
+ dtype=numpy.float32) * scale[axis] + origin[axis]
+ roiBounds = numpy.array(
+ (start, start, end, end),
+ dtype=numpy.float32) * scale[1 - axis] + origin[1 - axis]
+
+ if axis == 0: # Horizontal profile
+ area = profileBounds, roiBounds
+ else: # vertical profile
+ area = roiBounds, profileBounds
+
+ return profile, area
+
+
+def _alignedPartialProfile(data, rowRange, colRange, axis, method):
+ """Mean of a rectangular region (ROI) of a stack of images
+ along a given axis.
+
+ Returned values and all parameters are in image coordinates.
+
+ :param numpy.ndarray data: 3D volume (stack of 2D images)
+ The first dimension is the image index.
+ :param rowRange: [min, max[ of ROI rows (upper bound excluded).
+ :type rowRange: 2-tuple of int (min, max) with min < max
+ :param colRange: [min, max[ of ROI columns (upper bound excluded).
+ :type colRange: 2-tuple of int (min, max) with min < max
+ :param int axis: The axis along which to take the profile of the ROI.
+ 0: Sum rows along columns.
+ 1: Sum columns along rows.
+ :param str method: method to compute the profile. Can be 'mean' or 'sum'
+ :return: Profile image along the ROI as the mean of the intersection
+ of the ROI and the image.
+ """
+ assert axis in (0, 1)
+ assert len(data.shape) == 3
+ assert rowRange[0] < rowRange[1]
+ assert colRange[0] < colRange[1]
+ assert method in ('mean', 'sum')
+
+ nimages, height, width = data.shape
+
+ # Range aligned with the integration direction
+ profileRange = colRange if axis == 0 else rowRange
+
+ profileLength = abs(profileRange[1] - profileRange[0])
+
+ # Subset of the image to use as intersection of ROI and image
+ rowStart = min(max(0, rowRange[0]), height)
+ rowEnd = min(max(0, rowRange[1]), height)
+ colStart = min(max(0, colRange[0]), width)
+ colEnd = min(max(0, colRange[1]), width)
+
+ if method == 'mean':
+ _fct = numpy.mean
+ elif method == 'sum':
+ _fct = numpy.sum
+ else:
+ raise ValueError('method not managed')
+
+ imgProfile = _fct(data[:, rowStart:rowEnd, colStart:colEnd], axis=axis + 1,
+ dtype=numpy.float32)
+
+ # Profile including out of bound area
+ profile = numpy.zeros((nimages, profileLength), dtype=numpy.float32)
+
+ # Place imgProfile in full profile
+ offset = - min(0, profileRange[0])
+ profile[:, offset:offset + imgProfile.shape[1]] = imgProfile
+
+ return profile
+
+
+def createProfile(roiInfo, currentData, origin, scale, lineWidth, method):
+ """Create the profile line for the the given image.
+
+ :param roiInfo: information about the ROI: start point, end point and
+ type ("X", "Y", "D")
+ :param numpy.ndarray currentData: the 2D image or the 3D stack of images
+ on which we compute the profile.
+ :param origin: (ox, oy) the offset from origin
+ :type origin: 2-tuple of float
+ :param scale: (sx, sy) the scale to use
+ :type scale: 2-tuple of float
+ :param int lineWidth: width of the profile line
+ :param str method: method to compute the profile. Can be 'mean' or 'sum'
+ or 'none': to compute everything except the profile
+ :return: `coords, profile, area, profileName, xLabel`, where:
+ - coords is the X coordinate to use to display the profile
+ - profile is a 2D array of the profiles of the stack of images.
+ For a single image, the profile is a curve, so this parameter
+ has a shape *(1, len(curve))*
+ - area is a tuple of two 1D arrays with 4 values each. They represent
+ the effective ROI area corners in plot coords.
+ - profileName is a string describing the ROI, meant to be used as
+ title of the profile plot
+ - xLabel the label for X in the profile window
+
+ :rtype: tuple(ndarray,ndarray,(ndarray,ndarray),str)
+ """
+ if currentData is None or roiInfo is None or lineWidth is None:
+ raise ValueError("createProfile called with invalide arguments")
+
+ # force 3D data (stack of images)
+ if len(currentData.shape) == 2:
+ currentData3D = currentData.reshape((1,) + currentData.shape)
+ elif len(currentData.shape) == 3:
+ currentData3D = currentData
+
+ roiWidth = max(1, lineWidth)
+ roiStart, roiEnd, lineProjectionMode = roiInfo
+
+ if lineProjectionMode == 'X': # Horizontal profile on the whole image
+ profile, area = _alignedFullProfile(currentData3D,
+ origin, scale,
+ roiStart[1], roiWidth,
+ axis=0,
+ method=method)
+
+ if method == 'none':
+ coords = None
+ else:
+ coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
+ coords = coords * scale[0] + origin[0]
+
+ yMin, yMax = min(area[1]), max(area[1]) - 1
+ if roiWidth <= 1:
+ profileName = '{ylabel} = %g' % yMin
+ else:
+ profileName = '{ylabel} = [%g, %g]' % (yMin, yMax)
+ xLabel = '{xlabel}'
+
+ elif lineProjectionMode == 'Y': # Vertical profile on the whole image
+ profile, area = _alignedFullProfile(currentData3D,
+ origin, scale,
+ roiStart[0], roiWidth,
+ axis=1,
+ method=method)
+
+ if method == 'none':
+ coords = None
+ else:
+ coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
+ coords = coords * scale[1] + origin[1]
+
+ xMin, xMax = min(area[0]), max(area[0]) - 1
+ if roiWidth <= 1:
+ profileName = '{xlabel} = %g' % xMin
+ else:
+ profileName = '{xlabel} = [%g, %g]' % (xMin, xMax)
+ xLabel = '{ylabel}'
+
+ else: # Free line profile
+
+ # Convert start and end points in image coords as (row, col)
+ startPt = ((roiStart[1] - origin[1]) / scale[1],
+ (roiStart[0] - origin[0]) / scale[0])
+ endPt = ((roiEnd[1] - origin[1]) / scale[1],
+ (roiEnd[0] - origin[0]) / scale[0])
+
+ if (int(startPt[0]) == int(endPt[0]) or
+ int(startPt[1]) == int(endPt[1])):
+ # Profile is aligned with one of the axes
+
+ # Convert to int
+ startPt = int(startPt[0]), int(startPt[1])
+ endPt = int(endPt[0]), int(endPt[1])
+
+ # Ensure startPt <= endPt
+ if startPt[0] > endPt[0] or startPt[1] > endPt[1]:
+ startPt, endPt = endPt, startPt
+
+ if startPt[0] == endPt[0]: # Row aligned
+ rowRange = (int(startPt[0] + 0.5 - 0.5 * roiWidth),
+ int(startPt[0] + 0.5 + 0.5 * roiWidth))
+ colRange = startPt[1], endPt[1] + 1
+ if method == 'none':
+ profile = None
+ else:
+ profile = _alignedPartialProfile(currentData3D,
+ rowRange, colRange,
+ axis=0,
+ method=method)
+
+ else: # Column aligned
+ rowRange = startPt[0], endPt[0] + 1
+ colRange = (int(startPt[1] + 0.5 - 0.5 * roiWidth),
+ int(startPt[1] + 0.5 + 0.5 * roiWidth))
+ if method == 'none':
+ profile = None
+ else:
+ profile = _alignedPartialProfile(currentData3D,
+ rowRange, colRange,
+ axis=1,
+ method=method)
+ # Convert ranges to plot coords to draw ROI area
+ area = (
+ numpy.array(
+ (colRange[0], colRange[1], colRange[1], colRange[0]),
+ dtype=numpy.float32) * scale[0] + origin[0],
+ numpy.array(
+ (rowRange[0], rowRange[0], rowRange[1], rowRange[1]),
+ dtype=numpy.float32) * scale[1] + origin[1])
+
+ else: # General case: use bilinear interpolation
+
+ # Ensure startPt <= endPt
+ if (startPt[1] > endPt[1] or (
+ startPt[1] == endPt[1] and startPt[0] > endPt[0])):
+ startPt, endPt = endPt, startPt
+
+ if method == 'none':
+ profile = None
+ else:
+ profile = []
+ for slice_idx in range(currentData3D.shape[0]):
+ bilinear = BilinearImage(currentData3D[slice_idx, :, :])
+
+ profile.append(bilinear.profile_line(
+ (startPt[0] - 0.5, startPt[1] - 0.5),
+ (endPt[0] - 0.5, endPt[1] - 0.5),
+ roiWidth,
+ method=method))
+ profile = numpy.array(profile)
+
+ # Extend ROI with half a pixel on each end, and
+ # Convert back to plot coords (x, y)
+ length = numpy.sqrt((endPt[0] - startPt[0]) ** 2 +
+ (endPt[1] - startPt[1]) ** 2)
+ dRow = (endPt[0] - startPt[0]) / length
+ dCol = (endPt[1] - startPt[1]) / length
+
+ # Extend ROI with half a pixel on each end
+ roiStartPt = startPt[0] - 0.5 * dRow, startPt[1] - 0.5 * dCol
+ roiEndPt = endPt[0] + 0.5 * dRow, endPt[1] + 0.5 * dCol
+
+ # Rotate deltas by 90 degrees to apply line width
+ dRow, dCol = dCol, -dRow
+
+ area = (
+ numpy.array((roiStartPt[1] - 0.5 * roiWidth * dCol,
+ roiStartPt[1] + 0.5 * roiWidth * dCol,
+ roiEndPt[1] + 0.5 * roiWidth * dCol,
+ roiEndPt[1] - 0.5 * roiWidth * dCol),
+ dtype=numpy.float32) * scale[0] + origin[0],
+ numpy.array((roiStartPt[0] - 0.5 * roiWidth * dRow,
+ roiStartPt[0] + 0.5 * roiWidth * dRow,
+ roiEndPt[0] + 0.5 * roiWidth * dRow,
+ roiEndPt[0] - 0.5 * roiWidth * dRow),
+ dtype=numpy.float32) * scale[1] + origin[1])
+
+ # Convert start and end points back to plot coords
+ y0 = startPt[0] * scale[1] + origin[1]
+ x0 = startPt[1] * scale[0] + origin[0]
+ y1 = endPt[0] * scale[1] + origin[1]
+ x1 = endPt[1] * scale[0] + origin[0]
+
+ if startPt[1] == endPt[1]:
+ profileName = '{xlabel} = %g; {ylabel} = [%g, %g]' % (x0, y0, y1)
+ if method == 'none':
+ coords = None
+ else:
+ coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
+ coords = coords * scale[1] + y0
+ xLabel = '{ylabel}'
+
+ elif startPt[0] == endPt[0]:
+ profileName = '{ylabel} = %g; {xlabel} = [%g, %g]' % (y0, x0, x1)
+ if method == 'none':
+ coords = None
+ else:
+ coords = numpy.arange(len(profile[0]), dtype=numpy.float32)
+ coords = coords * scale[0] + x0
+ xLabel = '{xlabel}'
+
+ else:
+ m = (y1 - y0) / (x1 - x0)
+ b = y0 - m * x0
+ profileName = '{ylabel} = %g * {xlabel} %+g' % (m, b)
+ if method == 'none':
+ coords = None
+ else:
+ coords = numpy.linspace(x0, x1, len(profile[0]),
+ endpoint=True,
+ dtype=numpy.float32)
+ xLabel = '{xlabel}'
+
+ return coords, profile, area, profileName, xLabel
diff --git a/silx/gui/plot/tools/profile/editors.py b/silx/gui/plot/tools/profile/editors.py
new file mode 100644
index 0000000..80e0452
--- /dev/null
+++ b/silx/gui/plot/tools/profile/editors.py
@@ -0,0 +1,307 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018-2020 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 editors which are used to custom profile ROI properties.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+import logging
+
+from silx.gui import qt
+
+from silx.gui.utils import blockSignals
+from silx.gui.plot.PlotToolButtons import ProfileOptionToolButton
+from silx.gui.plot.PlotToolButtons import ProfileToolButton
+from . import rois
+from . import core
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _NoProfileRoiEditor(qt.QWidget):
+
+ sigDataCommited = qt.Signal()
+
+ def setEditorData(self, roi):
+ pass
+
+ def setRoiData(self, roi):
+ pass
+
+
+class _DefaultImageProfileRoiEditor(qt.QWidget):
+
+ sigDataCommited = qt.Signal()
+
+ def __init__(self, parent=None):
+ qt.QWidget.__init__(self, parent=parent)
+ layout = qt.QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ self._initLayout(layout)
+
+ def _initLayout(self, layout):
+ self._lineWidth = qt.QSpinBox(self)
+ self._lineWidth.setRange(1, 1000)
+ self._lineWidth.setValue(1)
+ self._lineWidth.valueChanged[int].connect(self._widgetChanged)
+
+ self._methodsButton = ProfileOptionToolButton(parent=self, plot=None)
+ self._methodsButton.sigMethodChanged.connect(self._widgetChanged)
+
+ label = qt.QLabel('W:')
+ label.setToolTip("Line width in pixels")
+ layout.addWidget(label)
+ layout.addWidget(self._lineWidth)
+ layout.addWidget(self._methodsButton)
+
+ def _widgetChanged(self, value=None):
+ self.commitData()
+
+ def commitData(self):
+ self.sigDataCommited.emit()
+
+ def setEditorData(self, roi):
+ with blockSignals(self._lineWidth):
+ self._lineWidth.setValue(roi.getProfileLineWidth())
+ with blockSignals(self._methodsButton):
+ method = roi.getProfileMethod()
+ self._methodsButton.setMethod(method)
+
+ def setRoiData(self, roi):
+ lineWidth = self._lineWidth.value()
+ roi.setProfileLineWidth(lineWidth)
+ method = self._methodsButton.getMethod()
+ roi.setProfileMethod(method)
+
+
+class _DefaultImageStackProfileRoiEditor(_DefaultImageProfileRoiEditor):
+
+ def _initLayout(self, layout):
+ super(_DefaultImageStackProfileRoiEditor, self)._initLayout(layout)
+ self._profileDim = ProfileToolButton(parent=self, plot=None)
+ self._profileDim.sigDimensionChanged.connect(self._widgetChanged)
+ layout.addWidget(self._profileDim)
+
+ def setEditorData(self, roi):
+ super(_DefaultImageStackProfileRoiEditor, self).setEditorData(roi)
+ with blockSignals(self._profileDim):
+ kind = roi.getProfileType()
+ dim = {"1D": 1, "2D": 2}[kind]
+ self._profileDim.setDimension(dim)
+
+ def setRoiData(self, roi):
+ super(_DefaultImageStackProfileRoiEditor, self).setRoiData(roi)
+ dim = self._profileDim.getDimension()
+ kind = {1: "1D", 2: "2D"}[dim]
+ roi.setProfileType(kind)
+
+
+class _DefaultScatterProfileRoiEditor(qt.QWidget):
+
+ sigDataCommited = qt.Signal()
+
+ def __init__(self, parent=None):
+ qt.QWidget.__init__(self, parent=parent)
+
+ self._nPoints = qt.QSpinBox(self)
+ self._nPoints.setRange(1, 9999)
+ self._nPoints.setValue(1024)
+ self._nPoints.valueChanged[int].connect(self.__widgetChanged)
+
+ layout = qt.QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ label = qt.QLabel('Samples:')
+ label.setToolTip("Number of sample points of the profile")
+ layout.addWidget(label)
+ layout.addWidget(self._nPoints)
+
+ def __widgetChanged(self, value=None):
+ self.commitData()
+
+ def commitData(self):
+ self.sigDataCommited.emit()
+
+ def setEditorData(self, roi):
+ with blockSignals(self._nPoints):
+ self._nPoints.setValue(roi.getNPoints())
+
+ def setRoiData(self, roi):
+ nPoints = self._nPoints.value()
+ roi.setNPoints(nPoints)
+
+
+class ProfileRoiEditorAction(qt.QWidgetAction):
+ """
+ Action displaying GUI to edit the selected ROI.
+
+ :param qt.QWidget parent: Parent widget
+ """
+ def __init__(self, parent=None):
+ super(ProfileRoiEditorAction, self).__init__(parent)
+ self.__roiManager = None
+ self.__roi = None
+ self.__inhibiteReentance = None
+
+ def createWidget(self, parent):
+ """Inherit the method to create a new editor"""
+ widget = qt.QWidget(parent)
+ layout = qt.QHBoxLayout(widget)
+ if isinstance(parent, qt.QMenu):
+ margins = layout.contentsMargins()
+ layout.setContentsMargins(margins.left(), 0, margins.right(), 0)
+ else:
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ editorClass = self.getEditorClass(self.__roi)
+ editor = editorClass(parent)
+ editor.setEditorData(self.__roi)
+ self.__setEditor(widget, editor)
+ return widget
+
+ def deleteWidget(self, widget):
+ """Inherit the method to delete an editor"""
+ self.__setEditor(widget, None)
+ return qt.QWidgetAction.deleteWidget(self, widget)
+
+ def _getEditor(self, widget):
+ """Returns the editor contained in the widget holder"""
+ layout = widget.layout()
+ if layout.count() == 0:
+ return None
+ return layout.itemAt(0).widget()
+
+ def setRoiManager(self, roiManager):
+ """
+ Connect this action to a ROI manager.
+
+ :param RegionOfInterestManager roiManager: A ROI manager
+ """
+ if self.__roiManager is roiManager:
+ return
+ if self.__roiManager is not None:
+ self.__roiManager.sigCurrentRoiChanged.disconnect(self.__currentRoiChanged)
+ self.__roiManager = roiManager
+ if self.__roiManager is not None:
+ self.__roiManager.sigCurrentRoiChanged.connect(self.__currentRoiChanged)
+ self.__currentRoiChanged(roiManager.getCurrentRoi())
+
+ def __currentRoiChanged(self, roi):
+ """Handle changes of the selected ROI"""
+ if roi is not None and not isinstance(roi, core.ProfileRoiMixIn):
+ return
+ self.setProfileRoi(roi)
+
+ def setProfileRoi(self, roi):
+ """Set a profile ROI to edit.
+
+ :param ProfileRoiMixIn roi: A profile ROI
+ """
+ if self.__roi is roi:
+ return
+ if self.__roi is not None:
+ self.__roi.sigProfilePropertyChanged.disconnect(self.__roiPropertyChanged)
+ self.__roi = roi
+ if self.__roi is not None:
+ self.__roi.sigProfilePropertyChanged.connect(self.__roiPropertyChanged)
+ self._updateWidgets()
+
+ def getRoiProfile(self):
+ """Returns the edited profile ROI.
+
+ :rtype: ProfileRoiMixIn
+ """
+ return self.__roi
+
+ def __roiPropertyChanged(self):
+ """Handle changes on the property defining the ROI.
+ """
+ self._updateWidgetValues()
+
+ def __setEditor(self, widget, editor):
+ """Set the editor to display.
+
+ :param qt.QWidget editor: The editor to display
+ """
+ previousEditor = self._getEditor(widget)
+ if previousEditor is editor:
+ return
+ layout = widget.layout()
+ if previousEditor is not None:
+ previousEditor.sigDataCommited.disconnect(self._editorDataCommited)
+ layout.removeWidget(previousEditor)
+ previousEditor.deleteLater()
+ if editor is not None:
+ editor.sigDataCommited.connect(self._editorDataCommited)
+ layout.addWidget(editor)
+
+ def getEditorClass(self, roi):
+ """Returns the editor class to use according to the ROI."""
+ if roi is None:
+ editorClass = _NoProfileRoiEditor
+ elif isinstance(roi, (rois._DefaultImageStackProfileRoiMixIn,
+ rois.ProfileImageStackCrossROI)):
+ # Must be done before the default image ROI
+ # Cause ImageStack ROIs inherit from Image ROIs
+ editorClass = _DefaultImageStackProfileRoiEditor
+ elif isinstance(roi, (rois._DefaultImageProfileRoiMixIn,
+ rois.ProfileImageCrossROI)):
+ editorClass = _DefaultImageProfileRoiEditor
+ elif isinstance(roi, (rois._DefaultScatterProfileRoiMixIn,
+ rois.ProfileScatterCrossROI)):
+ editorClass = _DefaultScatterProfileRoiEditor
+ else:
+ # Unsupported
+ editorClass = _NoProfileRoiEditor
+ return editorClass
+
+ def _updateWidgets(self):
+ """Update the kind of editor to display, according to the selected
+ profile ROI."""
+ parent = self.parent()
+ editorClass = self.getEditorClass(self.__roi)
+ for widget in self.createdWidgets():
+ editor = editorClass(parent)
+ editor.setEditorData(self.__roi)
+ self.__setEditor(widget, editor)
+
+ def _updateWidgetValues(self):
+ """Update the content of the displayed editor, according to the
+ selected profile ROI."""
+ for widget in self.createdWidgets():
+ editor = self._getEditor(widget)
+ if self.__inhibiteReentance is editor:
+ continue
+ editor.setEditorData(self.__roi)
+
+ def _editorDataCommited(self):
+ """Handle changes from the editor."""
+ editor = self.sender()
+ if self.__roi is not None:
+ self.__inhibiteReentance = editor
+ editor.setRoiData(self.__roi)
+ self.__inhibiteReentance = None
diff --git a/silx/gui/plot/tools/profile/manager.py b/silx/gui/plot/tools/profile/manager.py
new file mode 100644
index 0000000..4d467f0
--- /dev/null
+++ b/silx/gui/plot/tools/profile/manager.py
@@ -0,0 +1,1059 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018-2020 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 manager to compute and display profiles.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+import logging
+import weakref
+
+from silx.gui import qt
+from silx.gui import colors
+from silx.gui import utils
+
+from silx.utils.weakref import WeakMethodProxy
+from silx.gui import icons
+from silx.gui.plot import PlotWidget
+from silx.gui.plot.tools.roi import RegionOfInterestManager
+from silx.gui.plot.tools.roi import CreateRoiModeAction
+from silx.gui.plot import items
+from silx.gui.qt import silxGlobalThreadPool
+from silx.gui.qt import inspect
+from . import rois
+from . import core
+from . import editors
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _RunnableComputeProfile(qt.QRunnable):
+ """Runner to process profiles
+
+ :param qt.QThreadPool threadPool: The thread which will be used to
+ execute this runner. It is used to update the used signals
+ :param ~silx.gui.plot.items.Item item: Item in which the profile is
+ computed
+ :param ~silx.gui.plot.tools.profile.core.ProfileRoiMixIn roi: ROI
+ defining the profile shape and other characteristics
+ """
+
+ class _Signals(qt.QObject):
+ """Signal holder"""
+ resultReady = qt.Signal(object, object)
+ runnerFinished = qt.Signal(object)
+
+ def __init__(self, threadPool, item, roi):
+ """Constructor
+ """
+ super(_RunnableComputeProfile, self).__init__()
+ self._signals = self._Signals()
+ self._signals.moveToThread(threadPool.thread())
+ self._item = item
+ self._roi = roi
+
+ def autoDelete(self):
+ return False
+
+ def getRoi(self):
+ """Returns the ROI in which the runner will compute a profile.
+
+ :rtype: ~silx.gui.plot.tools.profile.core.ProfileRoiMixIn
+ """
+ return self._roi
+
+ @property
+ def resultReady(self):
+ """Signal emitted when the result of the computation is available.
+
+ This signal provides 2 values: The ROI, and the computation result.
+ """
+ return self._signals.resultReady
+
+ @property
+ def runnerFinished(self):
+ """Signal emitted when runner have finished.
+
+ This signal provides a single value: the runner itself.
+ """
+ return self._signals.runnerFinished
+
+ def run(self):
+ """Process the profile computation.
+ """
+ try:
+ profileData = self._roi.computeProfile(self._item)
+ except Exception:
+ _logger.error("Error while computing profile", exc_info=True)
+ else:
+ self.resultReady.emit(self._roi, profileData)
+ self.runnerFinished.emit(self)
+
+
+class ProfileWindow(qt.QMainWindow):
+ """
+ Display a computed profile.
+
+ The content can be described using :meth:`setRoiProfile` if the source of
+ the profile is a profile ROI, and :meth:`setProfile` for the data content.
+ """
+
+ sigClose = qt.Signal()
+ """Emitted by :meth:`closeEvent` (e.g. when the window is closed
+ through the window manager's close icon)."""
+
+ def __init__(self, parent=None, backend=None):
+ qt.QMainWindow.__init__(self, parent=parent, flags=qt.Qt.Dialog)
+
+ self.setWindowTitle('Profile window')
+ self._plot1D = None
+ self._plot2D = None
+ self._backend = backend
+ self._data = None
+
+ widget = qt.QWidget()
+ self._layout = qt.QStackedLayout(widget)
+ self._layout.setContentsMargins(0, 0, 0, 0)
+ self.setCentralWidget(widget)
+
+ def prepareWidget(self, roi):
+ """Called before the show to prepare the window to use with
+ a specific ROI."""
+ if isinstance(roi, rois._DefaultImageStackProfileRoiMixIn):
+ profileType = roi.getProfileType()
+ else:
+ profileType = "1D"
+ if profileType == "1D":
+ self.getPlot1D()
+ elif profileType == "2D":
+ self.getPlot2D()
+
+ def createPlot1D(self, parent, backend):
+ """Inherit this function to create your own plot to render 1D
+ profiles. The default value is a `Plot1D`.
+
+ :param parent: The parent of this widget or None.
+ :param backend: The backend to use for the plot.
+ See :class:`PlotWidget` for the list of supported backend.
+ :rtype: PlotWidget
+ """
+ # import here to avoid circular import
+ from ...PlotWindow import Plot1D
+ plot = Plot1D(parent=parent, backend=backend)
+ plot.setDataMargins(yMinMargin=0.1, yMaxMargin=0.1)
+ plot.setGraphYLabel('Profile')
+ plot.setGraphXLabel('')
+ return plot
+
+ def createPlot2D(self, parent, backend):
+ """Inherit this function to create your own plot to render 2D
+ profiles. The default value is a `Plot2D`.
+
+ :param parent: The parent of this widget or None.
+ :param backend: The backend to use for the plot.
+ See :class:`PlotWidget` for the list of supported backend.
+ :rtype: PlotWidget
+ """
+ # import here to avoid circular import
+ from ...PlotWindow import Plot2D
+ return Plot2D(parent=parent, backend=backend)
+
+ def getPlot1D(self, init=True):
+ """Return the current plot used to display curves and create it if it
+ does not yet exists and `init` is True. Else returns None."""
+ if not init:
+ return self._plot1D
+ if self._plot1D is None:
+ self._plot1D = self.createPlot1D(self, self._backend)
+ self._layout.addWidget(self._plot1D)
+ return self._plot1D
+
+ def _showPlot1D(self):
+ plot = self.getPlot1D()
+ self._layout.setCurrentWidget(plot)
+
+ def getPlot2D(self, init=True):
+ """Return the current plot used to display image and create it if it
+ does not yet exists and `init` is True. Else returns None."""
+ if not init:
+ return self._plot2D
+ if self._plot2D is None:
+ self._plot2D = self.createPlot2D(parent=self, backend=self._backend)
+ self._layout.addWidget(self._plot2D)
+ return self._plot2D
+
+ def _showPlot2D(self):
+ plot = self.getPlot2D()
+ self._layout.setCurrentWidget(plot)
+
+ def getCurrentPlotWidget(self):
+ return self._layout.currentWidget()
+
+ def closeEvent(self, qCloseEvent):
+ self.sigClose.emit()
+ qCloseEvent.accept()
+
+ def setRoiProfile(self, roi):
+ """Set the profile ROI which it the source of the following data
+ to display.
+
+ :param ProfileRoiMixIn roi: The profile ROI data source
+ """
+ if roi is None:
+ return
+ self.__color = colors.rgba(roi.getColor())
+
+ def _setImageProfile(self, data):
+ """
+ Setup the window to display a new profile data which is represented
+ by an image.
+
+ :param core.ImageProfileData data: Computed data profile
+ """
+ plot = self.getPlot2D()
+
+ plot.clear()
+ plot.setGraphTitle(data.title)
+ plot.getXAxis().setLabel(data.xLabel)
+
+
+ coords = data.coords
+ colormap = data.colormap
+ profileScale = (coords[-1] - coords[0]) / data.profile.shape[1], 1
+ plot.addImage(data.profile,
+ legend="profile",
+ colormap=colormap,
+ origin=(coords[0], 0),
+ scale=profileScale)
+ plot.getYAxis().setLabel("Frame index (depth)")
+
+ self._showPlot2D()
+
+ def _setCurveProfile(self, data):
+ """
+ Setup the window to display a new profile data which is represented
+ by a curve.
+
+ :param core.CurveProfileData data: Computed data profile
+ """
+ plot = self.getPlot1D()
+
+ plot.clear()
+ plot.setGraphTitle(data.title)
+ plot.getXAxis().setLabel(data.xLabel)
+ plot.getYAxis().setLabel(data.yLabel)
+
+ plot.addCurve(data.coords,
+ data.profile,
+ legend="level",
+ color=self.__color)
+
+ self._showPlot1D()
+
+ def _setRgbaProfile(self, data):
+ """
+ Setup the window to display a new profile data which is represented
+ by a curve.
+
+ :param core.RgbaProfileData data: Computed data profile
+ """
+ plot = self.getPlot1D()
+
+ plot.clear()
+ plot.setGraphTitle(data.title)
+ plot.getXAxis().setLabel(data.xLabel)
+ plot.getYAxis().setLabel(data.yLabel)
+
+ self._showPlot1D()
+
+ plot.addCurve(data.coords, data.profile,
+ legend="level", color="black")
+ plot.addCurve(data.coords, data.profile_r,
+ legend="red", color="red")
+ plot.addCurve(data.coords, data.profile_g,
+ legend="green", color="green")
+ plot.addCurve(data.coords, data.profile_b,
+ legend="blue", color="blue")
+ if data.profile_a is not None:
+ plot.addCurve(data.coords, data.profile_a, legend="alpha", color="gray")
+
+ def clear(self):
+ """Clear the window profile"""
+ plot = self.getPlot1D(init=False)
+ if plot is not None:
+ plot.clear()
+ plot = self.getPlot2D(init=False)
+ if plot is not None:
+ plot.clear()
+
+ def getProfile(self):
+ """Returns the profile data which is displayed"""
+ return self.__data
+
+ def setProfile(self, data):
+ """
+ Setup the window to display a new profile data.
+
+ This method dispatch the result to a specific method according to the
+ data type.
+
+ :param data: Computed data profile
+ """
+ self.__data = data
+ if data is None:
+ self.clear()
+ elif isinstance(data, core.ImageProfileData):
+ self._setImageProfile(data)
+ elif isinstance(data, core.RgbaProfileData):
+ self._setRgbaProfile(data)
+ elif isinstance(data, core.CurveProfileData):
+ self._setCurveProfile(data)
+ else:
+ raise TypeError("Unsupported type %s" % type(data))
+
+
+class _ClearAction(qt.QAction):
+ """Action to clear the profile manager
+
+ The action is only enabled if something can be cleaned up.
+ """
+
+ def __init__(self, parent, profileManager):
+ super(_ClearAction, self).__init__(parent)
+ self.__profileManager = weakref.ref(profileManager)
+ icon = icons.getQIcon('profile-clear')
+ self.setIcon(icon)
+ self.setText('Clear profile')
+ self.setToolTip('Clear the profiles')
+ self.setCheckable(False)
+ self.setEnabled(False)
+ self.triggered.connect(profileManager.clearProfile)
+ plot = profileManager.getPlotWidget()
+ roiManager = profileManager.getRoiManager()
+ plot.sigInteractiveModeChanged.connect(self.__modeUpdated)
+ roiManager.sigRoiChanged.connect(self.__roiListUpdated)
+
+ def getProfileManager(self):
+ return self.__profileManager()
+
+ def __roiListUpdated(self):
+ self.__update()
+
+ def __modeUpdated(self, source):
+ self.__update()
+
+ def __update(self):
+ profileManager = self.getProfileManager()
+ if profileManager is None:
+ return
+ roiManager = profileManager.getRoiManager()
+ if roiManager is None:
+ return
+ enabled = roiManager.isStarted() or len(roiManager.getRois()) > 0
+ self.setEnabled(enabled)
+
+
+class _StoreLastParamBehavior(qt.QObject):
+ """This object allow to store and restore the properties of the ROI
+ profiles"""
+
+ def __init__(self, parent):
+ assert isinstance(parent, ProfileManager)
+ super(_StoreLastParamBehavior, self).__init__(parent=parent)
+ self.__properties = {}
+ self.__profileRoi = None
+ self.__filter = utils.LockReentrant()
+
+ def _roi(self):
+ """Return the spied ROI"""
+ if self.__profileRoi is None:
+ return None
+ roi = self.__profileRoi()
+ if roi is None:
+ self.__profileRoi = None
+ return roi
+
+ def setProfileRoi(self, roi):
+ """Set a profile ROI to spy.
+
+ :param ProfileRoiMixIn roi: A profile ROI
+ """
+ previousRoi = self._roi()
+ if previousRoi is roi:
+ return
+ if previousRoi is not None:
+ previousRoi.sigProfilePropertyChanged.disconnect(self._profilePropertyChanged)
+ self.__profileRoi = None if roi is None else weakref.ref(roi)
+ if roi is not None:
+ roi.sigProfilePropertyChanged.connect(self._profilePropertyChanged)
+
+ def _profilePropertyChanged(self):
+ """Handle changes on the properties defining the profile ROI.
+ """
+ if self.__filter.locked():
+ return
+ roi = self.sender()
+ self.storeProperties(roi)
+
+ def storeProperties(self, roi):
+ if isinstance(roi, (rois._DefaultImageStackProfileRoiMixIn,
+ rois.ProfileImageStackCrossROI)):
+ self.__properties["method"] = roi.getProfileMethod()
+ self.__properties["line-width"] = roi.getProfileLineWidth()
+ self.__properties["type"] = roi.getProfileType()
+ elif isinstance(roi, (rois._DefaultImageProfileRoiMixIn,
+ rois.ProfileImageCrossROI)):
+ self.__properties["method"] = roi.getProfileMethod()
+ self.__properties["line-width"] = roi.getProfileLineWidth()
+ elif isinstance(roi, (rois._DefaultScatterProfileRoiMixIn,
+ rois.ProfileScatterCrossROI)):
+ self.__properties["npoints"] = roi.getNPoints()
+
+ def restoreProperties(self, roi):
+ with self.__filter:
+ if isinstance(roi, (rois._DefaultImageStackProfileRoiMixIn,
+ rois.ProfileImageStackCrossROI)):
+ value = self.__properties.get("method", None)
+ if value is not None:
+ roi.setProfileMethod(value)
+ value = self.__properties.get("line-width", None)
+ if value is not None:
+ roi.setProfileLineWidth(value)
+ value = self.__properties.get("type", None)
+ if value is not None:
+ roi.setProfileType(value)
+ elif isinstance(roi, (rois._DefaultImageProfileRoiMixIn,
+ rois.ProfileImageCrossROI)):
+ value = self.__properties.get("method", None)
+ if value is not None:
+ roi.setProfileMethod(value)
+ value = self.__properties.get("line-width", None)
+ if value is not None:
+ roi.setProfileLineWidth(value)
+ elif isinstance(roi, (rois._DefaultScatterProfileRoiMixIn,
+ rois.ProfileScatterCrossROI)):
+ value = self.__properties.get("npoints", None)
+ if value is not None:
+ roi.setNPoints(value)
+
+
+class ProfileManager(qt.QObject):
+ """Base class for profile management tools
+
+ :param plot: :class:`~silx.gui.plot.PlotWidget` on which to operate.
+ :param plot: :class:`~silx.gui.plot.tools.roi.RegionOfInterestManager`
+ on which to operate.
+ """
+ def __init__(self, parent=None, plot=None, roiManager=None):
+ super(ProfileManager, self).__init__(parent)
+
+ assert isinstance(plot, PlotWidget)
+ self._plotRef = weakref.ref(
+ plot, WeakMethodProxy(self.__plotDestroyed))
+
+ # Set-up interaction manager
+ if roiManager is None:
+ roiManager = RegionOfInterestManager(plot)
+
+ self._roiManagerRef = weakref.ref(roiManager)
+ self._rois = []
+ self._pendingRunners = []
+ """List of ROIs which have to be updated"""
+
+ self.__reentrantResults = {}
+ """Store reentrant result to avoid to skip some of them
+ cause the implementation uses a QEventLoop."""
+
+ self._profileWindowClass = ProfileWindow
+ """Class used to display the profile results"""
+
+ self._computedProfiles = 0
+ """Statistics for tests"""
+
+ self.__itemTypes = []
+ """Kind of items to use"""
+
+ self.__tracking = False
+ """Is the plot active items are tracked"""
+
+ self.__useColorFromCursor = True
+ """If true, force the ROI color with the colormap marker color"""
+
+ self._item = None
+ """The selected item"""
+
+ self.__singleProfileAtATime = True
+ """When it's true, only a single profile is displayed at a time."""
+
+ self._previousWindowGeometry = []
+
+ self._storeProperties = _StoreLastParamBehavior(self)
+ """If defined the profile properties of the last ROI are reused to the
+ new created ones"""
+
+ # Listen to plot limits changed
+ plot.getXAxis().sigLimitsChanged.connect(self.requestUpdateAllProfile)
+ plot.getYAxis().sigLimitsChanged.connect(self.requestUpdateAllProfile)
+
+ roiManager.sigInteractiveModeFinished.connect(self.__interactionFinished)
+ roiManager.sigInteractiveRoiCreated.connect(self.__roiCreated)
+ roiManager.sigRoiAdded.connect(self.__roiAdded)
+ roiManager.sigRoiAboutToBeRemoved.connect(self.__roiRemoved)
+
+ def setSingleProfile(self, enable):
+ """
+ Enable or disable the single profile mode.
+
+ In single mode, the manager enforce a single ROI at the same
+ time. A new one will remove the previous one.
+
+ If this mode is not enabled, many ROIs can be created, and many
+ profile windows will be displayed.
+ """
+ self.__singleProfileAtATime = enable
+
+ def isSingleProfile(self):
+ """
+ Returns true if the manager is in a single profile mode.
+
+ :rtype: bool
+ """
+ return self.__singleProfileAtATime
+
+ def __interactionFinished(self):
+ """Handle end of interactive mode"""
+ pass
+
+ def __roiAdded(self, roi):
+ """Handle new ROI"""
+ # Filter out non profile ROIs
+ if not isinstance(roi, core.ProfileRoiMixIn):
+ return
+ self.__addProfile(roi)
+
+ def __roiRemoved(self, roi):
+ """Handle removed ROI"""
+ # Filter out non profile ROIs
+ if not isinstance(roi, core.ProfileRoiMixIn):
+ return
+ self.__removeProfile(roi)
+
+ def createProfileAction(self, profileRoiClass, parent=None):
+ """Create an action from a class of ProfileRoi
+
+ :param core.ProfileRoiMixIn profileRoiClass: A class of a profile ROI
+ :param qt.QObject parent: The parent of the created action.
+ :rtype: qt.QAction
+ """
+ if not issubclass(profileRoiClass, core.ProfileRoiMixIn):
+ raise TypeError("Type %s not expected" % type(profileRoiClass))
+ roiManager = self.getRoiManager()
+ action = CreateRoiModeAction(parent, roiManager, profileRoiClass)
+ if hasattr(profileRoiClass, "ICON"):
+ action.setIcon(icons.getQIcon(profileRoiClass.ICON))
+ if hasattr(profileRoiClass, "NAME"):
+ def articulify(word):
+ """Add an an/a article in the front of the word"""
+ first = word[1] if word[0] == 'h' else word[0]
+ if first in "aeiou":
+ return "an " + word
+ return "a " + word
+ action.setText('Define %s' % articulify(profileRoiClass.NAME))
+ action.setToolTip('Enables %s selection mode' % profileRoiClass.NAME)
+ action.setSingleShot(True)
+ return action
+
+ def createClearAction(self, parent):
+ """Create an action to clean up the plot from the profile ROIs.
+
+ :param qt.QObject parent: The parent of the created action.
+ :rtype: qt.QAction
+ """
+ action = _ClearAction(parent, self)
+ return action
+
+ def createImageActions(self, parent):
+ """Create actions designed for image items. This actions created
+ new ROIs.
+
+ :param qt.QObject parent: The parent of the created action.
+ :rtype: List[qt.QAction]
+ """
+ profileClasses = [
+ rois.ProfileImageHorizontalLineROI,
+ rois.ProfileImageVerticalLineROI,
+ rois.ProfileImageLineROI,
+ rois.ProfileImageDirectedLineROI,
+ rois.ProfileImageCrossROI,
+ ]
+ return [self.createProfileAction(pc, parent=parent) for pc in profileClasses]
+
+ def createScatterActions(self, parent):
+ """Create actions designed for scatter items. This actions created
+ new ROIs.
+
+ :param qt.QObject parent: The parent of the created action.
+ :rtype: List[qt.QAction]
+ """
+ profileClasses = [
+ rois.ProfileScatterHorizontalLineROI,
+ rois.ProfileScatterVerticalLineROI,
+ rois.ProfileScatterLineROI,
+ rois.ProfileScatterCrossROI,
+ ]
+ return [self.createProfileAction(pc, parent=parent) for pc in profileClasses]
+
+ def createScatterSliceActions(self, parent):
+ """Create actions designed for regular scatter items. This actions
+ created new ROIs.
+
+ This ROIs was designed to use the input data without interpolation,
+ like you could do with an image.
+
+ :param qt.QObject parent: The parent of the created action.
+ :rtype: List[qt.QAction]
+ """
+ profileClasses = [
+ rois.ProfileScatterHorizontalSliceROI,
+ rois.ProfileScatterVerticalSliceROI,
+ rois.ProfileScatterCrossSliceROI,
+ ]
+ return [self.createProfileAction(pc, parent=parent) for pc in profileClasses]
+
+ def createImageStackActions(self, parent):
+ """Create actions designed for stack image items. This actions
+ created new ROIs.
+
+ This ROIs was designed to create both profile on the displayed image
+ and profile on the full stack (2D result).
+
+ :param qt.QObject parent: The parent of the created action.
+ :rtype: List[qt.QAction]
+ """
+ profileClasses = [
+ rois.ProfileImageStackHorizontalLineROI,
+ rois.ProfileImageStackVerticalLineROI,
+ rois.ProfileImageStackLineROI,
+ rois.ProfileImageStackCrossROI,
+ ]
+ return [self.createProfileAction(pc, parent=parent) for pc in profileClasses]
+
+ def createEditorAction(self, parent):
+ """Create an action containing GUI to edit the selected profile ROI.
+
+ :param qt.QObject parent: The parent of the created action.
+ :rtype: qt.QAction
+ """
+ action = editors.ProfileRoiEditorAction(parent)
+ action.setRoiManager(self.getRoiManager())
+ return action
+
+ def setItemType(self, image=False, scatter=False):
+ """Set the item type to use and select the active one.
+
+ :param bool image: Image item are allowed
+ :param bool scatter: Scatter item are allowed
+ """
+ self.__itemTypes = []
+ plot = self.getPlotWidget()
+ item = None
+ if image:
+ self.__itemTypes.append("image")
+ item = plot.getActiveImage()
+ if scatter:
+ self.__itemTypes.append("scatter")
+ if item is None:
+ item = plot.getActiveScatter()
+ self.setPlotItem(item)
+
+ def setProfileWindowClass(self, profileWindowClass):
+ """Set the class which will be instantiated to display profile result.
+ """
+ self._profileWindowClass = profileWindowClass
+
+ def setActiveItemTracking(self, tracking):
+ """Enable/disable the tracking of the active item of the plot.
+
+ :param bool tracking: Tracking mode
+ """
+ if self.__tracking == tracking:
+ return
+ plot = self.getPlotWidget()
+ if self.__tracking:
+ plot.sigActiveImageChanged.disconnect(self._activeImageChanged)
+ plot.sigActiveScatterChanged.disconnect(self._activeScatterChanged)
+ self.__tracking = tracking
+ if self.__tracking:
+ plot.sigActiveImageChanged.connect(self.__activeImageChanged)
+ plot.sigActiveScatterChanged.connect(self.__activeScatterChanged)
+
+ def setDefaultColorFromCursorColor(self, enabled):
+ """Enabled/disable the use of the colormap cursor color to display the
+ ROIs.
+
+ If set, the manager will update the color of the profile ROIs using the
+ current colormap cursor color from the selected item.
+ """
+ self.__useColorFromCursor = enabled
+
+ def __activeImageChanged(self, previous, legend):
+ """Handle plot item selection"""
+ if "image" in self.__itemTypes:
+ plot = self.getPlotWidget()
+ item = plot.getImage(legend)
+ self.setPlotItem(item)
+
+ def __activeScatterChanged(self, previous, legend):
+ """Handle plot item selection"""
+ if "scatter" in self.__itemTypes:
+ plot = self.getPlotWidget()
+ item = plot.getScatter(legend)
+ self.setPlotItem(item)
+
+ def __roiCreated(self, roi):
+ """Handle ROI creation"""
+ # Filter out non profile ROIs
+ if isinstance(roi, core.ProfileRoiMixIn):
+ if self._storeProperties is not None:
+ # Initialize the properties with the previous ones
+ self._storeProperties.restoreProperties(roi)
+
+ def __addProfile(self, profileRoi):
+ """Add a new ROI to the manager."""
+ if profileRoi.getFocusProxy() is None:
+ if self._storeProperties is not None:
+ # Follow changes on properties
+ self._storeProperties.setProfileRoi(profileRoi)
+ if self.__singleProfileAtATime:
+ # FIXME: It would be good to reuse the windows to avoid blinking
+ self.clearProfile()
+
+ profileRoi._setProfileManager(self)
+ self._updateRoiColor(profileRoi)
+ self._rois.append(profileRoi)
+ self.requestUpdateProfile(profileRoi)
+
+ def __removeProfile(self, profileRoi):
+ """Remove a ROI from the manager."""
+ window = self._disconnectProfileWindow(profileRoi)
+ if window is not None:
+ geometry = window.geometry()
+ self._previousWindowGeometry.append(geometry)
+ self.clearProfileWindow(window)
+ if profileRoi in self._rois:
+ self._rois.remove(profileRoi)
+
+ def _disconnectProfileWindow(self, profileRoi):
+ """Handle profile window close."""
+ window = profileRoi.getProfileWindow()
+ profileRoi.setProfileWindow(None)
+ return window
+
+ def clearProfile(self):
+ """Clear the associated ROI profile"""
+ roiManager = self.getRoiManager()
+ for roi in list(self._rois):
+ if roi.getFocusProxy() is not None:
+ # Skip sub ROIs, it will be removed by their parents
+ continue
+ roiManager.removeRoi(roi)
+
+ if not roiManager.isDrawing():
+ # Clean the selected mode
+ roiManager.stop()
+
+ def hasPendingOperations(self):
+ """Returns true if a thread is still computing or displaying a profile.
+
+ :rtype: bool
+ """
+ return len(self.__reentrantResults) > 0 or len(self._pendingRunners) > 0
+
+ def requestUpdateAllProfile(self):
+ """Request to update the profile of all the managed ROIs.
+ """
+ for roi in self._rois:
+ self.requestUpdateProfile(roi)
+
+ def requestUpdateProfile(self, profileRoi):
+ """Request to update a specific profile ROI.
+
+ :param ~core.ProfileRoiMixIn profileRoi:
+ """
+ if profileRoi.computeProfile is None:
+ return
+ threadPool = silxGlobalThreadPool()
+
+ # Clean up deprecated runners
+ for runner in list(self._pendingRunners):
+ if not inspect.isValid(runner):
+ self._pendingRunners.remove(runner)
+ continue
+ if runner.getRoi() is profileRoi:
+ if threadPool.tryTake(runner):
+ self._pendingRunners.remove(runner)
+
+ item = self.getPlotItem()
+ if item is None or not isinstance(item, profileRoi.ITEM_KIND):
+ # This item is not compatible with this profile
+ profileRoi._setPlotItem(None)
+ profileWindow = profileRoi.getProfileWindow()
+ if profileWindow is not None:
+ profileWindow.setProfile(None)
+ return
+
+ profileRoi._setPlotItem(item)
+ runner = _RunnableComputeProfile(threadPool, item, profileRoi)
+ runner.runnerFinished.connect(self.__cleanUpRunner)
+ runner.resultReady.connect(self.__displayResult)
+ self._pendingRunners.append(runner)
+ threadPool.start(runner)
+
+ def __cleanUpRunner(self, runner):
+ """Remove a thread pool runner from the list of hold tasks.
+
+ Called at the termination of the runner.
+ """
+ if runner in self._pendingRunners:
+ self._pendingRunners.remove(runner)
+
+ def __displayResult(self, roi, profileData):
+ """Display the result of a ROI.
+
+ :param ~core.ProfileRoiMixIn profileRoi: A managed ROI
+ :param ~core.CurveProfileData profileData: Computed data profile
+ """
+ if roi in self.__reentrantResults:
+ # Store the data to process it in the main loop
+ # And not a sub loop created by initProfileWindow
+ # This also remove the duplicated requested
+ self.__reentrantResults[roi] = profileData
+ return
+
+ self.__reentrantResults[roi] = profileData
+ self._computedProfiles = self._computedProfiles + 1
+ window = roi.getProfileWindow()
+ if window is None:
+ plot = self.getPlotWidget()
+ window = self.createProfileWindow(plot, roi)
+ # roi.profileWindow have to be set before initializing the window
+ # Cause the initialization is using QEventLoop
+ roi.setProfileWindow(window)
+ self.initProfileWindow(window, roi)
+ window.show()
+
+ lastData = self.__reentrantResults.pop(roi)
+ window.setProfile(lastData)
+
+ def __plotDestroyed(self, ref):
+ """Handle finalization of PlotWidget
+
+ :param ref: weakref to the plot
+ """
+ self._plotRef = None
+ self._roiManagerRef = None
+ self._pendingRunners = []
+
+ def setPlotItem(self, item):
+ """Set the plot item focused by the profile manager.
+
+ :param ~silx.gui.plot.items.Item item: A plot item
+ """
+ previous = self.getPlotItem()
+ if previous is item:
+ return
+ if item is None:
+ self._item = None
+ else:
+ item.sigItemChanged.connect(self.__itemChanged)
+ self._item = weakref.ref(item)
+ self._updateRoiColors()
+ self.requestUpdateAllProfile()
+
+ def getDefaultColor(self, item):
+ """Returns the default ROI color to use according to the given item.
+
+ :param ~silx.gui.plot.items.item.Item item: AN item
+ :rtype: qt.QColor
+ """
+ color = 'pink'
+ if isinstance(item, items.ColormapMixIn):
+ colormap = item.getColormap()
+ name = colormap.getName()
+ if name is not None:
+ color = colors.cursorColorForColormap(name)
+ color = colors.asQColor(color)
+ return color
+
+ def _updateRoiColors(self):
+ """Update ROI color according to the item selection"""
+ if not self.__useColorFromCursor:
+ return
+ item = self.getPlotItem()
+ color = self.getDefaultColor(item)
+ for roi in self._rois:
+ roi.setColor(color)
+
+ def _updateRoiColor(self, roi):
+ """Update a specific ROI according to the current selected item.
+
+ :param RegionOfInterest roi: The ROI to update
+ """
+ if not self.__useColorFromCursor:
+ return
+ item = self.getPlotItem()
+ color = self.getDefaultColor(item)
+ roi.setColor(color)
+
+ def __itemChanged(self, changeType):
+ """Handle item changes.
+ """
+ if changeType in (items.ItemChangedType.DATA,
+ items.ItemChangedType.POSITION,
+ items.ItemChangedType.SCALE):
+ self.requestUpdateAllProfile()
+ elif changeType == (items.ItemChangedType.COLORMAP):
+ self._updateRoiColors()
+
+ def getPlotItem(self):
+ """Returns the item focused by the profile manager.
+
+ :rtype: ~silx.gui.plot.items.Item
+ """
+ if self._item is None:
+ return None
+ item = self._item()
+ if item is None:
+ self._item = None
+ return item
+
+ def getPlotWidget(self):
+ """The plot associated to the profile manager.
+
+ :rtype: ~silx.gui.plot.PlotWidget
+ """
+ if self._plotRef is None:
+ return None
+ plot = self._plotRef()
+ if plot is None:
+ self._plotRef = None
+ return plot
+
+ def getCurrentRoi(self):
+ """Returns the currently selected ROI, else None.
+
+ :rtype: core.ProfileRoiMixIn
+ """
+ roiManager = self.getRoiManager()
+ if roiManager is None:
+ return None
+ roi = roiManager.getCurrentRoi()
+ if not isinstance(roi, core.ProfileRoiMixIn):
+ return None
+ return roi
+
+ def getRoiManager(self):
+ """Returns the used ROI manager
+
+ :rtype: RegionOfInterestManager
+ """
+ return self._roiManagerRef()
+
+ def createProfileWindow(self, plot, roi):
+ """Create a new profile window.
+
+ :param ~core.ProfileRoiMixIn roi: The plot containing the raw data
+ :param ~core.ProfileRoiMixIn roi: A managed ROI
+ :rtype: ~ProfileWindow
+ """
+ return self._profileWindowClass(plot)
+
+ def initProfileWindow(self, profileWindow, roi):
+ """This function is called just after the profile window creation in
+ order to initialize the window location.
+
+ :param ~ProfileWindow profileWindow:
+ The profile window to initialize.
+ """
+ # Enforce the use of one of the widgets
+ # To have the correct window size
+ profileWindow.prepareWidget(roi)
+ profileWindow.adjustSize()
+
+ # Trick to avoid blinking while retrieving the right window size
+ # Display the window, hide it and wait for some event loops
+ profileWindow.show()
+ profileWindow.hide()
+ eventLoop = qt.QEventLoop(self)
+ for _ in range(10):
+ if not eventLoop.processEvents():
+ break
+
+ profileWindow.show()
+ if len(self._previousWindowGeometry) > 0:
+ geometry = self._previousWindowGeometry.pop()
+ profileWindow.setGeometry(geometry)
+ return
+
+ window = self.getPlotWidget().window()
+ winGeom = window.frameGeometry()
+ qapp = qt.QApplication.instance()
+ desktop = qapp.desktop()
+ screenGeom = desktop.availableGeometry(window)
+ spaceOnLeftSide = winGeom.left()
+ spaceOnRightSide = screenGeom.width() - winGeom.right()
+
+ profileGeom = profileWindow.frameGeometry()
+ profileWidth = profileGeom.width()
+
+ # Align vertically to the center of the window
+ top = winGeom.top() + (winGeom.height() - profileGeom.height()) // 2
+
+ margin = 5
+ if profileWidth < spaceOnRightSide:
+ # Place profile on the right
+ left = winGeom.right() + margin
+ elif profileWidth < spaceOnLeftSide:
+ # Place profile on the left
+ left = max(0, winGeom.left() - profileWidth - margin)
+ else:
+ # Move it as much as possible where there is more space
+ if spaceOnLeftSide > spaceOnRightSide:
+ left = 0
+ else:
+ left = screenGeom.width() - profileGeom.width()
+ profileWindow.move(left, top)
+
+
+ def clearProfileWindow(self, profileWindow):
+ """Called when a profile window is not anymore needed.
+
+ By default the window will be closed. But it can be
+ inherited to change this behavior.
+ """
+ profileWindow.deleteLater()
diff --git a/silx/gui/plot/tools/profile/rois.py b/silx/gui/plot/tools/profile/rois.py
new file mode 100644
index 0000000..b49679c
--- /dev/null
+++ b/silx/gui/plot/tools/profile/rois.py
@@ -0,0 +1,1168 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018-2020 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module define ROIs for profile tools.
+
+.. inheritance-diagram::
+ silx.gui.plot.tools.profile.rois
+ :top-classes: silx.gui.plot.tools.profile.core.ProfileRoiMixIn, silx.gui.plot.items.roi.RegionOfInterest
+ :parts: 1
+ :private-bases:
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "03/04/2020"
+
+import numpy
+import weakref
+from concurrent.futures import CancelledError
+
+from silx.gui import colors
+
+from silx.gui.plot import items
+from silx.gui.plot.items import roi as roi_items
+from . import core
+from silx.gui import utils
+from .....utils.proxy import docstring
+
+
+def _relabelAxes(plot, text):
+ """Relabel {xlabel} and {ylabel} from this text using the corresponding
+ plot axis label. If the axis label is empty, label it with "X" and "Y".
+
+ :rtype: str
+ """
+ xLabel = plot.getXAxis().getLabel()
+ if not xLabel:
+ xLabel = "X"
+ yLabel = plot.getYAxis().getLabel()
+ if not yLabel:
+ yLabel = "Y"
+ return text.format(xlabel=xLabel, ylabel=yLabel)
+
+
+def _lineProfileTitle(x0, y0, x1, y1):
+ """Compute corresponding plot title
+
+ This can be overridden to change title behavior.
+
+ :param float x0: Profile start point X coord
+ :param float y0: Profile start point Y coord
+ :param float x1: Profile end point X coord
+ :param float y1: Profile end point Y coord
+ :return: Title to use
+ :rtype: str
+ """
+ if x0 == x1:
+ title = '{xlabel} = %g; {ylabel} = [%g, %g]' % (x0, y0, y1)
+ elif y0 == y1:
+ title = '{ylabel} = %g; {xlabel} = [%g, %g]' % (y0, x0, x1)
+ else:
+ m = (y1 - y0) / (x1 - x0)
+ b = y0 - m * x0
+ title = '{ylabel} = %g * {xlabel} %+g' % (m, b)
+
+ return title
+
+
+class _ImageProfileArea(items.Shape):
+ """This shape displays the location of pixels used to compute the
+ profile."""
+
+ def __init__(self, parentRoi):
+ items.Shape.__init__(self, "polygon")
+ color = colors.rgba(parentRoi.getColor())
+ self.setColor(color)
+ self.setFill(True)
+ self.setOverlay(True)
+ self.setPoints([[0, 0], [0, 0]]) # Else it segfault
+
+ self.__parentRoi = weakref.ref(parentRoi)
+ parentRoi.sigItemChanged.connect(self._updateAreaProperty)
+ parentRoi.sigRegionChanged.connect(self._updateArea)
+ parentRoi.sigProfilePropertyChanged.connect(self._updateArea)
+ parentRoi.sigPlotItemChanged.connect(self._updateArea)
+
+ def getParentRoi(self):
+ if self.__parentRoi is None:
+ return None
+ parentRoi = self.__parentRoi()
+ if parentRoi is None:
+ self.__parentRoi = None
+ return parentRoi
+
+ def _updateAreaProperty(self, event=None, checkVisibility=True):
+ parentRoi = self.sender()
+ if event == items.ItemChangedType.COLOR:
+ parentRoi._updateItemProperty(event, parentRoi, self)
+ elif event == items.ItemChangedType.VISIBLE:
+ if self.getPlotItem() is not None:
+ parentRoi._updateItemProperty(event, parentRoi, self)
+
+ def _updateArea(self):
+ roi = self.getParentRoi()
+ item = roi.getPlotItem()
+ if item is None:
+ self.setVisible(False)
+ return
+ polygon = self._computePolygon(item)
+ self.setVisible(True)
+ polygon = numpy.array(polygon).T
+ self.setLineStyle("--")
+ self.setPoints(polygon, copy=False)
+
+ def _computePolygon(self, item):
+ if not isinstance(item, items.ImageBase):
+ raise TypeError("Unexpected class %s" % type(item))
+
+ if isinstance(item, items.ImageData):
+ currentData = item.getData(copy=False)
+ elif isinstance(item, items.ImageRgba):
+ rgba = item.getData(copy=False)
+ currentData = rgba[..., 0]
+
+ roi = self.getParentRoi()
+ origin = item.getOrigin()
+ scale = item.getScale()
+ _coords, _profile, area, _profileName, _xLabel = core.createProfile(
+ roiInfo=roi._getRoiInfo(),
+ currentData=currentData,
+ origin=origin,
+ scale=scale,
+ lineWidth=roi.getProfileLineWidth(),
+ method="none")
+ return area
+
+
+class _SliceProfileArea(items.Shape):
+ """This shape displays the location a profile in a scatter.
+
+ Each point used to compute the slice are linked together.
+ """
+
+ def __init__(self, parentRoi):
+ items.Shape.__init__(self, "polygon")
+ color = colors.rgba(parentRoi.getColor())
+ self.setColor(color)
+ self.setFill(True)
+ self.setOverlay(True)
+ self.setPoints([[0, 0], [0, 0]]) # Else it segfault
+
+ self.__parentRoi = weakref.ref(parentRoi)
+ parentRoi.sigItemChanged.connect(self._updateAreaProperty)
+ parentRoi.sigRegionChanged.connect(self._updateArea)
+ parentRoi.sigProfilePropertyChanged.connect(self._updateArea)
+ parentRoi.sigPlotItemChanged.connect(self._updateArea)
+
+ def getParentRoi(self):
+ if self.__parentRoi is None:
+ return None
+ parentRoi = self.__parentRoi()
+ if parentRoi is None:
+ self.__parentRoi = None
+ return parentRoi
+
+ def _updateAreaProperty(self, event=None, checkVisibility=True):
+ parentRoi = self.sender()
+ if event == items.ItemChangedType.COLOR:
+ parentRoi._updateItemProperty(event, parentRoi, self)
+ elif event == items.ItemChangedType.VISIBLE:
+ if self.getPlotItem() is not None:
+ parentRoi._updateItemProperty(event, parentRoi, self)
+
+ def _updateArea(self):
+ roi = self.getParentRoi()
+ item = roi.getPlotItem()
+ if item is None:
+ self.setVisible(False)
+ return
+ polylines = self._computePolylines(roi, item)
+ if polylines is None:
+ self.setVisible(False)
+ return
+ self.setVisible(True)
+ self.setLineStyle("--")
+ self.setPoints(polylines, copy=False)
+
+ def _computePolylines(self, roi, item):
+ slicing = roi._getSlice(item)
+ if slicing is None:
+ return None
+ xx, yy, _values, _xx_error, _yy_error = item.getData(copy=False)
+ xx, yy = xx[slicing], yy[slicing]
+ polylines = numpy.array((xx, yy)).T
+ if len(polylines) == 0:
+ return None
+ return polylines
+
+
+class _DefaultImageProfileRoiMixIn(core.ProfileRoiMixIn):
+ """Provide common behavior for silx default image profile ROI.
+ """
+
+ ITEM_KIND = items.ImageBase
+
+ def __init__(self, parent=None):
+ core.ProfileRoiMixIn.__init__(self, parent=parent)
+ self.__method = "mean"
+ self.__width = 1
+ self.sigRegionChanged.connect(self.__regionChanged)
+ self.sigPlotItemChanged.connect(self.__updateArea)
+ self.__area = _ImageProfileArea(self)
+ self.addItem(self.__area)
+
+ def __regionChanged(self):
+ self.invalidateProfile()
+ self.__updateArea()
+
+ def setProfileMethod(self, method):
+ """
+ :param str method: method to compute the profile. Can be 'mean' or 'sum'
+ """
+ if self.__method == method:
+ return
+ self.__method = method
+ self.invalidateProperties()
+ self.invalidateProfile()
+
+ def getProfileMethod(self):
+ return self.__method
+
+ def setProfileLineWidth(self, width):
+ if self.__width == width:
+ return
+ self.__width = width
+ self.__updateArea()
+ self.invalidateProperties()
+ self.invalidateProfile()
+
+ def getProfileLineWidth(self):
+ return self.__width
+
+ def __updateArea(self):
+ plotItem = self.getPlotItem()
+ if plotItem is None:
+ self.setLineStyle("-")
+ else:
+ self.setLineStyle("--")
+
+ def _getRoiInfo(self):
+ """Wrapper to allow to reuse the previous Profile code.
+
+ It would be good to remove it at one point.
+ """
+ if isinstance(self, roi_items.HorizontalLineROI):
+ lineProjectionMode = 'X'
+ y = self.getPosition()
+ roiStart = (0, y)
+ roiEnd = (1, y)
+ elif isinstance(self, roi_items.VerticalLineROI):
+ lineProjectionMode = 'Y'
+ x = self.getPosition()
+ roiStart = (x, 0)
+ roiEnd = (x, 1)
+ elif isinstance(self, roi_items.LineROI):
+ lineProjectionMode = 'D'
+ roiStart, roiEnd = self.getEndPoints()
+ else:
+ assert False
+
+ return roiStart, roiEnd, lineProjectionMode
+
+ def computeProfile(self, item):
+ if not isinstance(item, items.ImageBase):
+ raise TypeError("Unexpected class %s" % type(item))
+
+ origin = item.getOrigin()
+ scale = item.getScale()
+ method = self.getProfileMethod()
+ lineWidth = self.getProfileLineWidth()
+
+ def createProfile2(currentData):
+ coords, profile, _area, profileName, xLabel = core.createProfile(
+ roiInfo=self._getRoiInfo(),
+ currentData=currentData,
+ origin=origin,
+ scale=scale,
+ lineWidth=lineWidth,
+ method=method)
+ return coords, profile, profileName, xLabel
+
+ if isinstance(item, items.ImageData):
+ currentData = item.getData(copy=False)
+ elif isinstance(item, items.ImageRgba):
+ rgba = item.getData(copy=False)
+ is_uint8 = rgba.dtype.type == numpy.uint8
+ # luminosity
+ if is_uint8:
+ rgba = rgba.astype(numpy.float)
+ currentData = 0.21 * rgba[..., 0] + 0.72 * rgba[..., 1] + 0.07 * rgba[..., 2]
+
+ yLabel = "%s" % str(method).capitalize()
+ coords, profile, title, xLabel = createProfile2(currentData)
+ title = title + "; width = %d" % lineWidth
+
+ # Use the axis names from the original plot
+ profileManager = self.getProfileManager()
+ plot = profileManager.getPlotWidget()
+ title = _relabelAxes(plot, title)
+ xLabel = _relabelAxes(plot, xLabel)
+
+ if isinstance(item, items.ImageRgba):
+ rgba = item.getData(copy=False)
+ _coords, r, _profileName, _xLabel = createProfile2(rgba[..., 0])
+ _coords, g, _profileName, _xLabel = createProfile2(rgba[..., 1])
+ _coords, b, _profileName, _xLabel = createProfile2(rgba[..., 2])
+ if rgba.shape[-1] == 4:
+ _coords, a, _profileName, _xLabel = createProfile2(rgba[..., 3])
+ else:
+ a = [None]
+ data = core.RgbaProfileData(
+ coords=coords,
+ profile=profile[0],
+ profile_r=r[0],
+ profile_g=g[0],
+ profile_b=b[0],
+ profile_a=a[0],
+ title=title,
+ xLabel=xLabel,
+ yLabel=yLabel,
+ )
+ else:
+ data = core.CurveProfileData(
+ coords=coords,
+ profile=profile[0],
+ title=title,
+ xLabel=xLabel,
+ yLabel=yLabel,
+ )
+ return data
+
+
+class ProfileImageHorizontalLineROI(roi_items.HorizontalLineROI,
+ _DefaultImageProfileRoiMixIn):
+ """ROI for an horizontal profile at a location of an image"""
+
+ ICON = 'shape-horizontal'
+ NAME = 'horizontal line profile'
+
+ def __init__(self, parent=None):
+ roi_items.HorizontalLineROI.__init__(self, parent=parent)
+ _DefaultImageProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileImageVerticalLineROI(roi_items.VerticalLineROI,
+ _DefaultImageProfileRoiMixIn):
+ """ROI for a vertical profile at a location of an image"""
+
+ ICON = 'shape-vertical'
+ NAME = 'vertical line profile'
+
+ def __init__(self, parent=None):
+ roi_items.VerticalLineROI.__init__(self, parent=parent)
+ _DefaultImageProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileImageLineROI(roi_items.LineROI,
+ _DefaultImageProfileRoiMixIn):
+ """ROI for an image profile between 2 points.
+
+ The X profile of this ROI is the projecting into one of the x/y axes,
+ using its scale and its orientation.
+ """
+
+ ICON = 'shape-diagonal'
+ NAME = 'line profile'
+
+ def __init__(self, parent=None):
+ roi_items.LineROI.__init__(self, parent=parent)
+ _DefaultImageProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileImageDirectedLineROI(roi_items.LineROI,
+ _DefaultImageProfileRoiMixIn):
+ """ROI for an image profile between 2 points.
+
+ The X profile of the line is displayed projected into the line itself,
+ using its scale and its orientation. It's the distance from the origin.
+ """
+
+ ICON = 'shape-diagonal-directed'
+ NAME = 'directed line profile'
+
+ def __init__(self, parent=None):
+ roi_items.LineROI.__init__(self, parent=parent)
+ _DefaultImageProfileRoiMixIn.__init__(self, parent=parent)
+ self._handleStart.setSymbol('o')
+
+ def computeProfile(self, item):
+ if not isinstance(item, items.ImageBase):
+ raise TypeError("Unexpected class %s" % type(item))
+
+ from silx.image.bilinear import BilinearImage
+
+ origin = item.getOrigin()
+ scale = item.getScale()
+ method = self.getProfileMethod()
+ lineWidth = self.getProfileLineWidth()
+ currentData = item.getData(copy=False)
+
+ roiInfo = self._getRoiInfo()
+ roiStart, roiEnd, _lineProjectionMode = roiInfo
+
+ startPt = ((roiStart[1] - origin[1]) / scale[1],
+ (roiStart[0] - origin[0]) / scale[0])
+ endPt = ((roiEnd[1] - origin[1]) / scale[1],
+ (roiEnd[0] - origin[0]) / scale[0])
+
+ if numpy.array_equal(startPt, endPt):
+ return None
+
+ bilinear = BilinearImage(currentData)
+ profile = bilinear.profile_line(
+ (startPt[0] - 0.5, startPt[1] - 0.5),
+ (endPt[0] - 0.5, endPt[1] - 0.5),
+ lineWidth,
+ method=method)
+
+ # Compute the line size
+ lineSize = numpy.sqrt((roiEnd[1] - roiStart[1])**2 +
+ (roiEnd[0] - roiStart[0])**2)
+ coords = numpy.linspace(0, lineSize, len(profile),
+ endpoint=True,
+ dtype=numpy.float32)
+
+ title = _lineProfileTitle(*roiStart, *roiEnd)
+ title = title + "; width = %d" % lineWidth
+ xLabel = "√({xlabel}²+{ylabel}²)"
+ yLabel = str(method).capitalize()
+
+ # Use the axis names from the original plot
+ profileManager = self.getProfileManager()
+ plot = profileManager.getPlotWidget()
+ xLabel = _relabelAxes(plot, xLabel)
+ title = _relabelAxes(plot, title)
+
+ data = core.CurveProfileData(
+ coords=coords,
+ profile=profile,
+ title=title,
+ xLabel=xLabel,
+ yLabel=yLabel,
+ )
+ return data
+
+
+class _ProfileCrossROI(roi_items.HandleBasedROI, core.ProfileRoiMixIn):
+
+ """ROI to manage a cross of profiles
+
+ It is managed using 2 sub ROIs for vertical and horizontal.
+ """
+
+ _kind = "Cross"
+ """Label for this kind of ROI"""
+
+ _plotShape = "point"
+ """Plot shape which is used for the first interaction"""
+
+ def __init__(self, parent=None):
+ roi_items.HandleBasedROI.__init__(self, parent=parent)
+ core.ProfileRoiMixIn.__init__(self, parent=parent)
+ self.sigRegionChanged.connect(self.__regionChanged)
+ self.sigAboutToBeRemoved.connect(self.__aboutToBeRemoved)
+ self.__position = 0, 0
+ self.__vline = None
+ self.__hline = None
+ self.__handle = self.addHandle()
+ self.__handleLabel = self.addLabelHandle()
+ self.__handleLabel.setText(self.getName())
+ self.__inhibitReentance = utils.LockReentrant()
+ self.computeProfile = None
+ self.sigItemChanged.connect(self.__updateLineProperty)
+
+ # Make sure the marker is over the ROIs
+ self.__handle.setZValue(1)
+ # Create the vline and the hline
+ self._createSubRois()
+
+ @docstring(roi_items.HandleBasedROI)
+ def contains(self, position):
+ roiPos = self.getPosition()
+ return position[0] == roiPos[0] or position[1] == roiPos[1]
+
+ def setFirstShapePoints(self, points):
+ pos = points[0]
+ self.setPosition(pos)
+
+ def getPosition(self):
+ """Returns the position of this ROI
+
+ :rtype: numpy.ndarray
+ """
+ return self.__position
+
+ def setPosition(self, pos):
+ """Set the position of this ROI
+
+ :param numpy.ndarray pos: 2d-coordinate of this point
+ """
+ self.__position = pos
+ with utils.blockSignals(self.__handle):
+ self.__handle.setPosition(*pos)
+ with utils.blockSignals(self.__handleLabel):
+ self.__handleLabel.setPosition(*pos)
+ self.sigRegionChanged.emit()
+
+ def handleDragUpdated(self, handle, origin, previous, current):
+ if handle is self.__handle:
+ self.setPosition(current)
+
+ def __updateLineProperty(self, event=None, checkVisibility=True):
+ if event == items.ItemChangedType.NAME:
+ self.__handleLabel.setText(self.getName())
+ elif event in [items.ItemChangedType.COLOR,
+ items.ItemChangedType.VISIBLE]:
+ lines = []
+ if self.__vline:
+ lines.append(self.__vline)
+ if self.__hline:
+ lines.append(self.__hline)
+ self._updateItemProperty(event, self, lines)
+
+ def _createLines(self, parent):
+ """Inherit this function to return 2 ROI objects for respectivly
+ the horizontal, and the vertical lines."""
+ raise NotImplementedError()
+
+ def _setProfileManager(self, profileManager):
+ core.ProfileRoiMixIn._setProfileManager(self, profileManager)
+ # Connecting the vline and the hline
+ roiManager = profileManager.getRoiManager()
+ roiManager.addRoi(self.__vline)
+ roiManager.addRoi(self.__hline)
+
+ def _createSubRois(self):
+ hline, vline = self._createLines(parent=None)
+ for i, line in enumerate([vline, hline]):
+ line.setPosition(self.__position[i])
+ line.setEditable(True)
+ line.setSelectable(True)
+ line.setFocusProxy(self)
+ line.setName("")
+ self.__vline = vline
+ self.__hline = hline
+ vline.sigAboutToBeRemoved.connect(self.__vlineRemoved)
+ vline.sigRegionChanged.connect(self.__vlineRegionChanged)
+ hline.sigAboutToBeRemoved.connect(self.__hlineRemoved)
+ hline.sigRegionChanged.connect(self.__hlineRegionChanged)
+
+ def _getLines(self):
+ return self.__hline, self.__vline
+
+ def __regionChanged(self):
+ if self.__inhibitReentance.locked():
+ return
+ x, y = self.getPosition()
+ hline, vline = self._getLines()
+ if hline is None:
+ return
+ with self.__inhibitReentance:
+ hline.setPosition(y)
+ vline.setPosition(x)
+
+ def __vlineRegionChanged(self):
+ if self.__inhibitReentance.locked():
+ return
+ pos = self.getPosition()
+ vline = self.__vline
+ pos = vline.getPosition(), pos[1]
+ with self.__inhibitReentance:
+ self.setPosition(pos)
+
+ def __hlineRegionChanged(self):
+ if self.__inhibitReentance.locked():
+ return
+ pos = self.getPosition()
+ hline = self.__hline
+ pos = pos[0], hline.getPosition()
+ with self.__inhibitReentance:
+ self.setPosition(pos)
+
+ def __aboutToBeRemoved(self):
+ vline = self.__vline
+ hline = self.__hline
+ # Avoid side remove signals
+ if hline is not None:
+ hline.sigAboutToBeRemoved.disconnect(self.__hlineRemoved)
+ hline.sigRegionChanged.disconnect(self.__hlineRegionChanged)
+ if vline is not None:
+ vline.sigAboutToBeRemoved.disconnect(self.__vlineRemoved)
+ vline.sigRegionChanged.disconnect(self.__vlineRegionChanged)
+ # Clean up the child
+ profileManager = self.getProfileManager()
+ roiManager = profileManager.getRoiManager()
+ if hline is not None:
+ roiManager.removeRoi(hline)
+ self.__hline = None
+ if vline is not None:
+ roiManager.removeRoi(vline)
+ self.__vline = None
+
+ def __hlineRemoved(self):
+ self.__lineRemoved(isHline=True)
+
+ def __vlineRemoved(self):
+ self.__lineRemoved(isHline=False)
+
+ def __lineRemoved(self, isHline):
+ """If any of the lines is removed: disconnect this objects, and let the
+ other one persist"""
+ hline, vline = self._getLines()
+
+ hline.sigAboutToBeRemoved.disconnect(self.__hlineRemoved)
+ vline.sigAboutToBeRemoved.disconnect(self.__vlineRemoved)
+ hline.sigRegionChanged.disconnect(self.__hlineRegionChanged)
+ vline.sigRegionChanged.disconnect(self.__vlineRegionChanged)
+
+ self.__hline = None
+ self.__vline = None
+ profileManager = self.getProfileManager()
+ roiManager = profileManager.getRoiManager()
+ if isHline:
+ self.__releaseLine(vline)
+ else:
+ self.__releaseLine(hline)
+ roiManager.removeRoi(self)
+
+ def __releaseLine(self, line):
+ """Release the line in order to make it independent"""
+ line.setFocusProxy(None)
+ line.setName(self.getName())
+ line.setEditable(self.isEditable())
+ line.setSelectable(self.isSelectable())
+
+
+class ProfileImageCrossROI(_ProfileCrossROI):
+ """ROI to manage a cross of profiles
+
+ It is managed using 2 sub ROIs for vertical and horizontal.
+ """
+
+ ICON = 'shape-cross'
+ NAME = 'cross profile'
+ ITEM_KIND = items.ImageBase
+
+ def _createLines(self, parent):
+ vline = ProfileImageVerticalLineROI(parent=parent)
+ hline = ProfileImageHorizontalLineROI(parent=parent)
+ return hline, vline
+
+ def setProfileMethod(self, method):
+ """
+ :param str method: method to compute the profile. Can be 'mean' or 'sum'
+ """
+ hline, vline = self._getLines()
+ hline.setProfileMethod(method)
+ vline.setProfileMethod(method)
+ self.invalidateProperties()
+
+ def getProfileMethod(self):
+ hline, _vline = self._getLines()
+ return hline.getProfileMethod()
+
+ def setProfileLineWidth(self, width):
+ hline, vline = self._getLines()
+ hline.setProfileLineWidth(width)
+ vline.setProfileLineWidth(width)
+ self.invalidateProperties()
+
+ def getProfileLineWidth(self):
+ hline, _vline = self._getLines()
+ return hline.getProfileLineWidth()
+
+
+class _DefaultScatterProfileRoiMixIn(core.ProfileRoiMixIn):
+ """Provide common behavior for silx default scatter profile ROI.
+ """
+
+ ITEM_KIND = items.Scatter
+
+ def __init__(self, parent=None):
+ core.ProfileRoiMixIn.__init__(self, parent=parent)
+ self.__nPoints = 1024
+ self.sigRegionChanged.connect(self.__regionChanged)
+
+ def __regionChanged(self):
+ self.invalidateProfile()
+
+ # Number of points
+
+ def getNPoints(self):
+ """Returns the number of points of the profiles
+
+ :rtype: int
+ """
+ return self.__nPoints
+
+ def setNPoints(self, npoints):
+ """Set the number of points of the profiles
+
+ :param int npoints:
+ """
+ npoints = int(npoints)
+ if npoints < 1:
+ raise ValueError("Unsupported number of points: %d" % npoints)
+ elif npoints != self.__nPoints:
+ self.__nPoints = npoints
+ self.invalidateProperties()
+ self.invalidateProfile()
+
+ def _computeProfile(self, scatter, x0, y0, x1, y1):
+ """Compute corresponding profile
+
+ :param float x0: Profile start point X coord
+ :param float y0: Profile start point Y coord
+ :param float x1: Profile end point X coord
+ :param float y1: Profile end point Y coord
+ :return: (points, values) profile data or None
+ """
+ future = scatter._getInterpolator()
+ try:
+ interpolator = future.result()
+ except CancelledError:
+ return None
+ if interpolator is None:
+ return None # Cannot init an interpolator
+
+ nPoints = self.getNPoints()
+ points = numpy.transpose((
+ numpy.linspace(x0, x1, nPoints, endpoint=True),
+ numpy.linspace(y0, y1, nPoints, endpoint=True)))
+
+ values = interpolator(points)
+
+ if not numpy.any(numpy.isfinite(values)):
+ return None # Profile outside convex hull
+
+ return points, values
+
+ def computeProfile(self, item):
+ """Update profile according to current ROI"""
+ if not isinstance(item, items.Scatter):
+ raise TypeError("Unexpected class %s" % type(item))
+
+ # Get end points
+ if isinstance(self, roi_items.LineROI):
+ points = self.getEndPoints()
+ x0, y0 = points[0]
+ x1, y1 = points[1]
+ elif isinstance(self, (roi_items.VerticalLineROI, roi_items.HorizontalLineROI)):
+ profileManager = self.getProfileManager()
+ plot = profileManager.getPlotWidget()
+
+ if isinstance(self, roi_items.HorizontalLineROI):
+ x0, x1 = plot.getXAxis().getLimits()
+ y0 = y1 = self.getPosition()
+
+ elif isinstance(self, roi_items.VerticalLineROI):
+ x0 = x1 = self.getPosition()
+ y0, y1 = plot.getYAxis().getLimits()
+ else:
+ raise RuntimeError('Unsupported ROI for profile: {}'.format(self.__class__))
+
+ if x1 < x0 or (x1 == x0 and y1 < y0):
+ # Invert points
+ x0, y0, x1, y1 = x1, y1, x0, y0
+
+ profile = self._computeProfile(item, x0, y0, x1, y1)
+ if profile is None:
+ return None
+
+ title = _lineProfileTitle(x0, y0, x1, y1)
+ points = profile[0]
+ values = profile[1]
+
+ if (numpy.abs(points[-1, 0] - points[0, 0]) >
+ numpy.abs(points[-1, 1] - points[0, 1])):
+ xProfile = points[:, 0]
+ xLabel = '{xlabel}'
+ else:
+ xProfile = points[:, 1]
+ xLabel = '{ylabel}'
+
+ # Use the axis names from the original
+ profileManager = self.getProfileManager()
+ plot = profileManager.getPlotWidget()
+ title = _relabelAxes(plot, title)
+ xLabel = _relabelAxes(plot, xLabel)
+
+ data = core.CurveProfileData(
+ coords=xProfile,
+ profile=values,
+ title=title,
+ xLabel=xLabel,
+ yLabel='Profile',
+ )
+ return data
+
+
+class ProfileScatterHorizontalLineROI(roi_items.HorizontalLineROI,
+ _DefaultScatterProfileRoiMixIn):
+ """ROI for an horizontal profile at a location of a scatter"""
+
+ ICON = 'shape-horizontal'
+ NAME = 'horizontal line profile'
+
+ def __init__(self, parent=None):
+ roi_items.HorizontalLineROI.__init__(self, parent=parent)
+ _DefaultScatterProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileScatterVerticalLineROI(roi_items.VerticalLineROI,
+ _DefaultScatterProfileRoiMixIn):
+ """ROI for an horizontal profile at a location of a scatter"""
+
+ ICON = 'shape-vertical'
+ NAME = 'vertical line profile'
+
+ def __init__(self, parent=None):
+ roi_items.VerticalLineROI.__init__(self, parent=parent)
+ _DefaultScatterProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileScatterLineROI(roi_items.LineROI,
+ _DefaultScatterProfileRoiMixIn):
+ """ROI for an horizontal profile at a location of a scatter"""
+
+ ICON = 'shape-diagonal'
+ NAME = 'line profile'
+
+ def __init__(self, parent=None):
+ roi_items.LineROI.__init__(self, parent=parent)
+ _DefaultScatterProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileScatterCrossROI(_ProfileCrossROI):
+ """ROI to manage a cross of profiles for scatters.
+ """
+
+ ICON = 'shape-cross'
+ NAME = 'cross profile'
+ ITEM_KIND = items.Scatter
+
+ def _createLines(self, parent):
+ vline = ProfileScatterVerticalLineROI(parent=parent)
+ hline = ProfileScatterHorizontalLineROI(parent=parent)
+ return hline, vline
+
+ def getNPoints(self):
+ """Returns the number of points of the profiles
+
+ :rtype: int
+ """
+ hline, _vline = self._getLines()
+ return hline.getNPoints()
+
+ def setNPoints(self, npoints):
+ """Set the number of points of the profiles
+
+ :param int npoints:
+ """
+ hline, vline = self._getLines()
+ hline.setNPoints(npoints)
+ vline.setNPoints(npoints)
+ self.invalidateProperties()
+
+
+class _DefaultScatterProfileSliceRoiMixIn(core.ProfileRoiMixIn):
+ """Default ROI to allow to slice in the scatter data."""
+
+ ITEM_KIND = items.Scatter
+
+ def __init__(self, parent=None):
+ core.ProfileRoiMixIn.__init__(self, parent=parent)
+ self.__area = _SliceProfileArea(self)
+ self.addItem(self.__area)
+ self.sigRegionChanged.connect(self._regionChanged)
+ self.sigPlotItemChanged.connect(self._updateArea)
+
+ def _regionChanged(self):
+ self.invalidateProfile()
+ self._updateArea()
+
+ def _updateArea(self):
+ plotItem = self.getPlotItem()
+ if plotItem is None:
+ self.setLineStyle("-")
+ else:
+ self.setLineStyle("--")
+
+ def _getSlice(self, item):
+ position = self.getPosition()
+ bounds = item.getCurrentVisualizationParameter(items.Scatter.VisualizationParameter.GRID_BOUNDS)
+ if isinstance(self, roi_items.HorizontalLineROI):
+ axis = 1
+ elif isinstance(self, roi_items.VerticalLineROI):
+ axis = 0
+ else:
+ assert False
+ if position < bounds[0][axis] or position > bounds[1][axis]:
+ # ROI outside of the scatter bound
+ return None
+
+ major_order = item.getCurrentVisualizationParameter(items.Scatter.VisualizationParameter.GRID_MAJOR_ORDER)
+ assert major_order == 'row'
+ max_grid_yy, max_grid_xx = item.getCurrentVisualizationParameter(items.Scatter.VisualizationParameter.GRID_SHAPE)
+
+ xx, yy, _values, _xx_error, _yy_error = item.getData(copy=False)
+ if isinstance(self, roi_items.HorizontalLineROI):
+ axis = yy
+ max_grid_first = max_grid_yy
+ max_grid_second = max_grid_xx
+ major_axis = major_order == 'column'
+ elif isinstance(self, roi_items.VerticalLineROI):
+ axis = xx
+ max_grid_first = max_grid_xx
+ max_grid_second = max_grid_yy
+ major_axis = major_order == 'row'
+ else:
+ assert False
+
+ def argnearest(array, value):
+ array = numpy.abs(array - value)
+ return numpy.argmin(array)
+
+ if major_axis:
+ # slice in the middle of the scatter
+ start = max_grid_second // 2 * max_grid_first
+ vslice = axis[start:start + max_grid_second]
+ index = argnearest(vslice, position)
+ slicing = slice(index, None, max_grid_first)
+ else:
+ # slice in the middle of the scatter
+ vslice = axis[max_grid_second // 2::max_grid_second]
+ index = argnearest(vslice, position)
+ start = index * max_grid_second
+ slicing = slice(start, start + max_grid_second)
+
+ return slicing
+
+ def computeProfile(self, item):
+ if not isinstance(item, items.Scatter):
+ raise TypeError("Unsupported %s item" % type(item))
+
+ slicing = self._getSlice(item)
+ if slicing is None:
+ # ROI out of bounds
+ return None
+
+ _xx, _yy, values, _xx_error, _yy_error = item.getData(copy=False)
+ profile = values[slicing]
+
+ if isinstance(self, roi_items.HorizontalLineROI):
+ title = "Horizontal slice"
+ xLabel = "{xlabel} index"
+ elif isinstance(self, roi_items.VerticalLineROI):
+ title = "Vertical slice"
+ xLabel = "{ylabel} index"
+ else:
+ assert False
+
+ # Use the axis names from the original plot
+ profileManager = self.getProfileManager()
+ plot = profileManager.getPlotWidget()
+ xLabel = _relabelAxes(plot, xLabel)
+
+ data = core.CurveProfileData(
+ coords=numpy.arange(len(profile)),
+ profile=profile,
+ title=title,
+ xLabel=xLabel,
+ yLabel="Profile",
+ )
+ return data
+
+
+class ProfileScatterHorizontalSliceROI(roi_items.HorizontalLineROI,
+ _DefaultScatterProfileSliceRoiMixIn):
+ """ROI for an horizontal profile at a location of a scatter
+ using data slicing.
+ """
+
+ ICON = 'slice-horizontal'
+ NAME = 'horizontal data slice profile'
+
+ def __init__(self, parent=None):
+ roi_items.HorizontalLineROI.__init__(self, parent=parent)
+ _DefaultScatterProfileSliceRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileScatterVerticalSliceROI(roi_items.VerticalLineROI,
+ _DefaultScatterProfileSliceRoiMixIn):
+ """ROI for a vertical profile at a location of a scatter
+ using data slicing.
+ """
+
+ ICON = 'slice-vertical'
+ NAME = 'vertical data slice profile'
+
+ def __init__(self, parent=None):
+ roi_items.VerticalLineROI.__init__(self, parent=parent)
+ _DefaultScatterProfileSliceRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileScatterCrossSliceROI(_ProfileCrossROI):
+ """ROI to manage a cross of slicing profiles on scatters.
+ """
+
+ ICON = 'slice-cross'
+ NAME = 'cross data slice profile'
+ ITEM_KIND = items.Scatter
+
+ def _createLines(self, parent):
+ vline = ProfileScatterVerticalSliceROI(parent=parent)
+ hline = ProfileScatterHorizontalSliceROI(parent=parent)
+ return hline, vline
+
+
+class _DefaultImageStackProfileRoiMixIn(_DefaultImageProfileRoiMixIn):
+
+ ITEM_KIND = items.ImageStack
+
+ def __init__(self, parent=None):
+ super(_DefaultImageStackProfileRoiMixIn, self).__init__(parent=parent)
+ self.__profileType = "1D"
+ """Kind of profile"""
+
+ def getProfileType(self):
+ return self.__profileType
+
+ def setProfileType(self, kind):
+ assert kind in ["1D", "2D"]
+ if self.__profileType == kind:
+ return
+ self.__profileType = kind
+ self.invalidateProperties()
+ self.invalidateProfile()
+
+ def computeProfile(self, item):
+ if not isinstance(item, items.ImageStack):
+ raise TypeError("Unexpected class %s" % type(item))
+
+ kind = self.getProfileType()
+ if kind == "1D":
+ result = _DefaultImageProfileRoiMixIn.computeProfile(self, item)
+ # z = item.getStackPosition()
+ return result
+
+ assert kind == "2D"
+
+ def createProfile2(currentData):
+ coords, profile, _area, profileName, xLabel = core.createProfile(
+ roiInfo=self._getRoiInfo(),
+ currentData=currentData,
+ origin=origin,
+ scale=scale,
+ lineWidth=self.getProfileLineWidth(),
+ method=method)
+ return coords, profile, profileName, xLabel
+
+ currentData = numpy.array(item.getStackData(copy=False))
+ origin = item.getOrigin()
+ scale = item.getScale()
+ colormap = item.getColormap()
+ method = self.getProfileMethod()
+
+ coords, profile, profileName, xLabel = createProfile2(currentData)
+
+ data = core.ImageProfileData(
+ coords=coords,
+ profile=profile,
+ title=profileName,
+ xLabel=xLabel,
+ yLabel="Profile",
+ colormap=colormap,
+ )
+ return data
+
+
+class ProfileImageStackHorizontalLineROI(roi_items.HorizontalLineROI,
+ _DefaultImageStackProfileRoiMixIn):
+ """ROI for an horizontal profile at a location of a stack of images"""
+
+ ICON = 'shape-horizontal'
+ NAME = 'horizontal line profile'
+
+ def __init__(self, parent=None):
+ roi_items.HorizontalLineROI.__init__(self, parent=parent)
+ _DefaultImageStackProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileImageStackVerticalLineROI(roi_items.VerticalLineROI,
+ _DefaultImageStackProfileRoiMixIn):
+ """ROI for an vertical profile at a location of a stack of images"""
+
+ ICON = 'shape-vertical'
+ NAME = 'vertical line profile'
+
+ def __init__(self, parent=None):
+ roi_items.VerticalLineROI.__init__(self, parent=parent)
+ _DefaultImageStackProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileImageStackLineROI(roi_items.LineROI,
+ _DefaultImageStackProfileRoiMixIn):
+ """ROI for an vertical profile at a location of a stack of images"""
+
+ ICON = 'shape-diagonal'
+ NAME = 'line profile'
+
+ def __init__(self, parent=None):
+ roi_items.LineROI.__init__(self, parent=parent)
+ _DefaultImageStackProfileRoiMixIn.__init__(self, parent=parent)
+
+
+class ProfileImageStackCrossROI(ProfileImageCrossROI):
+ """ROI for an vertical profile at a location of a stack of images"""
+
+ ICON = 'shape-cross'
+ NAME = 'cross profile'
+ ITEM_KIND = items.ImageStack
+
+ def _createLines(self, parent):
+ vline = ProfileImageStackVerticalLineROI(parent=parent)
+ hline = ProfileImageStackHorizontalLineROI(parent=parent)
+ return hline, vline
+
+ def getProfileType(self):
+ hline, _vline = self._getLines()
+ return hline.getProfileType()
+
+ def setProfileType(self, kind):
+ hline, vline = self._getLines()
+ hline.setProfileType(kind)
+ vline.setProfileType(kind)
+ self.invalidateProperties()
diff --git a/silx/gui/plot/tools/profile/toolbar.py b/silx/gui/plot/tools/profile/toolbar.py
new file mode 100644
index 0000000..4a9a195
--- /dev/null
+++ b/silx/gui/plot/tools/profile/toolbar.py
@@ -0,0 +1,172 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018-2019 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 tool bar helper.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+
+import logging
+import weakref
+
+from silx.gui import qt
+from silx.gui.widgets.MultiModeAction import MultiModeAction
+from . import manager
+from .. import roi as roi_mdl
+from silx.gui.plot import items
+
+
+_logger = logging.getLogger(__name__)
+
+
+class ProfileToolBar(qt.QToolBar):
+ """Tool bar to provide profile for a plot.
+
+ It is an helper class. For a dedicated application it would be better to
+ use an own tool bar in order in order have more flexibility.
+ """
+ def __init__(self, parent=None, plot=None):
+ super(ProfileToolBar, self).__init__(parent=parent)
+ self.__scheme = None
+ self.__manager = None
+ self.__plot = weakref.ref(plot)
+ self.__multiAction = None
+
+ def getPlotWidget(self):
+ """The :class:`~silx.gui.plot.PlotWidget` associated to the toolbar.
+
+ :rtype: Union[~silx.gui.plot.PlotWidget,None]
+ """
+ if self.__plot is None:
+ return None
+ plot = self.__plot()
+ if self.__plot is None:
+ self.__plot = None
+ return plot
+
+ def setScheme(self, scheme):
+ """Initialize the tool bar using a configuration scheme.
+
+ It have to be done once and only once.
+
+ :param str scheme: One of "scatter", "image", "imagestack"
+ """
+ assert self.__scheme is None
+ self.__scheme = scheme
+
+ plot = self.getPlotWidget()
+ self.__manager = manager.ProfileManager(self, plot)
+
+ if scheme == "image":
+ self.__manager.setItemType(image=True)
+ self.__manager.setActiveItemTracking(True)
+
+ multiAction = MultiModeAction(self)
+ self.addAction(multiAction)
+ for action in self.__manager.createImageActions(self):
+ multiAction.addAction(action)
+ self.__multiAction = multiAction
+
+ cleanAction = self.__manager.createClearAction(self)
+ self.addAction(cleanAction)
+ editorAction = self.__manager.createEditorAction(self)
+ self.addAction(editorAction)
+
+ plot.sigActiveImageChanged.connect(self._activeImageChanged)
+ self._activeImageChanged()
+
+ elif scheme == "scatter":
+ self.__manager.setItemType(scatter=True)
+ self.__manager.setActiveItemTracking(True)
+
+ multiAction = MultiModeAction(self)
+ self.addAction(multiAction)
+ for action in self.__manager.createScatterActions(self):
+ multiAction.addAction(action)
+ for action in self.__manager.createScatterSliceActions(self):
+ multiAction.addAction(action)
+ self.__multiAction = multiAction
+
+ cleanAction = self.__manager.createClearAction(self)
+ self.addAction(cleanAction)
+ editorAction = self.__manager.createEditorAction(self)
+ self.addAction(editorAction)
+
+ plot.sigActiveScatterChanged.connect(self._activeScatterChanged)
+ self._activeScatterChanged()
+
+ elif scheme == "imagestack":
+ self.__manager.setItemType(image=True)
+ self.__manager.setActiveItemTracking(True)
+
+ multiAction = MultiModeAction(self)
+ self.addAction(multiAction)
+ for action in self.__manager.createImageStackActions(self):
+ multiAction.addAction(action)
+ self.__multiAction = multiAction
+
+ cleanAction = self.__manager.createClearAction(self)
+ self.addAction(cleanAction)
+ editorAction = self.__manager.createEditorAction(self)
+ self.addAction(editorAction)
+
+ plot.sigActiveImageChanged.connect(self._activeImageChanged)
+ self._activeImageChanged()
+
+ else:
+ raise ValueError("Toolbar scheme %s unsupported" % scheme)
+
+ def _setRoiActionEnabled(self, itemKind, enabled):
+ for action in self.__multiAction.getMenu().actions():
+ if not isinstance(action, roi_mdl.CreateRoiModeAction):
+ continue
+ roiClass = action.getRoiClass()
+ if issubclass(itemKind, roiClass.ITEM_KIND):
+ action.setEnabled(enabled)
+
+ def _activeImageChanged(self, previous=None, legend=None):
+ """Handle active image change to toggle actions"""
+ if legend is None:
+ self._setRoiActionEnabled(items.ImageStack, False)
+ self._setRoiActionEnabled(items.ImageBase, False)
+ else:
+ plot = self.getPlotWidget()
+ image = plot.getActiveImage()
+ # Disable for empty image
+ enabled = image.getData(copy=False).size > 0
+ self._setRoiActionEnabled(type(image), enabled)
+
+ def _activeScatterChanged(self, previous=None, legend=None):
+ """Handle active scatter change to toggle actions"""
+ if legend is None:
+ self._setRoiActionEnabled(items.Scatter, False)
+ else:
+ plot = self.getPlotWidget()
+ scatter = plot.getActiveScatter()
+ # Disable for empty image
+ enabled = scatter.getValueData(copy=False).size > 0
+ self._setRoiActionEnabled(type(scatter), enabled)
diff --git a/silx/gui/plot/tools/roi.py b/silx/gui/plot/tools/roi.py
index 3535097..431ecb2 100644
--- a/silx/gui/plot/tools/roi.py
+++ b/silx/gui/plot/tools/roi.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2020 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,16 +30,13 @@ __license__ = "MIT"
__date__ = "28/06/2018"
-import collections
import enum
-import functools
import logging
import time
import weakref
import numpy
-from ....utils.weakref import WeakMethodProxy
from ... import qt, icons
from .. import PlotWidget
from ..items import roi as roi_items
@@ -50,6 +47,122 @@ from ...colors import rgba
logger = logging.getLogger(__name__)
+class CreateRoiModeAction(qt.QAction):
+ """
+ This action is a plot mode which allows to create new ROIs using a ROI
+ manager.
+
+ A ROI is created using a specific `roiClass`. `initRoi` and `finalizeRoi`
+ can be inherited to custom the ROI initialization.
+
+ :param class roiClass: The ROI class which will be created by this action.
+ :param qt.QObject parent: The action parent
+ :param RegionOfInterestManager roiManager: The ROI manager
+ """
+
+ def __init__(self, parent, roiManager, roiClass):
+ assert roiManager is not None
+ assert roiClass is not None
+ qt.QAction.__init__(self, parent=parent)
+ self._roiManager = weakref.ref(roiManager)
+ self._roiClass = roiClass
+ self._singleShot = False
+ self._initAction()
+ self.triggered[bool].connect(self._actionTriggered)
+
+ def _initAction(self):
+ """Default initialization of the action"""
+ roiClass = self._roiClass
+
+ name = None
+ iconName = None
+ if hasattr(roiClass, "NAME"):
+ name = roiClass.NAME
+ if hasattr(roiClass, "ICON"):
+ iconName = roiClass.ICON
+
+ if iconName is None:
+ iconName = "add-shape-unknown"
+ if name is None:
+ name = roiClass.__name__
+ text = 'Add %s' % name
+ self.setIcon(icons.getQIcon(iconName))
+ self.setText(text)
+ self.setCheckable(True)
+ self.setToolTip(text)
+
+ def getRoiClass(self):
+ """Return the ROI class used by this action to create ROIs"""
+ return self._roiClass
+
+ def getRoiManager(self):
+ return self._roiManager()
+
+ def setSingleShot(self, singleShot):
+ """Set it to True to deactivate the action after the first creation
+ of a ROI.
+
+ :param bool singleShot: New single short state
+ """
+ self._singleShot = singleShot
+
+ def getSingleShot(self):
+ """If True, after the first creation of a ROI with this mode,
+ the mode is deactivated.
+
+ :rtype: bool
+ """
+ return self._singleShot
+
+ def _actionTriggered(self, checked):
+ """Handle mode actions being checked by the user
+
+ :param bool checked:
+ :param str kind: Corresponding shape kind
+ """
+ roiManager = self.getRoiManager()
+ if roiManager is None:
+ return
+
+ if checked:
+ roiManager.start(self._roiClass, self)
+ self.__interactiveModeStarted(roiManager)
+ else:
+ source = roiManager.getInteractionSource()
+ if source is self:
+ roiManager.stop()
+
+ def __interactiveModeStarted(self, roiManager):
+ roiManager.sigInteractiveRoiCreated.connect(self.initRoi)
+ roiManager.sigInteractiveRoiFinalized.connect(self.__finalizeRoi)
+ roiManager.sigInteractiveModeFinished.connect(self.__interactiveModeFinished)
+
+ def __interactiveModeFinished(self):
+ roiManager = self.getRoiManager()
+ if roiManager is not None:
+ roiManager.sigInteractiveRoiCreated.disconnect(self.initRoi)
+ roiManager.sigInteractiveRoiFinalized.disconnect(self.__finalizeRoi)
+ roiManager.sigInteractiveModeFinished.disconnect(self.__interactiveModeFinished)
+ self.setChecked(False)
+
+ def initRoi(self, roi):
+ """Inherit it to custom the new ROI at it's creation during the
+ interaction."""
+ pass
+
+ def __finalizeRoi(self, roi):
+ self.finalizeRoi(roi)
+ if self._singleShot:
+ roiManager = self.getRoiManager()
+ if roiManager is not None:
+ roiManager.stop()
+
+ def finalizeRoi(self, roi):
+ """Inherit it to custom the new ROI after it's creation when the
+ interaction is finalized."""
+ pass
+
+
class RegionOfInterestManager(qt.QObject):
"""Class handling ROI interaction on a PlotWidget.
@@ -77,6 +190,9 @@ class RegionOfInterestManager(qt.QObject):
sigRoiChanged = qt.Signal()
"""Signal emitted whenever the ROIs have changed."""
+ sigCurrentRoiChanged = qt.Signal(object)
+ """Signal emitted whenever a ROI is selected."""
+
sigInteractiveModeStarted = qt.Signal(object)
"""Signal emitted when switching to ROI drawing interactive mode.
@@ -84,21 +200,36 @@ class RegionOfInterestManager(qt.QObject):
mode.
"""
- sigInteractiveModeFinished = qt.Signal()
- """Signal emitted when leaving and interactive ROI drawing.
+ sigInteractiveRoiCreated = qt.Signal(object)
+ """Signal emitted when a ROI is created during the interaction.
+ The interaction is still incomplete and can be aborted.
- It provides the list of ROIs.
+ It provides the ROI object which was just been created.
"""
- _MODE_ACTIONS_PARAMS = collections.OrderedDict()
- # Interactive mode: (icon name, text)
- _MODE_ACTIONS_PARAMS[roi_items.PointROI] = 'add-shape-point', 'Add point markers'
- _MODE_ACTIONS_PARAMS[roi_items.RectangleROI] = 'add-shape-rectangle', 'Add rectangle ROI'
- _MODE_ACTIONS_PARAMS[roi_items.PolygonROI] = 'add-shape-polygon', 'Add polygon ROI'
- _MODE_ACTIONS_PARAMS[roi_items.LineROI] = 'add-shape-diagonal', 'Add line ROI'
- _MODE_ACTIONS_PARAMS[roi_items.HorizontalLineROI] = 'add-shape-horizontal', 'Add horizontal line ROI'
- _MODE_ACTIONS_PARAMS[roi_items.VerticalLineROI] = 'add-shape-vertical', 'Add vertical line ROI'
- _MODE_ACTIONS_PARAMS[roi_items.ArcROI] = 'add-shape-arc', 'Add arc ROI'
+ sigInteractiveRoiFinalized = qt.Signal(object)
+ """Signal emitted when a ROI creation is complet.
+
+ It provides the ROI object which was just been created.
+ """
+
+ sigInteractiveModeFinished = qt.Signal()
+ """Signal emitted when leaving interactive ROI drawing mode.
+ """
+
+ ROI_CLASSES = (
+ roi_items.PointROI,
+ roi_items.CrossROI,
+ roi_items.RectangleROI,
+ roi_items.CircleROI,
+ roi_items.EllipseROI,
+ roi_items.PolygonROI,
+ roi_items.LineROI,
+ roi_items.HorizontalLineROI,
+ roi_items.VerticalLineROI,
+ roi_items.ArcROI,
+ roi_items.HorizontalRangeROI,
+ )
def __init__(self, parent):
assert isinstance(parent, PlotWidget)
@@ -106,28 +237,33 @@ class RegionOfInterestManager(qt.QObject):
self._rois = [] # List of ROIs
self._drawnROI = None # New ROI being currently drawn
- # Handle unique selection of interaction mode action
- self._actionGroup = qt.QActionGroup(self)
-
self._roiClass = None
+ self._source = None
self._color = rgba('red')
self._label = "__RegionOfInterestManager__%d" % id(self)
+ self._currentRoi = None
+ """Hold currently selected ROI"""
+
self._eventLoop = None
self._modeActions = {}
+ parent.sigPlotSignal.connect(self._plotSignals)
+
parent.sigInteractiveModeChanged.connect(
self._plotInteractiveModeChanged)
+ parent.sigItemRemoved.connect(self._itemRemoved)
+
@classmethod
def getSupportedRoiClasses(cls):
"""Returns the default available ROI classes
:rtype: List[class]
"""
- return tuple(cls._MODE_ACTIONS_PARAMS.keys())
+ return tuple(cls.ROI_CLASSES)
# Associated QActions
@@ -137,7 +273,7 @@ class RegionOfInterestManager(qt.QObject):
The QAction allows to enable the corresponding drawing
interactive mode.
- :param str roiClass: The ROI class which will be crated by this action.
+ :param class roiClass: The ROI class which will be created by this action.
:rtype: QAction
:raise ValueError: If kind is not supported
"""
@@ -146,42 +282,10 @@ class RegionOfInterestManager(qt.QObject):
action = self._modeActions.get(roiClass, None)
if action is None: # Lazy-loading
- if roiClass in self._MODE_ACTIONS_PARAMS:
- iconName, text = self._MODE_ACTIONS_PARAMS[roiClass]
- else:
- iconName = "add-shape-unknown"
- name = roiClass._getKind()
- if name is None:
- name = roiClass.__name__
- text = 'Add %s' % name
- action = qt.QAction(self)
- action.setIcon(icons.getQIcon(iconName))
- action.setText(text)
- action.setCheckable(True)
- action.setChecked(self.getCurrentInteractionModeRoiClass() is roiClass)
- action.setToolTip(text)
-
- self._actionGroup.addAction(action)
-
- action.triggered[bool].connect(functools.partial(
- WeakMethodProxy(self._modeActionTriggered), roiClass=roiClass))
+ action = CreateRoiModeAction(self, self, roiClass)
self._modeActions[roiClass] = action
return action
- def _modeActionTriggered(self, checked, roiClass):
- """Handle mode actions being checked by the user
-
- :param bool checked:
- :param str kind: Corresponding shape kind
- """
- if checked:
- self.start(roiClass)
-
- def _updateModeActions(self):
- """Check/Uncheck action corresponding to current mode"""
- for roiClass, action in self._modeActions.items():
- action.setChecked(roiClass == self.getCurrentInteractionModeRoiClass())
-
# PlotWidget eventFilter and listeners
def _plotInteractiveModeChanged(self, source):
@@ -189,8 +293,25 @@ class RegionOfInterestManager(qt.QObject):
if source is not self:
self.__roiInteractiveModeEnded()
- else: # Check the corresponding action
- self._updateModeActions()
+ def _getRoiFromItem(self, item):
+ """Returns the ROI which own this item, else None
+ if this manager do not have knowledge of this ROI."""
+ for roi in self._rois:
+ if isinstance(roi, roi_items.RegionOfInterest):
+ for child in roi.getItems():
+ if child is item:
+ return roi
+ return None
+
+ def _itemRemoved(self, item):
+ """Called after an item was removed from the plot."""
+ if not hasattr(item, "_roiGroup"):
+ # Early break to avoid to use _getRoiFromItem
+ # And to avoid reentrant signal when the ROI remove the item itself
+ return
+ roi = self._getRoiFromItem(item)
+ if roi is not None:
+ self.removeRoi(roi)
# Handle ROI interaction
@@ -205,8 +326,10 @@ class RegionOfInterestManager(qt.QObject):
if event['event'] == 'mouseClicked' and event['button'] == 'left':
points = numpy.array([(event['x'], event['y'])],
dtype=numpy.float64)
- self.createRoi(roiClass, points=points)
-
+ # Not an interactive creation
+ roi = self._createInteractiveRoi(roiClass, points=points)
+ roi.creationFinalized()
+ self.sigInteractiveRoiFinalized.emit(roi)
else: # other shapes
if (event['event'] in ('drawingProgress', 'drawingFinished') and
event['parameters']['label'] == self._label):
@@ -214,14 +337,88 @@ class RegionOfInterestManager(qt.QObject):
dtype=numpy.float64).T
if self._drawnROI is None: # Create new ROI
- self._drawnROI = self.createRoi(roiClass, points=points)
+ # NOTE: Set something before createRoi, so isDrawing is True
+ self._drawnROI = object()
+ self._drawnROI = self._createInteractiveRoi(roiClass, points=points)
else:
self._drawnROI.setFirstShapePoints(points)
if event['event'] == 'drawingFinished':
if kind == 'polygon' and len(points) > 1:
self._drawnROI.setFirstShapePoints(points[:-1])
+ roi = self._drawnROI
self._drawnROI = None # Stop drawing
+ roi.creationFinalized()
+ self.sigInteractiveRoiFinalized.emit(roi)
+
+ # RegionOfInterest selection
+
+ def __getRoiFromMarker(self, marker):
+ """Returns a ROI from a marker, else None"""
+ # This should be speed up
+ for roi in self._rois:
+ if isinstance(roi, roi_items.HandleBasedROI):
+ for m in roi.getHandles():
+ if m is marker:
+ return roi
+ else:
+ for m in roi.getItems():
+ if m is marker:
+ return roi
+ return None
+
+ def setCurrentRoi(self, roi):
+ """Set the currently selected ROI, and emit a signal.
+
+ :param Union[RegionOfInterest,None] roi: The ROI to select
+ """
+ if self._currentRoi is roi:
+ return
+ if roi is not None:
+ # Note: Fixed range to avoid infinite loops
+ for _ in range(10):
+ target = roi.getFocusProxy()
+ if target is None:
+ break
+ roi = target
+ else:
+ raise RuntimeError("Max selection proxy depth (10) reached.")
+
+ if self._currentRoi is not None:
+ self._currentRoi.setHighlighted(False)
+ self._currentRoi = roi
+ if self._currentRoi is not None:
+ self._currentRoi.setHighlighted(True)
+ self.sigCurrentRoiChanged.emit(roi)
+
+ def getCurrentRoi(self):
+ """Returns the currently selected ROI, else None.
+
+ :rtype: Union[RegionOfInterest,None]
+ """
+ return self._currentRoi
+
+ def _plotSignals(self, event):
+ """Handle mouse interaction for ROI addition"""
+ if event['event'] in ('markerClicked', 'markerMoving'):
+ plot = self.parent()
+ legend = event['label']
+ marker = plot._getMarker(legend=legend)
+ roi = self.__getRoiFromMarker(marker)
+ if roi is not None and roi.isSelectable():
+ self.setCurrentRoi(roi)
+ else:
+ self.setCurrentRoi(None)
+ elif event['event'] == 'mouseClicked' and event['button'] == 'left':
+ # Marker click is only for dnd
+ # This also can click on a marker
+ plot = self.parent()
+ marker = plot._getMarkerAt(event['xpixel'], event['ypixel'])
+ roi = self.__getRoiFromMarker(marker)
+ if roi is not None and roi.isSelectable():
+ self.setCurrentRoi(roi)
+ else:
+ self.setCurrentRoi(None)
# RegionOfInterest API
@@ -257,8 +454,8 @@ class RegionOfInterestManager(qt.QObject):
"""Handle ROI object changed"""
self.sigRoiChanged.emit()
- def createRoi(self, roiClass, points, label='', index=None):
- """Create a new ROI and add it to list of ROIs.
+ def _createInteractiveRoi(self, roiClass, points, label=None, index=None):
+ """Create a new ROI with interactive creation.
:param class roiClass: The class of the ROI to create
:param numpy.ndarray points: The first shape used to create the ROI
@@ -271,12 +468,25 @@ class RegionOfInterestManager(qt.QObject):
number of ROIs has been reached.
"""
roi = roiClass(parent=None)
- roi.setName(str(label))
+ if label is not None:
+ roi.setName(str(label))
+ roi.creationStarted()
roi.setFirstShapePoints(points)
self.addRoi(roi, index)
+ if roi.isSelectable():
+ self.setCurrentRoi(roi)
+ self.sigInteractiveRoiCreated.emit(roi)
return roi
+ def containsRoi(self, roi):
+ """Returns true if the ROI is part of this manager.
+
+ :param roi_items.RegionOfInterest roi: The ROI to add
+ :rtype: bool
+ """
+ return roi in self._rois
+
def addRoi(self, roi, index=None, useManagerColor=True):
"""Add the ROI to the list of ROIs.
@@ -321,14 +531,25 @@ class RegionOfInterestManager(qt.QObject):
raise ValueError(
'RegionOfInterest does not belong to this instance')
+ roi.sigAboutToBeRemoved.emit()
self.sigRoiAboutToBeRemoved.emit(roi)
+ if roi is self._currentRoi:
+ self.setCurrentRoi(None)
+
+ mustRestart = False
+ if roi is self._drawnROI:
+ self._drawnROI = None
+ mustRestart = True
self._rois.remove(roi)
roi.sigRegionChanged.disconnect(self._regionOfInterestChanged)
roi.sigItemChanged.disconnect(self._regionOfInterestChanged)
roi.setParent(None)
self._roisUpdated()
+ if mustRestart:
+ self._restart()
+
def _roisUpdated(self):
"""Handle update of the ROI list"""
self.sigRoiChanged.emit()
@@ -363,6 +584,15 @@ class RegionOfInterestManager(qt.QObject):
"""
return self._roiClass
+ def getInteractionSource(self):
+ """Returns the object which have requested the ROI creation.
+
+ Returns None if the ROI manager is not in an interactive mode.
+
+ :rtype: Union[object,None]
+ """
+ return self._source
+
def isStarted(self):
"""Returns True if an interactive ROI drawing mode is active.
@@ -370,11 +600,19 @@ class RegionOfInterestManager(qt.QObject):
"""
return self._roiClass is not None
- def start(self, roiClass):
+ def isDrawing(self):
+ """Returns True if an interactive ROI is drawing.
+
+ :rtype: bool
+ """
+ return self._drawnROI is not None
+
+ def start(self, roiClass, source=None):
"""Start an interactive ROI drawing mode.
:param class roiClass: The ROI class to create. It have to inherite from
`roi_items.RegionOfInterest`.
+ :param object source: SOurce of the ROI interaction.
:return: True if interactive ROI drawing was started, False otherwise
:rtype: bool
:raise ValueError: If roiClass is not supported
@@ -389,6 +627,22 @@ class RegionOfInterestManager(qt.QObject):
return False
self._roiClass = roiClass
+ self._source = source
+
+ self._restart()
+
+ plot.sigPlotSignal.connect(self._handleInteraction)
+
+ self.sigInteractiveModeStarted.emit(roiClass)
+
+ return True
+
+ def _restart(self):
+ """Restart the plot interaction without changing the
+ source or the ROI class.
+ """
+ roiClass = self._roiClass
+ plot = self.parent()
firstInteractionShapeKind = roiClass.getFirstInteractionShape()
if firstInteractionShapeKind == 'point':
@@ -404,16 +658,11 @@ class RegionOfInterestManager(qt.QObject):
color=color,
label=self._label)
- plot.sigPlotSignal.connect(self._handleInteraction)
-
- self.sigInteractiveModeStarted.emit(roiClass)
-
- return True
-
def __roiInteractiveModeEnded(self):
"""Handle end of ROI draw interactive mode"""
if self.isStarted():
self._roiClass = None
+ self._source = None
if self._drawnROI is not None:
# Cancel ROI create
@@ -424,8 +673,6 @@ class RegionOfInterestManager(qt.QObject):
if plot is not None:
plot.sigPlotSignal.disconnect(self._handleInteraction)
- self._updateModeActions()
-
self.sigInteractiveModeFinished.emit()
def stop(self):
@@ -441,7 +688,7 @@ class RegionOfInterestManager(qt.QObject):
if plot is not None:
# This leads to call __roiInteractiveModeEnded through
# interactive mode changed signal
- plot.setInteractiveMode(mode='zoom', source=None)
+ plot.resetInteractiveMode()
else: # Fallback
self.__roiInteractiveModeEnded()
@@ -675,16 +922,16 @@ class InteractiveRegionOfInterestManager(RegionOfInterestManager):
if nbrois is None:
nbrois = len(self.getRois())
- kind = self.__execClass._getKind()
- max_ = self.getMaxRois()
+ name = self.__execClass._getShortName()
+ max_ = self.getMaxRois()
if max_ is None:
- message = 'Select %ss (%d selected)' % (kind, nbrois)
+ message = 'Select %ss (%d selected)' % (name, nbrois)
elif max_ <= 1:
- message = 'Select a %s' % kind
+ message = 'Select a %s' % name
else:
- message = 'Select %d/%d %ss' % (nbrois, max_, kind)
+ message = 'Select %d/%d %ss' % (nbrois, max_, name)
if (self.getValidationMode() == self.ValidationMode.ENTER and
self.isMaxRois()):
@@ -915,7 +1162,7 @@ class RegionOfInterestTableWidget(qt.QTableWidget):
item.setText(None)
# Kind
- label = roi._getKind()
+ label = roi._getShortName()
if label is None:
# Default value if kind is not overrided
label = roi.__class__.__name__
diff --git a/silx/gui/plot/tools/test/__init__.py b/silx/gui/plot/tools/test/__init__.py
index 9cede27..1429545 100644
--- a/silx/gui/plot/tools/test/__init__.py
+++ b/silx/gui/plot/tools/test/__init__.py
@@ -33,6 +33,7 @@ from . import testROI
from . import testTools
from . import testScatterProfileToolBar
from . import testCurveLegendsWidget
+from . import testProfile
def suite():
@@ -42,6 +43,7 @@ def suite():
testTools.suite(),
testScatterProfileToolBar.suite(),
testCurveLegendsWidget.suite(),
+ testProfile.suite(),
])
return test_suite
diff --git a/silx/gui/plot/tools/test/testProfile.py b/silx/gui/plot/tools/test/testProfile.py
new file mode 100644
index 0000000..444cfe0
--- /dev/null
+++ b/silx/gui/plot/tools/test/testProfile.py
@@ -0,0 +1,673 @@
+# 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.
+#
+# ###########################################################################*/
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "28/06/2018"
+
+
+import unittest
+import contextlib
+import numpy
+import logging
+
+from silx.gui import qt
+from silx.utils import deprecation
+from silx.utils import testutils
+
+from silx.gui.utils.testutils import TestCaseQt
+from silx.utils.testutils import ParametricTestCase
+from silx.gui.plot import PlotWindow, Plot1D, Plot2D, Profile
+from silx.gui.plot.StackView import StackView
+from silx.gui.plot.tools.profile import rois
+from silx.gui.plot.tools.profile import editors
+from silx.gui.plot.items import roi as roi_items
+from silx.gui.plot.tools.profile import manager
+from silx.gui import plot as silx_plot
+
+_logger = logging.getLogger(__name__)
+
+
+class TestRois(TestCaseQt):
+
+ def test_init(self):
+ """Check that the constructor is not called twice"""
+ roi = rois.ProfileImageVerticalLineROI()
+ if qt.BINDING not in ["PySide", "PySide2"]:
+ # the profile ROI + the shape
+ self.assertEqual(roi.receivers(roi.sigRegionChanged), 2)
+
+
+class TestInteractions(TestCaseQt):
+
+ @contextlib.contextmanager
+ def defaultPlot(self):
+ try:
+ widget = silx_plot.PlotWidget()
+ widget.show()
+ self.qWaitForWindowExposed(widget)
+ yield widget
+ finally:
+ widget.close()
+ widget = None
+ self.qWait()
+
+ @contextlib.contextmanager
+ def imagePlot(self):
+ try:
+ widget = silx_plot.Plot2D()
+ image = numpy.arange(10 * 10).reshape(10, -1)
+ widget.addImage(image)
+ widget.show()
+ self.qWaitForWindowExposed(widget)
+ yield widget
+ finally:
+ widget.close()
+ widget = None
+ self.qWait()
+
+ @contextlib.contextmanager
+ def scatterPlot(self):
+ try:
+ widget = silx_plot.ScatterView()
+
+ nbX, nbY = 7, 5
+ yy = numpy.atleast_2d(numpy.ones(nbY)).T
+ xx = numpy.atleast_2d(numpy.ones(nbX))
+ positionX = numpy.linspace(10, 50, nbX) * yy
+ positionX = positionX.reshape(nbX * nbY)
+ positionY = numpy.atleast_2d(numpy.linspace(20, 60, nbY)).T * xx
+ positionY = positionY.reshape(nbX * nbY)
+ values = numpy.arange(nbX * nbY)
+
+ widget.setData(positionX, positionY, values)
+ widget.resetZoom()
+ widget.show()
+ self.qWaitForWindowExposed(widget)
+ yield widget.getPlotWidget()
+ finally:
+ widget.close()
+ widget = None
+ self.qWait()
+
+ @contextlib.contextmanager
+ def stackPlot(self):
+ try:
+ widget = silx_plot.StackView()
+ image = numpy.arange(10 * 10).reshape(10, -1)
+ cube = numpy.array([image, image, image])
+ widget.setStack(cube)
+ widget.resetZoom()
+ widget.show()
+ self.qWaitForWindowExposed(widget)
+ yield widget.getPlotWidget()
+ finally:
+ widget.close()
+ widget = None
+ self.qWait()
+
+ def waitPendingOperations(self, proflie):
+ for _ in range(10):
+ if not proflie.hasPendingOperations():
+ return
+ self.qWait(100)
+ _logger.error("The profile manager still have pending operations")
+
+ def genericRoiTest(self, plot, roiClass):
+ profileManager = manager.ProfileManager(plot, plot)
+ profileManager.setItemType(image=True, scatter=True)
+
+ try:
+ action = profileManager.createProfileAction(roiClass, plot)
+ action.triggered[bool].emit(True)
+ widget = plot.getWidgetHandle()
+
+ # Do the mouse interaction
+ pos1 = widget.width() * 0.4, widget.height() * 0.4
+ self.mouseMove(widget, pos=pos1)
+ self.mouseClick(widget, qt.Qt.LeftButton, pos=pos1)
+
+ if issubclass(roiClass, roi_items.LineROI):
+ pos2 = widget.width() * 0.6, widget.height() * 0.6
+ self.mouseMove(widget, pos=pos2)
+ self.mouseClick(widget, qt.Qt.LeftButton, pos=pos2)
+
+ self.waitPendingOperations(profileManager)
+
+ # Test that something was computed
+ if issubclass(roiClass, rois._ProfileCrossROI):
+ self.assertEqual(profileManager._computedProfiles, 2)
+ elif issubclass(roiClass, roi_items.LineROI):
+ self.assertGreaterEqual(profileManager._computedProfiles, 1)
+ else:
+ self.assertEqual(profileManager._computedProfiles, 1)
+
+ # Test the created ROIs
+ profileRois = profileManager.getRoiManager().getRois()
+ if issubclass(roiClass, rois._ProfileCrossROI):
+ self.assertEqual(len(profileRois), 3)
+ else:
+ self.assertEqual(len(profileRois), 1)
+ # The first one should be the expected one
+ roi = profileRois[0]
+
+ # Test that something was displayed
+ if issubclass(roiClass, rois._ProfileCrossROI):
+ profiles = roi._getLines()
+ window = profiles[0].getProfileWindow()
+ self.assertIsNotNone(window)
+ window = profiles[1].getProfileWindow()
+ self.assertIsNotNone(window)
+ else:
+ window = roi.getProfileWindow()
+ self.assertIsNotNone(window)
+ finally:
+ profileManager.clearProfile()
+
+ def testImageActions(self):
+ roiClasses = [
+ rois.ProfileImageHorizontalLineROI,
+ rois.ProfileImageVerticalLineROI,
+ rois.ProfileImageLineROI,
+ rois.ProfileImageCrossROI,
+ ]
+ with self.imagePlot() as plot:
+ for roiClass in roiClasses:
+ with self.subTest(roiClass=roiClass):
+ self.genericRoiTest(plot, roiClass)
+
+ def testScatterActions(self):
+ roiClasses = [
+ rois.ProfileScatterHorizontalLineROI,
+ rois.ProfileScatterVerticalLineROI,
+ rois.ProfileScatterLineROI,
+ rois.ProfileScatterCrossROI,
+ rois.ProfileScatterHorizontalSliceROI,
+ rois.ProfileScatterVerticalSliceROI,
+ rois.ProfileScatterCrossSliceROI,
+ ]
+ with self.scatterPlot() as plot:
+ for roiClass in roiClasses:
+ with self.subTest(roiClass=roiClass):
+ self.genericRoiTest(plot, roiClass)
+
+ def testStackActions(self):
+ roiClasses = [
+ rois.ProfileImageStackHorizontalLineROI,
+ rois.ProfileImageStackVerticalLineROI,
+ rois.ProfileImageStackLineROI,
+ rois.ProfileImageStackCrossROI,
+ ]
+ with self.stackPlot() as plot:
+ for roiClass in roiClasses:
+ with self.subTest(roiClass=roiClass):
+ self.genericRoiTest(plot, roiClass)
+
+ def genericEditorTest(self, plot, roi, editor):
+ if isinstance(editor, editors._NoProfileRoiEditor):
+ pass
+ elif isinstance(editor, editors._DefaultImageStackProfileRoiEditor):
+ # GUI to ROI
+ editor._lineWidth.setValue(2)
+ self.assertEqual(roi.getProfileLineWidth(), 2)
+ editor._methodsButton.setMethod("sum")
+ self.assertEqual(roi.getProfileMethod(), "sum")
+ editor._profileDim.setDimension(1)
+ self.assertEqual(roi.getProfileType(), "1D")
+ # ROI to GUI
+ roi.setProfileLineWidth(3)
+ self.assertEqual(editor._lineWidth.value(), 3)
+ roi.setProfileMethod("mean")
+ self.assertEqual(editor._methodsButton.getMethod(), "mean")
+ roi.setProfileType("2D")
+ self.assertEqual(editor._profileDim.getDimension(), 2)
+ elif isinstance(editor, editors._DefaultImageProfileRoiEditor):
+ # GUI to ROI
+ editor._lineWidth.setValue(2)
+ self.assertEqual(roi.getProfileLineWidth(), 2)
+ editor._methodsButton.setMethod("sum")
+ self.assertEqual(roi.getProfileMethod(), "sum")
+ # ROI to GUI
+ roi.setProfileLineWidth(3)
+ self.assertEqual(editor._lineWidth.value(), 3)
+ roi.setProfileMethod("mean")
+ self.assertEqual(editor._methodsButton.getMethod(), "mean")
+ elif isinstance(editor, editors._DefaultScatterProfileRoiEditor):
+ # GUI to ROI
+ editor._nPoints.setValue(100)
+ self.assertEqual(roi.getNPoints(), 100)
+ # ROI to GUI
+ roi.setNPoints(200)
+ self.assertEqual(editor._nPoints.value(), 200)
+ else:
+ assert False
+
+ def testEditors(self):
+ roiClasses = [
+ (rois.ProfileImageHorizontalLineROI, editors._DefaultImageProfileRoiEditor),
+ (rois.ProfileImageVerticalLineROI, editors._DefaultImageProfileRoiEditor),
+ (rois.ProfileImageLineROI, editors._DefaultImageProfileRoiEditor),
+ (rois.ProfileImageCrossROI, editors._DefaultImageProfileRoiEditor),
+ (rois.ProfileScatterHorizontalLineROI, editors._DefaultScatterProfileRoiEditor),
+ (rois.ProfileScatterVerticalLineROI, editors._DefaultScatterProfileRoiEditor),
+ (rois.ProfileScatterLineROI, editors._DefaultScatterProfileRoiEditor),
+ (rois.ProfileScatterCrossROI, editors._DefaultScatterProfileRoiEditor),
+ (rois.ProfileScatterHorizontalSliceROI, editors._NoProfileRoiEditor),
+ (rois.ProfileScatterVerticalSliceROI, editors._NoProfileRoiEditor),
+ (rois.ProfileScatterCrossSliceROI, editors._NoProfileRoiEditor),
+ (rois.ProfileImageStackHorizontalLineROI, editors._DefaultImageStackProfileRoiEditor),
+ (rois.ProfileImageStackVerticalLineROI, editors._DefaultImageStackProfileRoiEditor),
+ (rois.ProfileImageStackLineROI, editors._DefaultImageStackProfileRoiEditor),
+ (rois.ProfileImageStackCrossROI, editors._DefaultImageStackProfileRoiEditor),
+ ]
+ with self.defaultPlot() as plot:
+ profileManager = manager.ProfileManager(plot, plot)
+ editorAction = profileManager.createEditorAction(parent=plot)
+ for roiClass, editorClass in roiClasses:
+ with self.subTest(roiClass=roiClass):
+ roi = roiClass()
+ roi._setProfileManager(profileManager)
+ try:
+ # Force widget creation
+ menu = qt.QMenu(plot)
+ menu.addAction(editorAction)
+ widgets = editorAction.createdWidgets()
+ self.assertGreater(len(widgets), 0)
+
+ editorAction.setProfileRoi(roi)
+ editorWidget = editorAction._getEditor(widgets[0])
+ self.assertIsInstance(editorWidget, editorClass)
+ self.genericEditorTest(plot, roi, editorWidget)
+ finally:
+ editorAction.setProfileRoi(None)
+ menu.deleteLater()
+ menu = None
+ self.qapp.processEvents()
+
+
+class TestProfileToolBar(TestCaseQt, ParametricTestCase):
+ """Tests for ProfileToolBar widget."""
+
+ def setUp(self):
+ super(TestProfileToolBar, self).setUp()
+ self.plot = PlotWindow()
+ self.toolBar = Profile.ProfileToolBar(plot=self.plot)
+ self.plot.addToolBar(self.toolBar)
+
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ self.mouseMove(self.plot) # Move to center
+ self.qapp.processEvents()
+ deprecation.FORCE = True
+
+ def tearDown(self):
+ deprecation.FORCE = False
+ self.qapp.processEvents()
+ profileManager = self.toolBar.getProfileManager()
+ profileManager.clearProfile()
+ profileManager = None
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+ del self.toolBar
+
+ super(TestProfileToolBar, self).tearDown()
+
+ def testAlignedProfile(self):
+ """Test horizontal and vertical profile, without and with image"""
+ # Use Plot backend widget to submit mouse events
+ widget = self.plot.getWidgetHandle()
+ for method in ('sum', 'mean'):
+ with self.subTest(method=method):
+ # 2 positions to use for mouse events
+ pos1 = widget.width() * 0.4, widget.height() * 0.4
+ pos2 = widget.width() * 0.6, widget.height() * 0.6
+
+ for action in (self.toolBar.hLineAction, self.toolBar.vLineAction):
+ with self.subTest(mode=action.text()):
+ # Trigger tool button for mode
+ action.trigger()
+ # Without image
+ self.mouseMove(widget, pos=pos1)
+ self.mouseClick(widget, qt.Qt.LeftButton, pos=pos1)
+
+ # with image
+ self.plot.addImage(
+ numpy.arange(100 * 100).reshape(100, -1))
+ self.mousePress(widget, qt.Qt.LeftButton, pos=pos1)
+ self.mouseMove(widget, pos=pos2)
+ self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2)
+
+ self.mouseMove(widget)
+ self.mouseClick(widget, qt.Qt.LeftButton)
+
+ manager = self.toolBar.getProfileManager()
+ for _ in range(20):
+ self.qWait(200)
+ if not manager.hasPendingOperations():
+ break
+
+ @testutils.test_logging(deprecation.depreclog.name, warning=4)
+ def testDiagonalProfile(self):
+ """Test diagonal profile, without and with image"""
+ # Use Plot backend widget to submit mouse events
+ widget = self.plot.getWidgetHandle()
+
+ for method in ('sum', 'mean'):
+ for image in (False, True):
+ with self.subTest(method=method, image=image):
+ # 2 positions to use for mouse events
+ pos1 = widget.width() * 0.4, widget.height() * 0.4
+ pos2 = widget.width() * 0.6, widget.height() * 0.6
+
+ if image:
+ self.plot.addImage(
+ numpy.arange(100 * 100).reshape(100, -1))
+
+ # Trigger tool button for diagonal profile mode
+ self.toolBar.lineAction.trigger()
+
+ # draw profile line
+ widget.setFocus(qt.Qt.OtherFocusReason)
+ self.mouseMove(widget, pos=pos1)
+ self.qWait(100)
+ self.mousePress(widget, qt.Qt.LeftButton, pos=pos1)
+ self.qWait(100)
+ self.mouseMove(widget, pos=pos2)
+ self.qWait(100)
+ self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2)
+ self.qWait(100)
+
+ manager = self.toolBar.getProfileManager()
+
+ for _ in range(20):
+ self.qWait(200)
+ if not manager.hasPendingOperations():
+ break
+
+ roi = manager.getCurrentRoi()
+ self.assertIsNotNone(roi)
+ roi.setProfileLineWidth(3)
+ roi.setProfileMethod(method)
+
+ for _ in range(20):
+ self.qWait(200)
+ if not manager.hasPendingOperations():
+ break
+
+ if image is True:
+ curveItem = self.toolBar.getProfilePlot().getAllCurves()[0]
+ if method == 'sum':
+ self.assertTrue(curveItem.getData()[1].max() > 10000)
+ elif method == 'mean':
+ self.assertTrue(curveItem.getData()[1].max() < 10000)
+
+ # Remove the ROI so the profile window is also removed
+ roiManager = manager.getRoiManager()
+ roiManager.removeRoi(roi)
+ self.qWait(100)
+
+
+class TestDeprecatedProfileToolBar(TestCaseQt):
+ """Tests old features of the ProfileToolBar widget."""
+
+ def setUp(self):
+ self.plot = None
+ super(TestDeprecatedProfileToolBar, self).setUp()
+
+ def tearDown(self):
+ if self.plot is not None:
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ self.plot = None
+ self.qWait()
+
+ super(TestDeprecatedProfileToolBar, self).tearDown()
+
+ @testutils.test_logging(deprecation.depreclog.name, warning=2)
+ def testCustomProfileWindow(self):
+ from silx.gui.plot import ProfileMainWindow
+
+ self.plot = PlotWindow()
+ profileWindow = ProfileMainWindow.ProfileMainWindow(self.plot)
+ toolBar = Profile.ProfileToolBar(parent=self.plot,
+ plot=self.plot,
+ profileWindow=profileWindow)
+
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+ profileWindow.show()
+ self.qWaitForWindowExposed(profileWindow)
+ self.qapp.processEvents()
+
+ self.plot.addImage(numpy.arange(10 * 10).reshape(10, -1))
+ profile = rois.ProfileImageHorizontalLineROI()
+ profile.setPosition(5)
+ toolBar.getProfileManager().getRoiManager().addRoi(profile)
+ toolBar.getProfileManager().getRoiManager().setCurrentRoi(profile)
+
+ for _ in range(20):
+ self.qWait(200)
+ if not toolBar.getProfileManager().hasPendingOperations():
+ break
+
+ # There is a displayed profile
+ self.assertIsNotNone(profileWindow.getProfile())
+ self.assertIs(toolBar.getProfileMainWindow(), profileWindow)
+
+ # There is nothing anymore but the window is still there
+ toolBar.getProfileManager().clearProfile()
+ self.qapp.processEvents()
+ self.assertIsNone(profileWindow.getProfile())
+
+
+class TestProfile3DToolBar(TestCaseQt):
+ """Tests for Profile3DToolBar widget.
+ """
+ def setUp(self):
+ super(TestProfile3DToolBar, self).setUp()
+ self.plot = StackView()
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ self.plot.setStack(numpy.array([
+ [[0, 1, 2], [3, 4, 5]],
+ [[6, 7, 8], [9, 10, 11]],
+ [[12, 13, 14], [15, 16, 17]]
+ ]))
+ deprecation.FORCE = True
+
+ def tearDown(self):
+ deprecation.FORCE = False
+ profileManager = self.plot.getProfileToolbar().getProfileManager()
+ profileManager.clearProfile()
+ profileManager = None
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ self.plot = None
+
+ super(TestProfile3DToolBar, self).tearDown()
+
+ @testutils.test_logging(deprecation.depreclog.name, warning=2)
+ def testMethodProfile2D(self):
+ """Test that the profile can have a different method if we want to
+ compute then in 1D or in 2D"""
+
+ toolBar = self.plot.getProfileToolbar()
+
+ toolBar.vLineAction.trigger()
+ plot2D = self.plot.getPlotWidget().getWidgetHandle()
+ pos1 = plot2D.width() * 0.5, plot2D.height() * 0.5
+ self.mouseClick(plot2D, qt.Qt.LeftButton, pos=pos1)
+
+ manager = toolBar.getProfileManager()
+ roi = manager.getCurrentRoi()
+ roi.setProfileMethod("mean")
+ roi.setProfileType("2D")
+ roi.setProfileLineWidth(3)
+
+ for _ in range(20):
+ self.qWait(200)
+ if not manager.hasPendingOperations():
+ break
+
+ # check 2D 'mean' profile
+ profilePlot = toolBar.getProfilePlot()
+ data = profilePlot.getAllImages()[0].getData()
+ expected = numpy.array([[1, 4], [7, 10], [13, 16]])
+ numpy.testing.assert_almost_equal(data, expected)
+
+ @testutils.test_logging(deprecation.depreclog.name, warning=2)
+ def testMethodSumLine(self):
+ """Simple interaction test to make sure the sum is correctly computed
+ """
+ toolBar = self.plot.getProfileToolbar()
+
+ toolBar.lineAction.trigger()
+ plot2D = self.plot.getPlotWidget().getWidgetHandle()
+ pos1 = plot2D.width() * 0.5, plot2D.height() * 0.2
+ pos2 = plot2D.width() * 0.5, plot2D.height() * 0.8
+
+ self.mouseMove(plot2D, pos=pos1)
+ self.mousePress(plot2D, qt.Qt.LeftButton, pos=pos1)
+ self.mouseMove(plot2D, pos=pos2)
+ self.mouseRelease(plot2D, qt.Qt.LeftButton, pos=pos2)
+
+ manager = toolBar.getProfileManager()
+ roi = manager.getCurrentRoi()
+ roi.setProfileMethod("sum")
+ roi.setProfileType("2D")
+ roi.setProfileLineWidth(3)
+
+ for _ in range(20):
+ self.qWait(200)
+ if not manager.hasPendingOperations():
+ break
+
+ # check 2D 'sum' profile
+ profilePlot = toolBar.getProfilePlot()
+ data = profilePlot.getAllImages()[0].getData()
+ expected = numpy.array([[3, 12], [21, 30], [39, 48]])
+ numpy.testing.assert_almost_equal(data, expected)
+
+
+class TestGetProfilePlot(TestCaseQt):
+
+ def setUp(self):
+ self.plot = None
+ super(TestGetProfilePlot, self).setUp()
+
+ def tearDown(self):
+ if self.plot is not None:
+ manager = self.plot.getProfileToolbar().getProfileManager()
+ manager.clearProfile()
+ manager = None
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ self.plot = None
+
+ super(TestGetProfilePlot, self).tearDown()
+
+ def testProfile1D(self):
+ self.plot = Plot2D()
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+ self.plot.addImage([[0, 1], [2, 3]])
+
+ toolBar = self.plot.getProfileToolbar()
+
+ manager = toolBar.getProfileManager()
+ roiManager = manager.getRoiManager()
+
+ roi = rois.ProfileImageHorizontalLineROI()
+ roi.setPosition(0.5)
+ roiManager.addRoi(roi)
+ roiManager.setCurrentRoi(roi)
+
+ for _ in range(20):
+ self.qWait(200)
+ if not manager.hasPendingOperations():
+ break
+
+ profileWindow = roi.getProfileWindow()
+ self.assertIsInstance(roi.getProfileWindow(), qt.QMainWindow)
+ self.assertIsInstance(profileWindow.getCurrentPlotWidget(), Plot1D)
+
+ def testProfile2D(self):
+ """Test that the profile plot associated to a stack view is either a
+ Plot1D or a plot 2D instance."""
+ self.plot = StackView()
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ self.plot.setStack(numpy.array([[[0, 1], [2, 3]],
+ [[4, 5], [6, 7]]]))
+
+ toolBar = self.plot.getProfileToolbar()
+
+ manager = toolBar.getProfileManager()
+ roiManager = manager.getRoiManager()
+
+ roi = rois.ProfileImageStackHorizontalLineROI()
+ roi.setPosition(0.5)
+ roi.setProfileType("2D")
+ roiManager.addRoi(roi)
+ roiManager.setCurrentRoi(roi)
+
+ for _ in range(20):
+ self.qWait(200)
+ if not manager.hasPendingOperations():
+ break
+
+ profileWindow = roi.getProfileWindow()
+ self.assertIsInstance(roi.getProfileWindow(), qt.QMainWindow)
+ self.assertIsInstance(profileWindow.getCurrentPlotWidget(), Plot2D)
+
+ roi.setProfileType("1D")
+
+ for _ in range(20):
+ self.qWait(200)
+ if not manager.hasPendingOperations():
+ break
+
+ profileWindow = roi.getProfileWindow()
+ self.assertIsInstance(roi.getProfileWindow(), qt.QMainWindow)
+ self.assertIsInstance(profileWindow.getCurrentPlotWidget(), Plot1D)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loadTests(TestRois))
+ test_suite.addTest(loadTests(TestInteractions))
+ test_suite.addTest(loadTests(TestProfileToolBar))
+ test_suite.addTest(loadTests(TestGetProfilePlot))
+ test_suite.addTest(loadTests(TestProfile3DToolBar))
+ test_suite.addTest(loadTests(TestDeprecatedProfileToolBar))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/tools/test/testROI.py b/silx/gui/plot/tools/test/testROI.py
index 8aec1d9..33a0000 100644
--- a/silx/gui/plot/tools/test/testROI.py
+++ b/silx/gui/plot/tools/test/testROI.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2020 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
@@ -60,7 +60,7 @@ class TestRoiItems(TestCaseQt):
def testPoint_geometry(self):
point = numpy.array([1, 2])
- item = roi_items.VerticalLineROI()
+ item = roi_items.PointROI()
item.setPosition(point)
numpy.testing.assert_allclose(item.getPosition(), point)
@@ -108,6 +108,43 @@ class TestRoiItems(TestCaseQt):
numpy.testing.assert_allclose(item.getCenter(), expectedCenter)
numpy.testing.assert_allclose(item.getSize(), size)
+ def testCircle_geometry(self):
+ center = numpy.array([0, 0])
+ radius = 10.
+ item = roi_items.CircleROI()
+ item.setGeometry(center=center, radius=radius)
+ numpy.testing.assert_allclose(item.getCenter(), center)
+ numpy.testing.assert_allclose(item.getRadius(), radius)
+
+ def testCircle_setCenter(self):
+ center = numpy.array([0, 0])
+ radius = 10.
+ item = roi_items.CircleROI()
+ item.setGeometry(center=center, radius=radius)
+ newCenter = numpy.array([-10, 0])
+ item.setCenter(newCenter)
+ numpy.testing.assert_allclose(item.getCenter(), newCenter)
+ numpy.testing.assert_allclose(item.getRadius(), radius)
+
+ def testCircle_setRadius(self):
+ center = numpy.array([0, 0])
+ radius = 10.
+ item = roi_items.CircleROI()
+ item.setGeometry(center=center, radius=radius)
+ newRadius = 5.1
+ item.setRadius(newRadius)
+ numpy.testing.assert_allclose(item.getCenter(), center)
+ numpy.testing.assert_allclose(item.getRadius(), newRadius)
+
+ def testRectangle_isIn(self):
+ origin = numpy.array([0, 0])
+ size = numpy.array([10, 20])
+ item = roi_items.RectangleROI()
+ item.setGeometry(origin=origin, size=size)
+ self.assertTrue(item.contains(position=(0, 0)))
+ self.assertTrue(item.contains(position=(2, 14)))
+ self.assertFalse(item.contains(position=(14, 12)))
+
def testPolygon_emptyGeometry(self):
points = numpy.empty((0, 2))
item = roi_items.PolygonROI()
@@ -120,6 +157,17 @@ class TestRoiItems(TestCaseQt):
item.setPoints(points)
numpy.testing.assert_allclose(item.getPoints(), points)
+ def testPolygon_isIn(self):
+ points = numpy.array([[0, 0], [0, 10], [5, 10]])
+ item = roi_items.PolygonROI()
+ item.setPoints(points)
+ self.assertTrue(item.contains((0, 0)))
+ self.assertFalse(item.contains((6, 2)))
+ self.assertFalse(item.contains((-2, 5)))
+ self.assertFalse(item.contains((2, -1)))
+ self.assertFalse(item.contains((8, 1)))
+ self.assertTrue(item.contains((1, 8)))
+
def testArc_getToSetGeometry(self):
"""Test that we can use getGeometry as input to setGeometry"""
item = roi_items.ArcROI()
@@ -147,7 +195,7 @@ class TestRoiItems(TestCaseQt):
self.assertAlmostEqual(item.getInnerRadius(), innerRadius)
self.assertAlmostEqual(item.getOuterRadius(), outerRadius)
self.assertAlmostEqual(item.getStartAngle(), item.getEndAngle() - numpy.pi * 2.0)
- self.assertAlmostEqual(item.isClosed(), True)
+ self.assertTrue(item.isClosed())
def testArc_special_donut(self):
item = roi_items.ArcROI()
@@ -158,7 +206,7 @@ class TestRoiItems(TestCaseQt):
self.assertAlmostEqual(item.getInnerRadius(), innerRadius)
self.assertAlmostEqual(item.getOuterRadius(), outerRadius)
self.assertAlmostEqual(item.getStartAngle(), item.getEndAngle() - numpy.pi * 2.0)
- self.assertAlmostEqual(item.isClosed(), True)
+ self.assertTrue(item.isClosed())
def testArc_clockwiseGeometry(self):
"""Test that we can use getGeometry as input to setGeometry"""
@@ -186,6 +234,15 @@ class TestRoiItems(TestCaseQt):
self.assertAlmostEqual(item.getEndAngle(), endAngle)
self.assertAlmostEqual(item.isClosed(), False)
+ def testHRange_geometry(self):
+ item = roi_items.HorizontalRangeROI()
+ vmin = 1
+ vmax = 3
+ item.setRange(vmin, vmax)
+ self.assertAlmostEqual(item.getMin(), vmin)
+ self.assertAlmostEqual(item.getMax(), vmax)
+ self.assertAlmostEqual(item.getCenter(), 2)
+
class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase):
"""Tests for RegionOfInterestManager class"""
@@ -229,6 +286,9 @@ class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase):
(roi_items.VerticalLineROI,
numpy.array((((10., 20.), (10., 30.)),
((30., 40.), (30., 50.))))),
+ (roi_items.HorizontalLineROI,
+ numpy.array((((10., 20.), (10., 30.)),
+ ((30., 40.), (30., 50.))))),
)
for roiClass, points in tests:
@@ -246,7 +306,9 @@ class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase):
manager.sigRoiChanged.connect(changedListener)
# Add a point
- manager.createRoi(roiClass, points[0])
+ r = roiClass()
+ r.setFirstShapePoints(points[0])
+ manager.addRoi(r)
self.qapp.processEvents()
self.assertTrue(len(manager.getRois()), 1)
self.assertEqual(changedListener.callCount(), 1)
@@ -257,9 +319,13 @@ class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase):
self.assertEqual(changedListener.callCount(), 2)
# Add two point
- manager.createRoi(roiClass, points[0])
+ r = roiClass()
+ r.setFirstShapePoints(points[0])
+ manager.addRoi(r)
self.qapp.processEvents()
- manager.createRoi(roiClass, points[1])
+ r = roiClass()
+ r.setFirstShapePoints(points[1])
+ manager.addRoi(r)
self.qapp.processEvents()
self.assertTrue(len(manager.getRois()), 2)
self.assertEqual(changedListener.callCount(), 4)
@@ -273,9 +339,13 @@ class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase):
changedListener.clear()
# Add two point
- manager.createRoi(roiClass, points[0])
+ r = roiClass()
+ r.setFirstShapePoints(points[0])
+ manager.addRoi(r)
self.qapp.processEvents()
- manager.createRoi(roiClass, points[1])
+ r = roiClass()
+ r.setFirstShapePoints(points[1])
+ manager.addRoi(r)
self.qapp.processEvents()
self.assertTrue(len(manager.getRois()), 2)
self.assertEqual(changedListener.callCount(), 2)
@@ -356,6 +426,10 @@ class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase):
innerRadius, outerRadius, startAngle, endAngle = 1, 100, numpy.pi * 0.5, numpy.pi
item.setGeometry(center, innerRadius, outerRadius, startAngle, endAngle)
rois.append(item)
+ # Horizontal Range
+ item = roi_items.HorizontalRangeROI()
+ item.setRange(-1, 3)
+ rois.append(item)
manager = roi.RegionOfInterestManager(self.plot)
self.roiTableWidget.setRegionOfInterestManager(manager)
@@ -370,6 +444,25 @@ class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase):
manager.removeRoi(item)
self.qapp.processEvents()
+ def testSelectionProxy(self):
+ item1 = roi_items.PointROI()
+ item1.setSelectable(True)
+ item2 = roi_items.PointROI()
+ item2.setSelectable(True)
+ item1.setFocusProxy(item2)
+ manager = roi.RegionOfInterestManager(self.plot)
+ manager.setCurrentRoi(item1)
+ self.assertIs(manager.getCurrentRoi(), item2)
+
+ def testRemovedSelection(self):
+ item1 = roi_items.PointROI()
+ item1.setSelectable(True)
+ manager = roi.RegionOfInterestManager(self.plot)
+ manager.addRoi(item1)
+ manager.setCurrentRoi(item1)
+ manager.removeRoi(item1)
+ self.assertIs(manager.getCurrentRoi(), None)
+
def testMaxROI(self):
"""Test Max ROI"""
origin1 = numpy.array([1., 10.])
@@ -443,6 +536,86 @@ class TestRegionOfInterestManager(TestCaseQt, ParametricTestCase):
manager.clear()
+ def testLineInteraction(self):
+ """This test make sure that a ROI based on handles can be edited with
+ the mouse."""
+ xlimit = self.plot.getXAxis().getLimits()
+ ylimit = self.plot.getYAxis().getLimits()
+ points = numpy.array([xlimit, ylimit]).T
+ center = numpy.mean(points, axis=0)
+
+ # Create the line
+ manager = roi.RegionOfInterestManager(self.plot)
+ item = roi_items.LineROI()
+ item.setEndPoints(points[0], points[1])
+ item.setEditable(True)
+ manager.addRoi(item)
+ self.qapp.processEvents()
+
+ # Drag the center
+ widget = self.plot.getWidgetHandle()
+ mx, my = self.plot.dataToPixel(*center)
+ self.mouseMove(widget, pos=(mx, my))
+ self.mousePress(widget, qt.Qt.LeftButton, pos=(mx, my))
+ self.mouseMove(widget, pos=(mx, my+50))
+ self.mouseRelease(widget, qt.Qt.LeftButton, pos=(mx, my))
+
+ result = numpy.array(item.getEndPoints())
+ # x location is still the same
+ numpy.testing.assert_allclose(points[:, 0], result[:, 0], atol=0.5)
+ # size is still the same
+ numpy.testing.assert_allclose(points[1] - points[0],
+ result[1] - result[0], atol=0.5)
+ # But Y is not the same
+ self.assertNotEqual(points[0, 1], result[0, 1])
+ self.assertNotEqual(points[1, 1], result[1, 1])
+ item = None
+ manager.clear()
+ self.qapp.processEvents()
+
+ def testPlotWhenCleared(self):
+ """PlotWidget.clear should clean up the available ROIs"""
+ manager = roi.RegionOfInterestManager(self.plot)
+ item = roi_items.LineROI()
+ item.setEndPoints((0, 0), (1, 1))
+ item.setEditable(True)
+ manager.addRoi(item)
+ self.qWait()
+ try:
+ # Make sure the test setup is fine
+ self.assertNotEqual(len(manager.getRois()), 0)
+ self.assertNotEqual(len(self.plot.getItems()), 0)
+
+ # Call clear and test the expected state
+ self.plot.clear()
+ self.assertEqual(len(manager.getRois()), 0)
+ self.assertEqual(len(self.plot.getItems()), 0)
+ finally:
+ # Clean up
+ manager.clear()
+
+ def testPlotWhenRoiRemoved(self):
+ """Make sure there is no remaining items in the plot when a ROI is removed"""
+ manager = roi.RegionOfInterestManager(self.plot)
+ item = roi_items.LineROI()
+ item.setEndPoints((0, 0), (1, 1))
+ item.setEditable(True)
+ manager.addRoi(item)
+ self.qWait()
+ try:
+ # Make sure the test setup is fine
+ self.assertNotEqual(len(manager.getRois()), 0)
+ self.assertNotEqual(len(self.plot.getItems()), 0)
+
+ # Call clear and test the expected state
+ manager.removeRoi(item)
+ self.assertEqual(len(manager.getRois()), 0)
+ self.assertEqual(len(self.plot.getItems()), 0)
+ finally:
+ # Clean up
+ manager.clear()
+
+
def suite():
test_suite = unittest.TestSuite()
diff --git a/silx/gui/plot/tools/test/testScatterProfileToolBar.py b/silx/gui/plot/tools/test/testScatterProfileToolBar.py
index 714746a..b9f4885 100644
--- a/silx/gui/plot/tools/test/testScatterProfileToolBar.py
+++ b/silx/gui/plot/tools/test/testScatterProfileToolBar.py
@@ -34,8 +34,9 @@ from silx.gui import qt
from silx.utils.testutils import ParametricTestCase
from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot import PlotWindow
-from silx.gui.plot.tools import profile
-import silx.gui.plot.items.roi as roi_items
+from silx.gui.plot.tools.profile import manager
+from silx.gui.plot.tools.profile import core
+from silx.gui.plot.tools.profile import rois
class TestScatterProfileToolBar(TestCaseQt, ParametricTestCase):
@@ -45,40 +46,25 @@ class TestScatterProfileToolBar(TestCaseQt, ParametricTestCase):
super(TestScatterProfileToolBar, self).setUp()
self.plot = PlotWindow()
- self.profile = profile.ScatterProfileToolBar(plot=self.plot)
-
- self.plot.addToolBar(self.profile)
+ self.manager = manager.ProfileManager(plot=self.plot)
+ self.manager.setItemType(scatter=True)
+ self.manager.setActiveItemTracking(True)
self.plot.show()
self.qWaitForWindowExposed(self.plot)
def tearDown(self):
- del self.profile
+ del self.manager
self.qapp.processEvents()
self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
self.plot.close()
del self.plot
super(TestScatterProfileToolBar, self).tearDown()
- def testNoProfile(self):
- """Test ScatterProfileToolBar without profile"""
- self.assertEqual(self.profile.getPlotWidget(), self.plot)
-
- # Add a scatter plot
- self.plot.addScatter(
- x=(0., 1., 1., 0.), y=(0., 0., 1., 1.), value=(0., 1., 2., 3.))
- self.plot.resetZoom(dataMargins=(.1, .1, .1, .1))
- self.qapp.processEvents()
-
- # Check that there is no profile
- self.assertIsNone(self.profile.getProfileValues())
- self.assertIsNone(self.profile.getProfilePoints())
-
def testHorizontalProfile(self):
"""Test ScatterProfileToolBar horizontal profile"""
- nPoints = 8
- self.profile.setNPoints(nPoints)
- self.assertEqual(self.profile.getNPoints(), nPoints)
+
+ roiManager = self.manager.getRoiManager()
# Add a scatter plot
self.plot.addScatter(
@@ -86,46 +72,39 @@ class TestScatterProfileToolBar(TestCaseQt, ParametricTestCase):
self.plot.resetZoom(dataMargins=(.1, .1, .1, .1))
self.qapp.processEvents()
- # Activate Horizontal profile
- hlineAction = self.profile.actions()[0]
- hlineAction.trigger()
- self.qapp.processEvents()
-
# Set a ROI profile
- roi = roi_items.HorizontalLineROI()
+ roi = rois.ProfileScatterHorizontalLineROI()
roi.setPosition(0.5)
- self.profile._getRoiManager().addRoi(roi)
+ roi.setNPoints(8)
+ roiManager.addRoi(roi)
# Wait for async interpolator init
for _ in range(20):
self.qWait(200)
- if not self.profile.hasPendingOperations():
+ if not self.manager.hasPendingOperations():
break
self.qapp.processEvents()
- self.assertIsNotNone(self.profile.getProfileValues())
- points = self.profile.getProfilePoints()
- self.assertEqual(len(points), nPoints)
+ window = roi.getProfileWindow()
+ self.assertIsNotNone(window)
+ data = window.getProfile()
+ self.assertIsInstance(data, core.CurveProfileData)
+ self.assertEqual(len(data.coords), 8)
# Check that profile has same limits than Plot
xLimits = self.plot.getXAxis().getLimits()
- self.assertEqual(points[0, 0], xLimits[0])
- self.assertEqual(points[-1, 0], xLimits[1])
+ self.assertEqual(data.coords[0], xLimits[0])
+ self.assertEqual(data.coords[-1], xLimits[1])
# Clear the profile
- clearAction = self.profile.actions()[-1]
- clearAction.trigger()
+ self.manager.clearProfile()
self.qapp.processEvents()
-
- self.assertIsNone(self.profile.getProfileValues())
- self.assertIsNone(self.profile.getProfilePoints())
- self.assertEqual(self.profile.getProfileTitle(), '')
+ self.assertIsNone(roi.getProfileWindow())
def testVerticalProfile(self):
"""Test ScatterProfileToolBar vertical profile"""
- nPoints = 8
- self.profile.setNPoints(nPoints)
- self.assertEqual(self.profile.getNPoints(), nPoints)
+
+ roiManager = self.manager.getRoiManager()
# Add a scatter plot
self.plot.addScatter(
@@ -133,55 +112,52 @@ class TestScatterProfileToolBar(TestCaseQt, ParametricTestCase):
self.plot.resetZoom(dataMargins=(.1, .1, .1, .1))
self.qapp.processEvents()
- # Activate vertical profile
- vlineAction = self.profile.actions()[1]
- vlineAction.trigger()
- self.qapp.processEvents()
-
# Set a ROI profile
- roi = roi_items.VerticalLineROI()
+ roi = rois.ProfileScatterVerticalLineROI()
roi.setPosition(0.5)
- self.profile._getRoiManager().addRoi(roi)
+ roi.setNPoints(8)
+ roiManager.addRoi(roi)
# Wait for async interpolator init
for _ in range(10):
self.qWait(200)
- if not self.profile.hasPendingOperations():
+ if not self.manager.hasPendingOperations():
break
- self.assertIsNotNone(self.profile.getProfileValues())
- points = self.profile.getProfilePoints()
- self.assertEqual(len(points), nPoints)
+ window = roi.getProfileWindow()
+ self.assertIsNotNone(window)
+ data = window.getProfile()
+ self.assertIsInstance(data, core.CurveProfileData)
+ self.assertEqual(len(data.coords), 8)
# Check that profile has same limits than Plot
yLimits = self.plot.getYAxis().getLimits()
- self.assertEqual(points[0, 1], yLimits[0])
- self.assertEqual(points[-1, 1], yLimits[1])
+ self.assertEqual(data.coords[0], yLimits[0])
+ self.assertEqual(data.coords[-1], yLimits[1])
# Check that profile limits are updated when changing limits
self.plot.getYAxis().setLimits(yLimits[0] + 1, yLimits[1] + 10)
- self.qapp.processEvents()
+
+ # Wait for async interpolator init
+ for _ in range(10):
+ self.qWait(200)
+ if not self.manager.hasPendingOperations():
+ break
+
yLimits = self.plot.getYAxis().getLimits()
- points = self.profile.getProfilePoints()
- self.assertEqual(points[0, 1], yLimits[0])
- self.assertEqual(points[-1, 1], yLimits[1])
+ data = window.getProfile()
+ self.assertEqual(data.coords[0], yLimits[0])
+ self.assertEqual(data.coords[-1], yLimits[1])
- # Clear the plot
- self.plot.clear()
+ # Clear the profile
+ self.manager.clearProfile()
self.qapp.processEvents()
- self.assertIsNone(self.profile.getProfileValues())
- self.assertIsNone(self.profile.getProfilePoints())
+ self.assertIsNone(roi.getProfileWindow())
def testLineProfile(self):
"""Test ScatterProfileToolBar line profile"""
- nPoints = 8
- self.profile.setNPoints(nPoints)
- self.assertEqual(self.profile.getNPoints(), nPoints)
- # Activate line profile
- lineAction = self.profile.actions()[2]
- lineAction.trigger()
- self.qapp.processEvents()
+ roiManager = self.manager.getRoiManager()
# Add a scatter plot
self.plot.addScatter(
@@ -190,19 +166,22 @@ class TestScatterProfileToolBar(TestCaseQt, ParametricTestCase):
self.qapp.processEvents()
# Set a ROI profile
- roi = roi_items.LineROI()
+ roi = rois.ProfileScatterLineROI()
roi.setEndPoints(numpy.array([0., 0.]), numpy.array([1., 1.]))
- self.profile._getRoiManager().addRoi(roi)
+ roi.setNPoints(8)
+ roiManager.addRoi(roi)
# Wait for async interpolator init
for _ in range(10):
self.qWait(200)
- if not self.profile.hasPendingOperations():
+ if not self.manager.hasPendingOperations():
break
- self.assertIsNotNone(self.profile.getProfileValues())
- points = self.profile.getProfilePoints()
- self.assertEqual(len(points), nPoints)
+ window = roi.getProfileWindow()
+ self.assertIsNotNone(window)
+ data = window.getProfile()
+ self.assertIsInstance(data, core.CurveProfileData)
+ self.assertEqual(len(data.coords), 8)
def suite():
diff --git a/silx/gui/plot/tools/toolbars.py b/silx/gui/plot/tools/toolbars.py
index 04d0cfc..3df7d06 100644
--- a/silx/gui/plot/tools/toolbars.py
+++ b/silx/gui/plot/tools/toolbars.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2020 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
diff --git a/silx/gui/plot/utils/intersections.py b/silx/gui/plot/utils/intersections.py
new file mode 100644
index 0000000..53f2546
--- /dev/null
+++ b/silx/gui/plot/utils/intersections.py
@@ -0,0 +1,101 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module contains utils class for axes management.
+"""
+
+__authors__ = ["H. Payno", ]
+__license__ = "MIT"
+__date__ = "18/05/2020"
+
+
+import numpy
+
+
+def lines_intersection(line1_pt1, line1_pt2, line2_pt1, line2_pt2):
+ """
+ line segment intersection using vectors (Computer Graphics by F.S. Hill)
+
+ :param tuple line1_pt1:
+ :param tuple line1_pt2:
+ :param tuple line2_pt1:
+ :param tuple line2_pt2:
+ :return: Union[None,numpy.array]
+ """
+ dir_line1 = line1_pt2[0] - line1_pt1[0], line1_pt2[1] - line1_pt1[1]
+ dir_line2 = line2_pt2[0] - line2_pt1[0], line2_pt2[1] - line2_pt1[1]
+ dp = line1_pt1 - line2_pt1
+
+ def perp(a):
+ b = numpy.empty_like(a)
+ b[0] = -a[1]
+ b[1] = a[0]
+ return b
+
+ dap = perp(dir_line1)
+ denom = numpy.dot(dap, dir_line2)
+ num = numpy.dot(dap, dp)
+ if denom == 0:
+ return None
+ return (
+ (num / denom.astype(float)) * dir_line2[0] + line2_pt1[0],
+ (num / denom.astype(float)) * dir_line2[1] + line2_pt1[1])
+
+
+def segments_intersection(seg1_start_pt, seg1_end_pt, seg2_start_pt,
+ seg2_end_pt):
+ """
+ Compute intersection between two segments
+
+ :param seg1_start_pt:
+ :param seg1_end_pt:
+ :param seg2_start_pt:
+ :param seg2_end_pt:
+ :return: numpy.array if an intersection exists, else None
+ :rtype: Union[None,numpy.array]
+ """
+ intersection = lines_intersection(line1_pt1=seg1_start_pt,
+ line1_pt2=seg1_end_pt,
+ line2_pt1=seg2_start_pt,
+ line2_pt2=seg2_end_pt)
+ if intersection is not None:
+ max_x_seg1 = max(seg1_start_pt[0], seg1_end_pt[0])
+ max_x_seg2 = max(seg2_start_pt[0], seg2_end_pt[0])
+ max_y_seg1 = max(seg1_start_pt[1], seg1_end_pt[1])
+ max_y_seg2 = max(seg2_start_pt[1], seg2_end_pt[1])
+
+ min_x_seg1 = min(seg1_start_pt[0], seg1_end_pt[0])
+ min_x_seg2 = min(seg2_start_pt[0], seg2_end_pt[0])
+ min_y_seg1 = min(seg1_start_pt[1], seg1_end_pt[1])
+ min_y_seg2 = min(seg2_start_pt[1], seg2_end_pt[1])
+
+ min_tmp_x = max(min_x_seg1, min_x_seg2)
+ max_tmp_x = min(max_x_seg1, max_x_seg2)
+ min_tmp_y = max(min_y_seg1, min_y_seg2)
+ max_tmp_y = min(max_y_seg1, max_y_seg2)
+ if (min_tmp_x <= intersection[0] <= max_tmp_x and
+ min_tmp_y <= intersection[1] <= max_tmp_y):
+ return intersection
+ else:
+ return None
diff --git a/silx/gui/plot3d/SFViewParamTree.py b/silx/gui/plot3d/SFViewParamTree.py
index a2b771c..4e179fc 100644
--- a/silx/gui/plot3d/SFViewParamTree.py
+++ b/silx/gui/plot3d/SFViewParamTree.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2020 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
@@ -380,7 +380,7 @@ class _LightDirectionAngleBaseItem(SubjectItem):
editor.setOrientation(qt.Qt.Horizontal)
editor.setMinimum(-90)
editor.setMaximum(90)
- editor.setValue(self._pullData())
+ editor.setValue(int(self._pullData()))
# Wrapping call in lambda is a workaround for PySide with Python 3
editor.valueChanged.connect(
@@ -389,7 +389,7 @@ class _LightDirectionAngleBaseItem(SubjectItem):
return editor
def setEditorData(self, editor):
- editor.setValue(self._pullData())
+ editor.setValue(int(self._pullData()))
return True
def _setModelData(self, editor):
@@ -826,7 +826,7 @@ class _IsoLevelSlider(qt.QSlider):
if width > 0:
sliderWidth = self.maximum() - self.minimum()
sliderPosition = sliderWidth * (self.__norm(level) - min_) / width
- self.setValue(sliderPosition)
+ self.setValue(int(sliderPosition))
def __dataChanged(self):
"""Handles data update to refresh slider range if needed"""
diff --git a/silx/gui/plot3d/_model/items.py b/silx/gui/plot3d/_model/items.py
index 7f3921a..be51663 100644
--- a/silx/gui/plot3d/_model/items.py
+++ b/silx/gui/plot3d/_model/items.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -693,7 +693,7 @@ class _ColormapBaseProxyRow(ProxyRow):
"""
item = self.item()
if item is not None and self._colormap is not None:
- return self._colormap.getColormapRange(item._getDataRange())
+ return self._colormap.getColormapRange(item)
else:
return 1, 100 # Fallback
@@ -829,6 +829,55 @@ class _ColormapBoundRow(_ColormapBaseProxyRow):
return super(_ColormapBoundRow, self).setData(column, value, role)
+class _ColormapGammaRow(_ColormapBaseProxyRow):
+ """ProxyRow for colormap gamma normalization parameter
+
+ :param ColormapMixIn item: The item to handle
+ :param str name: Name of the raw
+ """
+
+ def __init__(self, item):
+ _ColormapBaseProxyRow.__init__(
+ self,
+ item,
+ name="Gamma",
+ fget=self._getGammaNormalizationParameter,
+ fset=self._setGammaNormalizationParameter)
+
+ self.setToolTip('Colormap gamma correction parameter:\n'
+ 'Only meaningful for gamma normalization.')
+
+ def _getGammaNormalizationParameter(self):
+ """Proxy for :meth:`Colormap.getGammaNormalizationParameter`"""
+ if self._colormap is not None:
+ return self._colormap.getGammaNormalizationParameter()
+ else:
+ return 0.0
+
+ def _setGammaNormalizationParameter(self, gamma):
+ """Proxy for :meth:`Colormap.setGammaNormalizationParameter`"""
+ if self._colormap is not None:
+ return self._colormap.setGammaNormalizationParameter(gamma)
+
+ def _getNormalization(self):
+ """Proxy for :meth:`Colormap.getNormalization`"""
+ if self._colormap is not None:
+ return self._colormap.getNormalization()
+ else:
+ return ''
+
+ def flags(self, column):
+ if column in (0, 1):
+ if self._getNormalization() == 'gamma':
+ flags = qt.Qt.ItemIsEditable | qt.Qt.ItemIsEnabled
+ else:
+ flags = qt.Qt.NoItemFlags # Disabled if not gamma correction
+ return flags
+
+ else: # Never event
+ return super(_ColormapGammaRow, self).flags(column)
+
+
class ColormapRow(_ColormapBaseProxyRow):
"""Represents :class:`ColormapMixIn` property.
@@ -862,6 +911,16 @@ class ColormapRow(_ColormapBaseProxyRow):
notify=self._sigColormapChanged,
editorHint=norms))
+ self.addRow(_ColormapGammaRow(item))
+
+ modes = [mode.title() for mode in self._colormap.AUTOSCALE_MODES]
+ self.addRow(ProxyRow(
+ name='Autoscale Mode',
+ fget=self._getAutoscaleMode,
+ fset=self._setAutoscaleMode,
+ notify=self._sigColormapChanged,
+ editorHint=modes))
+
self.addRow(_ColormapBoundRow(item, name='Min.', index=0))
self.addRow(_ColormapBoundRow(item, name='Max.', index=1))
@@ -908,6 +967,18 @@ class ColormapRow(_ColormapBaseProxyRow):
if self._colormap is not None:
return self._colormap.setNormalization(normalization.lower())
+ def _getAutoscaleMode(self):
+ """Proxy for :meth:`Colormap.getAutoscaleMode`"""
+ if self._colormap is not None:
+ return self._colormap.getAutoscaleMode().title()
+ else:
+ return ''
+
+ def _setAutoscaleMode(self, mode):
+ """Proxy for :meth:`Colormap.setAutoscaleMode`"""
+ if self._colormap is not None:
+ return self._colormap.setAutoscaleMode(mode.lower())
+
def _updateColormapImage(self, *args, **kwargs):
"""Notify colormap update to update the image in the tree"""
if self._colormapImage is not None:
diff --git a/silx/gui/plot3d/items/image.py b/silx/gui/plot3d/items/image.py
index 210f2f3..cfd1188 100644
--- a/silx/gui/plot3d/items/image.py
+++ b/silx/gui/plot3d/items/image.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -118,7 +118,7 @@ class ImageData(_Image, ColormapMixIn):
False to use as is (do not modify!).
"""
self._image.setData(data, copy=copy)
- ColormapMixIn._setRangeFromData(self, self.getData(copy=False))
+ self._setColormappedData(self.getData(copy=False), copy=False)
self._updated(ItemChangedType.DATA)
def getData(self, copy=True):
diff --git a/silx/gui/plot3d/items/mesh.py b/silx/gui/plot3d/items/mesh.py
index 3577dbf..4e19939 100644
--- a/silx/gui/plot3d/items/mesh.py
+++ b/silx/gui/plot3d/items/mesh.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -312,8 +312,7 @@ class ColormapMesh(_MeshBase, ColormapMixIn):
copy=copy)
self._setMesh(mesh)
- # Store data range info
- ColormapMixIn._setRangeFromData(self, self.getValueData(copy=False))
+ self._setColormappedData(self.getValueData(copy=False), copy=False)
def getData(self, copy=True):
"""Get the mesh geometry.
diff --git a/silx/gui/plot3d/items/mixins.py b/silx/gui/plot3d/items/mixins.py
index b355627..14cafc8 100644
--- a/silx/gui/plot3d/items/mixins.py
+++ b/silx/gui/plot3d/items/mixins.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -111,7 +111,6 @@ class ColormapMixIn(_ColormapMixIn):
def __init__(self, sceneColormap=None):
super(ColormapMixIn, self).__init__()
- self._dataRange = None
self.__sceneColormap = sceneColormap
self._syncSceneColormap()
@@ -120,37 +119,6 @@ class ColormapMixIn(_ColormapMixIn):
self._syncSceneColormap()
super(ColormapMixIn, self)._colormapChanged()
- def _setRangeFromData(self, data=None):
- """Compute the data range the colormap should use from provided data.
-
- :param data: Data set from which to compute the range or None
- """
- if data is None or data.size == 0:
- dataRange = None
- else:
- 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
-
- self._dataRange = dataRange
-
- colormap = self.getColormap()
- if None in (colormap.getVMin(), colormap.getVMax()):
- self._colormapChanged()
-
- def _getDataRange(self):
- """Returns the data range as used in the scene for colormap
-
- :rtype: Union[List[float],None]
- """
- return self._dataRange
-
def _setSceneColormap(self, sceneColormap):
"""Set the scene colormap to sync with Colormap object.
@@ -171,8 +139,8 @@ class ColormapMixIn(_ColormapMixIn):
self.__sceneColormap.colormap = colormap.getNColors()
self.__sceneColormap.norm = colormap.getNormalization()
- range_ = colormap.getColormapRange(data=self._dataRange)
- self.__sceneColormap.range_ = range_
+ self.__sceneColormap.gamma = colormap.getGammaNormalizationParameter()
+ self.__sceneColormap.range_ = colormap.getColormapRange(self)
class ComplexMixIn(_ComplexMixIn):
diff --git a/silx/gui/plot3d/items/scatter.py b/silx/gui/plot3d/items/scatter.py
index 5fce629..24abaa5 100644
--- a/silx/gui/plot3d/items/scatter.py
+++ b/silx/gui/plot3d/items/scatter.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -100,7 +100,7 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn):
self._scatter.setAttribute('z', z, copy=copy)
self._scatter.setAttribute('value', value, copy=copy)
- ColormapMixIn._setRangeFromData(self, self.getValueData(copy=False))
+ self._setColormappedData(self.getValueData(copy=False), copy=False)
self._updated(ItemChangedType.DATA)
def getData(self, copy=True):
@@ -366,8 +366,7 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn,
self._cachedLinesIndices = None
self._cachedTrianglesIndices = None
- # Store data range info
- ColormapMixIn._setRangeFromData(self, self.getValueData(copy=False))
+ self._setColormappedData(self.getValueData(copy=False), copy=False)
self._updateScene()
diff --git a/silx/gui/plot3d/items/volume.py b/silx/gui/plot3d/items/volume.py
index e0a2a1f..6c6562f 100644
--- a/silx/gui/plot3d/items/volume.py
+++ b/silx/gui/plot3d/items/volume.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -93,8 +93,12 @@ class CutPlane(Item3D, ColormapMixIn, InterpolationMixIn, PlaneMixIn):
# Store data range info as 3-tuple of values
self._dataRange = range_
- self._setRangeFromData(
- None if self._dataRange is None else numpy.array(self._dataRange))
+ if range_ is None:
+ range_ = None, None, None
+ self._setColormappedData(self._data, copy=False,
+ min_=range_[0],
+ minPositive=range_[1],
+ max_=range_[2])
self._updated(ItemChangedType.DATA)
@@ -724,15 +728,20 @@ class ComplexIsosurface(Isosurface, ComplexMixIn, ColormapMixIn):
def _syncDataWithParent(self):
"""Synchronize this instance data with that of its parent"""
- if self.getComplexMode() != self.ComplexMode.NONE:
- self._setRangeFromData(self.getColormappedData(copy=False))
-
parent = self.parent()
if parent is None:
self._data = None
else:
self._data = parent.getData(
mode=parent.getComplexMode(), copy=False)
+
+ if parent is None or self.getComplexMode() == self.ComplexMode.NONE:
+ self._setColormappedData(None, copy=False)
+ else:
+ self._setColormappedData(
+ parent.getData(mode=self.getComplexMode(), copy=False),
+ copy=False)
+
self._updateScenePrimitive()
def _parentChanged(self, event):
@@ -741,38 +750,16 @@ class ComplexIsosurface(Isosurface, ComplexMixIn, ColormapMixIn):
self._syncDataWithParent()
super(ComplexIsosurface, self)._parentChanged(event)
- def getColormappedData(self, copy=True):
- """Return 3D dataset used to apply the colormap on the isosurface.
-
- This depends on :meth:`getComplexMode`.
-
- :param bool copy:
- True (default) to get a copy,
- False to get the internal data (DO NOT modify!)
- :return: The data set (or None if not set)
- :rtype: Union[numpy.ndarray,None]
- """
- if self.getComplexMode() == self.ComplexMode.NONE:
- return None
- else:
- parent = self.parent()
- if parent is None:
- return None
- else:
- return parent.getData(mode=self.getComplexMode(), copy=copy)
-
def _updated(self, event=None):
"""Handle update of the isosurface (and take care of mode change)
:param ItemChangedType event: The kind of update
"""
- if (event == ItemChangedType.COMPLEX_MODE and
- self.getComplexMode() != self.ComplexMode.NONE):
- self._setRangeFromData(self.getColormappedData(copy=False))
+ if event == ItemChangedType.COMPLEX_MODE:
+ self._syncDataWithParent()
- if event in (ItemChangedType.COMPLEX_MODE,
- ItemChangedType.COLORMAP,
- Item3DChangedType.INTERPOLATION):
+ elif event in (ItemChangedType.COLORMAP,
+ Item3DChangedType.INTERPOLATION):
self._updateScenePrimitive()
super(ComplexIsosurface, self)._updated(event)
diff --git a/silx/gui/plot3d/scene/function.py b/silx/gui/plot3d/scene/function.py
index 7651f75..69a24dd 100644
--- a/silx/gui/plot3d/scene/function.py
+++ b/silx/gui/plot3d/scene/function.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2020 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
@@ -384,31 +384,44 @@ class DirectionalLight(event.Notifier, ProgramFunction):
class Colormap(event.Notifier, ProgramFunction):
_declTemplate = string.Template("""
- uniform struct {
- sampler2D texture;
- bool isLog;
- float min;
- float oneOverRange;
- } cmap;
+ uniform sampler2D cmap_texture;
+ uniform int cmap_normalization;
+ uniform float cmap_parameter;
+ uniform float cmap_min;
+ uniform float cmap_oneOverRange;
const float oneOverLog10 = 0.43429448190325176;
vec4 colormap(float value) {
- if (cmap.isLog) { /* Log10 mapping */
+ if (cmap_normalization == 1) { /* Log10 mapping */
if (value > 0.0) {
- value = clamp(cmap.oneOverRange *
- (oneOverLog10 * log(value) - cmap.min),
+ value = clamp(cmap_oneOverRange *
+ (oneOverLog10 * log(value) - cmap_min),
0.0, 1.0);
} else {
value = 0.0;
}
+ } else if (cmap_normalization == 2) { /* Sqrt mapping */
+ if (value > 0.0) {
+ value = clamp(cmap_oneOverRange * (sqrt(value) - cmap_min),
+ 0.0, 1.0);
+ } else {
+ value = 0.0;
+ }
+ } else if (cmap_normalization == 3) { /*Gamma correction mapping*/
+ value = pow(
+ clamp(cmap_oneOverRange * (value - cmap_min), 0.0, 1.0),
+ cmap_parameter);
+ } else if (cmap_normalization == 4) { /* arcsinh mapping */
+ /* asinh = log(x + sqrt(x*x + 1) for compatibility with GLSL 1.20 */
+ value = clamp(cmap_oneOverRange * (log(value + sqrt(value*value + 1.0)) - cmap_min), 0.0, 1.0);
} else { /* Linear mapping */
- value = clamp(cmap.oneOverRange * (value - cmap.min), 0.0, 1.0);
+ value = clamp(cmap_oneOverRange * (value - cmap_min), 0.0, 1.0);
}
$discard
- vec4 color = texture2D(cmap.texture, vec2(value, 0.5));
+ vec4 color = texture2D(cmap_texture, vec2(value, 0.5));
return color;
}
""")
@@ -421,18 +434,19 @@ class Colormap(event.Notifier, ProgramFunction):
call = "colormap"
- NORMS = 'linear', 'log'
+ NORMS = 'linear', 'log', 'sqrt', 'gamma', 'arcsinh'
"""Tuple of supported normalizations."""
_COLORMAP_TEXTURE_UNIT = 1
"""Texture unit to use for storing the colormap"""
- def __init__(self, colormap=None, norm='linear', range_=(1., 10.)):
+ def __init__(self, colormap=None, norm='linear', gamma=0., range_=(1., 10.)):
"""Shader function to apply a colormap to a value.
:param colormap: RGB(A) color look-up table (default: gray)
:param colormap: numpy.ndarray of numpy.uint8 of dimension Nx3 or Nx4
- :param str norm: Normalization to apply: 'linear' (default) or 'log'.
+ :param str norm: Normalization to apply: see :attr:`NORMS`.
+ :param float gamma: Gamma normalization parameter
:param range_: Range of value to map to the colormap.
:type range_: 2-tuple of float (begin, end).
"""
@@ -441,6 +455,7 @@ class Colormap(event.Notifier, ProgramFunction):
# Init privates to default
self._colormap = None
self._norm = 'linear'
+ self._gamma = -1.
self._range = 1., 10.
self._displayValuesBelowMin = True
@@ -456,6 +471,7 @@ class Colormap(event.Notifier, ProgramFunction):
# Set to param values through properties to go through asserts
self.colormap = colormap
self.norm = norm
+ self.gamma = gamma
self.range_ = range_
@property
@@ -482,8 +498,8 @@ class Colormap(event.Notifier, ProgramFunction):
def norm(self):
"""Normalization to use for colormap mapping.
- Either 'linear' (the default) or 'log' for log10 mapping.
- With 'log' normalization, values <= 0. are set to 1. (i.e. log == 0)
+ One of 'linear' (the default), 'log' for log10 mapping or 'sqrt'.
+ Invalid values (e.g., negative values with 'log' or 'sqrt') are mapped to 0.
"""
return self._norm
@@ -492,11 +508,23 @@ class Colormap(event.Notifier, ProgramFunction):
if norm != self._norm:
assert norm in self.NORMS
self._norm = norm
- if norm == 'log':
+ if norm in ('log', 'sqrt'):
self.range_ = self.range_ # To test for positive range_
self.notify()
@property
+ def gamma(self):
+ """Gamma correction normalization parameter (float >= 0.)"""
+ return self._gamma
+
+ @gamma.setter
+ def gamma(self, gamma):
+ if gamma != self._gamma:
+ assert gamma >= 0.
+ self._gamma = gamma
+ self.notify()
+
+ @property
def range_(self):
"""Range of values to map to the colormap.
@@ -517,6 +545,10 @@ class Colormap(event.Notifier, ProgramFunction):
"Log normalization and negative range: updating range.")
minPos = numpy.finfo(numpy.float32).tiny
range_ = max(range_[0], minPos), max(range_[1], minPos)
+ elif self.norm == 'sqrt' and (range_[0] < 0. or range_[1] < 0.):
+ _logger.warning(
+ "Sqrt normalization and negative range: updating range.")
+ range_ = max(range_[0], 0.), max(range_[1], 0.)
if range_ != self._range:
self._range = range_
@@ -549,16 +581,31 @@ class Colormap(event.Notifier, ProgramFunction):
self._texture.bind()
- gl.glUniform1i(program.uniforms['cmap.texture'],
+ gl.glUniform1i(program.uniforms['cmap_texture'],
self._texture.texUnit)
- gl.glUniform1i(program.uniforms['cmap.isLog'], self._norm == 'log')
min_, max_ = self.range_
+ param = 0.
if self._norm == 'log':
min_, max_ = numpy.log10(min_), numpy.log10(max_)
-
- gl.glUniform1f(program.uniforms['cmap.min'], min_)
- gl.glUniform1f(program.uniforms['cmap.oneOverRange'],
+ normID = 1
+ elif self._norm == 'sqrt':
+ min_, max_ = numpy.sqrt(min_), numpy.sqrt(max_)
+ normID = 2
+ elif self._norm == 'gamma':
+ # Keep min_, max_ as is
+ param = self._gamma
+ normID = 3
+ elif self._norm == 'arcsinh':
+ min_, max_ = numpy.arcsinh(min_), numpy.arcsinh(max_)
+ normID = 4
+ else: # Linear
+ normID = 0
+
+ gl.glUniform1i(program.uniforms['cmap_normalization'], normID)
+ gl.glUniform1f(program.uniforms['cmap_parameter'], param)
+ gl.glUniform1f(program.uniforms['cmap_min'], min_)
+ gl.glUniform1f(program.uniforms['cmap_oneOverRange'],
(1. / (max_ - min_)) if max_ != min_ else 0.)
def prepareGL2(self, context):
diff --git a/silx/gui/plot3d/tools/GroupPropertiesWidget.py b/silx/gui/plot3d/tools/GroupPropertiesWidget.py
index 5b0bcdb..ec995a3 100644
--- a/silx/gui/plot3d/tools/GroupPropertiesWidget.py
+++ b/silx/gui/plot3d/tools/GroupPropertiesWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2020 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
@@ -161,6 +161,8 @@ class GroupPropertiesWidget(qt.QWidget):
else:
itemCmap.setColormapLUT(colormap.getColormapLUT())
itemCmap.setNormalization(colormap.getNormalization())
+ itemCmap.setGammaNormalizationParameter(
+ colormap.getGammaNormalizationParameter())
itemCmap.setVRange(colormap.getVMin(), colormap.getVMax())
else:
# Reset colormap
diff --git a/silx/gui/plot3d/utils/mng.py b/silx/gui/plot3d/utils/mng.py
index fe79a52..8049a2f 100644
--- a/silx/gui/plot3d/utils/mng.py
+++ b/silx/gui/plot3d/utils/mng.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -108,7 +108,7 @@ def convert(images, nb_images=0, fps=25):
# Add filter 'None' before each scanline
prepared_data = b'\x00' + b'\x00'.join(
- line.tostring() for line in image) # TODO optimize that
+ line.tobytes() for line in image) # TODO optimize that
compressed_data = zlib.compress(prepared_data, 8)
# IDAT chunk: Payload
diff --git a/silx/gui/qt/_qt.py b/silx/gui/qt/_qt.py
index 9615342..29a6354 100644
--- a/silx/gui/qt/_qt.py
+++ b/silx/gui/qt/_qt.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2020 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
@@ -224,6 +224,12 @@ elif BINDING == 'PyQt5':
Slot = pyqtSlot
+ # Disable PyQt5's cooperative multi-inheritance since other bindings do not provide it.
+ # See https://www.riverbankcomputing.com/static/Docs/PyQt5/multiinheritance.html?highlight=inheritance
+ class _Foo(object): pass
+ class QObject(QObject, _Foo): pass
+
+
elif BINDING == 'PySide2':
_logger.debug('Using PySide2 bindings')
diff --git a/silx/gui/qt/_utils.py b/silx/gui/qt/_utils.py
index f5915ae..4a7a1c0 100644
--- a/silx/gui/qt/_utils.py
+++ b/silx/gui/qt/_utils.py
@@ -56,12 +56,16 @@ def silxGlobalThreadPool():
""""Manage an own QThreadPool to avoid issue on Qt5 Windows with the
default Qt global thread pool.
+ A thread pool is create in lazy loading. With a maximum of 4 threads.
+ Else `qt.Thread.idealThreadCount()` is used.
+
:rtype: qt.QThreadPool
"""
global __globalThreadPoolInstance
if __globalThreadPoolInstance is None:
tp = _qt.QThreadPool()
- # This pointless command fixes a segfault with PyQt 5.9.1 on Windows
- tp.setMaxThreadCount(tp.maxThreadCount())
+ # Setting maxThreadCount fixes a segfault with PyQt 5.9.1 on Windows
+ maxThreadCount = min(4, tp.maxThreadCount())
+ tp.setMaxThreadCount(maxThreadCount)
__globalThreadPoolInstance = tp
return __globalThreadPoolInstance
diff --git a/silx/gui/test/__init__.py b/silx/gui/test/__init__.py
index 8a9a949..2e7901d 100644
--- a/silx/gui/test/__init__.py
+++ b/silx/gui/test/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -106,7 +106,8 @@ def suite():
test_suite.addTest(test_icons.suite())
test_suite.addTest(test_colors.suite())
test_suite.addTest(test_data.suite())
- test_suite.addTest(test_utils.suite())
test_suite.addTest(test_plot3d_suite())
test_suite.addTest(test_dialog.suite())
+ # Run test_utils last: it interferes with OpenGLWidget through isOpenGLAvailable
+ test_suite.addTest(test_utils.suite())
return test_suite
diff --git a/silx/gui/test/test_colors.py b/silx/gui/test/test_colors.py
index 12387a3..f83ff58 100755
--- a/silx/gui/test/test_colors.py
+++ b/silx/gui/test/test_colors.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2020 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,8 +34,10 @@ __date__ = "09/11/2018"
import unittest
import numpy
from silx.utils.testutils import ParametricTestCase
+from silx.gui import qt
from silx.gui import colors
from silx.gui.colors import Colormap
+from silx.gui.plot import items
from silx.utils.exceptions import NotEditableError
@@ -94,6 +96,23 @@ class TestApplyColormapToData(ParametricTestCase):
result = colormap.applyToData(data=array)
self.assertTrue(numpy.all(numpy.equal(result, expected)))
+ def testAutoscaleFromDataReference(self):
+ colormap = Colormap(name='gray', normalization='linear')
+ data = numpy.array([50])
+ reference = numpy.array([0, 100])
+ value = colormap.applyToData(data, reference)
+ self.assertEqual(len(value), 1)
+ self.assertEqual(value[0, 0], 128)
+
+ def testAutoscaleFromItemReference(self):
+ colormap = Colormap(name='gray', normalization='linear')
+ data = numpy.array([50])
+ image = items.ImageData()
+ image.setData(numpy.array([[0, 100]]))
+ value = colormap.applyToData(data, reference=image)
+ self.assertEqual(len(value), 1)
+ self.assertEqual(value[0, 0], 128)
+
class TestDictAPI(unittest.TestCase):
"""Make sure the old dictionary API is working
@@ -406,6 +425,39 @@ class TestObjectAPI(ParametricTestCase):
self.assertIsNot(colormap, other)
self.assertEqual(colormap, other)
+ def testAutoscaleMode(self):
+ colormap = Colormap(autoscaleMode=Colormap.STDDEV3)
+ self.assertEqual(colormap.getAutoscaleMode(), Colormap.STDDEV3)
+ colormap.setAutoscaleMode(Colormap.MINMAX)
+ self.assertEqual(colormap.getAutoscaleMode(), Colormap.MINMAX)
+
+ def testStoreRestore(self):
+ colormaps = [
+ Colormap(name="viridis"),
+ Colormap(normalization=Colormap.SQRT)
+ ]
+ gamma = Colormap(normalization=Colormap.GAMMA)
+ gamma.setGammaNormalizationParameter(1.2)
+ colormaps.append(gamma)
+ for expected in colormaps:
+ with self.subTest(colormap=expected):
+ state = expected.saveState()
+ result = Colormap()
+ result.restoreState(state)
+ self.assertEqual(expected, result)
+
+ def testStorageV1(self):
+ state = b'\x00\x00\x00\x10\x00C\x00o\x00l\x00o\x00r\x00m\x00a\x00p\x00\x00'\
+ b'\x00\x01\x00\x00\x00\x0E\x00v\x00i\x00r\x00i\x00d\x00i\x00s\x00'\
+ b'\x00\x00\x00\x06\x00?\xF0\x00\x00\x00\x00\x00\x00\x00\x00\x00'\
+ b'\x00\x06\x00@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x06\x00'\
+ b'l\x00o\x00g'
+ state = qt.QByteArray(state)
+ colormap = Colormap()
+ colormap.restoreState(state)
+
+ expected = Colormap(name="viridis", vmin=1, vmax=2, normalization=Colormap.LOGARITHM)
+ self.assertEqual(colormap, expected)
class TestPreferredColormaps(unittest.TestCase):
"""Test get|setPreferredColormaps functions"""
@@ -484,6 +536,37 @@ class TestRegisteredLut(unittest.TestCase):
self.assertEqual(lut[0, 3], 128)
+class TestAutoscaleRange(ParametricTestCase):
+
+ def testAutoscaleRange(self):
+ nan = numpy.nan
+ data = [
+ # Positive values
+ (Colormap.LINEAR, Colormap.MINMAX, numpy.array([10, 20, 50]), (10, 50)),
+ (Colormap.LOGARITHM, Colormap.MINMAX, numpy.array([10, 50, 100]), (10, 100)),
+ (Colormap.LINEAR, Colormap.STDDEV3, numpy.array([10, 100]), (-80, 190)),
+ (Colormap.LOGARITHM, Colormap.STDDEV3, numpy.array([10, 100]), (1, 1000)),
+ # With nan
+ (Colormap.LINEAR, Colormap.MINMAX, numpy.array([10, 20, 50, nan]), (10, 50)),
+ (Colormap.LOGARITHM, Colormap.MINMAX, numpy.array([10, 50, 100, nan]), (10, 100)),
+ (Colormap.LINEAR, Colormap.STDDEV3, numpy.array([10, 100, nan]), (-80, 190)),
+ (Colormap.LOGARITHM, Colormap.STDDEV3, numpy.array([10, 100, nan]), (1, 1000)),
+ # With negative
+ (Colormap.LOGARITHM, Colormap.MINMAX, numpy.array([10, 50, 100, -50]), (10, 100)),
+ (Colormap.LOGARITHM, Colormap.STDDEV3, numpy.array([10, 100, -10]), (1, 1000)),
+ ]
+ for norm, mode, array, expectedRange in data:
+ with self.subTest(norm=norm, mode=mode, array=array):
+ colormap = Colormap()
+ colormap.setNormalization(norm)
+ colormap.setAutoscaleMode(mode)
+ vRange = colormap._computeAutoscaleRange(array)
+ if vRange is None:
+ self.assertIsNone(expectedRange)
+ else:
+ self.assertAlmostEqual(vRange[0], expectedRange[0])
+ self.assertAlmostEqual(vRange[1], expectedRange[1])
+
def suite():
test_suite = unittest.TestSuite()
loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
@@ -493,6 +576,7 @@ def suite():
test_suite.addTest(loadTests(TestObjectAPI))
test_suite.addTest(loadTests(TestPreferredColormaps))
test_suite.addTest(loadTests(TestRegisteredLut))
+ test_suite.addTest(loadTests(TestAutoscaleRange))
return test_suite
diff --git a/silx/gui/utils/__init__.py b/silx/gui/utils/__init__.py
index a4e442f..726ad74 100755
--- a/silx/gui/utils/__init__.py
+++ b/silx/gui/utils/__init__.py
@@ -48,6 +48,23 @@ def blockSignals(*objs):
obj.blockSignals(previous)
+class LockReentrant():
+ """Context manager to lock a code block and check the state.
+ """
+ def __init__(self):
+ self.__locked = False
+
+ def __enter__(self):
+ self.__locked = True
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.__locked = False
+
+ def locked(self):
+ """Returns True if the code block is locked"""
+ return self.__locked
+
+
def getQEventName(eventType):
"""
Returns the name of a QEvent.
diff --git a/silx/gui/utils/glutils.py b/silx/gui/utils/glutils.py
new file mode 100644
index 0000000..fca9a32
--- /dev/null
+++ b/silx/gui/utils/glutils.py
@@ -0,0 +1,199 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2020 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides the :func:`isOpenGLAvailable` utility function.
+"""
+
+import os
+import sys
+import subprocess
+from silx.gui import qt
+
+
+class _isOpenGLAvailableResult:
+ """Store result of checking OpenGL availability.
+
+ It provides a `status` boolean attribute storing the result of the check and
+ an `error` string attribute storting the possible error message.
+ """
+
+ def __init__(self, status=True, error=''):
+ self.__status = bool(status)
+ self.__error = str(error)
+
+ status = property(lambda self: self.__status, doc="True if OpenGL is working")
+ error = property(lambda self: self.__error, doc="Error message")
+
+ def __bool__(self):
+ return self.status
+
+ def __repr__(self):
+ return '<_isOpenGLAvailableResult: %s, "%s">' % (self.status, self.error)
+
+
+def _runtimeOpenGLCheck(version):
+ """Run OpenGL check in a subprocess.
+
+ This is done by starting a subprocess that displays a Qt OpenGL widget.
+
+ :param List[int] version:
+ The minimal required OpenGL version as a 2-tuple (major, minor).
+ Default: (2, 1)
+ :return: An error string that is empty if no error occured
+ :rtype: str
+ """
+ major, minor = str(version[0]), str(version[1])
+ env = os.environ.copy()
+ env['PYTHONPATH'] = os.pathsep.join(
+ [os.path.abspath(p) for p in sys.path])
+
+ try:
+ error = subprocess.check_output(
+ [sys.executable, __file__, major, minor],
+ env=env,
+ timeout=2)
+ except subprocess.TimeoutExpired:
+ status = False
+ error = "Qt OpenGL widget hang"
+ if sys.platform.startswith('linux'):
+ error += ':\nIf connected remotely, GLX forwarding might be disabled.'
+ except subprocess.CalledProcessError as e:
+ status = False
+ error = "Qt OpenGL widget error: retcode=%d, error=%s" % (e.returncode, e.output)
+ else:
+ status = True
+ error = error.decode()
+ return _isOpenGLAvailableResult(status, error)
+
+
+_runtimeCheckCache = {} # Cache runtime check results: {version: result}
+
+
+def isOpenGLAvailable(version=(2, 1), runtimeCheck=True):
+ """Check if OpenGL is available through Qt and actually working.
+
+ After some basic tests, this is done by starting a subprocess that
+ displays a Qt OpenGL widget.
+
+ :param List[int] version:
+ The minimal required OpenGL version as a 2-tuple (major, minor).
+ Default: (2, 1)
+ :param bool runtimeCheck:
+ True (default) to run the test creating a Qt OpenGL widgt in a subprocess,
+ False to avoid this check.
+ :return: A result object that evaluates to True if successful and
+ which has a `status` boolean attribute (True if successful) and
+ an `error` string attribute that is not empty if `status` is False.
+ """
+ error = ''
+
+ if sys.platform.startswith('linux') and not os.environ.get('DISPLAY', ''):
+ # On Linux and no DISPLAY available (e.g., ssh without -X)
+ error = 'DISPLAY environment variable not set'
+
+ else:
+ # Check pyopengl availability
+ try:
+ import silx.gui._glutils.gl # noqa
+ except ImportError:
+ error = "Cannot import OpenGL wrapper: pyopengl is not installed"
+ else:
+ # Pre checks for Qt < 5.4
+ if not hasattr(qt, 'QOpenGLWidget'):
+ if not qt.HAS_OPENGL:
+ error = '%s.QtOpenGL not available' % qt.BINDING
+
+ elif qt.QApplication.instance() and not qt.QGLFormat.hasOpenGL():
+ # qt.QGLFormat.hasOpenGL MUST be called with a QApplication created
+ # so this is only checked if the QApplication is already created
+ error = 'Qt reports OpenGL not available'
+
+ result = _isOpenGLAvailableResult(error == '', error)
+
+ if result: # No error so far, runtime check
+ if version in _runtimeCheckCache: # Use cache
+ result = _runtimeCheckCache[version]
+ elif runtimeCheck: # Run test in subprocess
+ result = _runtimeOpenGLCheck(version)
+ _runtimeCheckCache[version] = result
+
+ return result
+
+
+if __name__ == "__main__":
+ from silx.gui._glutils import OpenGLWidget
+ from silx.gui._glutils import gl
+ import argparse
+
+ class _TestOpenGLWidget(OpenGLWidget):
+ """Widget checking that OpenGL is indeed available
+
+ :param List[int] version: (major, minor) minimum OpenGL version
+ """
+
+ def __init__(self, version):
+ super(_TestOpenGLWidget, self).__init__(
+ alphaBufferSize=0,
+ depthBufferSize=0,
+ stencilBufferSize=0,
+ version=version)
+
+ def paintEvent(self, event):
+ super(_TestOpenGLWidget, self).paintEvent(event)
+
+ # Check once paint has been done
+ app = qt.QApplication.instance()
+ if not self.isValid():
+ print("OpenGL widget is not valid")
+ app.exit(1)
+ else:
+ qt.QTimer.singleShot(100, app.quit)
+
+ def paintGL(self):
+ gl.glClearColor(1., 0., 0., 0.)
+ gl.glClear(gl.GL_COLOR_BUFFER_BIT)
+
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument('major')
+ parser.add_argument('minor')
+
+ args = parser.parse_args(args=sys.argv[1:])
+
+ app = qt.QApplication([])
+ window = qt.QMainWindow(flags=
+ qt.Qt.Window |
+ qt.Qt.FramelessWindowHint |
+ qt.Qt.NoDropShadowWindowHint |
+ qt.Qt.WindowStaysOnTopHint)
+ window.setAttribute(qt.Qt.WA_ShowWithoutActivating)
+ window.move(0, 0)
+ window.resize(3, 3)
+ widget = _TestOpenGLWidget(version=(args.major, args.minor))
+ window.setCentralWidget(widget)
+ window.setWindowOpacity(0.04)
+ window.show()
+
+ qt.QTimer.singleShot(1000, app.quit)
+ sys.exit(app.exec_())
diff --git a/silx/gui/utils/qtutils.py b/silx/gui/utils/qtutils.py
index eb823a8..9682913 100755
--- a/silx/gui/utils/qtutils.py
+++ b/silx/gui/utils/qtutils.py
@@ -1,3 +1,29 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2020 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides the :func:`getQEventName` utility function."""
+
from silx.gui import qt
diff --git a/silx/gui/utils/test/__init__.py b/silx/gui/utils/test/__init__.py
index d500c05..41e0d6a 100755
--- a/silx/gui/utils/test/__init__.py
+++ b/silx/gui/utils/test/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2020 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,6 +33,7 @@ __date__ = "24/04/2018"
import unittest
from . import test_async
+from . import test_glutils
from . import test_image
from . import test_qtutils
from . import test_testutils
@@ -44,6 +45,7 @@ def suite():
test_suite = unittest.TestSuite()
test_suite.addTest(test.suite())
test_suite.addTest(test_async.suite())
+ test_suite.addTest(test_glutils.suite())
test_suite.addTest(test_image.suite())
test_suite.addTest(test_qtutils.suite())
test_suite.addTest(test_testutils.suite())
diff --git a/silx/gui/utils/test/test_glutils.py b/silx/gui/utils/test/test_glutils.py
new file mode 100644
index 0000000..66df8cf
--- /dev/null
+++ b/silx/gui/utils/test/test_glutils.py
@@ -0,0 +1,66 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2020 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Tests for the silx.gui.utils.glutils module."""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/01/2020"
+
+
+import logging
+import unittest
+from silx.gui.utils.glutils import isOpenGLAvailable
+
+
+_logger = logging.getLogger(__name__)
+
+
+class TestIsOpenGLAvailable(unittest.TestCase):
+ """Test isOpenGLAvailable"""
+
+ def test(self):
+ for version in ((2, 1), (2, 1), (1000, 1)):
+ with self.subTest(version=version):
+ result = isOpenGLAvailable(version=version)
+ _logger.info("isOpenGLAvailable returned: %s", str(result))
+ if version[0] == 1000:
+ self.assertFalse(result)
+ if not result:
+ self.assertFalse(result.status)
+ self.assertTrue(len(result.error) > 0)
+ else:
+ self.assertTrue(result.status)
+ self.assertTrue(len(result.error) == 0)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(
+ TestIsOpenGLAvailable))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/utils/testutils.py b/silx/gui/utils/testutils.py
index 14dcc3f..c086657 100644
--- a/silx/gui/utils/testutils.py
+++ b/silx/gui/utils/testutils.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2020 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
@@ -265,7 +265,7 @@ class TestCaseQt(unittest.TestCase):
"""
if modifier is None:
modifier = qt.Qt.KeyboardModifiers()
- pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint()
+ pos = qt.QPoint(int(pos[0]), int(pos[1])) if pos is not None else qt.QPoint()
QTest.mouseClick(widget, button, modifier, pos, delay)
self.qWait(20)
@@ -276,7 +276,7 @@ class TestCaseQt(unittest.TestCase):
"""
if modifier is None:
modifier = qt.Qt.KeyboardModifiers()
- pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint()
+ pos = qt.QPoint(int(pos[0]), int(pos[1])) if pos is not None else qt.QPoint()
QTest.mouseDClick(widget, button, modifier, pos, delay)
self.qWait(20)
@@ -285,7 +285,7 @@ class TestCaseQt(unittest.TestCase):
See QTest.mouseMove for details.
"""
- pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint()
+ pos = qt.QPoint(int(pos[0]), int(pos[1])) if pos is not None else qt.QPoint()
QTest.mouseMove(widget, pos, delay)
self.qWait(20)
@@ -296,7 +296,7 @@ class TestCaseQt(unittest.TestCase):
"""
if modifier is None:
modifier = qt.Qt.KeyboardModifiers()
- pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint()
+ pos = qt.QPoint(int(pos[0]), int(pos[1])) if pos is not None else qt.QPoint()
QTest.mousePress(widget, button, modifier, pos, delay)
self.qWait(20)
@@ -307,7 +307,7 @@ class TestCaseQt(unittest.TestCase):
"""
if modifier is None:
modifier = qt.Qt.KeyboardModifiers()
- pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint()
+ pos = qt.QPoint(int(pos[0]), int(pos[1])) if pos is not None else qt.QPoint()
QTest.mouseRelease(widget, button, modifier, pos, delay)
self.qWait(20)
@@ -316,7 +316,7 @@ class TestCaseQt(unittest.TestCase):
See QTest.qSleep for details.
"""
- QTest.qSleep(ms + self.TIMEOUT_WAIT)
+ QTest.qSleep(int(ms) + self.TIMEOUT_WAIT)
@classmethod
def qWait(cls, ms=None):
@@ -337,7 +337,7 @@ class TestCaseQt(unittest.TestCase):
maxtime=timeout)
timeout = endTimeMS - int(time.time() * 1000)
else:
- QTest.qWait(ms + cls.TIMEOUT_WAIT)
+ QTest.qWait(int(ms) + cls.TIMEOUT_WAIT)
def qWaitForWindowExposed(self, window, timeout=None):
"""Waits until the window is shown in the screen.
diff --git a/silx/gui/widgets/ElidedLabel.py b/silx/gui/widgets/ElidedLabel.py
new file mode 100644
index 0000000..58513c7
--- /dev/null
+++ b/silx/gui/widgets/ElidedLabel.py
@@ -0,0 +1,137 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2020 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 contains an elidable label
+"""
+
+__license__ = "MIT"
+__date__ = "07/12/2018"
+
+from silx.gui import qt
+
+
+class ElidedLabel(qt.QLabel):
+ """QLabel with an edile property.
+
+ By default if the text is too big, it is elided on the right.
+
+ This mode can be changed with :func:`setElideMode`.
+
+ In case the text is elided, the full content is displayed as part of the
+ tool tip. This behavior can be disabled with :func:`setTextAsToolTip`.
+ """
+
+ def __init__(self, parent=None):
+ super(ElidedLabel, self).__init__(parent)
+ self.__text = ""
+ self.__toolTip = ""
+ self.__textAsToolTip = True
+ self.__textIsElided = False
+ self.__elideMode = qt.Qt.ElideRight
+ self.__updateMinimumSize()
+
+ def resizeEvent(self, event):
+ self.__updateText()
+ return qt.QLabel.resizeEvent(self, event)
+
+ def setFont(self, font):
+ qt.QLabel.setFont(self, font)
+ self.__updateMinimumSize()
+ self.__updateText()
+
+ def __updateMinimumSize(self):
+ metrics = qt.QFontMetrics(self.font())
+ width = metrics.width("...")
+ self.setMinimumWidth(width)
+
+ def __updateText(self):
+ metrics = qt.QFontMetrics(self.font())
+ elidedText = metrics.elidedText(self.__text, self.__elideMode, self.width())
+ qt.QLabel.setText(self, elidedText)
+ wasElided = self.__textIsElided
+ self.__textIsElided = elidedText != self.__text
+ if self.__textIsElided or wasElided != self.__textIsElided:
+ self.__updateToolTip()
+
+ def __updateToolTip(self):
+ if self.__textIsElided and self.__textAsToolTip:
+ qt.QLabel.setToolTip(self, self.__text + "<br/>" + self.__toolTip)
+ else:
+ qt.QLabel.setToolTip(self, self.__toolTip)
+
+ # Properties
+
+ def setText(self, text):
+ self.__text = text
+ self.__updateText()
+
+ def getText(self):
+ return self.__text
+
+ text = qt.Property(str, getText, setText)
+
+ def setToolTip(self, toolTip):
+ self.__toolTip = toolTip
+ self.__updateToolTip()
+
+ def getToolTip(self):
+ return self.__toolTip
+
+ toolTip = qt.Property(str, getToolTip, setToolTip)
+
+ def setElideMode(self, elideMode):
+ """Set the elide mode.
+
+ :param qt.Qt.TextElideMode elidMode: Elide mode to use
+ """
+ self.__elideMode = elideMode
+ self.__updateText()
+
+ def getElideMode(self):
+ """Returns the used elide mode.
+
+ :rtype: qt.Qt.TextElideMode
+ """
+ return self.__elideMode
+
+ elideMode = qt.Property(qt.Qt.TextElideMode, getToolTip, setToolTip)
+
+ def setTextAsToolTip(self, enabled):
+ """Enable displaying text as part of the tooltip if it is elided.
+
+ :param bool enabled: Enable the behavior
+ """
+ if self.__textAsToolTip == enabled:
+ return
+ self.__textAsToolTip = enabled
+ self.__updateToolTip()
+
+ def getTextAsToolTip(self):
+ """True if an elided text is displayed as part of the tooltip.
+
+ :rtype: bool
+ """
+ return self.__textAsToolTip
+
+ textAsToolTip = qt.Property(bool, getTextAsToolTip, setTextAsToolTip)
diff --git a/silx/gui/widgets/LegendIconWidget.py b/silx/gui/widgets/LegendIconWidget.py
index 1a403cb..1c95e41 100755
--- a/silx/gui/widgets/LegendIconWidget.py
+++ b/silx/gui/widgets/LegendIconWidget.py
@@ -336,10 +336,11 @@ class LegendIconWidget(qt.QWidget):
pixmapRect = qt.QRect(0, 0, _COLORMAP_PIXMAP_SIZE, 1)
widthMargin = 0
halfHeight = 4
+ widgetRect = self.rect()
dest = qt.QRect(
- rect.left() + widthMargin,
- rect.center().y() - halfHeight + 1,
- rect.width() - widthMargin * 2,
+ widgetRect.left() + widthMargin,
+ widgetRect.center().y() - halfHeight + 1,
+ widgetRect.width() - widthMargin * 2,
halfHeight * 2,
)
painter.drawImage(dest, image, pixmapRect)
diff --git a/silx/gui/widgets/MultiModeAction.py b/silx/gui/widgets/MultiModeAction.py
new file mode 100644
index 0000000..502275d
--- /dev/null
+++ b/silx/gui/widgets/MultiModeAction.py
@@ -0,0 +1,83 @@
+# 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.
+#
+# ###########################################################################*/
+"""Action to hold many mode actions, usually for a tool bar.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__data__ = "22/04/2020"
+
+
+from silx.gui import qt
+
+
+class MultiModeAction(qt.QWidgetAction):
+ """This action provides a default checkable action from a list of checkable
+ actions.
+
+ The default action can be selected from a drop down list. The last one used
+ became the default one.
+
+ The default action is directly usable without using the drop down list.
+ """
+
+ def __init__(self, parent=None):
+ assert isinstance(parent, qt.QWidget)
+ qt.QWidgetAction.__init__(self, parent)
+ button = qt.QToolButton(parent)
+ button.setPopupMode(qt.QToolButton.MenuButtonPopup)
+ self.setDefaultWidget(button)
+ self.__button = button
+
+ def getMenu(self):
+ """Returns the menu.
+
+ :rtype: qt.QMenu
+ """
+ button = self.__button
+ menu = button.menu()
+ if menu is None:
+ menu = qt.QMenu(button)
+ button.setMenu(menu)
+ return menu
+
+ def addAction(self, action):
+ """Add a new action to the list.
+
+ :param qt.QAction action: New action
+ """
+ menu = self.getMenu()
+ button = self.__button
+ menu.addAction(action)
+ if button.defaultAction() is None:
+ button.setDefaultAction(action)
+ if action.isCheckable():
+ action.toggled.connect(self._toggled)
+
+ def _toggled(self, checked):
+ if checked:
+ action = self.sender()
+ button = self.__button
+ button.setDefaultAction(action)
diff --git a/silx/gui/widgets/RangeSlider.py b/silx/gui/widgets/RangeSlider.py
index c352147..31dbd4e 100644
--- a/silx/gui/widgets/RangeSlider.py
+++ b/silx/gui/widgets/RangeSlider.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2020 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
@@ -606,9 +606,9 @@ class RangeSlider(qt.QWidget):
-self._SLIDER_WIDTH, 0)
def __sliderAreaRect(self):
- return self.__drawArea().adjusted(self._SLIDER_WIDTH / 2.,
+ return self.__drawArea().adjusted(self._SLIDER_WIDTH // 2,
0,
- -self._SLIDER_WIDTH / 2. + 1,
+ -self._SLIDER_WIDTH // 2 + 1,
0)
def __pixMapRect(self):
@@ -722,7 +722,7 @@ class RangeSlider(qt.QWidget):
buttonColor = option.palette.button().color()
val = qt.qGray(buttonColor.rgb())
buttonColor = buttonColor.lighter(100 + max(1, (180 - val) // 6))
- buttonColor.setHsv(buttonColor.hue(), buttonColor.saturation() * 0.75, buttonColor.value())
+ buttonColor.setHsv(buttonColor.hue(), (buttonColor.saturation() * 3) // 4, buttonColor.value())
grooveColor = qt.QColor()
grooveColor.setHsv(buttonColor.hue(),
diff --git a/silx/gui/widgets/UrlSelectionTable.py b/silx/gui/widgets/UrlSelectionTable.py
index 4ac0381..27ea363 100644
--- a/silx/gui/widgets/UrlSelectionTable.py
+++ b/silx/gui/widgets/UrlSelectionTable.py
@@ -74,6 +74,14 @@ class UrlSelectionTable(TableWidget):
self.setSortingEnabled(True)
self._checkBoxes = {}
+ def setUrls(self, urls: list) -> None:
+ """
+
+ :param urls: urls to be displayed
+ """
+ for url in urls:
+ self.addUrl(url=url)
+
def addUrl(self, url, **kwargs):
"""
diff --git a/silx/gui/widgets/test/__init__.py b/silx/gui/widgets/test/__init__.py
index 8d179bc..b868171 100644
--- a/silx/gui/widgets/test/__init__.py
+++ b/silx/gui/widgets/test/__init__.py
@@ -33,6 +33,7 @@ from . import test_framebrowser
from . import test_boxlayoutdockwidget
from . import test_rangeslider
from . import test_flowlayout
+from . import test_elidedlabel
__authors__ = ["V. Valls", "P. Knobel"]
__license__ = "MIT"
@@ -51,5 +52,6 @@ def suite():
test_boxlayoutdockwidget.suite(),
test_rangeslider.suite(),
test_flowlayout.suite(),
+ test_elidedlabel.suite(),
])
return test_suite
diff --git a/silx/gui/widgets/test/test_elidedlabel.py b/silx/gui/widgets/test/test_elidedlabel.py
new file mode 100644
index 0000000..2856733
--- /dev/null
+++ b/silx/gui/widgets/test/test_elidedlabel.py
@@ -0,0 +1,111 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2020 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Tests for ElidedLabel"""
+
+__license__ = "MIT"
+__date__ = "08/06/2020"
+
+import unittest
+
+from silx.gui import qt
+from silx.gui.widgets.ElidedLabel import ElidedLabel
+from silx.gui.utils import testutils
+
+
+class TestElidedLabel(testutils.TestCaseQt):
+
+ def setUp(self):
+ self.label = ElidedLabel()
+ self.label.show()
+ self.qWaitForWindowExposed(self.label)
+
+ def tearDown(self):
+ self.label.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.label.close()
+ del self.label
+ self.qapp.processEvents()
+
+ def testElidedValue(self):
+ """Test elided text"""
+ raw = "mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm"
+ self.label.setText(raw)
+ self.label.setFixedWidth(30)
+ displayedText = qt.QLabel.text(self.label)
+ self.assertNotEqual(raw, displayedText)
+ self.assertIn("…", displayedText)
+ self.assertIn("m", displayedText)
+
+ def testNotElidedValue(self):
+ """Test elided text"""
+ raw = "mmmmmmm"
+ self.label.setText(raw)
+ self.label.setFixedWidth(200)
+ displayedText = qt.QLabel.text(self.label)
+ self.assertNotIn("…", displayedText)
+ self.assertEqual(raw, displayedText)
+
+ def testUpdateFromElidedToNotElided(self):
+ """Test tooltip when not elided"""
+ raw1 = "mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm"
+ raw2 = "nn"
+ self.label.setText(raw1)
+ self.label.setFixedWidth(30)
+ self.label.setText(raw2)
+ displayedTooltip = qt.QLabel.toolTip(self.label)
+ self.assertNotIn(raw1, displayedTooltip)
+ self.assertNotIn(raw2, displayedTooltip)
+
+ def testUpdateFromNotElidedToElided(self):
+ """Test tooltip when elided"""
+ raw1 = "nn"
+ raw2 = "mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm"
+ self.label.setText(raw1)
+ self.label.setFixedWidth(30)
+ self.label.setText(raw2)
+ displayedTooltip = qt.QLabel.toolTip(self.label)
+ self.assertNotIn(raw1, displayedTooltip)
+ self.assertIn(raw2, displayedTooltip)
+
+ def testUpdateFromElidedToElided(self):
+ """Test tooltip when elided"""
+ raw1 = "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"
+ raw2 = "mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm"
+ self.label.setText(raw1)
+ self.label.setFixedWidth(30)
+ self.label.setText(raw2)
+ displayedTooltip = qt.QLabel.toolTip(self.label)
+ self.assertNotIn(raw1, displayedTooltip)
+ self.assertIn(raw2, displayedTooltip)
+
+
+def suite():
+ loader = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(loader(TestElidedLabel))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/image/_boundingbox.py b/silx/image/_boundingbox.py
new file mode 100644
index 0000000..1c086b1
--- /dev/null
+++ b/silx/image/_boundingbox.py
@@ -0,0 +1,100 @@
+# 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.
+#
+# ###########################################################################*/
+"""offer some generic 2D bounding box features
+"""
+
+__authors__ = ["H. Payno"]
+__license__ = "MIT"
+__date__ = "07/09/2019"
+
+from silx.math.combo import min_max
+import numpy
+
+
+class _BoundingBox:
+ """
+ Simple 2D bounding box
+
+ :param tuple bottom_left: (y, x) bottom left point
+ :param tuple top_right: (y, x) top right point
+ """
+ def __init__(self, bottom_left, top_right):
+ self.bottom_left = bottom_left
+ self.top_right = top_right
+ self.min_x = bottom_left[1]
+ self.min_y = bottom_left[0]
+ self.max_x = top_right[1]
+ self.max_y = top_right[0]
+
+ def contains(self, item):
+ """
+
+ :param item: bouding box or point. If it is bounding check check if the
+ bottom_left and top_right corner of the given bounding box
+ are inside the current bounding box
+ :type: Union[BoundingBox, tuple]
+ :return:
+ """
+ if isinstance(item, _BoundingBox):
+ return self.contains(item.bottom_left) and self.contains(item.top_right)
+ else:
+ return (
+ (self.min_x <= item[1] <= self.max_x) and
+ (self.min_y <= item[0] <= self.max_y)
+ )
+
+ def collide(self, bb):
+ """
+ Check if the two bounding box collide
+
+ :param bb: bounding box to compare with
+ :type: :class:BoundingBox
+ :return: True if the two boxes collides
+ :rtype: bool
+ """
+ assert isinstance(bb, _BoundingBox)
+ return (
+ (self.min_x < bb.max_x and self.max_x > bb.min_x) and
+ (self.min_y < bb.max_y and self.max_y > bb.min_y)
+ )
+
+ @staticmethod
+ def from_points(points):
+ """
+
+ :param numpy.array tuple points: list of points. Should be 2D:
+ [(y1, x1), (y2, x2), (y3, x3), ...]
+ :return: bounding box from two points
+ :rtype: _BoundingBox
+ """
+ if not isinstance(points, numpy.ndarray):
+ points_ = numpy.ndarray(points)
+ else:
+ points_ = points
+ x = points_[:, 1]
+ y = points_[:, 0]
+ x_min, x_max = min_max(x)
+ y_min, y_max = min_max(y)
+ return _BoundingBox(bottom_left=(y_min, x_min), top_right=(y_max, x_max))
diff --git a/silx/image/medianfilter.py b/silx/image/medianfilter.py
index 5d98b48..857f73d 100644
--- a/silx/image/medianfilter.py
+++ b/silx/image/medianfilter.py
@@ -78,7 +78,7 @@ def medfilt2d(image, kernel_size=3, engine='cpp'):
err += '%s' % engine
raise ValueError(err)
- if len(image.shape) is not 2:
+ if len(image.shape) != 2:
raise ValueError('medfilt2d deals with arrays of dimension 2 only')
if engine == 'cpp':
diff --git a/silx/image/test/__init__.py b/silx/image/test/__init__.py
index db99c2f..f469edc 100644
--- a/silx/image/test/__init__.py
+++ b/silx/image/test/__init__.py
@@ -32,6 +32,7 @@ from . import test_bilinear
from . import test_shapes
from . import test_medianfilter
from . import test_tomography
+from . import test_bb
from ..marchingsquares.test import suite as marchingsquares_suite
@@ -43,4 +44,5 @@ def suite():
test_suite.addTest(test_shapes.suite())
test_suite.addTest(test_tomography.suite())
test_suite.addTest(marchingsquares_suite())
+ test_suite.addTest(test_bb.suite())
return test_suite
diff --git a/silx/image/test/test_bb.py b/silx/image/test/test_bb.py
new file mode 100644
index 0000000..3f33e80
--- /dev/null
+++ b/silx/image/test/test_bb.py
@@ -0,0 +1,86 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Basic tests for Bounding box"""
+
+__authors__ = ["H. Payno"]
+__license__ = "MIT"
+__date__ = "27/09/2019"
+
+
+import unittest
+import numpy
+from silx.image._boundingbox import _BoundingBox
+
+
+class TestBB(unittest.TestCase):
+ """Some simple test on the bounding box class"""
+ def test_creation(self):
+ """test some constructors"""
+ pts = numpy.array([(0, 0), (10, 20), (20, 0)])
+ bb = _BoundingBox.from_points(pts)
+ self.assertTrue(bb.bottom_left == (0, 0))
+ self.assertTrue(bb.top_right == (20, 20))
+ pts = numpy.array([(0, 10), (10, 20), (45, 30), (35, 0)])
+ bb = _BoundingBox.from_points(pts)
+ self.assertTrue(bb.bottom_left == (0, 0))
+ print(bb.top_right)
+ self.assertTrue(bb.top_right == (45, 30))
+
+ def test_isIn_pt(self):
+ """test the isIn function with points"""
+ bb = _BoundingBox(bottom_left=(6, 2), top_right=(12, 6))
+ self.assertTrue(bb.contains((10, 4)))
+ self.assertTrue(bb.contains((6, 2)))
+ self.assertTrue(bb.contains((12, 2)))
+ self.assertFalse(bb.contains((0, 0)))
+ self.assertFalse(bb.contains((20, 0)))
+ self.assertFalse(bb.contains((10, 0)))
+
+ def test_collide(self):
+ """test the collide function"""
+ bb1 = _BoundingBox(bottom_left=(6, 2), top_right=(12, 6))
+ self.assertTrue(bb1.collide(_BoundingBox(bottom_left=(6, 2), top_right=(12, 6))))
+ bb1 = _BoundingBox(bottom_left=(6, 2), top_right=(12, 6))
+ self.assertFalse(bb1.collide(_BoundingBox(bottom_left=(12, 2), top_right=(12, 2))))
+
+ def test_isIn_bb(self):
+ """test the isIn function with other bounding box"""
+ bb1 = _BoundingBox(bottom_left=(6, 2), top_right=(12, 6))
+ self.assertTrue(bb1.contains(_BoundingBox(bottom_left=(6, 2), top_right=(12, 6))))
+ bb1 = _BoundingBox(bottom_left=(6, 2), top_right=(12, 6))
+ self.assertTrue(bb1.contains(_BoundingBox(bottom_left=(12, 2), top_right=(12, 2))))
+ self.assertFalse(_BoundingBox(bottom_left=(12, 2), top_right=(12, 2)).contains(bb1))
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ for TestClass in (TestBB,):
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestClass))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/io/dictdump.py b/silx/io/dictdump.py
index da1bc5c..f2318e0 100644
--- a/silx/io/dictdump.py
+++ b/silx/io/dictdump.py
@@ -1,6 +1,6 @@
# coding: utf-8
# /*##########################################################################
-# Copyright (C) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (C) 2016-2020 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
@@ -154,14 +154,19 @@ def dicttoh5(treedict, h5file, h5path='/',
any other data type, it is cast into a numpy array and written as a
:mod:`h5py` dataset. Dictionary keys must be strings and cannot contain
the ``/`` character.
+
+ If dictionary keys are tuples they are interpreted to set h5 attributes.
+ The tuples should have the format (dataset_name,attr_name)
.. note::
This function requires `h5py <http://www.h5py.org/>`_ to be installed.
- :param treedict: Nested dictionary/tree structure with strings as keys
- and array-like objects as leafs. The ``"/"`` character is not allowed
- in keys.
+ :param treedict: Nested dictionary/tree structure with strings or tuples as
+ keys and array-like objects as leafs. The ``"/"`` character can be used
+ to define sub trees. If tuples are used as keys they should have the
+ format (dataset_name,attr_name) and will add a 5h attribute with the
+ corresponding value.
:param h5file: HDF5 file name or handle. If a file name is provided, the
function opens the file in the specified mode and closes it again
before completing.
@@ -186,10 +191,12 @@ def dicttoh5(treedict, h5file, h5path='/',
"Europe": {
"France": {
"Isère": {
- "Grenoble": "18.44 km2"
+ "Grenoble": 18.44,
+ ("Grenoble","unit"): "km2"
},
"Nord": {
- "Tourcoing": "15.19 km2"
+ "Tourcoing": 15.19,
+ ("Tourcoing","unit"): "km2"
},
},
},
@@ -207,7 +214,11 @@ def dicttoh5(treedict, h5file, h5path='/',
h5path += "/"
with _SafeH5FileWrite(h5file, mode=mode) as h5f:
- for key in treedict:
+ if isinstance(treedict, dict) and h5path != "/":
+ if h5path not in h5f:
+ h5f.create_group(h5path)
+
+ for key in filter(lambda k: not isinstance(k, tuple), treedict):
if isinstance(treedict[key], dict) and len(treedict[key]):
# non-empty group: recurse
dicttoh5(treedict[key], h5f, h5path + key,
@@ -253,6 +264,106 @@ def dicttoh5(treedict, h5file, h5path='/',
data=ds,
**create_dataset_args)
+ # deal with h5 attributes which have tuples as keys in treedict
+ for key in filter(lambda k: isinstance(k, tuple), treedict):
+ if (h5path + key[0]) not in h5f:
+ # Create empty group if key for attr does not exist
+ h5f.create_group(h5path + key[0])
+ logger.warning(
+ "key (%s) does not exist. attr %s "
+ "will be written to ." % (h5path + key[0], key[1])
+ )
+
+ if key[1] in h5f[h5path + key[0]].attrs:
+ if not overwrite_data:
+ logger.warning(
+ "attribute %s@%s already exists. Not overwriting."
+ "" % (h5path + key[0], key[1])
+ )
+ continue
+
+ # Write attribute
+ value = treedict[key]
+
+ # Makes list/tuple of str being encoded as vlen unicode array
+ # Workaround for h5py<2.9.0 (e.g. debian 10).
+ if (isinstance(value, (list, tuple)) and
+ numpy.asarray(value).dtype.type == numpy.unicode_):
+ value = numpy.array(value, dtype=h5py.special_dtype(vlen=str))
+
+ h5f[h5path + key[0]].attrs[key[1]] = value
+
+
+def dicttonx(
+ treedict,
+ h5file,
+ h5path="/",
+ mode="w",
+ overwrite_data=False,
+ create_dataset_args=None,
+):
+ """
+ Write a nested dictionary to a HDF5 file, using string keys as member names.
+ The NeXus convention is used to identify attributes with ``"@"`` character,
+ therefor the dataset_names should not contain ``"@"``.
+
+ :param treedict: Nested dictionary/tree structure with strings as keys
+ and array-like objects as leafs. The ``"/"`` character can be used
+ to define sub tree. The ``"@"`` character is used to write attributes.
+
+ Detais on all other params can be found in doc of dicttoh5.
+
+ Example::
+
+ import numpy
+ from silx.io.dictdump import dicttonx
+
+ gauss = {
+ "entry":{
+ "title":u"A plot of a gaussian",
+ "plot": {
+ "y": numpy.array([0.08, 0.19, 0.39, 0.66, 0.9, 1.,
+ 0.9, 0.66, 0.39, 0.19, 0.08]),
+ "x": numpy.arange(0,1.1,.1),
+ "@signal": "y",
+ "@axes": "x",
+ "@NX_class":u"NXdata",
+ "title:u"Gauss Plot",
+ },
+ "@NX_class":u"NXentry",
+ "default":"plot",
+ }
+ "@NX_class": u"NXroot",
+ "@default": "entry",
+ }
+
+ dicttonx(gauss,"test.h5")
+ """
+
+ def copy_keys_keep_values(original):
+ # create a new treedict with with modified keys but keep values
+ copy = dict()
+ for key, value in original.items():
+ if "@" in key:
+ newkey = tuple(key.rsplit("@", 1))
+ else:
+ newkey = key
+ if isinstance(value, dict):
+ copy[newkey] = copy_keys_keep_values(value)
+ else:
+ copy[newkey] = value
+ return copy
+
+ nxtreedict = copy_keys_keep_values(treedict)
+ dicttoh5(
+ nxtreedict,
+ h5file,
+ h5path=h5path,
+ mode=mode,
+ overwrite_data=overwrite_data,
+ create_dataset_args=create_dataset_args,
+ )
+
def _name_contains_string_in_list(name, strlist):
if strlist is None:
diff --git a/silx/io/nxdata/parse.py b/silx/io/nxdata/parse.py
index cce47ab..6bd18d6 100644
--- a/silx/io/nxdata/parse.py
+++ b/silx/io/nxdata/parse.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2020 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
@@ -41,6 +41,7 @@ Other public functions:
"""
+import json
import numpy
import six
@@ -53,13 +54,83 @@ from ._utils import get_attr_as_unicode, INTERPDIM, nxdata_logger, \
__authors__ = ["P. Knobel"]
__license__ = "MIT"
-__date__ = "15/02/2019"
+__date__ = "24/03/2020"
class InvalidNXdataError(Exception):
pass
+class _SilxStyle(object):
+ """NXdata@SILX_style parser.
+
+ :param NXdata nxdata:
+ NXdata description for which to extract silx_style information.
+ """
+
+ def __init__(self, nxdata):
+ naxes = len(nxdata.axes)
+ self._axes_scale_types = [None] * naxes
+ self._signal_scale_type = None
+
+ stylestr = get_attr_as_unicode(nxdata.group, "SILX_style")
+ if stylestr is None:
+ return
+
+ try:
+ style = json.loads(stylestr)
+ except json.JSONDecodeError:
+ nxdata_logger.error(
+ "Ignoring SILX_style, cannot parse: %s", stylestr)
+ return
+
+ if not isinstance(style, dict):
+ nxdata_logger.error(
+ "Ignoring SILX_style, cannot parse: %s", stylestr)
+
+ if 'axes_scale_types' in style:
+ axes_scale_types = style['axes_scale_types']
+
+ if isinstance(axes_scale_types, str):
+ # Convert single argument to list
+ axes_scale_types = [axes_scale_types]
+
+ if not isinstance(axes_scale_types, list):
+ nxdata_logger.error(
+ "Ignoring SILX_style:axes_scale_types, not a list")
+ else:
+ for scale_type in axes_scale_types:
+ if scale_type not in ('linear', 'log'):
+ nxdata_logger.error(
+ "Ignoring SILX_style:axes_scale_types, invalid value: %s", str(scale_type))
+ break
+ else: # All values are valid
+ if len(axes_scale_types) > naxes:
+ nxdata_logger.error(
+ "Clipping SILX_style:axes_scale_types, too many values")
+ axes_scale_types = axes_scale_types[:naxes]
+ elif len(axes_scale_types) < naxes:
+ # Extend axes_scale_types with None to match number of axes
+ axes_scale_types = [None] * (naxes - len(axes_scale_types)) + axes_scale_types
+ self._axes_scale_types = tuple(axes_scale_types)
+
+ if 'signal_scale_type' in style:
+ scale_type = style['signal_scale_type']
+ if scale_type not in ('linear', 'log'):
+ nxdata_logger.error(
+ "Ignoring SILX_style:signal_scale_type, invalid value: %s", str(scale_type))
+ else:
+ self._signal_scale_type = scale_type
+
+ axes_scale_types = property(
+ lambda self: self._axes_scale_types,
+ doc="Tuple of NXdata axes scale types (None, 'linear' or 'log'). List[str]")
+
+ signal_scale_type = property(
+ lambda self: self._signal_scale_type,
+ doc="NXdata signal scale type (None, 'linear' or 'log'). str")
+
+
class NXdata(object):
"""NXdata parser.
@@ -76,6 +147,7 @@ class NXdata(object):
"""
def __init__(self, group, validate=True):
super(NXdata, self).__init__()
+ self._plot_style = None
self.group = group
"""h5py-like group object with @NX_class=NXdata.
@@ -147,6 +219,8 @@ class NXdata(object):
# excludes scatters
self.signal_is_1d = self.signal_is_1d and len(self.axes) <= 1 # excludes n-D scatters
+ self._plot_style = _SilxStyle(self)
+
def _validate(self):
"""Fill :attr:`issues` with error messages for each error found."""
if not is_group(self.group):
@@ -250,8 +324,18 @@ class NXdata(object):
"dimensions as axis '%s'." % axis_name)
# test dimensions of errors associated with signal
+
+ signal_errors = signal_name + "_errors"
if "errors" in self.group and is_dataset(self.group["errors"]):
- if self.group["errors"].shape != self.group[signal_name].shape:
+ errors = "errors"
+ elif signal_errors in self.group and is_dataset(self.group[signal_errors]):
+ errors = signal_errors
+ else:
+ errors = None
+ if errors:
+ if self.group[errors].shape != self.group[signal_name].shape:
+ # In principle just the same size should be enough but
+ # NeXus documentation imposes to have the same shape
self.issues.append(
"Dataset containing standard deviations must " +
"have the same dimensions as the signal.")
@@ -629,9 +713,26 @@ class NXdata(object):
if not self.is_valid:
raise InvalidNXdataError("Unable to parse invalid NXdata")
- if "errors" not in self.group:
+ # case of signal
+ signal_errors = self.signal_dataset_name + "_errors"
+ if "errors" in self.group and is_dataset(self.group["errors"]):
+ errors = "errors"
+ elif signal_errors in self.group and is_dataset(self.group[signal_errors]):
+ errors = signal_errors
+ else:
return None
- return self.group["errors"]
+ return self.group[errors]
+
+ @property
+ def plot_style(self):
+ """Information extracted from the optional SILX_style attribute
+
+ :raises: InvalidNXdataError
+ """
+ if not self.is_valid:
+ raise InvalidNXdataError("Unable to parse invalid NXdata")
+
+ return self._plot_style
@property
def is_scatter(self):
diff --git a/silx/io/octaveh5.py b/silx/io/octaveh5.py
index 04e3890..84fa726 100644
--- a/silx/io/octaveh5.py
+++ b/silx/io/octaveh5.py
@@ -1,6 +1,6 @@
# coding: utf-8
# /*##########################################################################
-# Copyright (C) 2016 European Synchrotron Radiation Facility
+# Copyright (C) 2016-2020 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
@@ -44,7 +44,7 @@ Here is an example of a simple read and write :
strucDict = reader.get('mt_struct_name')
.. note:: These functions depend on the `h5py <http://www.h5py.org/>`_
- library, which is not a mandatory dependency for `silx`.
+ library, which is a mandatory dependency for `silx`.
"""
diff --git a/silx/io/test/test_dictdump.py b/silx/io/test/test_dictdump.py
index 12e13f5..c0b6914 100644
--- a/silx/io/test/test_dictdump.py
+++ b/silx/io/test/test_dictdump.py
@@ -1,6 +1,6 @@
# coding: utf-8
# /*##########################################################################
-# Copyright (C) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (C) 2016-2020 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -39,6 +39,7 @@ from collections import defaultdict
from silx.utils.testutils import TestLogging
from ..configdict import ConfigDict
+from .. import dictdump
from ..dictdump import dicttoh5, dicttojson, dump
from ..dictdump import h5todict, load
from ..dictdump import logger as dictdump_logger
@@ -64,7 +65,8 @@ class TestDictToH5(unittest.TestCase):
self.h5_fname = os.path.join(self.tempdir, "cityattrs.h5")
def tearDown(self):
- os.unlink(self.h5_fname)
+ if os.path.exists(self.h5_fname):
+ os.unlink(self.h5_fname)
os.rmdir(self.tempdir)
def testH5CityAttrs(self):
@@ -73,7 +75,7 @@ class TestDictToH5(unittest.TestCase):
dicttoh5(city_attrs, self.h5_fname, h5path='/city attributes',
mode="w", create_dataset_args=filters)
- h5f = h5py.File(self.h5_fname)
+ h5f = h5py.File(self.h5_fname, mode='r')
self.assertIn("Tourcoing/area", h5f["/city attributes/Europe/France"])
ds = h5f["/city attributes/Europe/France/Grenoble/inhabitants"]
@@ -110,6 +112,174 @@ class TestDictToH5(unittest.TestCase):
res = h5todict(self.h5_fname)
assert(res['t'] == False)
+ def testAttributes(self):
+ """Any kind of attribute can be described"""
+ ddict = {
+ "group": {"datatset": "hmmm", ("", "group_attr"): 10},
+ "dataset": "aaaaaaaaaaaaaaa",
+ ("", "root_attr"): 11,
+ ("dataset", "dataset_attr"): 12,
+ ("group", "group_attr2"): 13,
+ }
+ with h5py.File(self.h5_fname, "w") as h5file:
+ dictdump.dicttoh5(ddict, h5file)
+ self.assertEqual(h5file["group"].attrs['group_attr'], 10)
+ self.assertEqual(h5file.attrs['root_attr'], 11)
+ self.assertEqual(h5file["dataset"].attrs['dataset_attr'], 12)
+ self.assertEqual(h5file["group"].attrs['group_attr2'], 13)
+
+ def testPathAttributes(self):
+ """A group is requested at a path"""
+ ddict = {
+ ("", "NX_class"): 'NXcollection',
+ }
+ with h5py.File(self.h5_fname, "w") as h5file:
+ # This should not warn
+ with TestLogging(dictdump_logger, warning=0):
+ dictdump.dicttoh5(ddict, h5file, h5path="foo/bar")
+
+ def testKeyOrder(self):
+ ddict1 = {
+ "d": "plow",
+ ("d", "a"): "ox",
+ }
+ ddict2 = {
+ ("d", "a"): "ox",
+ "d": "plow",
+ }
+ with h5py.File(self.h5_fname, "w") as h5file:
+ dictdump.dicttoh5(ddict1, h5file, h5path="g1")
+ dictdump.dicttoh5(ddict2, h5file, h5path="g2")
+ self.assertEqual(h5file["g1/d"].attrs['a'], "ox")
+ self.assertEqual(h5file["g2/d"].attrs['a'], "ox")
+
+ def testAttributeValues(self):
+ """Any NX data types can be used"""
+ ddict = {
+ ("", "bool"): True,
+ ("", "int"): 11,
+ ("", "float"): 1.1,
+ ("", "str"): "a",
+ ("", "boollist"): [True, False, True],
+ ("", "intlist"): [11, 22, 33],
+ ("", "floatlist"): [1.1, 2.2, 3.3],
+ ("", "strlist"): ["a", "bb", "ccc"],
+ }
+ with h5py.File(self.h5_fname, "w") as h5file:
+ dictdump.dicttoh5(ddict, h5file)
+ for k, expected in ddict.items():
+ result = h5file.attrs[k[1]]
+ if isinstance(expected, list):
+ if isinstance(expected[0], str):
+ numpy.testing.assert_array_equal(result, expected)
+ else:
+ numpy.testing.assert_array_almost_equal(result, expected)
+ else:
+ self.assertEqual(result, expected)
+
+ def testAttributeAlreadyExists(self):
+ """A duplicated attribute warns if overwriting is not enabled"""
+ ddict = {
+ "group": {"dataset": "hmmm", ("", "attr"): 10},
+ ("group", "attr"): 10,
+ }
+ with h5py.File(self.h5_fname, "w") as h5file:
+ with TestLogging(dictdump_logger, warning=1):
+ dictdump.dicttoh5(ddict, h5file)
+ self.assertEqual(h5file["group"].attrs['attr'], 10)
+
+ def testFlatDict(self):
+ """Description of a tree with a single level of keys"""
+ ddict = {
+ "group/group/dataset": 10,
+ ("group/group/dataset", "attr"): 11,
+ ("group/group", "attr"): 12,
+ }
+ with h5py.File(self.h5_fname, "w") as h5file:
+ dictdump.dicttoh5(ddict, h5file)
+ self.assertEqual(h5file["group/group/dataset"][()], 10)
+ self.assertEqual(h5file["group/group/dataset"].attrs['attr'], 11)
+ self.assertEqual(h5file["group/group"].attrs['attr'], 12)
+
+
+class TestDictToNx(unittest.TestCase):
+ def setUp(self):
+ self.tempdir = tempfile.mkdtemp()
+ self.h5_fname = os.path.join(self.tempdir, "nx.h5")
+
+ def tearDown(self):
+ if os.path.exists(self.h5_fname):
+ os.unlink(self.h5_fname)
+ os.rmdir(self.tempdir)
+
+ def testAttributes(self):
+ """Any kind of attribute can be described"""
+ ddict = {
+ "group": {"datatset": "hmmm", "@group_attr": 10},
+ "dataset": "aaaaaaaaaaaaaaa",
+ "@root_attr": 11,
+ "dataset@dataset_attr": 12,
+ "group@group_attr2": 13,
+ }
+ with h5py.File(self.h5_fname, "w") as h5file:
+ dictdump.dicttonx(ddict, h5file)
+ self.assertEqual(h5file["group"].attrs['group_attr'], 10)
+ self.assertEqual(h5file.attrs['root_attr'], 11)
+ self.assertEqual(h5file["dataset"].attrs['dataset_attr'], 12)
+ self.assertEqual(h5file["group"].attrs['group_attr2'], 13)
+
+ def testKeyOrder(self):
+ ddict1 = {
+ "d": "plow",
+ "d@a": "ox",
+ }
+ ddict2 = {
+ "d@a": "ox",
+ "d": "plow",
+ }
+ with h5py.File(self.h5_fname, "w") as h5file:
+ dictdump.dicttonx(ddict1, h5file, h5path="g1")
+ dictdump.dicttonx(ddict2, h5file, h5path="g2")
+ self.assertEqual(h5file["g1/d"].attrs['a'], "ox")
+ self.assertEqual(h5file["g2/d"].attrs['a'], "ox")
+
+ def testAttributeValues(self):
+ """Any NX data types can be used"""
+ ddict = {
+ "@bool": True,
+ "@int": 11,
+ "@float": 1.1,
+ "@str": "a",
+ "@boollist": [True, False, True],
+ "@intlist": [11, 22, 33],
+ "@floatlist": [1.1, 2.2, 3.3],
+ "@strlist": ["a", "bb", "ccc"],
+ }
+ with h5py.File(self.h5_fname, "w") as h5file:
+ dictdump.dicttonx(ddict, h5file)
+ for k, expected in ddict.items():
+ result = h5file.attrs[k[1:]]
+ if isinstance(expected, list):
+ if isinstance(expected[0], str):
+ numpy.testing.assert_array_equal(result, expected)
+ else:
+ numpy.testing.assert_array_almost_equal(result, expected)
+ else:
+ self.assertEqual(result, expected)
+
+ def testFlatDict(self):
+ """Description of a tree with a single level of keys"""
+ ddict = {
+ "group/group/dataset": 10,
+ "group/group/dataset@attr": 11,
+ "group/group@attr": 12,
+ }
+ with h5py.File(self.h5_fname, "w") as h5file:
+ dictdump.dicttonx(ddict, h5file)
+ self.assertEqual(h5file["group/group/dataset"][()], 10)
+ self.assertEqual(h5file["group/group/dataset"].attrs['attr'], 11)
+ self.assertEqual(h5file["group/group"].attrs['attr'], 12)
+
class TestH5ToDict(unittest.TestCase):
def setUp(self):
@@ -260,14 +430,12 @@ class TestDictToIni(unittest.TestCase):
def suite():
test_suite = unittest.TestSuite()
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestDictToIni))
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestDictToH5))
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestDictToJson))
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestH5ToDict))
+ loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loadTests(TestDictToIni))
+ test_suite.addTest(loadTests(TestDictToH5))
+ test_suite.addTest(loadTests(TestDictToNx))
+ test_suite.addTest(loadTests(TestDictToJson))
+ test_suite.addTest(loadTests(TestH5ToDict))
return test_suite
diff --git a/silx/io/test/test_fabioh5.py b/silx/io/test/test_fabioh5.py
index 7b4abbc..f2c85b1 100755
--- a/silx/io/test/test_fabioh5.py
+++ b/silx/io/test/test_fabioh5.py
@@ -104,7 +104,7 @@ class TestFabioH5(unittest.TestCase):
data = numpy.arange(2 * 3)
data.shape = 2, 3
fabio_image = fabio.edfimage.edfimage(data=data)
- fabio_image.appendFrame(data=data)
+ fabio_image.append_frame(data=data)
h5_image = fabioh5.File(fabio_image=fabio_image)
dataset = h5_image["/scan_0/instrument/detector_0/data"]
@@ -124,8 +124,8 @@ class TestFabioH5(unittest.TestCase):
data3 = numpy.arange(2 * 5 * 1)
data3.shape = 2, 5, 1
fabio_image = fabio.edfimage.edfimage(data=data1)
- fabio_image.appendFrame(data=data2)
- fabio_image.appendFrame(data=data3)
+ fabio_image.append_frame(data=data2)
+ fabio_image.append_frame(data=data3)
h5_image = fabioh5.File(fabio_image=fabio_image)
dataset = h5_image["/scan_0/instrument/detector_0/data"]
@@ -207,7 +207,7 @@ class TestFabioH5(unittest.TestCase):
if fabio_image is None:
fabio_image = fabio.edfimage.EdfImage(data=data, header=header)
else:
- fabio_image.appendFrame(data=data, header=header)
+ fabio_image.append_frame(data=data, header=header)
h5_image = fabioh5.File(fabio_image=fabio_image)
data = h5_image["/scan_0/instrument/detector_0/others/float_item"]
# There is no equality between items
@@ -229,7 +229,7 @@ class TestFabioH5(unittest.TestCase):
if fabio_image is None:
fabio_image = fabio.edfimage.EdfImage(data=data, header=header)
else:
- fabio_image.appendFrame(data=data, header=header)
+ fabio_image.append_frame(data=data, header=header)
h5_image = fabioh5.File(fabio_image=fabio_image)
data = h5_image["/scan_0/instrument/detector_0/others/time_of_day"]
# There is no equality between items
@@ -249,7 +249,7 @@ class TestFabioH5(unittest.TestCase):
if fabio_image is None:
fabio_image = fabio.edfimage.EdfImage(data=data, header=header)
else:
- fabio_image.appendFrame(data=data, header=header)
+ fabio_image.append_frame(data=data, header=header)
h5_image = fabioh5.File(fabio_image=fabio_image)
data = h5_image["/scan_0/instrument/detector_0/others/float_item"]
# At worst a float32
@@ -269,7 +269,7 @@ class TestFabioH5(unittest.TestCase):
if fabio_image is None:
fabio_image = fabio.edfimage.EdfImage(data=data, header=header)
else:
- fabio_image.appendFrame(data=data, header=header)
+ fabio_image.append_frame(data=data, header=header)
h5_image = fabioh5.File(fabio_image=fabio_image)
data = h5_image["/scan_0/instrument/detector_0/others/float_item"]
# At worst a float32
@@ -289,7 +289,7 @@ class TestFabioH5(unittest.TestCase):
if fabio_image is None:
fabio_image = fabio.edfimage.EdfImage(data=data, header=header)
else:
- fabio_image.appendFrame(data=data, header=header)
+ fabio_image.append_frame(data=data, header=header)
h5_image = fabioh5.File(fabio_image=fabio_image)
data = h5_image["/scan_0/instrument/detector_0/others/float_item"]
# At worst a float32
@@ -390,7 +390,7 @@ class TestFabioH5(unittest.TestCase):
fabio_image = fabio.edfimage.edfimage(data=data, header=header)
header = {}
header["foo"] = b'a\x90bc\xFE'
- fabio_image.appendFrame(data=data, header=header)
+ fabio_image.append_frame(data=data, header=header)
except Exception as e:
_logger.error(e.args[0])
_logger.debug("Backtrace", exc_info=True)
@@ -411,7 +411,7 @@ class TestFabioH5(unittest.TestCase):
fabio_image = fabio.edfimage.edfimage(data=data, header=header)
header = {}
header["foo"] = u'abc\u2764'
- fabio_image.appendFrame(data=data, header=header)
+ fabio_image.append_frame(data=data, header=header)
except Exception as e:
_logger.error(e.args[0])
_logger.debug("Backtrace", exc_info=True)
@@ -456,7 +456,7 @@ class TestFabioH5MultiFrames(unittest.TestCase):
if fabio_file is None:
fabio_file = fabio.edfimage.EdfImage(data=data, header=header)
else:
- fabio_file.appendFrame(data=data, header=header)
+ fabio_file.append_frame(data=data, header=header)
cls.fabio_file = fabio_file
cls.fabioh5 = fabioh5.File(fabio_image=fabio_file)
diff --git a/silx/io/test/test_nxdata.py b/silx/io/test/test_nxdata.py
index a790e36..80cc193 100644
--- a/silx/io/test/test_nxdata.py
+++ b/silx/io/test/test_nxdata.py
@@ -1,6 +1,6 @@
# coding: utf-8
# /*##########################################################################
-# Copyright (C) 2016-2018 European Synchrotron Radiation Facility
+# Copyright (C) 2016-2020 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -25,7 +25,7 @@
__authors__ = ["P. Knobel"]
__license__ = "MIT"
-__date__ = "27/01/2018"
+__date__ = "24/03/2020"
import tempfile
@@ -54,6 +54,7 @@ class TestNXdata(unittest.TestCase):
g0d0.attrs["NX_class"] = "NXdata"
g0d0.attrs["signal"] = "scalar"
g0d0.create_dataset("scalar", data=10)
+ g0d0.create_dataset("scalar_errors", data=0.1)
g0d1 = g0d.create_group("2D_scalars")
g0d1.attrs["NX_class"] = "NXdata"
@@ -199,7 +200,7 @@ class TestNXdata(unittest.TestCase):
self.assertEqual(nxd.axes_names, [])
self.assertEqual(nxd.axes_dataset_names, [])
self.assertEqual(nxd.axes, [])
- self.assertIsNone(nxd.errors)
+ self.assertIsNotNone(nxd.errors)
self.assertFalse(nxd.is_scatter or nxd.is_x_y_value_scatter)
self.assertIsNone(nxd.interpretation)
diff --git a/silx/io/test/test_spectoh5.py b/silx/io/test/test_spectoh5.py
index 44b59e0..c3f03e9 100644
--- a/silx/io/test/test_spectoh5.py
+++ b/silx/io/test/test_spectoh5.py
@@ -39,7 +39,7 @@ __license__ = "MIT"
__date__ = "12/02/2018"
-sftext = """#F /tmp/sf.dat
+sfdata = b"""#F /tmp/sf.dat
#E 1455180875
#D Thu Feb 11 09:54:35 2016
#C imaging User = opid17
@@ -86,14 +86,11 @@ sftext = """#F /tmp/sf.dat
class TestConvertSpecHDF5(unittest.TestCase):
@classmethod
def setUpClass(cls):
- fd, cls.spec_fname = tempfile.mkstemp(text=False)
- if sys.version_info < (3, ):
- os.write(fd, sftext)
- else:
- os.write(fd, bytes(sftext, 'ascii'))
+ fd, cls.spec_fname = tempfile.mkstemp(prefix="TestConvertSpecHDF5")
+ os.write(fd, sfdata)
os.close(fd)
- fd, cls.h5_fname = tempfile.mkstemp(text=False)
+ fd, cls.h5_fname = tempfile.mkstemp(prefix="TestConvertSpecHDF5")
# Close and delete (we just need the name)
os.close(fd)
os.unlink(cls.h5_fname)
diff --git a/silx/io/test/test_url.py b/silx/io/test/test_url.py
index 5093fc2..e68c67a 100644
--- a/silx/io/test/test_url.py
+++ b/silx/io/test/test_url.py
@@ -197,6 +197,15 @@ class TestDataUrl(unittest.TestCase):
url = DataUrl(scheme="silx", file_path="/foo.h5", data_slice=(5, 1))
self.assertFalse(url.is_valid())
+ def test_path_creation(self):
+ """make sure the construction of path succeed and that we can
+ recreate a DataUrl from a path"""
+ for data_slice in (1, (1,)):
+ with self.subTest(data_slice=data_slice):
+ url = DataUrl(scheme="silx", file_path="/foo.h5", data_slice=data_slice)
+ path = url.path()
+ DataUrl(path=path)
+
def suite():
test_suite = unittest.TestSuite()
diff --git a/silx/io/test/test_utils.py b/silx/io/test/test_utils.py
index 56f89fc..6c70636 100644
--- a/silx/io/test/test_utils.py
+++ b/silx/io/test/test_utils.py
@@ -491,7 +491,7 @@ class TestGetData(unittest.TestCase):
data = numpy.array([[10, 50], [50, 10]])
fabiofile = fabio.edfimage.EdfImage(data, header)
fabiofile.write(cls.edf_filename)
- fabiofile.appendFrame(data=data, header=header)
+ fabiofile.append_frame(data=data, header=header)
fabiofile.write(cls.edf_multiframe_filename)
cls.txt_filename = os.path.join(directory, "test.txt")
diff --git a/silx/io/url.py b/silx/io/url.py
index c8cdc84..7607ae5 100644
--- a/silx/io/url.py
+++ b/silx/io/url.py
@@ -30,6 +30,7 @@ __date__ = "29/01/2018"
import logging
import six
+from collections.abc import Iterable
parse = six.moves.urllib.parse
@@ -211,7 +212,7 @@ class DataUrl(object):
pos = self.__path.index(url.path)
file_path = self.__path[0:pos] + url.path
else:
- scheme = url.scheme if url.scheme is not "" else None
+ scheme = url.scheme if url.scheme != "" else None
file_path = url.path
# Check absolute windows path
@@ -297,7 +298,10 @@ class DataUrl(object):
if self.__data_path is not None:
queries.append("path=" + self.__data_path)
if self.__data_slice is not None:
- data_slice = ",".join([slice_to_string(s) for s in self.__data_slice])
+ if isinstance(self.__data_slice, Iterable):
+ data_slice = ",".join([slice_to_string(s) for s in self.__data_slice])
+ else:
+ data_slice = slice_to_string(self.__data_slice)
queries.append("slice=" + data_slice)
query = "&".join(queries)
diff --git a/silx/io/utils.py b/silx/io/utils.py
index f294101..5da344d 100644
--- a/silx/io/utils.py
+++ b/silx/io/utils.py
@@ -1,6 +1,6 @@
# coding: utf-8
# /*##########################################################################
-# Copyright (C) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (C) 2016-2020 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
@@ -82,7 +82,7 @@ def supported_extensions(flat_formats=True):
:param bool flat_formats: If true, also include flat formats like npy or
edf (while the expected module is available)
:returns: A dictionary indexed by file description and containing a set of
- extensions (an extension is a string like "\*.ext").
+ extensions (an extension is a string like "\\*.ext").
:rtype: Dict[str, Set[str]]
"""
formats = collections.OrderedDict()
@@ -849,7 +849,7 @@ def rawfile_to_h5_external_dataset(bin_file, output_url, shape, dtype,
raise Exception('h5py >= 2.9 should be installed to access the '
'external feature.')
- with h5py.File(output_url.file_path()) as _h5_file:
+ with h5py.File(output_url.file_path(), mode="a") as _h5_file:
if output_url.data_path() in _h5_file:
if overwrite is False:
raise ValueError('data_path already exists')
diff --git a/silx/math/colormap.pyx b/silx/math/colormap.pyx
index c5d3e09..2495f3c 100644
--- a/silx/math/colormap.pyx
+++ b/silx/math/colormap.pyx
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2020 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,13 +30,16 @@ __license__ = "MIT"
__date__ = "16/05/2018"
+import os
cimport cython
from cython.parallel import prange
cimport numpy as cnumpy
-from libc.math cimport frexp, sqrt
+from libc.math cimport frexp, sinh, sqrt
from .math_compatibility cimport asinh, isnan, isfinite, lrint, INFINITY, NAN
import logging
+import numbers
+
import numpy
__all__ = ['cmap']
@@ -44,6 +47,16 @@ __all__ = ['cmap']
_logger = logging.getLogger(__name__)
+cdef int DEFAULT_NUM_THREADS
+if hasattr(os, 'sched_getaffinity'):
+ DEFAULT_NUM_THREADS = min(4, len(os.sched_getaffinity(0)))
+elif os.cpu_count() is not None:
+ DEFAULT_NUM_THREADS = min(4, os.cpu_count())
+else: # Fallback
+ DEFAULT_NUM_THREADS = 1
+# Number of threads to use for the computation (initialized to up to 4)
+
+
# Supported data types
ctypedef fused data_types:
cnumpy.uint8_t
@@ -84,60 +97,193 @@ ctypedef fused image_types:
float
-# Type of scale function
-ctypedef double (*scale_function)(double) nogil
+# Normalization
+ctypedef double (*NormalizationFunction)(double) nogil
+
+
+cdef class Normalization:
+ """Base class for colormap normalization"""
+
+ def apply(self, data, double vmin, double vmax):
+ """Apply normalization.
+
+ :param Union[float,numpy.ndarray] data:
+ :param float vmin: Lower bound of the range
+ :param float vmax: Upper bound of the range
+ :rtype: Union[float,numpy.ndarray]
+ """
+ cdef int length
+ cdef double[:] result
+
+ if isinstance(data, numbers.Real):
+ return self.apply_double(<double> data, vmin, vmax)
+ else:
+ data = numpy.array(data, copy=False)
+ length = <int> data.size
+ result = numpy.empty(length, dtype=numpy.float64)
+ data1d = numpy.ravel(data)
+ for index in range(length):
+ result[index] = self.apply_double(
+ <double> data1d[index], vmin, vmax)
+ return numpy.array(result).reshape(data.shape)
+
+ def revert(self, data, double vmin, double vmax):
+ """Revert normalization.
+
+ :param Union[float,numpy.ndarray] data:
+ :param float vmin: Lower bound of the range
+ :param float vmax: Upper bound of the range
+ :rtype: Union[float,numpy.ndarray]
+ """
+ cdef int length
+ cdef double[:] result
+
+ if isinstance(data, numbers.Real):
+ return self.revert_double(<double> data, vmin, vmax)
+ else:
+ data = numpy.array(data, copy=False)
+ length = <int> data.size
+ result = numpy.empty(length, dtype=numpy.float64)
+ data1d = numpy.ravel(data)
+ for index in range(length):
+ result[index] = self.revert_double(
+ <double> data1d[index], vmin, vmax)
+ return numpy.array(result).reshape(data.shape)
+
+ cdef double apply_double(self, double value, double vmin, double vmax) nogil:
+ """Apply normalization to a floating point value
+
+ Override in subclass
+
+ :param float value:
+ :param float vmin: Lower bound of the range
+ :param float vmax: Upper bound of the range
+ """
+ return value
+
+ cdef double revert_double(self, double value, double vmin, double vmax) nogil:
+ """Apply inverse of normalization to a floating point value
+
+ Override in subclass
+
+ :param float value:
+ :param float vmin: Lower bound of the range
+ :param float vmax: Upper bound of the range
+ """
+ return value
+
+
+cdef class LinearNormalization(Normalization):
+ """Linear normalization"""
+
+ cdef double apply_double(self, double value, double vmin, double vmax) nogil:
+ return value
+
+ cdef double revert_double(self, double value, double vmin, double vmax) nogil:
+ return value
+
+
+cdef class LogarithmicNormalization(Normalization):
+ """Logarithmic normalization using a fast log approximation"""
+ cdef:
+ readonly int lutsize
+ readonly double[::1] lut # LUT used for fast log approximation
+
+ def __cinit__(self, int lutsize=4096):
+ # Initialize log approximation LUT
+ self.lutsize = lutsize
+ self.lut = numpy.log2(
+ numpy.linspace(0.5, 1., lutsize + 1,
+ endpoint=True).astype(numpy.float64))
+ # index_lut can overflow of 1
+ self.lut[lutsize] = self.lut[lutsize - 1]
+
+ def __dealloc__(self):
+ self.lut = None
+
+ @cython.wraparound(False)
+ @cython.boundscheck(False)
+ @cython.nonecheck(False)
+ @cython.cdivision(True)
+ cdef double apply_double(self, double value, double vmin, double vmax) nogil:
+ """Return log10(value) fast approximation based on LUT"""
+ cdef double result = NAN # if value < 0.0 or value == NAN
+ cdef int exponent, index_lut
+ cdef double mantissa # in [0.5, 1) unless value == 0 NaN or +/-inf
+
+ if value <= 0.0 or not isfinite(value):
+ if value == 0.0:
+ result = - INFINITY
+ elif value > 0.0: # i.e., value = +INFINITY
+ result = value # i.e. +INFINITY
+ else:
+ mantissa = frexp(value, &exponent)
+ index_lut = lrint(self.lutsize * 2 * (mantissa - 0.5))
+ # 1/log2(10) = 0.30102999566398114
+ result = 0.30102999566398114 * (<double> exponent +
+ self.lut[index_lut])
+ return result
+
+ cdef double revert_double(self, double value, double vmin, double vmax) nogil:
+ return 10**value
+
+
+cdef class ArcsinhNormalization(Normalization):
+ """Inverse hyperbolic sine normalization"""
+
+ cdef double apply_double(self, double value, double vmin, double vmax) nogil:
+ return asinh(value)
+
+ cdef double revert_double(self, double value, double vmin, double vmax) nogil:
+ return sinh(value)
-# Normalization
+cdef class SqrtNormalization(Normalization):
+ """Square root normalization"""
-cdef double linear_scale(double value) nogil:
- """No-Op scaling function"""
- return value
+ cdef double apply_double(self, double value, double vmin, double vmax) nogil:
+ return sqrt(value)
+ cdef double revert_double(self, double value, double vmin, double vmax) nogil:
+ return value**2
-cdef double asinh_scale(double value) nogil:
- """asinh scaling function
- Wraps asinh as it is defined as a macro for Windows support.
+cdef class PowerNormalization(Normalization):
+ """Gamma correction:
+
+ Linear normalization to [0, 1] followed by power normalization.
+
+ :param gamma: Gamma correction factor
"""
- return asinh(value)
+ cdef:
+ readonly double gamma
-DEF LOG_LUT_SIZE = 4096
-cdef double[::1] _log_lut
-"""LUT used for fast log approximation"""
+ def __cinit__(self, double gamma):
+ self.gamma = gamma
-# Initialize log approximation LUT
-_log_lut = numpy.log2(
- numpy.linspace(0.5, 1., LOG_LUT_SIZE + 1,
- endpoint=True).astype(numpy.float64))
-# index_lut can overflow of 1
-_log_lut[LOG_LUT_SIZE] = _log_lut[LOG_LUT_SIZE - 1]
+ def __init__(self, gamma):
+ # Needed for multiple inheritance to work
+ pass
+ cdef double apply_double(self, double value, double vmin, double vmax) nogil:
+ if vmin == vmax:
+ return 0.
+ elif value <= vmin:
+ return 0.
+ elif value >= vmax:
+ return 1.
+ else:
+ return ((value - vmin) / (vmax - vmin))**self.gamma
-@cython.wraparound(False)
-@cython.boundscheck(False)
-@cython.nonecheck(False)
-@cython.cdivision(True)
-cdef double fast_log10(double value) nogil:
- """Return log10(value) fast approximation based on LUT"""
- cdef double result = NAN # if value < 0.0 or value == NAN
- cdef int exponent, index_lut
- cdef double mantissa # in [0.5, 1) unless value == 0 NaN or +/-inf
-
- if value <= 0.0 or not isfinite(value):
- if value == 0.0:
- result = - INFINITY
- elif value > 0.0: # i.e., value = +INFINITY
- result = value # i.e. +INFINITY
- else:
- mantissa = frexp(value, &exponent)
- index_lut = lrint(LOG_LUT_SIZE * 2 * (mantissa - 0.5))
- # 1/log2(10) = 0.30102999566398114
- result = 0.30102999566398114 * (<double> exponent +
- _log_lut[index_lut])
- return result
+ cdef double revert_double(self, double value, double vmin, double vmax) nogil:
+ if value <= 0.:
+ return vmin
+ elif value >= 1.:
+ return vmax
+ else:
+ return vmin + (vmax - vmin) * value**(1.0/self.gamma)
# Colormap
@@ -149,22 +295,22 @@ cdef double fast_log10(double value) nogil:
cdef image_types[:, ::1] compute_cmap(
default_types[:] data,
image_types[:, ::1] colors,
- double normalized_vmin,
- double normalized_vmax,
- image_types[::1] nan_color,
- scale_function scale_func):
+ Normalization normalization,
+ double vmin,
+ double vmax,
+ image_types[::1] nan_color):
"""Apply colormap to data.
:param data: Input data
:param colors: Colors look-up-table
- :param normalized_vmin: Normalized lower bound of the colormap range
- :param normalized_vmax: Normalized upper bound of the colormap range
+ :param vmin: Lower bound of the colormap range
+ :param vmax: Upper bound of the colormap range
:param nan_color: Color to use for NaN value
- :param scale_func: The function to use to scale data
+ :param normalization: Normalization to apply
:return: Data converted to colors
"""
cdef image_types[:, ::1] output
- cdef double scale, value
+ cdef double scale, value, normalized_vmin, normalized_vmax
cdef int length, nb_channels, nb_colors
cdef int channel, index, lut_index
@@ -175,14 +321,21 @@ cdef image_types[:, ::1] compute_cmap(
output = numpy.empty((length, nb_channels),
dtype=numpy.array(colors, copy=False).dtype)
+ normalized_vmin = normalization.apply_double(vmin, vmin, vmax)
+ normalized_vmax = normalization.apply_double(vmax, vmin, vmax)
+
+ if not isfinite(normalized_vmin) or not isfinite(normalized_vmax):
+ raise ValueError('Colormap range is not valid')
+
if normalized_vmin == normalized_vmax:
scale = 0.
else:
scale = nb_colors / (normalized_vmax - normalized_vmin)
with nogil:
- for index in prange(length):
- value = scale_func(<double> data[index])
+ for index in prange(length, num_threads=DEFAULT_NUM_THREADS):
+ value = normalization.apply_double(
+ <double> data[index], vmin, vmax)
# Handle NaN
if isnan(value):
@@ -212,20 +365,20 @@ cdef image_types[:, ::1] compute_cmap(
cdef image_types[:, ::1] compute_cmap_with_lut(
lut_types[:] data,
image_types[:, ::1] colors,
- double normalized_vmin,
- double normalized_vmax,
- image_types[::1] nan_color,
- scale_function scale_func):
+ Normalization normalization,
+ double vmin,
+ double vmax,
+ image_types[::1] nan_color):
"""Convert data to colors using look-up table to speed the process.
Only supports data of types: uint8, uint16, int8, int16.
:param data: Input data
:param colors: Colors look-up-table
- :param normalized_vmin: Normalized lower bound of the colormap range
- :param normalized_vmax: Normalized upper bound of the colormap range
+ :param vmin: Lower bound of the colormap range
+ :param vmax: Upper bound of the colormap range
:param nan_color: Color to use for NaN values
- :param scale_func: The function to use for scaling data
+ :param normalization: Normalization to apply
:return: The generated image
"""
cdef image_types[:, ::1] output
@@ -255,14 +408,13 @@ cdef image_types[:, ::1] compute_cmap_with_lut(
values = numpy.arange(type_min, type_max + 1, dtype=numpy.float64)
lut = compute_cmap(
- values, colors, normalized_vmin, normalized_vmax,
- nan_color, scale_func)
+ values, colors, normalization, vmin, vmax, nan_color)
output = numpy.empty((length, nb_channels), dtype=colors_dtype)
with nogil:
# Apply LUT
- for index in prange(length):
+ for index in prange(length, num_threads=DEFAULT_NUM_THREADS):
lut_index = data[index] - type_min
for channel in range(nb_channels):
output[index, channel] = lut[lut_index, channel]
@@ -270,13 +422,22 @@ cdef image_types[:, ::1] compute_cmap_with_lut(
return output
+# Normalizations without parameters
+_BASIC_NORMALIZATIONS = {
+ 'linear': LinearNormalization(),
+ 'log': LogarithmicNormalization(),
+ 'arcsinh': ArcsinhNormalization(),
+ 'sqrt': SqrtNormalization(),
+ }
+
+
@cython.wraparound(False)
@cython.boundscheck(False)
@cython.nonecheck(False)
@cython.cdivision(True)
def _cmap(data_types[:] data,
image_types[:, ::1] colors,
- str normalization,
+ Normalization normalization,
double vmin,
double vmax,
image_types[::1] nan_color):
@@ -286,42 +447,22 @@ def _cmap(data_types[:] data,
:param data: Input data
:param colors: Colors look-up-table
- :param normalization: Kind of scaling to apply on data
+ :param normalization: Normalization object to apply
:param vmin: Lower bound of the colormap range
:param vmax: Upper bound of the colormap range
:param nan_color: Color to use for NaN value.
:return: The generated image
"""
- cdef double normalized_vmin, normalized_vmax
- cdef scale_function scale_func
-
- if normalization == 'linear':
- scale_func = linear_scale
- elif normalization == 'log':
- scale_func = fast_log10
- elif normalization == 'arcsinh':
- scale_func = asinh_scale
- elif normalization == 'sqrt':
- scale_func = sqrt
- else:
- raise ValueError('Unsupported normalization %s' % normalization)
-
- normalized_vmin = scale_func(vmin)
- normalized_vmax = scale_func(vmax)
-
- if not isfinite(normalized_vmin) or not isfinite(normalized_vmax):
- raise ValueError('Colormap range is not valid')
+ cdef image_types[:, ::1] output
# Proxy for calling the right implementation depending on data type
if data_types in lut_types: # Use LUT implementation
output = compute_cmap_with_lut(
- data, colors, normalized_vmin, normalized_vmax,
- nan_color, scale_func)
+ data, colors, normalization, vmin, vmax, nan_color)
elif data_types in default_types: # Use default implementation
output = compute_cmap(
- data, colors, normalized_vmin, normalized_vmax,
- nan_color, scale_func)
+ data, colors, normalization, vmin, vmax, nan_color)
else:
raise ValueError('Unsupported data type')
@@ -342,12 +483,14 @@ def cmap(data,
It MUST be of type uint8 or float32
:param vmin: Data value to map to the beginning of colormap.
:param vmax: Data value to map to the end of the colormap.
- :param str normalization: The normalization to apply:
+ :param Union[str,Normalization] normalization:
+ Either a :class:`Normalization` instance or a str in:
- 'linear' (default)
- 'log'
- 'arcsinh'
- 'sqrt'
+ - 'gamma'
:param nan_color: Color to use for NaN value.
Default: A color with all channels set to 0
@@ -357,6 +500,7 @@ def cmap(data,
:rtype: numpy.ndarray
"""
cdef int nb_channels
+ cdef Normalization norm
# Make data a numpy array of native endian type (no need for contiguity)
data = numpy.array(data, copy=False)
@@ -371,6 +515,14 @@ def cmap(data,
colors = numpy.ascontiguousarray(colors,
dtype=colors.dtype.newbyteorder('N'))
+ # Make normalization a Normalization object
+ if isinstance(normalization, str):
+ norm = _BASIC_NORMALIZATIONS.get(normalization, None)
+ if norm is None:
+ raise ValueError('Unsupported normalization %s' % normalization)
+ else:
+ norm = normalization
+
# Check nan_color
if nan_color is None:
nan_color = numpy.zeros((nb_channels,), dtype=colors.dtype)
@@ -382,8 +534,10 @@ def cmap(data,
image = _cmap(
data.reshape(-1),
colors.reshape(-1, nb_channels),
- str(normalization),
- vmin, vmax, nan_color)
+ norm,
+ vmin,
+ vmax,
+ nan_color)
image.shape = data.shape + (nb_channels,)
return image
diff --git a/silx/math/fft/test/test_fft.py b/silx/math/fft/test/test_fft.py
index 358d0ee..14b1243 100644
--- a/silx/math/fft/test/test_fft.py
+++ b/silx/math/fft/test/test_fft.py
@@ -60,10 +60,10 @@ class TransformInfos(object):
"C2C_double": np.complex128,
}
self.sizes = {
- "1D": [(512,), (511,)],
- "2D": [(512, 512), (512, 511), (511, 512), (511, 511)],
- "3D": [(128, 128, 128), (128, 128, 127), (128, 127, 128), (127, 128, 128),
- (128, 127, 127), (127, 128, 127), (127, 127, 128), (127, 127, 127)]
+ "1D": [(128,), (127,)],
+ "2D": [(128, 128), (128, 127), (127, 128), (127, 127)],
+ "3D": [(64, 64, 64), (64, 64, 63), (64, 63, 64), (63, 64, 64),
+ (64, 63, 63), (63, 64, 63), (63, 63, 64), (63, 63, 63)]
}
self.axes = {
"1D": None,
@@ -80,7 +80,7 @@ class TestData(object):
def __init__(self):
self.data = ascent().astype("float32")
self.data1d = self.data[:, 0] # non-contiguous data
- self.data3d = np.tile(self.data[:128, :128], (128, 1, 1))
+ self.data3d = np.tile(self.data[:64, :64], (64, 1, 1))
self.data_refs = {
1: self.data1d,
2: self.data,
diff --git a/silx/math/fit/fittheories.py b/silx/math/fit/fittheories.py
index b5069e9..f733d1a 100644
--- a/silx/math/fit/fittheories.py
+++ b/silx/math/fit/fittheories.py
@@ -1,7 +1,7 @@
# coding: utf-8
#/*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2020 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
@@ -402,11 +402,12 @@ class FitTheories(object):
cons[1:len(param):3, 1] = param[1:len(param):3] - 0.5 * fwhmx
cons[1:len(param):3, 2] = param[1:len(param):3] + 0.5 * fwhmx
else:
+ shape = [max(1, int(x)) for x in (param[1:len(param):3])]
cons[1:len(param):3, 1] = min(xw) * numpy.ones(
- (param[1:len(param):3]),
+ shape,
numpy.float)
cons[1:len(param):3, 2] = max(xw) * numpy.ones(
- (param[1:len(param):3]),
+ shape,
numpy.float)
# ensure fwhm is positive
diff --git a/silx/math/test/test_colormap.py b/silx/math/test/test_colormap.py
index cafe537..4e80710 100644
--- a/silx/math/test/test_colormap.py
+++ b/silx/math/test/test_colormap.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018-2019 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2020 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,7 +34,6 @@ __date__ = "16/05/2018"
import logging
import sys
import unittest
-import warnings
import numpy
@@ -45,10 +44,77 @@ from silx.math import colormap
_logger = logging.getLogger(__name__)
-class TestColormap(ParametricTestCase):
- """Test silx.image.colormap.cmap"""
+class TestNormalization(ParametricTestCase):
+ """Test silx.math.colormap.Normalization sub classes"""
+
+ def _testCodec(self, normalization, rtol=1e-5):
+ """Test apply/revert for normalizations"""
+ test_data = (numpy.arange(1, 10, dtype=numpy.int32),
+ numpy.linspace(1., 100., 1000, dtype=numpy.float32),
+ numpy.linspace(-1., 1., 100, dtype=numpy.float32),
+ 1.,
+ 1)
+
+ for index in range(len(test_data)):
+ with self.subTest(normalization=normalization, data_index=index):
+ data = test_data[index]
+ normalized = normalization.apply(data, 1., 100.)
+ result = normalization.revert(normalized, 1., 100.)
+
+ self.assertTrue(numpy.array_equal(
+ numpy.isnan(normalized), numpy.isnan(result)))
+
+ if isinstance(data, numpy.ndarray):
+ notNaN = numpy.logical_not(numpy.isnan(result))
+ data = data[notNaN]
+ result = result[notNaN]
+ self.assertTrue(numpy.allclose(data, result, rtol=rtol))
+
+ def testLinearNormalization(self):
+ """Test for LinearNormalization"""
+ normalization = colormap.LinearNormalization()
+ self._testCodec(normalization)
+
+ def testLogarithmicNormalization(self):
+ """Test for LogarithmicNormalization"""
+ normalization = colormap.LogarithmicNormalization()
+ # relative tolerance is higher because of the log approximation
+ self._testCodec(normalization, rtol=1e-3)
+
+ # Specific extra tests
+ self.assertTrue(numpy.isnan(normalization.apply(-1., 1., 100.)))
+ self.assertTrue(numpy.isnan(normalization.apply(numpy.nan, 1., 100.)))
+ self.assertEqual(normalization.apply(numpy.inf, 1., 100.), numpy.inf)
+ self.assertEqual(normalization.apply(0, 1., 100.), - numpy.inf)
+
+ def testArcsinhNormalization(self):
+ """Test for ArcsinhNormalization"""
+ self._testCodec(colormap.ArcsinhNormalization())
+
+ def testSqrtNormalization(self):
+ """Test for SqrtNormalization"""
+ normalization = colormap.SqrtNormalization()
+ self._testCodec(normalization)
+
+ # Specific extra tests
+ self.assertTrue(numpy.isnan(normalization.apply(-1., 0., 100.)))
+ self.assertTrue(numpy.isnan(normalization.apply(numpy.nan, 0., 100.)))
+ self.assertEqual(normalization.apply(numpy.inf, 0., 100.), numpy.inf)
+ self.assertEqual(normalization.apply(0, 0., 100.), 0.)
+
- NORMALIZATIONS = 'linear', 'log', 'arcsinh', 'sqrt'
+class TestColormap(ParametricTestCase):
+ """Test silx.math.colormap.cmap"""
+
+ NORMALIZATIONS = (
+ 'linear',
+ 'log',
+ 'arcsinh',
+ 'sqrt',
+ colormap.LinearNormalization(),
+ colormap.LogarithmicNormalization(),
+ colormap.PowerNormalization(2.),
+ colormap.PowerNormalization(0.5))
@staticmethod
def ref_colormap(data, colors, vmin, vmax, normalization, nan_color):
@@ -66,9 +132,13 @@ class TestColormap(ParametricTestCase):
'arcsinh': numpy.arcsinh,
'sqrt': numpy.sqrt}
- norm_function = norm_functions[normalization]
- with warnings.catch_warnings():
- warnings.simplefilter('ignore', category=RuntimeWarning)
+ if isinstance(normalization, str):
+ norm_function = norm_functions[normalization]
+ else:
+ def norm_function(value):
+ return normalization.apply(value, vmin, vmax)
+
+ with numpy.errstate(divide='ignore', invalid='ignore'):
# Ignore divide by zero and invalid value encountered in log10, sqrt
norm_data, vmin, vmax = map(norm_function, (data, vmin, vmax))
@@ -187,6 +257,8 @@ def suite():
test_suite = unittest.TestSuite()
test_suite.addTest(
unittest.defaultTestLoader.loadTestsFromTestCase(TestColormap))
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestNormalization))
return test_suite
diff --git a/silx/math/test/test_combo.py b/silx/math/test/test_combo.py
index 8732954..1335763 100644
--- a/silx/math/test/test_combo.py
+++ b/silx/math/test/test_combo.py
@@ -1,6 +1,6 @@
# coding: utf-8
# /*##########################################################################
-# Copyright (C) 2016-2019 European Synchrotron Radiation Facility
+# Copyright (C) 2016-2020 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -31,7 +31,6 @@ __date__ = "17/01/2018"
import unittest
-import warnings
import numpy
@@ -88,8 +87,7 @@ class TestMinMax(ParametricTestCase):
argmax = numpy.where(data == maximum)[0][0]
if min_positive:
- with warnings.catch_warnings():
- warnings.simplefilter('ignore', category=RuntimeWarning)
+ with numpy.errstate(invalid='ignore'):
# Ignore invalid value encountered in greater
pos_data = filtered_data[filtered_data > 0]
if pos_data.size > 0:
diff --git a/silx/opencl/codec/byte_offset.py b/silx/opencl/codec/byte_offset.py
index eaf37ee..9a52427 100644
--- a/silx/opencl/codec/byte_offset.py
+++ b/silx/opencl/codec/byte_offset.py
@@ -4,7 +4,7 @@
# Project: Sift implementation in Python + OpenCL
# https://github.com/silx-kit/silx
#
-# Copyright (C) 2013-2018 European Synchrotron Radiation Facility, Grenoble, France
+# Copyright (C) 2013-2020 European Synchrotron Radiation Facility, Grenoble, France
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -436,4 +436,4 @@ class ByteOffset(OpenclProcessing):
:rtype: bytes
"""
compressed_array = self.encode(data)
- return compressed_array.get().tostring()
+ return compressed_array.get().tobytes()
diff --git a/silx/opencl/codec/test/test_byte_offset.py b/silx/opencl/codec/test/test_byte_offset.py
index 9ce1cfc..e523b0f 100644
--- a/silx/opencl/codec/test/test_byte_offset.py
+++ b/silx/opencl/codec/test/test_byte_offset.py
@@ -4,7 +4,7 @@
# Project: Byte-offset decompression in OpenCL
# https://github.com/silx-kit/silx
#
-# Copyright (C) 2013-2018 European Synchrotron Radiation Facility,
+# Copyright (C) 2013-2020 European Synchrotron Radiation Facility,
# Grenoble, France
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -170,7 +170,7 @@ class TestByteOffset(unittest.TestCase):
compressed_array = bo.encode(ref)
t1 = time.time()
- compressed_stream = compressed_array.get().tostring()
+ compressed_stream = compressed_array.get().tobytes()
self.assertEqual(raw, compressed_stream)
logger.debug("Global execution time: OpenCL: %.3fms.",
@@ -203,7 +203,7 @@ class TestByteOffset(unittest.TestCase):
# Get data from out array, read it from bo object queue
out_bo_queue = out.with_queue(bo.queue)
- compressed_stream = out_bo_queue.get().tostring()[:compressed_size]
+ compressed_stream = out_bo_queue.get().tobytes()[:compressed_size]
self.assertEqual(raw, compressed_stream)
def test_encode_to_bytes(self):
diff --git a/silx/opencl/convolution.py b/silx/opencl/convolution.py
index b8dd8f6..138b985 100644
--- a/silx/opencl/convolution.py
+++ b/silx/opencl/convolution.py
@@ -94,6 +94,14 @@ class Convolution(OpenclProcessing):
self.is_cpu = (self.device.type == "CPU")
self.use_textures = not(self.extra_options["dont_use_textures"])
self.use_textures *= not(self.is_cpu)
+ # Nvidia Fermi GPUs (compute capability 2.X) do not support opencl read_imagef
+ try:
+ cc = self.ctx.devices[0].compute_capability_major_nv
+ self.use_textures *= (cc >= 3)
+ except cl.LogicError: # probably not a Nvidia GPU
+ pass
+ except AttributeError: # probably not a Nvidia GPU
+ pass
def _get_dimensions(self, shape, kernel):
self.shape = shape
diff --git a/silx/opencl/test/test_convolution.py b/silx/opencl/test/test_convolution.py
index c213808..27cb8a9 100644
--- a/silx/opencl/test/test_convolution.py
+++ b/silx/opencl/test/test_convolution.py
@@ -113,9 +113,18 @@ class TestConvolution(unittest.TestCase):
)
def instantiate_convol(self, shape, kernel, axes=None):
+ def is_fermi_device(dev):
+ try:
+ res = (dev.compute_capability_major_nv < 3)
+ except cl.LogicError:
+ res = False
+ except AttributeError:
+ res = False
+ return res
if (self.mode == "constant") and (
not(self.param["use_textures"])
or (self.ctx.devices[0].type == cl._cl.device_type.CPU)
+ or (is_fermi_device(self.ctx.devices[0]))
):
self.skipTest("mode=constant not implemented without textures")
C = Convolution(
diff --git a/silx/opencl/test/test_linalg.py b/silx/opencl/test/test_linalg.py
index b72fa20..0b6c730 100644
--- a/silx/opencl/test/test_linalg.py
+++ b/silx/opencl/test/test_linalg.py
@@ -66,7 +66,7 @@ def gradient(img):
gradient = np.zeros(shape, dtype=img.dtype)
slice_all = [0, slice(None, -1),]
for d in range(img.ndim):
- gradient[slice_all] = np.diff(img, axis=d)
+ gradient[tuple(slice_all)] = np.diff(img, axis=d)
slice_all[0] = d + 1
slice_all.insert(1, slice(None))
return gradient
diff --git a/silx/resources/gui/icons/3d-plane-normal-x.svg b/silx/resources/gui/icons/3d-plane-normal-x.svg
index b1addae..203bd84 100644
--- a/silx/resources/gui/icons/3d-plane-normal-x.svg
+++ b/silx/resources/gui/icons/3d-plane-normal-x.svg
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(0 -1020.4)" stroke="#000">
-<path d="m12.5 1039.9v-18" fill="none" stroke="#008000" stroke-linecap="round" stroke-miterlimit="2" stroke-width="2"/>
-<path d="m30.5 1039.9h-18" fill="none" stroke="#F00" stroke-linecap="round" stroke-miterlimit="2" stroke-width="2"/>
-<path transform="matrix(1 0 -.69517 .71885 0 0)" d="m1018.3 1461.8v-15.133" fill="none" stroke="#00F" stroke-linecap="round" stroke-miterlimit="0" stroke-width="2.3589"/>
-<rect transform="matrix(0 -1 -.70641 .70781 0 0)" x="-1062.6" y="-31.854" width="18" height="15.399" fill="#f7941e" fill-opacity=".81569" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1898"/>
-</g>
+ <g transform="translate(0 -1020.4)" stroke="#000">
+ <path d="m12.5 1039.9v-18" fill="none" stroke="#008000" stroke-linecap="round" stroke-miterlimit="2" stroke-width="2"/>
+ <path d="m30.5 1039.9h-18" fill="none" stroke="#F00" stroke-linecap="round" stroke-miterlimit="2" stroke-width="2"/>
+ <path transform="matrix(1 0 -.69517 .71885 0 0)" d="m1018.3 1461.8v-15.133" fill="none" stroke="#00F" stroke-linecap="round" stroke-miterlimit="0" stroke-width="2.3589"/>
+ <rect transform="matrix(0 -1 -.70641 .70781 0 0)" x="-1062.6" y="-31.854" width="18" height="15.399" fill="#f7941e" fill-opacity=".81569" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1898"/>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/3d-plane-normal-y.svg b/silx/resources/gui/icons/3d-plane-normal-y.svg
index 7016992..78d8ebd 100644
--- a/silx/resources/gui/icons/3d-plane-normal-y.svg
+++ b/silx/resources/gui/icons/3d-plane-normal-y.svg
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(0 -1020.4)" stroke="#000">
-<path d="m12.5 1039.9v-18" fill="none" stroke="#008000" stroke-linecap="round" stroke-miterlimit="2" stroke-width="2"/>
-<path d="m30.5 1039.9h-18" fill="none" stroke="#F00" stroke-linecap="round" stroke-miterlimit="2" stroke-width="2"/>
-<path transform="matrix(1 0 -.69517 .71885 0 0)" d="m1018.3 1461.8v-15.133" fill="none" stroke="#00F" stroke-linecap="round" stroke-miterlimit="0" stroke-width="2.3589"/>
-<rect transform="matrix(1 0 -.70625 .70796 0 0)" x="1041.7" y="1457.5" width="18" height="15.365" fill="#f7941e" fill-opacity=".81569" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
-</g>
+ <g transform="translate(0 -1020.4)" stroke="#000">
+ <path d="m12.5 1039.9v-18" fill="none" stroke="#008000" stroke-linecap="round" stroke-miterlimit="2" stroke-width="2"/>
+ <path d="m30.5 1039.9h-18" fill="none" stroke="#F00" stroke-linecap="round" stroke-miterlimit="2" stroke-width="2"/>
+ <path transform="matrix(1 0 -.69517 .71885 0 0)" d="m1018.3 1461.8v-15.133" fill="none" stroke="#00F" stroke-linecap="round" stroke-miterlimit="0" stroke-width="2.3589"/>
+ <rect transform="matrix(1 0 -.70625 .70796 0 0)" x="1041.7" y="1457.5" width="18" height="15.365" fill="#f7941e" fill-opacity=".81569" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/3d-plane-normal-z.svg b/silx/resources/gui/icons/3d-plane-normal-z.svg
index 6ee06dd..5ac7d86 100644
--- a/silx/resources/gui/icons/3d-plane-normal-z.svg
+++ b/silx/resources/gui/icons/3d-plane-normal-z.svg
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(0 -1020.4)">
-<path d="m12.5 1039.9v-18" fill="none" stroke="#008000" stroke-linecap="round" stroke-miterlimit="2" stroke-width="2"/>
-<path d="m30.5 1039.9h-18" fill="none" stroke="#F00" stroke-linecap="round" stroke-miterlimit="2" stroke-width="2"/>
-<path transform="matrix(1 0 -.69517 .71885 0 0)" d="m1018.3 1461.8v-15.133" fill="none" stroke="#00F" stroke-linecap="round" stroke-miterlimit="0" stroke-width="2.3589"/>
-<g transform="translate(-24.646 -1.4219)" stroke="#000">
-<rect x="31.61" y="1029.2" width="18" height="18" ry="0" fill="#f7941e" fill-opacity=".81569" stroke-miterlimit="2"/>
-</g>
-</g>
+ <g transform="translate(0 -1020.4)">
+ <path d="m12.5 1039.9v-18" fill="none" stroke="#008000" stroke-linecap="round" stroke-miterlimit="2" stroke-width="2"/>
+ <path d="m30.5 1039.9h-18" fill="none" stroke="#F00" stroke-linecap="round" stroke-miterlimit="2" stroke-width="2"/>
+ <path transform="matrix(1 0 -.69517 .71885 0 0)" d="m1018.3 1461.8v-15.133" fill="none" stroke="#00F" stroke-linecap="round" stroke-miterlimit="0" stroke-width="2.3589"/>
+ <g transform="translate(-24.646 -1.4219)" stroke="#000">
+ <rect x="31.61" y="1029.2" width="18" height="18" ry="0" fill="#f7941e" fill-opacity=".81569" stroke-miterlimit="2"/>
+ </g>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/3d-plane-pan.svg b/silx/resources/gui/icons/3d-plane-pan.svg
index 2867c3e..73df5fc 100644
--- a/silx/resources/gui/icons/3d-plane-pan.svg
+++ b/silx/resources/gui/icons/3d-plane-pan.svg
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<rect transform="matrix(1 0 -.69517 .71885 0 0)" x="31.513" y="27.211" width="18" height="15.133" fill="none" stroke="#808080" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1795"/>
-<rect x="12.5" y="1.5" width="18" height="18" ry="0" fill="none" stroke="#808080" stroke-miterlimit="2"/>
-<g transform="translate(.085189 -2e-7)">
-<rect transform="rotate(90)" x="9.6949" y="-16.915" width="16.61" height="1.8305" ry=".020888" color="#000000"/>
-<path d="m19.47 24.598c-1.2305 2.0808-2.3818 3.924-3.5398 5.7873l-3.3995-5.7593z"/>
-<path d="m19.47 7.4021c-1.2305-2.0808-2.3818-3.924-3.5398-5.7873l-3.3995 5.7593z"/>
-<rect transform="matrix(1 0 -.70625 .70796 0 0)" x="23.765" y="16.176" width="18" height="15.365" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
-<rect transform="rotate(90)" x="5.6949" y="-16.915" width="10.225" height="1.8305" ry=".020888" color="#000000"/>
-</g>
-<rect x="1.5" y="12.5" width="18" height="18" ry="0" fill="none" stroke="#808080" stroke-miterlimit="2"/>
-<rect transform="matrix(1 0 -.70625 .70796 0 0)" x="13.765" y="2.0757" width="18" height="15.365" fill="none" stroke="#808080" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
+ <rect transform="matrix(1 0 -.69517 .71885 0 0)" x="31.513" y="27.211" width="18" height="15.133" fill="none" stroke="#808080" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1795"/>
+ <rect x="12.5" y="1.5" width="18" height="18" ry="0" fill="none" stroke="#808080" stroke-miterlimit="2"/>
+ <g transform="translate(.085189 -2e-7)">
+ <rect transform="rotate(90)" x="9.6949" y="-16.915" width="16.61" height="1.8305" ry=".020888" color="#000000"/>
+ <path d="m19.47 24.598c-1.2305 2.0808-2.3818 3.924-3.5398 5.7873l-3.3995-5.7593z"/>
+ <path d="m19.47 7.4021c-1.2305-2.0808-2.3818-3.924-3.5398-5.7873l-3.3995 5.7593z"/>
+ <rect transform="matrix(1 0 -.70625 .70796 0 0)" x="23.765" y="16.176" width="18" height="15.365" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
+ <rect transform="rotate(90)" x="5.6949" y="-16.915" width="10.225" height="1.8305" ry=".020888" color="#000000"/>
+ </g>
+ <rect x="1.5" y="12.5" width="18" height="18" ry="0" fill="none" stroke="#808080" stroke-miterlimit="2"/>
+ <rect transform="matrix(1 0 -.70625 .70796 0 0)" x="13.765" y="2.0757" width="18" height="15.365" fill="none" stroke="#808080" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
</svg>
diff --git a/silx/resources/gui/icons/3d-plane.svg b/silx/resources/gui/icons/3d-plane.svg
index 4ba88bc..830db78 100644
--- a/silx/resources/gui/icons/3d-plane.svg
+++ b/silx/resources/gui/icons/3d-plane.svg
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<path d="m12.5 19.538v-18" fill="none" stroke="#008000" stroke-linecap="round" stroke-miterlimit="2" stroke-width="2"/>
-<path d="m30.5 19.538h-18" fill="none" stroke="#F00" stroke-linecap="round" stroke-miterlimit="2" stroke-width="2"/>
-<path d="m2.1003 30.446 10.52-10.879" fill="none" stroke="#00F" stroke-linecap="round" stroke-miterlimit="0" stroke-width="2"/>
-<path d="m12.881 4.6102-7.7285 22.78 21.694-7.729z" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-width="1px"/>
+ <path d="m12.5 19.538v-18" fill="none" stroke="#008000" stroke-linecap="round" stroke-miterlimit="2" stroke-width="2"/>
+ <path d="m30.5 19.538h-18" fill="none" stroke="#F00" stroke-linecap="round" stroke-miterlimit="2" stroke-width="2"/>
+ <path d="m2.1003 30.446 10.52-10.879" fill="none" stroke="#00F" stroke-linecap="round" stroke-miterlimit="0" stroke-width="2"/>
+ <path d="m12.881 4.6102-7.7285 22.78 21.694-7.729z" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-width="1px"/>
</svg>
diff --git a/silx/resources/gui/icons/add-range-horizontal.png b/silx/resources/gui/icons/add-range-horizontal.png
new file mode 100644
index 0000000..14bdd18
--- /dev/null
+++ b/silx/resources/gui/icons/add-range-horizontal.png
Binary files differ
diff --git a/silx/resources/gui/icons/add-range-horizontal.svg b/silx/resources/gui/icons/add-range-horizontal.svg
new file mode 100644
index 0000000..0470609
--- /dev/null
+++ b/silx/resources/gui/icons/add-range-horizontal.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg2987" enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata2995"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata><g id="g850" transform="translate(-.7195)" fill="#f7941e"><rect id="rect2989" x="5.387" y="14.5" width="22.665" height="3"/><rect id="rect2989-3" transform="rotate(90)" x="11.854" y="-8.387" width="8.2921" height="3"/><rect id="rect2989-3-6" transform="rotate(90)" x="11.854" y="-28.052" width="8.2921" height="3"/></g><g id="g40" transform="translate(.25293 13.263)" fill="#00a651" stroke="#00a651" stroke-miterlimit="10"><rect id="rect42" x="24.483" y="7.225" width="1.239" height="8.379"/><rect id="rect44" x="20.913" y="10.796" width="8.38" height="1.237"/></g></svg>
diff --git a/silx/resources/gui/icons/add-shape-circle.png b/silx/resources/gui/icons/add-shape-circle.png
new file mode 100644
index 0000000..722c08a
--- /dev/null
+++ b/silx/resources/gui/icons/add-shape-circle.png
Binary files differ
diff --git a/silx/resources/gui/icons/add-shape-circle.svg b/silx/resources/gui/icons/add-shape-circle.svg
new file mode 100644
index 0000000..871d8ee
--- /dev/null
+++ b/silx/resources/gui/icons/add-shape-circle.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg16" enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata22"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><circle id="path24" cx="15.856" cy="15.802" r="12.691" fill="none" stroke="#f7941e" stroke-miterlimit="10" stroke-width="3"/><g id="g8" transform="translate(.25293 13.263)" fill="#00a651" stroke="#00a651" stroke-miterlimit="10"><rect id="rect4" x="24.483" y="7.225" width="1.239" height="8.379"/><rect id="rect6" x="20.913" y="10.796" width="8.38" height="1.237"/></g></svg>
diff --git a/silx/resources/gui/icons/add-shape-cross.png b/silx/resources/gui/icons/add-shape-cross.png
new file mode 100644
index 0000000..2e5eb60
--- /dev/null
+++ b/silx/resources/gui/icons/add-shape-cross.png
Binary files differ
diff --git a/silx/resources/gui/icons/add-shape-cross.svg b/silx/resources/gui/icons/add-shape-cross.svg
new file mode 100644
index 0000000..c08ef33
--- /dev/null
+++ b/silx/resources/gui/icons/add-shape-cross.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg2997" enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata3005"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata><g id="g40" transform="translate(.25293 13.263)" fill="#00a651" stroke="#00a651" stroke-miterlimit="10"><rect id="rect42" x="24.483" y="7.225" width="1.239" height="8.379"/><rect id="rect44" x="20.913" y="10.796" width="8.38" height="1.237"/></g><rect id="rect2999-6-3" transform="scale(-1)" x="-27.739" y="-17.5" width="23.478" height="3" fill="#f7941e"/><rect id="rect2999-6-3-3" transform="rotate(-90)" x="-27.739" y="14.5" width="23.478" height="3" fill="#f7941e"/></svg>
diff --git a/silx/resources/gui/icons/add-shape-ellipse.png b/silx/resources/gui/icons/add-shape-ellipse.png
new file mode 100644
index 0000000..c3f2290
--- /dev/null
+++ b/silx/resources/gui/icons/add-shape-ellipse.png
Binary files differ
diff --git a/silx/resources/gui/icons/add-shape-ellipse.svg b/silx/resources/gui/icons/add-shape-ellipse.svg
new file mode 100644
index 0000000..5c466ae
--- /dev/null
+++ b/silx/resources/gui/icons/add-shape-ellipse.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg16" enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata22"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><ellipse id="path24" transform="matrix(.8582 -.51332 .53796 .84297 0 0)" cx="5.273" cy="21.53" rx="14.141" ry="9.2386" fill="none" stroke="#f7941e" stroke-miterlimit="10" stroke-width="2.8572"/><g id="g8" transform="translate(.0372 12.616)" fill="#00a651" stroke="#00a651" stroke-miterlimit="10"><rect id="rect4" x="24.483" y="7.225" width="1.239" height="8.379"/><rect id="rect6" x="20.913" y="10.796" width="8.38" height="1.237"/></g></svg>
diff --git a/silx/resources/gui/icons/add-shape-point.png b/silx/resources/gui/icons/add-shape-point.png
index a1e5c1b..fa2111a 100644
--- a/silx/resources/gui/icons/add-shape-point.png
+++ b/silx/resources/gui/icons/add-shape-point.png
Binary files differ
diff --git a/silx/resources/gui/icons/add-shape-point.svg b/silx/resources/gui/icons/add-shape-point.svg
index 8b4ce13..c5ed941 100644
--- a/silx/resources/gui/icons/add-shape-point.svg
+++ b/silx/resources/gui/icons/add-shape-point.svg
@@ -1,2 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
-<svg id="svg2997" enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata3005"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata><rect id="rect2999" x="7.7183" y="14.5" width="16.563" height="3" fill="#f7941e"/><rect id="rect2999-6" transform="rotate(90)" x="7.7183" y="-17.5" width="16.563" height="3" fill="#f7941e"/><g id="g40" transform="translate(.25293 13.263)" fill="#00a651" stroke="#00a651" stroke-miterlimit="10"><rect id="rect42" x="24.483" y="7.225" width="1.239" height="8.379"/><rect id="rect44" x="20.913" y="10.796" width="8.38" height="1.237"/></g></svg>
+<svg id="svg2997" enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata3005"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata><rect id="rect2999" x="11.99" y="14.5" width="8.0206" height="3" fill="#f7941e"/><g id="g40" transform="translate(.25293 13.263)" fill="#00a651" stroke="#00a651" stroke-miterlimit="10"><rect id="rect42" x="24.483" y="7.225" width="1.239" height="8.379"/><rect id="rect44" x="20.913" y="10.796" width="8.38" height="1.237"/></g><rect id="rect2999-3" transform="rotate(90)" x="11.99" y="-17.5" width="8.0206" height="3" fill="#f7941e"/></svg>
diff --git a/silx/resources/gui/icons/axis.svg b/silx/resources/gui/icons/axis.svg
index fc07e30..4ea7ddc 100644
--- a/silx/resources/gui/icons/axis.svg
+++ b/silx/resources/gui/icons/axis.svg
@@ -1,2 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"><g><path d="m10.041 23.61c-0.24903 0.58659-0.65577 0.87988-1.2202 0.87988-0.79688 0-1.2119-0.48421-1.2451-1.4526-0.69174 0.99056-1.6546 1.4858-2.8887 1.4858-0.74707 0-1.3696-0.19645-1.8677-0.58936-0.53125-0.43164-0.79688-1.0099-0.79688-1.7349-9e-7 -1.7874 1.5246-2.7752 4.5737-2.9634l0.86328-0.0498v-0.30713c-6.4e-6 -1.7044-0.6696-2.5566-2.0088-2.5566-0.89095 8e-6 -1.4969 0.29607-1.8179 0.88818 0.32096 0.04981 0.48144 0.25456 0.48145 0.61426-3e-6 0.48698-0.24903 0.73047-0.74707 0.73047-0.54785 6e-6 -0.82178-0.27669-0.82178-0.83008-1.5e-6 -0.46484 0.26009-0.89094 0.78027-1.2783 0.61979-0.46484 1.3586-0.69726 2.2163-0.69727 1.9922 9e-6 2.9883 0.99334 2.9883 2.98v3.4531c-7.4e-6 1.1344 0.19368 1.7017 0.58105 1.7017 0.23795 0 0.43993-0.17155 0.60596-0.51465l0.32373 0.24072m-2.5815-1.4941v-2.3325l-0.71387 0.0332c-2.3574 0.11622-3.5361 0.87712-3.5361 2.2827-2.2e-6 0.52018 0.16325 0.94629 0.48975 1.2783 0.32096 0.31543 0.72493 0.47314 1.2119 0.47314 0.66406 0 1.2589-0.20475 1.7847-0.61426 0.35416-0.28776 0.60872-0.66129 0.76367-1.1206"/><path d="m19.222 24.241h-3.6523v-0.47314c0.68066 1e-6 1.021-0.11344 1.021-0.34033-6e-6 -0.10514-0.13558-0.34586-0.40674-0.72217l-1.5854-2.1416-1.9009 2.3159c-0.19369 0.24349-0.29053 0.42611-0.29053 0.54785-2e-6 0.22689 0.3846 0.34033 1.1538 0.34033v0.47314h-3.2456v-0.47314c0.61426 1e-6 1.2202-0.37077 1.8179-1.1123l2.0752-2.5483-1.8677-2.4321c-0.30436-0.40396-0.56445-0.69449-0.78027-0.87158-0.23242-0.16047-0.57276-0.24902-1.021-0.26562v-0.49805h3.6025v0.49805c-0.6696 0.01661-1.0044 0.13559-1.0044 0.35693-3e-6 0.11622 0.13558 0.35417 0.40674 0.71387l1.3945 1.8511 1.6851-2.0752c0.19368-0.24348 0.29052-0.42056 0.29053-0.53125-7e-6 -0.19368-0.40398-0.29882-1.2119-0.31543v-0.49805h3.2622v0.49805c-0.32097 7e-6 -0.57553 0.04981-0.76367 0.14941-0.2435 0.11622-0.58383 0.43441-1.021 0.95459l-1.8677 2.2412 2.0586 2.7227c0.55338 0.7526 1.1704 1.14 1.8511 1.1621v0.47314"/><path d="m21.828 12.205c0.24349 1.2e-5 0.44824 0.08579 0.61426 0.25732 0.17155 0.17156 0.25732 0.37631 0.25732 0.61426-3e-6 0.56446-0.29053 0.84669-0.87158 0.84668-0.56446 1.1e-5 -0.84668-0.28222-0.84668-0.84668-1e-6 -0.58104 0.28222-0.87157 0.84668-0.87158m2.0088 12.036h-4.0425v-0.47314c0.37077 1e-6 0.67513-0.03597 0.91309-0.10791 0.3763-0.09961 0.56445-0.42334 0.56445-0.97119v-5.0801c-2e-6 -0.53678-0.13558-0.85497-0.40674-0.95459-0.17155-0.0664-0.52848-0.0996-1.0708-0.09961v-0.51465c0.92415-0.01659 1.7902-0.1079 2.5981-0.27393v6.9229c-3e-6 0.50912 0.13004 0.81624 0.39014 0.92139 0.25455 0.10514 0.60595 0.15772 1.0542 0.15772v0.47314"/><path d="m30.602 18.555c-0.08855-0.64192-0.31544-1.1483-0.68066-1.519-0.47592-0.4759-1.0487-0.71386-1.7183-0.71387-0.55339 8e-6 -1.0127 0.18262-1.3779 0.54785-0.26009 0.2767-0.39014 0.60596-0.39014 0.98779-2e-6 0.66407 0.57275 1.1759 1.7183 1.5356l0.43994 0.13281c0.94628 0.30437 1.6159 0.60596 2.0088 0.90478 0.56445 0.42611 0.84667 0.99333 0.84668 1.7017-8e-6 0.84115-0.3182 1.4748-0.95459 1.9009-0.49252 0.33756-1.0985 0.50635-1.8179 0.50635-0.6364 0-1.3143-0.19645-2.0337-0.58936-0.24349-0.14388-0.43164-0.21582-0.56445-0.21582-0.18262 1e-6 -0.27393 0.19645-0.27393 0.58936h-0.48975v-3.1128h0.52295c0.05534 0.80241 0.29883 1.4194 0.73047 1.8511 0.55338 0.56999 1.2451 0.85498 2.0752 0.85498 1.1787 1e-6 1.7681-0.52295 1.7681-1.5688-6e-6 -0.45931-0.17432-0.83284-0.52295-1.1206-0.23796-0.18815-0.78858-0.43164-1.6519-0.73047l-0.50635-0.17432c-0.78028-0.25455-1.3032-0.49251-1.5688-0.71387-0.49805-0.3929-0.74707-0.90755-0.74707-1.5439-2e-6 -0.61978 0.23242-1.1538 0.69726-1.6021 0.50911-0.4759 1.1732-0.71386 1.9922-0.71387 0.70833 9e-6 1.342 0.16879 1.9009 0.50635 0.16048 0.09408 0.29606 0.14112 0.40674 0.14111 0.12727 8e-6 0.20751-0.16324 0.24072-0.48975h0.42334v2.6479h-0.47314"/></g><path d="m1.9688 2.1562-1 0.03125 0.125 25.625v0.46875h0.5 28.625v-1h-28.125l-0.125-25.125z" color="#000000" style="block-progression:tb;text-indent:0;text-transform:none"/><path d="m24.972 25.376 6.5555 2.4106-6.5555 2.4106c1.0473-1.4232 1.0413-3.3705 0-4.8213z" fill-rule="evenodd" stroke-linejoin="round" stroke-width=".625"/><path d="m-0.9349 7.4134 2.3759-6.5682 2.4453 6.5427c-1.4288-1.0398-3.3759-1.0234-4.8212 0.025509z" fill-rule="evenodd" stroke-linejoin="round" stroke-width=".625"/></svg>
+<svg version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m10.041 23.61c-0.24903 0.58659-0.65577 0.87988-1.2202 0.87988-0.79688 0-1.2119-0.48421-1.2451-1.4526-0.69174 0.99056-1.6546 1.4858-2.8887 1.4858-0.74707 0-1.3696-0.19645-1.8677-0.58936-0.53125-0.43164-0.79688-1.0099-0.79688-1.7349-9e-7 -1.7874 1.5246-2.7752 4.5737-2.9634l0.86328-0.0498v-0.30713c-6.4e-6 -1.7044-0.6696-2.5566-2.0088-2.5566-0.89095 8e-6 -1.4969 0.29607-1.8179 0.88818 0.32096 0.04981 0.48144 0.25456 0.48145 0.61426-3e-6 0.48698-0.24903 0.73047-0.74707 0.73047-0.54785 6e-6 -0.82178-0.27669-0.82178-0.83008-1.5e-6 -0.46484 0.26009-0.89094 0.78027-1.2783 0.61979-0.46484 1.3586-0.69726 2.2163-0.69727 1.9922 9e-6 2.9883 0.99334 2.9883 2.98v3.4531c-7.4e-6 1.1344 0.19368 1.7017 0.58105 1.7017 0.23795 0 0.43993-0.17155 0.60596-0.51465l0.32373 0.24072m-2.5815-1.4941v-2.3325l-0.71387 0.0332c-2.3574 0.11622-3.5361 0.87712-3.5361 2.2827-2.2e-6 0.52018 0.16325 0.94629 0.48975 1.2783 0.32096 0.31543 0.72493 0.47314 1.2119 0.47314 0.66406 0 1.2589-0.20475 1.7847-0.61426 0.35416-0.28776 0.60872-0.66129 0.76367-1.1206"/><path d="m19.222 24.241h-3.6523v-0.47314c0.68066 1e-6 1.021-0.11344 1.021-0.34033-6e-6 -0.10514-0.13558-0.34586-0.40674-0.72217l-1.5854-2.1416-1.9009 2.3159c-0.19369 0.24349-0.29053 0.42611-0.29053 0.54785-2e-6 0.22689 0.3846 0.34033 1.1538 0.34033v0.47314h-3.2456v-0.47314c0.61426 1e-6 1.2202-0.37077 1.8179-1.1123l2.0752-2.5483-1.8677-2.4321c-0.30436-0.40396-0.56445-0.69449-0.78027-0.87158-0.23242-0.16047-0.57276-0.24902-1.021-0.26562v-0.49805h3.6025v0.49805c-0.6696 0.01661-1.0044 0.13559-1.0044 0.35693-3e-6 0.11622 0.13558 0.35417 0.40674 0.71387l1.3945 1.8511 1.6851-2.0752c0.19368-0.24348 0.29052-0.42056 0.29053-0.53125-7e-6 -0.19368-0.40398-0.29882-1.2119-0.31543v-0.49805h3.2622v0.49805c-0.32097 7e-6 -0.57553 0.04981-0.76367 0.14941-0.2435 0.11622-0.58383 0.43441-1.021 0.95459l-1.8677 2.2412 2.0586 2.7227c0.55338 0.7526 1.1704 1.14 1.8511 1.1621v0.47314"/><path d="m21.828 12.205c0.24349 1.2e-5 0.44824 0.08579 0.61426 0.25732 0.17155 0.17156 0.25732 0.37631 0.25732 0.61426-3e-6 0.56446-0.29053 0.84669-0.87158 0.84668-0.56446 1.1e-5 -0.84668-0.28222-0.84668-0.84668-1e-6 -0.58104 0.28222-0.87157 0.84668-0.87158m2.0088 12.036h-4.0425v-0.47314c0.37077 1e-6 0.67513-0.03597 0.91309-0.10791 0.3763-0.09961 0.56445-0.42334 0.56445-0.97119v-5.0801c-2e-6 -0.53678-0.13558-0.85497-0.40674-0.95459-0.17155-0.0664-0.52848-0.0996-1.0708-0.09961v-0.51465c0.92415-0.01659 1.7902-0.1079 2.5981-0.27393v6.9229c-3e-6 0.50912 0.13004 0.81624 0.39014 0.92139 0.25455 0.10514 0.60595 0.15772 1.0542 0.15772v0.47314"/><path d="m30.602 18.555c-0.08855-0.64192-0.31544-1.1483-0.68066-1.519-0.47592-0.4759-1.0487-0.71386-1.7183-0.71387-0.55339 8e-6 -1.0127 0.18262-1.3779 0.54785-0.26009 0.2767-0.39014 0.60596-0.39014 0.98779-2e-6 0.66407 0.57275 1.1759 1.7183 1.5356l0.43994 0.13281c0.94628 0.30437 1.6159 0.60596 2.0088 0.90478 0.56445 0.42611 0.84667 0.99333 0.84668 1.7017-8e-6 0.84115-0.3182 1.4748-0.95459 1.9009-0.49252 0.33756-1.0985 0.50635-1.8179 0.50635-0.6364 0-1.3143-0.19645-2.0337-0.58936-0.24349-0.14388-0.43164-0.21582-0.56445-0.21582-0.18262 1e-6 -0.27393 0.19645-0.27393 0.58936h-0.48975v-3.1128h0.52295c0.05534 0.80241 0.29883 1.4194 0.73047 1.8511 0.55338 0.56999 1.2451 0.85498 2.0752 0.85498 1.1787 1e-6 1.7681-0.52295 1.7681-1.5688-6e-6 -0.45931-0.17432-0.83284-0.52295-1.1206-0.23796-0.18815-0.78858-0.43164-1.6519-0.73047l-0.50635-0.17432c-0.78028-0.25455-1.3032-0.49251-1.5688-0.71387-0.49805-0.3929-0.74707-0.90755-0.74707-1.5439-2e-6 -0.61978 0.23242-1.1538 0.69726-1.6021 0.50911-0.4759 1.1732-0.71386 1.9922-0.71387 0.70833 9e-6 1.342 0.16879 1.9009 0.50635 0.16048 0.09408 0.29606 0.14112 0.40674 0.14111 0.12727 8e-6 0.20751-0.16324 0.24072-0.48975h0.42334v2.6479h-0.47314"/><path d="m1.9688 2.1562-1 0.03125 0.125 25.625v0.46875h29.125v-1h-28.125l-0.125-25.125z" color="#000000" style="block-progression:tb;text-indent:0;text-transform:none"/><path d="m24.972 25.376 6.5555 2.4106-6.5555 2.4106c1.0473-1.4232 1.0413-3.3705 0-4.8213z" fill-rule="evenodd" stroke-linejoin="round" stroke-width=".625"/><path d="m-0.9349 7.4134 2.3759-6.5682 2.4453 6.5427c-1.4288-1.0398-3.3759-1.0234-4.8212 0.025509z" fill-rule="evenodd" stroke-linejoin="round" stroke-width=".625"/></svg>
diff --git a/silx/resources/gui/icons/camera.svg b/silx/resources/gui/icons/camera.svg
index ac9ed92..e53858a 100644
--- a/silx/resources/gui/icons/camera.svg
+++ b/silx/resources/gui/icons/camera.svg
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(0 -1020.4)">
-<g transform="translate(-.25 -.037824)">
-<rect x="3.5" y="1030.4" width="16" height="12" rx="2"/>
-<path d="m22 1033.1 6-2c0.52557-0.1752 1 0.446 1 1v8c0 0.554-0.47443 1.1752-1 1l-6-2c-0.52557-0.1752-1-0.446-1-1v-4c0-0.554 0.47443-0.8248 1-1z"/>
-</g>
-</g>
+ <g transform="translate(0 -1020.4)">
+ <g transform="translate(-.25 -.037824)">
+ <rect x="3.5" y="1030.4" width="16" height="12" rx="2"/>
+ <path d="m22 1033.1 6-2c0.52557-0.1752 1 0.446 1 1v8c0 0.554-0.47443 1.1752-1 1l-6-2c-0.52557-0.1752-1-0.446-1-1v-4c0-0.554 0.47443-0.8248 1-1z"/>
+ </g>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/colormap-histogram.svg b/silx/resources/gui/icons/colormap-histogram.svg
index 951dee6..d5a0996 100644
--- a/silx/resources/gui/icons/colormap-histogram.svg
+++ b/silx/resources/gui/icons/colormap-histogram.svg
@@ -1,37 +1,15 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<?xml version="1.0" encoding="UTF-8"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
-
-<svg
- xmlns:dc="http://purl.org/dc/elements/1.1/"
- xmlns:cc="http://creativecommons.org/ns#"
- xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
- xmlns:svg="http://www.w3.org/2000/svg"
- xmlns="http://www.w3.org/2000/svg"
- version="1.1"
- width="100%"
- height="100%"
- viewBox="0 0 32 32"
- id="svg2">
- <metadata
- id="metadata10">
- <rdf:RDF>
- <cc:Work
- rdf:about="">
- <dc:format>image/svg+xml</dc:format>
- <dc:type
- rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
- <dc:title></dc:title>
- </cc:Work>
- </rdf:RDF>
- </metadata>
- <defs
- id="defs8" />
- <path
- d="m 28.857399,28.857399 -26.0259991,0 C 12.191411,28.847797 6.2161112,2.8599459 15.844399,2.8313999 c 9.628288,-0.028546 3.521475,26.0156951 13.013,26.0259991 z"
- id="rect4"
- style="fill:#f7941e;fill-opacity:0.81568998;stroke:none" />
- <path
- d="m 28.857399,2.8314 0,26.025999 m -26.0259991,0 0,-26.025999"
- id="rect4-3"
- style="fill:none;stroke:#000000;stroke-width:1.39999998;stroke-miterlimit:2" />
+<svg id="svg2" version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+ <metadata id="metadata10">
+ <rdf:RDF>
+ <cc:Work rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
+ <dc:title/>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <path id="rect4" d="m28.857 28.857h-26.026c9.36-0.009602 3.3847-25.997 13.013-26.026 9.6283-0.028546 3.5215 26.016 13.013 26.026z" fill="#f7941e" fill-opacity=".81569"/>
+ <path id="rect4-3" d="m28.857 2.8314v26.026m-26.026 0v-26.026" fill="none" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
</svg>
diff --git a/silx/resources/gui/icons/colormap-none.svg b/silx/resources/gui/icons/colormap-none.svg
index 127238a..3136d62 100644
--- a/silx/resources/gui/icons/colormap-none.svg
+++ b/silx/resources/gui/icons/colormap-none.svg
@@ -1,33 +1,14 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<?xml version="1.0" encoding="UTF-8"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
-
-<svg
- xmlns:dc="http://purl.org/dc/elements/1.1/"
- xmlns:cc="http://creativecommons.org/ns#"
- xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
- xmlns:svg="http://www.w3.org/2000/svg"
- xmlns="http://www.w3.org/2000/svg"
- version="1.1"
- width="100%"
- height="100%"
- viewBox="0 0 32 32"
- id="svg2">
- <metadata
- id="metadata10">
- <rdf:RDF>
- <cc:Work
- rdf:about="">
- <dc:format>image/svg+xml</dc:format>
- <dc:type
- rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
- <dc:title></dc:title>
- </cc:Work>
- </rdf:RDF>
- </metadata>
- <defs
- id="defs8" />
- <path
- d="m 28.857399,2.8314 0,26.025999 m -26.0259991,0 0,-26.025999"
- id="rect4-3"
- style="fill:none;stroke:#000000;stroke-width:1.39999998;stroke-miterlimit:2" />
+<svg id="svg2" version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+ <metadata id="metadata10">
+ <rdf:RDF>
+ <cc:Work rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
+ <dc:title/>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <path id="rect4-3" d="m28.857 2.8314v26.026m-26.026 0v-26.026" fill="none" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
</svg>
diff --git a/silx/resources/gui/icons/colormap-norm-arcsinh.png b/silx/resources/gui/icons/colormap-norm-arcsinh.png
new file mode 100644
index 0000000..653102d
--- /dev/null
+++ b/silx/resources/gui/icons/colormap-norm-arcsinh.png
Binary files differ
diff --git a/silx/resources/gui/icons/colormap-norm-arcsinh.svg b/silx/resources/gui/icons/colormap-norm-arcsinh.svg
new file mode 100644
index 0000000..961df04
--- /dev/null
+++ b/silx/resources/gui/icons/colormap-norm-arcsinh.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg16" enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata22"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><path id="path955" d="m16 7.5913v16.817" stroke="#000" stroke-linecap="round" stroke-width="1.1339"/><path id="path817-6-3" d="m3.8135 22.486c7.5917-4.76e-4 9.1521-2.8336 12.186-6.4864 3.0344-3.6528 5.0521-6.4656 12.186-6.4864v0" fill="none" stroke="#f7941e" stroke-linecap="round" stroke-miterlimit="10" stroke-width="3.0009"/></svg>
diff --git a/silx/resources/gui/icons/colormap-norm-gamma.png b/silx/resources/gui/icons/colormap-norm-gamma.png
new file mode 100644
index 0000000..3fe9c3e
--- /dev/null
+++ b/silx/resources/gui/icons/colormap-norm-gamma.png
Binary files differ
diff --git a/silx/resources/gui/icons/colormap-norm-gamma.svg b/silx/resources/gui/icons/colormap-norm-gamma.svg
new file mode 100644
index 0000000..b43355e
--- /dev/null
+++ b/silx/resources/gui/icons/colormap-norm-gamma.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg16" enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata22"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><path id="path819-3" d="m3.0078 21.992c1.5704-10.935 8.591-13.546 25.965-12.572" fill="none" stroke="#f7941e" stroke-linecap="round" stroke-miterlimit="10" stroke-width="3.0009"/><path id="path819-3-5" d="m3.0078 21.992c11.546-0.22048 23.412 2.7854 25.965-12.572" fill="none" stroke="#f7941e" stroke-linecap="round" stroke-miterlimit="10" stroke-width="3.0009"/><path id="path817-6" d="m3.0078 21.992 25.965-12.572" fill="none" stroke="#f7941e" stroke-linecap="round" stroke-miterlimit="10" stroke-width="3.0009"/></svg>
diff --git a/silx/resources/gui/icons/colormap-norm-linear.png b/silx/resources/gui/icons/colormap-norm-linear.png
new file mode 100644
index 0000000..60d2fe1
--- /dev/null
+++ b/silx/resources/gui/icons/colormap-norm-linear.png
Binary files differ
diff --git a/silx/resources/gui/icons/colormap-norm-linear.svg b/silx/resources/gui/icons/colormap-norm-linear.svg
new file mode 100644
index 0000000..eabfa23
--- /dev/null
+++ b/silx/resources/gui/icons/colormap-norm-linear.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg16" enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata22"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><path id="path955-6" d="m16 7.5913v16.817" stroke="#000" stroke-linecap="round" stroke-width="1.1339"/><path id="path817" d="m4.5997 22.879 22.801-13.759v0" fill="none" stroke="#f7941e" stroke-linecap="round" stroke-miterlimit="10" stroke-width="3.0009"/></svg>
diff --git a/silx/resources/gui/icons/colormap-norm-log.png b/silx/resources/gui/icons/colormap-norm-log.png
new file mode 100644
index 0000000..2486255
--- /dev/null
+++ b/silx/resources/gui/icons/colormap-norm-log.png
Binary files differ
diff --git a/silx/resources/gui/icons/colormap-norm-log.svg b/silx/resources/gui/icons/colormap-norm-log.svg
new file mode 100644
index 0000000..69d6b96
--- /dev/null
+++ b/silx/resources/gui/icons/colormap-norm-log.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg16" enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata22"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><path id="path955-9" d="m16 7.5913v16.817" stroke="#000" stroke-linecap="round" stroke-width="1.1339"/><path id="path819" d="m18.372 22.531c0.081807-8.5235 2.7228-13.861 10.364-12.973" fill="none" stroke="#f7941e" stroke-linecap="round" stroke-miterlimit="10" stroke-width="3.0009" style="font-variant-east_asian:normal"/></svg>
diff --git a/silx/resources/gui/icons/colormap-norm-sqrt.png b/silx/resources/gui/icons/colormap-norm-sqrt.png
new file mode 100644
index 0000000..d1b3ef5
--- /dev/null
+++ b/silx/resources/gui/icons/colormap-norm-sqrt.png
Binary files differ
diff --git a/silx/resources/gui/icons/colormap-norm-sqrt.svg b/silx/resources/gui/icons/colormap-norm-sqrt.svg
new file mode 100644
index 0000000..4d239e4
--- /dev/null
+++ b/silx/resources/gui/icons/colormap-norm-sqrt.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg16" enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata22"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><path id="path955-3" d="m16 7.5913v16.817" stroke="#000" stroke-linecap="round" stroke-width="1.1339"/><path id="path815" d="m18.268 23.756c1.1576-8.0684 5.1299-12.689 10.556-15.528" fill="none" stroke="#f7941e" stroke-linecap="round" stroke-miterlimit="10" stroke-width="3.0009" style="font-variant-east_asian:normal"/></svg>
diff --git a/silx/resources/gui/icons/colormap-range.svg b/silx/resources/gui/icons/colormap-range.svg
index 087af92..0e70311 100644
--- a/silx/resources/gui/icons/colormap-range.svg
+++ b/silx/resources/gui/icons/colormap-range.svg
@@ -1,37 +1,15 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<?xml version="1.0" encoding="UTF-8"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
-
-<svg
- xmlns:dc="http://purl.org/dc/elements/1.1/"
- xmlns:cc="http://creativecommons.org/ns#"
- xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
- xmlns:svg="http://www.w3.org/2000/svg"
- xmlns="http://www.w3.org/2000/svg"
- version="1.1"
- width="100%"
- height="100%"
- viewBox="0 0 32 32"
- id="svg2">
- <metadata
- id="metadata10">
- <rdf:RDF>
- <cc:Work
- rdf:about="">
- <dc:format>image/svg+xml</dc:format>
- <dc:type
- rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
- <dc:title></dc:title>
- </cc:Work>
- </rdf:RDF>
- </metadata>
- <defs
- id="defs8" />
- <path
- d="m 28.857399,2.8313999 0,26.0259991 -26.0259991,0 0,-26.0259991 z"
- id="rect4"
- style="fill:#f7941e;fill-opacity:0.81568998;stroke:none" />
- <path
- d="m 28.857399,2.8314 0,26.025999 m -26.0259991,0 0,-26.025999"
- id="rect4-3"
- style="fill:none;stroke:#000000;stroke-width:1.39999998;stroke-miterlimit:2" />
+<svg id="svg2" version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+ <metadata id="metadata10">
+ <rdf:RDF>
+ <cc:Work rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
+ <dc:title/>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <path id="rect4" d="m28.857 2.8314v26.026h-26.026v-26.026z" fill="#f7941e" fill-opacity=".81569"/>
+ <path id="rect4-3" d="m28.857 2.8314v26.026m-26.026 0v-26.026" fill="none" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
</svg>
diff --git a/silx/resources/gui/icons/crosshair.svg b/silx/resources/gui/icons/crosshair.svg
index d61f301..e96ef83 100644
--- a/silx/resources/gui/icons/crosshair.svg
+++ b/silx/resources/gui/icons/crosshair.svg
@@ -1,2 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><filter id="a" x="-.18999" y="-.11594" width="1.38" height="1.2319" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="1.239375"/></filter></defs><path d="m0.13559 5.2881h31.864" fill="none" stroke="#f7941e" stroke-width="3"/><path d="m6.8475 0.067797v31.864" fill="none" stroke="#f7941e" stroke-width="3"/><path transform="matrix(.83268 0 0 .83268 1.0722 .21558)" d="m6.9515 5.8837v21.469c1.5625-1.5625 3.125-3.125 4.6875-4.6875 1.2861 2.9607 2.596 5.9112 3.875 8.875 1.6799-0.58623 3.0577-1.1237 4.5625-1.6875-1.3552-2.9246-2.7857-5.8158-4.1875-8.7188h6.7188c-5.3704-5.3112-11.062-10.667-15.656-15.25z" color="#000000" filter="url(#a)" style="block-progression:tb;text-indent:0;text-transform:none"/><path d="m7.5738 5.8489v15.891l3.6382-3.6382 3.3172 7.5975 2.9962-1.1236-3.5847-7.437h5.2433z" stroke="#fff" stroke-width=".83268"/></svg>
+<svg version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><filter id="a" x="-.18999" y="-.11594" width="1.38" height="1.2319" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="1.239375"/></filter></defs><path d="m0.13559 5.2881h31.864" fill="none" stroke="#f7941e" stroke-width="3"/><path d="m6.8475 0.067797v31.864" fill="none" stroke="#f7941e" stroke-width="3"/><path transform="matrix(.83268 0 0 .83268 1.0722 .21558)" d="m6.9515 5.8837v21.469l4.6875-4.6875c1.2861 2.9607 2.596 5.9112 3.875 8.875 1.6799-0.58623 3.0577-1.1237 4.5625-1.6875-1.3552-2.9246-2.7857-5.8158-4.1875-8.7188h6.7188c-5.3704-5.3112-11.062-10.667-15.656-15.25z" color="#000000" filter="url(#a)" style="block-progression:tb;text-indent:0;text-transform:none"/><path d="m7.5738 5.8489v15.891l3.6382-3.6382 3.3172 7.5975 2.9962-1.1236-3.5847-7.437h5.2433z" stroke="#fff" stroke-width=".83268"/></svg>
diff --git a/silx/resources/gui/icons/cube-back.svg b/silx/resources/gui/icons/cube-back.svg
index 8e9c690..d1d79a5 100644
--- a/silx/resources/gui/icons/cube-back.svg
+++ b/silx/resources/gui/icons/cube-back.svg
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(0 -1020.4)" stroke="#000">
-<rect transform="matrix(1 0 -.69517 .71885 0 0)" x="1018.3" y="1446.7" width="18" height="15.133" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1795"/>
-<rect x="12.5" y="1021.9" width="18" height="18" ry="0" fill="#f7941e" fill-opacity=".81569" stroke-miterlimit="2"/>
-<rect transform="matrix(1 0 -.70625 .70796 0 0)" x="1031.7" y="1443.4" width="18" height="15.365" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
-<rect x="1.5" y="1032.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
-</g>
+ <g transform="translate(0 -1020.4)" stroke="#000">
+ <rect transform="matrix(1 0 -.69517 .71885 0 0)" x="1018.3" y="1446.7" width="18" height="15.133" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1795"/>
+ <rect x="12.5" y="1021.9" width="18" height="18" ry="0" fill="#f7941e" fill-opacity=".81569" stroke-miterlimit="2"/>
+ <rect transform="matrix(1 0 -.70625 .70796 0 0)" x="1031.7" y="1443.4" width="18" height="15.365" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
+ <rect x="1.5" y="1032.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/cube-bottom.svg b/silx/resources/gui/icons/cube-bottom.svg
index ac2ae4b..f3d9cbc 100644
--- a/silx/resources/gui/icons/cube-bottom.svg
+++ b/silx/resources/gui/icons/cube-bottom.svg
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(0 -1020.4)" stroke="#000">
-<rect transform="matrix(1 0 -.69517 .71885 0 0)" x="1018.3" y="1446.7" width="18" height="15.133" fill="#f7941e" fill-opacity=".81569" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1795"/>
-<rect x="12.5" y="1021.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
-<rect transform="matrix(1 0 -.70625 .70796 0 0)" x="1031.7" y="1443.4" width="18" height="15.365" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
-<rect x="1.5" y="1032.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
-</g>
+ <g transform="translate(0 -1020.4)" stroke="#000">
+ <rect transform="matrix(1 0 -.69517 .71885 0 0)" x="1018.3" y="1446.7" width="18" height="15.133" fill="#f7941e" fill-opacity=".81569" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1795"/>
+ <rect x="12.5" y="1021.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
+ <rect transform="matrix(1 0 -.70625 .70796 0 0)" x="1031.7" y="1443.4" width="18" height="15.365" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
+ <rect x="1.5" y="1032.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/cube-front.svg b/silx/resources/gui/icons/cube-front.svg
index 9ea8aef..11f4fa2 100644
--- a/silx/resources/gui/icons/cube-front.svg
+++ b/silx/resources/gui/icons/cube-front.svg
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(0 -1020.4)" stroke="#000">
-<rect transform="matrix(1 0 -.69517 .71885 0 0)" x="1018.3" y="1446.7" width="18" height="15.133" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1795"/>
-<rect x="12.5" y="1021.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
-<rect transform="matrix(1 0 -.70625 .70796 0 0)" x="1031.7" y="1443.4" width="18" height="15.365" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
-<rect x="1.5" y="1032.9" width="18" height="18" ry="0" fill="#f7941e" fill-opacity=".81569" stroke-miterlimit="2"/>
-</g>
+ <g transform="translate(0 -1020.4)" stroke="#000">
+ <rect transform="matrix(1 0 -.69517 .71885 0 0)" x="1018.3" y="1446.7" width="18" height="15.133" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1795"/>
+ <rect x="12.5" y="1021.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
+ <rect transform="matrix(1 0 -.70625 .70796 0 0)" x="1031.7" y="1443.4" width="18" height="15.365" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
+ <rect x="1.5" y="1032.9" width="18" height="18" ry="0" fill="#f7941e" fill-opacity=".81569" stroke-miterlimit="2"/>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/cube-left.svg b/silx/resources/gui/icons/cube-left.svg
index f5c3753..7d0ee95 100644
--- a/silx/resources/gui/icons/cube-left.svg
+++ b/silx/resources/gui/icons/cube-left.svg
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(0 -1020.4)" stroke="#000">
-<rect transform="matrix(0 -1 -.70641 .70781 0 0)" x="-1052.2" y="-17.566" width="18" height="15.399" fill="#f7941e" fill-opacity=".81569" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1898"/>
-<rect transform="matrix(0 -1 -.70641 .70781 0 0)" x="-1070.6" y="-43.173" width="18" height="15.399" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1898"/>
-<rect x="12.5" y="1021.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
-<rect x="1.5" y="1032.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
-</g>
+ <g transform="translate(0 -1020.4)" stroke="#000">
+ <rect transform="matrix(0 -1 -.70641 .70781 0 0)" x="-1052.2" y="-17.566" width="18" height="15.399" fill="#f7941e" fill-opacity=".81569" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1898"/>
+ <rect transform="matrix(0 -1 -.70641 .70781 0 0)" x="-1070.6" y="-43.173" width="18" height="15.399" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1898"/>
+ <rect x="12.5" y="1021.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
+ <rect x="1.5" y="1032.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/cube-right.svg b/silx/resources/gui/icons/cube-right.svg
index f74ff51..c98e3e1 100644
--- a/silx/resources/gui/icons/cube-right.svg
+++ b/silx/resources/gui/icons/cube-right.svg
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(0 -1020.4)" stroke="#000">
-<rect transform="matrix(0 -1 -.70641 .70781 0 0)" x="-1052.2" y="-17.566" width="18" height="15.399" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1898"/>
-<rect x="12.5" y="1021.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
-<rect x="1.5" y="1032.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
-<rect transform="matrix(0 -1 -.70641 .70781 0 0)" x="-1070.6" y="-43.173" width="18" height="15.399" fill="#f7941e" fill-opacity=".81569" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1898"/>
-</g>
+ <g transform="translate(0 -1020.4)" stroke="#000">
+ <rect transform="matrix(0 -1 -.70641 .70781 0 0)" x="-1052.2" y="-17.566" width="18" height="15.399" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1898"/>
+ <rect x="12.5" y="1021.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
+ <rect x="1.5" y="1032.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
+ <rect transform="matrix(0 -1 -.70641 .70781 0 0)" x="-1070.6" y="-43.173" width="18" height="15.399" fill="#f7941e" fill-opacity=".81569" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1898"/>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/cube-rotate.svg b/silx/resources/gui/icons/cube-rotate.svg
index fb835e6..44cdfe4 100644
--- a/silx/resources/gui/icons/cube-rotate.svg
+++ b/silx/resources/gui/icons/cube-rotate.svg
@@ -1,12 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(.47016 0 0 .47016 8.4669 -465.76)" stroke="#000">
-<rect transform="matrix(1 0 -.69517 .71885 0 0)" x="1018.3" y="1446.7" width="18" height="15.133" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1795"/>
-<rect x="12.5" y="1021.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
-<rect transform="matrix(1 0 -.70625 .70796 0 0)" x="1031.7" y="1443.4" width="18" height="15.365" fill="#f7941e" fill-opacity=".81569" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
-<rect x="1.5" y="1032.9" width="18" height="18" ry="0" fill="#f7941e" fill-opacity=".81569" stroke-miterlimit="2"/>
-<path transform="matrix(0 -1 -.70641 .70781 0 0)" d="m-1070.3-42.826 17.725-0.01751 0.026 14.9-18.077 0.01751z" fill="#f7941e" fill-opacity=".81569" stroke-linejoin="bevel" stroke-miterlimit="0" stroke-width=".5949"/>
-</g>
-<path d="m20.844 3.6071c2.0047-0.09094 3.8084-0.12892 5.6292-0.1714l-2.4557 4.9793z"/>
-<path d="m24.868 5.9491c1.1349 0.30058 2.0533 0.6585 2.6879 1.0559 0.63468 0.39744 0.98564 0.83441 0.98564 1.2931 0 1.8347-5.6154 3.322-12.542 3.322-6.927 0-12.542-1.4873-12.542-3.322 0-0.45868 0.35096-0.89564 0.98564-1.2931 0.63468-0.39744 1.5531-0.75536 2.6879-1.0559" fill="none" stroke="#000" stroke-linecap="round" stroke-width="2"/>
+ <g transform="matrix(.47016 0 0 .47016 8.4669 -465.76)" stroke="#000">
+ <rect transform="matrix(1 0 -.69517 .71885 0 0)" x="1018.3" y="1446.7" width="18" height="15.133" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1795"/>
+ <rect x="12.5" y="1021.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
+ <rect transform="matrix(1 0 -.70625 .70796 0 0)" x="1031.7" y="1443.4" width="18" height="15.365" fill="#f7941e" fill-opacity=".81569" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
+ <rect x="1.5" y="1032.9" width="18" height="18" ry="0" fill="#f7941e" fill-opacity=".81569" stroke-miterlimit="2"/>
+ <path transform="matrix(0 -1 -.70641 .70781 0 0)" d="m-1070.3-42.826 17.725-0.01751 0.026 14.9-18.077 0.01751z" fill="#f7941e" fill-opacity=".81569" stroke-linejoin="bevel" stroke-miterlimit="0" stroke-width=".5949"/>
+ </g>
+ <path d="m20.844 3.6071c2.0047-0.09094 3.8084-0.12892 5.6292-0.1714l-2.4557 4.9793z"/>
+ <path d="m24.868 5.9491c1.1349 0.30058 2.0533 0.6585 2.6879 1.0559 0.63468 0.39744 0.98564 0.83441 0.98564 1.2931 0 1.8347-5.6154 3.322-12.542 3.322-6.927 0-12.542-1.4873-12.542-3.322 0-0.45868 0.35096-0.89564 0.98564-1.2931 0.63468-0.39744 1.5531-0.75536 2.6879-1.0559" fill="none" stroke="#000" stroke-linecap="round" stroke-width="2"/>
</svg>
diff --git a/silx/resources/gui/icons/cube-top.svg b/silx/resources/gui/icons/cube-top.svg
index 75ec3a4..1bc0e2c 100644
--- a/silx/resources/gui/icons/cube-top.svg
+++ b/silx/resources/gui/icons/cube-top.svg
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(0 -1020.4)" stroke="#000">
-<rect transform="matrix(1 0 -.69517 .71885 0 0)" x="1018.3" y="1446.7" width="18" height="15.133" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1795"/>
-<rect x="12.5" y="1021.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
-<rect transform="matrix(1 0 -.70625 .70796 0 0)" x="1031.7" y="1443.4" width="18" height="15.365" fill="#f7941e" fill-opacity=".81569" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
-<rect x="1.5" y="1032.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
-</g>
+ <g transform="translate(0 -1020.4)" stroke="#000">
+ <rect transform="matrix(1 0 -.69517 .71885 0 0)" x="1018.3" y="1446.7" width="18" height="15.133" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1795"/>
+ <rect x="12.5" y="1021.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
+ <rect transform="matrix(1 0 -.70625 .70796 0 0)" x="1031.7" y="1443.4" width="18" height="15.365" fill="#f7941e" fill-opacity=".81569" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
+ <rect x="1.5" y="1032.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/cube.svg b/silx/resources/gui/icons/cube.svg
index 08e84a3..19e4f9c 100644
--- a/silx/resources/gui/icons/cube.svg
+++ b/silx/resources/gui/icons/cube.svg
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(0 -1020.4)" stroke="#000">
-<rect transform="matrix(1 0 -.69517 .71885 0 0)" x="1018.3" y="1446.7" width="18" height="15.133" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1795"/>
-<rect x="12.5" y="1021.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
-<rect transform="matrix(1 0 -.70625 .70796 0 0)" x="1031.7" y="1443.4" width="18" height="15.365" fill="#f7941e" fill-opacity=".81569" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
-<rect x="1.5" y="1032.9" width="18" height="18" ry="0" fill="#f7941e" fill-opacity=".81569" stroke-miterlimit="2"/>
-<path transform="matrix(0 -1 -.70641 .70781 0 0)" d="m-1070.3-42.826 17.725-0.01751 0.026 14.9-18.077 0.01751z" fill="#f7941e" fill-opacity=".81569" stroke-linejoin="bevel" stroke-miterlimit="0" stroke-width=".5949"/>
-</g>
+ <g transform="translate(0 -1020.4)" stroke="#000">
+ <rect transform="matrix(1 0 -.69517 .71885 0 0)" x="1018.3" y="1446.7" width="18" height="15.133" fill="none" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1795"/>
+ <rect x="12.5" y="1021.9" width="18" height="18" ry="0" fill="none" stroke-miterlimit="2"/>
+ <rect transform="matrix(1 0 -.70625 .70796 0 0)" x="1031.7" y="1443.4" width="18" height="15.365" fill="#f7941e" fill-opacity=".81569" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1885"/>
+ <rect x="1.5" y="1032.9" width="18" height="18" ry="0" fill="#f7941e" fill-opacity=".81569" stroke-miterlimit="2"/>
+ <path transform="matrix(0 -1 -.70641 .70781 0 0)" d="m-1070.3-42.826 17.725-0.01751 0.026 14.9-18.077 0.01751z" fill="#f7941e" fill-opacity=".81569" stroke-linejoin="bevel" stroke-miterlimit="0" stroke-width=".5949"/>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/first.svg b/silx/resources/gui/icons/first.svg
index 8af1df7..bb3b5d8 100644
--- a/silx/resources/gui/icons/first.svg
+++ b/silx/resources/gui/icons/first.svg
@@ -1,15 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<defs>
-<linearGradient id="c" x1="11.913" x2="27.737" y1="10.398" y2="16.471" gradientTransform="matrix(-1 0 0 1 32.506 0)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#002839" offset="0"/>
-<stop stop-color="#00f" stop-opacity=".2585" offset="1"/>
-</linearGradient>
-<linearGradient id="d" x1="11.913" x2="27.737" y1="10.398" y2="16.471" gradientTransform="matrix(-1 0 0 1 32.506 0)" gradientUnits="userSpaceOnUse">
-<stop offset="0"/>
-<stop stop-color="#00f" stop-opacity=".30612" offset="1"/>
-</linearGradient>
-</defs>
-<path d="m25.451 4.9951c-6.6141 3.9114-12.473 7.571-18.396 11.252l18.307 10.806z" fill="url(#c)" stroke="url(#d)" stroke-linejoin="round" stroke-width=".4"/>
-<path d="m6.5357 6.2992-1e-7 9.9456v9.9456" fill="none" stroke="#00006a"/>
+ <defs>
+ <linearGradient id="c" x1="11.913" x2="27.737" y1="10.398" y2="16.471" gradientTransform="matrix(-1 0 0 1 32.506 0)" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#002839" offset="0"/>
+ <stop stop-color="#00f" stop-opacity=".2585" offset="1"/>
+ </linearGradient>
+ <linearGradient id="d" x1="11.913" x2="27.737" y1="10.398" y2="16.471" gradientTransform="matrix(-1 0 0 1 32.506 0)" gradientUnits="userSpaceOnUse">
+ <stop offset="0"/>
+ <stop stop-color="#00f" stop-opacity=".30612" offset="1"/>
+ </linearGradient>
+ </defs>
+ <path d="m25.451 4.9951c-6.6141 3.9114-12.473 7.571-18.396 11.252l18.307 10.806z" fill="url(#c)" stroke="url(#d)" stroke-linejoin="round" stroke-width=".4"/>
+ <path d="m6.5357 6.2992-1e-7 9.9456v9.9456" fill="none" stroke="#00006a"/>
</svg>
diff --git a/silx/resources/gui/icons/image-mask.svg b/silx/resources/gui/icons/image-mask.svg
index b439b13..1309376 100644
--- a/silx/resources/gui/icons/image-mask.svg
+++ b/silx/resources/gui/icons/image-mask.svg
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(-.4361 -.1802)">
-<path d="m7.9557 1.6942c-2.4733 3.0522-4.0586 7.3933-4.0586 12.242 0 9.2281 5.6201 16.73 12.539 16.73s12.539-7.5015 12.539-16.73c0-4.5475-1.354-8.6683-3.5637-11.681-6.001 1.9433-11.808 1.5682-17.455-0.5608z"/>
-<path transform="matrix(1.0559 0 0 1.0559 -2.567 -1.5541)" d="m16.644 11.458a3.8814 2.9492 0 1 1-7.7627 0 3.8814 2.9492 0 1 1 7.7627 0z" fill="#FFF"/>
-<path transform="matrix(1.0559 0 0 1.0559 8.869 -1.5541)" d="m16.644 11.458a3.8814 2.9492 0 1 1-7.7627 0 3.8814 2.9492 0 1 1 7.7627 0z" fill="#FFF"/>
-<path d="m16.563 13.059-2.0993 5.8501c1.2282 0.78863 2.6176 1.0247 4.3666 0.02799z" color="#000000" fill="#FFF"/>
-<path d="m13.111 21.648h7.0723c1.1245 0 2.0297 0.90526 2.0297 2.0297h-11.132c0-1.1245 0.90526-2.0297 2.0297-2.0297z" fill="#FFF" stroke-linecap="round" stroke-linejoin="round"/>
-<path d="m13.111 27.422h7.0723c1.1245 0 2.0297-0.90526 2.0297-2.0297h-11.132c0 1.1245 0.90526 2.0297 2.0297 2.0297z" fill="#FFF" stroke-linecap="round" stroke-linejoin="round"/>
-</g>
+ <g transform="translate(-.4361 -.1802)">
+ <path d="m7.9557 1.6942c-2.4733 3.0522-4.0586 7.3933-4.0586 12.242 0 9.2281 5.6201 16.73 12.539 16.73s12.539-7.5015 12.539-16.73c0-4.5475-1.354-8.6683-3.5637-11.681-6.001 1.9433-11.808 1.5682-17.455-0.5608z"/>
+ <path transform="matrix(1.0559 0 0 1.0559 -2.567 -1.5541)" d="m16.644 11.458a3.8814 2.9492 0 1 1-7.7627 0 3.8814 2.9492 0 1 1 7.7627 0z" fill="#FFF"/>
+ <path transform="matrix(1.0559 0 0 1.0559 8.869 -1.5541)" d="m16.644 11.458a3.8814 2.9492 0 1 1-7.7627 0 3.8814 2.9492 0 1 1 7.7627 0z" fill="#FFF"/>
+ <path d="m16.563 13.059-2.0993 5.8501c1.2282 0.78863 2.6176 1.0247 4.3666 0.02799z" color="#000000" fill="#FFF"/>
+ <path d="m13.111 21.648h7.0723c1.1245 0 2.0297 0.90526 2.0297 2.0297h-11.132c0-1.1245 0.90526-2.0297 2.0297-2.0297z" fill="#FFF" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="m13.111 27.422h7.0723c1.1245 0 2.0297-0.90526 2.0297-2.0297h-11.132c0 1.1245 0.90526 2.0297 2.0297 2.0297z" fill="#FFF" stroke-linecap="round" stroke-linejoin="round"/>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/item-0dim.svg b/silx/resources/gui/icons/item-0dim.svg
index 115c4f9..9a86c3a 100644
--- a/silx/resources/gui/icons/item-0dim.svg
+++ b/silx/resources/gui/icons/item-0dim.svg
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<path d="m20.984 16c0 2.7539-2.2305 4.9844-4.9844 4.9844s-4.9844-2.2305-4.9844-4.9844 2.2305-4.9844 4.9844-4.9844 4.9844 2.2305 4.9844 4.9844z" fill="#0034ff"/>
+ <path d="m20.984 16c0 2.7539-2.2305 4.9844-4.9844 4.9844s-4.9844-2.2305-4.9844-4.9844 2.2305-4.9844 4.9844-4.9844 4.9844 2.2305 4.9844 4.9844z" fill="#0034ff"/>
</svg>
diff --git a/silx/resources/gui/icons/item-1dim.svg b/silx/resources/gui/icons/item-1dim.svg
index 784537e..a422e31 100644
--- a/silx/resources/gui/icons/item-1dim.svg
+++ b/silx/resources/gui/icons/item-1dim.svg
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<path transform="scale(.66667)" d="m4.7051 29.947c1.4414-1.6055 9.2402-12.574 15.721-7.9395 7.8984 5.6426 11.443 23.842 23.449-0.21094" fill="none" stroke="#0034ff" stroke-linecap="round" stroke-width="5"/>
+ <path transform="scale(.66667)" d="m4.7051 29.947c1.4414-1.6055 9.2402-12.574 15.721-7.9395 7.8984 5.6426 11.443 23.842 23.449-0.21094" fill="none" stroke="#0034ff" stroke-linecap="round" stroke-width="5"/>
</svg>
diff --git a/silx/resources/gui/icons/item-2dim.svg b/silx/resources/gui/icons/item-2dim.svg
index ef2bfd5..8e80fd0 100644
--- a/silx/resources/gui/icons/item-2dim.svg
+++ b/silx/resources/gui/icons/item-2dim.svg
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<path d="m6.5703 6.5703h18.859v18.859h-18.859z" fill="#0034ff"/>
+ <path d="m6.5703 6.5703h18.859v18.859h-18.859z" fill="#0034ff"/>
</svg>
diff --git a/silx/resources/gui/icons/item-3dim.svg b/silx/resources/gui/icons/item-3dim.svg
index 54b2f54..2220ee3 100644
--- a/silx/resources/gui/icons/item-3dim.svg
+++ b/silx/resources/gui/icons/item-3dim.svg
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g fill-rule="evenodd">
-<path d="m2.4219 7.9062 13.625 4.543 13.531-4.5117-13.625-4.543z" fill="#00f"/>
-<path d="m16.047 12.449v16.156l13.531-4.5117v-16.156z" fill="#0063ff"/>
-<path d="m2.4219 7.9062 13.625 4.543v16.156l-13.625-4.543z" fill="#0034ff"/>
-</g>
+ <g fill-rule="evenodd">
+ <path d="m2.4219 7.9062 13.625 4.543 13.531-4.5117-13.625-4.543z" fill="#00f"/>
+ <path d="m16.047 12.449v16.156l13.531-4.5117v-16.156z" fill="#0063ff"/>
+ <path d="m2.4219 7.9062 13.625 4.543v16.156l-13.625-4.543z" fill="#0034ff"/>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/item-ndim.svg b/silx/resources/gui/icons/item-ndim.svg
index 40bc244..a00e1b3 100644
--- a/silx/resources/gui/icons/item-ndim.svg
+++ b/silx/resources/gui/icons/item-ndim.svg
@@ -1,26 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g fill-rule="evenodd">
-<path d="m16.031 9.7188 6.8398 2.2812 6.793-2.2656-6.8398-2.2812z" fill="#00f"/>
-<path d="m22.871 12v8.1094l6.793-2.2656v-8.1094z" fill="#0063ff"/>
-<path d="m16.031 9.7188 6.8398 2.2812v8.1094l-6.8398-2.2812z" fill="#0034ff"/>
-<path d="m2.3359 9.7188 6.8398 2.2812 6.793-2.2656-6.8398-2.2812z" fill="#00f"/>
-<path d="m9.1758 12v8.1094l6.793-2.2656v-8.1094z" fill="#0063ff"/>
-<path d="m2.3359 9.7188 6.8398 2.2812v8.1094l-6.8398-2.2812z" fill="#0034ff"/>
-<path d="m9.1406 20.043 6.8398 2.2773 6.793-2.2617-6.8398-2.2812z" fill="#00f"/>
-<path d="m15.98 22.32v8.1133l6.7891-2.2656v-8.1094z" fill="#0063ff"/>
-<path d="m9.1406 20.043 6.8398 2.2773v8.1133l-6.8398-2.2812z" fill="#0034ff"/>
-<path d="m9.1406 11.938 6.8398 2.2773 6.793-2.2617-6.8398-2.2812z" fill="#00f"/>
-<path d="m15.98 14.215v8.1133l6.7891-2.2656v-8.1094z" fill="#0063ff"/>
-<path d="m9.1406 11.938 6.8398 2.2773v8.1133l-6.8398-2.2812z" fill="#0034ff"/>
-<path d="m9.1406 3.832 6.8398 2.2812 6.793-2.2656-6.8398-2.2812z" fill="#00f"/>
-<path d="m15.98 6.1133v8.1094l6.7891-2.2617v-8.1133z" fill="#0063ff"/>
-<path d="m9.1406 3.832 6.8398 2.2812v8.1094l-6.8398-2.2812z" fill="#0034ff"/>
-<path d="m16.031 14.242 6.8398 2.2812 6.793-2.2656-6.8398-2.2773z" fill="#00f"/>
-<path d="m22.871 16.523v8.1094l6.793-2.2617v-8.1094z" fill="#0063ff"/>
-<path d="m16.031 14.242 6.8398 2.2812v8.1094l-6.8398-2.2773z" fill="#0034ff"/>
-<path d="m2.3359 14.242 6.8398 2.2812 6.793-2.2656-6.8398-2.2773z" fill="#00f"/>
-<path d="m9.1758 16.523v8.1094l6.793-2.2617v-8.1094z" fill="#0063ff"/>
-<path d="m2.3359 14.242 6.8398 2.2812v8.1094l-6.8398-2.2773z" fill="#0034ff"/>
-</g>
+ <g fill-rule="evenodd">
+ <path d="m16.031 9.7188 6.8398 2.2812 6.793-2.2656-6.8398-2.2812z" fill="#00f"/>
+ <path d="m22.871 12v8.1094l6.793-2.2656v-8.1094z" fill="#0063ff"/>
+ <path d="m16.031 9.7188 6.8398 2.2812v8.1094l-6.8398-2.2812z" fill="#0034ff"/>
+ <path d="m2.3359 9.7188 6.8398 2.2812 6.793-2.2656-6.8398-2.2812z" fill="#00f"/>
+ <path d="m9.1758 12v8.1094l6.793-2.2656v-8.1094z" fill="#0063ff"/>
+ <path d="m2.3359 9.7188 6.8398 2.2812v8.1094l-6.8398-2.2812z" fill="#0034ff"/>
+ <path d="m9.1406 20.043 6.8398 2.2773 6.793-2.2617-6.8398-2.2812z" fill="#00f"/>
+ <path d="m15.98 22.32v8.1133l6.7891-2.2656v-8.1094z" fill="#0063ff"/>
+ <path d="m9.1406 20.043 6.8398 2.2773v8.1133l-6.8398-2.2812z" fill="#0034ff"/>
+ <path d="m9.1406 11.938 6.8398 2.2773 6.793-2.2617-6.8398-2.2812z" fill="#00f"/>
+ <path d="m15.98 14.215v8.1133l6.7891-2.2656v-8.1094z" fill="#0063ff"/>
+ <path d="m9.1406 11.938 6.8398 2.2773v8.1133l-6.8398-2.2812z" fill="#0034ff"/>
+ <path d="m9.1406 3.832 6.8398 2.2812 6.793-2.2656-6.8398-2.2812z" fill="#00f"/>
+ <path d="m15.98 6.1133v8.1094l6.7891-2.2617v-8.1133z" fill="#0063ff"/>
+ <path d="m9.1406 3.832 6.8398 2.2812v8.1094l-6.8398-2.2812z" fill="#0034ff"/>
+ <path d="m16.031 14.242 6.8398 2.2812 6.793-2.2656-6.8398-2.2773z" fill="#00f"/>
+ <path d="m22.871 16.523v8.1094l6.793-2.2617v-8.1094z" fill="#0063ff"/>
+ <path d="m16.031 14.242 6.8398 2.2812v8.1094l-6.8398-2.2773z" fill="#0034ff"/>
+ <path d="m2.3359 14.242 6.8398 2.2812 6.793-2.2656-6.8398-2.2773z" fill="#00f"/>
+ <path d="m9.1758 16.523v8.1094l6.793-2.2617v-8.1094z" fill="#0063ff"/>
+ <path d="m2.3359 14.242 6.8398 2.2812v8.1094l-6.8398-2.2773z" fill="#0034ff"/>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/item-none.svg b/silx/resources/gui/icons/item-none.svg
index 2590b78..08a5b51 100644
--- a/silx/resources/gui/icons/item-none.svg
+++ b/silx/resources/gui/icons/item-none.svg
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<path d="m22.628 16c0 3.6621-2.9661 6.6282-6.6282 6.6282s-6.6282-2.9661-6.6282-6.6282 2.9661-6.6282 6.6282-6.6282 6.6282 2.9661 6.6282 6.6282z" fill="none" stroke="#0034ff" stroke-width="3"/>
-<path d="m7.7549 24.245 16.49-16.49" fill="none" stroke="#0034ff" stroke-width="3"/>
+ <path d="m22.628 16c0 3.6621-2.9661 6.6282-6.6282 6.6282s-6.6282-2.9661-6.6282-6.6282 2.9661-6.6282 6.6282-6.6282 6.6282 2.9661 6.6282 6.6282z" fill="none" stroke="#0034ff" stroke-width="3"/>
+ <path d="m7.7549 24.245 16.49-16.49" fill="none" stroke="#0034ff" stroke-width="3"/>
</svg>
diff --git a/silx/resources/gui/icons/item-object.svg b/silx/resources/gui/icons/item-object.svg
index 2c4dc15..4f36bbe 100644
--- a/silx/resources/gui/icons/item-object.svg
+++ b/silx/resources/gui/icons/item-object.svg
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g fill="#0034ff">
-<path d="m13.617 8.7812 0.67969-4.0391 4.0039 0.039062 0.47656 3.9609z"/>
-<path d="m9.2266 12.59-2.375-3.3359 2.8555-2.8008 3.1406 2.4609z"/>
-<path d="m8.7852 18.41-4.043-0.67969 0.042968-4 3.957-0.48047z"/>
-<path d="m12.594 22.801-3.3398 2.375-2.8008-2.8555 2.4609-3.1406z"/>
-<path d="m18.383 23.227-0.67969 4.0391-4-0.039063-0.48047-3.9609z"/>
-<path d="m22.773 19.418 2.375 3.3359-2.8555 2.8008-3.1406-2.4609z"/>
-<path d="m23.219 13.598 4.0391 0.67969-0.039062 4-3.9609 0.48047z"/>
-<path d="m19.41 9.207 3.3359-2.375 2.8008 2.8555-2.4609 3.1406z"/>
-<path d="m16 7.9609c-4.4375 0-8.0391 3.6016-8.0391 8.0391s3.6016 8.0391 8.0391 8.0391 8.0391-3.6016 8.0391-8.0391-3.6016-8.0391-8.0391-8.0391zm0 5.168c1.5859 0 2.8711 1.2852 2.8711 2.8711s-1.2852 2.8672-2.8711 2.8672-2.8711-1.2812-2.8711-2.8672 1.2852-2.8711 2.8711-2.8711z"/>
-</g>
+ <g fill="#0034ff">
+ <path d="m13.617 8.7812 0.67969-4.0391 4.0039 0.039062 0.47656 3.9609z"/>
+ <path d="m9.2266 12.59-2.375-3.3359 2.8555-2.8008 3.1406 2.4609z"/>
+ <path d="m8.7852 18.41-4.043-0.67969 0.042968-4 3.957-0.48047z"/>
+ <path d="m12.594 22.801-3.3398 2.375-2.8008-2.8555 2.4609-3.1406z"/>
+ <path d="m18.383 23.227-0.67969 4.0391-4-0.039063-0.48047-3.9609z"/>
+ <path d="m22.773 19.418 2.375 3.3359-2.8555 2.8008-3.1406-2.4609z"/>
+ <path d="m23.219 13.598 4.0391 0.67969-0.039062 4-3.9609 0.48047z"/>
+ <path d="m19.41 9.207 3.3359-2.375 2.8008 2.8555-2.4609 3.1406z"/>
+ <path d="m16 7.9609c-4.4375 0-8.0391 3.6016-8.0391 8.0391s3.6016 8.0391 8.0391 8.0391 8.0391-3.6016 8.0391-8.0391-3.6016-8.0391-8.0391-8.0391zm0 5.168c1.5859 0 2.8711 1.2852 2.8711 2.8711s-1.2852 2.8672-2.8711 2.8672-2.8711-1.2812-2.8711-2.8672 1.2852-2.8711 2.8711-2.8711z"/>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/last.svg b/silx/resources/gui/icons/last.svg
index 4e904d7..df8d7d3 100644
--- a/silx/resources/gui/icons/last.svg
+++ b/silx/resources/gui/icons/last.svg
@@ -1,15 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<defs>
-<linearGradient id="c" x1="11.913" x2="27.737" y1="10.398" y2="16.471" gradientTransform="translate(-.81925)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#002839" offset="0"/>
-<stop stop-color="#00f" stop-opacity=".2585" offset="1"/>
-</linearGradient>
-<linearGradient id="d" x1="11.913" x2="27.737" y1="10.398" y2="16.471" gradientTransform="translate(-.81925)" gradientUnits="userSpaceOnUse">
-<stop offset="0"/>
-<stop stop-color="#00f" stop-opacity=".30612" offset="1"/>
-</linearGradient>
-</defs>
-<path d="m6.2357 4.9951c6.6141 3.9114 12.473 7.571 18.396 11.252l-18.307 10.806z" fill="url(#c)" stroke="url(#d)" stroke-linejoin="round" stroke-width=".4"/>
-<path d="m25.151 6.2992v19.891" fill="none" stroke="#00006a"/>
+ <defs>
+ <linearGradient id="c" x1="11.913" x2="27.737" y1="10.398" y2="16.471" gradientTransform="translate(-.81925)" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#002839" offset="0"/>
+ <stop stop-color="#00f" stop-opacity=".2585" offset="1"/>
+ </linearGradient>
+ <linearGradient id="d" x1="11.913" x2="27.737" y1="10.398" y2="16.471" gradientTransform="translate(-.81925)" gradientUnits="userSpaceOnUse">
+ <stop offset="0"/>
+ <stop stop-color="#00f" stop-opacity=".30612" offset="1"/>
+ </linearGradient>
+ </defs>
+ <path d="m6.2357 4.9951c6.6141 3.9114 12.473 7.571 18.396 11.252l-18.307 10.806z" fill="url(#c)" stroke="url(#d)" stroke-linejoin="round" stroke-width=".4"/>
+ <path d="m25.151 6.2992v19.891" fill="none" stroke="#00006a"/>
</svg>
diff --git a/silx/resources/gui/icons/mask-clear-all.png b/silx/resources/gui/icons/mask-clear-all.png
new file mode 100644
index 0000000..2d6cf55
--- /dev/null
+++ b/silx/resources/gui/icons/mask-clear-all.png
Binary files differ
diff --git a/silx/resources/gui/icons/mask-clear-all.svg b/silx/resources/gui/icons/mask-clear-all.svg
new file mode 100644
index 0000000..7db5055
--- /dev/null
+++ b/silx/resources/gui/icons/mask-clear-all.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg15" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata19"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><defs id="defs5"><filter id="a" x="-.1418" y="-.14627" width="1.2836" height="1.2925" color-interpolation-filters="sRGB"><feGaussianBlur id="feGaussianBlur2" stdDeviation="0.9522046"/></filter></defs><path id="rect826" d="m12.539 13.476h14.988l-7.2601 8.3045h-14.988z" fill="none" stroke="#f7941e" stroke-miterlimit="10" stroke-width="3"/><path id="rect826-6" d="m12.539 18.832h14.988l-7.2601 8.3045h-14.988z" fill="none" stroke="#f7941e" stroke-miterlimit="10" stroke-width="3"/><path id="rect826-7" d="m12.539 8.1198h14.988l-7.2601 8.3045h-14.988z" fill="none" stroke="#f7941e" stroke-miterlimit="10" stroke-width="3"/><g id="g13" transform="translate(-.28743 -.28743)"><path id="path9" transform="matrix(.68044 0 0 .68044 2.0969 3.5975)" d="m26.957 11.637c-0.39375 2e-3 -0.79775 0.17675-1.0938 0.46875l-5.375 5.25-5.4375-5.2188c-0.602-0.58-1.5592-0.583-2.1562 0-0.598 0.584-0.602 1.547 0 2.125l5.4375 5.2187-5.375 5.25c-0.599 0.583-0.605 1.5168 0 2.0938 0.601 0.577 1.5905 0.586 2.1875 0l5.375-5.25 5.4375 5.1875c0.605 0.578 1.5582 0.584 2.1562 0 0.596-0.58 0.598-1.5148 0-2.0938l-5.4375-5.2188 5.375-5.25c0.594-0.584 0.602-1.548 0-2.125-0.301-0.29-0.7-0.4395-1.0938-0.4375z" filter="url(#a)"/><path id="path11" d="m20.137 10.881c-0.26792 0.0014-0.54282 0.099-0.74423 0.29769l-3.6574 3.5723-3.6999-3.5298c-0.40962-0.39466-1.061-0.3967-1.4672 0-0.4069 0.39738-0.40962 1.0314 0 1.4247l3.6999 3.551-3.6574 3.5723c-0.40758 0.3967-0.41167 1.0533 0 1.4459 0.40894 0.39261 1.0822 0.37747 1.4885-0.02126l3.6574-3.5723 3.6999 3.551c0.41166 0.39329 1.0603 0.39738 1.4672 0 0.40554-0.39465 0.4069-1.052 0-1.4459l-3.6999-3.551 3.6574-3.5723c0.40418-0.39738 0.40962-1.0321 0-1.4247-0.20481-0.19733-0.47631-0.29905-0.74423-0.29769z" fill="#f00" stroke="#ff4042" stroke-miterlimit="10" stroke-width=".20413"/></g></svg>
diff --git a/silx/resources/gui/icons/mask-clear.png b/silx/resources/gui/icons/mask-clear.png
new file mode 100644
index 0000000..940b607
--- /dev/null
+++ b/silx/resources/gui/icons/mask-clear.png
Binary files differ
diff --git a/silx/resources/gui/icons/mask-clear.svg b/silx/resources/gui/icons/mask-clear.svg
new file mode 100644
index 0000000..77410c2
--- /dev/null
+++ b/silx/resources/gui/icons/mask-clear.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg15" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata19"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata><defs id="defs5"><filter id="a" x="-.1418" y="-.14627" width="1.2836" height="1.2925" color-interpolation-filters="sRGB"><feGaussianBlur id="feGaussianBlur2" stdDeviation="0.9522046"/></filter></defs><path id="rect826" d="m12.539 13.272h14.988l-7.2601 8.3045h-14.988z" fill="none" stroke="#f7941e" stroke-miterlimit="10" stroke-width="3"/><g id="g13" transform="translate(-.28743 -.28743)"><path id="path9" transform="matrix(.68044 0 0 .68044 2.0969 3.5975)" d="m26.957 11.637c-0.39375 2e-3 -0.79775 0.17675-1.0938 0.46875l-5.375 5.25-5.4375-5.2188c-0.602-0.58-1.5592-0.583-2.1562 0-0.598 0.584-0.602 1.547 0 2.125l5.4375 5.2187-5.375 5.25c-0.599 0.583-0.605 1.5168 0 2.0938 0.601 0.577 1.5905 0.586 2.1875 0l5.375-5.25 5.4375 5.1875c0.605 0.578 1.5582 0.584 2.1562 0 0.596-0.58 0.598-1.5148 0-2.0938l-5.4375-5.2188 5.375-5.25c0.594-0.584 0.602-1.548 0-2.125-0.301-0.29-0.7-0.4395-1.0938-0.4375z" filter="url(#a)"/><path id="path11" d="m20.137 10.881c-0.26792 0.0014-0.54282 0.099-0.74423 0.29769l-3.6574 3.5723-3.6999-3.5298c-0.40962-0.39466-1.061-0.3967-1.4672 0-0.4069 0.39738-0.40962 1.0314 0 1.4247l3.6999 3.551-3.6574 3.5723c-0.40758 0.3967-0.41167 1.0533 0 1.4459 0.40894 0.39261 1.0822 0.37747 1.4885-0.02126l3.6574-3.5723 3.6999 3.551c0.41166 0.39329 1.0603 0.39738 1.4672 0 0.40554-0.39465 0.4069-1.052 0-1.4459l-3.6999-3.551 3.6574-3.5723c0.40418-0.39738 0.40962-1.0321 0-1.4247-0.20481-0.19733-0.47631-0.29905-0.74423-0.29769z" fill="#f00" stroke="#ff4042" stroke-miterlimit="10" stroke-width=".20413"/></g></svg>
diff --git a/silx/resources/gui/icons/mask-invert.png b/silx/resources/gui/icons/mask-invert.png
new file mode 100644
index 0000000..f1cc339
--- /dev/null
+++ b/silx/resources/gui/icons/mask-invert.png
Binary files differ
diff --git a/silx/resources/gui/icons/mask-invert.svg b/silx/resources/gui/icons/mask-invert.svg
new file mode 100644
index 0000000..8fb0c17
--- /dev/null
+++ b/silx/resources/gui/icons/mask-invert.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg15" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata19"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><rect id="rect891" x="3.661" y="3.661" width="24.678" height="24.678" fill="none" stroke="#f7941e" stroke-miterlimit="10" stroke-width="1.5" style="font-variant-east_asian:normal"/><path id="rect826" d="m16 4.2715v2.9414a8.788 8.788 0 0 1 8.7871 8.7871 8.788 8.788 0 0 1-8.7871 8.7871v2.9414h11.729v-23.457h-11.729z" fill="#f7941e"/><path id="path822" d="m16 7.2129a8.788 8.788 0 0 0-8.7871 8.7871 8.788 8.788 0 0 0 8.7871 8.7871v-17.574z" fill="#f7941e"/></svg>
diff --git a/silx/resources/gui/icons/math-peak-search.svg b/silx/resources/gui/icons/math-peak-search.svg
index 0d86ad0..2c19792 100644
--- a/silx/resources/gui/icons/math-peak-search.svg
+++ b/silx/resources/gui/icons/math-peak-search.svg
@@ -1,2 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><filter id="a" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="1.2128746"/></filter></defs><path d="m4.356 26.781c0.66-0.935 1.841-0.809 2.729-1.399 0.703-0.467 0.856-1.623 0.992-2.349 0.218-1.165-0.362-4.839 1.218-5.27 1.004-0.274 1.677-0.422 2.422-1.176 1.721-1.742 1.883-4.988 2.669-7.182 0.504-1.407 1.142-1.524 1.711-0.079 0.35 0.886 0.697 1.771 1.017 2.668 0.689 1.934 1.256 3.931 1.737 5.926 0.45 1.865 0.957 3.707 1.576 5.523 0.279 0.821 0.38 1.479 1.177 1.893 1.154 0.598 1.675-0.925 1.896-1.673 0.278-0.937 0.439-1.908 0.69-2.854 0.455-1.711 0.864 0.714 1.019 1.371 0.442 1.884 0.466 3.932 1.071 5.769 0.181 0.549 1.05 0.314 0.867-0.238-0.398-1.209-0.782-9.396-2.967-8.609-1.242 0.448-1.363 3.699-1.672 4.738-0.364 1.226-1.034-0.032-1.215-0.635-0.366-1.225-0.775-2.429-1.108-3.664-0.629-2.33-1.193-4.659-1.927-6.96-0.276-0.867-1.45-6-3.046-5.583-2.015 0.528-2.388 4.501-2.846 6.112-0.615 2.163-1.571 3.309-3.726 3.896-0.864 0.236-1.143 0.979-1.28 1.771-0.3 1.735 0.738 5.357-1.488 6.215-1.107 0.426-1.578 0.317-2.295 1.332-0.334 0.478 0.447 0.927 0.779 0.457z"/><g transform="translate(1.6271 .13559)" filter="url(#a)"><path d="m2.1425 16.187c-0.417 0.236-1.12 0.115-1.557-0.271-0.442-0.39-0.455-0.906-0.039-1.147l7.33-4.184c0.422-0.242 1.121-0.119 1.56 0.27 0.44 0.392 0.457 0.901 0.035 1.146l-7.329 4.186z" stroke="#00a651" stroke-miterlimit="10" stroke-width=".1"/><path d="m14.176 2.8136c-1.8408-0.22181-3.7106 0.0891-5.25 0.96875-1.5391 0.88172-2.4552 2.2584-2.5625 3.75-0.10727 1.4916 0.57148 3.0357 1.9375 4.25 2.7388 2.4255 7.203 2.9807 10.281 1.2188 1.5391-0.87925 2.4546-2.2587 2.5625-3.75 0.10787-1.4913-0.5729-3.0355-1.9375-4.25-1.3708-1.2142-3.1904-1.9657-5.0312-2.1875zm-0.15625 1.5625c1.5617 0.18769 3.0903 0.77817 4.1875 1.75 1.0904 0.97048 1.5071 2.0373 1.4375 3-0.06963 0.96271-0.62261 1.8827-1.8125 2.5625-2.3797 1.3621-6.3401 0.90923-8.5312-1.0312-1.092-0.9707-1.5067-2.0686-1.4375-3.0312 0.06923-0.96267 0.62157-1.849 1.8125-2.5312 1.1906-0.68035 2.7821-0.90644 4.3437-0.71875z" color="#000000" style="block-progression:tb;text-indent:0;text-transform:none"/><path d="m30.572 31.718c0.247 0.361 0.019 0.865-0.506 1.109-0.531 0.246-1.174 0.141-1.42-0.221l-4.346-6.416c-0.255-0.369-0.025-0.869 0.502-1.111 0.533-0.244 1.163-0.146 1.422 0.227l4.348 6.412z" stroke="#00a651" stroke-miterlimit="10" stroke-width=".1"/><path d="m21.551 15.595c-0.87491 0.08975-1.7393 0.30814-2.5625 0.6875-1.6444 0.76154-2.8268 2.0268-3.3438 3.4688-0.51696 1.4419-0.34202 3.0547 0.59375 4.4375v0.03125c1.8808 2.7617 5.9597 3.6148 9.25 2.0938 1.6461-0.76046 2.8267-2.0267 3.3438-3.4688 0.5171-1.442 0.34525-3.0565-0.59375-4.4375-1.4049-2.0747-4.0628-3.0818-6.6875-2.8125zm0.15625 1.5c2.128-0.19847 4.2576 0.6445 5.2812 2.1562 0.684 1.006 0.7729 2.0713 0.40625 3.0938-0.36665 1.0225-1.2054 1.9812-2.5312 2.5938-2.6497 1.2249-6.0018 0.45384-7.375-1.5625-0.68223-1.0082-0.80429-2.1019-0.4375-3.125 0.36679-1.0231 1.2379-1.9803 2.5625-2.5938 0.66327-0.30564 1.3844-0.49634 2.0938-0.5625z" color="#000000" style="block-progression:tb;text-indent:0;text-transform:none"/></g><g stroke="#00a651" stroke-miterlimit="10"><path d="m3.222 15.385c-0.417 0.236-1.12 0.115-1.557-0.271-0.442-0.39-0.455-0.906-0.039-1.147l7.33-4.184c0.422-0.242 1.121-0.119 1.56 0.27 0.44 0.392 0.457 0.901 0.035 1.146l-7.329 4.186z" fill="#00a651" stroke-width=".1"/><path d="m19.291 11.538c-2.729 1.562-6.936 1.054-9.401-1.129-2.458-2.185-2.241-5.219 0.489-6.783 2.73-1.56 6.936-1.054 9.404 1.132 2.455 2.185 2.237 5.221-0.492 6.78z" fill="none" stroke-width="1.5"/></g><g stroke="#00a651" stroke-miterlimit="10"><path d="m31.651 30.916c0.247 0.361 0.019 0.865-0.506 1.109-0.531 0.246-1.174 0.141-1.42-0.221l-4.346-6.416c-0.255-0.369-0.025-0.869 0.502-1.111 0.533-0.244 1.163-0.146 1.422 0.227l4.348 6.412z" fill="#00a651" stroke-width=".1"/><path d="m28.693 18.014c1.623 2.387 0.53 5.436-2.442 6.809-2.97 1.373-6.686 0.547-8.313-1.842-1.618-2.391-0.526-5.438 2.443-6.813 2.973-1.37 6.693-0.545 8.312 1.846z" fill="none" stroke-width="1.5"/></g></svg>
+<svg version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><filter id="a" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="1.2128746"/></filter></defs><path d="m4.356 26.781c0.66-0.935 1.841-0.809 2.729-1.399 0.703-0.467 0.856-1.623 0.992-2.349 0.218-1.165-0.362-4.839 1.218-5.27 1.004-0.274 1.677-0.422 2.422-1.176 1.721-1.742 1.883-4.988 2.669-7.182 0.504-1.407 1.142-1.524 1.711-0.079 0.35 0.886 0.697 1.771 1.017 2.668 0.689 1.934 1.256 3.931 1.737 5.926 0.45 1.865 0.957 3.707 1.576 5.523 0.279 0.821 0.38 1.479 1.177 1.893 1.154 0.598 1.675-0.925 1.896-1.673 0.278-0.937 0.439-1.908 0.69-2.854 0.455-1.711 0.864 0.714 1.019 1.371 0.442 1.884 0.466 3.932 1.071 5.769 0.181 0.549 1.05 0.314 0.867-0.238-0.398-1.209-0.782-9.396-2.967-8.609-1.242 0.448-1.363 3.699-1.672 4.738-0.364 1.226-1.034-0.032-1.215-0.635-0.366-1.225-0.775-2.429-1.108-3.664-0.629-2.33-1.193-4.659-1.927-6.96-0.276-0.867-1.45-6-3.046-5.583-2.015 0.528-2.388 4.501-2.846 6.112-0.615 2.163-1.571 3.309-3.726 3.896-0.864 0.236-1.143 0.979-1.28 1.771-0.3 1.735 0.738 5.357-1.488 6.215-1.107 0.426-1.578 0.317-2.295 1.332-0.334 0.478 0.447 0.927 0.779 0.457z"/><g transform="translate(1.6271 .13559)" filter="url(#a)"><path d="m2.1425 16.187c-0.417 0.236-1.12 0.115-1.557-0.271-0.442-0.39-0.455-0.906-0.039-1.147l7.33-4.184c0.422-0.242 1.121-0.119 1.56 0.27 0.44 0.392 0.457 0.901 0.035 1.146l-7.329 4.186z" stroke="#00a651" stroke-miterlimit="10" stroke-width=".1"/><path d="m14.176 2.8136c-1.8408-0.22181-3.7106 0.0891-5.25 0.96875-1.5391 0.88172-2.4552 2.2584-2.5625 3.75-0.10727 1.4916 0.57148 3.0357 1.9375 4.25 2.7388 2.4255 7.203 2.9807 10.281 1.2188 1.5391-0.87925 2.4546-2.2587 2.5625-3.75 0.10787-1.4913-0.5729-3.0355-1.9375-4.25-1.3708-1.2142-3.1904-1.9657-5.0312-2.1875zm-0.15625 1.5625c1.5617 0.18769 3.0903 0.77817 4.1875 1.75 1.0904 0.97048 1.5071 2.0373 1.4375 3-0.06963 0.96271-0.62261 1.8827-1.8125 2.5625-2.3797 1.3621-6.3401 0.90923-8.5312-1.0312-1.092-0.9707-1.5067-2.0686-1.4375-3.0312 0.06923-0.96267 0.62157-1.849 1.8125-2.5312 1.1906-0.68035 2.7821-0.90644 4.3437-0.71875z" color="#000000" style="block-progression:tb;text-indent:0;text-transform:none"/><path d="m30.572 31.718c0.247 0.361 0.019 0.865-0.506 1.109-0.531 0.246-1.174 0.141-1.42-0.221l-4.346-6.416c-0.255-0.369-0.025-0.869 0.502-1.111 0.533-0.244 1.163-0.146 1.422 0.227l4.348 6.412z" stroke="#00a651" stroke-miterlimit="10" stroke-width=".1"/><path d="m21.551 15.595c-0.87491 0.08975-1.7393 0.30814-2.5625 0.6875-1.6444 0.76154-2.8268 2.0268-3.3438 3.4688-0.51696 1.4419-0.34202 3.0547 0.59375 4.4375v0.03125c1.8808 2.7617 5.9597 3.6148 9.25 2.0938 1.6461-0.76046 2.8267-2.0267 3.3438-3.4688 0.5171-1.442 0.34525-3.0565-0.59375-4.4375-1.4049-2.0747-4.0628-3.0818-6.6875-2.8125zm0.15625 1.5c2.128-0.19847 4.2576 0.6445 5.2812 2.1562 0.684 1.006 0.7729 2.0713 0.40625 3.0938s-1.2054 1.9812-2.5312 2.5938c-2.6497 1.2249-6.0018 0.45384-7.375-1.5625-0.68223-1.0082-0.80429-2.1019-0.4375-3.125s1.2379-1.9803 2.5625-2.5938c0.66327-0.30564 1.3844-0.49634 2.0938-0.5625z" color="#000000" style="block-progression:tb;text-indent:0;text-transform:none"/></g><g stroke="#00a651" stroke-miterlimit="10"><path d="m3.222 15.385c-0.417 0.236-1.12 0.115-1.557-0.271-0.442-0.39-0.455-0.906-0.039-1.147l7.33-4.184c0.422-0.242 1.121-0.119 1.56 0.27 0.44 0.392 0.457 0.901 0.035 1.146l-7.329 4.186z" fill="#00a651" stroke-width=".1"/><path d="m19.291 11.538c-2.729 1.562-6.936 1.054-9.401-1.129-2.458-2.185-2.241-5.219 0.489-6.783 2.73-1.56 6.936-1.054 9.404 1.132 2.455 2.185 2.237 5.221-0.492 6.78z" fill="none" stroke-width="1.5"/></g><g stroke="#00a651" stroke-miterlimit="10"><path d="m31.651 30.916c0.247 0.361 0.019 0.865-0.506 1.109-0.531 0.246-1.174 0.141-1.42-0.221l-4.346-6.416c-0.255-0.369-0.025-0.869 0.502-1.111 0.533-0.244 1.163-0.146 1.422 0.227l4.348 6.412z" fill="#00a651" stroke-width=".1"/><path d="m28.693 18.014c1.623 2.387 0.53 5.436-2.442 6.809-2.97 1.373-6.686 0.547-8.313-1.842-1.618-2.391-0.526-5.438 2.443-6.813 2.973-1.37 6.693-0.545 8.312 1.846z" fill="none" stroke-width="1.5"/></g></svg>
diff --git a/silx/resources/gui/icons/math-phase.svg b/silx/resources/gui/icons/math-phase.svg
index 44a7160..275eb69 100644
--- a/silx/resources/gui/icons/math-phase.svg
+++ b/silx/resources/gui/icons/math-phase.svg
@@ -1,3 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><radialGradient id="d" cx="-16.701" cy="15.943" r="7.9219" gradientTransform="matrix(1,0,0,1.1509,0,-2.4056)" gradientUnits="userSpaceOnUse"><stop offset="0"/><stop stop-color="#c0c0c0" offset="1"/></radialGradient><radialGradient id="c" cx="-16.701" cy="15.943" r="7.9219" gradientTransform="matrix(1,0,0,1.1509,0,-2.4056)" gradientUnits="userSpaceOnUse" xlink:href="#d"/></defs><g transform="matrix(.062683 0 0 -.062683 15.338 21.436)"><path d="m17.834 8.7344c-0.50782 1.14e-5 -0.76173 0.56642-0.76172 1.6992v8.0391c0.74218 2e-6 1.4844-0.40625 2.2266-1.2188 0.66405-0.72656 0.99608-1.9609 0.99609-3.7031-1.2e-5 -1.625-0.33595-2.875-1.0078-3.75-0.54688-0.71093-1.0313-1.0664-1.4531-1.0664m0-1.8398c1.0937 1.33e-5 2.1367 0.51564 3.1289 1.5469 1.0703 1.1016 1.6055 2.8047 1.6055 5.1094-1.4e-5 2.1172-0.53517 3.8047-1.6055 5.0625-1.0078 1.1875-2.3047 1.7812-3.8906 1.7812v4.7109h-2.1445v-4.6992c-1.5547 0-2.8555-0.59766-3.9023-1.793-1.0625-1.2187-1.5938-2.9023-1.5938-5.0508-1.3e-6 -2.2344 0.53125-3.9219 1.5938-5.0625 0.79687-0.85155 1.8437-1.3867 3.1406-1.6055v1.9102c-0.51563 0.1797-1 0.57423-1.4531 1.1836-0.67188 0.89845-1.0078 2.0899-1.0078 3.5742-3e-6 1.5781 0.33593 2.8164 1.0078 3.7148 0.60156 0.80469 1.3398 1.207 2.2148 1.207v-8.0508c-6e-6 -2.3594 0.96874-3.539 2.9062-3.5391"/><g fill="url(#c)"><path d="m-14.111 6.8256c1.1797 1.32e-5 2.3164 0.50001 3.4102 1.5 1.2812 1.1641 1.9219 2.8945 1.9219 5.1914-1.74e-5 3.8672-1.9453 6.0508-5.8359 6.5508v4.9922h-4.1719v-4.9922c-3.8906-0.5-5.8359-2.6836-5.8359-6.5508-1e-6 -2.1562 0.64062-3.8437 1.9219-5.0625 1.0547-1.0078 2.2383-1.5117 3.5508-1.5117v2.6836c-0.17969 0.015635-0.43751 0.35548-0.77344 1.0195-0.32813 0.65626-0.49219 1.4727-0.49219 2.4492-5e-6 1.8672 0.54296 3.1914 1.6289 3.9727v-4.5938c-8e-6 -3.7656 1.5586-5.6484 4.6758-5.6484m0.12891 3.1172c-0.42189 0.046885-0.63282 0.89063-0.63281 2.5313v4.5938c1.0469-0.75781 1.5898-2.082 1.6289-3.9727 0.02342-1.0469-0.06251-1.8398-0.25781-2.3789-0.19532-0.54686-0.44142-0.80468-0.73828-0.77344" fill="url(#c)"/></g><path id="a" d="m210.56 70.719-200 16 200 16z" opacity=".99"/><use width="32" height="32" fill="#ff0000" stroke="#ff0000" xlink:href="#a"/><use transform="matrix(.98769 .15643 -.15643 .98769 13.696 -.58469)" width="32" height="32" fill="#ff2600" stroke="#ff2600" xlink:href="#a"/><use transform="matrix(.95106 .30902 -.30902 .95106 27.315 .98033)" width="32" height="32" fill="#ff4d00" stroke="#ff4d00" xlink:href="#a"/><use transform="matrix(.89101 .45399 -.45399 .89101 40.521 4.6565)" width="32" height="32" fill="#ff7300" stroke="#ff7300" xlink:href="#a"/><use transform="matrix(.80902 .58779 -.58779 .80902 52.989 10.353)" width="32" height="32" fill="#ff9900" stroke="#ff9900" xlink:href="#a"/><use transform="matrix(.70711 .70711 -.70711 .70711 64.413 17.931)" width="32" height="32" fill="#ffbf00" stroke="#ffbf00" xlink:href="#a"/><use transform="matrix(.58779 .80902 -.80902 .58779 74.511 27.202)" width="32" height="32" fill="#ffe600" stroke="#ffe600" xlink:href="#a"/><use transform="matrix(.45399 .89101 -.89101 .45399 83.034 37.938)" width="32" height="32" fill="#f2ff00" stroke="#f2ff00" xlink:href="#a"/><use transform="matrix(.30902 .95106 -.95106 .30902 89.773 49.876)" width="32" height="32" fill="#ccff00" stroke="#ccff00" xlink:href="#a"/><use transform="matrix(.15643 .98769 -.98769 .15643 94.561 62.72)" width="32" height="32" fill="#a6ff00" stroke="#a6ff00" xlink:href="#a"/><use transform="matrix(0,1,-1,0,97.281,76.156)" width="32" height="32" fill="#80ff00" stroke="#80ff00" xlink:href="#a"/><use transform="matrix(-.15643 .98769 -.98769 -.15643 97.866 89.852)" width="32" height="32" fill="#59ff00" stroke="#59ff00" xlink:href="#a"/><use transform="matrix(-.30902 .95106 -.95106 -.30902 96.301 103.47)" width="32" height="32" fill="#33ff00" stroke="#33ff00" xlink:href="#a"/><use transform="matrix(-.45399 .89101 -.89101 -.45399 92.625 116.68)" width="32" height="32" fill="#0dff00" stroke="#0dff00" xlink:href="#a"/><use transform="matrix(-.58779 .80902 -.80902 -.58779 86.928 129.15)" width="32" height="32" fill="#00ff19" stroke="#00ff19" xlink:href="#a"/><use transform="matrix(-.70711 .70711 -.70711 -.70711 79.351 140.57)" width="32" height="32" fill="#00ff40" stroke="#00ff40" xlink:href="#a"/><use transform="matrix(-.80902 .58779 -.58779 -.80902 70.08 150.67)" width="32" height="32" fill="#00ff66" stroke="#00ff66" xlink:href="#a"/><use transform="matrix(-.89101 .45399 -.45399 -.89101 59.343 159.19)" width="32" height="32" fill="#00ff8c" stroke="#00ff8c" xlink:href="#a"/><use transform="matrix(-.95106 .30902 -.30902 -.95106 47.406 165.93)" width="32" height="32" fill="#00ffb2" stroke="#00ffb2" xlink:href="#a"/><use transform="matrix(-.98769 .15643 -.15643 -.98769 34.561 170.72)" width="32" height="32" fill="#00ffd9" stroke="#00ffd9" xlink:href="#a"/><use transform="matrix(-1,0,0,-1,21.125,173.44)" width="32" height="32" fill="#00ffff" stroke="#00ffff" xlink:href="#a"/><use transform="matrix(-.98769 -.15643 .15643 -.98769 7.4292 174.02)" width="32" height="32" fill="#00d9ff" stroke="#00d9ff" xlink:href="#a"/><use transform="matrix(-.95106 -.30902 .30902 -.95106 -6.1895 172.46)" width="32" height="32" fill="#00b2ff" stroke="#00b2ff" xlink:href="#a"/><use transform="matrix(-.89101 -.45399 .45399 -.89101 -19.396 168.78)" width="32" height="32" fill="#008cff" stroke="#008cff" xlink:href="#a"/><use transform="matrix(-.80902 -.58779 .58779 -.80902 -31.864 163.08)" width="32" height="32" fill="#0066ff" stroke="#0066ff" xlink:href="#a"/><use transform="matrix(-.70711 -.70711 .70711 -.70711 -43.288 155.51)" width="32" height="32" fill="#0040ff" stroke="#0040ff" xlink:href="#a"/><use transform="matrix(-.58779 -.80902 .80902 -.58779 -53.386 146.24)" width="32" height="32" fill="#001aff" stroke="#001aff" xlink:href="#a"/><use transform="matrix(-.45399 -.89101 .89101 -.45399 -61.909 135.5)" width="32" height="32" fill="#0d00ff" stroke="#0d00ff" xlink:href="#a"/><use transform="matrix(-.30902 -.95106 .95106 -.30902 -68.648 123.56)" width="32" height="32" fill="#3300ff" stroke="#3300ff" xlink:href="#a"/><use transform="matrix(-.15643 -.98769 .98769 -.15643 -73.436 110.72)" width="32" height="32" fill="#5900ff" stroke="#5900ff" xlink:href="#a"/><use transform="matrix(0,-1,1,0,-76.156,97.281)" width="32" height="32" fill="#8000ff" stroke="#8000ff" xlink:href="#a"/><use transform="matrix(.15643 -.98769 .98769 .15643 -76.741 83.585)" width="32" height="32" fill="#a600ff" stroke="#a600ff" xlink:href="#a"/><use transform="matrix(.30902 -.95106 .95106 .30902 -75.176 69.967)" width="32" height="32" fill="#cc00ff" stroke="#cc00ff" xlink:href="#a"/><use transform="matrix(.45399 -.89101 .89101 .45399 -71.5 56.761)" width="32" height="32" fill="#f200ff" stroke="#f200ff" xlink:href="#a"/><use transform="matrix(.58779 -.80902 .80902 .58779 -65.803 44.292)" width="32" height="32" fill="#ff00e5" stroke="#ff00e5" xlink:href="#a"/><use transform="matrix(.70711 -.70711 .70711 .70711 -58.226 32.868)" width="32" height="32" fill="#ff00bf" stroke="#ff00bf" xlink:href="#a"/><use transform="matrix(.80902 -.58779 .58779 .80902 -48.955 22.77)" width="32" height="32" fill="#ff0099" stroke="#ff0099" xlink:href="#a"/><use transform="matrix(.89101 -.45399 .45399 .89101 -38.218 14.247)" width="32" height="32" fill="#ff0073" stroke="#ff0073" xlink:href="#a"/><use transform="matrix(.95106 -.30902 .30902 .95106 -26.281 7.5083)" width="32" height="32" fill="#ff004d" stroke="#ff004d" xlink:href="#a"/><use transform="matrix(.98769 -.15643 .15643 .98769 -13.436 2.72)" width="32" height="32" fill="#ff0026" stroke="#ff0026" xlink:href="#a"/></g><text x="0.49070829" y="48.29847" fill="#f7941e" font-family="Accanthis ADF Std" font-size="40px" letter-spacing="0px" word-spacing="0px" style="line-height:125%" xml:space="preserve"><tspan x="0.49070829" y="48.29847" fill="#f7941e" font-family="Sans" font-size="24px"/></text>
+<svg version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><radialGradient id="c" cx="-16.701" cy="15.943" r="7.9219" gradientTransform="matrix(1,0,0,1.1509,0,-2.4056)" gradientUnits="userSpaceOnUse"><stop offset="0"/><stop stop-color="#c0c0c0" offset="1"/></radialGradient></defs><g transform="matrix(.062683 0 0 -.062683 15.338 21.436)"><path d="m17.834 8.7344c-0.50782 1.14e-5 -0.76173 0.56642-0.76172 1.6992v8.0391c0.74218 2e-6 1.4844-0.40625 2.2266-1.2188 0.66405-0.72656 0.99608-1.9609 0.99609-3.7031-1.2e-5 -1.625-0.33595-2.875-1.0078-3.75-0.54688-0.71093-1.0313-1.0664-1.4531-1.0664m0-1.8398c1.0937 1.33e-5 2.1367 0.51564 3.1289 1.5469 1.0703 1.1016 1.6055 2.8047 1.6055 5.1094-1.4e-5 2.1172-0.53517 3.8047-1.6055 5.0625-1.0078 1.1875-2.3047 1.7812-3.8906 1.7812v4.7109h-2.1445v-4.6992c-1.5547 0-2.8555-0.59766-3.9023-1.793-1.0625-1.2187-1.5938-2.9023-1.5938-5.0508-1.3e-6 -2.2344 0.53125-3.9219 1.5938-5.0625 0.79687-0.85155 1.8437-1.3867 3.1406-1.6055v1.9102c-0.51563 0.1797-1 0.57423-1.4531 1.1836-0.67188 0.89845-1.0078 2.0899-1.0078 3.5742-3e-6 1.5781 0.33593 2.8164 1.0078 3.7148 0.60156 0.80469 1.3398 1.207 2.2148 1.207v-8.0508c-6e-6 -2.3594 0.96874-3.539 2.9062-3.5391"/><g fill="url(#c)"><path d="m-14.111 6.8256c1.1797 1.32e-5 2.3164 0.50001 3.4102 1.5 1.2812 1.1641 1.9219 2.8945 1.9219 5.1914-1.74e-5 3.8672-1.9453 6.0508-5.8359 6.5508v4.9922h-4.1719v-4.9922c-3.8906-0.5-5.8359-2.6836-5.8359-6.5508-1e-6 -2.1562 0.64062-3.8437 1.9219-5.0625 1.0547-1.0078 2.2383-1.5117 3.5508-1.5117v2.6836c-0.17969 0.015635-0.43751 0.35548-0.77344 1.0195-0.32813 0.65626-0.49219 1.4727-0.49219 2.4492-5e-6 1.8672 0.54296 3.1914 1.6289 3.9727v-4.5938c-8e-6 -3.7656 1.5586-5.6484 4.6758-5.6484m0.12891 3.1172c-0.42189 0.046885-0.63282 0.89063-0.63281 2.5313v4.5938c1.0469-0.75781 1.5898-2.082 1.6289-3.9727 0.02342-1.0469-0.06251-1.8398-0.25781-2.3789-0.19532-0.54686-0.44142-0.80468-0.73828-0.77344" fill="url(#c)"/></g><path id="a" d="m210.56 70.719-200 16 200 16z" opacity=".99"/><use width="32" height="32" fill="#ff0000" stroke="#ff0000" xlink:href="#a"/><use transform="matrix(.98769 .15643 -.15643 .98769 13.696 -.58469)" width="32" height="32" fill="#ff2600" stroke="#ff2600" xlink:href="#a"/><use transform="matrix(.95106 .30902 -.30902 .95106 27.315 .98033)" width="32" height="32" fill="#ff4d00" stroke="#ff4d00" xlink:href="#a"/><use transform="matrix(.89101 .45399 -.45399 .89101 40.521 4.6565)" width="32" height="32" fill="#ff7300" stroke="#ff7300" xlink:href="#a"/><use transform="matrix(.80902 .58779 -.58779 .80902 52.989 10.353)" width="32" height="32" fill="#ff9900" stroke="#ff9900" xlink:href="#a"/><use transform="matrix(.70711 .70711 -.70711 .70711 64.413 17.931)" width="32" height="32" fill="#ffbf00" stroke="#ffbf00" xlink:href="#a"/><use transform="matrix(.58779 .80902 -.80902 .58779 74.511 27.202)" width="32" height="32" fill="#ffe600" stroke="#ffe600" xlink:href="#a"/><use transform="matrix(.45399 .89101 -.89101 .45399 83.034 37.938)" width="32" height="32" fill="#f2ff00" stroke="#f2ff00" xlink:href="#a"/><use transform="matrix(.30902 .95106 -.95106 .30902 89.773 49.876)" width="32" height="32" fill="#ccff00" stroke="#ccff00" xlink:href="#a"/><use transform="matrix(.15643 .98769 -.98769 .15643 94.561 62.72)" width="32" height="32" fill="#a6ff00" stroke="#a6ff00" xlink:href="#a"/><use transform="matrix(0,1,-1,0,97.281,76.156)" width="32" height="32" fill="#80ff00" stroke="#80ff00" xlink:href="#a"/><use transform="matrix(-.15643 .98769 -.98769 -.15643 97.866 89.852)" width="32" height="32" fill="#59ff00" stroke="#59ff00" xlink:href="#a"/><use transform="matrix(-.30902 .95106 -.95106 -.30902 96.301 103.47)" width="32" height="32" fill="#33ff00" stroke="#33ff00" xlink:href="#a"/><use transform="matrix(-.45399 .89101 -.89101 -.45399 92.625 116.68)" width="32" height="32" fill="#0dff00" stroke="#0dff00" xlink:href="#a"/><use transform="matrix(-.58779 .80902 -.80902 -.58779 86.928 129.15)" width="32" height="32" fill="#00ff19" stroke="#00ff19" xlink:href="#a"/><use transform="matrix(-.70711 .70711 -.70711 -.70711 79.351 140.57)" width="32" height="32" fill="#00ff40" stroke="#00ff40" xlink:href="#a"/><use transform="matrix(-.80902 .58779 -.58779 -.80902 70.08 150.67)" width="32" height="32" fill="#00ff66" stroke="#00ff66" xlink:href="#a"/><use transform="matrix(-.89101 .45399 -.45399 -.89101 59.343 159.19)" width="32" height="32" fill="#00ff8c" stroke="#00ff8c" xlink:href="#a"/><use transform="matrix(-.95106 .30902 -.30902 -.95106 47.406 165.93)" width="32" height="32" fill="#00ffb2" stroke="#00ffb2" xlink:href="#a"/><use transform="matrix(-.98769 .15643 -.15643 -.98769 34.561 170.72)" width="32" height="32" fill="#00ffd9" stroke="#00ffd9" xlink:href="#a"/><use transform="matrix(-1,0,0,-1,21.125,173.44)" width="32" height="32" fill="#00ffff" stroke="#00ffff" xlink:href="#a"/><use transform="matrix(-.98769 -.15643 .15643 -.98769 7.4292 174.02)" width="32" height="32" fill="#00d9ff" stroke="#00d9ff" xlink:href="#a"/><use transform="matrix(-.95106 -.30902 .30902 -.95106 -6.1895 172.46)" width="32" height="32" fill="#00b2ff" stroke="#00b2ff" xlink:href="#a"/><use transform="matrix(-.89101 -.45399 .45399 -.89101 -19.396 168.78)" width="32" height="32" fill="#008cff" stroke="#008cff" xlink:href="#a"/><use transform="matrix(-.80902 -.58779 .58779 -.80902 -31.864 163.08)" width="32" height="32" fill="#0066ff" stroke="#0066ff" xlink:href="#a"/><use transform="matrix(-.70711 -.70711 .70711 -.70711 -43.288 155.51)" width="32" height="32" fill="#0040ff" stroke="#0040ff" xlink:href="#a"/><use transform="matrix(-.58779 -.80902 .80902 -.58779 -53.386 146.24)" width="32" height="32" fill="#001aff" stroke="#001aff" xlink:href="#a"/><use transform="matrix(-.45399 -.89101 .89101 -.45399 -61.909 135.5)" width="32" height="32" fill="#0d00ff" stroke="#0d00ff" xlink:href="#a"/><use transform="matrix(-.30902 -.95106 .95106 -.30902 -68.648 123.56)" width="32" height="32" fill="#3300ff" stroke="#3300ff" xlink:href="#a"/><use transform="matrix(-.15643 -.98769 .98769 -.15643 -73.436 110.72)" width="32" height="32" fill="#5900ff" stroke="#5900ff" xlink:href="#a"/><use transform="matrix(0,-1,1,0,-76.156,97.281)" width="32" height="32" fill="#8000ff" stroke="#8000ff" xlink:href="#a"/><use transform="matrix(.15643 -.98769 .98769 .15643 -76.741 83.585)" width="32" height="32" fill="#a600ff" stroke="#a600ff" xlink:href="#a"/><use transform="matrix(.30902 -.95106 .95106 .30902 -75.176 69.967)" width="32" height="32" fill="#cc00ff" stroke="#cc00ff" xlink:href="#a"/><use transform="matrix(.45399 -.89101 .89101 .45399 -71.5 56.761)" width="32" height="32" fill="#f200ff" stroke="#f200ff" xlink:href="#a"/><use transform="matrix(.58779 -.80902 .80902 .58779 -65.803 44.292)" width="32" height="32" fill="#ff00e5" stroke="#ff00e5" xlink:href="#a"/><use transform="matrix(.70711 -.70711 .70711 .70711 -58.226 32.868)" width="32" height="32" fill="#ff00bf" stroke="#ff00bf" xlink:href="#a"/><use transform="matrix(.80902 -.58779 .58779 .80902 -48.955 22.77)" width="32" height="32" fill="#ff0099" stroke="#ff0099" xlink:href="#a"/><use transform="matrix(.89101 -.45399 .45399 .89101 -38.218 14.247)" width="32" height="32" fill="#ff0073" stroke="#ff0073" xlink:href="#a"/><use transform="matrix(.95106 -.30902 .30902 .95106 -26.281 7.5083)" width="32" height="32" fill="#ff004d" stroke="#ff004d" xlink:href="#a"/><use transform="matrix(.98769 -.15643 .15643 .98769 -13.436 2.72)" width="32" height="32" fill="#ff0026" stroke="#ff0026" xlink:href="#a"/></g><text x="0.49070829" y="48.29847" fill="#f7941e" font-family="Accanthis ADF Std" font-size="40px" letter-spacing="0px" word-spacing="0px" style="line-height:125%" xml:space="preserve"><tspan x="0.49070829" y="48.29847" fill="#f7941e" font-family="Sans" font-size="24px"/></text>
<g transform="translate(8.7329 -5.8799)"><g transform="translate(-17.638 -.085622)" fill="#fff" stroke="#fff" stroke-width="1.5"><path d="m26.739 14.7c-0.50782 1.1e-5 -0.76173 0.56642-0.76172 1.6992v8.0391c0.74218 2e-6 1.4844-0.40625 2.2266-1.2188 0.66405-0.72656 0.99608-1.9609 0.99609-3.7031-1.2e-5 -1.625-0.33595-2.875-1.0078-3.75-0.54689-0.71093-1.0313-1.0664-1.4531-1.0664m0-1.8398c1.0937 1.3e-5 2.1367 0.51564 3.1289 1.5469 1.0703 1.1016 1.6055 2.8047 1.6055 5.1094-1.4e-5 2.1172-0.53517 3.8047-1.6055 5.0625-1.0078 1.1875-2.3047 1.7812-3.8906 1.7812v4.7109h-2.1445v-4.6992c-1.5547-1e-6 -2.8555-0.59766-3.9023-1.793-1.0625-1.2187-1.5938-2.9023-1.5938-5.0508-1e-6 -2.2344 0.53125-3.9219 1.5938-5.0625 0.79687-0.85155 1.8437-1.3867 3.1406-1.6055v1.9102c-0.51563 0.1797-1 0.57423-1.4531 1.1836-0.67188 0.89845-1.0078 2.0899-1.0078 3.5742-4e-6 1.5781 0.33593 2.8164 1.0078 3.7148 0.60156 0.80469 1.3398 1.207 2.2148 1.207v-8.0508c-7e-6 -2.3594 0.96874-3.539 2.9062-3.5391" fill="#fff" stroke="#fff" stroke-width="1.5"/></g><g transform="translate(-17.638 -.085622)"><path d="m26.739 14.7c-0.50782 1.1e-5 -0.76173 0.56642-0.76172 1.6992v8.0391c0.74218 2e-6 1.4844-0.40625 2.2266-1.2188 0.66405-0.72656 0.99608-1.9609 0.99609-3.7031-1.2e-5 -1.625-0.33595-2.875-1.0078-3.75-0.54689-0.71093-1.0313-1.0664-1.4531-1.0664m0-1.8398c1.0937 1.3e-5 2.1367 0.51564 3.1289 1.5469 1.0703 1.1016 1.6055 2.8047 1.6055 5.1094-1.4e-5 2.1172-0.53517 3.8047-1.6055 5.0625-1.0078 1.1875-2.3047 1.7812-3.8906 1.7812v4.7109h-2.1445v-4.6992c-1.5547-1e-6 -2.8555-0.59766-3.9023-1.793-1.0625-1.2187-1.5938-2.9023-1.5938-5.0508-1e-6 -2.2344 0.53125-3.9219 1.5938-5.0625 0.79687-0.85155 1.8437-1.3867 3.1406-1.6055v1.9102c-0.51563 0.1797-1 0.57423-1.4531 1.1836-0.67188 0.89845-1.0078 2.0899-1.0078 3.5742-4e-6 1.5781 0.33593 2.8164 1.0078 3.7148 0.60156 0.80469 1.3398 1.207 2.2148 1.207v-8.0508c-7e-6 -2.3594 0.96874-3.539 2.9062-3.5391"/></g></g></svg>
diff --git a/silx/resources/gui/icons/median-filter.svg b/silx/resources/gui/icons/median-filter.svg
index bb1a972..e908860 100644
--- a/silx/resources/gui/icons/median-filter.svg
+++ b/silx/resources/gui/icons/median-filter.svg
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(0 -1020.4)">
-<g transform="scale(.9969 1.0031)">
-<path d="m16.281 1025.7v2.1874c-0.18067-0.2392-0.37403-0.4188-0.59375-0.5312-0.21729-0.1151-0.4668-0.1562-0.75-0.1562-0.50537 0-0.92529 0.1908-1.25 0.5937-0.32471 0.4004-0.5 0.9302-0.5 1.5625 0 0.6324 0.17529 1.1283 0.5 1.5312 0.32471 0.4005 0.74463 0.625 1.25 0.625 0.28564 0 0.53271-0.075 0.75-0.1874 0.21728-0.1123 0.41308-0.2896 0.59375-0.5313v0.5937h1.3125v-5.6874h-1.3125zm2.5938 0v1.0624h1.3125v-1.0624h-1.3125zm-17.875 0.2187v5.4687h1.3438v-4l1.25 2.9688h0.90625l1.25-2.9688v4h1.3438v-5.4687h-1.8125l-1.25 2.9375-1.25-2.9375h-1.7812zm9.375 1.2813c-0.70801-1e-4 -1.251 0.1792-1.6562 0.5624-0.40283 0.3834-0.625 0.9249-0.625 1.5938-3e-7 0.6812 0.21484 1.2153 0.625 1.5938 0.4126 0.376 0.97656 0.5624 1.7188 0.5624 0.31006 0 0.62744-0.033 0.9375-0.094 0.31006-0.059 0.62744-0.1641 0.9375-0.2813v-1c-0.30518 0.1661-0.61573 0.292-0.90625 0.375-0.28809 0.081-0.56787 0.125-0.84375 0.125-0.3418 0-0.59082-0.096-0.78125-0.25-0.19043-0.1538-0.31201-0.3797-0.34375-0.6874h3.0625v-0.375c-4e-6 -0.6495-0.17432-1.1743-0.5625-1.5626-0.38575-0.3881-0.91797-0.5624-1.5625-0.5624zm12.812 0c-0.28321 0-0.56055 0.025-0.84375 0.062-0.2832 0.034-0.56299 0.057-0.84375 0.125v1c0.20752-0.1123 0.4458-0.1939 0.6875-0.25 0.2417-0.056 0.50049-0.062 0.78125-0.062 0.34668 0 0.58642 0.027 0.75 0.125 0.16357 0.095 0.25 0.2612 0.25 0.4687v0.094h-0.78125c-0.70801 0-1.2305 0.1192-1.5625 0.3438-0.32959 0.2247-0.46875 0.5815-0.46875 1.0625 0 0.3955 0.11133 0.7197 0.375 0.9687 0.26611 0.2466 0.60644 0.375 1.0312 0.375 0.31494 1e-4 0.59277-0.075 0.8125-0.1874 0.21972-0.1147 0.41797-0.2823 0.59375-0.5313v0.5937h1.3125v-2.3437c-4e-6 -0.6568-0.17041-1.1157-0.5-1.4063-0.32715-0.2929-0.85401-0.4374-1.5938-0.4374zm6 0c-0.26856-1e-4 -0.49658 0.041-0.71875 0.1562-0.22217 0.1124-0.42725 0.292-0.625 0.5312v-0.5937h-1.3125v4.0937h1.3125v-2c-2e-6 -0.3734 0.0625-0.6649 0.21875-0.875 0.15625-0.2124 0.38281-0.3437 0.65625-0.3437 0.1123 0 0.19336 0.045 0.28125 0.094 0.08789 0.046 0.16748 0.1025 0.21875 0.1876 0.03906 0.063 0.0791 0.1782 0.09375 0.3124 0.01713 0.1343-4e-6 0.3648 0 0.7188v1.5 0.4062h1.3438v-2.5c-5e-6 -0.5542-0.12842-0.9594-0.375-1.25-0.24414-0.2905-0.62012-0.4374-1.0938-0.4374zm-10.312 0.094v4.0937h1.3125v-4.0937h-1.3125zm-8.5 0.7813c0.23437 0 0.41845 0.065 0.5625 0.2187 0.14648 0.1514 0.21875 0.3447 0.21875 0.5937h-1.6875c0.039061-0.2685 0.12256-0.4521 0.28125-0.5937 0.15869-0.1441 0.37109-0.2187 0.625-0.2187zm5.0312 0.062c0.28076 0 0.50976 0.1075 0.65625 0.3126 0.14892 0.2051 0.21875 0.5156 0.21875 0.9062-3e-6 0.3907-0.06983 0.6699-0.21875 0.875-0.14649 0.2051-0.37549 0.3125-0.65625 0.3125-0.27832 0-0.47608-0.1074-0.625-0.3125-0.14649-0.2051-0.21875-0.4843-0.21875-0.875-2e-6 -0.3906 0.07226-0.7012 0.21875-0.9062 0.14892-0.2052 0.34668-0.3126 0.625-0.3126zm7.875 1.4063h0.6875v0.1563c-3e-6 0.266-0.08155 0.478-0.25 0.6562-0.16846 0.1757-0.37842 0.2812-0.625 0.2812-0.19776 0-0.35645-0.063-0.46875-0.1562-0.10986-0.095-0.15625-0.209-0.15625-0.375-2e-6 -0.1807 0.05078-0.3132 0.1875-0.4062 0.13916-0.093 0.35156-0.1563 0.625-0.1563zm-17.219 5.5313v1.0624h1.3125v-1.0624h-1.3125zm2.5938 0v5.6874h1.3125v-5.6874h-1.3125zm-7.6562 0.2188v5.4687h1.4062v-2.3124h2.25v-1.0626h-2.25v-1.0312h2.4062v-1.0625h-3.8125zm10.344 0.2187v1.1563h-0.65625v0.9375h0.65625v1.75c-1e-6 0.5176 0.09766 0.8764 0.3125 1.0938 0.21728 0.2148 0.60742 0.3124 1.125 0.3124h1.125v-0.9374h-0.6875c-0.22461 0-0.36182-0.028-0.4375-0.094-0.07569-0.068-0.125-0.1846-0.125-0.375v-1.75h1.3438v-0.9375h-1.3438v-1.1563h-1.3125zm5.4375 1.0626c-0.70801 0-1.251 0.1792-1.6562 0.5624-0.40283 0.3833-0.625 0.9249-0.625 1.5938 0 0.6812 0.21484 1.2153 0.625 1.5938 0.4126 0.376 0.97656 0.5624 1.7188 0.5624 0.31006 0 0.62744-0.033 0.9375-0.094 0.31006-0.059 0.62744-0.1641 0.9375-0.2813v-1c-0.30518 0.1661-0.61573 0.292-0.90625 0.375-0.28809 0.081-0.56787 0.125-0.84375 0.125-0.3418 0-0.59082-0.096-0.78125-0.25-0.19043-0.1538-0.31201-0.3797-0.34375-0.6874h3.0625v-0.375c-5e-6 -0.6494-0.20557-1.1743-0.59375-1.5626-0.38575-0.3882-0.88672-0.5624-1.5312-0.5624zm5.7812 0c-0.30518 0-0.56153 0.063-0.78125 0.1874-0.21729 0.1222-0.39404 0.3253-0.5625 0.5938v-0.6875h-1.3125v4.0937h1.3125v-1.875c-2e-6 -0.4027 0.06933-0.7227 0.25-0.9374 0.1831-0.2174 0.44433-0.3126 0.78125-0.3126 0.11474 0 0.23144 0 0.34375 0.031 0.11474 0.024 0.229 0.071 0.34375 0.125v-1.1875c-0.09766-0.012-0.16748-0.031-0.21875-0.031-0.05127-0.01-0.11231 0-0.15625 0zm-16.5 0.094v4.0937h1.3125v-4.0937h-1.3125zm10.719 0.7813c0.23437 0 0.41845 0.065 0.5625 0.2187 0.14648 0.1514 0.21875 0.3447 0.21875 0.5937h-1.7188c0.03906-0.2685 0.15381-0.4521 0.3125-0.5937 0.15869-0.1441 0.37109-0.2187 0.625-0.2187z"/>
-</g>
-</g>
+ <g transform="translate(0 -1020.4)">
+ <g transform="scale(.9969 1.0031)">
+ <path d="m16.281 1025.7v2.1874c-0.18067-0.2392-0.37403-0.4188-0.59375-0.5312-0.21729-0.1151-0.4668-0.1562-0.75-0.1562-0.50537 0-0.92529 0.1908-1.25 0.5937-0.32471 0.4004-0.5 0.9302-0.5 1.5625 0 0.6324 0.17529 1.1283 0.5 1.5312 0.32471 0.4005 0.74463 0.625 1.25 0.625 0.28564 0 0.53271-0.075 0.75-0.1874 0.21728-0.1123 0.41308-0.2896 0.59375-0.5313v0.5937h1.3125v-5.6874h-1.3125zm2.5938 0v1.0624h1.3125v-1.0624h-1.3125zm-17.875 0.2187v5.4687h1.3438v-4l1.25 2.9688h0.90625l1.25-2.9688v4h1.3438v-5.4687h-1.8125l-1.25 2.9375-1.25-2.9375h-1.7812zm9.375 1.2813c-0.70801-1e-4 -1.251 0.1792-1.6562 0.5624-0.40283 0.3834-0.625 0.9249-0.625 1.5938-3e-7 0.6812 0.21484 1.2153 0.625 1.5938 0.4126 0.376 0.97656 0.5624 1.7188 0.5624 0.31006 0 0.62744-0.033 0.9375-0.094 0.31006-0.059 0.62744-0.1641 0.9375-0.2813v-1c-0.30518 0.1661-0.61573 0.292-0.90625 0.375-0.28809 0.081-0.56787 0.125-0.84375 0.125-0.3418 0-0.59082-0.096-0.78125-0.25-0.19043-0.1538-0.31201-0.3797-0.34375-0.6874h3.0625v-0.375c-4e-6 -0.6495-0.17432-1.1743-0.5625-1.5626-0.38575-0.3881-0.91797-0.5624-1.5625-0.5624zm12.812 0c-0.28321 0-0.56055 0.025-0.84375 0.062-0.2832 0.034-0.56299 0.057-0.84375 0.125v1c0.20752-0.1123 0.4458-0.1939 0.6875-0.25 0.2417-0.056 0.50049-0.062 0.78125-0.062 0.34668 0 0.58642 0.027 0.75 0.125 0.16357 0.095 0.25 0.2612 0.25 0.4687v0.094h-0.78125c-0.70801 0-1.2305 0.1192-1.5625 0.3438-0.32959 0.2247-0.46875 0.5815-0.46875 1.0625 0 0.3955 0.11133 0.7197 0.375 0.9687 0.26611 0.2466 0.60644 0.375 1.0312 0.375 0.31494 1e-4 0.59277-0.075 0.8125-0.1874 0.21972-0.1147 0.41797-0.2823 0.59375-0.5313v0.5937h1.3125v-2.3437c-4e-6 -0.6568-0.17041-1.1157-0.5-1.4063-0.32715-0.2929-0.85401-0.4374-1.5938-0.4374zm6 0c-0.26856-1e-4 -0.49658 0.041-0.71875 0.1562-0.22217 0.1124-0.42725 0.292-0.625 0.5312v-0.5937h-1.3125v4.0937h1.3125v-2c-2e-6 -0.3734 0.0625-0.6649 0.21875-0.875 0.15625-0.2124 0.38281-0.3437 0.65625-0.3437 0.1123 0 0.19336 0.045 0.28125 0.094 0.08789 0.046 0.16748 0.1025 0.21875 0.1876 0.03906 0.063 0.0791 0.1782 0.09375 0.3124 0.01713 0.1343-4e-6 0.3648 0 0.7188v1.9062h1.3438v-2.5c-5e-6 -0.5542-0.12842-0.9594-0.375-1.25-0.24414-0.2905-0.62012-0.4374-1.0938-0.4374zm-10.312 0.094v4.0937h1.3125v-4.0937h-1.3125zm-8.5 0.7813c0.23437 0 0.41845 0.065 0.5625 0.2187 0.14648 0.1514 0.21875 0.3447 0.21875 0.5937h-1.6875c0.039061-0.2685 0.12256-0.4521 0.28125-0.5937 0.15869-0.1441 0.37109-0.2187 0.625-0.2187zm5.0312 0.062c0.28076 0 0.50976 0.1075 0.65625 0.3126 0.14892 0.2051 0.21875 0.5156 0.21875 0.9062-3e-6 0.3907-0.06983 0.6699-0.21875 0.875-0.14649 0.2051-0.37549 0.3125-0.65625 0.3125-0.27832 0-0.47608-0.1074-0.625-0.3125-0.14649-0.2051-0.21875-0.4843-0.21875-0.875-2e-6 -0.3906 0.07226-0.7012 0.21875-0.9062 0.14892-0.2052 0.34668-0.3126 0.625-0.3126zm7.875 1.4063h0.6875v0.1563c-3e-6 0.266-0.08155 0.478-0.25 0.6562-0.16846 0.1757-0.37842 0.2812-0.625 0.2812-0.19776 0-0.35645-0.063-0.46875-0.1562-0.10986-0.095-0.15625-0.209-0.15625-0.375-2e-6 -0.1807 0.05078-0.3132 0.1875-0.4062 0.13916-0.093 0.35156-0.1563 0.625-0.1563zm-17.219 5.5313v1.0624h1.3125v-1.0624h-1.3125zm2.5938 0v5.6874h1.3125v-5.6874h-1.3125zm-7.6562 0.2188v5.4687h1.4062v-2.3124h2.25v-1.0626h-2.25v-1.0312h2.4062v-1.0625h-3.8125zm10.344 0.2187v1.1563h-0.65625v0.9375h0.65625v1.75c-1e-6 0.5176 0.09766 0.8764 0.3125 1.0938 0.21728 0.2148 0.60742 0.3124 1.125 0.3124h1.125v-0.9374h-0.6875c-0.22461 0-0.36182-0.028-0.4375-0.094-0.07569-0.068-0.125-0.1846-0.125-0.375v-1.75h1.3438v-0.9375h-1.3438v-1.1563h-1.3125zm5.4375 1.0626c-0.70801 0-1.251 0.1792-1.6562 0.5624-0.40283 0.3833-0.625 0.9249-0.625 1.5938 0 0.6812 0.21484 1.2153 0.625 1.5938 0.4126 0.376 0.97656 0.5624 1.7188 0.5624 0.31006 0 0.62744-0.033 0.9375-0.094 0.31006-0.059 0.62744-0.1641 0.9375-0.2813v-1c-0.30518 0.1661-0.61573 0.292-0.90625 0.375-0.28809 0.081-0.56787 0.125-0.84375 0.125-0.3418 0-0.59082-0.096-0.78125-0.25-0.19043-0.1538-0.31201-0.3797-0.34375-0.6874h3.0625v-0.375c-5e-6 -0.6494-0.20557-1.1743-0.59375-1.5626-0.38575-0.3882-0.88672-0.5624-1.5312-0.5624zm5.7812 0c-0.30518 0-0.56153 0.063-0.78125 0.1874-0.21729 0.1222-0.39404 0.3253-0.5625 0.5938v-0.6875h-1.3125v4.0937h1.3125v-1.875c-2e-6 -0.4027 0.06933-0.7227 0.25-0.9374 0.1831-0.2174 0.44433-0.3126 0.78125-0.3126 0.11474 0 0.23144 0 0.34375 0.031 0.11474 0.024 0.229 0.071 0.34375 0.125v-1.1875c-0.09766-0.012-0.16748-0.031-0.21875-0.031-0.05127-0.01-0.11231 0-0.15625 0zm-16.5 0.094v4.0937h1.3125v-4.0937h-1.3125zm10.719 0.7813c0.23437 0 0.41845 0.065 0.5625 0.2187 0.14648 0.1514 0.21875 0.3447 0.21875 0.5937h-1.7188c0.03906-0.2685 0.15381-0.4521 0.3125-0.5937 0.15869-0.1441 0.37109-0.2187 0.625-0.2187z"/>
+ </g>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/next.svg b/silx/resources/gui/icons/next.svg
index eb455d1..a906fc3 100644
--- a/silx/resources/gui/icons/next.svg
+++ b/silx/resources/gui/icons/next.svg
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<defs>
-<linearGradient id="c" x1="11.913" x2="27.737" y1="10.398" y2="16.471" gradientUnits="userSpaceOnUse">
-<stop stop-color="#002839" offset="0"/>
-<stop stop-color="#00f" stop-opacity=".2585" offset="1"/>
-</linearGradient>
-<linearGradient id="d" x1="11.913" x2="27.737" y1="10.398" y2="16.471" gradientUnits="userSpaceOnUse">
-<stop offset="0"/>
-<stop stop-color="#00f" stop-opacity=".30612" offset="1"/>
-</linearGradient>
-</defs>
-<path d="m7.055 4.9951c6.6141 3.9114 12.473 7.571 18.396 11.252l-18.307 10.806z" fill="url(#c)" stroke="url(#d)" stroke-linejoin="round" stroke-width=".4"/>
+ <defs>
+ <linearGradient id="c" x1="11.913" x2="27.737" y1="10.398" y2="16.471" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#002839" offset="0"/>
+ <stop stop-color="#00f" stop-opacity=".2585" offset="1"/>
+ </linearGradient>
+ <linearGradient id="d" x1="11.913" x2="27.737" y1="10.398" y2="16.471" gradientUnits="userSpaceOnUse">
+ <stop offset="0"/>
+ <stop stop-color="#00f" stop-opacity=".30612" offset="1"/>
+ </linearGradient>
+ </defs>
+ <path d="m7.055 4.9951c6.6141 3.9114 12.473 7.571 18.396 11.252l-18.307 10.806z" fill="url(#c)" stroke="url(#d)" stroke-linejoin="round" stroke-width=".4"/>
</svg>
diff --git a/silx/resources/gui/icons/normal.svg b/silx/resources/gui/icons/normal.svg
index 7a3ca5e..306f67d 100644
--- a/silx/resources/gui/icons/normal.svg
+++ b/silx/resources/gui/icons/normal.svg
@@ -1,2 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><filter id="a" x="-.18999" y="-.11594" width="1.38" height="1.2319" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="1.239375"/></filter></defs><path d="m8.9854 3.2659v21.469c1.5625-1.5625 3.125-3.125 4.6875-4.6875 1.2861 2.9607 2.596 5.9112 3.875 8.875 1.6799-0.58623 3.0577-1.1237 4.5625-1.6875-1.3552-2.9246-2.7857-5.8158-4.1875-8.7188h6.7188c-5.3704-5.3112-11.062-10.667-15.656-15.25z" color="#000000" filter="url(#a)" style="block-progression:tb;text-indent:0;text-transform:none"/><path d="m9.842 4.1474v19.084l4.3693-4.3693 3.9838 9.1241 3.5982-1.3493-4.305-8.9314h6.2969z" stroke="#fff" stroke-width=".8"/></svg>
+<svg version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><filter id="a" x="-.18999" y="-.11594" width="1.38" height="1.2319" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="1.239375"/></filter></defs><path d="m8.9854 3.2659v21.469l4.6875-4.6875c1.2861 2.9607 2.596 5.9112 3.875 8.875 1.6799-0.58623 3.0577-1.1237 4.5625-1.6875-1.3552-2.9246-2.7857-5.8158-4.1875-8.7188h6.7188c-5.3704-5.3112-11.062-10.667-15.656-15.25z" color="#000000" filter="url(#a)" style="block-progression:tb;text-indent:0;text-transform:none"/><path d="m9.842 4.1474v19.084l4.3693-4.3693 3.9838 9.1241 3.5982-1.3493-4.305-8.9314h6.2969z" stroke="#fff" stroke-width=".8"/></svg>
diff --git a/silx/resources/gui/icons/pan.svg b/silx/resources/gui/icons/pan.svg
index e21ca50..7425124 100644
--- a/silx/resources/gui/icons/pan.svg
+++ b/silx/resources/gui/icons/pan.svg
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<rect x="7.6949" y="15.085" width="16.61" height="1.8305" ry=".020888" color="#000000"/>
-<path d="m22.598 12.53c2.0808 1.2305 3.924 2.3818 5.7873 3.5398l-5.7593 3.3995z"/>
-<path d="m9.4021 12.53c-2.0808 1.2305-3.924 2.3818-5.7873 3.5398l5.7593 3.3995z"/>
-<rect transform="rotate(90)" x="7.6949" y="-16.915" width="16.61" height="1.8305" ry=".020888" color="#000000"/>
-<path d="m19.47 22.598c-1.2305 2.0808-2.3818 3.924-3.5398 5.7873l-3.3995-5.7593z"/>
-<path d="m19.47 9.4021c-1.2305-2.0808-2.3818-3.924-3.5398-5.7873l-3.3995 5.7593z"/>
+ <rect x="7.6949" y="15.085" width="16.61" height="1.8305" ry=".020888" color="#000000"/>
+ <path d="m22.598 12.53c2.0808 1.2305 3.924 2.3818 5.7873 3.5398l-5.7593 3.3995z"/>
+ <path d="m9.4021 12.53c-2.0808 1.2305-3.924 2.3818-5.7873 3.5398l5.7593 3.3995z"/>
+ <rect transform="rotate(90)" x="7.6949" y="-16.915" width="16.61" height="1.8305" ry=".020888" color="#000000"/>
+ <path d="m19.47 22.598c-1.2305 2.0808-2.3818 3.924-3.5398 5.7873l-3.3995-5.7593z"/>
+ <path d="m19.47 9.4021c-1.2305-2.0808-2.3818-3.924-3.5398-5.7873l-3.3995 5.7593z"/>
</svg>
diff --git a/silx/resources/gui/icons/pixel-intensities.svg b/silx/resources/gui/icons/pixel-intensities.svg
index 02e4674..bfed7cf 100644
--- a/silx/resources/gui/icons/pixel-intensities.svg
+++ b/silx/resources/gui/icons/pixel-intensities.svg
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<rect x="2.6311" y="19.92" width="5.4569" height="9.7621" color="#000000" fill="#66aad7" stroke="#000" stroke-width=".8"/>
-<rect x="23.776" y="22.768" width="5.4569" height="6.9146" color="#000000" fill="#66aad7" stroke="#000" stroke-width=".8"/>
-<rect x="7.9174" y="14.903" width="5.4569" height="14.779" color="#000000" fill="#66aad7" stroke="#000" stroke-width=".8"/>
-<rect x="18.49" y="11.107" width="5.4569" height="18.576" color="#000000" fill="#66aad7" stroke="#000" stroke-width=".8"/>
-<rect x="13.204" y="5.6831" width="5.4569" height="23.999" color="#000000" fill="#66aad7" stroke="#000" stroke-width=".8"/>
+ <rect x="2.6311" y="19.92" width="5.4569" height="9.7621" color="#000000" fill="#66aad7" stroke="#000" stroke-width=".8"/>
+ <rect x="23.776" y="22.768" width="5.4569" height="6.9146" color="#000000" fill="#66aad7" stroke="#000" stroke-width=".8"/>
+ <rect x="7.9174" y="14.903" width="5.4569" height="14.779" color="#000000" fill="#66aad7" stroke="#000" stroke-width=".8"/>
+ <rect x="18.49" y="11.107" width="5.4569" height="18.576" color="#000000" fill="#66aad7" stroke="#000" stroke-width=".8"/>
+ <rect x="13.204" y="5.6831" width="5.4569" height="23.999" color="#000000" fill="#66aad7" stroke="#000" stroke-width=".8"/>
</svg>
diff --git a/silx/resources/gui/icons/previous.svg b/silx/resources/gui/icons/previous.svg
index 6b11053..0f6bcad 100644
--- a/silx/resources/gui/icons/previous.svg
+++ b/silx/resources/gui/icons/previous.svg
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<defs>
-<linearGradient id="c" x1="11.913" x2="27.737" y1="10.398" y2="16.471" gradientTransform="matrix(-1 0 0 1 32.506 0)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#002839" offset="0"/>
-<stop stop-color="#00f" stop-opacity=".2585" offset="1"/>
-</linearGradient>
-<linearGradient id="d" x1="11.913" x2="27.737" y1="10.398" y2="16.471" gradientTransform="matrix(-1 0 0 1 32.506 0)" gradientUnits="userSpaceOnUse">
-<stop offset="0"/>
-<stop stop-color="#00f" stop-opacity=".30612" offset="1"/>
-</linearGradient>
-</defs>
-<path d="m25.451 4.9951c-6.6141 3.9114-12.473 7.571-18.396 11.252l18.307 10.806z" fill="url(#c)" stroke="url(#d)" stroke-linejoin="round" stroke-width=".4"/>
+ <defs>
+ <linearGradient id="c" x1="11.913" x2="27.737" y1="10.398" y2="16.471" gradientTransform="matrix(-1 0 0 1 32.506 0)" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#002839" offset="0"/>
+ <stop stop-color="#00f" stop-opacity=".2585" offset="1"/>
+ </linearGradient>
+ <linearGradient id="d" x1="11.913" x2="27.737" y1="10.398" y2="16.471" gradientTransform="matrix(-1 0 0 1 32.506 0)" gradientUnits="userSpaceOnUse">
+ <stop offset="0"/>
+ <stop stop-color="#00f" stop-opacity=".30612" offset="1"/>
+ </linearGradient>
+ </defs>
+ <path d="m25.451 4.9951c-6.6141 3.9114-12.473 7.571-18.396 11.252l18.307 10.806z" fill="url(#c)" stroke="url(#d)" stroke-linejoin="round" stroke-width=".4"/>
</svg>
diff --git a/silx/resources/gui/icons/profile1D.svg b/silx/resources/gui/icons/profile1D.svg
index 67e932a..c332345 100644
--- a/silx/resources/gui/icons/profile1D.svg
+++ b/silx/resources/gui/icons/profile1D.svg
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(-35.201 -492.37)">
-<flowRoot fill="#000000" font-family="Sans" font-size="40px" letter-spacing="0px" word-spacing="0px" style="line-height:125%" xml:space="preserve"><flowRegion><rect x="299.51" y="378.09" width="73.741" height="62.629"/></flowRegion><flowPara/></flowRoot>
-<rect x="38.265" y="494.84" width="27.563" height="26.906" ry="0" color="#000000" fill="#fff" stroke="#000" stroke-miterlimit="2" stroke-width="1.0776"/>
-<path d="m64.793 513.73-25.771-0.0252" fill="none" stroke="#f7941e" stroke-linecap="round" stroke-miterlimit="0" stroke-opacity=".81569" stroke-width="1.3908"/>
-<rect x="38.145" y="494.84" width="27.563" height="26.906" ry="0" color="#000000" fill="none" stroke="#000" stroke-miterlimit="2" stroke-width="1.0776"/>
-</g>
+ <g transform="translate(-35.201 -492.37)">
+ <flowRoot fill="#000000" font-family="Sans" font-size="40px" letter-spacing="0px" word-spacing="0px" style="line-height:125%" xml:space="preserve"><flowRegion><rect x="299.51" y="378.09" width="73.741" height="62.629"/></flowRegion><flowPara/></flowRoot>
+ <rect x="38.265" y="494.84" width="27.563" height="26.906" ry="0" color="#000000" fill="#fff" stroke="#000" stroke-miterlimit="2" stroke-width="1.0776"/>
+ <path d="m64.793 513.73-25.771-0.0252" fill="none" stroke="#f7941e" stroke-linecap="round" stroke-miterlimit="0" stroke-opacity=".81569" stroke-width="1.3908"/>
+ <rect x="38.145" y="494.84" width="27.563" height="26.906" ry="0" color="#000000" fill="none" stroke="#000" stroke-miterlimit="2" stroke-width="1.0776"/>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/profile2D.svg b/silx/resources/gui/icons/profile2D.svg
index 51e8ece..e682b3c 100644
--- a/silx/resources/gui/icons/profile2D.svg
+++ b/silx/resources/gui/icons/profile2D.svg
@@ -1,12 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(-35.201 -492.37)">
-<path d="m36.808 505.45v17.66h17.686v-0.0531h0.58775l10.366-10.692-0.05335-0.0265-0.05335-17.633h-0.26716l0.05335-0.0531h-17.739l8.7e-5 -1.7e-4 -10.633 10.692z" fill="#fff"/>
-<rect transform="matrix(1 0 -.69678 .71728 0 0)" x="545.71" y="714.56" width="17.72" height="14.863" fill="#fff" stroke="#000" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1598"/>
-<rect x="47.726" y="494.85" width="17.72" height="17.64" ry="0" fill="#fff" stroke="#000" stroke-miterlimit="2" stroke-width=".98223"/>
-<rect transform="matrix(1 0 -.70784 .70637 0 0)" x="543.37" y="700.5" width="17.72" height="15.092" fill="none" stroke="#000" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1687"/>
-<rect transform="matrix(1 0 -.69678 .71728 0 0)" x="540.83" y="707.56" width="17.72" height="14.863" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1598"/>
-<rect x="36.897" y="505.63" width="17.72" height="17.64" ry="0" fill="none" stroke="#000" stroke-miterlimit="2" stroke-width=".98223"/>
-<flowRoot fill="#000000" font-family="Sans" font-size="40px" letter-spacing="0px" word-spacing="0px" style="line-height:125%" xml:space="preserve"><flowRegion><rect x="299.51" y="378.09" width="73.741" height="62.629"/></flowRegion><flowPara/></flowRoot>
-</g>
+ <g transform="translate(-35.201 -492.37)">
+ <path d="m36.808 505.45v17.66h17.686v-0.0531h0.58775l10.366-10.692-0.05335-0.0265-0.05335-17.633h-0.26716l0.05335-0.0531h-17.739l8.7e-5 -1.7e-4 -10.633 10.692z" fill="#fff"/>
+ <rect transform="matrix(1 0 -.69678 .71728 0 0)" x="545.71" y="714.56" width="17.72" height="14.863" fill="#fff" stroke="#000" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1598"/>
+ <rect x="47.726" y="494.85" width="17.72" height="17.64" ry="0" fill="#fff" stroke="#000" stroke-miterlimit="2" stroke-width=".98223"/>
+ <rect transform="matrix(1 0 -.70784 .70637 0 0)" x="543.37" y="700.5" width="17.72" height="15.092" fill="none" stroke="#000" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1687"/>
+ <rect transform="matrix(1 0 -.69678 .71728 0 0)" x="540.83" y="707.56" width="17.72" height="14.863" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.1598"/>
+ <rect x="36.897" y="505.63" width="17.72" height="17.64" ry="0" fill="none" stroke="#000" stroke-miterlimit="2" stroke-width=".98223"/>
+ <flowRoot fill="#000000" font-family="Sans" font-size="40px" letter-spacing="0px" word-spacing="0px" style="line-height:125%" xml:space="preserve"><flowRegion><rect x="299.51" y="378.09" width="73.741" height="62.629"/></flowRegion><flowPara/></flowRoot>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/rotate-3d.svg b/silx/resources/gui/icons/rotate-3d.svg
index 7ae0e34..32a4327 100644
--- a/silx/resources/gui/icons/rotate-3d.svg
+++ b/silx/resources/gui/icons/rotate-3d.svg
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<path d="m18.125 24.869c-0.30058 1.1349-0.6585 2.0533-1.0559 2.6879-0.39744 0.63468-0.83441 0.98564-1.2931 0.98564-1.8347 0-3.322-5.6154-3.322-12.542s1.4873-12.542 3.322-12.542c0.45868 0 0.89564 0.35096 1.2931 0.98564 0.39744 0.63468 0.75536 1.5531 1.0559 2.6879" fill="none" stroke="#000" stroke-linecap="round" stroke-width="2"/>
-<path d="m20.337 11.025c2.0047-0.09094 3.8084-0.12892 5.6292-0.1714l-2.4557 4.9793z"/>
-<path d="m24.361 13.367c1.1349 0.30058 2.0533 0.6585 2.6879 1.0559 0.63468 0.39744 0.98564 0.83441 0.98564 1.2931 0 1.8347-5.6154 3.322-12.542 3.322-6.927 0-12.542-1.4873-12.542-3.322 0-0.45868 0.35096-0.89564 0.98564-1.2931 0.63468-0.39744 1.5531-0.75536 2.6879-1.0559" fill="none" stroke="#000" stroke-linecap="round" stroke-width="2"/>
-<path d="m19.008 11.678c-1.3149-1.516-2.4635-2.9072-3.6262-4.309l5.4288-1.1625z"/>
+ <path d="m18.125 24.869c-0.30058 1.1349-0.6585 2.0533-1.0559 2.6879-0.39744 0.63468-0.83441 0.98564-1.2931 0.98564-1.8347 0-3.322-5.6154-3.322-12.542s1.4873-12.542 3.322-12.542c0.45868 0 0.89564 0.35096 1.2931 0.98564 0.39744 0.63468 0.75536 1.5531 1.0559 2.6879" fill="none" stroke="#000" stroke-linecap="round" stroke-width="2"/>
+ <path d="m20.337 11.025c2.0047-0.09094 3.8084-0.12892 5.6292-0.1714l-2.4557 4.9793z"/>
+ <path d="m24.361 13.367c1.1349 0.30058 2.0533 0.6585 2.6879 1.0559 0.63468 0.39744 0.98564 0.83441 0.98564 1.2931 0 1.8347-5.6154 3.322-12.542 3.322-6.927 0-12.542-1.4873-12.542-3.322 0-0.45868 0.35096-0.89564 0.98564-1.2931 0.63468-0.39744 1.5531-0.75536 2.6879-1.0559" fill="none" stroke="#000" stroke-linecap="round" stroke-width="2"/>
+ <path d="m19.008 11.678c-1.3149-1.516-2.4635-2.9072-3.6262-4.309l5.4288-1.1625z"/>
</svg>
diff --git a/silx/resources/gui/icons/shape-cross.png b/silx/resources/gui/icons/shape-cross.png
new file mode 100644
index 0000000..72106a4
--- /dev/null
+++ b/silx/resources/gui/icons/shape-cross.png
Binary files differ
diff --git a/silx/resources/gui/icons/shape-cross.svg b/silx/resources/gui/icons/shape-cross.svg
new file mode 100644
index 0000000..cba6638
--- /dev/null
+++ b/silx/resources/gui/icons/shape-cross.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg4" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata10"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata><line id="line2" x1="16.261" x2="16.261" y1="7.668" y2="27.668" fill="none" stroke="#f7941e" stroke-miterlimit="10" stroke-width="3"/><line id="line2-3" x1="26.261" x2="6.261" y1="17.668" y2="17.668" fill="none" stroke="#f7941e" stroke-miterlimit="10" stroke-width="3"/></svg>
diff --git a/silx/resources/gui/icons/shape-diagonal-directed.png b/silx/resources/gui/icons/shape-diagonal-directed.png
new file mode 100644
index 0000000..f2405b4
--- /dev/null
+++ b/silx/resources/gui/icons/shape-diagonal-directed.png
Binary files differ
diff --git a/silx/resources/gui/icons/shape-diagonal-directed.svg b/silx/resources/gui/icons/shape-diagonal-directed.svg
new file mode 100644
index 0000000..24e1b12
--- /dev/null
+++ b/silx/resources/gui/icons/shape-diagonal-directed.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg4" enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata10"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata>
+<line id="line2" x1="8.3532" x2="23.365" y1="8.6304" y2="23.555" fill="#f7941e" stroke="#f7941e" stroke-miterlimit="10" stroke-width="3" style="font-variant-east_asian:normal"/>
+<path id="path821-3" d="m15.81 25.221h9.0847v-9.0847" fill="#f7941e"/></svg>
diff --git a/silx/resources/gui/icons/shape-ellipse.svg b/silx/resources/gui/icons/shape-ellipse.svg
index bf13040..e5aeeaa 100644
--- a/silx/resources/gui/icons/shape-ellipse.svg
+++ b/silx/resources/gui/icons/shape-ellipse.svg
@@ -1,2 +1,2 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<svg id="svg2" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" height="100%" viewBox="0 0 32 32" width="100%" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><metadata id="metadata10"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><path id="path3006" style="color:#000000" d="m24.949 5.4237a10.034 5.0169 0 1 1 -20.068 0 10.034 5.0169 0 1 1 20.068 0z" transform="matrix(1.1976 0 0 1.4223 -1.8629 8.2859)" stroke="#f7941e" stroke-miterlimit="10" stroke-width="2.2986" fill="none"/></svg>
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg2" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata10"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><path id="path3006" transform="matrix(1.1976 0 0 1.4223 -1.8629 8.2859)" d="m24.949 5.4237a10.034 5.0169 0 1 1-20.068 0 10.034 5.0169 0 1 1 20.068 0z" color="#000000" fill="none" stroke="#f7941e" stroke-miterlimit="10" stroke-width="2.2986"/></svg>
diff --git a/silx/resources/gui/icons/slice-cross.png b/silx/resources/gui/icons/slice-cross.png
new file mode 100644
index 0000000..106362e
--- /dev/null
+++ b/silx/resources/gui/icons/slice-cross.png
Binary files differ
diff --git a/silx/resources/gui/icons/slice-cross.svg b/silx/resources/gui/icons/slice-cross.svg
new file mode 100644
index 0000000..271a656
--- /dev/null
+++ b/silx/resources/gui/icons/slice-cross.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg14" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata20"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata><line id="line12-3" x1="26" x2="6" y1="17.356" y2="17.356" fill="none" stroke="#f7941e" stroke-miterlimit="10" stroke-width="3"/><line id="line12" x1="16.261" x2="16.261" y1="7.668" y2="27.668" fill="none" stroke="#f7941e" stroke-miterlimit="10" stroke-width="3"/><circle id="path1370" cx="16.261" cy="9.6949" r="3.1186" fill="#f7941e"/><circle id="path1370-3" cx="16.261" cy="17.356" r="3.1186" fill="#f7941e"/><circle id="path1370-6" cx="16.261" cy="25.017" r="3.1186" fill="#f7941e"/><circle id="path1370-7" cx="23.932" cy="9.7627" r="2.1017" fill="#948b81" style="font-variant-east_asian:normal"/><circle id="path1370-7-6" cx="23.932" cy="25.085" r="2.1017" fill="#948b81" style="font-variant-east_asian:normal"/><circle id="path1370-7-2" cx="8.6102" cy="9.7627" r="2.1017" fill="#948b81"/><circle id="path1370-7-6-1" cx="8.6102" cy="25.085" r="2.1017" fill="#948b81"/><circle id="path1370-67" cx="8.6102" cy="17.424" r="3.1186" fill="#f7941e"/><circle id="path1370-5" cx="23.932" cy="17.424" r="3.1186" fill="#f7941e"/></svg>
diff --git a/silx/resources/gui/icons/slice-horizontal.png b/silx/resources/gui/icons/slice-horizontal.png
new file mode 100644
index 0000000..d16b74c
--- /dev/null
+++ b/silx/resources/gui/icons/slice-horizontal.png
Binary files differ
diff --git a/silx/resources/gui/icons/slice-horizontal.svg b/silx/resources/gui/icons/slice-horizontal.svg
new file mode 100644
index 0000000..9402bc6
--- /dev/null
+++ b/silx/resources/gui/icons/slice-horizontal.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg4" enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata10"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata>
+ <rect id="rect2" x="5.387" y="14.5" width="22.665" height="3" fill="#F7941E"/>
+<circle id="path1370" transform="rotate(-90)" cx="-16" cy="25.602" r="3.1186" fill="#f7941e"/><circle id="path1370-3" transform="rotate(-90)" cx="-16" cy="16.766" r="3.1186" fill="#f7941e"/><circle id="path1370-6" transform="rotate(-90)" cx="-16" cy="7.9308" r="3.1186" fill="#f7941e"/><circle id="path1370-7" transform="rotate(-90)" cx="-23.39" cy="25.534" r="2.1017" fill="#948b81"/><circle id="path1370-7-5" transform="rotate(-90)" cx="-23.39" cy="16.699" r="2.1017" fill="#948b81"/><circle id="path1370-7-6" transform="rotate(-90)" cx="-23.39" cy="7.863" r="2.1017" fill="#948b81"/><circle id="path1370-7-2" transform="rotate(-90)" cx="-8.0678" cy="25.534" r="2.1017" fill="#948b81"/><circle id="path1370-7-5-9" transform="rotate(-90)" cx="-8.0678" cy="16.699" r="2.1017" fill="#948b81"/><circle id="path1370-7-6-1" transform="rotate(-90)" cx="-8.0678" cy="7.863" r="2.1017" fill="#948b81"/></svg>
diff --git a/silx/resources/gui/icons/slice-vertical.png b/silx/resources/gui/icons/slice-vertical.png
new file mode 100644
index 0000000..6fc99b3
--- /dev/null
+++ b/silx/resources/gui/icons/slice-vertical.png
Binary files differ
diff --git a/silx/resources/gui/icons/slice-vertical.svg b/silx/resources/gui/icons/slice-vertical.svg
new file mode 100644
index 0000000..d9d67a4
--- /dev/null
+++ b/silx/resources/gui/icons/slice-vertical.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg14" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata20"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata><line id="line12" x1="16.261" x2="16.261" y1="7.668" y2="27.668" fill="none" stroke="#f7941e" stroke-miterlimit="10" stroke-width="3"/><circle id="path1370" cx="16.261" cy="9.6949" r="3.1186" fill="#f7941e"/><circle id="path1370-3" cx="16.261" cy="17.356" r="3.1186" fill="#f7941e"/><circle id="path1370-6" cx="16.261" cy="25.017" r="3.1186" fill="#f7941e"/><circle id="path1370-7" cx="23.932" cy="9.7627" r="2.1017" fill="#948b81" style="font-variant-east_asian:normal"/><circle id="path1370-7-5" cx="23.932" cy="17.424" r="2.1017" fill="#948b81" style="font-variant-east_asian:normal"/><circle id="path1370-7-6" cx="23.932" cy="25.085" r="2.1017" fill="#948b81" style="font-variant-east_asian:normal"/><circle id="path1370-7-2" cx="8.6102" cy="9.7627" r="2.1017" fill="#948b81"/><circle id="path1370-7-5-9" cx="8.6102" cy="17.424" r="2.1017" fill="#948b81"/><circle id="path1370-7-6-1" cx="8.6102" cy="25.085" r="2.1017" fill="#948b81"/></svg>
diff --git a/silx/resources/gui/icons/tree-sort.png b/silx/resources/gui/icons/tree-sort.png
new file mode 100644
index 0000000..2e759b6
--- /dev/null
+++ b/silx/resources/gui/icons/tree-sort.png
Binary files differ
diff --git a/silx/resources/gui/icons/tree-sort.svg b/silx/resources/gui/icons/tree-sort.svg
new file mode 100644
index 0000000..b813d60
--- /dev/null
+++ b/silx/resources/gui/icons/tree-sort.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="svg36" enable-background="new 0 0 32 32" version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata id="metadata42"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata>
+<g id="g16" transform="rotate(-90 14.865 24.209)"><g id="g14" transform="matrix(1.2615 0 0 1.1339 -31.185 -5.3706)"><path id="path12" d="m37.374 15.523-5.0575 5.0575 5.0575 5.0575v-3.9367h16.103l0.098202-1.1207-0.098202-1.1207h-16.103z" stroke-width="1.5712"/></g><g id="text867-3" transform="matrix(0 1.0925 -.91533 0 0 0)" aria-label="a"><path id="path821" d="m29.574-30.472v-2.989q0-0.30586-0.19464-0.30586-0.097317 0-0.83415 0.36146-2.3634 1.1817-2.3634 3.0864 0 0.68122 0.43098 1.1122 0.44488 0.41707 1.2234 0.41707 0.5561 0 1.14-0.5839 0.59781-0.59781 0.59781-1.0983zm4.1568 1.0566q0.30586 0.041707 0.40317 0.41707 0 0.041708-0.013903 0.069513-0.57 0.95927-1.0566 1.2512t-1.279 0.29195q-1.4598 0-1.9742-1.8907-1.0566 1.0566-1.6822 1.4737-0.62561 0.41707-1.8351 0.41707-0.77854 0-1.2929-0.43098-0.51439-0.43098-0.73683-0.98708-0.20854-0.57-0.20854-1.1539t0.27805-1.14q0.27805-0.57 0.80634-1.0149 0.5422-0.45878 0.93147-0.66732l3.2254-1.7934q0.27805-0.15293 0.27805-0.44488v-2.5164q0-1.2234-1.849-1.2234-1.3485 0-1.8768 0.69512-0.069513 0.097317-0.20854 1.2929-0.16683 1.3763-0.95927 1.3763-0.76464 0-0.76464-0.77854t0.5561-1.5571q0.57-0.79244 0.97317-1.0288 1.7795-1.0427 3.42-1.0427 0.98708 0 1.9046 0.5561 0.93147 0.5561 0.93147 1.3763v7.3961q0 1.5988 0.93147 1.5988 0.26415 0 1.001-0.51439 0.041707-0.027805 0.097317-0.027805z" stroke-width=".71181"/></g>
+<g id="text867-3-8" transform="matrix(0 1.0925 -.91533 0 0 0)" aria-label="z"><path id="path818" d="m33.625-16.008q-0.43098 3.1837-0.43098 3.6981 0 0.51439-0.37537 0.51439-0.13902 0-0.95927-0.19464-0.80634-0.18073-1.3068-0.18073h-5.839q-0.23634 0-0.23634-0.19464 0-0.083414 0.027805-0.12512l5.5332-10.135q0.11122-0.20854 0.11122-0.31976 0-0.25024-0.40317-0.25024h-2.1966q-0.76464 0-1.2929 0.66732-0.51439 0.65342-1.3763 2.5303-0.041707 0.05561-0.097317 0.05561-0.041708 0-0.13902-0.041708-0.083415-0.041707-0.083415-0.097317l0.51439-3.712q0.041707-0.31976 0.12512-0.38927 0.083415-0.083415 0.19464-0.083415l1.432 0.22244q0.36146 0.05561 1.0705 0.05561h4.8103q0.20854 0 0.20854 0.097317 0 0.097318-0.069513 0.22244l-5.5193 10.051q-0.083415 0.15293-0.083415 0.25024 0 0.25024 0.37537 0.30585t1.6822 0.05561q1.3207 0 2.0854-0.50049 0.77854-0.50049 1.6683-2.6276 0.05561-0.083415 0.13902-0.083415 0.097318 0 0.26415 0.05561 0.16683 0.041707 0.16683 0.15293z" stroke-width=".71181"/></g>
+</g></svg>
diff --git a/silx/resources/gui/icons/view-1d.svg b/silx/resources/gui/icons/view-1d.svg
index 1c6780d..a2ad9cc 100644
--- a/silx/resources/gui/icons/view-1d.svg
+++ b/silx/resources/gui/icons/view-1d.svg
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(1.1932 -1.149)">
-<path d="m28.765 3.4301v26.581h-26.863v-26.581" fill="#f7941e" fill-opacity=".81569"/>
-<path d="m28.76 30.013h-26.951v-26.583" fill="none" stroke="#000" stroke-miterlimit="2" stroke-width="1.3"/>
-</g>
-<path d="m29.956 16.168c-2.7537-0.0701-5.2366 4.4566-7.5238 3.9365-7.6475-1.7392-8.9368-19.912-19.399-3.7641-0.048098 0.07424-0.33967-0.06041-0.52072-0.05101" fill="none" stroke="#000" stroke-miterlimit="2" stroke-width="1.5627"/>
+ <g transform="translate(1.1932 -1.149)">
+ <path d="m28.765 3.4301v26.581h-26.863v-26.581" fill="#f7941e" fill-opacity=".81569"/>
+ <path d="m28.76 30.013h-26.951v-26.583" fill="none" stroke="#000" stroke-miterlimit="2" stroke-width="1.3"/>
+ </g>
+ <path d="m29.956 16.168c-2.7537-0.0701-5.2366 4.4566-7.5238 3.9365-7.6475-1.7392-8.9368-19.912-19.399-3.7641-0.048098 0.07424-0.33967-0.06041-0.52072-0.05101" fill="none" stroke="#000" stroke-miterlimit="2" stroke-width="1.5627"/>
</svg>
diff --git a/silx/resources/gui/icons/view-2d-stack.svg b/silx/resources/gui/icons/view-2d-stack.svg
index 8d6d355..922d745 100644
--- a/silx/resources/gui/icons/view-2d-stack.svg
+++ b/silx/resources/gui/icons/view-2d-stack.svg
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<rect x="10.867" y="2.9322" width="18" height="18" ry="0" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
-<rect x="6.9153" y="6.8983" width="18" height="18" ry="0" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
-<rect x="2.9631" y="10.864" width="18" height="18" ry="0" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <rect x="10.867" y="2.9322" width="18" height="18" ry="0" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <rect x="6.9153" y="6.8983" width="18" height="18" ry="0" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <rect x="2.9631" y="10.864" width="18" height="18" ry="0" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
</svg>
diff --git a/silx/resources/gui/icons/view-2d.svg b/silx/resources/gui/icons/view-2d.svg
index a571895..10f4cc0 100644
--- a/silx/resources/gui/icons/view-2d.svg
+++ b/silx/resources/gui/icons/view-2d.svg
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<rect x="2.8314" y="2.8314" width="26.026" height="26.026" ry="0" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <rect x="2.8314" y="2.8314" width="26.026" height="26.026" ry="0" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
</svg>
diff --git a/silx/resources/gui/icons/view-3d.svg b/silx/resources/gui/icons/view-3d.svg
index 90e4686..7e417ae 100644
--- a/silx/resources/gui/icons/view-3d.svg
+++ b/silx/resources/gui/icons/view-3d.svg
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<rect transform="matrix(1 0 -.69517 .71885 0 0)" x="31.3" y="26.522" width="16.142" height="13.571" fill="none" stroke="#000" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.6512"/>
-<rect x="12.767" y="2.935" width="15.819" height="16.09" ry="0" fill="none" stroke="#000" stroke-miterlimit="2" stroke-width="1.3837"/>
-<rect transform="matrix(1 0 -.70625 .70796 0 0)" x="15.432" y="4.0219" width="16.142" height="13.779" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.6639"/>
-<rect x="2.9112" y="12.74" width="16.142" height="16.142" ry="0" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
-<path d="m28.899 18.96 0.0111-15.906-9.4388 9.4342-0.0111 16.222z" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-linejoin="bevel" stroke-miterlimit="0" stroke-width="1.4"/>
+ <rect transform="matrix(1 0 -.69517 .71885 0 0)" x="31.3" y="26.522" width="16.142" height="13.571" fill="none" stroke="#000" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.6512"/>
+ <rect x="12.767" y="2.935" width="15.819" height="16.09" ry="0" fill="none" stroke="#000" stroke-miterlimit="2" stroke-width="1.3837"/>
+ <rect transform="matrix(1 0 -.70625 .70796 0 0)" x="15.432" y="4.0219" width="16.142" height="13.779" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-linecap="square" stroke-miterlimit="0" stroke-width="1.6639"/>
+ <rect x="2.9112" y="12.74" width="16.142" height="16.142" ry="0" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <path d="m28.899 18.96 0.0111-15.906-9.4388 9.4342-0.0111 16.222z" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-linejoin="bevel" stroke-miterlimit="0" stroke-width="1.4"/>
</svg>
diff --git a/silx/resources/gui/icons/view-hdf5.svg b/silx/resources/gui/icons/view-hdf5.svg
index 591bc4a..265db72 100644
--- a/silx/resources/gui/icons/view-hdf5.svg
+++ b/silx/resources/gui/icons/view-hdf5.svg
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<path transform="translate(9.9117 -.3238)" d="m18.888 16.324a12.8 12.8 0 1 1-25.6 0 12.8 12.8 0 1 1 25.6 0z" color="#000000" fill="#f6941d" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
-<text x="6.1552963" y="21.15884" fill="#000000" font-family="Sans" font-size="13.838px" letter-spacing="0px" word-spacing="0px" style="line-height:125%" xml:space="preserve"><tspan x="6.1552963" y="21.15884" font-weight="bold">h5</tspan></text>
+ <path transform="translate(9.9117 -.3238)" d="m18.888 16.324a12.8 12.8 0 1 1-25.6 0 12.8 12.8 0 1 1 25.6 0z" color="#000000" fill="#f6941d" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <text x="6.1552963" y="21.15884" fill="#000000" font-family="Sans" font-size="13.838px" letter-spacing="0px" word-spacing="0px" style="line-height:125%" xml:space="preserve"><tspan x="6.1552963" y="21.15884" font-weight="bold">h5</tspan></text>
</svg>
diff --git a/silx/resources/gui/icons/view-nexus.svg b/silx/resources/gui/icons/view-nexus.svg
index eef5e54..4bfff81 100644
--- a/silx/resources/gui/icons/view-nexus.svg
+++ b/silx/resources/gui/icons/view-nexus.svg
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<path transform="translate(9.9117 -.3238)" d="m18.888 16.324a12.8 12.8 0 1 1-25.6 0 12.8 12.8 0 1 1 25.6 0z" color="#000000" fill="#f6941d" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
-<text transform="scale(.95955 1.0422)" x="6.0864153" y="20.080931" fill="#000000" font-family="Sans" font-size="13.1px" letter-spacing="0px" word-spacing="0px" style="line-height:125%" xml:space="preserve"><tspan x="6.0864153" y="20.080931" font-weight="bold">NX</tspan></text>
+ <path transform="translate(9.9117 -.3238)" d="m18.888 16.324a12.8 12.8 0 1 1-25.6 0 12.8 12.8 0 1 1 25.6 0z" color="#000000" fill="#f6941d" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <text transform="scale(.95955 1.0422)" x="6.0864153" y="20.080931" fill="#000000" font-family="Sans" font-size="13.1px" letter-spacing="0px" word-spacing="0px" style="line-height:125%" xml:space="preserve"><tspan x="6.0864153" y="20.080931" font-weight="bold">NX</tspan></text>
</svg>
diff --git a/silx/resources/gui/icons/view-nofullscreen.svg b/silx/resources/gui/icons/view-nofullscreen.svg
index ee0bd9f..003ba53 100644
--- a/silx/resources/gui/icons/view-nofullscreen.svg
+++ b/silx/resources/gui/icons/view-nofullscreen.svg
@@ -1,2 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><filter id="d" x="-.33807" y="-.42625" width="1.6761" height="1.8525" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="1.16241"/></filter><filter id="c" x="-.35332" y="-.42547" width="1.7066" height="1.8509" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="1.16241"/></filter><filter id="b" x="-.34982" y="-.42521" width="1.6996" height="1.8504" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="1.16241"/></filter><filter id="a" x="-.34548" y="-.41977" width="1.691" height="1.8395" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="1.16241"/></filter></defs><g><polygon transform="translate(.11749 .92429)" points="28.095 10.325 26.996 8.802 22.393 12.264 21.201 10.608 20.02 15.224 24.684 15.448 23.488 13.785" filter="url(#a)"/><polygon transform="translate(.11749 .92429)" points="26.968 24.394 28.044 22.933 23.491 19.431 24.671 17.833 20.069 17.94 21.237 22.488 22.416 20.892" filter="url(#b)"/><polygon transform="translate(.11749 .92429)" points="5.118 22.968 6.202 24.392 10.702 20.813 11.887 22.369 13.014 17.835 8.434 17.838 9.618 19.391" filter="url(#c)"/><polygon transform="translate(.11749 .92429)" points="5.971 8.852 4.94 10.418 9.675 13.685 8.543 15.397 13.192 14.976 11.837 10.404 10.709 12.119" filter="url(#d)"/></g><path d="m18.462 31.115" stroke="#fff" stroke-miterlimit="10" stroke-width=".51"/><polygon points="24.684 15.448 23.488 13.785 28.095 10.325 26.996 8.802 22.393 12.264 21.201 10.608 20.02 15.224" fill="#ed1c24"/><rect x="3.413" y="5.123" width="26.216" height="2.705" fill="#fff" stroke="#000" stroke-miterlimit="10" stroke-width=".2"/><polygon points="21.237 22.488 22.416 20.892 26.968 24.394 28.044 22.933 23.491 19.431 24.671 17.833 20.069 17.94" fill="#ed1c24"/><polygon points="8.434 17.838 9.618 19.391 5.118 22.968 6.202 24.392 10.702 20.813 11.887 22.369 13.014 17.835" fill="#ed1c24"/><polygon points="11.837 10.404 10.709 12.119 5.971 8.852 4.94 10.418 9.675 13.685 8.543 15.397 13.192 14.976" fill="#ed1c24"/><rect x="3.048" y="5.123" width="26.581" height="19.925" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width=".9"/></svg>
+<svg version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><filter id="d" x="-.33807" y="-.42625" width="1.6761" height="1.8525" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="1.16241"/></filter><filter id="c" x="-.35332" y="-.42547" width="1.7066" height="1.8509" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="1.16241"/></filter><filter id="b" x="-.34982" y="-.42521" width="1.6996" height="1.8504" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="1.16241"/></filter><filter id="a" x="-.34548" y="-.41977" width="1.691" height="1.8395" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="1.16241"/></filter></defs><polygon transform="translate(.11749 .92429)" points="28.095 10.325 26.996 8.802 22.393 12.264 21.201 10.608 20.02 15.224 24.684 15.448 23.488 13.785" filter="url(#a)"/><polygon transform="translate(.11749 .92429)" points="26.968 24.394 28.044 22.933 23.491 19.431 24.671 17.833 20.069 17.94 21.237 22.488 22.416 20.892" filter="url(#b)"/><polygon transform="translate(.11749 .92429)" points="5.118 22.968 6.202 24.392 10.702 20.813 11.887 22.369 13.014 17.835 8.434 17.838 9.618 19.391" filter="url(#c)"/><polygon transform="translate(.11749 .92429)" points="5.971 8.852 4.94 10.418 9.675 13.685 8.543 15.397 13.192 14.976 11.837 10.404 10.709 12.119" filter="url(#d)"/><path d="m18.462 31.115" stroke="#fff" stroke-miterlimit="10" stroke-width=".51"/><polygon points="24.684 15.448 23.488 13.785 28.095 10.325 26.996 8.802 22.393 12.264 21.201 10.608 20.02 15.224" fill="#ed1c24"/><rect x="3.413" y="5.123" width="26.216" height="2.705" fill="#fff" stroke="#000" stroke-miterlimit="10" stroke-width=".2"/><polygon points="21.237 22.488 22.416 20.892 26.968 24.394 28.044 22.933 23.491 19.431 24.671 17.833 20.069 17.94" fill="#ed1c24"/><polygon points="8.434 17.838 9.618 19.391 5.118 22.968 6.202 24.392 10.702 20.813 11.887 22.369 13.014 17.835" fill="#ed1c24"/><polygon points="11.837 10.404 10.709 12.119 5.971 8.852 4.94 10.418 9.675 13.685 8.543 15.397 13.192 14.976" fill="#ed1c24"/><rect x="3.048" y="5.123" width="26.581" height="19.925" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width=".9"/></svg>
diff --git a/silx/resources/gui/icons/view-raw.svg b/silx/resources/gui/icons/view-raw.svg
index 869e037..ff15da3 100644
--- a/silx/resources/gui/icons/view-raw.svg
+++ b/silx/resources/gui/icons/view-raw.svg
@@ -1,12 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<rect x="3.2544" y="21.322" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
-<rect x="3.2544" y="13.051" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
-<rect x="3.2544" y="4.9832" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
-<rect x="11.017" y="21.322" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
-<rect x="11.017" y="13.051" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
-<rect x="11.017" y="4.9832" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
-<rect x="19.22" y="21.322" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
-<rect x="19.22" y="13.051" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
-<rect x="19.22" y="4.9832" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <rect x="3.2544" y="21.322" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <rect x="3.2544" y="13.051" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <rect x="3.2544" y="4.9832" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <rect x="11.017" y="21.322" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <rect x="11.017" y="13.051" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <rect x="11.017" y="4.9832" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <rect x="19.22" y="21.322" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <rect x="19.22" y="13.051" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <rect x="19.22" y="4.9832" width="7.7285" height="7.7285" ry="0" color="#000000" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
</svg>
diff --git a/silx/resources/gui/icons/view-text.svg b/silx/resources/gui/icons/view-text.svg
index 4d924ba..fbf0a7c 100644
--- a/silx/resources/gui/icons/view-text.svg
+++ b/silx/resources/gui/icons/view-text.svg
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
-<rect x="3.4068" y="7.339" width="25.219" height="16.007" ry="0" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
-<g transform="matrix(.89445 0 0 .89445 1.3024 .77355)">
-<path d="m8.9353 15.017v-3.1925h0.96935v8.1973h-0.96935v-0.88506c-0.20371 0.35121-0.46185 0.61287-0.77443 0.78496-0.30907 0.16858-0.68136 0.25287-1.1169 0.25287-0.71297 0-1.2942-0.28448-1.7438-0.85345-0.44604-0.56896-0.66906-1.317-0.66906-2.2443-6e-7 -0.9272 0.22302-1.6753 0.66906-2.2443 0.44955-0.56896 1.0308-0.85344 1.7438-0.85345 0.4355 7e-6 0.80779 0.08605 1.1169 0.25814 0.31258 0.16859 0.57072 0.42849 0.77443 0.77969m-3.3032 2.0599c-1.6e-6 0.71297 0.14575 1.2732 0.43726 1.6806 0.29502 0.4039 0.69891 0.60584 1.2117 0.60584 0.51277 1e-6 0.91666-0.20195 1.2117-0.60584 0.29501-0.40741 0.44252-0.96759 0.44253-1.6806-4.9e-6 -0.71296-0.14751-1.2714-0.44253-1.6753-0.29502-0.4074-0.69892-0.61111-1.2117-0.61111-0.51277 6e-6 -0.91667 0.20371-1.2117 0.61111-0.29151 0.4039-0.43726 0.96233-0.43726 1.6753"/>
-<path d="m14.583 17.056c-0.78321 3e-6 -1.3258 0.08956-1.6279 0.26868-0.30204 0.17912-0.45307 0.48468-0.45306 0.91667-1e-6 0.34419 0.11239 0.61814 0.33716 0.82184 0.22829 0.20019 0.53735 0.30029 0.9272 0.30029 0.53735 1e-6 0.96759-0.18965 1.2907-0.56896 0.32662-0.38282 0.48994-0.89032 0.48994-1.5225v-0.216h-0.96408m1.9334-0.40038v3.3664h-0.96935v-0.89559c-0.22127 0.35824-0.49697 0.6234-0.82711 0.7955-0.33014 0.16858-0.73404 0.25287-1.2117 0.25287-0.60409 0-1.0853-0.16858-1.4435-0.50575-0.35473-0.34068-0.53209-0.7955-0.53209-1.3645 0-0.66379 0.22126-1.1643 0.66379-1.5014 0.44604-0.33716 1.1098-0.50574 1.9914-0.50575h1.3592v-0.09483c-5e-6 -0.44604-0.14751-0.79022-0.44253-1.0326-0.29151-0.24584-0.70243-0.36877-1.2328-0.36877-0.33717 5e-6 -0.66555 0.04039-0.98515 0.12117-0.31961 0.08078-0.62692 0.20195-0.92194 0.36351v-0.89559c0.35472-0.13697 0.69891-0.23882 1.0326-0.30556 0.33365-0.07024 0.65852-0.10536 0.97462-0.10536 0.85344 7e-6 1.4909 0.22127 1.9124 0.66379 0.42145 0.44253 0.63218 1.1134 0.63218 2.0125"/>
-<path d="m19.477 12.447v1.6753h1.9966v0.75335h-1.9966v3.2031c-2e-6 0.48116 0.06497 0.79023 0.19492 0.9272 0.13346 0.13697 0.40214 0.20546 0.80603 0.20546h0.99569v0.8113h-0.99569c-0.74809 0-1.2644-0.13873-1.5489-0.41619-0.28448-0.28097-0.42672-0.79023-0.42672-1.5278v-3.2031h-0.71121v-0.75335h0.71121v-1.6753h0.97462"/>
-<path d="m25.435 17.056c-0.78321 3e-6 -1.3258 0.08956-1.6279 0.26868-0.30204 0.17912-0.45307 0.48468-0.45306 0.91667-2e-6 0.34419 0.11239 0.61814 0.33716 0.82184 0.22829 0.20019 0.53735 0.30029 0.9272 0.30029 0.53735 1e-6 0.96759-0.18965 1.2907-0.56896 0.32662-0.38282 0.48994-0.89032 0.48994-1.5225v-0.216h-0.96408m1.9334-0.40038v3.3664h-0.96935v-0.89559c-0.22127 0.35824-0.49697 0.6234-0.82711 0.7955-0.33014 0.16858-0.73404 0.25287-1.2117 0.25287-0.60409 0-1.0853-0.16858-1.4435-0.50575-0.35473-0.34068-0.53209-0.7955-0.53209-1.3645-1e-6 -0.66379 0.22126-1.1643 0.66379-1.5014 0.44604-0.33716 1.1098-0.50574 1.9914-0.50575h1.3592v-0.09483c-4e-6 -0.44604-0.14751-0.79022-0.44253-1.0326-0.29151-0.24584-0.70243-0.36877-1.2328-0.36877-0.33717 5e-6 -0.66555 0.04039-0.98515 0.12117-0.31961 0.08078-0.62692 0.20195-0.92194 0.36351v-0.89559c0.35472-0.13697 0.69891-0.23882 1.0326-0.30556 0.33365-0.07024 0.65852-0.10536 0.97462-0.10536 0.85344 7e-6 1.4909 0.22127 1.9124 0.66379 0.42145 0.44253 0.63218 1.1134 0.63218 2.0125"/>
-</g>
+ <rect x="3.4068" y="7.339" width="25.219" height="16.007" ry="0" fill="#f7941e" fill-opacity=".81569" stroke="#000" stroke-miterlimit="2" stroke-width="1.4"/>
+ <g transform="matrix(.89445 0 0 .89445 1.3024 .77355)">
+ <path d="m8.9353 15.017v-3.1925h0.96935v8.1973h-0.96935v-0.88506c-0.20371 0.35121-0.46185 0.61287-0.77443 0.78496-0.30907 0.16858-0.68136 0.25287-1.1169 0.25287-0.71297 0-1.2942-0.28448-1.7438-0.85345-0.44604-0.56896-0.66906-1.317-0.66906-2.2443-6e-7 -0.9272 0.22302-1.6753 0.66906-2.2443 0.44955-0.56896 1.0308-0.85344 1.7438-0.85345 0.4355 7e-6 0.80779 0.08605 1.1169 0.25814 0.31258 0.16859 0.57072 0.42849 0.77443 0.77969m-3.3032 2.0599c-1.6e-6 0.71297 0.14575 1.2732 0.43726 1.6806 0.29502 0.4039 0.69891 0.60584 1.2117 0.60584 0.51277 1e-6 0.91666-0.20195 1.2117-0.60584 0.29501-0.40741 0.44252-0.96759 0.44253-1.6806-4.9e-6 -0.71296-0.14751-1.2714-0.44253-1.6753-0.29502-0.4074-0.69892-0.61111-1.2117-0.61111-0.51277 6e-6 -0.91667 0.20371-1.2117 0.61111-0.29151 0.4039-0.43726 0.96233-0.43726 1.6753"/>
+ <path d="m14.583 17.056c-0.78321 3e-6 -1.3258 0.08956-1.6279 0.26868-0.30204 0.17912-0.45307 0.48468-0.45306 0.91667-1e-6 0.34419 0.11239 0.61814 0.33716 0.82184 0.22829 0.20019 0.53735 0.30029 0.9272 0.30029 0.53735 1e-6 0.96759-0.18965 1.2907-0.56896 0.32662-0.38282 0.48994-0.89032 0.48994-1.5225v-0.216h-0.96408m1.9334-0.40038v3.3664h-0.96935v-0.89559c-0.22127 0.35824-0.49697 0.6234-0.82711 0.7955-0.33014 0.16858-0.73404 0.25287-1.2117 0.25287-0.60409 0-1.0853-0.16858-1.4435-0.50575-0.35473-0.34068-0.53209-0.7955-0.53209-1.3645 0-0.66379 0.22126-1.1643 0.66379-1.5014 0.44604-0.33716 1.1098-0.50574 1.9914-0.50575h1.3592v-0.09483c-5e-6 -0.44604-0.14751-0.79022-0.44253-1.0326-0.29151-0.24584-0.70243-0.36877-1.2328-0.36877-0.33717 5e-6 -0.66555 0.04039-0.98515 0.12117-0.31961 0.08078-0.62692 0.20195-0.92194 0.36351v-0.89559c0.35472-0.13697 0.69891-0.23882 1.0326-0.30556 0.33365-0.07024 0.65852-0.10536 0.97462-0.10536 0.85344 7e-6 1.4909 0.22127 1.9124 0.66379 0.42145 0.44253 0.63218 1.1134 0.63218 2.0125"/>
+ <path d="m19.477 12.447v1.6753h1.9966v0.75335h-1.9966v3.2031c-2e-6 0.48116 0.06497 0.79023 0.19492 0.9272 0.13346 0.13697 0.40214 0.20546 0.80603 0.20546h0.99569v0.8113h-0.99569c-0.74809 0-1.2644-0.13873-1.5489-0.41619-0.28448-0.28097-0.42672-0.79023-0.42672-1.5278v-3.2031h-0.71121v-0.75335h0.71121v-1.6753h0.97462"/>
+ <path d="m25.435 17.056c-0.78321 3e-6 -1.3258 0.08956-1.6279 0.26868-0.30204 0.17912-0.45307 0.48468-0.45306 0.91667-2e-6 0.34419 0.11239 0.61814 0.33716 0.82184 0.22829 0.20019 0.53735 0.30029 0.9272 0.30029 0.53735 1e-6 0.96759-0.18965 1.2907-0.56896 0.32662-0.38282 0.48994-0.89032 0.48994-1.5225v-0.216h-0.96408m1.9334-0.40038v3.3664h-0.96935v-0.89559c-0.22127 0.35824-0.49697 0.6234-0.82711 0.7955-0.33014 0.16858-0.73404 0.25287-1.2117 0.25287-0.60409 0-1.0853-0.16858-1.4435-0.50575-0.35473-0.34068-0.53209-0.7955-0.53209-1.3645-1e-6 -0.66379 0.22126-1.1643 0.66379-1.5014 0.44604-0.33716 1.1098-0.50574 1.9914-0.50575h1.3592v-0.09483c-4e-6 -0.44604-0.14751-0.79022-0.44253-1.0326-0.29151-0.24584-0.70243-0.36877-1.2328-0.36877-0.33717 5e-6 -0.66555 0.04039-0.98515 0.12117-0.31961 0.08078-0.62692 0.20195-0.92194 0.36351v-0.89559c0.35472-0.13697 0.69891-0.23882 1.0326-0.30556 0.33365-0.07024 0.65852-0.10536 0.97462-0.10536 0.85344 7e-6 1.4909 0.22127 1.9124 0.66379 0.42145 0.44253 0.63218 1.1134 0.63218 2.0125"/>
+ </g>
</svg>
diff --git a/silx/resources/gui/icons/window-new.svg b/silx/resources/gui/icons/window-new.svg
index 0a232ed..114f26c 100644
--- a/silx/resources/gui/icons/window-new.svg
+++ b/silx/resources/gui/icons/window-new.svg
@@ -1,2 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="m24.653 7.496c-10.964-0.107-10.073 10.653-10.266 10.974 0 0 0.193-7.139 10.267-6.713v-4.261z" fill="#f7941e" stroke="#f7941e" stroke-miterlimit="10" stroke-width=".1"/><path d="m24.653 5.819c0-0.17 0.122-0.243 0.271-0.16l6.169 3.403c0.149 0.083 0.157 0.23 0.018 0.328l-6.204 4.348c-0.14 0.098-0.254 0.038-0.254-0.132v-7.787z" fill="#f7941e" stroke="#f7941e" stroke-miterlimit="10"/><path d="m6.2188 8.75c-0.96309 0-1.7812 0.78845-1.7812 1.75v14.125c0 0.96309 0.8197 1.75 1.7812 1.75h19.562c0.45682 0 0.87277-0.20852 1.1875-0.5 0.39449-0.39136 0.54581-0.73311 0.59375-1.25v-9.625l-2.5 2.0312c-0.0096 2.2812 0.03017 4.5643-0.03125 6.8438h-18.094v-12.625h6.5625l1.6875-2.5z" color="#000000" fill="#f7941e" style="block-progression:tb;text-indent:0;text-transform:none"/></svg>
+<svg version="1.1" viewBox="0 0 32 32" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m24.653 7.496c-10.964-0.107-10.073 10.653-10.266 10.974 0 0 0.193-7.139 10.267-6.713v-4.261z" fill="#f7941e" stroke="#f7941e" stroke-miterlimit="10" stroke-width=".1"/><path d="m24.653 5.819c0-0.17 0.122-0.243 0.271-0.16l6.169 3.403c0.149 0.083 0.157 0.23 0.018 0.328l-6.204 4.348c-0.14 0.098-0.254 0.038-0.254-0.132v-7.787z" fill="#f7941e" stroke="#f7941e" stroke-miterlimit="10"/><path d="m6.2188 8.75c-0.96309 0-1.7812 0.78845-1.7812 1.75v14.125c0 0.96309 0.8197 1.75 1.7812 1.75h19.562c0.45682 0 0.87277-0.20852 1.1875-0.5 0.39449-0.39136 0.54581-0.73311 0.59375-1.25v-9.625l-2.5 2.0312c-0.0096 2.2812 0.03017 4.5643-0.03125 6.8438h-18.094v-12.625h6.5625l1.6875-2.5z" color="#000000" fill="#f7941e" style="block-progression:tb;text-indent:0;text-transform:none"/></svg>
diff --git a/silx/sx/_plot.py b/silx/sx/_plot.py
index 74ebe84..b0a5a41 100644
--- a/silx/sx/_plot.py
+++ b/silx/sx/_plot.py
@@ -50,7 +50,6 @@ from ..gui.plot.tools import roi
from ..gui.plot.items import roi as roi_items
from ..gui.plot.tools.toolbars import InteractiveModeToolBar
-
_logger = logging.getLogger(__name__)
_plots = WeakList()
diff --git a/silx/third_party/EdfFile.py b/silx/third_party/EdfFile.py
index d06a211..0606d1c 100644
--- a/silx/third_party/EdfFile.py
+++ b/silx/third_party/EdfFile.py
@@ -1,6 +1,6 @@
# /*##########################################################################
#
-# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2020 European Synchrotron Radiation Facility
#
# This file is part of the PyMca X-ray Fluorescence Toolkit developed at
# the ESRF by the Software group.
@@ -846,9 +846,9 @@ class EdfFile(object):
# if self.Images[Index].StaticHeader["ByteOrder"] != self.SysByteOrder:
if self.Images[Index].ByteOrder.upper() != self.SysByteOrder.upper():
- self.File.write((Data.byteswap()).tostring())
+ self.File.write((Data.byteswap()).tobytes())
else:
- self.File.write(Data.tostring())
+ self.File.write(Data.tobytes())
def __makeSureFileIsOpen(self):
if DEBUG:
diff --git a/silx/third_party/TiffIO.py b/silx/third_party/TiffIO.py
index 8768cff..7526a75 100644
--- a/silx/third_party/TiffIO.py
+++ b/silx/third_party/TiffIO.py
@@ -2,7 +2,7 @@
#
# The PyMca X-Ray Fluorescence Toolkit
#
-# Copyright (c) 2004-2015 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2020 European Synchrotron Radiation Facility
#
# This file is part of the PyMca X-ray Fluorescence Toolkit developed at
# the ESRF by the Software group.
@@ -850,9 +850,9 @@ class TiffIO(object):
#write the image
if self._swap:
- fd.write(image.byteswap().tostring())
+ fd.write(image.byteswap().tobytes())
else:
- fd.write(image.tostring())
+ fd.write(image.tobytes())
fd.flush()
self.fd=fd
diff --git a/silx/utils/ExternalResources.py b/silx/utils/ExternalResources.py
index 7d9008b..e21381c 100644
--- a/silx/utils/ExternalResources.py
+++ b/silx/utils/ExternalResources.py
@@ -167,12 +167,11 @@ class ExternalResources(object):
if not os.path.isfile(fullfilename):
raise RuntimeError(
- "Could not automatically \
- download test images %s!\n \ If you are behind a firewall, \
- please set both environment variable http_proxy and https_proxy.\
- This even works under windows ! \n \
- Otherwise please try to download the images manually from \n%s/%s"
- % (filename, self.url_base, filename))
+ """Could not automatically download test images %s!
+ If you are behind a firewall, please set both environment variable http_proxy and https_proxy.
+ This even works under windows !
+ Otherwise please try to download the images manually from
+ %s/%s""" % (filename, self.url_base, filename))
if filename not in self.all_data:
self.all_data.add(filename)
@@ -263,11 +262,11 @@ class ExternalResources(object):
if not os.path.isfile(fullimagename_bz2):
self.getfile(bzip2name)
if not os.path.isfile(fullimagename_bz2):
- raise RuntimeError("Could not automatically \
- download test images %s!\n \ If you are behind a firewall, \
- please set the environment variable http_proxy.\n \
- Otherwise please try to download the images manually from \n \
- %s" % (self.url_base, filename))
+ raise RuntimeError(
+ """Could not automatically download test images %s!
+ If you are behind a firewall, please set the environment variable http_proxy.
+ Otherwise please try to download the images manually from
+ %s""" % (self.url_base, filename))
try:
import bz2
diff --git a/silx/utils/array_like.py b/silx/utils/array_like.py
index 8ef1ace..1a2e72e 100644
--- a/silx/utils/array_like.py
+++ b/silx/utils/array_like.py
@@ -53,6 +53,7 @@ import sys
import numpy
import six
+import numbers
__authors__ = ["P. Knobel"]
__license__ = "MIT"
@@ -363,7 +364,7 @@ class ListOfImages(object):
frozen_dimensions = []
for i, idx in enumerate(item):
# slices and sequences
- if not isinstance(idx, int):
+ if not isinstance(idx, numbers.Integral):
output_dimensions.append(self.transposition[i])
# regular integer index
else:
diff --git a/silx/utils/deprecation.py b/silx/utils/deprecation.py
index f9ba017..7b19ee5 100644
--- a/silx/utils/deprecation.py
+++ b/silx/utils/deprecation.py
@@ -39,6 +39,10 @@ depreclog = logging.getLogger("silx.DEPRECATION")
deprecache = set([])
+FORCE = False
+"""If true, deprecation using only_once are also generated.
+It is needed for reproducible tests.
+"""
def deprecated(func=None, reason=None, replacement=None, since_version=None, only_once=True, skip_backtrace_count=1):
"""
@@ -110,7 +114,7 @@ def deprecated_warning(type_, name, reason=None, replacement=None,
limit = 2 + skip_backtrace_count
backtrace = "".join(traceback.format_stack(limit=limit)[0])
backtrace = backtrace.rstrip()
- if only_once:
+ if not FORCE and only_once:
data = (msg, type_, name, backtrace)
if data in deprecache:
return
diff --git a/version.py b/version.py
index 58be1b2..d09c692 100644
--- a/version.py
+++ b/version.py
@@ -67,8 +67,8 @@ RELEASE_LEVEL_VALUE = {"dev": 0,
"final": 15}
MAJOR = 0
-MINOR = 12
-MICRO = 0
+MINOR = 13
+MICRO = 1
RELEV = "final" # <16
SERIAL = 0 # <16