summaryrefslogtreecommitdiff
path: root/silx/gui
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui')
-rw-r--r--silx/gui/_glutils/OpenGLWidget.py5
-rw-r--r--silx/gui/_glutils/Texture.py7
-rw-r--r--silx/gui/_glutils/gl.py5
-rw-r--r--silx/gui/_utils.py79
-rw-r--r--silx/gui/console.py5
-rw-r--r--silx/gui/data/DataViewer.py153
-rw-r--r--silx/gui/data/DataViewerFrame.py17
-rw-r--r--silx/gui/data/DataViewerSelector.py40
-rw-r--r--silx/gui/data/DataViews.py365
-rw-r--r--silx/gui/data/Hdf5TableView.py35
-rw-r--r--silx/gui/data/NXdataWidgets.py390
-rw-r--r--silx/gui/data/NumpyAxesSelector.py24
-rw-r--r--silx/gui/data/TextFormatter.py47
-rw-r--r--silx/gui/data/test/test_dataviewer.py87
-rw-r--r--silx/gui/data/test/test_numpyaxesselector.py16
-rw-r--r--silx/gui/data/test/test_textformatter.py13
-rw-r--r--silx/gui/dialog/AbstractDataFileDialog.py1718
-rw-r--r--silx/gui/dialog/DataFileDialog.py342
-rw-r--r--silx/gui/dialog/FileTypeComboBox.py213
-rw-r--r--silx/gui/dialog/ImageFileDialog.py338
-rw-r--r--silx/gui/dialog/SafeFileIconProvider.py150
-rw-r--r--silx/gui/dialog/SafeFileSystemModel.py802
-rw-r--r--silx/gui/dialog/__init__.py29
-rw-r--r--silx/gui/dialog/setup.py40
-rw-r--r--silx/gui/dialog/test/__init__.py47
-rw-r--r--silx/gui/dialog/test/test_datafiledialog.py981
-rw-r--r--silx/gui/dialog/test/test_imagefiledialog.py803
-rw-r--r--silx/gui/dialog/utils.py104
-rw-r--r--silx/gui/hdf5/Hdf5Formatter.py5
-rw-r--r--silx/gui/hdf5/Hdf5Item.py126
-rw-r--r--silx/gui/hdf5/Hdf5TreeModel.py135
-rw-r--r--silx/gui/hdf5/Hdf5TreeView.py113
-rw-r--r--silx/gui/hdf5/NexusSortFilterProxyModel.py20
-rw-r--r--silx/gui/hdf5/_utils.py51
-rw-r--r--silx/gui/hdf5/test/test_hdf5.py99
-rw-r--r--silx/gui/icons.py3
-rw-r--r--silx/gui/plot/ColorBar.py44
-rw-r--r--silx/gui/plot/Colormap.py243
-rw-r--r--silx/gui/plot/ColormapDialog.py897
-rw-r--r--silx/gui/plot/ComplexImageView.py314
-rw-r--r--silx/gui/plot/CurvesROIWidget.py684
-rw-r--r--silx/gui/plot/Interaction.py2
-rw-r--r--silx/gui/plot/PlotToolButtons.py15
-rw-r--r--silx/gui/plot/PlotTools.py10
-rw-r--r--silx/gui/plot/PlotWidget.py172
-rw-r--r--silx/gui/plot/PlotWindow.py63
-rw-r--r--silx/gui/plot/Profile.py12
-rw-r--r--silx/gui/plot/StackView.py24
-rw-r--r--silx/gui/plot/_utils/test/test_ticklayout.py18
-rw-r--r--silx/gui/plot/_utils/ticklayout.py20
-rw-r--r--silx/gui/plot/actions/PlotAction.py3
-rw-r--r--silx/gui/plot/actions/__init__.py12
-rw-r--r--silx/gui/plot/actions/control.py140
-rw-r--r--silx/gui/plot/actions/fit.py4
-rw-r--r--silx/gui/plot/actions/histogram.py4
-rw-r--r--silx/gui/plot/actions/io.py133
-rw-r--r--silx/gui/plot/actions/medfilt.py8
-rw-r--r--silx/gui/plot/backends/BackendBase.py11
-rw-r--r--silx/gui/plot/backends/BackendMatplotlib.py154
-rw-r--r--silx/gui/plot/backends/BackendOpenGL.py177
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotCurve.py2
-rw-r--r--silx/gui/plot/backends/glutils/PlotImageFile.py2
-rw-r--r--silx/gui/plot/items/__init__.py7
-rw-r--r--silx/gui/plot/items/axis.py8
-rw-r--r--silx/gui/plot/items/complex.py356
-rw-r--r--silx/gui/plot/items/core.py95
-rw-r--r--silx/gui/plot/items/image.py7
-rw-r--r--silx/gui/plot/items/marker.py3
-rw-r--r--silx/gui/plot/matplotlib/Colormap.py58
-rw-r--r--silx/gui/plot/matplotlib/__init__.py5
-rw-r--r--silx/gui/plot/test/__init__.py6
-rw-r--r--silx/gui/plot/test/testColormap.py76
-rw-r--r--silx/gui/plot/test/testColormapDialog.py321
-rw-r--r--silx/gui/plot/test/testColors.py4
-rw-r--r--silx/gui/plot/test/testComplexImageView.py4
-rw-r--r--silx/gui/plot/test/testCurvesROIWidget.py19
-rw-r--r--silx/gui/plot/test/testItem.py20
-rw-r--r--silx/gui/plot/test/testMaskToolsWidget.py9
-rw-r--r--silx/gui/plot/test/testPlotTools.py4
-rw-r--r--silx/gui/plot/test/testPlotWidget.py22
-rw-r--r--silx/gui/plot/test/testPlotWidgetNoBackend.py4
-rw-r--r--silx/gui/plot/test/testProfile.py4
-rw-r--r--silx/gui/plot/test/testSaveAction.py97
-rw-r--r--silx/gui/plot/test/testScatterMaskToolsWidget.py9
-rw-r--r--silx/gui/plot/test/testUtilsAxis.py23
-rw-r--r--silx/gui/plot/test/utils.py127
-rw-r--r--silx/gui/plot/utils/axis.py78
-rw-r--r--silx/gui/plot3d/ParamTreeView.py541
-rw-r--r--silx/gui/plot3d/Plot3DWidget.py60
-rw-r--r--silx/gui/plot3d/Plot3DWindow.py23
-rw-r--r--silx/gui/plot3d/SFViewParamTree.py270
-rw-r--r--silx/gui/plot3d/ScalarFieldView.py430
-rw-r--r--silx/gui/plot3d/SceneWidget.py646
-rw-r--r--silx/gui/plot3d/SceneWindow.py192
-rw-r--r--silx/gui/plot3d/_model/__init__.py35
-rw-r--r--silx/gui/plot3d/_model/core.py372
-rw-r--r--silx/gui/plot3d/_model/items.py1388
-rw-r--r--silx/gui/plot3d/_model/model.py184
-rw-r--r--silx/gui/plot3d/actions/Plot3DAction.py10
-rw-r--r--silx/gui/plot3d/actions/io.py15
-rw-r--r--silx/gui/plot3d/actions/mode.py15
-rw-r--r--silx/gui/plot3d/actions/viewpoint.py141
-rw-r--r--silx/gui/plot3d/items/__init__.py43
-rw-r--r--silx/gui/plot3d/items/clipplane.py50
-rw-r--r--silx/gui/plot3d/items/core.py622
-rw-r--r--silx/gui/plot3d/items/image.py126
-rw-r--r--silx/gui/plot3d/items/mesh.py145
-rw-r--r--silx/gui/plot3d/items/mixins.py302
-rw-r--r--silx/gui/plot3d/items/scatter.py474
-rw-r--r--silx/gui/plot3d/items/volume.py463
-rw-r--r--silx/gui/plot3d/scene/__init__.py2
-rw-r--r--silx/gui/plot3d/scene/axes.py19
-rw-r--r--silx/gui/plot3d/scene/camera.py7
-rw-r--r--silx/gui/plot3d/scene/cutplane.py46
-rw-r--r--silx/gui/plot3d/scene/function.py36
-rw-r--r--silx/gui/plot3d/scene/interaction.py58
-rw-r--r--silx/gui/plot3d/scene/primitives.py1241
-rw-r--r--silx/gui/plot3d/scene/test/test_utils.py4
-rw-r--r--silx/gui/plot3d/scene/transform.py24
-rw-r--r--silx/gui/plot3d/scene/utils.py42
-rw-r--r--silx/gui/plot3d/scene/viewport.py59
-rw-r--r--silx/gui/plot3d/scene/window.py27
-rw-r--r--silx/gui/plot3d/setup.py2
-rw-r--r--silx/gui/plot3d/test/__init__.py12
-rw-r--r--silx/gui/plot3d/test/testScalarFieldView.py4
-rw-r--r--silx/gui/plot3d/tools/GroupPropertiesWidget.py200
-rw-r--r--silx/gui/plot3d/tools/ViewpointTools.py119
-rw-r--r--silx/gui/plot3d/tools/__init__.py6
-rw-r--r--silx/gui/plot3d/tools/toolbars.py111
-rw-r--r--silx/gui/qt/_qt.py64
-rw-r--r--silx/gui/qt/_utils.py25
-rw-r--r--silx/gui/setup.py3
-rw-r--r--silx/gui/test/__init__.py15
-rw-r--r--silx/gui/test/test_utils.py91
-rw-r--r--silx/gui/test/utils.py88
-rw-r--r--silx/gui/widgets/FrameBrowser.py2
-rw-r--r--silx/gui/widgets/MedianFilterDialog.py10
-rw-r--r--silx/gui/widgets/PeriodicTable.py8
-rw-r--r--silx/gui/widgets/PrintPreview.py4
-rw-r--r--silx/gui/widgets/ThreadPoolPushButton.py11
-rw-r--r--silx/gui/widgets/test/test_threadpoolpushbutton.py24
141 files changed, 18083 insertions, 2769 deletions
diff --git a/silx/gui/_glutils/OpenGLWidget.py b/silx/gui/_glutils/OpenGLWidget.py
index 6cbf8f0..7f600a0 100644
--- a/silx/gui/_glutils/OpenGLWidget.py
+++ b/silx/gui/_glutils/OpenGLWidget.py
@@ -116,6 +116,9 @@ else:
format_.setSwapBehavior(qt.QSurfaceFormat.DoubleBuffer)
self.setFormat(format_)
+ # Enable receiving mouse move events when no buttons are pressed
+ self.setMouseTracking(True)
+
def getDevicePixelRatio(self):
"""Returns the ratio device-independent / device pixel size
@@ -217,7 +220,7 @@ else:
_logger.error('_OpenGLWidget has no parent')
return
- if qt.BINDING == 'PyQt5':
+ if qt.BINDING in ('PyQt5', 'PySide2'):
devicePixelRatio = self.window().windowHandle().devicePixelRatio()
if devicePixelRatio != self.getDevicePixelRatio():
diff --git a/silx/gui/_glutils/Texture.py b/silx/gui/_glutils/Texture.py
index 9f09a86..0875ebe 100644
--- a/silx/gui/_glutils/Texture.py
+++ b/silx/gui/_glutils/Texture.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -49,7 +49,8 @@ class Texture(object):
:type data: numpy.ndarray or None
:param format_: Input data format if different from internalFormat
:param shape: If data is None, shape of the texture
- :type shape: 2 or 3-tuple of int (height, width) or (depth, height, width)
+ (height, width) or (depth, height, width)
+ :type shape: List[int]
:param int texUnit: The texture unit to use
:param minFilter: OpenGL texture minimization filter (default: GL_NEAREST)
:param magFilter: OpenGL texture magnification filter (default: GL_LINEAR)
@@ -258,7 +259,7 @@ class Texture(object):
:param format_: The OpenGL format of the data
:param data: The data to use to update the texture
:param offset: The offset in the texture where to copy the data
- :type offset: 2 or 3-tuple of int
+ :type offset: List[int]
:param int texUnit:
The texture unit to use (default: the one provided at init)
"""
diff --git a/silx/gui/_glutils/gl.py b/silx/gui/_glutils/gl.py
index 4b9a7bb..608d9ce 100644
--- a/silx/gui/_glutils/gl.py
+++ b/silx/gui/_glutils/gl.py
@@ -101,7 +101,10 @@ def enabled(capacity, enable=True):
:param bool enable:
True (default) to enable during context, False to disable
"""
- if enable:
+ if bool(enable) == glGetBoolean(capacity):
+ # Already in the right state: noop
+ yield
+ elif enable:
glEnable(capacity)
yield
glDisable(capacity)
diff --git a/silx/gui/_utils.py b/silx/gui/_utils.py
index e29141f..d91a572 100644
--- a/silx/gui/_utils.py
+++ b/silx/gui/_utils.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -24,7 +24,10 @@
# ###########################################################################*/
"""This module provides convenient functions to use with Qt objects.
-It provides conversion between numpy and QImage.
+It provides:
+- conversion between numpy and QImage:
+ :func:`convertArrayToQImage`, :func:`convertQImageToArray`
+- Execution of function in Qt main thread: :func:`submitToQtMainThread`
"""
from __future__ import division
@@ -38,6 +41,8 @@ __date__ = "16/01/2017"
import sys
import numpy
+from silx.third_party.concurrent_futures import Future
+
from . import qt
@@ -87,7 +92,7 @@ def convertQImageToArray(image):
image = image.convertToFormat(qt.QImage.Format_RGB888)
ptr = image.bits()
- if qt.BINDING != 'PySide':
+ if qt.BINDING not in ('PySide', 'PySide2'):
ptr.setsize(image.byteCount())
if qt.BINDING == 'PyQt4' and sys.version_info[0] == 2:
ptr = ptr.asstring()
@@ -100,3 +105,71 @@ def convertQImageToArray(image):
array = array.reshape(image.height(), -1)[:, :image.width() * 3]
array.shape = image.height(), image.width(), 3
return array
+
+
+class _QtExecutor(qt.QObject):
+ """Executor of tasks in Qt main thread"""
+
+ __sigSubmit = qt.Signal(Future, object, tuple, dict)
+ """Signal used to run tasks."""
+
+ def __init__(self):
+ super(_QtExecutor, self).__init__(parent=None)
+
+ # Makes sure the executor lives in the main thread
+ app = qt.QApplication.instance()
+ assert app is not None
+ mainThread = app.thread()
+ if self.thread() != mainThread:
+ self.moveToThread(mainThread)
+
+ self.__sigSubmit.connect(self.__run)
+
+ def submit(self, fn, *args, **kwargs):
+ """Submit fn(*args, **kwargs) to Qt main thread
+
+ :param callable fn: Function to call in main thread
+ :return: Future object to retrieve result
+ :rtype: concurrent.future.Future
+ """
+ future = Future()
+ self.__sigSubmit.emit(future, fn, args, kwargs)
+ return future
+
+ def __run(self, future, fn, args, kwargs):
+ """Run task in Qt main thread
+
+ :param concurrent.future.Future future:
+ :param callable fn: Function to run
+ :param tuple args: Arguments
+ :param dict kwargs: Keyword arguments
+ """
+ if not future.set_running_or_notify_cancel():
+ return
+
+ try:
+ result = fn(*args, **kwargs)
+ except BaseException as e:
+ future.set_exception(e)
+ else:
+ future.set_result(result)
+
+
+_executor = None
+"""QObject running the tasks in main thread"""
+
+
+def submitToQtMainThread(fn, *args, **kwargs):
+ """Run fn(*args, **kwargs) in Qt's main thread.
+
+ If not called from the main thread, this is run asynchronously.
+
+ :param callable fn: Function to call in main thread.
+ :return: A future object to retrieve the result
+ :rtype: concurrent.future.Future
+ """
+ global _executor
+ if _executor is None: # Lazy-loading
+ _executor = _QtExecutor()
+
+ return _executor.submit(fn, *args, **kwargs)
diff --git a/silx/gui/console.py b/silx/gui/console.py
index 7812e2d..3c69419 100644
--- a/silx/gui/console.py
+++ b/silx/gui/console.py
@@ -129,7 +129,10 @@ if qtconsole is None:
IPython.external.qt_loaders.has_binding = has_binding
- from IPython.qt.console.rich_ipython_widget import RichIPythonWidget
+ try:
+ from IPython.qtconsole.rich_ipython_widget import RichIPythonWidget
+ except ImportError:
+ from IPython.qt.console.rich_ipython_widget import RichIPythonWidget
from IPython.qt.inprocess import QtInProcessKernelManager
diff --git a/silx/gui/data/DataViewer.py b/silx/gui/data/DataViewer.py
index 750c654..5e0b25e 100644
--- a/silx/gui/data/DataViewer.py
+++ b/silx/gui/data/DataViewer.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -32,10 +32,12 @@ from silx.gui.data.DataViews import _normalizeData
import logging
from silx.gui import qt
from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector
+from silx.utils import deprecation
+from silx.utils.property import classproperty
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "03/10/2017"
+__date__ = "26/02/2018"
_logger = logging.getLogger(__name__)
@@ -68,16 +70,65 @@ class DataViewer(qt.QFrame):
viewer.setVisible(True)
"""
- EMPTY_MODE = 0
- PLOT1D_MODE = 10
- PLOT2D_MODE = 20
- PLOT3D_MODE = 30
- RAW_MODE = 40
- RAW_ARRAY_MODE = 41
- RAW_RECORD_MODE = 42
- RAW_SCALAR_MODE = 43
- STACK_MODE = 50
- HDF5_MODE = 60
+ # TODO: Can be removed for silx 0.8
+ @classproperty
+ @deprecation.deprecated(replacement="DataViews.EMPTY_MODE", since_version="0.7", skip_backtrace_count=2)
+ def EMPTY_MODE(self):
+ return DataViews.EMPTY_MODE
+
+ # TODO: Can be removed for silx 0.8
+ @classproperty
+ @deprecation.deprecated(replacement="DataViews.PLOT1D_MODE", since_version="0.7", skip_backtrace_count=2)
+ def PLOT1D_MODE(self):
+ return DataViews.PLOT1D_MODE
+
+ # TODO: Can be removed for silx 0.8
+ @classproperty
+ @deprecation.deprecated(replacement="DataViews.PLOT2D_MODE", since_version="0.7", skip_backtrace_count=2)
+ def PLOT2D_MODE(self):
+ return DataViews.PLOT2D_MODE
+
+ # TODO: Can be removed for silx 0.8
+ @classproperty
+ @deprecation.deprecated(replacement="DataViews.PLOT3D_MODE", since_version="0.7", skip_backtrace_count=2)
+ def PLOT3D_MODE(self):
+ return DataViews.PLOT3D_MODE
+
+ # TODO: Can be removed for silx 0.8
+ @classproperty
+ @deprecation.deprecated(replacement="DataViews.RAW_MODE", since_version="0.7", skip_backtrace_count=2)
+ def RAW_MODE(self):
+ return DataViews.RAW_MODE
+
+ # TODO: Can be removed for silx 0.8
+ @classproperty
+ @deprecation.deprecated(replacement="DataViews.RAW_ARRAY_MODE", since_version="0.7", skip_backtrace_count=2)
+ def RAW_ARRAY_MODE(self):
+ return DataViews.RAW_ARRAY_MODE
+
+ # TODO: Can be removed for silx 0.8
+ @classproperty
+ @deprecation.deprecated(replacement="DataViews.RAW_RECORD_MODE", since_version="0.7", skip_backtrace_count=2)
+ def RAW_RECORD_MODE(self):
+ return DataViews.RAW_RECORD_MODE
+
+ # TODO: Can be removed for silx 0.8
+ @classproperty
+ @deprecation.deprecated(replacement="DataViews.RAW_SCALAR_MODE", since_version="0.7", skip_backtrace_count=2)
+ def RAW_SCALAR_MODE(self):
+ return DataViews.RAW_SCALAR_MODE
+
+ # TODO: Can be removed for silx 0.8
+ @classproperty
+ @deprecation.deprecated(replacement="DataViews.STACK_MODE", since_version="0.7", skip_backtrace_count=2)
+ def STACK_MODE(self):
+ return DataViews.STACK_MODE
+
+ # TODO: Can be removed for silx 0.8
+ @classproperty
+ @deprecation.deprecated(replacement="DataViews.HDF5_MODE", since_version="0.7", skip_backtrace_count=2)
+ def HDF5_MODE(self):
+ return DataViews.HDF5_MODE
displayedViewChanged = qt.Signal(object)
"""Emitted when the displayed view changes"""
@@ -129,7 +180,7 @@ class DataViewer(qt.QFrame):
"""Inisialize the available views"""
views = self.createDefaultViews(self.__stack)
self.__views = list(views)
- self.setDisplayMode(self.EMPTY_MODE)
+ self.setDisplayMode(DataViews.EMPTY_MODE)
def createDefaultViews(self, parent=None):
"""Create and returns available views which can be displayed by default
@@ -137,7 +188,7 @@ class DataViewer(qt.QFrame):
overwriten to provide a different set of viewers.
:param QWidget parent: QWidget parent of the views
- :rtype: list[silx.gui.data.DataViews.DataView]
+ :rtype: List[silx.gui.data.DataViews.DataView]
"""
viewClasses = [
DataViews._EmptyView,
@@ -262,6 +313,7 @@ class DataViewer(qt.QFrame):
def getViewFromModeId(self, modeId):
"""Returns the first available view which have the requested modeId.
+ Return None if modeId does not correspond to an existing view.
:param int modeId: Requested mode id
:rtype: silx.gui.data.DataViews.DataView
@@ -269,7 +321,7 @@ class DataViewer(qt.QFrame):
for view in self.__views:
if view.modeId() == modeId:
return view
- return view
+ return None
def setDisplayMode(self, modeId):
"""Set the displayed view using display mode.
@@ -278,13 +330,14 @@ class DataViewer(qt.QFrame):
:param int modeId: Display mode, one of
- - `EMPTY_MODE`: display nothing
- - `PLOT1D_MODE`: display the data as a curve
- - `PLOT2D_MODE`: display the data as an image
- - `PLOT3D_MODE`: display the data as an isosurface
- - `RAW_MODE`: display the data as a table
- - `STACK_MODE`: display the data as a stack of images
- - `HDF5_MODE`: display the data as a table
+ - `DataViews.EMPTY_MODE`: display nothing
+ - `DataViews.PLOT1D_MODE`: display the data as a curve
+ - `DataViews.IMAGE_MODE`: display the data as an image
+ - `DataViews.PLOT3D_MODE`: display the data as an isosurface
+ - `DataViews.RAW_MODE`: display the data as a table
+ - `DataViews.STACK_MODE`: display the data as a stack of images
+ - `DataViews.HDF5_MODE`: display the data as a table of HDF5 info
+ - `DataViews.NXDATA_MODE`: display the data as NXdata
"""
try:
view = self.getViewFromModeId(modeId)
@@ -377,21 +430,21 @@ class DataViewer(qt.QFrame):
on rendering.
:param object data: data which will be displayed
- :param list[view] available: List of available views, from highest
+ :param List[view] available: List of available views, from highest
priority to lowest.
:rtype: DataView
"""
hdf5View = self.getViewFromModeId(DataViewer.HDF5_MODE)
if hdf5View in available:
return hdf5View
- return self.getViewFromModeId(DataViewer.EMPTY_MODE)
+ return self.getViewFromModeId(DataViews.EMPTY_MODE)
def getDefaultViewFromAvailableViews(self, data, available):
"""Returns the default view which will be used according to available
views.
:param object data: data which will be displayed
- :param list[view] available: List of available views, from highest
+ :param List[view] available: List of available views, from highest
priority to lowest.
:rtype: DataView
"""
@@ -403,7 +456,7 @@ class DataViewer(qt.QFrame):
view = available[0]
else:
# else returns the empty view
- view = self.getViewFromModeId(DataViewer.EMPTY_MODE)
+ view = self.getViewFromModeId(DataViews.EMPTY_MODE)
return view
def __setCurrentAvailableViews(self, availableViews):
@@ -462,3 +515,51 @@ class DataViewer(qt.QFrame):
def displayMode(self):
"""Returns the current display mode"""
return self.__currentView.modeId()
+
+ def replaceView(self, modeId, newView):
+ """Replace one of the builtin data views with a custom view.
+ Return True in case of success, False in case of failure.
+
+ .. note::
+
+ This method must be called just after instantiation, before
+ the viewer is used.
+
+ :param int modeId: Unique mode ID identifying the DataView to
+ be replaced. One of:
+
+ - `DataViews.EMPTY_MODE`
+ - `DataViews.PLOT1D_MODE`
+ - `DataViews.IMAGE_MODE`
+ - `DataViews.PLOT2D_MODE`
+ - `DataViews.COMPLEX_IMAGE_MODE`
+ - `DataViews.PLOT3D_MODE`
+ - `DataViews.RAW_MODE`
+ - `DataViews.STACK_MODE`
+ - `DataViews.HDF5_MODE`
+ - `DataViews.NXDATA_MODE`
+ - `DataViews.NXDATA_INVALID_MODE`
+ - `DataViews.NXDATA_SCALAR_MODE`
+ - `DataViews.NXDATA_CURVE_MODE`
+ - `DataViews.NXDATA_XYVSCATTER_MODE`
+ - `DataViews.NXDATA_IMAGE_MODE`
+ - `DataViews.NXDATA_STACK_MODE`
+
+ :param DataViews.DataView newView: New data view
+ :return: True if replacement was successful, else False
+ """
+ assert isinstance(newView, DataViews.DataView)
+ isReplaced = False
+ for idx, view in enumerate(self.__views):
+ if view.modeId() == modeId:
+ self.__views[idx] = newView
+ isReplaced = True
+ break
+ elif isinstance(view, DataViews.CompositeDataView):
+ isReplaced = view.replaceView(modeId, newView)
+ if isReplaced:
+ break
+
+ if isReplaced:
+ self.__updateAvailableViews()
+ return isReplaced
diff --git a/silx/gui/data/DataViewerFrame.py b/silx/gui/data/DataViewerFrame.py
index e050d4a..89a9992 100644
--- a/silx/gui/data/DataViewerFrame.py
+++ b/silx/gui/data/DataViewerFrame.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -133,7 +133,7 @@ class DataViewerFrame(qt.QWidget):
overwriten to provide a different set of viewers.
:param QWidget parent: QWidget parent of the views
- :rtype: list[silx.gui.data.DataViews.DataView]
+ :rtype: List[silx.gui.data.DataViews.DataView]
"""
return self.__dataViewer._createDefaultViews(parent)
@@ -192,3 +192,16 @@ class DataViewerFrame(qt.QWidget):
- `ARRAY_MODE`: display the data as a table
"""
return self.__dataViewer.setDisplayMode(modeId)
+
+ def getViewFromModeId(self, modeId):
+ """See :meth:`DataViewer.getViewFromModeId`"""
+ return self.__dataViewer.getViewFromModeId(modeId)
+
+ def replaceView(self, modeId, newView):
+ """Replace one of the builtin data views with a custom view.
+ See :meth:`DataViewer.replaceView` for more documentation.
+
+ :param DataViews.DataView newView: New data view
+ :return: True if replacement was successful, else False
+ """
+ return self.__dataViewer.replaceView(modeId, newView)
diff --git a/silx/gui/data/DataViewerSelector.py b/silx/gui/data/DataViewerSelector.py
index 32cc636..35bbe99 100644
--- a/silx/gui/data/DataViewerSelector.py
+++ b/silx/gui/data/DataViewerSelector.py
@@ -29,12 +29,11 @@ from __future__ import division
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "26/01/2017"
+__date__ = "23/01/2018"
import weakref
import functools
from silx.gui import qt
-from silx.gui.data.DataViewer import DataViewer
import silx.utils.weakref
@@ -51,21 +50,36 @@ class DataViewerSelector(qt.QWidget):
self.__group = None
self.__buttons = {}
+ self.__buttonLayout = None
self.__buttonDummy = None
self.__dataViewer = None
+ # Create the fixed layout
+ self.setLayout(qt.QHBoxLayout())
+ layout = self.layout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ self.__buttonLayout = qt.QHBoxLayout()
+ self.__buttonLayout.setContentsMargins(0, 0, 0, 0)
+ layout.addLayout(self.__buttonLayout)
+ layout.addStretch(1)
+
if dataViewer is not None:
self.setDataViewer(dataViewer)
def __updateButtons(self):
if self.__group is not None:
self.__group.deleteLater()
+
+ # Clean up
+ for _, b in self.__buttons.items():
+ b.deleteLater()
+ if self.__buttonDummy is not None:
+ self.__buttonDummy.deleteLater()
+ self.__buttonDummy = None
self.__buttons = {}
self.__buttonDummy = None
self.__group = qt.QButtonGroup(self)
- self.setLayout(qt.QHBoxLayout())
- self.layout().setContentsMargins(0, 0, 0, 0)
if self.__dataViewer is None:
return
@@ -83,19 +97,17 @@ class DataViewerSelector(qt.QWidget):
weakMethod = silx.utils.weakref.WeakMethodProxy(self.__setDisplayedView)
callback = functools.partial(weakMethod, weakView)
button.clicked.connect(callback)
- self.layout().addWidget(button)
+ self.__buttonLayout.addWidget(button)
self.__group.addButton(button)
self.__buttons[view] = button
button = qt.QPushButton("Dummy")
button.setCheckable(True)
button.setVisible(False)
- self.layout().addWidget(button)
+ self.__buttonLayout.addWidget(button)
self.__group.addButton(button)
self.__buttonDummy = button
- self.layout().addStretch(1)
-
self.__updateButtonsVisibility()
self.__displayedViewChanged(self.__dataViewer.displayedView())
@@ -125,7 +137,7 @@ class DataViewerSelector(qt.QWidget):
self.__buttonDummy.setFlat(isFlat)
def __displayedViewChanged(self, view):
- """Called on displayed view changeS"""
+ """Called on displayed view changes"""
selectedButton = self.__buttons.get(view, self.__buttonDummy)
selectedButton.setChecked(True)
@@ -142,12 +154,22 @@ class DataViewerSelector(qt.QWidget):
return
self.__dataViewer.setDisplayedView(view)
+ def __checkAvailableButtons(self):
+ views = set(self.__dataViewer.availableViews())
+ if views == set(self.__buttons.keys()):
+ return
+ # Recreate all the buttons
+ # TODO: We dont have to create everything again
+ # We expect the views stay quite stable
+ self.__updateButtons()
+
def __updateButtonsVisibility(self):
"""Called on data changed"""
if self.__dataViewer is None:
for b in self.__buttons.values():
b.setVisible(False)
else:
+ self.__checkAvailableButtons()
availableViews = set(self.__dataViewer.currentAvailableViews())
for view, button in self.__buttons.items():
button.setVisible(view in availableViews)
diff --git a/silx/gui/data/DataViews.py b/silx/gui/data/DataViews.py
index 1ad997b..ef69441 100644
--- a/silx/gui/data/DataViews.py
+++ b/silx/gui/data/DataViews.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -35,11 +35,13 @@ from silx.gui import qt, icons
from silx.gui.data.TextFormatter import TextFormatter
from silx.io import nxdata
from silx.gui.hdf5 import H5Node
-from silx.io.nxdata import NXdata, get_attr_as_string
+from silx.io.nxdata import get_attr_as_string
+from silx.gui.plot.Colormap import Colormap
+from silx.gui.plot.actions.control import ColormapAction
__authors__ = ["V. Valls", "P. Knobel"]
__license__ = "MIT"
-__date__ = "03/10/2017"
+__date__ = "23/01/2018"
_logger = logging.getLogger(__name__)
@@ -47,7 +49,9 @@ _logger = logging.getLogger(__name__)
# DataViewer modes
EMPTY_MODE = 0
PLOT1D_MODE = 10
-PLOT2D_MODE = 20
+IMAGE_MODE = 20
+PLOT2D_MODE = 21
+COMPLEX_IMAGE_MODE = 22
PLOT3D_MODE = 30
RAW_MODE = 40
RAW_ARRAY_MODE = 41
@@ -56,6 +60,13 @@ RAW_SCALAR_MODE = 43
RAW_HEXA_MODE = 44
STACK_MODE = 50
HDF5_MODE = 60
+NXDATA_MODE = 70
+NXDATA_INVALID_MODE = 71
+NXDATA_SCALAR_MODE = 72
+NXDATA_CURVE_MODE = 73
+NXDATA_XYVSCATTER_MODE = 74
+NXDATA_IMAGE_MODE = 75
+NXDATA_STACK_MODE = 76
def _normalizeData(data):
@@ -77,7 +88,7 @@ def _normalizeComplex(data):
absolute value.
Else returns the input data."""
if hasattr(data, "dtype"):
- isComplex = numpy.issubdtype(data.dtype, numpy.complex)
+ isComplex = numpy.issubdtype(data.dtype, numpy.complexfloating)
else:
isComplex = isinstance(data, numbers.Complex)
if isComplex:
@@ -97,7 +108,7 @@ class DataInfo(object):
self.isComplex = False
self.isBoolean = False
self.isRecord = False
- self.isNXdata = False
+ self.hasNXdata = False
self.shape = tuple()
self.dim = 0
self.size = 0
@@ -105,9 +116,10 @@ class DataInfo(object):
if data is None:
return
- if silx.io.is_group(data) and nxdata.is_valid_nxdata(data):
- self.isNXdata = True
- nxd = nxdata.NXdata(data)
+ if silx.io.is_group(data):
+ nxd = nxdata.get_default(data)
+ if nxd is not None:
+ self.hasNXdata = True
if isinstance(data, numpy.ndarray):
self.isArray = True
@@ -121,7 +133,7 @@ class DataInfo(object):
self.interpretation = get_attr_as_string(data, "interpretation")
else:
self.interpretation = None
- elif self.isNXdata:
+ elif self.hasNXdata:
self.interpretation = nxd.interpretation
else:
self.interpretation = None
@@ -132,12 +144,12 @@ class DataInfo(object):
self.isVoid = data.dtype.fields is None
self.isNumeric = numpy.issubdtype(data.dtype, numpy.number)
self.isRecord = data.dtype.fields is not None
- self.isComplex = numpy.issubdtype(data.dtype, numpy.complex)
+ self.isComplex = numpy.issubdtype(data.dtype, numpy.complexfloating)
self.isBoolean = numpy.issubdtype(data.dtype, numpy.bool_)
- elif self.isNXdata:
+ elif self.hasNXdata:
self.isNumeric = numpy.issubdtype(nxd.signal.dtype,
numpy.number)
- self.isComplex = numpy.issubdtype(nxd.signal.dtype, numpy.complex)
+ self.isComplex = numpy.issubdtype(nxd.signal.dtype, numpy.complexfloating)
self.isBoolean = numpy.issubdtype(nxd.signal.dtype, numpy.bool_)
else:
self.isNumeric = isinstance(data, numbers.Number)
@@ -147,7 +159,7 @@ class DataInfo(object):
if hasattr(data, "shape"):
self.shape = data.shape
- elif self.isNXdata:
+ elif self.hasNXdata:
self.shape = nxd.signal.shape
else:
self.shape = tuple()
@@ -172,6 +184,12 @@ class DataView(object):
"""Priority returned when the requested data can't be displayed by the
view."""
+ _defaultColormap = None
+ """Store a default colormap shared with all the views"""
+
+ _defaultColorDialog = None
+ """Store a default color dialog shared with all the views"""
+
def __init__(self, parent, modeId=None, icon=None, label=None):
"""Constructor
@@ -187,6 +205,32 @@ class DataView(object):
icon = qt.QIcon()
self.__icon = icon
+ @staticmethod
+ def defaultColormap():
+ """Returns a shared colormap as default for all the views.
+
+ :rtype: Colormap
+ """
+ if DataView._defaultColormap is None:
+ DataView._defaultColormap = Colormap(name="viridis")
+ return DataView._defaultColormap
+
+ @staticmethod
+ def defaultColorDialog():
+ """Returns a shared color dialog as default for all the views.
+
+ :rtype: ColorDialog
+ """
+ if DataView._defaultColorDialog is None:
+ DataView._defaultColorDialog = ColormapAction._createDialog(qt.QApplication.instance().activeWindow())
+ return DataView._defaultColorDialog
+
+ @staticmethod
+ def _cleanUpCache():
+ """Clean up the cache. Needed for tests"""
+ DataView._defaultColormap = None
+ DataView._defaultColorDialog = None
+
def icon(self):
"""Returns the default icon"""
return self.__icon
@@ -305,6 +349,13 @@ class CompositeDataView(DataView):
"""Add a new dataview to the available list."""
self.__views[dataView] = None
+ def availableViews(self):
+ """Returns the list of registered views
+
+ :rtype: List[DataView]
+ """
+ return list(self.__views.keys())
+
def getBestView(self, data, info):
"""Returns the best view according to priorities."""
views = [(v.getDataPriority(data, info), v) for v in self.__views.keys()]
@@ -374,6 +425,38 @@ class CompositeDataView(DataView):
else:
return view.getDataPriority(data, info)
+ def replaceView(self, modeId, newView):
+ """Replace a data view with a custom view.
+ Return True in case of success, False in case of failure.
+
+ .. note::
+
+ This method must be called just after instantiation, before
+ the viewer is used.
+
+ :param int modeId: Unique mode ID identifying the DataView to
+ be replaced.
+ :param DataViews.DataView newView: New data view
+ :return: True if replacement was successful, else False
+ """
+ oldView = None
+ for view in self.__views:
+ if view.modeId() == modeId:
+ oldView = view
+ break
+ elif isinstance(view, CompositeDataView):
+ # recurse
+ if view.replaceView(modeId, newView):
+ return True
+ if oldView is None:
+ return False
+
+ # replace oldView with new view in dict
+ self.__views = OrderedDict(
+ (newView, None) if view is oldView else (view, idx) for
+ view, idx in self.__views.items())
+ return True
+
class _EmptyView(DataView):
"""Dummy view to display nothing"""
@@ -457,6 +540,8 @@ class _Plot2dView(DataView):
def createWidget(self, parent):
from silx.gui import plot
widget = plot.Plot2D(parent=parent)
+ widget.setDefaultColormap(self.defaultColormap())
+ widget.getColormapAction().setColorDialog(self.defaultColorDialog())
widget.getIntensityHistogramAction().setVisible(True)
widget.setKeepDataAspectRatio(True)
widget.getXAxis().setLabel('X')
@@ -582,13 +667,18 @@ class _ComplexImageView(DataView):
def __init__(self, parent):
super(_ComplexImageView, self).__init__(
parent=parent,
- modeId=PLOT2D_MODE,
+ modeId=COMPLEX_IMAGE_MODE,
label="Complex Image",
icon=icons.getQIcon("view-2d"))
def createWidget(self, parent):
from silx.gui.plot.ComplexImageView import ComplexImageView
widget = ComplexImageView(parent=parent)
+ widget.setColormap(self.defaultColormap(), mode=ComplexImageView.Mode.ABSOLUTE)
+ widget.setColormap(self.defaultColormap(), mode=ComplexImageView.Mode.SQUARE_AMPLITUDE)
+ widget.setColormap(self.defaultColormap(), mode=ComplexImageView.Mode.REAL)
+ widget.setColormap(self.defaultColormap(), mode=ComplexImageView.Mode.IMAGINARY)
+ widget.getPlot().getColormapAction().setColorDialog(self.defaultColorDialog())
widget.getPlot().getIntensityHistogramAction().setVisible(True)
widget.getPlot().setKeepDataAspectRatio(True)
widget.getXAxis().setLabel('X')
@@ -681,6 +771,8 @@ class _StackView(DataView):
def createWidget(self, parent):
from silx.gui import plot
widget = plot.StackView(parent=parent)
+ widget.setColormap(self.defaultColormap())
+ widget.getPlot().getColormapAction().setColorDialog(self.defaultColorDialog())
widget.setKeepDataAspectRatio(True)
widget.setLabels(self.axesNames(None, None))
# hide default option panel
@@ -699,6 +791,8 @@ class _StackView(DataView):
def setData(self, data):
data = self.normalizeData(data)
self.getWidget().setStack(stack=data, reset=self.__resetZoomNextTime)
+ # Override the colormap, while setStack overwrite it
+ self.getWidget().setColormap(self.defaultColormap())
self.__resetZoomNextTime = False
def axesNames(self, data, info):
@@ -736,7 +830,11 @@ class _ScalarView(DataView):
d = self.normalizeData(data)
if silx.io.is_dataset(d):
d = d[()]
- text = self.__formatter.toString(d, data.dtype)
+ dtype = None
+ if data is not None:
+ if hasattr(data, "dtype"):
+ dtype = data.dtype
+ text = self.__formatter.toString(d, dtype)
self.getWidget().setText(text)
def axesNames(self, data, info):
@@ -891,18 +989,111 @@ class _ImageView(CompositeDataView):
def __init__(self, parent):
super(_ImageView, self).__init__(
parent=parent,
- modeId=PLOT2D_MODE,
+ modeId=IMAGE_MODE,
label="Image",
icon=icons.getQIcon("view-2d"))
self.addView(_ComplexImageView(parent))
self.addView(_Plot2dView(parent))
+class _InvalidNXdataView(DataView):
+ """DataView showing a simple label with an error message
+ to inform that a group with @NX_class=NXdata cannot be
+ interpreted by any NXDataview."""
+ def __init__(self, parent):
+ DataView.__init__(self, parent,
+ modeId=NXDATA_INVALID_MODE)
+ self._msg = ""
+
+ def createWidget(self, parent):
+ widget = qt.QLabel(parent)
+ widget.setWordWrap(True)
+ widget.setStyleSheet("QLabel { color : red; }")
+ return widget
+
+ def axesNames(self, data, info):
+ return []
+
+ def clear(self):
+ self.getWidget().setText("")
+
+ def setData(self, data):
+ self.getWidget().setText(self._msg)
+
+ def getDataPriority(self, data, info):
+ data = self.normalizeData(data)
+ if silx.io.is_group(data):
+ nxd = nxdata.get_default(data)
+ nx_class = get_attr_as_string(data, "NX_class")
+
+ if nxd is None:
+ if nx_class == "NXdata":
+ # invalid: could not even be parsed by NXdata
+ self._msg = "Group has @NX_class = NXdata, but could not be interpreted"
+ self._msg += " as valid NXdata."
+ return 100
+ elif nx_class == "NXentry":
+ if "default" not in data.attrs:
+ # no link to NXdata, no problem
+ return DataView.UNSUPPORTED
+ self._msg = "NXentry group provides a @default attribute,"
+ default_nxdata_name = data.attrs["default"]
+ if default_nxdata_name not in data:
+ self._msg += " but no corresponding NXdata group exists."
+ elif get_attr_as_string(data[default_nxdata_name], "NX_class") != "NXdata":
+ self._msg += " but the corresponding item is not a "
+ self._msg += "NXdata group."
+ else:
+ self._msg += " but the corresponding NXdata seems to be"
+ self._msg += " malformed."
+ return 100
+ elif nx_class == "NXroot" or silx.io.is_file(data):
+ if "default" not in data.attrs:
+ # no link to NXentry, no problem
+ return DataView.UNSUPPORTED
+ default_entry_name = data.attrs["default"]
+ if default_entry_name not in data:
+ # this is a problem, but not NXdata related
+ return DataView.UNSUPPORTED
+ default_entry = data[default_entry_name]
+ if "default" not in default_entry.attrs:
+ # no NXdata specified, no problemo
+ return DataView.UNSUPPORTED
+ default_nxdata_name = default_entry.attrs["default"]
+ self._msg = "NXroot group provides a @default attribute "
+ self._msg += "pointing to a NXentry which defines its own "
+ self._msg += "@default attribute, "
+ if default_nxdata_name not in default_entry:
+ self._msg += " but no corresponding NXdata group exists."
+ elif get_attr_as_string(default_entry[default_nxdata_name],
+ "NX_class") != "NXdata":
+ self._msg += " but the corresponding item is not a "
+ self._msg += "NXdata group."
+ else:
+ self._msg += " but the corresponding NXdata seems to be"
+ self._msg += " malformed."
+ return 100
+ else:
+ # Not pretending to be NXdata, no problem
+ return DataView.UNSUPPORTED
+
+ is_scalar = nxd.signal_is_0d or nxd.interpretation in ["scalar", "scaler"]
+ if not (is_scalar or nxd.is_curve or nxd.is_x_y_value_scatter or
+ nxd.is_image or nxd.is_stack):
+ # invalid: cannot be plotted by any widget (I cannot imagine a case)
+ self._msg = "NXdata seems valid, but cannot be displayed "
+ self._msg += "by any existing plot widget."
+ return 100
+
+ return DataView.UNSUPPORTED
+
+
class _NXdataScalarView(DataView):
"""DataView using a table view for displaying NXdata scalars:
0-D signal or n-D signal with *@interpretation=scalar*"""
def __init__(self, parent):
- DataView.__init__(self, parent)
+ DataView.__init__(self, parent,
+ modeId=NXDATA_SCALAR_MODE)
def createWidget(self, parent):
from silx.gui.data.ArrayTableWidget import ArrayTableWidget
@@ -919,14 +1110,17 @@ class _NXdataScalarView(DataView):
def setData(self, data):
data = self.normalizeData(data)
- signal = NXdata(data).signal
+ # data could be a NXdata or an NXentry
+ nxd = nxdata.get_default(data)
+ signal = nxd.signal
self.getWidget().setArrayData(signal,
labels=True)
def getDataPriority(self, data, info):
data = self.normalizeData(data)
- if info.isNXdata:
- nxd = NXdata(data)
+
+ if info.hasNXdata:
+ nxd = nxdata.get_default(data)
if nxd.signal_is_0d or nxd.interpretation in ["scalar", "scaler"]:
return 100
return DataView.UNSUPPORTED
@@ -940,7 +1134,8 @@ class _NXdataCurveView(DataView):
a 1-D signal with one axis whose values are not monotonically increasing.
"""
def __init__(self, parent):
- DataView.__init__(self, parent)
+ DataView.__init__(self, parent,
+ modeId=NXDATA_CURVE_MODE)
def createWidget(self, parent):
from silx.gui.data.NXdataWidgets import ArrayCurvePlot
@@ -956,29 +1151,34 @@ class _NXdataCurveView(DataView):
def setData(self, data):
data = self.normalizeData(data)
- nxd = NXdata(data)
- signal_name = get_attr_as_string(data, "signal")
- group_name = data.name
+ nxd = nxdata.get_default(data)
+ signals_names = [nxd.signal_name] + nxd.auxiliary_signals_names
if nxd.axes_dataset_names[-1] is not None:
x_errors = nxd.get_axis_errors(nxd.axes_dataset_names[-1])
else:
x_errors = None
- self.getWidget().setCurveData(nxd.signal, nxd.axes[-1],
- yerror=nxd.errors, xerror=x_errors,
- ylabel=signal_name, xlabel=nxd.axes_names[-1],
- title="NXdata group " + group_name)
+ # this fix is necessary until the next release of PyMca (5.2.3 or 5.3.0)
+ # see https://github.com/vasole/pymca/issues/144 and https://github.com/vasole/pymca/pull/145
+ if not hasattr(self.getWidget(), "setCurvesData") and \
+ hasattr(self.getWidget(), "setCurveData"):
+ _logger.warning("Using deprecated ArrayCurvePlot API, "
+ "without support of auxiliary signals")
+ self.getWidget().setCurveData(nxd.signal, nxd.axes[-1],
+ yerror=nxd.errors, xerror=x_errors,
+ ylabel=nxd.signal_name, xlabel=nxd.axes_names[-1],
+ title=nxd.title or nxd.signal_name)
+ return
+
+ self.getWidget().setCurvesData([nxd.signal] + nxd.auxiliary_signals, nxd.axes[-1],
+ yerror=nxd.errors, xerror=x_errors,
+ ylabels=signals_names, xlabel=nxd.axes_names[-1],
+ title=nxd.title or signals_names[0])
def getDataPriority(self, data, info):
data = self.normalizeData(data)
- if info.isNXdata:
- nxd = NXdata(data)
- if nxd.is_x_y_value_scatter or nxd.is_unsupported_scatter:
- return DataView.UNSUPPORTED
- if nxd.signal_is_1d and \
- not nxd.interpretation in ["scalar", "scaler"]:
- return 100
- if nxd.interpretation == "spectrum":
+ if info.hasNXdata:
+ if nxdata.get_default(data).is_curve:
return 100
return DataView.UNSUPPORTED
@@ -987,11 +1187,12 @@ class _NXdataXYVScatterView(DataView):
"""DataView using a Plot1D for displaying NXdata 3D scatters as
a scatter of coloured points (1-D signal with 2 axes)"""
def __init__(self, parent):
- DataView.__init__(self, parent)
+ DataView.__init__(self, parent,
+ modeId=NXDATA_XYVSCATTER_MODE)
def createWidget(self, parent):
- from silx.gui.data.NXdataWidgets import ArrayCurvePlot
- widget = ArrayCurvePlot(parent)
+ from silx.gui.data.NXdataWidgets import XYVScatterPlot
+ widget = XYVScatterPlot(parent)
return widget
def axesNames(self, data, info):
@@ -1003,10 +1204,7 @@ class _NXdataXYVScatterView(DataView):
def setData(self, data):
data = self.normalizeData(data)
- nxd = NXdata(data)
- signal_name = get_attr_as_string(data, "signal")
- # signal_errors = nx.errors # not supported
- group_name = data.name
+ nxd = nxdata.get_default(data)
x_axis, y_axis = nxd.axes[-2:]
x_label, y_label = nxd.axes_names[-2:]
@@ -1020,16 +1218,18 @@ class _NXdataXYVScatterView(DataView):
else:
y_errors = None
- self.getWidget().setCurveData(y_axis, x_axis, values=nxd.signal,
- yerror=y_errors, xerror=x_errors,
- ylabel=signal_name, xlabel=x_label,
- title="NXdata group " + group_name)
+ self.getWidget().setScattersData(y_axis, x_axis, values=[nxd.signal] + nxd.auxiliary_signals,
+ yerror=y_errors, xerror=x_errors,
+ ylabel=y_label, xlabel=x_label,
+ title=nxd.title,
+ scatter_titles=[nxd.signal_name] + nxd.auxiliary_signals_names)
def getDataPriority(self, data, info):
data = self.normalizeData(data)
- if info.isNXdata:
- if NXdata(data).is_x_y_value_scatter:
+ if info.hasNXdata:
+ if nxdata.get_default(data).is_x_y_value_scatter:
return 100
+
return DataView.UNSUPPORTED
@@ -1037,11 +1237,14 @@ class _NXdataImageView(DataView):
"""DataView using a Plot2D for displaying NXdata images:
2-D signal or n-D signals with *@interpretation=spectrum*."""
def __init__(self, parent):
- DataView.__init__(self, parent)
+ DataView.__init__(self, parent,
+ modeId=NXDATA_IMAGE_MODE)
def createWidget(self, parent):
from silx.gui.data.NXdataWidgets import ArrayImagePlot
widget = ArrayImagePlot(parent)
+ widget.getPlot().setDefaultColormap(self.defaultColormap())
+ widget.getPlot().getColormapAction().setColorDialog(self.defaultColorDialog())
return widget
def axesNames(self, data, info):
@@ -1053,36 +1256,41 @@ class _NXdataImageView(DataView):
def setData(self, data):
data = self.normalizeData(data)
- nxd = NXdata(data)
- signal_name = get_attr_as_string(data, "signal")
- group_name = data.name
- y_axis, x_axis = nxd.axes[-2:]
- y_label, x_label = nxd.axes_names[-2:]
+ nxd = nxdata.get_default(data)
+ isRgba = nxd.interpretation == "rgba-image"
+
+ # last two axes are Y & X
+ img_slicing = slice(-2, None) if not isRgba else slice(-3, -1)
+ y_axis, x_axis = nxd.axes[img_slicing]
+ y_label, x_label = nxd.axes_names[img_slicing]
self.getWidget().setImageData(
- nxd.signal, x_axis=x_axis, y_axis=y_axis,
- signal_name=signal_name, xlabel=x_label, ylabel=y_label,
- title="NXdata group %s: %s" % (group_name, signal_name))
+ [nxd.signal] + nxd.auxiliary_signals,
+ x_axis=x_axis, y_axis=y_axis,
+ signals_names=[nxd.signal_name] + nxd.auxiliary_signals_names,
+ xlabel=x_label, ylabel=y_label,
+ title=nxd.title, isRgba=isRgba)
def getDataPriority(self, data, info):
data = self.normalizeData(data)
- if info.isNXdata:
- nxd = NXdata(data)
- if nxd.signal_is_2d:
- if nxd.interpretation not in ["scalar", "spectrum", "scaler"]:
- return 100
- if nxd.interpretation == "image":
+
+ if info.hasNXdata:
+ if nxdata.get_default(data).is_image:
return 100
+
return DataView.UNSUPPORTED
class _NXdataStackView(DataView):
def __init__(self, parent):
- DataView.__init__(self, parent)
+ DataView.__init__(self, parent,
+ modeId=NXDATA_STACK_MODE)
def createWidget(self, parent):
from silx.gui.data.NXdataWidgets import ArrayStackPlot
widget = ArrayStackPlot(parent)
+ widget.getStackView().setColormap(self.defaultColormap())
+ widget.getStackView().getPlot().getColormapAction().setColorDialog(self.defaultColorDialog())
return widget
def axesNames(self, data, info):
@@ -1094,26 +1302,27 @@ class _NXdataStackView(DataView):
def setData(self, data):
data = self.normalizeData(data)
- nxd = NXdata(data)
- signal_name = get_attr_as_string(data, "signal")
- group_name = data.name
+ nxd = nxdata.get_default(data)
+ signal_name = nxd.signal_name
z_axis, y_axis, x_axis = nxd.axes[-3:]
z_label, y_label, x_label = nxd.axes_names[-3:]
+ title = nxd.title or signal_name
- self.getWidget().setStackData(
+ widget = self.getWidget()
+ widget.setStackData(
nxd.signal, x_axis=x_axis, y_axis=y_axis, z_axis=z_axis,
signal_name=signal_name,
xlabel=x_label, ylabel=y_label, zlabel=z_label,
- title="NXdata group %s: %s" % (group_name, signal_name))
+ title=title)
+ # Override the colormap, while setStack overwrite it
+ widget.getStackView().setColormap(self.defaultColormap())
def getDataPriority(self, data, info):
data = self.normalizeData(data)
- if info.isNXdata:
- nxd = NXdata(data)
- if nxd.signal_ndim >= 3:
- if nxd.interpretation not in ["scalar", "scaler",
- "spectrum", "image"]:
- return 100
+ if info.hasNXdata:
+ if nxdata.get_default(data).is_stack:
+ return 100
+
return DataView.UNSUPPORTED
@@ -1124,8 +1333,10 @@ class _NXdataView(CompositeDataView):
super(_NXdataView, self).__init__(
parent=parent,
label="NXdata",
+ modeId=NXDATA_MODE,
icon=icons.getQIcon("view-nexus"))
+ self.addView(_InvalidNXdataView(parent))
self.addView(_NXdataScalarView(parent))
self.addView(_NXdataCurveView(parent))
self.addView(_NXdataXYVScatterView(parent))
diff --git a/silx/gui/data/Hdf5TableView.py b/silx/gui/data/Hdf5TableView.py
index ba737e3..e4a0747 100644
--- a/silx/gui/data/Hdf5TableView.py
+++ b/silx/gui/data/Hdf5TableView.py
@@ -30,7 +30,7 @@ from __future__ import division
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "29/09/2017"
+__date__ = "10/10/2017"
import functools
import os.path
@@ -330,7 +330,7 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
self.__data.addHeaderRow(headerLabel="Data info")
- if h5py is not None and hasattr(obj, "id"):
+ if h5py is not None and hasattr(obj, "id") and hasattr(obj.id, "get_type"):
# display the HDF5 type
self.__data.addHeaderValueRow("HDF5 type", self.__formatHdf5Type)
self.__data.addHeaderValueRow("dtype", self.__formatDType)
@@ -345,21 +345,22 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
# h5py also expose fletcher32 and shuffle attributes, but it is also
# part of the filters
if hasattr(obj, "shape") and hasattr(obj, "id"):
- dcpl = obj.id.get_create_plist()
- if dcpl.get_nfilters() > 0:
- self.__data.addHeaderRow(headerLabel="Compression info")
- pos = _CellData(value="Position", isHeader=True)
- hdf5id = _CellData(value="HDF5 ID", isHeader=True)
- name = _CellData(value="Name", isHeader=True)
- options = _CellData(value="Options", isHeader=True)
- self.__data.addRow(pos, hdf5id, name, options)
- for index in range(dcpl.get_nfilters()):
- callback = lambda index, dataIndex, x: self.__get_filter_info(x, index)[dataIndex]
- pos = _CellData(value=functools.partial(callback, index, 0))
- hdf5id = _CellData(value=functools.partial(callback, index, 1))
- name = _CellData(value=functools.partial(callback, index, 2))
- options = _CellData(value=functools.partial(callback, index, 3))
- self.__data.addRow(pos, hdf5id, name, options)
+ if hasattr(obj.id, "get_create_plist"):
+ dcpl = obj.id.get_create_plist()
+ if dcpl.get_nfilters() > 0:
+ self.__data.addHeaderRow(headerLabel="Compression info")
+ pos = _CellData(value="Position", isHeader=True)
+ hdf5id = _CellData(value="HDF5 ID", isHeader=True)
+ name = _CellData(value="Name", isHeader=True)
+ options = _CellData(value="Options", isHeader=True)
+ self.__data.addRow(pos, hdf5id, name, options)
+ for index in range(dcpl.get_nfilters()):
+ callback = lambda index, dataIndex, x: self.__get_filter_info(x, index)[dataIndex]
+ pos = _CellData(value=functools.partial(callback, index, 0))
+ hdf5id = _CellData(value=functools.partial(callback, index, 1))
+ name = _CellData(value=functools.partial(callback, index, 2))
+ options = _CellData(value=functools.partial(callback, index, 3))
+ self.__data.addRow(pos, hdf5id, name, options)
if hasattr(obj, "attrs"):
if len(obj.attrs) > 0:
diff --git a/silx/gui/data/NXdataWidgets.py b/silx/gui/data/NXdataWidgets.py
index 7aaf3ad..ae2911d 100644
--- a/silx/gui/data/NXdataWidgets.py
+++ b/silx/gui/data/NXdataWidgets.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -26,13 +26,15 @@
"""
__authors__ = ["P. Knobel"]
__license__ = "MIT"
-__date__ = "27/06/2017"
+__date__ = "20/12/2017"
import numpy
from silx.gui import qt
from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector
from silx.gui.plot import Plot1D, Plot2D, StackView
+from silx.gui.plot.Colormap import Colormap
+from silx.gui.widgets.FrameBrowser import HorizontalSliderWithBrowser
from silx.math.calibration import ArrayCalibration, NoCalibration, LinearCalibration
@@ -60,83 +62,79 @@ class ArrayCurvePlot(qt.QWidget):
"""
super(ArrayCurvePlot, self).__init__(parent)
- self.__signal = None
- self.__signal_name = None
+ self.__signals = None
+ self.__signals_names = None
self.__signal_errors = None
self.__axis = None
self.__axis_name = None
- self.__axis_errors = None
+ self.__x_axis_errors = None
self.__values = None
- self.__first_curve_added = False
-
self._plot = Plot1D(self)
- self._plot.setDefaultColormap( # for scatters
- {"name": "viridis",
- "vmin": 0., "vmax": 1., # ignored (autoscale) but mandatory
- "normalization": "linear",
- "autoscale": True})
self.selectorDock = qt.QDockWidget("Data selector", self._plot)
# not closable
self.selectorDock.setFeatures(qt.QDockWidget.DockWidgetMovable |
- qt.QDockWidget.DockWidgetFloatable)
+ qt.QDockWidget.DockWidgetFloatable)
self._selector = NumpyAxesSelector(self.selectorDock)
self._selector.setNamedAxesSelectorVisibility(False)
self.__selector_is_connected = False
self.selectorDock.setWidget(self._selector)
self._plot.addTabbedDockWidget(self.selectorDock)
+ self._plot.sigActiveCurveChanged.connect(self._setYLabelFromActiveLegend)
+
layout = qt.QGridLayout()
layout.setContentsMargins(0, 0, 0, 0)
- layout.addWidget(self._plot, 0, 0)
+ layout.addWidget(self._plot, 0, 0)
self.setLayout(layout)
- def setCurveData(self, y, x=None, values=None,
- yerror=None, xerror=None,
- ylabel=None, xlabel=None, title=None):
+ def getPlot(self):
+ """Returns the plot used for the display
+
+ :rtype: Plot1D
+ """
+ return self._plot
+
+ def setCurvesData(self, ys, x=None,
+ yerror=None, xerror=None,
+ ylabels=None, xlabel=None, title=None):
"""
- :param y: dataset to be represented by the y (vertical) axis.
- For a scatter, this must be a 1D array and x and values must be
- 1-D arrays of the same size.
- In other cases, it can be a n-D array whose last dimension must
+ :param List[ndarray] ys: List of arrays to be represented by the y (vertical) axis.
+ It can be multiple n-D array whose last dimension must
have the same length as x (and values must be None)
- :param x: 1-D dataset used as the curve's x values. If provided,
+ :param ndarray x: 1-D dataset used as the curve's x values. If provided,
its lengths must be equal to the length of the last dimension of
``y`` (and equal to the length of ``value``, for a scatter plot).
- :param values: Values, to be provided for a x-y-value scatter plot.
- This will be used to compute the color map and assign colors
- to the points.
- :param yerror: 1-D dataset of errors for y, or None
- :param xerror: 1-D dataset of errors for x, or None
- :param ylabel: Label for Y axis
- :param xlabel: Label for X axis
- :param title: Graph title
+ :param ndarray yerror: Single array of errors for y (same shape), or None.
+ There can only be one array, and it applies to the first/main y
+ (no y errors for auxiliary_signals curves).
+ :param ndarray xerror: 1-D dataset of errors for x, or None
+ :param str ylabels: Labels for each curve's Y axis
+ :param str xlabel: Label for X axis
+ :param str title: Graph title
"""
- self.__signal = y
- self.__signal_name = ylabel or "Y"
+ self.__signals = ys
+ self.__signals_names = ylabels or (["Y"] * len(ys))
self.__signal_errors = yerror
self.__axis = x
self.__axis_name = xlabel
- self.__axis_errors = xerror
- self.__values = values
+ self.__x_axis_errors = xerror
if self.__selector_is_connected:
self._selector.selectionChanged.disconnect(self._updateCurve)
self.__selector_is_connected = False
- self._selector.setData(y)
- self._selector.setAxisNames([ylabel or "Y"])
+ self._selector.setData(ys[0])
+ self._selector.setAxisNames(["Y"])
- if len(y.shape) < 2:
+ if len(ys[0].shape) < 2:
self.selectorDock.hide()
else:
self.selectorDock.show()
self._plot.setGraphTitle(title or "")
- self._plot.getXAxis().setLabel(self.__axis_name or "X")
- self._plot.getYAxis().setLabel(self.__signal_name)
self._updateCurve()
if not self.__selector_is_connected:
@@ -144,52 +142,165 @@ class ArrayCurvePlot(qt.QWidget):
self.__selector_is_connected = True
def _updateCurve(self):
- y = self._selector.selectedData()
+ selection = self._selector.selection()
+ ys = [sig[selection] for sig in self.__signals]
+ y0 = ys[0]
+ len_y = len(y0)
x = self.__axis
if x is None:
- x = numpy.arange(len(y))
+ x = numpy.arange(len_y)
elif numpy.isscalar(x) or len(x) == 1:
# constant axis
- x = x * numpy.ones_like(y)
- elif len(x) == 2 and len(y) != 2:
+ x = x * numpy.ones_like(y0)
+ elif len(x) == 2 and len_y != 2:
# linear calibration a + b * x
- x = x[0] + x[1] * numpy.arange(len(y))
- legend = self.__signal_name + "["
- for sl in self._selector.selection():
- if sl == slice(None):
- legend += ":, "
- else:
- legend += str(sl) + ", "
- legend = legend[:-2] + "]"
- if self.__signal_errors is not None:
- y_errors = self.__signal_errors[self._selector.selection()]
- else:
- y_errors = None
+ x = x[0] + x[1] * numpy.arange(len_y)
- self._plot.remove(kind=("curve", "scatter"))
+ self._plot.remove(kind=("curve",))
- # values: x-y-v scatter
- if self.__values is not None:
- self._plot.addScatter(x, y, self.__values,
- legend=legend,
- xerror=self.__axis_errors,
- yerror=y_errors)
+ for i in range(len(self.__signals)):
+ legend = self.__signals_names[i]
- # x monotonically increasing or decreasiing: curve
- elif numpy.all(numpy.diff(x) > 0) or numpy.all(numpy.diff(x) < 0):
- self._plot.addCurve(x, y, legend=legend,
- xerror=self.__axis_errors,
+ # errors only supported for primary signal in NXdata
+ y_errors = None
+ if i == 0 and self.__signal_errors is not None:
+ y_errors = self.__signal_errors[self._selector.selection()]
+ self._plot.addCurve(x, ys[i], legend=legend,
+ xerror=self.__x_axis_errors,
yerror=y_errors)
+ if i == 0:
+ self._plot.setActiveCurve(legend)
- # scatter
- else:
- self._plot.addScatter(x, y, value=numpy.ones_like(y),
- legend=legend,
- xerror=self.__axis_errors,
- yerror=y_errors)
self._plot.resetZoom()
self._plot.getXAxis().setLabel(self.__axis_name)
- self._plot.getYAxis().setLabel(self.__signal_name)
+ self._plot.getYAxis().setLabel(self.__signals_names[0])
+
+ def _setYLabelFromActiveLegend(self, previous_legend, new_legend):
+ for ylabel in self.__signals_names:
+ if new_legend is not None and new_legend == ylabel:
+ self._plot.getYAxis().setLabel(ylabel)
+ break
+
+ def clear(self):
+ self._plot.clear()
+
+
+class XYVScatterPlot(qt.QWidget):
+ """
+ Widget for plotting one or more scatters
+ (with identical x, y coordinates).
+ """
+ def __init__(self, parent=None):
+ """
+
+ :param parent: Parent QWidget
+ """
+ super(XYVScatterPlot, self).__init__(parent)
+
+ self.__y_axis = None
+ """1D array"""
+ self.__y_axis_name = None
+ self.__values = None
+ """List of 1D arrays (for multiple scatters with identical
+ x, y coordinates)"""
+
+ self.__x_axis = None
+ self.__x_axis_name = None
+ self.__x_axis_errors = None
+ self.__y_axis = None
+ self.__y_axis_name = None
+ self.__y_axis_errors = None
+
+ self._plot = Plot1D(self)
+ self._plot.setDefaultColormap(Colormap(name="viridis",
+ vmin=None, vmax=None,
+ normalization=Colormap.LINEAR))
+
+ self._slider = HorizontalSliderWithBrowser(parent=self)
+ self._slider.setMinimum(0)
+ self._slider.setValue(0)
+ self._slider.valueChanged[int].connect(self._sliderIdxChanged)
+ self._slider.setToolTip("Select auxiliary signals")
+
+ layout = qt.QGridLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self._plot, 0, 0)
+ layout.addWidget(self._slider, 1, 0)
+
+ self.setLayout(layout)
+
+ def _sliderIdxChanged(self, value):
+ self._updateScatter()
+
+ def getPlot(self):
+ """Returns the plot used for the display
+
+ :rtype: Plot1D
+ """
+ return self._plot
+
+ def setScattersData(self, y, x, values,
+ yerror=None, xerror=None,
+ ylabel=None, xlabel=None,
+ title="", scatter_titles=None):
+ """
+
+ :param ndarray y: 1D array for y (vertical) coordinates.
+ :param ndarray x: 1D array for x coordinates.
+ :param List[ndarray] values: List of 1D arrays of values.
+ This will be used to compute the color map and assign colors
+ to the points. There should be as many arrays in the list as
+ scatters to be represented.
+ :param ndarray yerror: 1D array of errors for y (same shape), or None.
+ :param ndarray xerror: 1D array of errors for x, or None
+ :param str ylabel: Label for Y axis
+ :param str xlabel: Label for X axis
+ :param str title: Main graph title
+ :param List[str] scatter_titles: Subtitles (one per scatter)
+ """
+ self.__y_axis = y
+ self.__x_axis = x
+ self.__x_axis_name = xlabel or "X"
+ self.__y_axis_name = ylabel or "Y"
+ self.__x_axis_errors = xerror
+ self.__y_axis_errors = yerror
+ self.__values = values
+
+ self.__graph_title = title or ""
+ self.__scatter_titles = scatter_titles
+
+ self._slider.valueChanged[int].disconnect(self._sliderIdxChanged)
+ self._slider.setMaximum(len(values) - 1)
+ if len(values) > 1:
+ self._slider.show()
+ else:
+ self._slider.hide()
+ self._slider.setValue(0)
+ self._slider.valueChanged[int].connect(self._sliderIdxChanged)
+
+ self._updateScatter()
+
+ def _updateScatter(self):
+ x = self.__x_axis
+ y = self.__y_axis
+
+ self._plot.remove(kind=("scatter", ))
+
+ idx = self._slider.value()
+
+ title = ""
+ if self.__graph_title:
+ title += self.__graph_title + "\n" # main NXdata @title
+ title += self.__scatter_titles[idx] # scatter dataset name
+
+ self._plot.setGraphTitle(title)
+ self._plot.addScatter(x, y, self.__values[idx],
+ legend="scatter%d" % idx,
+ xerror=self.__x_axis_errors,
+ yerror=self.__y_axis_errors)
+ self._plot.resetZoom()
+ self._plot.getXAxis().setLabel(self.__x_axis_name)
+ self._plot.getYAxis().setLabel(self.__y_axis_name)
def clear(self):
self._plot.clear()
@@ -218,97 +329,117 @@ class ArrayImagePlot(qt.QWidget):
"""
super(ArrayImagePlot, self).__init__(parent)
- self.__signal = None
- self.__signal_name = None
+ self.__signals = None
+ self.__signals_names = None
self.__x_axis = None
self.__x_axis_name = None
self.__y_axis = None
self.__y_axis_name = None
self._plot = Plot2D(self)
- self._plot.setDefaultColormap(
- {"name": "viridis",
- "vmin": 0., "vmax": 1., # ignored (autoscale) but mandatory
- "normalization": "linear",
- "autoscale": True})
+ self._plot.setDefaultColormap(Colormap(name="viridis",
+ vmin=None, vmax=None,
+ normalization=Colormap.LINEAR))
self.selectorDock = qt.QDockWidget("Data selector", self._plot)
# not closable
self.selectorDock.setFeatures(qt.QDockWidget.DockWidgetMovable |
qt.QDockWidget.DockWidgetFloatable)
- self._legend = qt.QLabel(self)
self._selector = NumpyAxesSelector(self.selectorDock)
self._selector.setNamedAxesSelectorVisibility(False)
- self.__selector_is_connected = False
+ self._selector.selectionChanged.connect(self._updateImage)
+
+ self._auxSigSlider = HorizontalSliderWithBrowser(parent=self)
+ self._auxSigSlider.setMinimum(0)
+ self._auxSigSlider.setValue(0)
+ self._auxSigSlider.valueChanged[int].connect(self._sliderIdxChanged)
+ self._auxSigSlider.setToolTip("Select auxiliary signals")
layout = qt.QVBoxLayout()
layout.addWidget(self._plot)
- layout.addWidget(self._legend)
+ layout.addWidget(self._auxSigSlider)
self.selectorDock.setWidget(self._selector)
self._plot.addTabbedDockWidget(self.selectorDock)
self.setLayout(layout)
- def setImageData(self, signal,
+ def _sliderIdxChanged(self, value):
+ self._updateImage()
+
+ def getPlot(self):
+ """Returns the plot used for the display
+
+ :rtype: Plot2D
+ """
+ return self._plot
+
+ def setImageData(self, signals,
x_axis=None, y_axis=None,
- signal_name=None,
+ signals_names=None,
xlabel=None, ylabel=None,
- title=None):
+ title=None, isRgba=False):
"""
- :param signal: n-D dataset, whose last 2 dimensions are used as the
- image's values.
+ :param signals: list of n-D datasets, whose last 2 dimensions are used as the
+ image's values, or list of 3D datasets interpreted as RGBA image.
:param x_axis: 1-D dataset used as the image's x coordinates. If
provided, its lengths must be equal to the length of the last
dimension of ``signal``.
:param y_axis: 1-D dataset used as the image's y. If provided,
its lengths must be equal to the length of the 2nd to last
dimension of ``signal``.
- :param signal_name: Label used in the legend
+ :param signals_names: Names for each image, used as subtitle and legend.
:param xlabel: Label for X axis
:param ylabel: Label for Y axis
:param title: Graph title
+ :param isRgba: True if data is a 3D RGBA image
"""
- if self.__selector_is_connected:
- self._selector.selectionChanged.disconnect(self._updateImage)
- self.__selector_is_connected = False
+ self._selector.selectionChanged.disconnect(self._updateImage)
+ self._auxSigSlider.valueChanged.disconnect(self._sliderIdxChanged)
- self.__signal = signal
- self.__signal_name = signal_name or ""
+ self.__signals = signals
+ self.__signals_names = signals_names
self.__x_axis = x_axis
self.__x_axis_name = xlabel
self.__y_axis = y_axis
self.__y_axis_name = ylabel
+ self.__title = title
- self._selector.setData(signal)
- self._selector.setAxisNames([ylabel or "Y", xlabel or "X"])
+ self._selector.clear()
+ if not isRgba:
+ self._selector.setAxisNames(["Y", "X"])
+ img_ndim = 2
+ else:
+ self._selector.setAxisNames(["Y", "X", "RGB(A) channel"])
+ img_ndim = 3
+ self._selector.setData(signals[0])
- if len(signal.shape) < 3:
+ if len(signals[0].shape) <= img_ndim:
self.selectorDock.hide()
else:
self.selectorDock.show()
- self._plot.setGraphTitle(title or "")
- self._plot.getXAxis().setLabel(self.__x_axis_name or "X")
- self._plot.getYAxis().setLabel(self.__y_axis_name or "Y")
+ self._auxSigSlider.setMaximum(len(signals) - 1)
+ if len(signals) > 1:
+ self._auxSigSlider.show()
+ else:
+ self._auxSigSlider.hide()
+ self._auxSigSlider.setValue(0)
self._updateImage()
- if not self.__selector_is_connected:
- self._selector.selectionChanged.connect(self._updateImage)
- self.__selector_is_connected = True
+ self._selector.selectionChanged.connect(self._updateImage)
+ self._auxSigSlider.valueChanged.connect(self._sliderIdxChanged)
def _updateImage(self):
- legend = self.__signal_name + "["
- for sl in self._selector.selection():
- if sl == slice(None):
- legend += ":, "
- else:
- legend += str(sl) + ", "
- legend = legend[:-2] + "]"
- self._legend.setText("Displayed data: " + legend)
+ selection = self._selector.selection()
+ auxSigIdx = self._auxSigSlider.value()
+
+ legend = self.__signals_names[auxSigIdx]
+
+ images = [img[selection] for img in self.__signals]
+ image = images[auxSigIdx]
- img = self._selector.selectedData()
x_axis = self.__x_axis
y_axis = self.__y_axis
@@ -318,25 +449,25 @@ class ArrayImagePlot(qt.QWidget):
else:
if x_axis is None:
# no calibration
- x_axis = numpy.arange(img.shape[-1])
+ x_axis = numpy.arange(image.shape[1])
elif numpy.isscalar(x_axis) or len(x_axis) == 1:
# constant axis
- x_axis = x_axis * numpy.ones((img.shape[-1], ))
+ x_axis = x_axis * numpy.ones((image.shape[1], ))
elif len(x_axis) == 2:
# linear calibration
- x_axis = x_axis[0] * numpy.arange(img.shape[-1]) + x_axis[1]
+ x_axis = x_axis[0] * numpy.arange(image.shape[1]) + x_axis[1]
if y_axis is None:
- y_axis = numpy.arange(img.shape[-2])
+ y_axis = numpy.arange(image.shape[0])
elif numpy.isscalar(y_axis) or len(y_axis) == 1:
- y_axis = y_axis * numpy.ones((img.shape[-2], ))
+ y_axis = y_axis * numpy.ones((image.shape[0], ))
elif len(y_axis) == 2:
- y_axis = y_axis[0] * numpy.arange(img.shape[-2]) + y_axis[1]
+ y_axis = y_axis[0] * numpy.arange(image.shape[0]) + y_axis[1]
xcalib = ArrayCalibration(x_axis)
ycalib = ArrayCalibration(y_axis)
- self._plot.remove(kind=("scatter", "image"))
+ self._plot.remove(kind=("scatter", "image",))
if xcalib.is_affine() and ycalib.is_affine():
# regular image
xorigin, xscale = xcalib(0), xcalib.get_slope()
@@ -344,14 +475,22 @@ class ArrayImagePlot(qt.QWidget):
origin = (xorigin, yorigin)
scale = (xscale, yscale)
- self._plot.addImage(img, legend=legend,
+ self._plot.addImage(image, legend=legend,
origin=origin, scale=scale)
else:
scatterx, scattery = numpy.meshgrid(x_axis, y_axis)
+ # fixme: i don't think this can handle "irregular" RGBA images
self._plot.addScatter(numpy.ravel(scatterx),
numpy.ravel(scattery),
- numpy.ravel(img),
+ numpy.ravel(image),
legend=legend)
+
+ title = ""
+ if self.__title:
+ title += self.__title
+ if not title.strip().endswith(self.__signals_names[auxSigIdx]):
+ title += "\n" + self.__signals_names[auxSigIdx]
+ self._plot.setGraphTitle(title)
self._plot.getXAxis().setLabel(self.__x_axis_name)
self._plot.getYAxis().setLabel(self.__y_axis_name)
self._plot.resetZoom()
@@ -408,6 +547,13 @@ class ArrayStackPlot(qt.QWidget):
self.setLayout(layout)
+ def getStackView(self):
+ """Returns the plot used for the display
+
+ :rtype: StackView
+ """
+ return self._stack_view
+
def setStackData(self, signal,
x_axis=None, y_axis=None, z_axis=None,
signal_name=None,
@@ -446,7 +592,7 @@ class ArrayStackPlot(qt.QWidget):
self.__z_axis_name = zlabel
self._selector.setData(signal)
- self._selector.setAxisNames([ylabel or "Y", xlabel or "X", zlabel or "Z"])
+ self._selector.setAxisNames(["Y", "X", "Z"])
self._stack_view.setGraphTitle(title or "")
# by default, the z axis is the image position (dimension not plotted)
diff --git a/silx/gui/data/NumpyAxesSelector.py b/silx/gui/data/NumpyAxesSelector.py
index f4641da..4530aa9 100644
--- a/silx/gui/data/NumpyAxesSelector.py
+++ b/silx/gui/data/NumpyAxesSelector.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -29,7 +29,7 @@ from __future__ import division
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "16/01/2017"
+__date__ = "29/01/2018"
import numpy
import functools
@@ -133,7 +133,7 @@ class _Axis(qt.QWidget):
def setAxisNames(self, axesNames):
"""Set the available list of names for the axis.
- :param list[str] axesNames: List of available names
+ :param List[str] axesNames: List of available names
"""
self.__axes.clear()
previous = self.__axes.blockSignals(True)
@@ -146,7 +146,7 @@ class _Axis(qt.QWidget):
def setCustomAxis(self, axesNames):
"""Set the available list of named axis which can be set to a value.
- :param list[str] axesNames: List of customable axis names
+ :param List[str] axesNames: List of customable axis names
"""
self.__customAxisNames = set(axesNames)
self.__updateSliderVisibility()
@@ -258,9 +258,12 @@ class NumpyAxesSelector(qt.QWidget):
The size of the list will constrain the dimension of the resulting
array.
- :param list[str] axesNames: List of string identifying axis names
+ :param List[str] axesNames: List of distinct strings identifying axis names
"""
self.__axisNames = list(axesNames)
+ assert len(set(self.__axisNames)) == len(self.__axisNames),\
+ "Non-unique axes names: %s" % self.__axisNames
+
delta = len(self.__axis) - len(self.__axisNames)
if delta < 0:
delta = 0
@@ -277,7 +280,7 @@ class NumpyAxesSelector(qt.QWidget):
def setCustomAxis(self, axesNames):
"""Set the available list of named axis which can be set to a value.
- :param list[str] axesNames: List of customable axis names
+ :param List[str] axesNames: List of customable axis names
"""
self.__customAxisNames = set(axesNames)
for axis in self.__axis:
@@ -415,13 +418,20 @@ class NumpyAxesSelector(qt.QWidget):
else:
selection.append(slice(None))
axisNames.append(name)
-
self.__selection = tuple(selection)
# get a view with few fixed dimensions
# with a h5py dataset, it create a copy
# TODO we can reuse the same memory in case of a copy
view = self.__data[self.__selection]
+ if set(self.__axisNames) - set(axisNames) != set([]):
+ # Not all the expected axis are there
+ if self.__selectedData is not None:
+ self.__selectedData = None
+ self.__selection = tuple()
+ self.selectionChanged.emit()
+ return
+
# order axis as expected
source = []
destination = []
diff --git a/silx/gui/data/TextFormatter.py b/silx/gui/data/TextFormatter.py
index 37e1f48..332625c 100644
--- a/silx/gui/data/TextFormatter.py
+++ b/silx/gui/data/TextFormatter.py
@@ -27,12 +27,13 @@ data module to format data as text in the same way."""
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "27/09/2017"
+__date__ = "13/12/2017"
import numpy
import numbers
from silx.third_party import six
from silx.gui import qt
+import logging
try:
import h5py
@@ -40,6 +41,9 @@ except ImportError:
h5py = None
+_logger = logging.getLogger(__name__)
+
+
class TextFormatter(qt.QObject):
"""Formatter to convert data to string.
@@ -203,8 +207,9 @@ class TextFormatter(qt.QObject):
data = [ord(d) for d in data.item()]
else:
data = data.item().astype(numpy.uint8)
- else:
+ elif six.PY2:
data = [ord(d) for d in data]
+ # In python3 data is already a bytes array
data = ["\\x%02X" % d for d in data]
if self.__useQuoteForText:
return "b\"%s\"" % "".join(data)
@@ -221,6 +226,30 @@ class TextFormatter(qt.QObject):
else:
return "".join(data)
+ def __formatCharString(self, data):
+ """Format text of char.
+
+ From the specifications we expect to have ASCII, but we also allow
+ CP1252 in some ceases as fallback.
+
+ If no encoding fits, it will display a readable ASCII chars, with
+ escaped chars (using the python syntax) for non decoded characters.
+
+ :param data: A binary string of char expected in ASCII
+ :rtype: str
+ """
+ try:
+ text = "%s" % data.decode("ascii")
+ return self.__formatText(text)
+ except UnicodeDecodeError:
+ # Here we can spam errors, this is definitly a badly
+ # generated file
+ _logger.error("Invalid ASCII string %s.", data)
+ if data == b"\xB0":
+ _logger.error("Fallback using cp1252 encoding")
+ return self.__formatText(u"\u00B0")
+ return self.__formatSafeAscii(data)
+
def __formatH5pyObject(self, data, dtype):
# That's an HDF5 object
ref = h5py.check_dtype(ref=dtype)
@@ -236,11 +265,7 @@ class TextFormatter(qt.QObject):
return self.__formatText(data)
elif vlen == six.binary_type:
# HDF5 ASCII
- try:
- text = "%s" % data.decode("ascii")
- return self.__formatText(text)
- except UnicodeDecodeError:
- return self.__formatSafeAscii(data)
+ return self.__formatCharString(data)
return None
def toString(self, data, dtype=None):
@@ -276,14 +301,12 @@ class TextFormatter(qt.QObject):
elif isinstance(data, (numpy.unicode_, six.text_type)):
return self.__formatText(data)
elif isinstance(data, (numpy.string_, six.binary_type)):
+ if dtype is None and hasattr(data, "dtype"):
+ dtype = data.dtype
if dtype is not None:
# Maybe a sub item from HDF5
if dtype.kind == 'S':
- try:
- text = "%s" % data.decode("ascii")
- return self.__formatText(text)
- except UnicodeDecodeError:
- return self.__formatSafeAscii(data)
+ return self.__formatCharString(data)
elif dtype.kind == 'O':
if h5py is not None:
text = self.__formatH5pyObject(data, dtype)
diff --git a/silx/gui/data/test/test_dataviewer.py b/silx/gui/data/test/test_dataviewer.py
index dd3114a..274df92 100644
--- a/silx/gui/data/test/test_dataviewer.py
+++ b/silx/gui/data/test/test_dataviewer.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -24,7 +24,7 @@
# ###########################################################################*/
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "22/08/2017"
+__date__ = "22/02/2018"
import os
import tempfile
@@ -67,7 +67,8 @@ class _DataViewMock(DataView):
class AbstractDataViewerTests(TestCaseQt):
def create_widget(self):
- raise NotImplementedError()
+ # Avoid to raise an error when testing the full module
+ self.skipTest("Not implemented")
@contextmanager
def h5_temporary_file(self):
@@ -89,7 +90,7 @@ class AbstractDataViewerTests(TestCaseQt):
widget = self.create_widget()
for data in data_list:
widget.setData(data)
- self.assertEqual(DataViewer.RAW_MODE, widget.displayMode())
+ self.assertEqual(DataViews.RAW_MODE, widget.displayMode())
def test_plot_1d_data(self):
data = numpy.arange(3 ** 1)
@@ -97,35 +98,35 @@ class AbstractDataViewerTests(TestCaseQt):
widget = self.create_widget()
widget.setData(data)
availableModes = set([v.modeId() for v in widget.currentAvailableViews()])
- self.assertEqual(DataViewer.RAW_MODE, widget.displayMode())
- self.assertIn(DataViewer.PLOT1D_MODE, availableModes)
+ self.assertEqual(DataViews.RAW_MODE, widget.displayMode())
+ self.assertIn(DataViews.PLOT1D_MODE, availableModes)
- def test_plot_2d_data(self):
+ def test_image_data(self):
data = numpy.arange(3 ** 2)
data.shape = [3] * 2
widget = self.create_widget()
widget.setData(data)
availableModes = set([v.modeId() for v in widget.currentAvailableViews()])
- self.assertEqual(DataViewer.RAW_MODE, widget.displayMode())
- self.assertIn(DataViewer.PLOT2D_MODE, availableModes)
+ self.assertEqual(DataViews.RAW_MODE, widget.displayMode())
+ self.assertIn(DataViews.IMAGE_MODE, availableModes)
- def test_plot_2d_bool(self):
+ def test_image_bool(self):
data = numpy.zeros((10, 10), dtype=numpy.bool)
data[::2, ::2] = True
widget = self.create_widget()
widget.setData(data)
availableModes = set([v.modeId() for v in widget.currentAvailableViews()])
- self.assertEqual(DataViewer.RAW_MODE, widget.displayMode())
- self.assertIn(DataViewer.PLOT2D_MODE, availableModes)
+ self.assertEqual(DataViews.RAW_MODE, widget.displayMode())
+ self.assertIn(DataViews.IMAGE_MODE, availableModes)
- def test_plot_2d_complex_data(self):
+ def test_image_complex_data(self):
data = numpy.arange(3 ** 2, dtype=numpy.complex)
data.shape = [3] * 2
widget = self.create_widget()
widget.setData(data)
availableModes = set([v.modeId() for v in widget.currentAvailableViews()])
- self.assertEqual(DataViewer.RAW_MODE, widget.displayMode())
- self.assertIn(DataViewer.PLOT2D_MODE, availableModes)
+ self.assertEqual(DataViews.RAW_MODE, widget.displayMode())
+ self.assertIn(DataViews.IMAGE_MODE, availableModes)
def test_plot_3d_data(self):
data = numpy.arange(3 ** 3)
@@ -135,38 +136,38 @@ class AbstractDataViewerTests(TestCaseQt):
availableModes = set([v.modeId() for v in widget.currentAvailableViews()])
try:
import silx.gui.plot3d # noqa
- self.assertIn(DataViewer.PLOT3D_MODE, availableModes)
+ self.assertIn(DataViews.PLOT3D_MODE, availableModes)
except ImportError:
- self.assertIn(DataViewer.STACK_MODE, availableModes)
- self.assertEqual(DataViewer.RAW_MODE, widget.displayMode())
+ self.assertIn(DataViews.STACK_MODE, availableModes)
+ self.assertEqual(DataViews.RAW_MODE, widget.displayMode())
def test_array_1d_data(self):
data = numpy.array(["aaa"] * (3 ** 1))
data.shape = [3] * 1
widget = self.create_widget()
widget.setData(data)
- self.assertEqual(DataViewer.RAW_MODE, widget.displayedView().modeId())
+ self.assertEqual(DataViews.RAW_MODE, widget.displayedView().modeId())
def test_array_2d_data(self):
data = numpy.array(["aaa"] * (3 ** 2))
data.shape = [3] * 2
widget = self.create_widget()
widget.setData(data)
- self.assertEqual(DataViewer.RAW_MODE, widget.displayedView().modeId())
+ self.assertEqual(DataViews.RAW_MODE, widget.displayedView().modeId())
def test_array_4d_data(self):
data = numpy.array(["aaa"] * (3 ** 4))
data.shape = [3] * 4
widget = self.create_widget()
widget.setData(data)
- self.assertEqual(DataViewer.RAW_MODE, widget.displayedView().modeId())
+ self.assertEqual(DataViews.RAW_MODE, widget.displayedView().modeId())
def test_record_4d_data(self):
data = numpy.zeros(3 ** 4, dtype='3int8, float32, (2,3)float64')
data.shape = [3] * 4
widget = self.create_widget()
widget.setData(data)
- self.assertEqual(DataViewer.RAW_MODE, widget.displayedView().modeId())
+ self.assertEqual(DataViews.RAW_MODE, widget.displayedView().modeId())
def test_3d_h5_dataset(self):
if h5py is None:
@@ -191,7 +192,7 @@ class AbstractDataViewerTests(TestCaseQt):
widget.setData(10)
widget.setData(None)
modes = [v.modeId() for v in listener.arguments(argumentIndex=0)]
- self.assertEquals(modes, [DataViewer.RAW_MODE, DataViewer.EMPTY_MODE])
+ self.assertEquals(modes, [DataViews.RAW_MODE, DataViews.EMPTY_MODE])
listener.clear()
def test_change_display_mode(self):
@@ -199,14 +200,15 @@ class AbstractDataViewerTests(TestCaseQt):
data.shape = [10] * 4
widget = self.create_widget()
widget.setData(data)
- widget.setDisplayMode(DataViewer.PLOT1D_MODE)
- self.assertEquals(widget.displayedView().modeId(), DataViewer.PLOT1D_MODE)
- widget.setDisplayMode(DataViewer.PLOT2D_MODE)
- self.assertEquals(widget.displayedView().modeId(), DataViewer.PLOT2D_MODE)
- widget.setDisplayMode(DataViewer.RAW_MODE)
- self.assertEquals(widget.displayedView().modeId(), DataViewer.RAW_MODE)
- widget.setDisplayMode(DataViewer.EMPTY_MODE)
- self.assertEquals(widget.displayedView().modeId(), DataViewer.EMPTY_MODE)
+ widget.setDisplayMode(DataViews.PLOT1D_MODE)
+ self.assertEquals(widget.displayedView().modeId(), DataViews.PLOT1D_MODE)
+ widget.setDisplayMode(DataViews.IMAGE_MODE)
+ self.assertEquals(widget.displayedView().modeId(), DataViews.IMAGE_MODE)
+ widget.setDisplayMode(DataViews.RAW_MODE)
+ self.assertEquals(widget.displayedView().modeId(), DataViews.RAW_MODE)
+ widget.setDisplayMode(DataViews.EMPTY_MODE)
+ self.assertEquals(widget.displayedView().modeId(), DataViews.EMPTY_MODE)
+ DataView._cleanUpCache()
def test_create_default_views(self):
widget = self.create_widget()
@@ -228,6 +230,26 @@ class AbstractDataViewerTests(TestCaseQt):
self.assertTrue(view not in widget.availableViews())
self.assertTrue(view not in widget.currentAvailableViews())
+ def test_replace_view(self):
+ widget = self.create_widget()
+ view = _DataViewMock(widget)
+ widget.replaceView(DataViews.RAW_MODE,
+ view)
+ self.assertIsNone(widget.getViewFromModeId(DataViews.RAW_MODE))
+ self.assertTrue(view in widget.availableViews())
+ self.assertTrue(view in widget.currentAvailableViews())
+
+ def test_replace_view_in_composite(self):
+ # replace a view that is a child of a composite view
+ widget = self.create_widget()
+ view = _DataViewMock(widget)
+ widget.replaceView(DataViews.NXDATA_INVALID_MODE,
+ view)
+ nxdata_view = widget.getViewFromModeId(DataViews.NXDATA_MODE)
+ self.assertNotIn(DataViews.NXDATA_INVALID_MODE,
+ [v.modeId() for v in nxdata_view.availableViews()])
+ self.assertTrue(view in nxdata_view.availableViews())
+
class TestDataViewer(AbstractDataViewerTests):
def create_widget(self):
@@ -265,6 +287,7 @@ class TestDataView(TestCaseQt):
dataViewClass = DataViews._Plot2dView
widget = self.createDataViewWithData(dataViewClass, data[0])
self.qWaitForWindowExposed(widget)
+ DataView._cleanUpCache()
def testCubeWithComplex(self):
self.skipTest("OpenGL widget not yet tested")
@@ -276,12 +299,14 @@ class TestDataView(TestCaseQt):
dataViewClass = DataViews._Plot3dView
widget = self.createDataViewWithData(dataViewClass, data)
self.qWaitForWindowExposed(widget)
+ DataView._cleanUpCache()
def testImageStackWithComplex(self):
data = self.createComplexData()
dataViewClass = DataViews._StackView
widget = self.createDataViewWithData(dataViewClass, data)
self.qWaitForWindowExposed(widget)
+ DataView._cleanUpCache()
def suite():
diff --git a/silx/gui/data/test/test_numpyaxesselector.py b/silx/gui/data/test/test_numpyaxesselector.py
index cc15f83..6ce5119 100644
--- a/silx/gui/data/test/test_numpyaxesselector.py
+++ b/silx/gui/data/test/test_numpyaxesselector.py
@@ -24,7 +24,7 @@
# ###########################################################################*/
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "15/12/2016"
+__date__ = "29/01/2018"
import os
import tempfile
@@ -70,6 +70,20 @@ class TestNumpyAxesSelector(TestCaseQt):
result = widget.selectedData()
self.assertTrue(numpy.array_equal(result, expectedResult))
+ def test_output_moredim(self):
+ data = numpy.arange(3 * 3 * 3 * 3)
+ data.shape = 3, 3, 3, 3
+ expectedResult = data
+
+ widget = NumpyAxesSelector()
+ widget.setAxisNames(["x", "y", "z", "boum"])
+ widget.setData(data[0])
+ result = widget.selectedData()
+ self.assertEqual(result, None)
+ widget.setData(data)
+ result = widget.selectedData()
+ self.assertTrue(numpy.array_equal(result, expectedResult))
+
def test_output_lessdim(self):
data = numpy.arange(3 * 3 * 3)
data.shape = 3, 3, 3
diff --git a/silx/gui/data/test/test_textformatter.py b/silx/gui/data/test/test_textformatter.py
index 2a7a66b..06a29ba 100644
--- a/silx/gui/data/test/test_textformatter.py
+++ b/silx/gui/data/test/test_textformatter.py
@@ -24,7 +24,7 @@
# ###########################################################################*/
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "27/09/2017"
+__date__ = "12/12/2017"
import unittest
import shutil
@@ -91,6 +91,17 @@ class TestTextFormatter(TestCaseQt):
result = formatter.toString("toto")
self.assertEquals(result, '"toto"')
+ def test_numpy_void(self):
+ formatter = TextFormatter()
+ result = formatter.toString(numpy.void(b"\xFF"))
+ self.assertEquals(result, 'b"\\xFF"')
+
+ def test_char_cp1252(self):
+ # degree character in cp1252
+ formatter = TextFormatter()
+ result = formatter.toString(numpy.bytes_(b"\xB0"))
+ self.assertEquals(result, u'"\u00B0"')
+
class TestTextFormatterWithH5py(TestCaseQt):
diff --git a/silx/gui/dialog/AbstractDataFileDialog.py b/silx/gui/dialog/AbstractDataFileDialog.py
new file mode 100644
index 0000000..1bd52bb
--- /dev/null
+++ b/silx/gui/dialog/AbstractDataFileDialog.py
@@ -0,0 +1,1718 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""
+This module contains an :class:`AbstractDataFileDialog`.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "12/02/2018"
+
+
+import sys
+import os
+import logging
+import numpy
+import functools
+import silx.io.url
+from silx.gui import qt
+from silx.gui.hdf5.Hdf5TreeModel import Hdf5TreeModel
+from . import utils
+from silx.third_party import six
+from .FileTypeComboBox import FileTypeComboBox
+try:
+ import fabio
+except ImportError:
+ fabio = None
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _IconProvider(object):
+
+ FileDialogToParentDir = qt.QStyle.SP_CustomBase + 1
+
+ FileDialogToParentFile = qt.QStyle.SP_CustomBase + 2
+
+ def __init__(self):
+ self.__iconFileDialogToParentDir = None
+ self.__iconFileDialogToParentFile = None
+
+ def _createIconToParent(self, standardPixmap):
+ """
+
+ FIXME: It have to be tested for some OS (arrow icon do not have always
+ the same direction)
+ """
+ style = qt.QApplication.style()
+ baseIcon = style.standardIcon(qt.QStyle.SP_FileDialogToParent)
+ backgroundIcon = style.standardIcon(standardPixmap)
+ icon = qt.QIcon()
+
+ sizes = baseIcon.availableSizes()
+ sizes = sorted(sizes, key=lambda s: s.height())
+ sizes = filter(lambda s: s.height() < 100, sizes)
+ sizes = list(sizes)
+ if len(sizes) > 0:
+ baseSize = sizes[-1]
+ else:
+ baseSize = baseIcon.availableSizes()[0]
+ size = qt.QSize(baseSize.width(), baseSize.height() * 3 // 2)
+
+ modes = [qt.QIcon.Normal, qt.QIcon.Disabled]
+ for mode in modes:
+ pixmap = qt.QPixmap(size)
+ pixmap.fill(qt.Qt.transparent)
+ painter = qt.QPainter(pixmap)
+ painter.drawPixmap(0, 0, backgroundIcon.pixmap(baseSize, mode=mode))
+ painter.drawPixmap(0, size.height() // 3, baseIcon.pixmap(baseSize, mode=mode))
+ painter.end()
+ icon.addPixmap(pixmap, mode=mode)
+
+ return icon
+
+ def getFileDialogToParentDir(self):
+ if self.__iconFileDialogToParentDir is None:
+ self.__iconFileDialogToParentDir = self._createIconToParent(qt.QStyle.SP_DirIcon)
+ return self.__iconFileDialogToParentDir
+
+ def getFileDialogToParentFile(self):
+ if self.__iconFileDialogToParentFile is None:
+ self.__iconFileDialogToParentFile = self._createIconToParent(qt.QStyle.SP_FileIcon)
+ return self.__iconFileDialogToParentFile
+
+ def icon(self, kind):
+ if kind == self.FileDialogToParentDir:
+ return self.getFileDialogToParentDir()
+ elif kind == self.FileDialogToParentFile:
+ return self.getFileDialogToParentFile()
+ else:
+ style = qt.QApplication.style()
+ icon = style.standardIcon(kind)
+ return icon
+
+
+class _SideBar(qt.QListView):
+ """Sidebar containing shortcuts for common directories"""
+
+ def __init__(self, parent=None):
+ super(_SideBar, self).__init__(parent)
+ self.__iconProvider = qt.QFileIconProvider()
+ self.setUniformItemSizes(True)
+ model = qt.QStandardItemModel(self)
+ self.setModel(model)
+ self._initModel()
+ self.setEditTriggers(qt.QAbstractItemView.NoEditTriggers)
+
+ def iconProvider(self):
+ return self.__iconProvider
+
+ def _initModel(self):
+ urls = self._getDefaultUrls()
+ self.setUrls(urls)
+
+ def _getDefaultUrls(self):
+ """Returns the default shortcuts.
+
+ It uses the default QFileDialog shortcuts if it is possible, else
+ provides a link to the computer's root and the user's home.
+
+ :rtype: List[str]
+ """
+ urls = []
+ if qt.qVersion().startswith("5.") and sys.platform in ["linux", "linux2"]:
+ # Avoid segfault on PyQt5 + gtk
+ _logger.debug("Skip default sidebar URLs (avoid PyQt5 segfault)")
+ pass
+ elif qt.qVersion().startswith("4.") and sys.platform in ["win32"]:
+ # Avoid 5min of locked GUI relative to network driver
+ _logger.debug("Skip default sidebar URLs (avoid lock when using network drivers)")
+ else:
+ # Get default shortcut
+ # There is no other way
+ d = qt.QFileDialog(self)
+ # Needed to be able to reach the sidebar urls
+ d.setOption(qt.QFileDialog.DontUseNativeDialog, True)
+ urls = d.sidebarUrls()
+ d.deleteLater()
+ d = None
+
+ if len(urls) == 0:
+ urls.append(qt.QUrl("file://"))
+ urls.append(qt.QUrl.fromLocalFile(qt.QDir.homePath()))
+
+ return urls
+
+ def setSelectedPath(self, path):
+ selected = None
+ model = self.model()
+ for i in range(model.rowCount()):
+ index = model.index(i, 0)
+ url = model.data(index, qt.Qt.UserRole)
+ urlPath = url.toLocalFile()
+ if path == urlPath:
+ selected = index
+
+ selectionModel = self.selectionModel()
+ if selected is not None:
+ selectionModel.setCurrentIndex(selected, qt.QItemSelectionModel.ClearAndSelect)
+ else:
+ selectionModel.clear()
+
+ def setUrls(self, urls):
+ model = self.model()
+ model.clear()
+
+ names = {}
+ names[qt.QDir.rootPath()] = "Computer"
+ names[qt.QDir.homePath()] = "Home"
+
+ style = qt.QApplication.style()
+ iconProvider = self.iconProvider()
+ for url in urls:
+ path = url.toLocalFile()
+ if path == "":
+ if sys.platform != "win32":
+ url = qt.QUrl(qt.QDir.rootPath())
+ name = "Computer"
+ icon = style.standardIcon(qt.QStyle.SP_ComputerIcon)
+ else:
+ fileInfo = qt.QFileInfo(path)
+ name = names.get(path, fileInfo.fileName())
+ icon = iconProvider.icon(fileInfo)
+
+ if icon.isNull():
+ icon = style.standardIcon(qt.QStyle.SP_MessageBoxCritical)
+
+ item = qt.QStandardItem()
+ item.setText(name)
+ item.setIcon(icon)
+ item.setData(url, role=qt.Qt.UserRole)
+ model.appendRow(item)
+
+ def urls(self):
+ result = []
+ model = self.model()
+ for i in range(model.rowCount()):
+ index = model.index(i, 0)
+ url = model.data(index, qt.Qt.UserRole)
+ result.append(url)
+ return result
+
+ def sizeHint(self):
+ index = self.model().index(0, 0)
+ return self.sizeHintForIndex(index) + qt.QSize(2 * self.frameWidth(), 2 * self.frameWidth())
+
+
+class _Browser(qt.QStackedWidget):
+
+ activated = qt.Signal(qt.QModelIndex)
+ selected = qt.Signal(qt.QModelIndex)
+ rootIndexChanged = qt.Signal(qt.QModelIndex)
+
+ def __init__(self, parent, listView, detailView):
+ qt.QStackedWidget.__init__(self, parent)
+ self.__listView = listView
+ self.__detailView = detailView
+ self.insertWidget(0, self.__listView)
+ self.insertWidget(1, self.__detailView)
+
+ self.__listView.activated.connect(self.__emitActivated)
+ self.__detailView.activated.connect(self.__emitActivated)
+
+ def __emitActivated(self, index):
+ self.activated.emit(index)
+
+ def __emitSelected(self, selected, deselected):
+ index = self.selectedIndex()
+ if index is not None:
+ self.selected.emit(index)
+
+ def selectedIndex(self):
+ if self.currentIndex() == 0:
+ selectionModel = self.__listView.selectionModel()
+ else:
+ selectionModel = self.__detailView.selectionModel()
+
+ if selectionModel is None:
+ return None
+
+ indexes = selectionModel.selectedIndexes()
+ # Filter non-main columns
+ indexes = [i for i in indexes if i.column() == 0]
+ if len(indexes) == 1:
+ index = indexes[0]
+ return index
+ return None
+
+ def model(self):
+ """Returns the current model."""
+ if self.currentIndex() == 0:
+ return self.__listView.model()
+ else:
+ return self.__detailView.model()
+
+ def selectIndex(self, index):
+ if self.currentIndex() == 0:
+ selectionModel = self.__listView.selectionModel()
+ else:
+ selectionModel = self.__detailView.selectionModel()
+ if selectionModel is None:
+ return
+ selectionModel.setCurrentIndex(index, qt.QItemSelectionModel.ClearAndSelect)
+
+ def viewMode(self):
+ """Returns the current view mode.
+
+ :rtype: qt.QFileDialog.ViewMode
+ """
+ if self.currentIndex() == 0:
+ return qt.QFileDialog.List
+ elif self.currentIndex() == 1:
+ return qt.QFileDialog.Detail
+ else:
+ assert(False)
+
+ def setViewMode(self, mode):
+ """Set the current view mode.
+
+ :param qt.QFileDialog.ViewMode mode: The new view mode
+ """
+ if mode == qt.QFileDialog.Detail:
+ self.showDetails()
+ elif mode == qt.QFileDialog.List:
+ self.showList()
+ else:
+ assert(False)
+
+ def showList(self):
+ self.__listView.show()
+ self.__detailView.hide()
+ self.setCurrentIndex(0)
+
+ def showDetails(self):
+ self.__listView.hide()
+ self.__detailView.show()
+ self.setCurrentIndex(1)
+ self.__detailView.updateGeometry()
+
+ def clear(self):
+ self.__listView.setRootIndex(qt.QModelIndex())
+ self.__detailView.setRootIndex(qt.QModelIndex())
+ selectionModel = self.__listView.selectionModel()
+ if selectionModel is not None:
+ selectionModel.selectionChanged.disconnect()
+ selectionModel.clear()
+ selectionModel = self.__detailView.selectionModel()
+ if selectionModel is not None:
+ selectionModel.selectionChanged.disconnect()
+ selectionModel.clear()
+ self.__listView.setModel(None)
+ self.__detailView.setModel(None)
+
+ def setRootIndex(self, index, model=None):
+ """Sets the root item to the item at the given index.
+ """
+ rootIndex = self.__listView.rootIndex()
+ newModel = model or index.model()
+ assert(newModel is not None)
+
+ if rootIndex is None or rootIndex.model() is not newModel:
+ # update the model
+ selectionModel = self.__listView.selectionModel()
+ if selectionModel is not None:
+ selectionModel.selectionChanged.disconnect()
+ selectionModel.clear()
+ selectionModel = self.__detailView.selectionModel()
+ if selectionModel is not None:
+ selectionModel.selectionChanged.disconnect()
+ selectionModel.clear()
+ pIndex = qt.QPersistentModelIndex(index)
+ self.__listView.setModel(newModel)
+ # changing the model of the tree view change the index mapping
+ # that is why we are using a persistance model index
+ self.__detailView.setModel(newModel)
+ index = newModel.index(pIndex.row(), pIndex.column(), pIndex.parent())
+ selectionModel = self.__listView.selectionModel()
+ selectionModel.selectionChanged.connect(self.__emitSelected)
+ selectionModel = self.__detailView.selectionModel()
+ selectionModel.selectionChanged.connect(self.__emitSelected)
+
+ self.__listView.setRootIndex(index)
+ self.__detailView.setRootIndex(index)
+ self.rootIndexChanged.emit(index)
+
+ def rootIndex(self):
+ """Returns the model index of the model's root item. The root item is
+ the parent item to the view's toplevel items. The root can be invalid.
+ """
+ return self.__listView.rootIndex()
+
+ __serialVersion = 1
+ """Store the current version of the serialized data"""
+
+ def visualRect(self, index):
+ """Returns the rectangle on the viewport occupied by the item at index.
+
+ :param qt.QModelIndex index: An index
+ :rtype: QRect
+ """
+ if self.currentIndex() == 0:
+ return self.__listView.visualRect(index)
+ else:
+ return self.__detailView.visualRect(index)
+
+ def viewport(self):
+ """Returns the viewport widget.
+
+ :param qt.QModelIndex index: An index
+ :rtype: QRect
+ """
+ if self.currentIndex() == 0:
+ return self.__listView.viewport()
+ else:
+ return self.__detailView.viewport()
+
+ def restoreState(self, state):
+ """Restores the dialogs's layout, history and current directory to the
+ state specified.
+
+ :param qt.QByeArray state: Stream containing the new state
+ :rtype: bool
+ """
+ stream = qt.QDataStream(state, qt.QIODevice.ReadOnly)
+
+ nameId = stream.readQString()
+ if nameId != "Browser":
+ _logger.warning("Stored state contains an invalid name id. Browser restoration cancelled.")
+ return False
+
+ version = stream.readInt32()
+ if version != self.__serialVersion:
+ _logger.warning("Stored state contains an invalid version. Browser restoration cancelled.")
+ return False
+
+ headerData = stream.readQVariant()
+ self.__detailView.header().restoreState(headerData)
+
+ viewMode = stream.readInt32()
+ self.setViewMode(viewMode)
+ return True
+
+ def saveState(self):
+ """Saves the state of the dialog's layout.
+
+ :rtype: qt.QByteArray
+ """
+ data = qt.QByteArray()
+ stream = qt.QDataStream(data, qt.QIODevice.WriteOnly)
+
+ nameId = u"Browser"
+ stream.writeQString(nameId)
+ stream.writeInt32(self.__serialVersion)
+ stream.writeQVariant(self.__detailView.header().saveState())
+ stream.writeInt32(self.viewMode())
+
+ return data
+
+
+class _FabioData(object):
+
+ def __init__(self, fabioFile):
+ self.__fabioFile = fabioFile
+
+ @property
+ def dtype(self):
+ # Let say it is a valid type
+ return numpy.dtype("float")
+
+ @property
+ def shape(self):
+ if self.__fabioFile.nframes == 0:
+ return None
+ return [self.__fabioFile.nframes, slice(None), slice(None)]
+
+ def __getitem__(self, selector):
+ if isinstance(selector, tuple) and len(selector) == 1:
+ selector = selector[0]
+
+ if isinstance(selector, six.integer_types):
+ if 0 <= selector < self.__fabioFile.nframes:
+ if self.__fabioFile.nframes == 1:
+ return self.__fabioFile.data
+ else:
+ frame = self.__fabioFile.getframe(selector)
+ return frame.data
+ else:
+ raise ValueError("Invalid selector %s" % selector)
+ else:
+ raise TypeError("Unsupported selector type %s" % type(selector))
+
+
+class _PathEdit(qt.QLineEdit):
+ pass
+
+
+class _CatchResizeEvent(qt.QObject):
+
+ resized = qt.Signal(qt.QResizeEvent)
+
+ def __init__(self, parent, target):
+ super(_CatchResizeEvent, self).__init__(parent)
+ self.__target = target
+ self.__target_oldResizeEvent = self.__target.resizeEvent
+ self.__target.resizeEvent = self.__resizeEvent
+
+ def __resizeEvent(self, event):
+ result = self.__target_oldResizeEvent(event)
+ self.resized.emit(event)
+ return result
+
+
+class AbstractDataFileDialog(qt.QDialog):
+ """The `AbstractFileDialog` provides a generic GUI to create a custom dialog
+ allowing to access to file resources like HDF5 files or HDF5 datasets
+
+ The dialog contains:
+
+ - Shortcuts: It provides few links to have a fast access of browsing
+ locations.
+ - Browser: It provides a display to browse throw the file system and inside
+ HDF5 files or fabio files. A file format selector is provided.
+ - URL: Display the URL available to reach the data using
+ :meth:`silx.io.get_data`, :meth:`silx.io.open`.
+ - Data selector: A widget to apply a sub selection of the browsed dataset.
+ This widget can be provided, else nothing will be used.
+ - Data preview: A widget to preview the selected data, which is the result
+ of the filter from the data selector.
+ This widget can be provided, else nothing will be used.
+ - Preview's toolbar: Provides tools used to custom data preview or data
+ selector.
+ This widget can be provided, else nothing will be used.
+ - Buttons to validate the dialog
+ """
+
+ _defaultIconProvider = None
+ """Lazy loaded default icon provider"""
+
+ def __init__(self, parent=None):
+ super(AbstractDataFileDialog, self).__init__(parent)
+ self._init()
+
+ def _init(self):
+ self.setWindowTitle("Open")
+
+ self.__directory = None
+ self.__directoryLoadedFilter = None
+ self.__errorWhileLoadingFile = None
+ self.__selectedFile = None
+ self.__selectedData = None
+ self.__currentHistory = []
+ """Store history of URLs, last index one is the latest one"""
+ self.__currentHistoryLocation = -1
+ """Store the location in the history. Bigger is older"""
+
+ self.__processing = 0
+ """Number of asynchronous processing tasks"""
+ self.__h5 = None
+ self.__fabio = None
+
+ if qt.qVersion() < "5.0":
+ # On Qt4 it is needed to provide a safe file system model
+ _logger.debug("Uses SafeFileSystemModel")
+ from .SafeFileSystemModel import SafeFileSystemModel
+ self.__fileModel = SafeFileSystemModel(self)
+ else:
+ # On Qt5 a safe icon provider is still needed to avoid freeze
+ _logger.debug("Uses default QFileSystemModel with a SafeFileIconProvider")
+ self.__fileModel = qt.QFileSystemModel(self)
+ from .SafeFileIconProvider import SafeFileIconProvider
+ iconProvider = SafeFileIconProvider()
+ self.__fileModel.setIconProvider(iconProvider)
+
+ # The common file dialog filter only on Mac OS X
+ self.__fileModel.setNameFilterDisables(sys.platform == "darwin")
+ self.__fileModel.setReadOnly(True)
+ self.__fileModel.directoryLoaded.connect(self.__directoryLoaded)
+
+ self.__dataModel = Hdf5TreeModel(self)
+
+ self.__createWidgets()
+ self.__initLayout()
+ self.__showAsListView()
+
+ path = os.getcwd()
+ self.__fileModel_setRootPath(path)
+
+ self.__clearData()
+ self.__updatePath()
+
+ # Update the file model filter
+ self.__fileTypeCombo.setCurrentIndex(0)
+ self.__filterSelected(0)
+
+ self.__openedFiles = []
+ """Store the list of files opened by the model itself."""
+ # FIXME: It should be managed one by one by Hdf5Item itself
+
+ # It is not possible to override the QObject destructor nor
+ # to access to the content of the Python object with the `destroyed`
+ # signal cause the Python method was already removed with the QWidget,
+ # while the QObject still exists.
+ # We use a static method plus explicit references to objects to
+ # release. The callback do not use any ref to self.
+ onDestroy = functools.partial(self._closeFileList, self.__openedFiles)
+ self.destroyed.connect(onDestroy)
+
+ @staticmethod
+ def _closeFileList(fileList):
+ """Static method to close explicit references to internal objects."""
+ _logger.debug("Clear AbstractDataFileDialog")
+ for obj in fileList:
+ _logger.debug("Close file %s", obj.filename)
+ obj.close()
+ fileList[:] = []
+
+ def done(self, result):
+ self._clear()
+ super(AbstractDataFileDialog, self).done(result)
+
+ def _clear(self):
+ """Explicit method to clear data stored in the dialog.
+ After this call it is not anymore possible to use the widget.
+
+ This method is triggered by the destruction of the object and the
+ QDialog :meth:`done`. Then it can be triggered more than once.
+ """
+ _logger.debug("Clear dialog")
+ self.__errorWhileLoadingFile = None
+ self.__clearData()
+ if self.__fileModel is not None:
+ # Cache the directory before cleaning the model
+ self.__directory = self.directory()
+ self.__browser.clear()
+ self.__closeFile()
+ self.__fileModel = None
+ self.__dataModel = None
+
+ def hasPendingEvents(self):
+ """Returns true if the dialog have asynchronous tasks working on the
+ background."""
+ return self.__processing > 0
+
+ # User interface
+
+ def __createWidgets(self):
+ self.__sidebar = self._createSideBar()
+ if self.__sidebar is not None:
+ sideBarModel = self.__sidebar.selectionModel()
+ sideBarModel.selectionChanged.connect(self.__shortcutSelected)
+ self.__sidebar.setSelectionMode(qt.QAbstractItemView.SingleSelection)
+
+ listView = qt.QListView(self)
+ listView.setSelectionBehavior(qt.QAbstractItemView.SelectRows)
+ listView.setSelectionMode(qt.QAbstractItemView.SingleSelection)
+ listView.setResizeMode(qt.QListView.Adjust)
+ listView.setWrapping(True)
+ listView.setEditTriggers(qt.QAbstractItemView.NoEditTriggers)
+ listView.setContextMenuPolicy(qt.Qt.CustomContextMenu)
+ utils.patchToConsumeReturnKey(listView)
+
+ treeView = qt.QTreeView(self)
+ treeView.setSelectionBehavior(qt.QAbstractItemView.SelectRows)
+ treeView.setSelectionMode(qt.QAbstractItemView.SingleSelection)
+ treeView.setRootIsDecorated(False)
+ treeView.setItemsExpandable(False)
+ treeView.setSortingEnabled(True)
+ treeView.header().setSortIndicator(0, qt.Qt.AscendingOrder)
+ treeView.header().setStretchLastSection(False)
+ treeView.setTextElideMode(qt.Qt.ElideMiddle)
+ treeView.setEditTriggers(qt.QAbstractItemView.NoEditTriggers)
+ treeView.setContextMenuPolicy(qt.Qt.CustomContextMenu)
+ treeView.setDragDropMode(qt.QAbstractItemView.InternalMove)
+ utils.patchToConsumeReturnKey(treeView)
+
+ self.__browser = _Browser(self, listView, treeView)
+ self.__browser.activated.connect(self.__browsedItemActivated)
+ self.__browser.selected.connect(self.__browsedItemSelected)
+ self.__browser.rootIndexChanged.connect(self.__rootIndexChanged)
+ self.__browser.setObjectName("browser")
+
+ self.__previewWidget = self._createPreviewWidget(self)
+
+ self.__fileTypeCombo = FileTypeComboBox(self)
+ self.__fileTypeCombo.setObjectName("fileTypeCombo")
+ self.__fileTypeCombo.setDuplicatesEnabled(False)
+ self.__fileTypeCombo.setSizeAdjustPolicy(qt.QComboBox.AdjustToMinimumContentsLength)
+ self.__fileTypeCombo.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed)
+ self.__fileTypeCombo.activated[int].connect(self.__filterSelected)
+ self.__fileTypeCombo.setFabioUrlSupproted(self._isFabioFilesSupported())
+
+ self.__pathEdit = _PathEdit(self)
+ self.__pathEdit.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed)
+ self.__pathEdit.textChanged.connect(self.__textChanged)
+ self.__pathEdit.setObjectName("url")
+ utils.patchToConsumeReturnKey(self.__pathEdit)
+
+ self.__buttons = qt.QDialogButtonBox(self)
+ self.__buttons.setSizePolicy(qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed)
+ types = qt.QDialogButtonBox.Open | qt.QDialogButtonBox.Cancel
+ self.__buttons.setStandardButtons(types)
+ self.__buttons.button(qt.QDialogButtonBox.Cancel).setObjectName("cancel")
+ self.__buttons.button(qt.QDialogButtonBox.Open).setObjectName("open")
+
+ self.__buttons.accepted.connect(self.accept)
+ self.__buttons.rejected.connect(self.reject)
+
+ self.__browseToolBar = self._createBrowseToolBar()
+ self.__backwardAction.setEnabled(False)
+ self.__forwardAction.setEnabled(False)
+ self.__fileDirectoryAction.setEnabled(False)
+ self.__parentFileDirectoryAction.setEnabled(False)
+
+ self.__selectorWidget = self._createSelectorWidget(self)
+ if self.__selectorWidget is not None:
+ self.__selectorWidget.selectionChanged.connect(self.__selectorWidgetChanged)
+
+ self.__previewToolBar = self._createPreviewToolbar(self, self.__previewWidget, self.__selectorWidget)
+
+ self.__dataIcon = qt.QLabel(self)
+ self.__dataIcon.setSizePolicy(qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed)
+ self.__dataIcon.setScaledContents(True)
+ self.__dataIcon.setMargin(2)
+ self.__dataIcon.setAlignment(qt.Qt.AlignCenter)
+
+ self.__dataInfo = qt.QLabel(self)
+ self.__dataInfo.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed)
+
+ def _createSideBar(self):
+ sidebar = _SideBar(self)
+ sidebar.setObjectName("sidebar")
+ return sidebar
+
+ def iconProvider(self):
+ iconProvider = self.__class__._defaultIconProvider
+ if iconProvider is None:
+ iconProvider = _IconProvider()
+ self.__class__._defaultIconProvider = iconProvider
+ return iconProvider
+
+ def _createBrowseToolBar(self):
+ toolbar = qt.QToolBar(self)
+ toolbar.setIconSize(qt.QSize(16, 16))
+ iconProvider = self.iconProvider()
+
+ backward = qt.QAction(toolbar)
+ backward.setText("Back")
+ backward.setObjectName("backwardAction")
+ backward.setIcon(iconProvider.icon(qt.QStyle.SP_ArrowBack))
+ backward.triggered.connect(self.__navigateBackward)
+ self.__backwardAction = backward
+
+ forward = qt.QAction(toolbar)
+ forward.setText("Forward")
+ forward.setObjectName("forwardAction")
+ forward.setIcon(iconProvider.icon(qt.QStyle.SP_ArrowForward))
+ forward.triggered.connect(self.__navigateForward)
+ self.__forwardAction = forward
+
+ parentDirectory = qt.QAction(toolbar)
+ parentDirectory.setText("Go to parent")
+ parentDirectory.setObjectName("toParentAction")
+ parentDirectory.setIcon(iconProvider.icon(qt.QStyle.SP_FileDialogToParent))
+ parentDirectory.triggered.connect(self.__navigateToParent)
+ self.__toParentAction = parentDirectory
+
+ fileDirectory = qt.QAction(toolbar)
+ fileDirectory.setText("Root of the file")
+ fileDirectory.setObjectName("toRootFileAction")
+ fileDirectory.setIcon(iconProvider.icon(iconProvider.FileDialogToParentFile))
+ fileDirectory.triggered.connect(self.__navigateToParentFile)
+ self.__fileDirectoryAction = fileDirectory
+
+ parentFileDirectory = qt.QAction(toolbar)
+ parentFileDirectory.setText("Parent directory of the file")
+ parentFileDirectory.setObjectName("toDirectoryAction")
+ parentFileDirectory.setIcon(iconProvider.icon(iconProvider.FileDialogToParentDir))
+ parentFileDirectory.triggered.connect(self.__navigateToParentDir)
+ self.__parentFileDirectoryAction = parentFileDirectory
+
+ listView = qt.QAction(toolbar)
+ listView.setText("List view")
+ listView.setObjectName("listModeAction")
+ listView.setIcon(iconProvider.icon(qt.QStyle.SP_FileDialogListView))
+ listView.triggered.connect(self.__showAsListView)
+ listView.setCheckable(True)
+
+ detailView = qt.QAction(toolbar)
+ detailView.setText("Detail view")
+ detailView.setObjectName("detailModeAction")
+ detailView.setIcon(iconProvider.icon(qt.QStyle.SP_FileDialogDetailedView))
+ detailView.triggered.connect(self.__showAsDetailedView)
+ detailView.setCheckable(True)
+
+ self.__listViewAction = listView
+ self.__detailViewAction = detailView
+
+ toolbar.addAction(backward)
+ toolbar.addAction(forward)
+ toolbar.addSeparator()
+ toolbar.addAction(parentDirectory)
+ toolbar.addAction(fileDirectory)
+ toolbar.addAction(parentFileDirectory)
+ toolbar.addSeparator()
+ toolbar.addAction(listView)
+ toolbar.addAction(detailView)
+
+ toolbar.setStyleSheet("QToolBar { border: 0px }")
+
+ return toolbar
+
+ def __initLayout(self):
+ sideBarLayout = qt.QVBoxLayout()
+ sideBarLayout.setContentsMargins(0, 0, 0, 0)
+ dummyToolBar = qt.QWidget(self)
+ dummyToolBar.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed)
+ dummyCombo = qt.QWidget(self)
+ dummyCombo.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed)
+ sideBarLayout.addWidget(dummyToolBar)
+ if self.__sidebar is not None:
+ sideBarLayout.addWidget(self.__sidebar)
+ sideBarLayout.addWidget(dummyCombo)
+ sideBarWidget = qt.QWidget(self)
+ sideBarWidget.setLayout(sideBarLayout)
+
+ dummyCombo.setFixedHeight(self.__fileTypeCombo.height())
+ self.__resizeCombo = _CatchResizeEvent(self, self.__fileTypeCombo)
+ self.__resizeCombo.resized.connect(lambda e: dummyCombo.setFixedHeight(e.size().height()))
+
+ dummyToolBar.setFixedHeight(self.__browseToolBar.height())
+ self.__resizeToolbar = _CatchResizeEvent(self, self.__browseToolBar)
+ self.__resizeToolbar.resized.connect(lambda e: dummyToolBar.setFixedHeight(e.size().height()))
+
+ datasetSelection = qt.QWidget(self)
+ layoutLeft = qt.QVBoxLayout()
+ layoutLeft.setContentsMargins(0, 0, 0, 0)
+ layoutLeft.addWidget(self.__browseToolBar)
+ layoutLeft.addWidget(self.__browser)
+ layoutLeft.addWidget(self.__fileTypeCombo)
+ datasetSelection.setLayout(layoutLeft)
+ datasetSelection.setSizePolicy(qt.QSizePolicy.MinimumExpanding, qt.QSizePolicy.Expanding)
+
+ infoLayout = qt.QHBoxLayout()
+ infoLayout.setContentsMargins(0, 0, 0, 0)
+ infoLayout.addWidget(self.__dataIcon)
+ infoLayout.addWidget(self.__dataInfo)
+
+ dataFrame = qt.QFrame(self)
+ dataFrame.setFrameShape(qt.QFrame.StyledPanel)
+ layout = qt.QVBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ layout.addWidget(self.__previewWidget)
+ layout.addLayout(infoLayout)
+ dataFrame.setLayout(layout)
+
+ dataSelection = qt.QWidget(self)
+ dataLayout = qt.QVBoxLayout()
+ dataLayout.setContentsMargins(0, 0, 0, 0)
+ if self.__previewToolBar is not None:
+ dataLayout.addWidget(self.__previewToolBar)
+ else:
+ # Add dummy space
+ dummyToolbar2 = qt.QWidget(self)
+ dummyToolbar2.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed)
+ dummyToolbar2.setFixedHeight(self.__browseToolBar.height())
+ self.__resizeToolbar = _CatchResizeEvent(self, self.__browseToolBar)
+ self.__resizeToolbar.resized.connect(lambda e: dummyToolbar2.setFixedHeight(e.size().height()))
+ dataLayout.addWidget(dummyToolbar2)
+
+ dataLayout.addWidget(dataFrame)
+ if self.__selectorWidget is not None:
+ dataLayout.addWidget(self.__selectorWidget)
+ else:
+ # Add dummy space
+ dummyCombo2 = qt.QWidget(self)
+ dummyCombo2.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Fixed)
+ dummyCombo2.setFixedHeight(self.__fileTypeCombo.height())
+ self.__resizeToolbar = _CatchResizeEvent(self, self.__fileTypeCombo)
+ self.__resizeToolbar.resized.connect(lambda e: dummyCombo2.setFixedHeight(e.size().height()))
+ dataLayout.addWidget(dummyCombo2)
+ dataSelection.setLayout(dataLayout)
+
+ self.__splitter = qt.QSplitter(self)
+ self.__splitter.setContentsMargins(0, 0, 0, 0)
+ self.__splitter.addWidget(sideBarWidget)
+ self.__splitter.addWidget(datasetSelection)
+ self.__splitter.addWidget(dataSelection)
+ self.__splitter.setStretchFactor(1, 10)
+
+ bottomLayout = qt.QHBoxLayout()
+ bottomLayout.setContentsMargins(0, 0, 0, 0)
+ bottomLayout.addWidget(self.__pathEdit)
+ bottomLayout.addWidget(self.__buttons)
+
+ layout = qt.QVBoxLayout(self)
+ layout.addWidget(self.__splitter)
+ layout.addLayout(bottomLayout)
+
+ self.setLayout(layout)
+ self.updateGeometry()
+
+ # Logic
+
+ def __navigateBackward(self):
+ """Navigate through the history one step backward."""
+ if len(self.__currentHistory) > 0 and self.__currentHistoryLocation > 0:
+ self.__currentHistoryLocation -= 1
+ url = self.__currentHistory[self.__currentHistoryLocation]
+ self.selectUrl(url)
+
+ def __navigateForward(self):
+ """Navigate through the history one step forward."""
+ if len(self.__currentHistory) > 0 and self.__currentHistoryLocation < len(self.__currentHistory) - 1:
+ self.__currentHistoryLocation += 1
+ url = self.__currentHistory[self.__currentHistoryLocation]
+ self.selectUrl(url)
+
+ def __navigateToParent(self):
+ index = self.__browser.rootIndex()
+ if index.model() is self.__fileModel:
+ # browse throw the file system
+ index = index.parent()
+ path = self.__fileModel.filePath(index)
+ self.__fileModel_setRootPath(path)
+ self.__browser.selectIndex(qt.QModelIndex())
+ self.__updatePath()
+ elif index.model() is self.__dataModel:
+ index = index.parent()
+ if index.isValid():
+ # browse throw the hdf5
+ self.__browser.setRootIndex(index)
+ self.__browser.selectIndex(qt.QModelIndex())
+ self.__updatePath()
+ else:
+ # go back to the file system
+ self.__navigateToParentDir()
+ else:
+ # Root of the file system (my computer)
+ pass
+
+ def __navigateToParentFile(self):
+ index = self.__browser.rootIndex()
+ if index.model() is self.__dataModel:
+ index = self.__dataModel.indexFromH5Object(self.__h5)
+ self.__browser.setRootIndex(index)
+ self.__browser.selectIndex(qt.QModelIndex())
+ self.__updatePath()
+
+ def __navigateToParentDir(self):
+ index = self.__browser.rootIndex()
+ if index.model() is self.__dataModel:
+ path = os.path.dirname(self.__h5.file.filename)
+ index = self.__fileModel.index(path)
+ self.__browser.setRootIndex(index)
+ self.__browser.selectIndex(qt.QModelIndex())
+ self.__closeFile()
+ self.__updatePath()
+
+ def viewMode(self):
+ """Returns the current view mode.
+
+ :rtype: qt.QFileDialog.ViewMode
+ """
+ return self.__browser.viewMode()
+
+ def setViewMode(self, mode):
+ """Set the current view mode.
+
+ :param qt.QFileDialog.ViewMode mode: The new view mode
+ """
+ if mode == qt.QFileDialog.Detail:
+ self.__browser.showDetails()
+ self.__listViewAction.setChecked(False)
+ self.__detailViewAction.setChecked(True)
+ elif mode == qt.QFileDialog.List:
+ self.__browser.showList()
+ self.__listViewAction.setChecked(True)
+ self.__detailViewAction.setChecked(False)
+ else:
+ assert(False)
+
+ def __showAsListView(self):
+ self.setViewMode(qt.QFileDialog.List)
+
+ def __showAsDetailedView(self):
+ self.setViewMode(qt.QFileDialog.Detail)
+
+ def __shortcutSelected(self):
+ self.__browser.selectIndex(qt.QModelIndex())
+ self.__clearData()
+ self.__updatePath()
+ selectionModel = self.__sidebar.selectionModel()
+ indexes = selectionModel.selectedIndexes()
+ if len(indexes) == 1:
+ index = indexes[0]
+ url = self.__sidebar.model().data(index, role=qt.Qt.UserRole)
+ path = url.toLocalFile()
+ self.__fileModel_setRootPath(path)
+
+ def __browsedItemActivated(self, index):
+ if not index.isValid():
+ return
+ if index.model() is self.__fileModel:
+ path = self.__fileModel.filePath(index)
+ if self.__fileModel.isDir(index):
+ self.__fileModel_setRootPath(path)
+ if os.path.isfile(path):
+ self.__fileActivated(index)
+ elif index.model() is self.__dataModel:
+ obj = self.__dataModel.data(index, role=Hdf5TreeModel.H5PY_OBJECT_ROLE)
+ if silx.io.is_group(obj):
+ self.__browser.setRootIndex(index)
+ else:
+ assert(False)
+
+ def __browsedItemSelected(self, index):
+ self.__dataSelected(index)
+ self.__updatePath()
+
+ def __fileModel_setRootPath(self, path):
+ """Set the root path of the fileModel with a filter on the
+ directoryLoaded event.
+
+ Without this filter an extra event is received (at least with PyQt4)
+ when we use for the first time the sidebar.
+
+ :param str path: Path to load
+ """
+ assert(path is not None)
+ if path != "" and not os.path.exists(path):
+ return
+ if self.hasPendingEvents():
+ # Make sure the asynchronous fileModel setRootPath is finished
+ qt.QApplication.instance().processEvents()
+
+ if self.__directoryLoadedFilter is not None:
+ if utils.samefile(self.__directoryLoadedFilter, path):
+ return
+ self.__directoryLoadedFilter = path
+ self.__processing += 1
+ index = self.__fileModel.setRootPath(path)
+ if not index.isValid():
+ self.__processing -= 1
+ self.__browser.setRootIndex(index, model=self.__fileModel)
+ self.__clearData()
+ self.__updatePath()
+ else:
+ # asynchronous process
+ pass
+
+ def __directoryLoaded(self, path):
+ if self.__directoryLoadedFilter is not None:
+ if not utils.samefile(self.__directoryLoadedFilter, path):
+ # Filter event which should not arrive in PyQt4
+ # The first click on the sidebar sent 2 events
+ self.__processing -= 1
+ return
+ index = self.__fileModel.index(path)
+ self.__browser.setRootIndex(index, model=self.__fileModel)
+ self.__updatePath()
+ self.__processing -= 1
+
+ def __closeFile(self):
+ self.__openedFiles[:] = []
+ self.__fileDirectoryAction.setEnabled(False)
+ self.__parentFileDirectoryAction.setEnabled(False)
+ if self.__h5 is not None:
+ self.__dataModel.removeH5pyObject(self.__h5)
+ self.__h5.close()
+ self.__h5 = None
+ if self.__fabio is not None:
+ if hasattr(self.__fabio, "close"):
+ self.__fabio.close()
+ self.__fabio = None
+
+ def __openFabioFile(self, filename):
+ self.__closeFile()
+ try:
+ if fabio is None:
+ raise ImportError("Fabio module is not available")
+ self.__fabio = fabio.open(filename)
+ self.__openedFiles.append(self.__fabio)
+ self.__selectedFile = filename
+ except Exception as e:
+ _logger.error("Error while loading file %s: %s", filename, e.args[0])
+ _logger.debug("Backtrace", exc_info=True)
+ self.__errorWhileLoadingFile = filename, e.args[0]
+ return False
+ else:
+ return True
+
+ def __openSilxFile(self, filename):
+ self.__closeFile()
+ try:
+ self.__h5 = silx.io.open(filename)
+ self.__openedFiles.append(self.__h5)
+ self.__selectedFile = filename
+ except IOError as e:
+ _logger.error("Error while loading file %s: %s", filename, e.args[0])
+ _logger.debug("Backtrace", exc_info=True)
+ self.__errorWhileLoadingFile = filename, e.args[0]
+ return False
+ else:
+ self.__fileDirectoryAction.setEnabled(True)
+ self.__parentFileDirectoryAction.setEnabled(True)
+ self.__dataModel.insertH5pyObject(self.__h5)
+ return True
+
+ def __isSilxHavePriority(self, filename):
+ """Silx have priority when there is a specific decoder
+ """
+ _, ext = os.path.splitext(filename)
+ ext = "*%s" % ext
+ formats = silx.io.supported_extensions(flat_formats=False)
+ for extensions in formats.values():
+ if ext in extensions:
+ return True
+ return False
+
+ def __openFile(self, filename):
+ codec = self.__fileTypeCombo.currentCodec()
+ openners = []
+ if codec.is_autodetect():
+ if self.__isSilxHavePriority(filename):
+ openners.append(self.__openSilxFile)
+ if fabio is not None and self._isFabioFilesSupported():
+ openners.append(self.__openFabioFile)
+ else:
+ if fabio is not None and self._isFabioFilesSupported():
+ openners.append(self.__openFabioFile)
+ openners.append(self.__openSilxFile)
+ elif codec.is_silx_codec():
+ openners.append(self.__openSilxFile)
+ elif self._isFabioFilesSupported() and codec.is_fabio_codec():
+ # It is requested to use fabio, anyway fabio is here or not
+ openners.append(self.__openFabioFile)
+
+ for openner in openners:
+ ref = openner(filename)
+ if ref is not None:
+ return True
+ return False
+
+ def __fileActivated(self, index):
+ self.__selectedFile = None
+ path = self.__fileModel.filePath(index)
+ if os.path.isfile(path):
+ loaded = self.__openFile(path)
+ if loaded:
+ if self.__h5 is not None:
+ index = self.__dataModel.indexFromH5Object(self.__h5)
+ self.__browser.setRootIndex(index)
+ elif self.__fabio is not None:
+ data = _FabioData(self.__fabio)
+ self.__setData(data)
+ self.__updatePath()
+ else:
+ self.__clearData()
+
+ def __dataSelected(self, index):
+ selectedData = None
+ if index is not None:
+ if index.model() is self.__dataModel:
+ obj = self.__dataModel.data(index, self.__dataModel.H5PY_OBJECT_ROLE)
+ if self._isDataSupportable(obj):
+ selectedData = obj
+ elif index.model() is self.__fileModel:
+ self.__closeFile()
+ if self._isFabioFilesSupported():
+ path = self.__fileModel.filePath(index)
+ if os.path.isfile(path):
+ codec = self.__fileTypeCombo.currentCodec()
+ is_fabio_decoder = codec.is_fabio_codec()
+ is_fabio_have_priority = not codec.is_silx_codec() and not self.__isSilxHavePriority(path)
+ if is_fabio_decoder or is_fabio_have_priority:
+ # Then it's flat frame container
+ if fabio is not None:
+ self.__openFabioFile(path)
+ if self.__fabio is not None:
+ selectedData = _FabioData(self.__fabio)
+ else:
+ assert(False)
+
+ self.__setData(selectedData)
+
+ def __filterSelected(self, index):
+ filters = self.__fileTypeCombo.itemExtensions(index)
+ self.__fileModel.setNameFilters(filters)
+
+ def __setData(self, data):
+ self.__data = data
+
+ if data is not None and self._isDataSupportable(data):
+ if self.__selectorWidget is not None:
+ self.__selectorWidget.setData(data)
+ if not self.__selectorWidget.isUsed():
+ # Needed to fake the fact we have to reset the zoom in preview
+ self.__selectedData = None
+ self.__setSelectedData(data)
+ self.__selectorWidget.hide()
+ else:
+ self.__selectorWidget.setVisible(self.__selectorWidget.hasVisibleSelectors())
+ # Needed to fake the fact we have to reset the zoom in preview
+ self.__selectedData = None
+ self.__selectorWidget.selectionChanged.emit()
+ else:
+ # Needed to fake the fact we have to reset the zoom in preview
+ self.__selectedData = None
+ self.__setSelectedData(data)
+ else:
+ self.__clearData()
+ self.__updatePath()
+
+ def _isDataSupported(self, data):
+ """Check if the data can be returned by the dialog.
+
+ If true, this data can be returned by the dialog and the open button
+ while be enabled. If false the button will be disabled.
+
+ :rtype: bool
+ """
+ raise NotImplementedError()
+
+ def _isDataSupportable(self, data):
+ """Check if the selected data can be supported at one point.
+
+ If true, the data selector will be checked and it will update the data
+ preview. Else the selecting is disabled.
+
+ :rtype: bool
+ """
+ raise NotImplementedError()
+
+ def __clearData(self):
+ """Clear the data part of the GUI"""
+ if self.__previewWidget is not None:
+ self.__previewWidget.setData(None)
+ if self.__selectorWidget is not None:
+ self.__selectorWidget.hide()
+ self.__selectedData = None
+ self.__data = None
+ self.__updateDataInfo()
+ button = self.__buttons.button(qt.QDialogButtonBox.Open)
+ button.setEnabled(False)
+
+ def __selectorWidgetChanged(self):
+ data = self.__selectorWidget.getSelectedData(self.__data)
+ self.__setSelectedData(data)
+
+ def __setSelectedData(self, data):
+ """Set the data selected by the dialog.
+
+ If :meth:`_isDataSupported` returns false, this function will be
+ inhibited and no data will be selected.
+ """
+ if self.__previewWidget is not None:
+ fromDataSelector = self.__selectedData is not None
+ self.__previewWidget.setData(data, fromDataSelector=fromDataSelector)
+ if self._isDataSupported(data):
+ self.__selectedData = data
+ else:
+ self.__clearData()
+ return
+ self.__updateDataInfo()
+ self.__updatePath()
+ button = self.__buttons.button(qt.QDialogButtonBox.Open)
+ button.setEnabled(True)
+
+ def __updateDataInfo(self):
+ if self.__errorWhileLoadingFile is not None:
+ filename, message = self.__errorWhileLoadingFile
+ message = "<b>Error while loading file '%s'</b><hr/>%s" % (filename, message)
+ size = self.__dataInfo.height()
+ icon = self.style().standardIcon(qt.QStyle.SP_MessageBoxCritical)
+ pixmap = icon.pixmap(size, size)
+
+ self.__dataInfo.setText("Error while loading file")
+ self.__dataInfo.setToolTip(message)
+ self.__dataIcon.setToolTip(message)
+ self.__dataIcon.setVisible(True)
+ self.__dataIcon.setPixmap(pixmap)
+
+ self.__errorWhileLoadingFile = None
+ return
+
+ self.__dataIcon.setVisible(False)
+ self.__dataInfo.setToolTip("")
+ if self.__selectedData is None:
+ self.__dataInfo.setText("No data selected")
+ else:
+ text = self._displayedDataInfo(self.__data, self.__selectedData)
+ self.__dataInfo.setVisible(text is not None)
+ if text is not None:
+ self.__dataInfo.setText(text)
+
+ def _displayedDataInfo(self, dataBeforeSelection, dataAfterSelection):
+ """Returns the text displayed under the data preview.
+
+ This zone is used to display error in case or problem of data selection
+ or problems with IO.
+
+ :param numpy.ndarray dataAfterSelection: Data as it is after the
+ selection widget (basically the data from the preview widget)
+ :param numpy.ndarray dataAfterSelection: Data as it is before the
+ selection widget (basically the data from the browsing widget)
+ :rtype: bool
+ """
+ return None
+
+ def __createUrlFromIndex(self, index, useSelectorWidget=True):
+ if index.model() is self.__fileModel:
+ filename = self.__fileModel.filePath(index)
+ dataPath = None
+ elif index.model() is self.__dataModel:
+ obj = self.__dataModel.data(index, role=Hdf5TreeModel.H5PY_OBJECT_ROLE)
+ filename = obj.file.filename
+ dataPath = obj.name
+ else:
+ # root of the computer
+ filename = ""
+ dataPath = None
+
+ if useSelectorWidget and self.__selectorWidget is not None and self.__selectorWidget.isVisible():
+ slicing = self.__selectorWidget.slicing()
+ else:
+ slicing = None
+
+ if self.__fabio is not None:
+ scheme = "fabio"
+ elif self.__h5 is not None:
+ scheme = "silx"
+ else:
+ if os.path.isfile(filename):
+ codec = self.__fileTypeCombo.currentCodec()
+ if codec.is_fabio_codec():
+ scheme = "fabio"
+ elif codec.is_silx_codec():
+ scheme = "silx"
+ else:
+ scheme = None
+ else:
+ scheme = None
+
+ url = silx.io.url.DataUrl(file_path=filename, data_path=dataPath, data_slice=slicing, scheme=scheme)
+ return url
+
+ def __updatePath(self):
+ index = self.__browser.selectedIndex()
+ if index is None:
+ index = self.__browser.rootIndex()
+ url = self.__createUrlFromIndex(index)
+ if url.path() != self.__pathEdit.text():
+ old = self.__pathEdit.blockSignals(True)
+ self.__pathEdit.setText(url.path())
+ self.__pathEdit.blockSignals(old)
+
+ def __rootIndexChanged(self, index):
+ url = self.__createUrlFromIndex(index, useSelectorWidget=False)
+
+ currentUrl = None
+ if 0 <= self.__currentHistoryLocation < len(self.__currentHistory):
+ currentUrl = self.__currentHistory[self.__currentHistoryLocation]
+
+ if currentUrl is None or currentUrl != url.path():
+ # clean up the forward history
+ self.__currentHistory = self.__currentHistory[0:self.__currentHistoryLocation + 1]
+ self.__currentHistory.append(url.path())
+ self.__currentHistoryLocation += 1
+
+ if index.model() != self.__dataModel:
+ if sys.platform == "win32":
+ # path == ""
+ isRoot = not index.isValid()
+ else:
+ # path in ["", "/"]
+ isRoot = not index.isValid() or not index.parent().isValid()
+ else:
+ isRoot = False
+
+ if index.isValid():
+ self.__dataSelected(index)
+ self.__toParentAction.setEnabled(not isRoot)
+ self.__updateActionHistory()
+ self.__updateSidebar()
+
+ def __updateSidebar(self):
+ """Called when the current directory location change"""
+ if self.__sidebar is None:
+ return
+ selectionModel = self.__sidebar.selectionModel()
+ selectionModel.selectionChanged.disconnect(self.__shortcutSelected)
+ index = self.__browser.rootIndex()
+ if index.model() == self.__fileModel:
+ path = self.__fileModel.filePath(index)
+ self.__sidebar.setSelectedPath(path)
+ elif index.model() is None:
+ path = ""
+ self.__sidebar.setSelectedPath(path)
+ else:
+ selectionModel.clear()
+ selectionModel.selectionChanged.connect(self.__shortcutSelected)
+
+ def __updateActionHistory(self):
+ self.__forwardAction.setEnabled(len(self.__currentHistory) - 1 > self.__currentHistoryLocation)
+ self.__backwardAction.setEnabled(self.__currentHistoryLocation > 0)
+
+ def __textChanged(self, text):
+ self.__pathChanged()
+
+ def _isFabioFilesSupported(self):
+ """Returns true fabio files can be loaded.
+ """
+ return True
+
+ def _isLoadableUrl(self, url):
+ """Returns true if the URL is loadable by this dialog.
+
+ :param DataUrl url: The requested URL
+ """
+ return True
+
+ def __pathChanged(self):
+ url = silx.io.url.DataUrl(path=self.__pathEdit.text())
+ if url.is_valid() or url.path() == "":
+ if url.path() in ["", "/"] or url.file_path() in ["", "/"]:
+ self.__fileModel_setRootPath(qt.QDir.rootPath())
+ elif os.path.exists(url.file_path()):
+ rootIndex = None
+ if os.path.isdir(url.file_path()):
+ self.__fileModel_setRootPath(url.file_path())
+ index = self.__fileModel.index(url.file_path())
+ elif os.path.isfile(url.file_path()):
+ if self._isLoadableUrl(url):
+ if url.scheme() == "silx":
+ loaded = self.__openSilxFile(url.file_path())
+ elif url.scheme() == "fabio" and self._isFabioFilesSupported():
+ loaded = self.__openFabioFile(url.file_path())
+ else:
+ loaded = self.__openFile(url.file_path())
+ else:
+ loaded = False
+ if loaded:
+ if self.__h5 is not None:
+ rootIndex = self.__dataModel.indexFromH5Object(self.__h5)
+ elif self.__fabio is not None:
+ index = self.__fileModel.index(url.file_path())
+ rootIndex = index
+ if rootIndex is None:
+ index = self.__fileModel.index(url.file_path())
+ index = index.parent()
+
+ if rootIndex is not None:
+ if rootIndex.model() == self.__dataModel:
+ if url.data_path() is not None:
+ dataPath = url.data_path()
+ if dataPath in self.__h5:
+ obj = self.__h5[dataPath]
+ else:
+ path = utils.findClosestSubPath(self.__h5, dataPath)
+ if path is None:
+ path = "/"
+ obj = self.__h5[path]
+
+ if silx.io.is_file(obj):
+ self.__browser.setRootIndex(rootIndex)
+ elif silx.io.is_group(obj):
+ index = self.__dataModel.indexFromH5Object(obj)
+ self.__browser.setRootIndex(index)
+ else:
+ index = self.__dataModel.indexFromH5Object(obj)
+ self.__browser.setRootIndex(index.parent())
+ self.__browser.selectIndex(index)
+ else:
+ self.__browser.setRootIndex(rootIndex)
+ self.__clearData()
+ elif rootIndex.model() == self.__fileModel:
+ # that's a fabio file
+ self.__browser.setRootIndex(rootIndex.parent())
+ self.__browser.selectIndex(rootIndex)
+ # data = _FabioData(self.__fabio)
+ # self.__setData(data)
+ else:
+ assert(False)
+ else:
+ self.__browser.setRootIndex(index, model=self.__fileModel)
+ self.__clearData()
+
+ if self.__selectorWidget is not None:
+ self.__selectorWidget.setVisible(url.data_slice() is not None)
+ if url.data_slice() is not None:
+ self.__selectorWidget.setSlicing(url.data_slice())
+ else:
+ self.__errorWhileLoadingFile = (url.file_path(), "File not found")
+ self.__clearData()
+ else:
+ self.__errorWhileLoadingFile = (url.file_path(), "Path invalid")
+ self.__clearData()
+
+ def previewToolbar(self):
+ return self.__previewToolbar
+
+ def previewWidget(self):
+ return self.__previewWidget
+
+ def selectorWidget(self):
+ return self.__selectorWidget
+
+ def _createPreviewToolbar(self, parent, dataPreviewWidget, dataSelectorWidget):
+ return None
+
+ def _createPreviewWidget(self, parent):
+ return None
+
+ def _createSelectorWidget(self, parent):
+ return None
+
+ # Selected file
+
+ def setDirectory(self, path):
+ """Sets the data dialog's current directory."""
+ self.__fileModel_setRootPath(path)
+
+ def selectedFile(self):
+ """Returns the file path containing the selected data.
+
+ :rtype: str
+ """
+ return self.__selectedFile
+
+ def selectFile(self, filename):
+ """Sets the data dialog's current file."""
+ self.__directoryLoadedFilter = ""
+ old = self.__pathEdit.blockSignals(True)
+ try:
+ self.__pathEdit.setText(filename)
+ finally:
+ self.__pathEdit.blockSignals(old)
+ self.__pathChanged()
+
+ # Selected data
+
+ def selectUrl(self, url):
+ """Sets the data dialog's current data url.
+
+ :param Union[str,DataUrl] url: URL identifying a data (it can be a
+ `DataUrl` object)
+ """
+ if isinstance(url, silx.io.url.DataUrl):
+ url = url.path()
+ self.__directoryLoadedFilter = ""
+ old = self.__pathEdit.blockSignals(True)
+ try:
+ self.__pathEdit.setText(url)
+ finally:
+ self.__pathEdit.blockSignals(old)
+ self.__pathChanged()
+
+ def selectedUrl(self):
+ """Returns the URL from the file system to the data.
+
+ If the dialog is not validated, the path can be an intermediat
+ selected path, or an invalid path.
+
+ :rtype: str
+ """
+ return self.__pathEdit.text()
+
+ def selectedDataUrl(self):
+ """Returns the URL as a :class:`DataUrl` from the file system to the
+ data.
+
+ If the dialog is not validated, the path can be an intermediat
+ selected path, or an invalid path.
+
+ :rtype: DataUrl
+ """
+ url = self.selectedUrl()
+ return silx.io.url.DataUrl(url)
+
+ def directory(self):
+ """Returns the path from the current browsed directory.
+
+ :rtype: str
+ """
+ if self.__directory is not None:
+ # At post execution, returns the cache
+ return self.__directory
+
+ index = self.__browser.rootIndex()
+ if index.model() is self.__fileModel:
+ path = self.__fileModel.filePath(index)
+ return path
+ elif index.model() is self.__dataModel:
+ path = os.path.dirname(self.__h5.file.filename)
+ return path
+ else:
+ return ""
+
+ def _selectedData(self):
+ """Returns the internal selected data
+
+ :rtype: numpy.ndarray
+ """
+ return self.__selectedData
+
+ # Filters
+
+ def selectedNameFilter(self):
+ """Returns the filter that the user selected in the file dialog."""
+ return self.__fileTypeCombo.currentText()
+
+ # History
+
+ def history(self):
+ """Returns the browsing history of the filedialog as a list of paths.
+
+ :rtype: List<str>
+ """
+ if len(self.__currentHistory) <= 1:
+ return []
+ history = self.__currentHistory[0:self.__currentHistoryLocation]
+ return list(history)
+
+ def setHistory(self, history):
+ self.__currentHistory = []
+ self.__currentHistory.extend(history)
+ self.__currentHistoryLocation = len(self.__currentHistory) - 1
+ self.__updateActionHistory()
+
+ # Colormap
+
+ def colormap(self):
+ if self.__previewWidget is None:
+ return None
+ return self.__previewWidget.colormap()
+
+ def setColormap(self, colormap):
+ if self.__previewWidget is None:
+ raise RuntimeError("No preview widget defined")
+ self.__previewWidget.setColormap(colormap)
+
+ # Sidebar
+
+ def setSidebarUrls(self, urls):
+ """Sets the urls that are located in the sidebar."""
+ if self.__sidebar is None:
+ return
+ self.__sidebar.setUrls(urls)
+
+ def sidebarUrls(self):
+ """Returns a list of urls that are currently in the sidebar."""
+ if self.__sidebar is None:
+ return []
+ return self.__sidebar.urls()
+
+ # State
+
+ __serialVersion = 1
+ """Store the current version of the serialized data"""
+
+ @classmethod
+ def qualifiedName(cls):
+ return "%s.%s" % (cls.__module__, cls.__name__)
+
+ def restoreState(self, state):
+ """Restores the dialogs's layout, history and current directory to the
+ state specified.
+
+ :param qt.QByteArray state: Stream containing the new state
+ :rtype: bool
+ """
+ stream = qt.QDataStream(state, qt.QIODevice.ReadOnly)
+
+ qualifiedName = stream.readQString()
+ if qualifiedName != self.qualifiedName():
+ _logger.warning("Stored state contains an invalid qualified name. %s restoration cancelled.", self.__class__.__name__)
+ return False
+
+ version = stream.readInt32()
+ if version != self.__serialVersion:
+ _logger.warning("Stored state contains an invalid version. %s restoration cancelled.", self.__class__.__name__)
+ return False
+
+ result = True
+
+ splitterData = stream.readQVariant()
+ sidebarUrls = stream.readQStringList()
+ history = stream.readQStringList()
+ workingDirectory = stream.readQString()
+ browserData = stream.readQVariant()
+ viewMode = stream.readInt32()
+ colormapData = stream.readQVariant()
+
+ result &= self.__splitter.restoreState(splitterData)
+ sidebarUrls = [qt.QUrl(s) for s in sidebarUrls]
+ self.setSidebarUrls(list(sidebarUrls))
+ history = [s for s in history]
+ self.setHistory(list(history))
+ if workingDirectory is not None:
+ self.setDirectory(workingDirectory)
+ result &= self.__browser.restoreState(browserData)
+ self.setViewMode(viewMode)
+ colormap = self.colormap()
+ if colormap is not None:
+ result &= self.colormap().restoreState(colormapData)
+
+ return result
+
+ def saveState(self):
+ """Saves the state of the dialog's layout, history and current
+ directory.
+
+ :rtype: qt.QByteArray
+ """
+ data = qt.QByteArray()
+ stream = qt.QDataStream(data, qt.QIODevice.WriteOnly)
+
+ s = self.qualifiedName()
+ stream.writeQString(u"%s" % s)
+ stream.writeInt32(self.__serialVersion)
+ stream.writeQVariant(self.__splitter.saveState())
+ strings = [u"%s" % s.toString() for s in self.sidebarUrls()]
+ stream.writeQStringList(strings)
+ strings = [u"%s" % s for s in self.history()]
+ stream.writeQStringList(strings)
+ stream.writeQString(u"%s" % self.directory())
+ stream.writeQVariant(self.__browser.saveState())
+ stream.writeInt32(self.viewMode())
+ colormap = self.colormap()
+ if colormap is not None:
+ stream.writeQVariant(self.colormap().saveState())
+ else:
+ stream.writeQVariant(None)
+
+ return data
diff --git a/silx/gui/dialog/DataFileDialog.py b/silx/gui/dialog/DataFileDialog.py
new file mode 100644
index 0000000..7ff1258
--- /dev/null
+++ b/silx/gui/dialog/DataFileDialog.py
@@ -0,0 +1,342 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""
+This module contains an :class:`DataFileDialog`.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "14/02/2018"
+
+import logging
+from silx.gui import qt
+from silx.gui.hdf5.Hdf5Formatter import Hdf5Formatter
+import silx.io
+from .AbstractDataFileDialog import AbstractDataFileDialog
+from silx.third_party import enum
+try:
+ import fabio
+except ImportError:
+ fabio = None
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _DataPreview(qt.QWidget):
+ """Provide a preview of the selected image"""
+
+ def __init__(self, parent=None):
+ super(_DataPreview, self).__init__(parent)
+
+ self.__formatter = Hdf5Formatter(self)
+ self.__data = None
+ self.__info = qt.QTableView(self)
+ self.__model = qt.QStandardItemModel(self)
+ self.__info.setModel(self.__model)
+ self.__info.horizontalHeader().hide()
+ self.__info.horizontalHeader().setStretchLastSection(True)
+ layout = qt.QVBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self.__info)
+ self.setLayout(layout)
+
+ def colormap(self):
+ return None
+
+ def setColormap(self, colormap):
+ # Ignored
+ pass
+
+ def sizeHint(self):
+ return qt.QSize(200, 200)
+
+ def setData(self, data, fromDataSelector=False):
+ self.__info.setEnabled(data is not None)
+ if data is None:
+ self.__model.clear()
+ else:
+ self.__model.clear()
+
+ if silx.io.is_dataset(data):
+ kind = "Dataset"
+ elif silx.io.is_group(data):
+ kind = "Group"
+ elif silx.io.is_file(data):
+ kind = "File"
+ else:
+ kind = "Unknown"
+
+ headers = []
+
+ basename = data.name.split("/")[-1]
+ if basename == "":
+ basename = "/"
+ headers.append("Basename")
+ self.__model.appendRow([qt.QStandardItem(basename)])
+ headers.append("Kind")
+ self.__model.appendRow([qt.QStandardItem(kind)])
+ if hasattr(data, "dtype"):
+ headers.append("Type")
+ text = self.__formatter.humanReadableType(data)
+ self.__model.appendRow([qt.QStandardItem(text)])
+ if hasattr(data, "shape"):
+ headers.append("Shape")
+ text = self.__formatter.humanReadableShape(data)
+ self.__model.appendRow([qt.QStandardItem(text)])
+ if hasattr(data, "attrs") and "NX_class" in data.attrs:
+ headers.append("NX_class")
+ value = data.attrs["NX_class"]
+ formatter = self.__formatter.textFormatter()
+ old = formatter.useQuoteForText()
+ formatter.setUseQuoteForText(False)
+ text = self.__formatter.textFormatter().toString(value)
+ formatter.setUseQuoteForText(old)
+ self.__model.appendRow([qt.QStandardItem(text)])
+ self.__model.setVerticalHeaderLabels(headers)
+ self.__data = data
+
+ def __imageItem(self):
+ image = self.__plot.getImage("data")
+ return image
+
+ def data(self):
+ if self.__data is not None:
+ if hasattr(self.__data, "name"):
+ # in case of HDF5
+ if self.__data.name is None:
+ # The dataset was closed
+ self.__data = None
+ return self.__data
+
+ def clear(self):
+ self.__data = None
+ self.__info.setText("")
+
+
+class DataFileDialog(AbstractDataFileDialog):
+ """The `DataFileDialog` class provides a dialog that allow users to select
+ any datasets or groups from an HDF5-like file.
+
+ The `DataFileDialog` class enables a user to traverse the file system in
+ order to select an HDF5-like file. Then to traverse the file to select an
+ HDF5 node.
+
+ .. image:: img/datafiledialog.png
+
+ The selected data is any kind of group or dataset. It can be restricted
+ to only existing datasets or only existing groups using
+ :meth:`setFilterMode`. A callback can be defining using
+ :meth:`setFilterCallback` to filter even more data which can be returned.
+
+ Filtering data which can be returned by a `DataFileDialog` can be done like
+ that:
+
+ .. code-block:: python
+
+ # Force to return only a dataset
+ dialog = DataFileDialog()
+ dialog.setFilterMode(DataFileDialog.FilterMode.ExistingDataset)
+
+ .. code-block:: python
+
+ def customFilter(obj):
+ if "NX_class" in obj.attrs:
+ return obj.attrs["NX_class"] in [b"NXentry", u"NXentry"]
+ return False
+
+ # Force to return an NX entry
+ dialog = DataFileDialog()
+ # 1st, filter out everything which is not a group
+ dialog.setFilterMode(DataFileDialog.FilterMode.ExistingGroup)
+ # 2nd, check what NX_class is an NXentry
+ dialog.setFilterCallback(customFilter)
+
+ Executing a `DataFileDialog` can be done like that:
+
+ .. code-block:: python
+
+ dialog = DataFileDialog()
+ result = dialog.exec_()
+ if result:
+ print("Selection:")
+ print(dialog.selectedFile())
+ print(dialog.selectedUrl())
+ else:
+ print("Nothing selected")
+
+ If the selection is a dataset you can access to the data using
+ :meth:`selectedData`.
+
+ If the selection is a group or if you want to read the selected object on
+ your own you can use the `silx.io` API.
+
+ .. code-block:: python
+
+ url = dialog.selectedUrl()
+ with silx.io.open(url) as data:
+ pass
+
+ Or by loading the file first
+
+ .. code-block:: python
+
+ url = dialog.selectedDataUrl()
+ with silx.io.open(url.file_path()) as h5:
+ data = h5[url.data_path()]
+
+ Or by using `h5py` library
+
+ .. code-block:: python
+
+ url = dialog.selectedDataUrl()
+ with h5py.File(url.file_path()) as h5:
+ data = h5[url.data_path()]
+ """
+
+ class FilterMode(enum.Enum):
+ """This enum is used to indicate what the user may select in the
+ dialog; i.e. what the dialog will return if the user clicks OK."""
+
+ AnyNode = 0
+ """Any existing node from an HDF5-like file."""
+ ExistingDataset = 1
+ """An existing HDF5-like dataset."""
+ ExistingGroup = 2
+ """An existing HDF5-like group. A file root is a group."""
+
+ def __init__(self, parent=None):
+ AbstractDataFileDialog.__init__(self, parent=parent)
+ self.__filter = DataFileDialog.FilterMode.AnyNode
+ self.__filterCallback = None
+
+ def selectedData(self):
+ """Returns the selected data by using the :meth:`silx.io.get_data`
+ API with the selected URL provided by the dialog.
+
+ If the URL identify a group of a file it will raise an exception. For
+ group or file you have to use on your own the API :meth:`silx.io.open`.
+
+ :rtype: numpy.ndarray
+ :raise ValueError: If the URL do not link to a dataset
+ """
+ url = self.selectedUrl()
+ return silx.io.get_data(url)
+
+ def _createPreviewWidget(self, parent):
+ previewWidget = _DataPreview(parent)
+ previewWidget.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Expanding)
+ return previewWidget
+
+ def _createSelectorWidget(self, parent):
+ # There is no selector
+ return None
+
+ def _createPreviewToolbar(self, parent, dataPreviewWidget, dataSelectorWidget):
+ # There is no toolbar
+ return None
+
+ def _isDataSupportable(self, data):
+ """Check if the selected data can be supported at one point.
+
+ If true, the data selector will be checked and it will update the data
+ preview. Else the selecting is disabled.
+
+ :rtype: bool
+ """
+ # Everything is supported
+ return True
+
+ def _isFabioFilesSupported(self):
+ # Everything is supported
+ return False
+
+ def _isDataSupported(self, data):
+ """Check if the data can be returned by the dialog.
+
+ If true, this data can be returned by the dialog and the open button
+ will be enabled. If false the button will be disabled.
+
+ :rtype: bool
+ """
+ if self.__filter == DataFileDialog.FilterMode.AnyNode:
+ accepted = True
+ elif self.__filter == DataFileDialog.FilterMode.ExistingDataset:
+ accepted = silx.io.is_dataset(data)
+ elif self.__filter == DataFileDialog.FilterMode.ExistingGroup:
+ accepted = silx.io.is_group(data)
+ else:
+ raise ValueError("Filter %s is not supported" % self.__filter)
+ if not accepted:
+ return False
+ if self.__filterCallback is not None:
+ try:
+ return self.__filterCallback(data)
+ except Exception:
+ _logger.error("Error while executing custom callback", exc_info=True)
+ return False
+ return True
+
+ def setFilterCallback(self, callback):
+ """Set the filter callback. This filter is applied only if the filter
+ mode (:meth:`filterMode`) first accepts the selected data.
+
+ It is not supposed to be set while the dialog is being used.
+
+ :param callable callback: Define a custom function returning a boolean
+ and taking as argument an h5-like node. If the function returns true
+ the dialog can return the associated URL.
+ """
+ self.__filterCallback = callback
+
+ def setFilterMode(self, mode):
+ """Set the filter mode.
+
+ It is not supposed to be set while the dialog is being used.
+
+ :param DataFileDialog.FilterMode mode: The new filter.
+ """
+ self.__filter = mode
+
+ def fileMode(self):
+ """Returns the filter mode.
+
+ :rtype: DataFileDialog.FilterMode
+ """
+ return self.__filter
+
+ def _displayedDataInfo(self, dataBeforeSelection, dataAfterSelection):
+ """Returns the text displayed under the data preview.
+
+ This zone is used to display error in case or problem of data selection
+ or problems with IO.
+
+ :param numpy.ndarray dataAfterSelection: Data as it is after the
+ selection widget (basically the data from the preview widget)
+ :param numpy.ndarray dataAfterSelection: Data as it is before the
+ selection widget (basically the data from the browsing widget)
+ :rtype: bool
+ """
+ return u""
diff --git a/silx/gui/dialog/FileTypeComboBox.py b/silx/gui/dialog/FileTypeComboBox.py
new file mode 100644
index 0000000..07b11cf
--- /dev/null
+++ b/silx/gui/dialog/FileTypeComboBox.py
@@ -0,0 +1,213 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""
+This module contains utilitaries used by other dialog modules.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "06/02/2018"
+
+try:
+ import fabio
+except ImportError:
+ fabio = None
+import silx.io
+from silx.gui import qt
+
+
+class Codec(object):
+
+ def __init__(self, any_fabio=False, any_silx=False, fabio_codec=None, auto=False):
+ self.__any_fabio = any_fabio
+ self.__any_silx = any_silx
+ self.fabio_codec = fabio_codec
+ self.__auto = auto
+
+ def is_autodetect(self):
+ return self.__auto
+
+ def is_fabio_codec(self):
+ return self.__any_fabio or self.fabio_codec is not None
+
+ def is_silx_codec(self):
+ return self.__any_silx
+
+
+class FileTypeComboBox(qt.QComboBox):
+ """
+ A combobox providing all image file formats supported by fabio and silx.
+
+ It provides access for each fabio codecs individually.
+ """
+
+ EXTENSIONS_ROLE = qt.Qt.UserRole + 1
+
+ CODEC_ROLE = qt.Qt.UserRole + 2
+
+ INDENTATION = u"\u2022 "
+
+ def __init__(self, parent=None):
+ qt.QComboBox.__init__(self, parent)
+ self.__fabioUrlSupported = True
+ self.__initItems()
+
+ def setFabioUrlSupproted(self, isSupported):
+ if self.__fabioUrlSupported == isSupported:
+ return
+ self.__fabioUrlSupported = isSupported
+ self.__initItems()
+
+ def __initItems(self):
+ self.clear()
+ if fabio is not None and self.__fabioUrlSupported:
+ self.__insertFabioFormats()
+ self.__insertSilxFormats()
+ self.__insertAllSupported()
+ self.__insertAnyFiles()
+
+ def __insertAnyFiles(self):
+ index = self.count()
+ self.addItem("All files (*)")
+ self.setItemData(index, ["*"], role=self.EXTENSIONS_ROLE)
+ self.setItemData(index, Codec(auto=True), role=self.CODEC_ROLE)
+
+ def __insertAllSupported(self):
+ allExtensions = set([])
+ for index in range(self.count()):
+ ext = self.itemExtensions(index)
+ allExtensions.update(ext)
+ allExtensions = allExtensions - set("*")
+ list(sorted(list(allExtensions)))
+ index = 0
+ self.insertItem(index, "All supported files")
+ self.setItemData(index, allExtensions, role=self.EXTENSIONS_ROLE)
+ self.setItemData(index, Codec(auto=True), role=self.CODEC_ROLE)
+
+ def __insertSilxFormats(self):
+ formats = silx.io.supported_extensions()
+
+ extensions = []
+ allExtensions = set([])
+
+ for description, ext in formats.items():
+ allExtensions.update(ext)
+ if ext == []:
+ ext = ["*"]
+ extensions.append((description, ext, "silx"))
+ extensions = list(sorted(extensions))
+
+ allExtensions = list(sorted(list(allExtensions)))
+ index = self.count()
+ self.addItem("All supported files, using Silx")
+ self.setItemData(index, allExtensions, role=self.EXTENSIONS_ROLE)
+ self.setItemData(index, Codec(any_silx=True), role=self.CODEC_ROLE)
+
+ for e in extensions:
+ index = self.count()
+ if len(e[1]) < 10:
+ self.addItem("%s%s (%s)" % (self.INDENTATION, e[0], " ".join(e[1])))
+ else:
+ self.addItem("%s%s" % (self.INDENTATION, e[0]))
+ codec = Codec(any_silx=True)
+ self.setItemData(index, e[1], role=self.EXTENSIONS_ROLE)
+ self.setItemData(index, codec, role=self.CODEC_ROLE)
+
+ def __insertFabioFormats(self):
+ formats = fabio.fabioformats.get_classes(reader=True)
+
+ extensions = []
+ allExtensions = set([])
+
+ for reader in formats:
+ if not hasattr(reader, "DESCRIPTION"):
+ continue
+ if not hasattr(reader, "DEFAULT_EXTENSIONS"):
+ continue
+
+ ext = reader.DEFAULT_EXTENSIONS
+ ext = ["*.%s" % e for e in ext]
+ allExtensions.update(ext)
+ if ext == []:
+ ext = ["*"]
+ extensions.append((reader.DESCRIPTION, ext, reader.codec_name()))
+ extensions = list(sorted(extensions))
+
+ allExtensions = list(sorted(list(allExtensions)))
+ index = self.count()
+ self.addItem("All supported files, using Fabio")
+ self.setItemData(index, allExtensions, role=self.EXTENSIONS_ROLE)
+ self.setItemData(index, Codec(any_fabio=True), role=self.CODEC_ROLE)
+
+ for e in extensions:
+ index = self.count()
+ if len(e[1]) < 10:
+ self.addItem("%s%s (%s)" % (self.INDENTATION, e[0], " ".join(e[1])))
+ else:
+ self.addItem(e[0])
+ codec = Codec(fabio_codec=e[2])
+ self.setItemData(index, e[1], role=self.EXTENSIONS_ROLE)
+ self.setItemData(index, codec, role=self.CODEC_ROLE)
+
+ def itemExtensions(self, index):
+ """Returns the extensions associated to an index."""
+ result = self.itemData(index, self.EXTENSIONS_ROLE)
+ if result is None:
+ result = None
+ return result
+
+ def currentExtensions(self):
+ """Returns the current selected extensions."""
+ index = self.currentIndex()
+ return self.itemExtensions(index)
+
+ def indexFromCodec(self, codecName):
+ for i in range(self.count()):
+ codec = self.itemCodec(i)
+ if codecName == "auto":
+ if codec.is_autodetect():
+ return i
+ elif codecName == "silx":
+ if codec.is_silx_codec():
+ return i
+ elif codecName == "fabio":
+ if codec.is_fabio_codec() and codec.fabio_codec is None:
+ return i
+ elif codecName == codec.fabio_codec:
+ return i
+ return -1
+
+ def itemCodec(self, index):
+ """Returns the codec associated to an index."""
+ result = self.itemData(index, self.CODEC_ROLE)
+ if result is None:
+ result = None
+ return result
+
+ def currentCodec(self):
+ """Returns the current selected codec. None if nothing selected
+ or if the item is not a codec"""
+ index = self.currentIndex()
+ return self.itemCodec(index)
diff --git a/silx/gui/dialog/ImageFileDialog.py b/silx/gui/dialog/ImageFileDialog.py
new file mode 100644
index 0000000..c324071
--- /dev/null
+++ b/silx/gui/dialog/ImageFileDialog.py
@@ -0,0 +1,338 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""
+This module contains an :class:`ImageFileDialog`.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "12/02/2018"
+
+import logging
+from silx.gui.plot import actions
+from silx.gui import qt
+from silx.gui.plot.PlotWidget import PlotWidget
+from .AbstractDataFileDialog import AbstractDataFileDialog
+import silx.io
+try:
+ import fabio
+except ImportError:
+ fabio = None
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _ImageSelection(qt.QWidget):
+ """Provide a widget allowing to select an image from an hypercube by
+ selecting a slice."""
+
+ selectionChanged = qt.Signal()
+ """Emitted when the selection change."""
+
+ def __init__(self, parent=None):
+ qt.QWidget.__init__(self, parent)
+ self.__shape = None
+ self.__axis = []
+ layout = qt.QVBoxLayout()
+ self.setLayout(layout)
+
+ def hasVisibleSelectors(self):
+ return self.__visibleSliders > 0
+
+ def isUsed(self):
+ if self.__shape is None:
+ return None
+ return len(self.__shape) > 2
+
+ def getSelectedData(self, data):
+ slicing = self.slicing()
+ image = data[slicing]
+ return image
+
+ def setData(self, data):
+ shape = data.shape
+ if self.__shape is not None:
+ # clean up
+ for widget in self.__axis:
+ self.layout().removeWidget(widget)
+ widget.deleteLater()
+ self.__axis = []
+
+ self.__shape = shape
+ self.__visibleSliders = 0
+
+ if shape is not None:
+ # create expected axes
+ for index in range(len(shape) - 2):
+ axis = qt.QSlider(self)
+ axis.setMinimum(0)
+ axis.setMaximum(shape[index] - 1)
+ axis.setOrientation(qt.Qt.Horizontal)
+ if shape[index] == 1:
+ axis.setVisible(False)
+ else:
+ self.__visibleSliders += 1
+
+ axis.valueChanged.connect(self.__axisValueChanged)
+ self.layout().addWidget(axis)
+ self.__axis.append(axis)
+
+ self.selectionChanged.emit()
+
+ def __axisValueChanged(self):
+ self.selectionChanged.emit()
+
+ def slicing(self):
+ slicing = []
+ for axes in self.__axis:
+ slicing.append(axes.value())
+ return tuple(slicing)
+
+ def setSlicing(self, slicing):
+ for i, value in enumerate(slicing):
+ if i > len(self.__axis):
+ break
+ self.__axis[i].setValue(value)
+
+
+class _ImagePreview(qt.QWidget):
+ """Provide a preview of the selected image"""
+
+ def __init__(self, parent=None):
+ super(_ImagePreview, self).__init__(parent)
+
+ self.__data = None
+ self.__plot = PlotWidget(self)
+ self.__plot.setAxesDisplayed(False)
+ self.__plot.setKeepDataAspectRatio(True)
+ layout = qt.QVBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self.__plot)
+ self.setLayout(layout)
+
+ def resizeEvent(self, event):
+ self.__updateConstraints()
+ return qt.QWidget.resizeEvent(self, event)
+
+ def sizeHint(self):
+ return qt.QSize(200, 200)
+
+ def plot(self):
+ return self.__plot
+
+ def setData(self, data, fromDataSelector=False):
+ if data is None:
+ self.clear()
+ return
+
+ resetzoom = not fromDataSelector
+ previousImage = self.data()
+ if previousImage is not None and data.shape != previousImage.shape:
+ resetzoom = True
+
+ self.__plot.addImage(legend="data", data=data, resetzoom=resetzoom)
+ self.__data = data
+ self.__updateConstraints()
+
+ def __updateConstraints(self):
+ """
+ Update the constraints depending on the size of the widget
+ """
+ image = self.data()
+ if image is None:
+ return
+ size = self.size()
+ if size.width() == 0 or size.height() == 0:
+ return
+
+ heightData, widthData = image.shape
+
+ widthContraint = heightData * size.width() / size.height()
+ if widthContraint > widthData:
+ heightContraint = heightData
+ else:
+ heightContraint = heightData * size.height() / size.width()
+ widthContraint = widthData
+
+ midWidth, midHeight = widthData * 0.5, heightData * 0.5
+ heightContraint, widthContraint = heightContraint * 0.5, widthContraint * 0.5
+
+ axis = self.__plot.getXAxis()
+ axis.setLimitsConstraints(midWidth - widthContraint, midWidth + widthContraint)
+ axis = self.__plot.getYAxis()
+ axis.setLimitsConstraints(midHeight - heightContraint, midHeight + heightContraint)
+
+ def __imageItem(self):
+ image = self.__plot.getImage("data")
+ return image
+
+ def data(self):
+ if self.__data is not None:
+ if hasattr(self.__data, "name"):
+ # in case of HDF5
+ if self.__data.name is None:
+ # The dataset was closed
+ self.__data = None
+ return self.__data
+
+ def colormap(self):
+ image = self.__imageItem()
+ if image is not None:
+ return image.getColormap()
+ return self.__plot.getDefaultColormap()
+
+ def setColormap(self, colormap):
+ self.__plot.setDefaultColormap(colormap)
+
+ def clear(self):
+ self.__data = None
+ image = self.__imageItem()
+ if image is not None:
+ self.__plot.removeImage(legend="data")
+
+
+class ImageFileDialog(AbstractDataFileDialog):
+ """The `ImageFileDialog` class provides a dialog that allow users to select
+ an image from a file.
+
+ The `ImageFileDialog` class enables a user to traverse the file system in
+ order to select one file. Then to traverse the file to select a frame or
+ a slice of a dataset.
+
+ .. image:: img/imagefiledialog_h5.png
+
+ It supports fast access to image files using `FabIO`. Which is not the case
+ of the default silx API. Image files still also can be available using the
+ NeXus layout, by editing the file type combo box.
+
+ .. image:: img/imagefiledialog_edf.png
+
+ The selected data is an numpy array with 2 dimension.
+
+ Using an `ImageFileDialog` can be done like that.
+
+ .. code-block:: python
+
+ dialog = ImageFileDialog()
+ result = dialog.exec_()
+ if result:
+ print("Selection:")
+ print(dialog.selectedFile())
+ print(dialog.selectedUrl())
+ print(dialog.selectedImage())
+ else:
+ print("Nothing selected")
+ """
+
+ def selectedImage(self):
+ """Returns the selected image data as numpy
+
+ :rtype: numpy.ndarray
+ """
+ url = self.selectedUrl()
+ return silx.io.get_data(url)
+
+ def _createPreviewWidget(self, parent):
+ previewWidget = _ImagePreview(parent)
+ previewWidget.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Expanding)
+ return previewWidget
+
+ def _createSelectorWidget(self, parent):
+ return _ImageSelection(parent)
+
+ def _createPreviewToolbar(self, parent, dataPreviewWidget, dataSelectorWidget):
+ plot = dataPreviewWidget.plot()
+ toolbar = qt.QToolBar(parent)
+ toolbar.setIconSize(qt.QSize(16, 16))
+ toolbar.setStyleSheet("QToolBar { border: 0px }")
+ toolbar.addAction(actions.mode.ZoomModeAction(plot, parent))
+ toolbar.addAction(actions.mode.PanModeAction(plot, parent))
+ toolbar.addSeparator()
+ toolbar.addAction(actions.control.ResetZoomAction(plot, parent))
+ toolbar.addSeparator()
+ toolbar.addAction(actions.control.ColormapAction(plot, parent))
+ return toolbar
+
+ def _isDataSupportable(self, data):
+ """Check if the selected data can be supported at one point.
+
+ If true, the data selector will be checked and it will update the data
+ preview. Else the selecting is disabled.
+
+ :rtype: bool
+ """
+ if not hasattr(data, "dtype"):
+ # It is not an HDF5 dataset nor a fabio image wrapper
+ return False
+
+ if data is None or data.shape is None:
+ return False
+
+ if data.dtype.kind not in set(["f", "u", "i", "b"]):
+ return False
+
+ dim = len(data.shape)
+ return dim >= 2
+
+ def _isFabioFilesSupported(self):
+ return True
+
+ def _isDataSupported(self, data):
+ """Check if the data can be returned by the dialog.
+
+ If true, this data can be returned by the dialog and the open button
+ while be enabled. If false the button will be disabled.
+
+ :rtype: bool
+ """
+ dim = len(data.shape)
+ return dim == 2
+
+ def _displayedDataInfo(self, dataBeforeSelection, dataAfterSelection):
+ """Returns the text displayed under the data preview.
+
+ This zone is used to display error in case or problem of data selection
+ or problems with IO.
+
+ :param numpy.ndarray dataAfterSelection: Data as it is after the
+ selection widget (basically the data from the preview widget)
+ :param numpy.ndarray dataAfterSelection: Data as it is before the
+ selection widget (basically the data from the browsing widget)
+ :rtype: bool
+ """
+ destination = self.__formatShape(dataAfterSelection.shape)
+ source = self.__formatShape(dataBeforeSelection.shape)
+ return u"%s \u2192 %s" % (source, destination)
+
+ def __formatShape(self, shape):
+ result = []
+ for s in shape:
+ if isinstance(s, slice):
+ v = u"\u2026"
+ else:
+ v = str(s)
+ result.append(v)
+ return u" \u00D7 ".join(result)
diff --git a/silx/gui/dialog/SafeFileIconProvider.py b/silx/gui/dialog/SafeFileIconProvider.py
new file mode 100644
index 0000000..7fac7c0
--- /dev/null
+++ b/silx/gui/dialog/SafeFileIconProvider.py
@@ -0,0 +1,150 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""
+This module contains :class:`SafeIconProvider`.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "31/10/2017"
+
+import sys
+import logging
+from silx.gui import qt
+
+
+_logger = logging.getLogger(__name__)
+
+
+class SafeFileIconProvider(qt.QFileIconProvider):
+ """
+ This class reimplement :class:`qt.QFileIconProvider` to avoid blocking
+ access to the file system.
+
+ It avoid to use `qt.QFileInfo.absoluteFilePath` or
+ `qt.QFileInfo.canonicalPath` to reach drive icons which are known to
+ freeze the file system using network drives.
+
+ Computer root, and drive root paths are filtered. Other paths are not
+ filtered while it is anyway needed to synchronoze a drive to accesss to it.
+ """
+
+ WIN32_DRIVE_UNKNOWN = 0
+ """The drive type cannot be determined."""
+ WIN32_DRIVE_NO_ROOT_DIR = 1
+ """The root path is invalid; for example, there is no volume mounted at the
+ specified path."""
+ WIN32_DRIVE_REMOVABLE = 2
+ """The drive has removable media; for example, a floppy drive, thumb drive,
+ or flash card reader."""
+ WIN32_DRIVE_FIXED = 3
+ """The drive has fixed media; for example, a hard disk drive or flash
+ drive."""
+ WIN32_DRIVE_REMOTE = 4
+ """The drive is a remote (network) drive."""
+ WIN32_DRIVE_CDROM = 5
+ """The drive is a CD-ROM drive."""
+ WIN32_DRIVE_RAMDISK = 6
+ """The drive is a RAM disk."""
+
+ def __init__(self):
+ qt.QFileIconProvider.__init__(self)
+ self.__filterDirAndFiles = False
+ if sys.platform == "win32":
+ self._windowsTypes = {}
+ item = "Drive", qt.QStyle.SP_DriveHDIcon
+ self._windowsTypes[self.WIN32_DRIVE_UNKNOWN] = item
+ item = "Invalid root", qt.QStyle.SP_DriveHDIcon
+ self._windowsTypes[self.WIN32_DRIVE_NO_ROOT_DIR] = item
+ item = "Removable", qt.QStyle.SP_DriveNetIcon
+ self._windowsTypes[self.WIN32_DRIVE_REMOVABLE] = item
+ item = "Drive", qt.QStyle.SP_DriveHDIcon
+ self._windowsTypes[self.WIN32_DRIVE_FIXED] = item
+ item = "Remote", qt.QStyle.SP_DriveNetIcon
+ self._windowsTypes[self.WIN32_DRIVE_REMOTE] = item
+ item = "CD-ROM", qt.QStyle.SP_DriveCDIcon
+ self._windowsTypes[self.WIN32_DRIVE_CDROM] = item
+ item = "RAM disk", qt.QStyle.SP_DriveHDIcon
+ self._windowsTypes[self.WIN32_DRIVE_RAMDISK] = item
+
+ def __windowsDriveTypeId(self, info):
+ try:
+ import ctypes
+ path = info.filePath()
+ dtype = ctypes.cdll.kernel32.GetDriveTypeW(path)
+ except Exception:
+ _logger.warning("Impossible to identify drive %s" % path)
+ _logger.debug("Backtrace", exc_info=True)
+ return self.WIN32_DRIVE_UNKNOWN
+ return dtype
+
+ def __windowsDriveIcon(self, info):
+ dtype = self.__windowsDriveTypeId(info)
+ default = self._windowsTypes[self.WIN32_DRIVE_UNKNOWN]
+ driveInfo = self._windowsTypes.get(dtype, default)
+ style = qt.QApplication.instance().style()
+ icon = style.standardIcon(driveInfo[1])
+ return icon
+
+ def __windowsDriveType(self, info):
+ dtype = self.__windowsDriveTypeId(info)
+ default = self._windowsTypes[self.WIN32_DRIVE_UNKNOWN]
+ driveInfo = self._windowsTypes.get(dtype, default)
+ return driveInfo[0]
+
+ def icon(self, info):
+ style = qt.QApplication.instance().style()
+ path = info.filePath()
+ if path in ["", "/"]:
+ # That's the computer root on Windows or Linux
+ result = style.standardIcon(qt.QStyle.SP_ComputerIcon)
+ elif sys.platform == "win32" and path[-2] == ":":
+ # That's a drive on Windows
+ result = self.__windowsDriveIcon(info)
+ elif self.__filterDirAndFiles:
+ if info.isDir():
+ result = style.standardIcon(qt.QStyle.SP_DirIcon)
+ else:
+ result = style.standardIcon(qt.QStyle.SP_FileIcon)
+ else:
+ result = qt.QFileIconProvider.icon(self, info)
+ return result
+
+ def type(self, info):
+ path = info.filePath()
+ if path in ["", "/"]:
+ # That's the computer root on Windows or Linux
+ result = "Computer"
+ elif sys.platform == "win32" and path[-2] == ":":
+ # That's a drive on Windows
+ result = self.__windowsDriveType(info)
+ elif self.__filterDirAndFiles:
+ if info.isDir():
+ result = "Directory"
+ else:
+ result = info.suffix()
+ else:
+ result = qt.QFileIconProvider.type(self, info)
+ return result
diff --git a/silx/gui/dialog/SafeFileSystemModel.py b/silx/gui/dialog/SafeFileSystemModel.py
new file mode 100644
index 0000000..8a97974
--- /dev/null
+++ b/silx/gui/dialog/SafeFileSystemModel.py
@@ -0,0 +1,802 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""
+This module contains an :class:`SafeFileSystemModel`.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "22/11/2017"
+
+import sys
+import os.path
+import logging
+import weakref
+from silx.gui import qt
+from silx.third_party import six
+from .SafeFileIconProvider import SafeFileIconProvider
+
+_logger = logging.getLogger(__name__)
+
+
+class _Item(object):
+
+ def __init__(self, fileInfo):
+ self.__fileInfo = fileInfo
+ self.__parent = None
+ self.__children = None
+ self.__absolutePath = None
+
+ def isDrive(self):
+ if sys.platform == "win32":
+ return self.parent().parent() is None
+ else:
+ return False
+
+ def isRoot(self):
+ return self.parent() is None
+
+ def isFile(self):
+ """
+ Returns true if the path is a file.
+
+ It avoid to access to the `Qt.QFileInfo` in case the file is a drive.
+ """
+ if self.isDrive():
+ return False
+ return self.__fileInfo.isFile()
+
+ def isDir(self):
+ """
+ Returns true if the path is a directory.
+
+ The default `qt.QFileInfo.isDir` can freeze the file system with
+ network drives. This function avoid the freeze in case of browsing
+ the root.
+ """
+ if self.isDrive():
+ # A drive is a directory, we don't have to synchronize the
+ # drive to know that
+ return True
+ return self.__fileInfo.isDir()
+
+ def absoluteFilePath(self):
+ """
+ Returns an absolute path including the file name.
+
+ This function uses in most cases the default
+ `qt.QFileInfo.absoluteFilePath`. But it is known to freeze the file
+ system with network drives.
+
+ This function uses `qt.QFileInfo.filePath` in case of root drives, to
+ avoid this kind of issues. In case of drive, the result is the same,
+ while the file path is already absolute.
+
+ :rtype: str
+ """
+ if self.__absolutePath is None:
+ if self.isRoot():
+ path = ""
+ elif self.isDrive():
+ path = self.__fileInfo.filePath()
+ else:
+ path = os.path.join(self.parent().absoluteFilePath(), self.__fileInfo.fileName())
+ if path == "":
+ return "/"
+ self.__absolutePath = path
+ return self.__absolutePath
+
+ def child(self):
+ self.populate()
+ return self.__children
+
+ def childAt(self, position):
+ self.populate()
+ return self.__children[position]
+
+ def childCount(self):
+ self.populate()
+ return len(self.__children)
+
+ def indexOf(self, item):
+ self.populate()
+ return self.__children.index(item)
+
+ def parent(self):
+ parent = self.__parent
+ if parent is None:
+ return None
+ return parent()
+
+ def filePath(self):
+ return self.__fileInfo.filePath()
+
+ def fileName(self):
+ if self.isDrive():
+ name = self.absoluteFilePath()
+ if name[-1] == "/":
+ name = name[:-1]
+ return name
+ return os.path.basename(self.absoluteFilePath())
+
+ def fileInfo(self):
+ """
+ Returns the Qt file info.
+
+ :rtype: Qt.QFileInfo
+ """
+ return self.__fileInfo
+
+ def _setParent(self, parent):
+ self.__parent = weakref.ref(parent)
+
+ def findChildrenByPath(self, path):
+ if path == "":
+ return self
+ path = path.replace("\\", "/")
+ if path[-1] == "/":
+ path = path[:-1]
+ names = path.split("/")
+ caseSensitive = qt.QFSFileEngine(path).caseSensitive()
+ count = len(names)
+ cursor = self
+ for name in names:
+ for item in cursor.child():
+ if caseSensitive:
+ same = item.fileName() == name
+ else:
+ same = item.fileName().lower() == name.lower()
+ if same:
+ cursor = item
+ count -= 1
+ break
+ else:
+ return None
+ if count == 0:
+ break
+ else:
+ return None
+ return cursor
+
+ def populate(self):
+ if self.__children is not None:
+ return
+ self.__children = []
+ if self.isRoot():
+ items = qt.QDir.drives()
+ else:
+ directory = qt.QDir(self.absoluteFilePath())
+ filters = qt.QDir.AllEntries | qt.QDir.Hidden | qt.QDir.System
+ items = directory.entryInfoList(filters)
+ for fileInfo in items:
+ i = _Item(fileInfo)
+ self.__children.append(i)
+ i._setParent(self)
+
+
+class _RawFileSystemModel(qt.QAbstractItemModel):
+ """
+ This class implement a file system model and try to avoid freeze. On Qt4,
+ :class:`qt.QFileSystemModel` is known to freeze the file system when
+ network drives are available.
+
+ To avoid this behaviour, this class does not use
+ `qt.QFileInfo.absoluteFilePath` nor `qt.QFileInfo.canonicalPath` to reach
+ information on drives.
+
+ This model do not take care of sorting and filtering. This features are
+ managed by another model, by composition.
+
+ And because it is the end of life of Qt4, we do not implement asynchronous
+ loading of files as it is done by :class:`qt.QFileSystemModel`, nor some
+ useful features.
+ """
+
+ __directoryLoadedSync = qt.Signal(str)
+ """This signal is connected asynchronously to a slot. It allows to
+ emit directoryLoaded as an asynchronous signal."""
+
+ directoryLoaded = qt.Signal(str)
+ """This signal is emitted when the gatherer thread has finished to load the
+ path."""
+
+ rootPathChanged = qt.Signal(str)
+ """This signal is emitted whenever the root path has been changed to a
+ newPath."""
+
+ NAME_COLUMN = 0
+ SIZE_COLUMN = 1
+ TYPE_COLUMN = 2
+ LAST_MODIFIED_COLUMN = 3
+
+ def __init__(self, parent=None):
+ qt.QAbstractItemModel.__init__(self, parent)
+ self.__computer = _Item(qt.QFileInfo())
+ self.__header = "Name", "Size", "Type", "Last modification"
+ self.__currentPath = ""
+ self.__iconProvider = SafeFileIconProvider()
+ self.__directoryLoadedSync.connect(self.__emitDirectoryLoaded, qt.Qt.QueuedConnection)
+
+ def headerData(self, section, orientation, role=qt.Qt.DisplayRole):
+ if orientation == qt.Qt.Horizontal:
+ if role == qt.Qt.DisplayRole:
+ return self.__header[section]
+ if role == qt.Qt.TextAlignmentRole:
+ return qt.Qt.AlignRight if section == 1 else qt.Qt.AlignLeft
+ return None
+
+ def flags(self, index):
+ if not index.isValid():
+ return 0
+ return qt.Qt.ItemIsEnabled | qt.Qt.ItemIsSelectable
+
+ def columnCount(self, parent=qt.QModelIndex()):
+ return len(self.__header)
+
+ def rowCount(self, parent=qt.QModelIndex()):
+ item = self.__getItem(parent)
+ return item.childCount()
+
+ def data(self, index, role=qt.Qt.DisplayRole):
+ if not index.isValid():
+ return None
+
+ column = index.column()
+ if role in [qt.Qt.DisplayRole, qt.Qt.EditRole]:
+ if column == self.NAME_COLUMN:
+ return self.__displayName(index)
+ elif column == self.SIZE_COLUMN:
+ return self.size(index)
+ elif column == self.TYPE_COLUMN:
+ return self.type(index)
+ elif column == self.LAST_MODIFIED_COLUMN:
+ return self.lastModified(index)
+ else:
+ _logger.warning("data: invalid display value column %d", index.column())
+ elif role == qt.QFileSystemModel.FilePathRole:
+ return self.filePath(index)
+ elif role == qt.QFileSystemModel.FileNameRole:
+ return self.fileName(index)
+ elif role == qt.Qt.DecorationRole:
+ if column == self.NAME_COLUMN:
+ icon = self.fileIcon(index)
+ if icon is None or icon.isNull():
+ if self.isDir(index):
+ self.__iconProvider.icon(qt.QFileIconProvider.Folder)
+ else:
+ self.__iconProvider.icon(qt.QFileIconProvider.File)
+ return icon
+ elif role == qt.Qt.TextAlignmentRole:
+ if column == self.SIZE_COLUMN:
+ return qt.Qt.AlignRight
+ elif role == qt.QFileSystemModel.FilePermissions:
+ return self.permissions(index)
+
+ return None
+
+ def index(self, *args, **kwargs):
+ path_api = False
+ path_api |= len(args) >= 1 and isinstance(args[0], six.string_types)
+ path_api |= "path" in kwargs
+
+ if path_api:
+ return self.__indexFromPath(*args, **kwargs)
+ else:
+ return self.__index(*args, **kwargs)
+
+ def __index(self, row, column, parent=qt.QModelIndex()):
+ if parent.isValid() and parent.column() != 0:
+ return None
+
+ parentItem = self.__getItem(parent)
+ item = parentItem.childAt(row)
+ return self.createIndex(row, column, item)
+
+ def __indexFromPath(self, path, column=0):
+ """
+ Uses the index(str) C++ API
+
+ :rtype: qt.QModelIndex
+ """
+ if path == "":
+ return qt.QModelIndex()
+
+ item = self.__computer.findChildrenByPath(path)
+ if item is None:
+ return qt.QModelIndex()
+
+ return self.createIndex(item.parent().indexOf(item), column, item)
+
+ def parent(self, index):
+ if not index.isValid():
+ return qt.QModelIndex()
+
+ item = self.__getItem(index)
+ if index is None:
+ return qt.QModelIndex()
+
+ parent = item.parent()
+ if parent is None or parent is self.__computer:
+ return qt.QModelIndex()
+
+ return self.createIndex(parent.parent().indexOf(parent), 0, parent)
+
+ def __emitDirectoryLoaded(self, path):
+ self.directoryLoaded.emit(path)
+
+ def __emitRootPathChanged(self, path):
+ self.rootPathChanged.emit(path)
+
+ def __getItem(self, index):
+ if not index.isValid():
+ return self.__computer
+ item = index.internalPointer()
+ return item
+
+ def fileIcon(self, index):
+ item = self.__getItem(index)
+ if self.__iconProvider is not None:
+ fileInfo = item.fileInfo()
+ result = self.__iconProvider.icon(fileInfo)
+ else:
+ style = qt.QApplication.instance().style()
+ if item.isRoot():
+ result = style.standardIcon(qt.QStyle.SP_ComputerIcon)
+ elif item.isDrive():
+ result = style.standardIcon(qt.QStyle.SP_DriveHDIcon)
+ elif item.isDir():
+ result = style.standardIcon(qt.QStyle.SP_DirIcon)
+ else:
+ result = style.standardIcon(qt.QStyle.SP_FileIcon)
+ return result
+
+ def _item(self, index):
+ item = self.__getItem(index)
+ return item
+
+ def fileInfo(self, index):
+ item = self.__getItem(index)
+ result = item.fileInfo()
+ return result
+
+ def __fileIcon(self, index):
+ item = self.__getItem(index)
+ result = item.fileName()
+ return result
+
+ def __displayName(self, index):
+ item = self.__getItem(index)
+ result = item.fileName()
+ return result
+
+ def fileName(self, index):
+ item = self.__getItem(index)
+ result = item.fileName()
+ return result
+
+ def filePath(self, index):
+ item = self.__getItem(index)
+ result = item.fileInfo().filePath()
+ return result
+
+ def isDir(self, index):
+ item = self.__getItem(index)
+ result = item.isDir()
+ return result
+
+ def lastModified(self, index):
+ item = self.__getItem(index)
+ result = item.fileInfo().lastModified()
+ return result
+
+ def permissions(self, index):
+ item = self.__getItem(index)
+ result = item.fileInfo().permissions()
+ return result
+
+ def size(self, index):
+ item = self.__getItem(index)
+ result = item.fileInfo().size()
+ return result
+
+ def type(self, index):
+ item = self.__getItem(index)
+ if self.__iconProvider is not None:
+ fileInfo = item.fileInfo()
+ result = self.__iconProvider.type(fileInfo)
+ else:
+ if item.isRoot():
+ result = "Computer"
+ elif item.isDrive():
+ result = "Drive"
+ elif item.isDir():
+ result = "Directory"
+ else:
+ fileInfo = item.fileInfo()
+ result = fileInfo.suffix()
+ return result
+
+ # File manipulation
+
+ # bool remove(const QModelIndex & index) const
+ # bool rmdir(const QModelIndex & index) const
+ # QModelIndex mkdir(const QModelIndex & parent, const QString & name)
+
+ # Configuration
+
+ def rootDirectory(self):
+ return qt.QDir(self.rootPath())
+
+ def rootPath(self):
+ return self.__currentPath
+
+ def setRootPath(self, path):
+ if self.__currentPath == path:
+ return
+ self.__currentPath = path
+ item = self.__computer.findChildrenByPath(path)
+ self.__emitRootPathChanged(path)
+ if item is None or item.parent() is None:
+ return qt.QModelIndex()
+ index = self.createIndex(item.parent().indexOf(item), 0, item)
+ self.__directoryLoadedSync.emit(path)
+ return index
+
+ def iconProvider(self):
+ # FIXME: invalidate the model
+ return self.__iconProvider
+
+ def setIconProvider(self, provider):
+ # FIXME: invalidate the model
+ self.__iconProvider = provider
+
+ # bool resolveSymlinks() const
+ # void setResolveSymlinks(bool enable)
+
+ def setNameFilterDisables(self, enable):
+ return None
+
+ def nameFilterDisables(self):
+ return None
+
+ def myComputer(self, role=qt.Qt.DisplayRole):
+ return None
+
+ def setNameFilters(self, filters):
+ return
+
+ def nameFilters(self):
+ return None
+
+ def filter(self):
+ return self.__filters
+
+ def setFilter(self, filters):
+ return
+
+ def setReadOnly(self, enable):
+ assert(enable is True)
+
+ def isReadOnly(self):
+ return False
+
+
+class SafeFileSystemModel(qt.QSortFilterProxyModel):
+ """
+ This class implement a file system model and try to avoid freeze. On Qt4,
+ :class:`qt.QFileSystemModel` is known to freeze the file system when
+ network drives are available.
+
+ To avoid this behaviour, this class does not use
+ `qt.QFileInfo.absoluteFilePath` nor `qt.QFileInfo.canonicalPath` to reach
+ information on drives.
+
+ And because it is the end of life of Qt4, we do not implement asynchronous
+ loading of files as it is done by :class:`qt.QFileSystemModel`, nor some
+ useful features.
+ """
+
+ def __init__(self, parent=None):
+ qt.QSortFilterProxyModel.__init__(self, parent=parent)
+ self.__nameFilterDisables = sys.platform == "darwin"
+ self.__nameFilters = []
+ self.__filters = qt.QDir.AllEntries | qt.QDir.NoDotAndDotDot | qt.QDir.AllDirs
+ sourceModel = _RawFileSystemModel(self)
+ self.setSourceModel(sourceModel)
+
+ @property
+ def directoryLoaded(self):
+ return self.sourceModel().directoryLoaded
+
+ @property
+ def rootPathChanged(self):
+ return self.sourceModel().rootPathChanged
+
+ def index(self, *args, **kwargs):
+ path_api = False
+ path_api |= len(args) >= 1 and isinstance(args[0], six.string_types)
+ path_api |= "path" in kwargs
+
+ if path_api:
+ return self.__indexFromPath(*args, **kwargs)
+ else:
+ return self.__index(*args, **kwargs)
+
+ def __index(self, row, column, parent=qt.QModelIndex()):
+ return qt.QSortFilterProxyModel.index(self, row, column, parent)
+
+ def __indexFromPath(self, path, column=0):
+ """
+ Uses the index(str) C++ API
+
+ :rtype: qt.QModelIndex
+ """
+ if path == "":
+ return qt.QModelIndex()
+
+ index = self.sourceModel().index(path, column)
+ index = self.mapFromSource(index)
+ return index
+
+ def lessThan(self, leftSourceIndex, rightSourceIndex):
+ sourceModel = self.sourceModel()
+ sortColumn = self.sortColumn()
+ if sortColumn == _RawFileSystemModel.NAME_COLUMN:
+ leftItem = sourceModel._item(leftSourceIndex)
+ rightItem = sourceModel._item(rightSourceIndex)
+ if sys.platform != "darwin":
+ # Sort directories before files
+ leftIsDir = leftItem.isDir()
+ rightIsDir = rightItem.isDir()
+ if leftIsDir ^ rightIsDir:
+ return leftIsDir
+ return leftItem.fileName().lower() < rightItem.fileName().lower()
+ elif sortColumn == _RawFileSystemModel.SIZE_COLUMN:
+ left = sourceModel.fileInfo(leftSourceIndex)
+ right = sourceModel.fileInfo(rightSourceIndex)
+ return left.size() < right.size()
+ elif sortColumn == _RawFileSystemModel.TYPE_COLUMN:
+ left = sourceModel.type(leftSourceIndex)
+ right = sourceModel.type(rightSourceIndex)
+ return left < right
+ elif sortColumn == _RawFileSystemModel.LAST_MODIFIED_COLUMN:
+ left = sourceModel.fileInfo(leftSourceIndex)
+ right = sourceModel.fileInfo(rightSourceIndex)
+ return left.lastModified() < right.lastModified()
+ else:
+ _logger.warning("Unsupported sorted column %d", sortColumn)
+
+ return False
+
+ def __filtersAccepted(self, item, filters):
+ """
+ Check individual flag filters.
+ """
+ if not (filters & (qt.QDir.Dirs | qt.QDir.AllDirs)):
+ # Hide dirs
+ if item.isDir():
+ return False
+ if not (filters & qt.QDir.Files):
+ # Hide files
+ if item.isFile():
+ return False
+ if not (filters & qt.QDir.Drives):
+ # Hide drives
+ if item.isDrive():
+ return False
+
+ fileInfo = item.fileInfo()
+ if fileInfo is None:
+ return False
+
+ filterPermissions = (filters & qt.QDir.PermissionMask) != 0
+ if filterPermissions and (filters & (qt.QDir.Dirs | qt.QDir.Files)):
+ if (filters & qt.QDir.Readable):
+ # Hide unreadable
+ if not fileInfo.isReadable():
+ return False
+ if (filters & qt.QDir.Writable):
+ # Hide unwritable
+ if not fileInfo.isWritable():
+ return False
+ if (filters & qt.QDir.Executable):
+ # Hide unexecutable
+ if not fileInfo.isExecutable():
+ return False
+
+ if (filters & qt.QDir.NoSymLinks):
+ # Hide sym links
+ if fileInfo.isSymLink():
+ return False
+
+ if not (filters & qt.QDir.System):
+ # Hide system
+ if not item.isDir() and not item.isFile():
+ return False
+
+ fileName = item.fileName()
+ isDot = fileName == "."
+ isDotDot = fileName == ".."
+
+ if not (filters & qt.QDir.Hidden):
+ # Hide hidden
+ if not (isDot or isDotDot) and fileInfo.isHidden():
+ return False
+
+ if filters & (qt.QDir.NoDot | qt.QDir.NoDotDot | qt.QDir.NoDotAndDotDot):
+ # Hide parent/self references
+ if filters & qt.QDir.NoDot:
+ if isDot:
+ return False
+ if filters & qt.QDir.NoDotDot:
+ if isDotDot:
+ return False
+ if filters & qt.QDir.NoDotAndDotDot:
+ if isDot or isDotDot:
+ return False
+
+ return True
+
+ def filterAcceptsRow(self, sourceRow, sourceParent):
+ if not sourceParent.isValid():
+ return True
+
+ sourceModel = self.sourceModel()
+ index = sourceModel.index(sourceRow, 0, sourceParent)
+ if not index.isValid():
+ return True
+ item = sourceModel._item(index)
+
+ filters = self.__filters
+
+ if item.isDrive():
+ # Let say a user always have access to a drive
+ # It avoid to access to fileInfo then avoid to freeze the file
+ # system
+ return True
+
+ if not self.__filtersAccepted(item, filters):
+ return False
+
+ if self.__nameFilterDisables:
+ return True
+
+ if item.isDir() and (filters & qt.QDir.AllDirs):
+ # dont apply the filters to directory names
+ return True
+
+ return self.__nameFiltersAccepted(item)
+
+ def __nameFiltersAccepted(self, item):
+ if len(self.__nameFilters) == 0:
+ return True
+
+ fileName = item.fileName()
+ for reg in self.__nameFilters:
+ if reg.exactMatch(fileName):
+ return True
+ return False
+
+ def setNameFilterDisables(self, enable):
+ self.__nameFilterDisables = enable
+ self.invalidate()
+
+ def nameFilterDisables(self):
+ return self.__nameFilterDisables
+
+ def myComputer(self, role=qt.Qt.DisplayRole):
+ return self.sourceModel().myComputer(role)
+
+ def setNameFilters(self, filters):
+ self.__nameFilters = []
+ isCaseSensitive = self.__filters & qt.QDir.CaseSensitive
+ caseSensitive = qt.Qt.CaseSensitive if isCaseSensitive else qt.Qt.CaseInsensitive
+ for f in filters:
+ reg = qt.QRegExp(f, caseSensitive, qt.QRegExp.Wildcard)
+ self.__nameFilters.append(reg)
+ self.invalidate()
+
+ def nameFilters(self):
+ return [f.pattern() for f in self.__nameFilters]
+
+ def filter(self):
+ return self.__filters
+
+ def setFilter(self, filters):
+ self.__filters = filters
+ # In case of change of case sensitivity
+ self.setNameFilters(self.nameFilters())
+ self.invalidate()
+
+ def setReadOnly(self, enable):
+ assert(enable is True)
+
+ def isReadOnly(self):
+ return False
+
+ def rootPath(self):
+ return self.sourceModel().rootPath()
+
+ def setRootPath(self, path):
+ index = self.sourceModel().setRootPath(path)
+ index = self.mapFromSource(index)
+ return index
+
+ def flags(self, index):
+ sourceModel = self.sourceModel()
+ index = self.mapToSource(index)
+ filters = sourceModel.flags(index)
+
+ if self.__nameFilterDisables:
+ item = sourceModel._item(index)
+ if not self.__nameFiltersAccepted(item):
+ filters &= ~qt.Qt.ItemIsEnabled
+
+ return filters
+
+ def fileIcon(self, index):
+ sourceModel = self.sourceModel()
+ index = self.mapToSource(index)
+ return sourceModel.fileIcon(index)
+
+ def fileInfo(self, index):
+ sourceModel = self.sourceModel()
+ index = self.mapToSource(index)
+ return sourceModel.fileInfo(index)
+
+ def fileName(self, index):
+ sourceModel = self.sourceModel()
+ index = self.mapToSource(index)
+ return sourceModel.fileName(index)
+
+ def filePath(self, index):
+ sourceModel = self.sourceModel()
+ index = self.mapToSource(index)
+ return sourceModel.filePath(index)
+
+ def isDir(self, index):
+ sourceModel = self.sourceModel()
+ index = self.mapToSource(index)
+ return sourceModel.isDir(index)
+
+ def lastModified(self, index):
+ sourceModel = self.sourceModel()
+ index = self.mapToSource(index)
+ return sourceModel.lastModified(index)
+
+ def permissions(self, index):
+ sourceModel = self.sourceModel()
+ index = self.mapToSource(index)
+ return sourceModel.permissions(index)
+
+ def size(self, index):
+ sourceModel = self.sourceModel()
+ index = self.mapToSource(index)
+ return sourceModel.size(index)
+
+ def type(self, index):
+ sourceModel = self.sourceModel()
+ index = self.mapToSource(index)
+ return sourceModel.type(index)
diff --git a/silx/gui/dialog/__init__.py b/silx/gui/dialog/__init__.py
new file mode 100644
index 0000000..77c5949
--- /dev/null
+++ b/silx/gui/dialog/__init__.py
@@ -0,0 +1,29 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Qt dialogs"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "11/10/2017"
diff --git a/silx/gui/dialog/setup.py b/silx/gui/dialog/setup.py
new file mode 100644
index 0000000..48ab8d8
--- /dev/null
+++ b/silx/gui/dialog/setup.py
@@ -0,0 +1,40 @@
+# coding: utf-8
+# /*##########################################################################
+# Copyright (C) 2016 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ############################################################################*/
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "23/10/2017"
+
+from numpy.distutils.misc_util import Configuration
+
+
+def configuration(parent_package='', top_path=None):
+ config = Configuration('dialog', parent_package, top_path)
+ config.add_subpackage('test')
+ return config
+
+
+if __name__ == "__main__":
+ from numpy.distutils.core import setup
+ setup(configuration=configuration)
diff --git a/silx/gui/dialog/test/__init__.py b/silx/gui/dialog/test/__init__.py
new file mode 100644
index 0000000..eee8aea
--- /dev/null
+++ b/silx/gui/dialog/test/__init__.py
@@ -0,0 +1,47 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Tests for Qt dialogs"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "07/02/2018"
+
+
+import logging
+import os
+import sys
+import unittest
+
+
+_logger = logging.getLogger(__name__)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ from . import test_imagefiledialog
+ from . import test_datafiledialog
+ test_suite.addTest(test_imagefiledialog.suite())
+ test_suite.addTest(test_datafiledialog.suite())
+ return test_suite
diff --git a/silx/gui/dialog/test/test_datafiledialog.py b/silx/gui/dialog/test/test_datafiledialog.py
new file mode 100644
index 0000000..bdda810
--- /dev/null
+++ b/silx/gui/dialog/test/test_datafiledialog.py
@@ -0,0 +1,981 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Test for silx.gui.hdf5 module"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "14/02/2018"
+
+
+import unittest
+import tempfile
+import numpy
+import shutil
+import os
+import io
+import weakref
+
+try:
+ import fabio
+except ImportError:
+ fabio = None
+try:
+ import h5py
+except ImportError:
+ h5py = None
+
+import silx.io.url
+from silx.gui import qt
+from silx.gui.test import utils
+from ..DataFileDialog import DataFileDialog
+from silx.gui.hdf5 import Hdf5TreeModel
+
+_tmpDirectory = None
+
+
+def setUpModule():
+ global _tmpDirectory
+ _tmpDirectory = tempfile.mkdtemp(prefix=__name__)
+
+ data = numpy.arange(100 * 100)
+ data.shape = 100, 100
+
+ if fabio is not None:
+ filename = _tmpDirectory + "/singleimage.edf"
+ image = fabio.edfimage.EdfImage(data=data)
+ image.write(filename)
+
+ if h5py is not None:
+ filename = _tmpDirectory + "/data.h5"
+ f = h5py.File(filename, "w")
+ f["scalar"] = 10
+ f["image"] = data
+ f["cube"] = [data, data + 1, data + 2]
+ f["complex_image"] = data * 1j
+ f["group/image"] = data
+ f["nxdata/foo"] = 10
+ f["nxdata"].attrs["NX_class"] = u"NXdata"
+ f.close()
+
+ filename = _tmpDirectory + "/badformat.h5"
+ with io.open(filename, "wb") as f:
+ f.write(b"{\nHello Nurse!")
+
+
+def tearDownModule():
+ global _tmpDirectory
+ shutil.rmtree(_tmpDirectory)
+ _tmpDirectory = None
+
+
+class _UtilsMixin(object):
+
+ def createDialog(self):
+ self._deleteDialog()
+ self._dialog = self._createDialog()
+ return self._dialog
+
+ def _createDialog(self):
+ return DataFileDialog()
+
+ def _deleteDialog(self):
+ if not hasattr(self, "_dialog"):
+ return
+ if self._dialog is not None:
+ ref = weakref.ref(self._dialog)
+ self._dialog = None
+ self.qWaitForDestroy(ref)
+
+ def qWaitForPendingActions(self, dialog):
+ for _ in range(20):
+ if not dialog.hasPendingEvents():
+ return
+ self.qWait(10)
+ raise RuntimeError("Still have pending actions")
+
+ def assertSamePath(self, path1, path2):
+ path1_ = os.path.normcase(path1)
+ path2_ = os.path.normcase(path2)
+ if path1_ != path2_:
+ # Use the unittest API to log and display error
+ self.assertEquals(path1, path2)
+
+ def assertNotSamePath(self, path1, path2):
+ path1_ = os.path.normcase(path1)
+ path2_ = os.path.normcase(path2)
+ if path1_ == path2_:
+ # Use the unittest API to log and display error
+ self.assertNotEquals(path1, path2)
+
+
+class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
+
+ def tearDown(self):
+ self._deleteDialog()
+ utils.TestCaseQt.tearDown(self)
+
+ def testDisplayAndKeyEscape(self):
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ self.assertTrue(dialog.isVisible())
+
+ self.keyClick(dialog, qt.Qt.Key_Escape)
+ self.assertFalse(dialog.isVisible())
+ self.assertEquals(dialog.result(), qt.QDialog.Rejected)
+
+ def testDisplayAndClickCancel(self):
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ self.assertTrue(dialog.isVisible())
+
+ button = utils.findChildren(dialog, qt.QPushButton, name="cancel")[0]
+ self.mouseClick(button, qt.Qt.LeftButton)
+ self.assertFalse(dialog.isVisible())
+ self.assertFalse(dialog.isVisible())
+ self.assertEquals(dialog.result(), qt.QDialog.Rejected)
+
+ def testDisplayAndClickLockedOpen(self):
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ self.assertTrue(dialog.isVisible())
+
+ button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ self.mouseClick(button, qt.Qt.LeftButton)
+ # open button locked, dialog is not closed
+ self.assertTrue(dialog.isVisible())
+ self.assertEquals(dialog.result(), qt.QDialog.Rejected)
+
+ def testSelectRoot_Activate(self):
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ self.assertTrue(dialog.isVisible())
+ filename = _tmpDirectory + "/data.h5"
+ dialog.selectFile(os.path.dirname(filename))
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().index(filename)
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ self.assertTrue(button.isEnabled())
+ self.mouseClick(button, qt.Qt.LeftButton)
+ url = silx.io.url.DataUrl(dialog.selectedUrl())
+ self.assertTrue(url.data_path() is not None)
+ self.assertFalse(dialog.isVisible())
+ self.assertEquals(dialog.result(), qt.QDialog.Accepted)
+
+ def testSelectGroup_Activate(self):
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ self.assertTrue(dialog.isVisible())
+ filename = _tmpDirectory + "/data.h5"
+ dialog.selectFile(os.path.dirname(filename))
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().index(filename)
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/group"])
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ self.assertTrue(button.isEnabled())
+ self.mouseClick(button, qt.Qt.LeftButton)
+ url = silx.io.url.DataUrl(dialog.selectedUrl())
+ self.assertEqual(url.data_path(), "/group")
+ self.assertFalse(dialog.isVisible())
+ self.assertEquals(dialog.result(), qt.QDialog.Accepted)
+
+ def testSelectDataset_Activate(self):
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ self.assertTrue(dialog.isVisible())
+ filename = _tmpDirectory + "/data.h5"
+ dialog.selectFile(os.path.dirname(filename))
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().index(filename)
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/scalar"])
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ self.assertTrue(button.isEnabled())
+ self.mouseClick(button, qt.Qt.LeftButton)
+ url = silx.io.url.DataUrl(dialog.selectedUrl())
+ self.assertEqual(url.data_path(), "/scalar")
+ self.assertFalse(dialog.isVisible())
+ self.assertEquals(dialog.result(), qt.QDialog.Accepted)
+
+ def testClickOnBackToParentTool(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ action = utils.findChildren(dialog, qt.QAction, name="toParentAction")[0]
+ toParentButton = utils.getQToolButtonFromAction(action)
+ filename = _tmpDirectory + "/data.h5"
+
+ # init state
+ path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path()
+ dialog.selectUrl(path)
+ self.qWaitForPendingActions(dialog)
+ path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path()
+ self.assertSamePath(url.text(), path)
+ # test
+ self.mouseClick(toParentButton, qt.Qt.LeftButton)
+ self.qWaitForPendingActions(dialog)
+ path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path()
+ self.assertSamePath(url.text(), path)
+
+ self.mouseClick(toParentButton, qt.Qt.LeftButton)
+ self.qWaitForPendingActions(dialog)
+ self.assertSamePath(url.text(), _tmpDirectory)
+
+ self.mouseClick(toParentButton, qt.Qt.LeftButton)
+ self.qWaitForPendingActions(dialog)
+ self.assertSamePath(url.text(), os.path.dirname(_tmpDirectory))
+
+ def testClickOnBackToRootTool(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ action = utils.findChildren(dialog, qt.QAction, name="toRootFileAction")[0]
+ button = utils.getQToolButtonFromAction(action)
+ filename = _tmpDirectory + "/data.h5"
+
+ # init state
+ path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path()
+ dialog.selectUrl(path)
+ self.qWaitForPendingActions(dialog)
+ self.assertSamePath(url.text(), path)
+ self.assertTrue(button.isEnabled())
+ # test
+ self.mouseClick(button, qt.Qt.LeftButton)
+ self.qWaitForPendingActions(dialog)
+ path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path()
+ self.assertSamePath(url.text(), path)
+ # self.assertFalse(button.isEnabled())
+
+ def testClickOnBackToDirectoryTool(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ action = utils.findChildren(dialog, qt.QAction, name="toDirectoryAction")[0]
+ button = utils.getQToolButtonFromAction(action)
+ filename = _tmpDirectory + "/data.h5"
+
+ # init state
+ path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path()
+ dialog.selectUrl(path)
+ self.qWaitForPendingActions(dialog)
+ path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path()
+ self.assertSamePath(url.text(), path)
+ self.assertTrue(button.isEnabled())
+ # test
+ self.mouseClick(button, qt.Qt.LeftButton)
+ self.qWaitForPendingActions(dialog)
+ self.assertSamePath(url.text(), _tmpDirectory)
+ self.assertFalse(button.isEnabled())
+
+ # FIXME: There is an unreleased qt.QWidget without nameObject
+ # No idea where it come from.
+ self.allowedLeakingWidgets = 1
+
+ def testClickOnHistoryTools(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ forwardAction = utils.findChildren(dialog, qt.QAction, name="forwardAction")[0]
+ backwardAction = utils.findChildren(dialog, qt.QAction, name="backwardAction")[0]
+ filename = _tmpDirectory + "/data.h5"
+
+ dialog.setDirectory(_tmpDirectory)
+ self.qWaitForPendingActions(dialog)
+ # No way to use QTest.mouseDClick with QListView, QListWidget
+ # Then we feed the history using selectPath
+ dialog.selectUrl(filename)
+ self.qWaitForPendingActions(dialog)
+ path2 = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path()
+ dialog.selectUrl(path2)
+ self.qWaitForPendingActions(dialog)
+ path3 = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group").path()
+ dialog.selectUrl(path3)
+ self.qWaitForPendingActions(dialog)
+ self.assertFalse(forwardAction.isEnabled())
+ self.assertTrue(backwardAction.isEnabled())
+
+ button = utils.getQToolButtonFromAction(backwardAction)
+ self.mouseClick(button, qt.Qt.LeftButton)
+ self.qWaitForPendingActions(dialog)
+ self.assertTrue(forwardAction.isEnabled())
+ self.assertTrue(backwardAction.isEnabled())
+ self.assertSamePath(url.text(), path2)
+
+ button = utils.getQToolButtonFromAction(forwardAction)
+ self.mouseClick(button, qt.Qt.LeftButton)
+ self.qWaitForPendingActions(dialog)
+ self.assertFalse(forwardAction.isEnabled())
+ self.assertTrue(backwardAction.isEnabled())
+ self.assertSamePath(url.text(), path3)
+
+ def testSelectImageFromEdf(self):
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ # init state
+ filename = _tmpDirectory + "/singleimage.edf"
+ url = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/scan_0/instrument/detector_0/data")
+ dialog.selectUrl(url.path())
+ self.assertTrue(dialog._selectedData().shape, (100, 100))
+ self.assertSamePath(dialog.selectedFile(), filename)
+ self.assertSamePath(dialog.selectedUrl(), url.path())
+
+ def testSelectImage(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ # init state
+ filename = _tmpDirectory + "/data.h5"
+ path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/image").path()
+ dialog.selectUrl(path)
+ # test
+ self.assertTrue(dialog._selectedData().shape, (100, 100))
+ self.assertSamePath(dialog.selectedFile(), filename)
+ self.assertSamePath(dialog.selectedUrl(), path)
+
+ def testSelectScalar(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ # init state
+ filename = _tmpDirectory + "/data.h5"
+ path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/scalar").path()
+ dialog.selectUrl(path)
+ # test
+ self.assertEqual(dialog._selectedData()[()], 10)
+ self.assertSamePath(dialog.selectedFile(), filename)
+ self.assertSamePath(dialog.selectedUrl(), path)
+
+ def testSelectGroup(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ # init state
+ filename = _tmpDirectory + "/data.h5"
+ uri = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group")
+ dialog.selectUrl(uri.path())
+ self.qWaitForPendingActions(dialog)
+ # test
+ self.assertTrue(silx.io.is_group(dialog._selectedData()))
+ self.assertSamePath(dialog.selectedFile(), filename)
+ uri = silx.io.url.DataUrl(dialog.selectedUrl())
+ self.assertSamePath(uri.data_path(), "/group")
+
+ def testSelectRoot(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ # init state
+ filename = _tmpDirectory + "/data.h5"
+ uri = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/")
+ dialog.selectUrl(uri.path())
+ self.qWaitForPendingActions(dialog)
+ # test
+ self.assertTrue(silx.io.is_file(dialog._selectedData()))
+ self.assertSamePath(dialog.selectedFile(), filename)
+ uri = silx.io.url.DataUrl(dialog.selectedUrl())
+ self.assertSamePath(uri.data_path(), "/")
+
+ def testSelectH5_Activate(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ # init state
+ dialog.selectUrl(_tmpDirectory)
+ self.qWaitForPendingActions(dialog)
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ filename = _tmpDirectory + "/data.h5"
+ path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path()
+ index = browser.rootIndex().model().index(filename)
+ # click
+ browser.selectIndex(index)
+ # double click
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+ # test
+ self.assertSamePath(dialog.selectedUrl(), path)
+
+ def testSelectBadFileFormat_Activate(self):
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ # init state
+ dialog.selectUrl(_tmpDirectory)
+ self.qWaitForPendingActions(dialog)
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ filename = _tmpDirectory + "/badformat.h5"
+ index = browser.rootIndex().model().index(filename)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+ # test
+ self.assertTrue(dialog.selectedUrl(), filename)
+
+ def _countSelectableItems(self, model, rootIndex):
+ selectable = 0
+ for i in range(model.rowCount(rootIndex)):
+ index = model.index(i, 0, rootIndex)
+ flags = model.flags(index)
+ isEnabled = (int(flags) & qt.Qt.ItemIsEnabled) != 0
+ if isEnabled:
+ selectable += 1
+ return selectable
+
+ def testFilterExtensions(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ dialog.selectUrl(_tmpDirectory)
+ self.qWaitForPendingActions(dialog)
+ self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 3)
+
+
+class TestDataFileDialog_FilterDataset(utils.TestCaseQt, _UtilsMixin):
+
+ def tearDown(self):
+ self._deleteDialog()
+ utils.TestCaseQt.tearDown(self)
+
+ def _createDialog(self):
+ dialog = DataFileDialog()
+ dialog.setFilterMode(DataFileDialog.FilterMode.ExistingDataset)
+ return dialog
+
+ def testSelectGroup_Activate(self):
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ self.assertTrue(dialog.isVisible())
+ filename = _tmpDirectory + "/data.h5"
+ dialog.selectFile(os.path.dirname(filename))
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().index(filename)
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/group"])
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ self.assertFalse(button.isEnabled())
+
+ def testSelectDataset_Activate(self):
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ self.assertTrue(dialog.isVisible())
+ filename = _tmpDirectory + "/data.h5"
+ dialog.selectFile(os.path.dirname(filename))
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().index(filename)
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/scalar"])
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ self.assertTrue(button.isEnabled())
+ self.mouseClick(button, qt.Qt.LeftButton)
+ url = silx.io.url.DataUrl(dialog.selectedUrl())
+ self.assertEqual(url.data_path(), "/scalar")
+ self.assertFalse(dialog.isVisible())
+ self.assertEquals(dialog.result(), qt.QDialog.Accepted)
+
+ data = dialog.selectedData()
+ self.assertEqual(data, 10)
+
+
+class TestDataFileDialog_FilterGroup(utils.TestCaseQt, _UtilsMixin):
+
+ def tearDown(self):
+ self._deleteDialog()
+ utils.TestCaseQt.tearDown(self)
+
+ def _createDialog(self):
+ dialog = DataFileDialog()
+ dialog.setFilterMode(DataFileDialog.FilterMode.ExistingGroup)
+ return dialog
+
+ def testSelectGroup_Activate(self):
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ self.assertTrue(dialog.isVisible())
+ filename = _tmpDirectory + "/data.h5"
+ dialog.selectFile(os.path.dirname(filename))
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().index(filename)
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/group"])
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ self.assertTrue(button.isEnabled())
+ self.mouseClick(button, qt.Qt.LeftButton)
+ url = silx.io.url.DataUrl(dialog.selectedUrl())
+ self.assertEqual(url.data_path(), "/group")
+ self.assertFalse(dialog.isVisible())
+ self.assertEquals(dialog.result(), qt.QDialog.Accepted)
+
+ self.assertRaises(Exception, dialog.selectedData)
+
+ def testSelectDataset_Activate(self):
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ self.assertTrue(dialog.isVisible())
+ filename = _tmpDirectory + "/data.h5"
+ dialog.selectFile(os.path.dirname(filename))
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().index(filename)
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/scalar"])
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ self.assertFalse(button.isEnabled())
+
+
+class TestDataFileDialog_FilterNXdata(utils.TestCaseQt, _UtilsMixin):
+
+ def tearDown(self):
+ self._deleteDialog()
+ utils.TestCaseQt.tearDown(self)
+
+ def _createDialog(self):
+ def customFilter(obj):
+ if "NX_class" in obj.attrs:
+ return obj.attrs["NX_class"] == u"NXdata"
+ return False
+
+ dialog = DataFileDialog()
+ dialog.setFilterMode(DataFileDialog.FilterMode.ExistingGroup)
+ dialog.setFilterCallback(customFilter)
+ return dialog
+
+ def testSelectGroupRefused_Activate(self):
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ self.assertTrue(dialog.isVisible())
+ filename = _tmpDirectory + "/data.h5"
+ dialog.selectFile(os.path.dirname(filename))
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().index(filename)
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/group"])
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ self.assertFalse(button.isEnabled())
+
+ self.assertRaises(Exception, dialog.selectedData)
+
+ def testSelectNXdataAccepted_Activate(self):
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ self.assertTrue(dialog.isVisible())
+ filename = _tmpDirectory + "/data.h5"
+ dialog.selectFile(os.path.dirname(filename))
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().index(filename)
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ # select, then double click on the file
+ index = browser.rootIndex().model().indexFromH5Object(dialog._AbstractDataFileDialog__h5["/nxdata"])
+ browser.selectIndex(index)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+
+ button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ self.assertTrue(button.isEnabled())
+ self.mouseClick(button, qt.Qt.LeftButton)
+ url = silx.io.url.DataUrl(dialog.selectedUrl())
+ self.assertEqual(url.data_path(), "/nxdata")
+ self.assertFalse(dialog.isVisible())
+ self.assertEquals(dialog.result(), qt.QDialog.Accepted)
+
+
+class TestDataFileDialogApi(utils.TestCaseQt, _UtilsMixin):
+
+ def tearDown(self):
+ self._deleteDialog()
+ utils.TestCaseQt.tearDown(self)
+
+ def _createDialog(self):
+ dialog = DataFileDialog()
+ return dialog
+
+ def testSaveRestoreState(self):
+ dialog = self.createDialog()
+ dialog.setDirectory(_tmpDirectory)
+ self.qWaitForPendingActions(dialog)
+ state = dialog.saveState()
+ dialog = None
+
+ dialog2 = self.createDialog()
+ result = dialog2.restoreState(state)
+ self.assertTrue(result)
+ dialog2 = None
+
+ def printState(self):
+ """
+ Print state of the ImageFileDialog.
+
+ Can be used to add or regenerate `STATE_VERSION1_QT4` or
+ `STATE_VERSION1_QT5`.
+
+ >>> ./run_tests.py -v silx.gui.dialog.test.test_datafiledialog.TestDataFileDialogApi.printState
+ """
+ dialog = self.createDialog()
+ dialog.setDirectory("")
+ dialog.setHistory([])
+ dialog.setSidebarUrls([])
+ state = dialog.saveState()
+ string = ""
+ strings = []
+ for i in range(state.size()):
+ d = state.data()[i]
+ if not isinstance(d, int):
+ d = ord(d)
+ if d > 0x20 and d < 0x7F:
+ string += chr(d)
+ else:
+ string += "\\x%02X" % d
+ if len(string) > 60:
+ strings.append(string)
+ string = ""
+ strings.append(string)
+ strings = ["b'%s'" % s for s in strings]
+ print()
+ print("\\\n".join(strings))
+
+ STATE_VERSION1_QT4 = b''\
+ b'\x00\x00\x00Z\x00s\x00i\x00l\x00x\x00.\x00g\x00u\x00i\x00.\x00'\
+ b'd\x00i\x00a\x00l\x00o\x00g\x00.\x00D\x00a\x00t\x00a\x00F\x00i'\
+ b'\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00.\x00D\x00a\x00t\x00'\
+ b'a\x00F\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00\x00\x00'\
+ b'\x01\x00\x00\x00\x0C\x00\x00\x00\x00"\x00\x00\x00\xFF\x00\x00'\
+ b'\x00\x00\x00\x00\x00\x03\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF'\
+ b'\xFF\xFF\x01\x00\x00\x00\x06\x01\x00\x00\x00\x01\x00\x00\x00\x00'\
+ b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C\x00\x00\x00\x00'\
+ b'}\x00\x00\x00\x0E\x00B\x00r\x00o\x00w\x00s\x00e\x00r\x00\x00\x00'\
+ b'\x01\x00\x00\x00\x0C\x00\x00\x00\x00Z\x00\x00\x00\xFF\x00\x00'\
+ b'\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00'\
+ b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\
+ b'\x00\x01\x90\x00\x00\x00\x04\x01\x01\x00\x00\x00\x00\x00\x00\x00'\
+ b'\x00\x00\x00\x00\x00\x00\x00d\xFF\xFF\xFF\xFF\x00\x00\x00\x81'\
+ b'\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x01\x90\x00\x00\x00\x04'\
+ b'\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00'\
+ b'\x01\xFF\xFF\xFF\xFF'
+ """Serialized state on Qt4. Generated using :meth:`printState`"""
+
+ STATE_VERSION1_QT5 = b''\
+ b'\x00\x00\x00Z\x00s\x00i\x00l\x00x\x00.\x00g\x00u\x00i\x00.\x00'\
+ b'd\x00i\x00a\x00l\x00o\x00g\x00.\x00D\x00a\x00t\x00a\x00F\x00i'\
+ b'\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00.\x00D\x00a\x00t\x00'\
+ b'a\x00F\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00\x00\x00'\
+ b'\x01\x00\x00\x00\x0C\x00\x00\x00\x00#\x00\x00\x00\xFF\x00\x00'\
+ b'\x00\x01\x00\x00\x00\x03\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF'\
+ b'\xFF\xFF\x01\xFF\xFF\xFF\xFF\x01\x00\x00\x00\x01\x00\x00\x00\x00'\
+ b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C\x00\x00\x00'\
+ b'\x00\xAA\x00\x00\x00\x0E\x00B\x00r\x00o\x00w\x00s\x00e\x00r\x00'\
+ b'\x00\x00\x01\x00\x00\x00\x0C\x00\x00\x00\x00\x87\x00\x00\x00\xFF'\
+ b'\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00'\
+ b'\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\
+ b'\x00\x00\x00\x01\x90\x00\x00\x00\x04\x01\x01\x00\x00\x00\x00\x00'\
+ b'\x00\x00\x00\x00\x00\x00\x00\x00\x00d\xFF\xFF\xFF\xFF\x00\x00'\
+ b'\x00\x81\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00d\x00\x00'\
+ b'\x00\x01\x00\x00\x00\x00\x00\x00\x00d\x00\x00\x00\x01\x00\x00'\
+ b'\x00\x00\x00\x00\x00d\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00'\
+ b'\x00d\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x03\xE8\x00\xFF'\
+ b'\xFF\xFF\xFF\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x01'
+ """Serialized state on Qt5. Generated using :meth:`printState`"""
+
+ def testAvoidRestoreRegression_Version1(self):
+ version = qt.qVersion().split(".")[0]
+ if version == "4":
+ state = self.STATE_VERSION1_QT4
+ elif version == "5":
+ state = self.STATE_VERSION1_QT5
+ else:
+ self.skipTest("Resource not available")
+
+ state = qt.QByteArray(state)
+ dialog = self.createDialog()
+ result = dialog.restoreState(state)
+ self.assertTrue(result)
+
+ def testRestoreRobusness(self):
+ """What's happen if you try to open a config file with a different
+ binding."""
+ state = qt.QByteArray(self.STATE_VERSION1_QT4)
+ dialog = self.createDialog()
+ dialog.restoreState(state)
+ state = qt.QByteArray(self.STATE_VERSION1_QT5)
+ dialog = None
+ dialog = self.createDialog()
+ dialog.restoreState(state)
+
+ def testRestoreNonExistingDirectory(self):
+ directory = os.path.join(_tmpDirectory, "dir")
+ os.mkdir(directory)
+ dialog = self.createDialog()
+ dialog.setDirectory(directory)
+ self.qWaitForPendingActions(dialog)
+ state = dialog.saveState()
+ os.rmdir(directory)
+ dialog = None
+
+ dialog2 = self.createDialog()
+ result = dialog2.restoreState(state)
+ self.assertTrue(result)
+ self.assertNotEquals(dialog2.directory(), directory)
+
+ def testHistory(self):
+ dialog = self.createDialog()
+ history = dialog.history()
+ dialog.setHistory([])
+ self.assertEqual(dialog.history(), [])
+ dialog.setHistory(history)
+ self.assertEqual(dialog.history(), history)
+
+ def testSidebarUrls(self):
+ dialog = self.createDialog()
+ urls = dialog.sidebarUrls()
+ dialog.setSidebarUrls([])
+ self.assertEqual(dialog.sidebarUrls(), [])
+ dialog.setSidebarUrls(urls)
+ self.assertEqual(dialog.sidebarUrls(), urls)
+
+ def testDirectory(self):
+ dialog = self.createDialog()
+ self.qWaitForPendingActions(dialog)
+ dialog.selectUrl(_tmpDirectory)
+ self.qWaitForPendingActions(dialog)
+ self.assertSamePath(dialog.directory(), _tmpDirectory)
+
+ def testBadFileFormat(self):
+ dialog = self.createDialog()
+ dialog.selectUrl(_tmpDirectory + "/badformat.h5")
+ self.qWaitForPendingActions(dialog)
+ self.assertIsNone(dialog._selectedData())
+
+ def testBadPath(self):
+ dialog = self.createDialog()
+ dialog.selectUrl("#$%/#$%")
+ self.qWaitForPendingActions(dialog)
+ self.assertIsNone(dialog._selectedData())
+
+ def testBadSubpath(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ self.qWaitForPendingActions(dialog)
+
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+
+ filename = _tmpDirectory + "/data.h5"
+ url = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/foobar")
+ dialog.selectUrl(url.path())
+ self.qWaitForPendingActions(dialog)
+ self.assertIsNotNone(dialog._selectedData())
+
+ # an existing node is browsed, but the wrong path is selected
+ index = browser.rootIndex()
+ obj = index.model().data(index, role=Hdf5TreeModel.H5PY_OBJECT_ROLE)
+ self.assertEqual(obj.name, "/group")
+ url = silx.io.url.DataUrl(dialog.selectedUrl())
+ self.assertEqual(url.data_path(), "/group")
+
+ def testUnsupportedSlicingPath(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ self.qWaitForPendingActions(dialog)
+ dialog.selectUrl(_tmpDirectory + "/data.h5?path=/cube&slice=0")
+ self.qWaitForPendingActions(dialog)
+ data = dialog._selectedData()
+ if data is None:
+ # Maybe nothing is selected
+ self.assertTrue(True)
+ else:
+ # Maybe the cube is selected but not sliced
+ self.assertEqual(len(data.shape), 3)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loadTests(TestDataFileDialogInteraction))
+ test_suite.addTest(loadTests(TestDataFileDialogApi))
+ test_suite.addTest(loadTests(TestDataFileDialog_FilterDataset))
+ test_suite.addTest(loadTests(TestDataFileDialog_FilterGroup))
+ test_suite.addTest(loadTests(TestDataFileDialog_FilterNXdata))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/dialog/test/test_imagefiledialog.py b/silx/gui/dialog/test/test_imagefiledialog.py
new file mode 100644
index 0000000..7909f10
--- /dev/null
+++ b/silx/gui/dialog/test/test_imagefiledialog.py
@@ -0,0 +1,803 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Test for silx.gui.hdf5 module"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "12/02/2018"
+
+
+import unittest
+import tempfile
+import numpy
+import shutil
+import os
+import io
+import weakref
+
+try:
+ import fabio
+except ImportError:
+ fabio = None
+try:
+ import h5py
+except ImportError:
+ h5py = None
+
+import silx.io.url
+from silx.gui import qt
+from silx.gui.test import utils
+from ..ImageFileDialog import ImageFileDialog
+from silx.gui.plot.Colormap import Colormap
+from silx.gui.hdf5 import Hdf5TreeModel
+
+_tmpDirectory = None
+
+
+def setUpModule():
+ global _tmpDirectory
+ _tmpDirectory = tempfile.mkdtemp(prefix=__name__)
+
+ data = numpy.arange(100 * 100)
+ data.shape = 100, 100
+
+ if fabio is not None:
+ filename = _tmpDirectory + "/singleimage.edf"
+ image = fabio.edfimage.EdfImage(data=data)
+ image.write(filename)
+
+ filename = _tmpDirectory + "/multiframe.edf"
+ image = fabio.edfimage.EdfImage(data=data)
+ image.appendFrame(data=data + 1)
+ image.appendFrame(data=data + 2)
+ image.write(filename)
+
+ filename = _tmpDirectory + "/singleimage.msk"
+ image = fabio.fit2dmaskimage.Fit2dMaskImage(data=data % 2 == 1)
+ image.write(filename)
+
+ if h5py is not None:
+ filename = _tmpDirectory + "/data.h5"
+ f = h5py.File(filename, "w")
+ f["scalar"] = 10
+ f["image"] = data
+ f["cube"] = [data, data + 1, data + 2]
+ f["complex_image"] = data * 1j
+ f["group/image"] = data
+ f.close()
+
+ filename = _tmpDirectory + "/badformat.edf"
+ with io.open(filename, "wb") as f:
+ f.write(b"{\nHello Nurse!")
+
+
+def tearDownModule():
+ global _tmpDirectory
+ shutil.rmtree(_tmpDirectory)
+ _tmpDirectory = None
+
+
+class _UtilsMixin(object):
+
+ def createDialog(self):
+ self._deleteDialog()
+ self._dialog = self._createDialog()
+ return self._dialog
+
+ def _createDialog(self):
+ return ImageFileDialog()
+
+ def _deleteDialog(self):
+ if not hasattr(self, "_dialog"):
+ return
+ if self._dialog is not None:
+ ref = weakref.ref(self._dialog)
+ self._dialog = None
+ self.qWaitForDestroy(ref)
+
+ def qWaitForPendingActions(self, dialog):
+ for _ in range(20):
+ if not dialog.hasPendingEvents():
+ return
+ self.qWait(10)
+ raise RuntimeError("Still have pending actions")
+
+ def assertSamePath(self, path1, path2):
+ path1_ = os.path.normcase(path1)
+ path2_ = os.path.normcase(path2)
+ if path1_ != path2_:
+ # Use the unittest API to log and display error
+ self.assertEquals(path1, path2)
+
+ def assertNotSamePath(self, path1, path2):
+ path1_ = os.path.normcase(path1)
+ path2_ = os.path.normcase(path2)
+ if path1_ == path2_:
+ # Use the unittest API to log and display error
+ self.assertNotEquals(path1, path2)
+
+
+class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
+
+ def tearDown(self):
+ self._deleteDialog()
+ utils.TestCaseQt.tearDown(self)
+
+ def testDisplayAndKeyEscape(self):
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ self.assertTrue(dialog.isVisible())
+
+ self.keyClick(dialog, qt.Qt.Key_Escape)
+ self.assertFalse(dialog.isVisible())
+ self.assertEquals(dialog.result(), qt.QDialog.Rejected)
+
+ def testDisplayAndClickCancel(self):
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ self.assertTrue(dialog.isVisible())
+
+ button = utils.findChildren(dialog, qt.QPushButton, name="cancel")[0]
+ self.mouseClick(button, qt.Qt.LeftButton)
+ self.assertFalse(dialog.isVisible())
+ self.assertFalse(dialog.isVisible())
+ self.assertEquals(dialog.result(), qt.QDialog.Rejected)
+
+ def testDisplayAndClickLockedOpen(self):
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ self.assertTrue(dialog.isVisible())
+
+ button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ self.mouseClick(button, qt.Qt.LeftButton)
+ # open button locked, dialog is not closed
+ self.assertTrue(dialog.isVisible())
+ self.assertEquals(dialog.result(), qt.QDialog.Rejected)
+
+ def testDisplayAndClickOpen(self):
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ self.assertTrue(dialog.isVisible())
+ filename = _tmpDirectory + "/singleimage.edf"
+ dialog.selectFile(filename)
+ self.qWaitForPendingActions(dialog)
+
+ button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ self.assertTrue(button.isEnabled())
+ self.mouseClick(button, qt.Qt.LeftButton)
+ self.assertFalse(dialog.isVisible())
+ self.assertEquals(dialog.result(), qt.QDialog.Accepted)
+
+ def testClickOnShortcut(self):
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ sidebar = utils.findChildren(dialog, qt.QListView, name="sidebar")[0]
+ url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ dialog.setDirectory(_tmpDirectory)
+ self.qWaitForPendingActions(dialog)
+
+ self.assertSamePath(url.text(), _tmpDirectory)
+
+ urls = sidebar.urls()
+ if len(urls) == 0:
+ self.skipTest("No sidebar path")
+ path = urls[0].path()
+ if path != "" and not os.path.exists(path):
+ self.skipTest("Sidebar path do not exists")
+
+ index = sidebar.model().index(0, 0)
+ # rect = sidebar.visualRect(index)
+ # self.mouseClick(sidebar, qt.Qt.LeftButton, pos=rect.center())
+ # Using mouse click is not working, let's use the selection API
+ sidebar.selectionModel().select(index, qt.QItemSelectionModel.ClearAndSelect)
+ self.qWaitForPendingActions(dialog)
+
+ index = browser.rootIndex()
+ if not index.isValid():
+ path = ""
+ else:
+ path = index.model().filePath(index)
+ self.assertNotSamePath(_tmpDirectory, path)
+ self.assertNotSamePath(url.text(), _tmpDirectory)
+
+ def testClickOnDetailView(self):
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ action = utils.findChildren(dialog, qt.QAction, name="detailModeAction")[0]
+ detailModeButton = utils.getQToolButtonFromAction(action)
+ self.mouseClick(detailModeButton, qt.Qt.LeftButton)
+ self.assertEqual(dialog.viewMode(), qt.QFileDialog.Detail)
+
+ action = utils.findChildren(dialog, qt.QAction, name="listModeAction")[0]
+ listModeButton = utils.getQToolButtonFromAction(action)
+ self.mouseClick(listModeButton, qt.Qt.LeftButton)
+ self.assertEqual(dialog.viewMode(), qt.QFileDialog.List)
+
+ def testClickOnBackToParentTool(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ action = utils.findChildren(dialog, qt.QAction, name="toParentAction")[0]
+ toParentButton = utils.getQToolButtonFromAction(action)
+ filename = _tmpDirectory + "/data.h5"
+
+ # init state
+ path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path()
+ dialog.selectUrl(path)
+ self.qWaitForPendingActions(dialog)
+ path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path()
+ self.assertSamePath(url.text(), path)
+ # test
+ self.mouseClick(toParentButton, qt.Qt.LeftButton)
+ self.qWaitForPendingActions(dialog)
+ path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path()
+ self.assertSamePath(url.text(), path)
+
+ self.mouseClick(toParentButton, qt.Qt.LeftButton)
+ self.qWaitForPendingActions(dialog)
+ self.assertSamePath(url.text(), _tmpDirectory)
+
+ self.mouseClick(toParentButton, qt.Qt.LeftButton)
+ self.qWaitForPendingActions(dialog)
+ self.assertSamePath(url.text(), os.path.dirname(_tmpDirectory))
+
+ def testClickOnBackToRootTool(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ action = utils.findChildren(dialog, qt.QAction, name="toRootFileAction")[0]
+ button = utils.getQToolButtonFromAction(action)
+ filename = _tmpDirectory + "/data.h5"
+
+ # init state
+ path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path()
+ dialog.selectUrl(path)
+ self.qWaitForPendingActions(dialog)
+ self.assertSamePath(url.text(), path)
+ self.assertTrue(button.isEnabled())
+ # test
+ self.mouseClick(button, qt.Qt.LeftButton)
+ self.qWaitForPendingActions(dialog)
+ path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path()
+ self.assertSamePath(url.text(), path)
+ # self.assertFalse(button.isEnabled())
+
+ def testClickOnBackToDirectoryTool(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ action = utils.findChildren(dialog, qt.QAction, name="toDirectoryAction")[0]
+ button = utils.getQToolButtonFromAction(action)
+ filename = _tmpDirectory + "/data.h5"
+
+ # init state
+ path = silx.io.url.DataUrl(file_path=filename, data_path="/group/image").path()
+ dialog.selectUrl(path)
+ self.qWaitForPendingActions(dialog)
+ path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path()
+ self.assertSamePath(url.text(), path)
+ self.assertTrue(button.isEnabled())
+ # test
+ self.mouseClick(button, qt.Qt.LeftButton)
+ self.qWaitForPendingActions(dialog)
+ self.assertSamePath(url.text(), _tmpDirectory)
+ self.assertFalse(button.isEnabled())
+
+ # FIXME: There is an unreleased qt.QWidget without nameObject
+ # No idea where it come from.
+ self.allowedLeakingWidgets = 1
+
+ def testClickOnHistoryTools(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ forwardAction = utils.findChildren(dialog, qt.QAction, name="forwardAction")[0]
+ backwardAction = utils.findChildren(dialog, qt.QAction, name="backwardAction")[0]
+ filename = _tmpDirectory + "/data.h5"
+
+ dialog.setDirectory(_tmpDirectory)
+ self.qWaitForPendingActions(dialog)
+ # No way to use QTest.mouseDClick with QListView, QListWidget
+ # Then we feed the history using selectPath
+ dialog.selectUrl(filename)
+ self.qWaitForPendingActions(dialog)
+ path2 = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path()
+ dialog.selectUrl(path2)
+ self.qWaitForPendingActions(dialog)
+ path3 = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group").path()
+ dialog.selectUrl(path3)
+ self.qWaitForPendingActions(dialog)
+ self.assertFalse(forwardAction.isEnabled())
+ self.assertTrue(backwardAction.isEnabled())
+
+ button = utils.getQToolButtonFromAction(backwardAction)
+ self.mouseClick(button, qt.Qt.LeftButton)
+ self.qWaitForPendingActions(dialog)
+ self.assertTrue(forwardAction.isEnabled())
+ self.assertTrue(backwardAction.isEnabled())
+ self.assertSamePath(url.text(), path2)
+
+ button = utils.getQToolButtonFromAction(forwardAction)
+ self.mouseClick(button, qt.Qt.LeftButton)
+ self.qWaitForPendingActions(dialog)
+ self.assertFalse(forwardAction.isEnabled())
+ self.assertTrue(backwardAction.isEnabled())
+ self.assertSamePath(url.text(), path3)
+
+ def testSelectImageFromEdf(self):
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ # init state
+ filename = _tmpDirectory + "/singleimage.edf"
+ path = filename
+ dialog.selectUrl(path)
+ self.assertTrue(dialog.selectedImage().shape, (100, 100))
+ self.assertSamePath(dialog.selectedFile(), filename)
+ path = silx.io.url.DataUrl(scheme="fabio", file_path=filename).path()
+ self.assertSamePath(dialog.selectedUrl(), path)
+
+ def testSelectImageFromEdf_Activate(self):
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ # init state
+ dialog.selectUrl(_tmpDirectory)
+ self.qWaitForPendingActions(dialog)
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ filename = _tmpDirectory + "/singleimage.edf"
+ path = silx.io.url.DataUrl(scheme="fabio", file_path=filename).path()
+ index = browser.rootIndex().model().index(filename)
+ # click
+ browser.selectIndex(index)
+ # double click
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+ # test
+ self.assertTrue(dialog.selectedImage().shape, (100, 100))
+ self.assertSamePath(dialog.selectedFile(), filename)
+ self.assertSamePath(dialog.selectedUrl(), path)
+
+ def testSelectFrameFromEdf(self):
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ # init state
+ filename = _tmpDirectory + "/multiframe.edf"
+ path = silx.io.url.DataUrl(scheme="fabio", file_path=filename, data_slice=(1,)).path()
+ dialog.selectUrl(path)
+ # test
+ image = dialog.selectedImage()
+ self.assertTrue(image.shape, (100, 100))
+ self.assertTrue(image[0, 0], 1)
+ self.assertSamePath(dialog.selectedFile(), filename)
+ self.assertSamePath(dialog.selectedUrl(), path)
+
+ def testSelectImageFromMsk(self):
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ # init state
+ filename = _tmpDirectory + "/singleimage.msk"
+ path = silx.io.url.DataUrl(scheme="fabio", file_path=filename).path()
+ dialog.selectUrl(path)
+ # test
+ self.assertTrue(dialog.selectedImage().shape, (100, 100))
+ self.assertSamePath(dialog.selectedFile(), filename)
+ self.assertSamePath(dialog.selectedUrl(), path)
+
+ def testSelectImageFromH5(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ # init state
+ filename = _tmpDirectory + "/data.h5"
+ path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/image").path()
+ dialog.selectUrl(path)
+ # test
+ self.assertTrue(dialog.selectedImage().shape, (100, 100))
+ self.assertSamePath(dialog.selectedFile(), filename)
+ self.assertSamePath(dialog.selectedUrl(), path)
+
+ def testSelectH5_Activate(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ # init state
+ dialog.selectUrl(_tmpDirectory)
+ self.qWaitForPendingActions(dialog)
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ filename = _tmpDirectory + "/data.h5"
+ path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path()
+ index = browser.rootIndex().model().index(filename)
+ # click
+ browser.selectIndex(index)
+ # double click
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+ # test
+ self.assertSamePath(dialog.selectedUrl(), path)
+
+ def testSelectFrameFromH5(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ # init state
+ filename = _tmpDirectory + "/data.h5"
+ path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/cube", data_slice=(1, )).path()
+ dialog.selectUrl(path)
+ # test
+ self.assertTrue(dialog.selectedImage().shape, (100, 100))
+ self.assertTrue(dialog.selectedImage()[0, 0], 1)
+ self.assertSamePath(dialog.selectedFile(), filename)
+ self.assertSamePath(dialog.selectedUrl(), path)
+
+ def testSelectBadFileFormat_Activate(self):
+ dialog = self.createDialog()
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+
+ # init state
+ dialog.selectUrl(_tmpDirectory)
+ self.qWaitForPendingActions(dialog)
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ filename = _tmpDirectory + "/badformat.edf"
+ index = browser.rootIndex().model().index(filename)
+ browser.activated.emit(index)
+ self.qWaitForPendingActions(dialog)
+ # test
+ self.assertTrue(dialog.selectedUrl(), filename)
+
+ def _countSelectableItems(self, model, rootIndex):
+ selectable = 0
+ for i in range(model.rowCount(rootIndex)):
+ index = model.index(i, 0, rootIndex)
+ flags = model.flags(index)
+ isEnabled = (int(flags) & qt.Qt.ItemIsEnabled) != 0
+ if isEnabled:
+ selectable += 1
+ return selectable
+
+ def testFilterExtensions(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ if fabio is None:
+ self.skipTest("fabio is missing")
+ dialog = self.createDialog()
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ filters = utils.findChildren(dialog, qt.QWidget, name="fileTypeCombo")[0]
+ dialog.show()
+ self.qWaitForWindowExposed(dialog)
+ dialog.selectUrl(_tmpDirectory)
+ self.qWaitForPendingActions(dialog)
+ self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 5)
+
+ codecName = fabio.edfimage.EdfImage.codec_name()
+ index = filters.indexFromCodec(codecName)
+ filters.setCurrentIndex(index)
+ filters.activated[int].emit(index)
+ self.qWait(50)
+ self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 3)
+
+ codecName = fabio.fit2dmaskimage.Fit2dMaskImage.codec_name()
+ index = filters.indexFromCodec(codecName)
+ filters.setCurrentIndex(index)
+ filters.activated[int].emit(index)
+ self.qWait(50)
+ self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 1)
+
+
+class TestImageFileDialogApi(utils.TestCaseQt, _UtilsMixin):
+
+ def tearDown(self):
+ self._deleteDialog()
+ utils.TestCaseQt.tearDown(self)
+
+ def testSaveRestoreState(self):
+ dialog = self.createDialog()
+ dialog.setDirectory(_tmpDirectory)
+ colormap = Colormap(normalization=Colormap.LOGARITHM)
+ dialog.setColormap(colormap)
+ self.qWaitForPendingActions(dialog)
+ state = dialog.saveState()
+ dialog = None
+
+ dialog2 = self.createDialog()
+ result = dialog2.restoreState(state)
+ self.qWaitForPendingActions(dialog2)
+ self.assertTrue(result)
+ self.assertTrue(dialog2.colormap().getNormalization(), "log")
+
+ def printState(self):
+ """
+ Print state of the ImageFileDialog.
+
+ Can be used to add or regenerate `STATE_VERSION1_QT4` or
+ `STATE_VERSION1_QT5`.
+
+ >>> ./run_tests.py -v silx.gui.dialog.test.test_imagefiledialog.TestImageFileDialogApi.printState
+ """
+ dialog = self.createDialog()
+ colormap = Colormap(normalization=Colormap.LOGARITHM)
+ dialog.setDirectory("")
+ dialog.setHistory([])
+ dialog.setColormap(colormap)
+ dialog.setSidebarUrls([])
+ state = dialog.saveState()
+ string = ""
+ strings = []
+ for i in range(state.size()):
+ d = state.data()[i]
+ if not isinstance(d, int):
+ d = ord(d)
+ if d > 0x20 and d < 0x7F:
+ string += chr(d)
+ else:
+ string += "\\x%02X" % d
+ if len(string) > 60:
+ strings.append(string)
+ string = ""
+ strings.append(string)
+ strings = ["b'%s'" % s for s in strings]
+ print()
+ print("\\\n".join(strings))
+
+ STATE_VERSION1_QT4 = b''\
+ b'\x00\x00\x00^\x00s\x00i\x00l\x00x\x00.\x00g\x00u\x00i\x00.\x00'\
+ b'd\x00i\x00a\x00l\x00o\x00g\x00.\x00I\x00m\x00a\x00g\x00e\x00F'\
+ b'\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00.\x00I\x00m\x00'\
+ b'a\x00g\x00e\x00F\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g'\
+ b'\x00\x00\x00\x01\x00\x00\x00\x0C\x00\x00\x00\x00"\x00\x00\x00'\
+ b'\xFF\x00\x00\x00\x00\x00\x00\x00\x03\xFF\xFF\xFF\xFF\xFF\xFF\xFF'\
+ b'\xFF\xFF\xFF\xFF\xFF\x01\x00\x00\x00\x06\x01\x00\x00\x00\x01\x00'\
+ b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C\x00'\
+ b'\x00\x00\x00}\x00\x00\x00\x0E\x00B\x00r\x00o\x00w\x00s\x00e\x00'\
+ b'r\x00\x00\x00\x01\x00\x00\x00\x0C\x00\x00\x00\x00Z\x00\x00\x00'\
+ b'\xFF\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00'\
+ b'\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\
+ b'\x00\x00\x00\x00\x01\x90\x00\x00\x00\x04\x01\x01\x00\x00\x00\x00'\
+ b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00d\xFF\xFF\xFF\xFF\x00'\
+ b'\x00\x00\x81\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x01\x90\x00'\
+ b'\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00'\
+ b'\x00\x00\x0C\x00\x00\x00\x000\x00\x00\x00\x10\x00C\x00o\x00l\x00'\
+ b'o\x00r\x00m\x00a\x00p\x00\x00\x00\x01\x00\x00\x00\x08\x00g\x00'\
+ b'r\x00a\x00y\x01\x01\x00\x00\x00\x06\x00l\x00o\x00g'
+ """Serialized state on Qt4. Generated using :meth:`printState`"""
+
+ STATE_VERSION1_QT5 = b''\
+ b'\x00\x00\x00^\x00s\x00i\x00l\x00x\x00.\x00g\x00u\x00i\x00.\x00'\
+ b'd\x00i\x00a\x00l\x00o\x00g\x00.\x00I\x00m\x00a\x00g\x00e\x00F'\
+ b'\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g\x00.\x00I\x00m\x00'\
+ b'a\x00g\x00e\x00F\x00i\x00l\x00e\x00D\x00i\x00a\x00l\x00o\x00g'\
+ b'\x00\x00\x00\x01\x00\x00\x00\x0C\x00\x00\x00\x00#\x00\x00\x00'\
+ b'\xFF\x00\x00\x00\x01\x00\x00\x00\x03\xFF\xFF\xFF\xFF\xFF\xFF\xFF'\
+ b'\xFF\xFF\xFF\xFF\xFF\x01\xFF\xFF\xFF\xFF\x01\x00\x00\x00\x01\x00'\
+ b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0C'\
+ b'\x00\x00\x00\x00\xAA\x00\x00\x00\x0E\x00B\x00r\x00o\x00w\x00s'\
+ b'\x00e\x00r\x00\x00\x00\x01\x00\x00\x00\x0C\x00\x00\x00\x00\x87'\
+ b'\x00\x00\x00\xFF\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00'\
+ b'\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'\
+ b'\x00\x00\x00\x00\x00\x00\x00\x01\x90\x00\x00\x00\x04\x01\x01\x00'\
+ b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00d\xFF\xFF'\
+ b'\xFF\xFF\x00\x00\x00\x81\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00'\
+ b'\x00d\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00d\x00\x00\x00'\
+ b'\x01\x00\x00\x00\x00\x00\x00\x00d\x00\x00\x00\x01\x00\x00\x00'\
+ b'\x00\x00\x00\x00d\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x03'\
+ b'\xE8\x00\xFF\xFF\xFF\xFF\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00'\
+ b'\x00\x0C\x00\x00\x00\x000\x00\x00\x00\x10\x00C\x00o\x00l\x00o'\
+ b'\x00r\x00m\x00a\x00p\x00\x00\x00\x01\x00\x00\x00\x08\x00g\x00'\
+ b'r\x00a\x00y\x01\x01\x00\x00\x00\x06\x00l\x00o\x00g'
+ """Serialized state on Qt5. Generated using :meth:`printState`"""
+
+ def testAvoidRestoreRegression_Version1(self):
+ version = qt.qVersion().split(".")[0]
+ if version == "4":
+ state = self.STATE_VERSION1_QT4
+ elif version == "5":
+ state = self.STATE_VERSION1_QT5
+ else:
+ self.skipTest("Resource not available")
+
+ state = qt.QByteArray(state)
+ dialog = self.createDialog()
+ result = dialog.restoreState(state)
+ self.assertTrue(result)
+ colormap = dialog.colormap()
+ self.assertTrue(colormap.getNormalization(), "log")
+
+ def testRestoreRobusness(self):
+ """What's happen if you try to open a config file with a different
+ binding."""
+ state = qt.QByteArray(self.STATE_VERSION1_QT4)
+ dialog = self.createDialog()
+ dialog.restoreState(state)
+ state = qt.QByteArray(self.STATE_VERSION1_QT5)
+ dialog = None
+ dialog = self.createDialog()
+ dialog.restoreState(state)
+
+ def testRestoreNonExistingDirectory(self):
+ directory = os.path.join(_tmpDirectory, "dir")
+ os.mkdir(directory)
+ dialog = self.createDialog()
+ dialog.setDirectory(directory)
+ self.qWaitForPendingActions(dialog)
+ state = dialog.saveState()
+ os.rmdir(directory)
+ dialog = None
+
+ dialog2 = self.createDialog()
+ result = dialog2.restoreState(state)
+ self.assertTrue(result)
+ self.assertNotEquals(dialog2.directory(), directory)
+
+ def testHistory(self):
+ dialog = self.createDialog()
+ history = dialog.history()
+ dialog.setHistory([])
+ self.assertEqual(dialog.history(), [])
+ dialog.setHistory(history)
+ self.assertEqual(dialog.history(), history)
+
+ def testSidebarUrls(self):
+ dialog = self.createDialog()
+ urls = dialog.sidebarUrls()
+ dialog.setSidebarUrls([])
+ self.assertEqual(dialog.sidebarUrls(), [])
+ dialog.setSidebarUrls(urls)
+ self.assertEqual(dialog.sidebarUrls(), urls)
+
+ def testColomap(self):
+ dialog = self.createDialog()
+ colormap = dialog.colormap()
+ self.assertEqual(colormap.getNormalization(), "linear")
+ colormap = Colormap(normalization=Colormap.LOGARITHM)
+ dialog.setColormap(colormap)
+ self.assertEqual(colormap.getNormalization(), "log")
+
+ def testDirectory(self):
+ dialog = self.createDialog()
+ self.qWaitForPendingActions(dialog)
+ dialog.selectUrl(_tmpDirectory)
+ self.qWaitForPendingActions(dialog)
+ self.assertSamePath(dialog.directory(), _tmpDirectory)
+
+ def testBadDataType(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.selectUrl(_tmpDirectory + "/data.h5::/complex_image")
+ self.qWaitForPendingActions(dialog)
+ self.assertIsNone(dialog._selectedData())
+
+ def testBadDataShape(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ dialog.selectUrl(_tmpDirectory + "/data.h5::/unknown")
+ self.qWaitForPendingActions(dialog)
+ self.assertIsNone(dialog._selectedData())
+
+ def testBadDataFormat(self):
+ dialog = self.createDialog()
+ dialog.selectUrl(_tmpDirectory + "/badformat.edf")
+ self.qWaitForPendingActions(dialog)
+ self.assertIsNone(dialog._selectedData())
+
+ def testBadPath(self):
+ dialog = self.createDialog()
+ dialog.selectUrl("#$%/#$%")
+ self.qWaitForPendingActions(dialog)
+ self.assertIsNone(dialog._selectedData())
+
+ def testBadSubpath(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ self.qWaitForPendingActions(dialog)
+
+ browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+
+ filename = _tmpDirectory + "/data.h5"
+ url = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/foobar")
+ dialog.selectUrl(url.path())
+ self.qWaitForPendingActions(dialog)
+ self.assertIsNone(dialog._selectedData())
+
+ # an existing node is browsed, but the wrong path is selected
+ index = browser.rootIndex()
+ obj = index.model().data(index, role=Hdf5TreeModel.H5PY_OBJECT_ROLE)
+ self.assertEqual(obj.name, "/group")
+ url = silx.io.url.DataUrl(dialog.selectedUrl())
+ self.assertEqual(url.data_path(), "/group")
+
+ def testBadSlicingPath(self):
+ if h5py is None:
+ self.skipTest("h5py is missing")
+ dialog = self.createDialog()
+ self.qWaitForPendingActions(dialog)
+ dialog.selectUrl(_tmpDirectory + "/data.h5::/cube[a;45,-90]")
+ self.qWaitForPendingActions(dialog)
+ self.assertIsNone(dialog._selectedData())
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loadTests(TestImageFileDialogInteraction))
+ test_suite.addTest(loadTests(TestImageFileDialogApi))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/dialog/utils.py b/silx/gui/dialog/utils.py
new file mode 100644
index 0000000..1c16b44
--- /dev/null
+++ b/silx/gui/dialog/utils.py
@@ -0,0 +1,104 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""
+This module contains utilitaries used by other dialog modules.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "25/10/2017"
+
+import os
+import sys
+import types
+from silx.gui import qt
+from silx.third_party import six
+
+
+def samefile(path1, path2):
+ """Portable :func:`os.path.samepath` function.
+
+ :param str path1: A path to a file
+ :param str path2: Another path to a file
+ :rtype: bool
+ """
+ if six.PY2 and sys.platform == "win32":
+ path1 = os.path.normcase(path1)
+ path2 = os.path.normcase(path2)
+ return path1 == path2
+ if path1 == path2:
+ return True
+ if path1 == "":
+ return False
+ if path2 == "":
+ return False
+ return os.path.samefile(path1, path2)
+
+
+def findClosestSubPath(hdf5Object, path):
+ """Find the closest existing path from the hdf5Object using a subset of the
+ provided path.
+
+ Returns None if no path found. It is possible if the path is a relative
+ path.
+
+ :param h5py.Node hdf5Object: An HDF5 node
+ :param str path: A path
+ :rtype: str
+ """
+ if path in ["", "/"]:
+ return "/"
+ names = path.split("/")
+ if path[0] == "/":
+ names.pop(0)
+ for i in range(len(names)):
+ n = len(names) - i
+ path2 = "/".join(names[0:n])
+ if path2 == "":
+ return ""
+ if path2 in hdf5Object:
+ return path2
+
+ if path[0] == "/":
+ return "/"
+ return None
+
+
+def patchToConsumeReturnKey(widget):
+ """
+ Monkey-patch a widget to consume the return key instead of propagating it
+ to the dialog.
+ """
+ assert(not hasattr(widget, "_oldKeyPressEvent"))
+
+ def keyPressEvent(self, event):
+ k = event.key()
+ result = self._oldKeyPressEvent(event)
+ if k in [qt.Qt.Key_Return, qt.Qt.Key_Enter]:
+ event.accept()
+ return result
+
+ widget._oldKeyPressEvent = widget.keyPressEvent
+ widget.keyPressEvent = types.MethodType(keyPressEvent, widget)
diff --git a/silx/gui/hdf5/Hdf5Formatter.py b/silx/gui/hdf5/Hdf5Formatter.py
index 3a4c1c1..0e3697f 100644
--- a/silx/gui/hdf5/Hdf5Formatter.py
+++ b/silx/gui/hdf5/Hdf5Formatter.py
@@ -27,7 +27,7 @@ text."""
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "27/09/2017"
+__date__ = "23/01/2018"
import numpy
from silx.third_party import six
@@ -153,7 +153,8 @@ class Hdf5Formatter(qt.QObject):
if not full:
return "compound"
else:
- compound = [d[0] for d in dtype.fields.values()]
+ fields = sorted(dtype.fields.items(), key=lambda e: e[1][1])
+ compound = [d[1][0] for d in fields]
compound = [self.humanReadableDType(d) for d in compound]
return "compound(%s)" % ", ".join(compound)
elif numpy.issubdtype(dtype, numpy.integer):
diff --git a/silx/gui/hdf5/Hdf5Item.py b/silx/gui/hdf5/Hdf5Item.py
index f131f61..9804907 100644
--- a/silx/gui/hdf5/Hdf5Item.py
+++ b/silx/gui/hdf5/Hdf5Item.py
@@ -25,7 +25,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "26/09/2017"
+__date__ = "10/10/2017"
import logging
@@ -40,12 +40,6 @@ from ..hdf5.Hdf5Formatter import Hdf5Formatter
_logger = logging.getLogger(__name__)
-try:
- import h5py
-except ImportError as e:
- _logger.error("Module %s requires h5py", __name__)
- raise e
-
_formatter = TextFormatter()
_hdf5Formatter = Hdf5Formatter(textFormatter=_formatter)
# FIXME: The formatter should be an attribute of the Hdf5Model
@@ -57,15 +51,15 @@ class Hdf5Item(Hdf5Node):
tree structure.
"""
- def __init__(self, text, obj, parent, key=None, h5pyClass=None, linkClass=None, populateAll=False):
+ def __init__(self, text, obj, parent, key=None, h5Class=None, linkClass=None, populateAll=False):
"""
:param str text: text displayed
- :param object obj: Pointer to h5py data. See the `obj` attribute.
+ :param object obj: Pointer to a h5py-link object. See the `obj` attribute.
"""
self.__obj = obj
self.__key = key
- self.__h5pyClass = h5pyClass
- self.__isBroken = obj is None and h5pyClass is None
+ self.__h5Class = h5Class
+ self.__isBroken = obj is None and h5Class is None
self.__error = None
self.__text = text
self.__linkClass = linkClass
@@ -74,7 +68,7 @@ class Hdf5Item(Hdf5Node):
@property
def obj(self):
if self.__key:
- self.__initH5pyObject()
+ self.__initH5Object()
return self.__obj
@property
@@ -82,6 +76,20 @@ class Hdf5Item(Hdf5Node):
return self.__text
@property
+ def h5Class(self):
+ """Returns the class of the stored object.
+
+ When the object is in lazy loading, this method should be able to
+ return the type of the futrue loaded object. It allows to delay the
+ real load of the object.
+
+ :rtype: silx.io.utils.H5Type
+ """
+ if self.__h5Class is None and self.obj is not None:
+ self.__h5Class = silx.io.utils.get_h5_class(self.obj)
+ return self.__h5Class
+
+ @property
def h5pyClass(self):
"""Returns the class of the stored object.
@@ -91,15 +99,14 @@ class Hdf5Item(Hdf5Node):
:rtype: h5py.File or h5py.Dataset or h5py.Group
"""
- if self.__h5pyClass is None and self.obj is not None:
- self.__h5pyClass = silx.io.utils.get_h5py_class(self.obj)
- return self.__h5pyClass
+ type_ = self.h5Class
+ return silx.io.utils.h5type_to_h5py_class(type_)
@property
def linkClass(self):
"""Returns the link class object of this node
- :type: h5py.SoftLink or h5py.HardLink or h5py.ExternalLink or None
+ :rtype: H5Type
"""
return self.__linkClass
@@ -109,16 +116,16 @@ class Hdf5Item(Hdf5Node):
:rtype: bool
"""
- if self.h5pyClass is None:
+ if self.h5Class is None:
return False
- return issubclass(self.h5pyClass, h5py.Group)
+ return self.h5Class in [silx.io.utils.H5Type.GROUP, silx.io.utils.H5Type.FILE]
def isBrokenObj(self):
"""Returns true if the stored HDF5 object is broken.
- The stored object is then an h5py link (external or not) which point
- to nowhere (tbhe external file is not here, the expected dataset is
- still not on the file...)
+ The stored object is then an h5py-like link (external or not) which
+ point to nowhere (tbhe external file is not here, the expected
+ dataset is still not on the file...)
:rtype: bool
"""
@@ -137,7 +144,7 @@ class Hdf5Item(Hdf5Node):
return len(self.obj)
return 0
- def __initH5pyObject(self):
+ def __initH5Object(self):
"""Lazy load of the HDF5 node. It is reached from the parent node
with the key of the node."""
parent_obj = self.parent.obj
@@ -145,7 +152,9 @@ class Hdf5Item(Hdf5Node):
try:
obj = parent_obj.get(self.__key)
except Exception as e:
- _logger.debug("Internal h5py error", exc_info=True)
+ lib_name = self.obj.__class__.__module__.split(".")[0]
+ _logger.debug("Internal %s error", lib_name, exc_info=True)
+ _logger.debug("Backtrace", exc_info=True)
try:
self.__obj = parent_obj.get(self.__key, getlink=True)
except Exception:
@@ -168,9 +177,11 @@ class Hdf5Item(Hdf5Node):
if not hasattr(self.__obj, "file"):
self.__obj.file = parent_obj.file
- if isinstance(self.__obj, h5py.ExternalLink):
+ class_ = silx.io.utils.get_h5_class(self.__obj)
+
+ if class_ == silx.io.utils.H5Type.EXTERNAL_LINK:
message = "External link broken. Path %s::%s does not exist" % (self.__obj.filename, self.__obj.path)
- elif isinstance(self.__obj, h5py.SoftLink):
+ elif class_ == silx.io.utils.H5Type.SOFT_LINK:
message = "Soft link broken. Path %s does not exist" % (self.__obj.path)
else:
name = self.obj.__class__.__name__.split(".")[-1].capitalize()
@@ -204,14 +215,25 @@ class Hdf5Item(Hdf5Node):
try:
class_ = self.obj.get(name, getclass=True)
link = self.obj.get(name, getclass=True, getlink=True)
- except Exception as e:
- _logger.warn("Internal h5py error", exc_info=True)
+ link = silx.io.utils.get_h5_class(class_=link)
+ except Exception:
+ lib_name = self.obj.__class__.__module__.split(".")[0]
+ _logger.warning("Internal %s error", lib_name, exc_info=True)
+ _logger.debug("Backtrace", exc_info=True)
class_ = None
try:
link = self.obj.get(name, getclass=True, getlink=True)
- except Exception as e:
- link = h5py.HardLink
- item = Hdf5Item(text=name, obj=None, parent=self, key=name, h5pyClass=class_, linkClass=link)
+ link = silx.io.utils.get_h5_class(class_=link)
+ except Exception:
+ _logger.debug("Backtrace", exc_info=True)
+ link = silx.io.utils.H5Type.HARD_LINK
+
+ h5class = None
+ if class_ is not None:
+ h5class = silx.io.utils.get_h5_class(class_=class_)
+ if h5class is None:
+ _logger.error("Class %s unsupported", class_)
+ item = Hdf5Item(text=name, obj=None, parent=self, key=name, h5Class=h5class, linkClass=link)
self.appendChild(item)
def hasChildren(self):
@@ -234,16 +256,16 @@ class Hdf5Item(Hdf5Node):
if self.__isBroken:
icon = style.standardIcon(qt.QStyle.SP_MessageBoxCritical)
return icon
- class_ = self.h5pyClass
- if issubclass(class_, h5py.File):
+ class_ = self.h5Class
+ if class_ == silx.io.utils.H5Type.FILE:
return style.standardIcon(qt.QStyle.SP_FileIcon)
- elif issubclass(class_, h5py.Group):
+ elif class_ == silx.io.utils.H5Type.GROUP:
return style.standardIcon(qt.QStyle.SP_DirIcon)
- elif issubclass(class_, h5py.SoftLink):
+ elif class_ == silx.io.utils.H5Type.SOFT_LINK:
return style.standardIcon(qt.QStyle.SP_DirLinkIcon)
- elif issubclass(class_, h5py.ExternalLink):
+ elif class_ == silx.io.utils.H5Type.EXTERNAL_LINK:
return style.standardIcon(qt.QStyle.SP_FileLinkIcon)
- elif issubclass(class_, h5py.Dataset):
+ elif class_ == silx.io.utils.H5Type.DATASET:
if obj.shape is None:
name = "item-none"
elif len(obj.shape) < 4:
@@ -262,28 +284,28 @@ class Hdf5Item(Hdf5Node):
"""
attributeDict = collections.OrderedDict()
- if issubclass(self.h5pyClass, h5py.Dataset):
+ if self.h5Class == silx.io.utils.H5Type.DATASET:
attributeDict["#Title"] = "HDF5 Dataset"
attributeDict["Name"] = self.basename
attributeDict["Path"] = self.obj.name
attributeDict["Shape"] = self._getFormatter().humanReadableShape(self.obj)
attributeDict["Value"] = self._getFormatter().humanReadableValue(self.obj)
attributeDict["Data type"] = self._getFormatter().humanReadableType(self.obj, full=True)
- elif issubclass(self.h5pyClass, h5py.Group):
+ elif self.h5Class == silx.io.utils.H5Type.GROUP:
attributeDict["#Title"] = "HDF5 Group"
attributeDict["Name"] = self.basename
attributeDict["Path"] = self.obj.name
- elif issubclass(self.h5pyClass, h5py.File):
+ elif self.h5Class == silx.io.utils.H5Type.FILE:
attributeDict["#Title"] = "HDF5 File"
attributeDict["Name"] = self.basename
attributeDict["Path"] = "/"
- elif isinstance(self.obj, h5py.ExternalLink):
+ elif self.h5Class == silx.io.utils.H5Type.EXTERNAL_LINK:
attributeDict["#Title"] = "HDF5 External Link"
attributeDict["Name"] = self.basename
attributeDict["Path"] = self.obj.name
attributeDict["Linked path"] = self.obj.path
attributeDict["Linked file"] = self.obj.filename
- elif isinstance(self.obj, h5py.SoftLink):
+ elif self.h5Class == silx.io.utils.H5Type.SOFT_LINK:
attributeDict["#Title"] = "HDF5 Soft Link"
attributeDict["Name"] = self.basename
attributeDict["Path"] = self.obj.name
@@ -331,8 +353,8 @@ class Hdf5Item(Hdf5Node):
if role == qt.Qt.DisplayRole:
if self.__error is not None:
return ""
- class_ = self.h5pyClass
- if issubclass(class_, h5py.Dataset):
+ class_ = self.h5Class
+ if class_ == silx.io.utils.H5Type.DATASET:
text = self._getFormatter().humanReadableType(self.obj)
else:
text = ""
@@ -349,8 +371,8 @@ class Hdf5Item(Hdf5Node):
if role == qt.Qt.DisplayRole:
if self.__error is not None:
return ""
- class_ = self.h5pyClass
- if not issubclass(class_, h5py.Dataset):
+ class_ = self.h5Class
+ if class_ != silx.io.utils.H5Type.DATASET:
return ""
return self._getFormatter().humanReadableShape(self.obj)
return None
@@ -364,7 +386,7 @@ class Hdf5Item(Hdf5Node):
if role == qt.Qt.DisplayRole:
if self.__error is not None:
return ""
- if not issubclass(self.h5pyClass, h5py.Dataset):
+ if self.h5Class != silx.io.utils.H5Type.DATASET:
return ""
return self._getFormatter().humanReadableValue(self.obj)
return None
@@ -387,7 +409,7 @@ class Hdf5Item(Hdf5Node):
if role == qt.Qt.ToolTipRole:
if self.__error is not None:
self.obj # lazy loading of the object
- self.__initH5pyObject()
+ self.__initH5Object()
return self.__error
if "desc" in self.obj.attrs:
text = self.obj.attrs["desc"]
@@ -405,11 +427,11 @@ class Hdf5Item(Hdf5Node):
if role == qt.Qt.DisplayRole:
if self.isBrokenObj():
return ""
- class_ = self.h5pyClass
+ class_ = self.obj.__class__
text = class_.__name__.split(".")[-1]
return text
if role == qt.Qt.ToolTipRole:
- class_ = self.h5pyClass
+ class_ = self.obj.__class__
if class_ is None:
return ""
return "Class name: %s" % self.__class__
@@ -430,11 +452,11 @@ class Hdf5Item(Hdf5Node):
link = self.linkClass
if link is None:
return ""
- elif link is h5py.ExternalLink:
+ elif link == silx.io.utils.H5Type.EXTERNAL_LINK:
return "External"
- elif link is h5py.SoftLink:
+ elif link == silx.io.utils.H5Type.SOFT_LINK:
return "Soft"
- elif link is h5py.HardLink:
+ elif link == silx.io.utils.H5Type.HARD_LINK:
return ""
else:
return link.__name__
diff --git a/silx/gui/hdf5/Hdf5TreeModel.py b/silx/gui/hdf5/Hdf5TreeModel.py
index 41fa91c..2d62429 100644
--- a/silx/gui/hdf5/Hdf5TreeModel.py
+++ b/silx/gui/hdf5/Hdf5TreeModel.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -25,11 +25,12 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "22/09/2017"
+__date__ = "29/11/2017"
import os
import logging
+import functools
from .. import qt
from .. import icons
from .Hdf5Node import Hdf5Node
@@ -130,7 +131,6 @@ class LoadingItemRunnable(qt.QRunnable):
item = Hdf5Item(text=text, obj=h5obj, parent=oldItem.parent, populateAll=True)
return item
- @qt.Slot()
def run(self):
"""Process the file loading. The worker is used as holder
of the data and the signal. The result is sent as a signal.
@@ -237,25 +237,32 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
self.__openedFiles = []
"""Store the list of files opened by the model itself."""
- # FIXME: It should managed one by one by Hdf5Item itself
-
- def __del__(self):
- self._closeOpened()
- s = super(Hdf5TreeModel, self)
- if hasattr(s, "__del__"):
- # else it fail on Python 3
- s.__del__()
+ # FIXME: It should be managed one by one by Hdf5Item itself
+
+ # It is not possible to override the QObject destructor nor
+ # to access to the content of the Python object with the `destroyed`
+ # signal cause the Python method was already removed with the QWidget,
+ # while the QObject still exists.
+ # We use a static method plus explicit references to objects to
+ # release. The callback do not use any ref to self.
+ onDestroy = functools.partial(self._closeFileList, self.__openedFiles)
+ self.destroyed.connect(onDestroy)
+
+ @staticmethod
+ def _closeFileList(fileList):
+ """Static method to close explicit references to internal objects."""
+ _logger.debug("Clear Hdf5TreeModel")
+ for obj in fileList:
+ _logger.debug("Close file %s", obj.filename)
+ obj.close()
+ fileList[:] = []
def _closeOpened(self):
"""Close files which was opened by this model.
- This function may be removed in the future.
-
File are opened by the model when it was inserted using
`insertFileAsync`, `insertFile`, `appendFile`."""
- for h5file in self.__openedFiles:
- h5file.close()
- self.__openedFiles = []
+ self._closeFileList(self.__openedFiles)
def __updateLoadingItems(self, icon):
for i in range(self.__root.childCount()):
@@ -283,6 +290,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
self.__root.removeChildAtIndex(row)
self.endRemoveRows()
if newItem is not None:
+ rootIndex = qt.QModelIndex()
self.__openedFiles.append(newItem.obj)
self.beginInsertRows(rootIndex, row, row)
self.__root.insertChild(row, newItem)
@@ -325,7 +333,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
Returns an object that contains serialized items of data corresponding
to the list of indexes specified.
- :param list(qt.QModelIndex) indexes: List of indexes
+ :param List[qt.QModelIndex] indexes: List of indexes
:rtype: qt.QMimeData
"""
if not self.__fileMoveEnabled or len(indexes) == 0:
@@ -512,6 +520,16 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
def nodeFromIndex(self, index):
return index.internalPointer() if index.isValid() else self.__root
+ def _closeFileIfOwned(self, node):
+ """"Close the file if it was loaded from a filename or a
+ drag-and-drop"""
+ obj = node.obj
+ for f in self.__openedFiles:
+ if f in obj:
+ _logger.debug("Close file %s", obj.filename)
+ obj.close()
+ self.__openedFiles.remove(obj)
+
def synchronizeIndex(self, index):
"""
Synchronize a file a given its index.
@@ -524,9 +542,8 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
if node.parent is not self.__root:
return
- self.removeIndex(index)
filename = node.obj.filename
- node.obj.close()
+ self.removeIndex(index)
self.insertFileAsync(filename, index.row())
def synchronizeH5pyObject(self, h5pyObject):
@@ -555,6 +572,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
node = self.nodeFromIndex(index)
if node.parent is not self.__root:
return
+ self._closeFileIfOwned(node)
self.beginRemoveRows(qt.QModelIndex(), index.row(), index.row())
self.__root.removeChildAtIndex(index.row())
self.endRemoveRows()
@@ -587,6 +605,9 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
row = self.__root.childCount()
self.insertNode(row, Hdf5Item(text=text, obj=h5pyObject, parent=self.__root))
+ def hasPendingOperations(self):
+ return len(self.__runnerSet) > 0
+
def insertFileAsync(self, filename, row=-1):
if not os.path.isfile(filename):
raise IOError("Filename '%s' must be a file path" % filename)
@@ -599,9 +620,9 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
# start loading the real one
runnable = LoadingItemRunnable(filename, item)
runnable.itemReady.connect(self.__itemReady)
- self.__runnerSet.add(runnable)
runnable.runnerFinished.connect(self.__releaseRunner)
- qt.QThreadPool.globalInstance().start(runnable)
+ self.__runnerSet.add(runnable)
+ qt.silxGlobalThreadPool().start(runnable)
def __releaseRunner(self, runner):
self.__runnerSet.remove(runner)
@@ -621,3 +642,75 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
def appendFile(self, filename):
self.insertFile(filename, -1)
+
+ def indexFromH5Object(self, h5Object):
+ """Returns a model index from an h5py-like object.
+
+ :param object h5Object: An h5py-like object
+ :rtype: qt.QModelIndex
+ """
+ if h5Object is None:
+ return qt.QModelIndex()
+
+ filename = h5Object.file.filename
+
+ # Seach for the right roots
+ rootIndices = []
+ for index in range(self.rowCount(qt.QModelIndex())):
+ index = self.index(index, 0, qt.QModelIndex())
+ obj = self.data(index, Hdf5TreeModel.H5PY_OBJECT_ROLE)
+ if obj.file.filename == filename:
+ # We can have many roots with different subtree of the same
+ # root
+ rootIndices.append(index)
+
+ if len(rootIndices) == 0:
+ # No root found
+ return qt.QModelIndex()
+
+ path = h5Object.name + "/"
+ path = path.replace("//", "/")
+
+ # Search for the right node
+ found = False
+ foundIndices = []
+ for _ in range(1000 * len(rootIndices)):
+ # Avoid too much iterations, in case of recurssive links
+ if len(foundIndices) == 0:
+ if len(rootIndices) == 0:
+ # Nothing found
+ break
+ # Start fron a new root
+ foundIndices.append(rootIndices.pop(0))
+
+ obj = self.data(index, Hdf5TreeModel.H5PY_OBJECT_ROLE)
+ p = obj.name + "/"
+ p = p.replace("//", "/")
+ if path == p:
+ found = True
+ break
+
+ parentIndex = foundIndices[-1]
+ for index in range(self.rowCount(parentIndex)):
+ index = self.index(index, 0, parentIndex)
+ obj = self.data(index, Hdf5TreeModel.H5PY_OBJECT_ROLE)
+
+ p = obj.name + "/"
+ p = p.replace("//", "/")
+ if path == p:
+ foundIndices.append(index)
+ found = True
+ break
+ elif path.startswith(p):
+ foundIndices.append(index)
+ break
+ else:
+ # Nothing found, start again with another root
+ foundIndices = []
+
+ if found:
+ break
+
+ if found:
+ return foundIndices[-1]
+ return qt.QModelIndex()
diff --git a/silx/gui/hdf5/Hdf5TreeView.py b/silx/gui/hdf5/Hdf5TreeView.py
index 0a4198e..78b5c19 100644
--- a/silx/gui/hdf5/Hdf5TreeView.py
+++ b/silx/gui/hdf5/Hdf5TreeView.py
@@ -25,7 +25,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "20/09/2017"
+__date__ = "20/02/2018"
import logging
@@ -114,12 +114,12 @@ class Hdf5TreeView(qt.QTreeView):
callback(event)
except KeyboardInterrupt:
raise
- except:
+ except Exception:
# make sure no user callback crash the application
_logger.error("Error while calling callback", exc_info=True)
pass
- if len(menu.children()) > 0:
+ if not menu.isEmpty():
for action in actions:
menu.addAction(action)
menu.popup(self.viewport().mapToGlobal(pos))
@@ -194,6 +194,38 @@ class Hdf5TreeView(qt.QTreeView):
continue
yield _utils.H5Node(item)
+ def __intermediateModels(self, index):
+ """Returns intermediate models from the view model to the
+ model of the index."""
+ models = []
+ targetModel = index.model()
+ model = self.model()
+ while model is not None:
+ if model is targetModel:
+ # found
+ return models
+ models.append(model)
+ if isinstance(model, qt.QAbstractProxyModel):
+ model = model.sourceModel()
+ else:
+ break
+ raise RuntimeError("Model from the requested index is not reachable from this view")
+
+ def mapToModel(self, index):
+ """Map an index from any model reachable by the view to an index from
+ the very first model connected to the view.
+
+ :param qt.QModelIndex index: Index from the Hdf5Tree model
+ :rtype: qt.QModelIndex
+ :return: Index from the model connected to the view
+ """
+ if not index.isValid():
+ return index
+ models = self.__intermediateModels(index)
+ for model in reversed(models):
+ index = model.mapFromSource(index)
+ return index
+
def setSelectedH5Node(self, h5Object):
"""
Select the specified node of the tree using an h5py node.
@@ -203,77 +235,22 @@ class Hdf5TreeView(qt.QTreeView):
- If the item is not found, the selection do not change.
- A none argument allow to deselect everything
- :param h5py.Npde h5Object: The node to select
+ :param h5py.Node h5Object: The node to select
"""
if h5Object is None:
self.setCurrentIndex(qt.QModelIndex())
return
- filename = h5Object.file.filename
-
- # Seach for the right roots
- rootIndices = []
- model = self.model()
- for index in range(model.rowCount(qt.QModelIndex())):
- index = model.index(index, 0, qt.QModelIndex())
- obj = model.data(index, Hdf5TreeModel.H5PY_OBJECT_ROLE)
- if obj.file.filename == filename:
- # We can have many roots with different subtree of the same
- # root
- rootIndices.append(index)
-
- if len(rootIndices) == 0:
- # No root found
- return
-
- path = h5Object.name + "/"
- path = path.replace("//", "/")
-
- # Search for the right node
- found = False
- foundIndices = []
- for _ in range(1000 * len(rootIndices)):
- # Avoid too much iterations, in case of recurssive links
- if len(foundIndices) == 0:
- if len(rootIndices) == 0:
- # Nothing found
- break
- # Start fron a new root
- foundIndices.append(rootIndices.pop(0))
-
- obj = model.data(index, Hdf5TreeModel.H5PY_OBJECT_ROLE)
- p = obj.name + "/"
- p = p.replace("//", "/")
- if path == p:
- found = True
- break
-
- parentIndex = foundIndices[-1]
- for index in range(model.rowCount(parentIndex)):
- index = model.index(index, 0, parentIndex)
- obj = model.data(index, Hdf5TreeModel.H5PY_OBJECT_ROLE)
-
- p = obj.name + "/"
- p = p.replace("//", "/")
- if path == p:
- foundIndices.append(index)
- found = True
- break
- elif path.startswith(p):
- foundIndices.append(index)
- break
- else:
- # Nothing found, start again with another root
- foundIndices = []
-
- if found:
- break
-
- if found:
+ model = self.findHdf5TreeModel()
+ index = model.indexFromH5Object(h5Object)
+ index = self.mapToModel(index)
+ if index.isValid():
# Update the GUI
- for index in foundIndices[:-1]:
- self.expand(index)
- self.setCurrentIndex(foundIndices[-1])
+ i = index
+ while i.isValid():
+ self.expand(i)
+ i = i.parent()
+ self.setCurrentIndex(index)
def mousePressEvent(self, event):
"""Override mousePressEvent to provide a consistante compatible API
diff --git a/silx/gui/hdf5/NexusSortFilterProxyModel.py b/silx/gui/hdf5/NexusSortFilterProxyModel.py
index 49a22d3..9a27968 100644
--- a/silx/gui/hdf5/NexusSortFilterProxyModel.py
+++ b/silx/gui/hdf5/NexusSortFilterProxyModel.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -25,7 +25,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "16/06/2017"
+__date__ = "10/10/2017"
import logging
@@ -33,14 +33,8 @@ import re
import numpy
from .. import qt
from .Hdf5TreeModel import Hdf5TreeModel
+import silx.io.utils
-_logger = logging.getLogger(__name__)
-
-try:
- import h5py
-except ImportError as e:
- _logger.error("Module %s requires h5py", __name__)
- raise e
_logger = logging.getLogger(__name__)
@@ -86,8 +80,8 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel):
def __isNXentry(self, node):
"""Returns true if the node is an NXentry"""
- class_ = node.h5pyClass
- if class_ is None or not issubclass(node.h5pyClass, h5py.Group):
+ class_ = node.h5Class
+ if class_ is None or class_ != silx.io.utils.H5Type.GROUP:
return False
nxClass = node.obj.attrs.get("NX_class", None)
return nxClass == "NXentry"
@@ -100,7 +94,7 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel):
`["aaa", 10, "bbb", 50, ".", 30]`.
:param str name: A name
- :rtype: list
+ :rtype: List
"""
words = self.__split.findall(name)
result = []
@@ -148,6 +142,6 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel):
return left_time < right_time
except KeyboardInterrupt:
raise
- except Exception as e:
+ except Exception:
_logger.debug("Exception occurred", exc_info=True)
return None
diff --git a/silx/gui/hdf5/_utils.py b/silx/gui/hdf5/_utils.py
index 048aa20..ddf4db5 100644
--- a/silx/gui/hdf5/_utils.py
+++ b/silx/gui/hdf5/_utils.py
@@ -28,7 +28,7 @@ package `silx.gui.hdf5` package.
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "29/09/2017"
+__date__ = "20/12/2017"
import logging
@@ -38,12 +38,6 @@ from silx.utils.html import escape
_logger = logging.getLogger(__name__)
-try:
- import h5py
-except ImportError as e:
- _logger.error("Module %s requires h5py", __name__)
- raise e
-
class Hdf5ContextMenuEvent(object):
"""Hold information provided to context menu callbacks."""
@@ -168,12 +162,13 @@ class H5Node(object):
e = elements.pop(0)
path = path + "/" + e
link = obj.parent.get(path, getlink=True)
+ classlink = silx.io.utils.get_h5_class(link)
- if isinstance(link, h5py.ExternalLink):
+ if classlink == silx.io.utils.H5Type.EXTERNAL_LINK:
subpath = "/".join(elements)
external_obj = obj.parent.get(self.basename + "/" + subpath)
return self.__get_target(external_obj)
- elif silx.io.utils.is_softlink(link):
+ elif classlink == silx.io.utils.H5Type.SOFT_LINK:
# Restart from this stat
path = ""
root_elements = link.path.split("/")
@@ -202,13 +197,22 @@ class H5Node(object):
return self.__h5py_object
@property
+ def h5type(self):
+ """Returns the node type, as an H5Type.
+
+ :rtype: H5Node
+ """
+ return silx.io.utils.get_h5_class(self.__h5py_object)
+
+ @property
def ntype(self):
"""Returns the node type, as an h5py class.
:rtype:
:class:`h5py.File`, :class:`h5py.Group` or :class:`h5py.Dataset`
"""
- return silx.io.utils.get_h5py_class(self.__h5py_object)
+ type_ = self.h5type
+ return silx.io.utils.h5type_to_h5py_class(type_)
@property
def basename(self):
@@ -269,13 +273,13 @@ class H5Node(object):
"""
item = self.__h5py_item
while item.parent.parent is not None:
- class_ = item.h5pyClass
- if class_ is not None and issubclass(class_, h5py.File):
+ class_ = silx.io.utils.get_h5_class(class_=item.h5pyClass)
+ if class_ == silx.io.utils.H5Type.FILE:
break
item = item.parent
- class_ = item.h5pyClass
- if class_ is not None and issubclass(class_, h5py.File):
+ class_ = silx.io.utils.get_h5_class(class_=item.h5pyClass)
+ if class_ == silx.io.utils.H5Type.FILE:
return item.obj
else:
return item.obj.file
@@ -313,8 +317,8 @@ class H5Node(object):
:rtype: str
"""
- class_ = self.__h5py_item.h5pyClass
- if class_ is not None and issubclass(class_, h5py.File):
+ class_ = self.__h5py_item.h5Class
+ if class_ is not None and class_ == silx.io.utils.H5Type.FILE:
return ""
return self.__h5py_item.basename
@@ -327,10 +331,11 @@ class H5Node(object):
:rtype: h5py.File
:raises RuntimeError: If no file are found
"""
- if isinstance(self.__h5py_object, h5py.ExternalLink):
+ class_ = silx.io.utils.get_h5_class(self.__h5py_object)
+ if class_ == silx.io.utils.H5Type.EXTERNAL_LINK:
# It means the link is broken
raise RuntimeError("No file node found")
- if isinstance(self.__h5py_object, h5py.SoftLink):
+ if class_ == silx.io.utils.H5Type.SOFT_LINK:
# It means the link is broken
return self.local_file
@@ -347,10 +352,11 @@ class H5Node(object):
:rtype: str
"""
- if isinstance(self.__h5py_object, h5py.ExternalLink):
+ class_ = silx.io.utils.get_h5_class(self.__h5py_object)
+ if class_ == silx.io.utils.H5Type.EXTERNAL_LINK:
# It means the link is broken
return self.__h5py_object.path
- if isinstance(self.__h5py_object, h5py.SoftLink):
+ if class_ == silx.io.utils.H5Type.SOFT_LINK:
# It means the link is broken
return self.__h5py_object.path
@@ -367,10 +373,11 @@ class H5Node(object):
:rtype: str
"""
- if isinstance(self.__h5py_object, h5py.ExternalLink):
+ class_ = silx.io.utils.get_h5_class(self.__h5py_object)
+ if class_ == silx.io.utils.H5Type.EXTERNAL_LINK:
# It means the link is broken
return self.__h5py_object.filename
- if isinstance(self.__h5py_object, h5py.SoftLink):
+ if class_ == silx.io.utils.H5Type.SOFT_LINK:
# It means the link is broken
return self.local_file.filename
diff --git a/silx/gui/hdf5/test/test_hdf5.py b/silx/gui/hdf5/test/test_hdf5.py
index 8e375f2..44c4456 100644
--- a/silx/gui/hdf5/test/test_hdf5.py
+++ b/silx/gui/hdf5/test/test_hdf5.py
@@ -26,7 +26,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "22/09/2017"
+__date__ = "20/02/2018"
import time
@@ -40,6 +40,7 @@ from silx.gui import qt
from silx.gui.test.utils import TestCaseQt
from silx.gui import hdf5
from silx.io import commonh5
+import weakref
try:
import h5py
@@ -69,6 +70,14 @@ class TestHdf5TreeModel(TestCaseQt):
if h5py is None:
self.skipTest("h5py is not available")
+ def waitForPendingOperations(self, model):
+ for i in range(10):
+ if not model.hasPendingOperations():
+ break
+ self.qWait(10)
+ else:
+ raise RuntimeError("Still waiting for a pending operation")
+
@contextmanager
def h5TempFile(self):
# create tmp file
@@ -96,7 +105,9 @@ class TestHdf5TreeModel(TestCaseQt):
# clean up
index = model.index(0, 0, qt.QModelIndex())
h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE)
- h5File.close()
+ ref = weakref.ref(model)
+ model = None
+ self.qWaitForDestroy(ref)
def testAppendBadFilename(self):
model = hdf5.Hdf5TreeModel()
@@ -104,32 +115,35 @@ class TestHdf5TreeModel(TestCaseQt):
def testInsertFilename(self):
with self.h5TempFile() as filename:
- model = hdf5.Hdf5TreeModel()
- self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
- model.insertFile(filename)
- self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
- # clean up
- index = model.index(0, 0, qt.QModelIndex())
- h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE)
- h5File.close()
+ try:
+ model = hdf5.Hdf5TreeModel()
+ self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
+ model.insertFile(filename)
+ self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
+ # clean up
+ index = model.index(0, 0, qt.QModelIndex())
+ h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE)
+ self.assertIsNotNone(h5File)
+ finally:
+ ref = weakref.ref(model)
+ model = None
+ self.qWaitForDestroy(ref)
def testInsertFilenameAsync(self):
with self.h5TempFile() as filename:
- model = hdf5.Hdf5TreeModel()
- self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
- model.insertFileAsync(filename)
- index = model.index(0, 0, qt.QModelIndex())
- self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5LoadingItem.Hdf5LoadingItem)
- time.sleep(0.1)
- self.qapp.processEvents()
- time.sleep(0.1)
- index = model.index(0, 0, qt.QModelIndex())
- self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item)
- self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
- # clean up
- index = model.index(0, 0, qt.QModelIndex())
- h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE)
- h5File.close()
+ try:
+ model = hdf5.Hdf5TreeModel()
+ self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
+ model.insertFileAsync(filename)
+ index = model.index(0, 0, qt.QModelIndex())
+ self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5LoadingItem.Hdf5LoadingItem)
+ self.waitForPendingOperations(model)
+ index = model.index(0, 0, qt.QModelIndex())
+ self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item)
+ finally:
+ ref = weakref.ref(model)
+ model = None
+ self.qWaitForDestroy(ref)
def testInsertObject(self):
h5 = commonh5.File("/foo/bar/1.mock", "w")
@@ -156,6 +170,10 @@ class TestHdf5TreeModel(TestCaseQt):
index = model.index(0, 0, qt.QModelIndex())
node1 = model.nodeFromIndex(index)
model.synchronizeH5pyObject(h5)
+ # Now h5 was loaded from it's filename
+ # Another ref is owned by the model
+ h5.close()
+
index = model.index(0, 0, qt.QModelIndex())
node2 = model.nodeFromIndex(index)
self.assertIsNot(node1, node2)
@@ -168,7 +186,12 @@ class TestHdf5TreeModel(TestCaseQt):
# clean up
index = model.index(0, 0, qt.QModelIndex())
h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE)
- h5File.close()
+ self.assertIsNotNone(h5File)
+ h5File = None
+ # delete the model
+ ref = weakref.ref(model)
+ model = None
+ self.qWaitForDestroy(ref)
def testFileMoveState(self):
model = hdf5.Hdf5TreeModel()
@@ -206,15 +229,17 @@ class TestHdf5TreeModel(TestCaseQt):
model.dropMimeData(mimeData, qt.Qt.CopyAction, 0, 0, qt.QModelIndex())
self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
# after sync
- time.sleep(0.1)
- self.qapp.processEvents()
- time.sleep(0.1)
+ self.waitForPendingOperations(model)
index = model.index(0, 0, qt.QModelIndex())
self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5Item.Hdf5Item)
# clean up
index = model.index(0, 0, qt.QModelIndex())
h5File = model.data(index, role=hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE)
- h5File.close()
+ self.assertIsNotNone(h5File)
+ h5File = None
+ ref = weakref.ref(model)
+ model = None
+ self.qWaitForDestroy(ref)
def getRowDataAsDict(self, model, row):
displayed = {}
@@ -503,7 +528,9 @@ class TestH5Node(TestCaseQt):
@classmethod
def tearDownClass(cls):
+ ref = weakref.ref(cls.model)
cls.model = None
+ cls.qWaitForDestroy(ref)
cls.h5File.close()
shutil.rmtree(cls.tmpDirectory)
super(TestH5Node, cls).tearDownClass()
@@ -696,6 +723,18 @@ class TestHdf5TreeView(TestCaseQt):
view = hdf5.Hdf5TreeView()
view._createContextMenu(qt.QPoint(0, 0))
+ def testSelection_OriginalModel(self):
+ tree = commonh5.File("/foo/bar/1.mock", "w")
+ item = tree.create_group("a/b/c/d")
+ item.create_group("e").create_group("f")
+
+ view = hdf5.Hdf5TreeView()
+ view.findHdf5TreeModel().insertH5pyObject(tree)
+ view.setSelectedH5Node(item)
+
+ selected = list(view.selectedH5Nodes())[0]
+ self.assertIs(item, selected.h5py_object)
+
def testSelection_Simple(self):
tree = commonh5.File("/foo/bar/1.mock", "w")
item = tree.create_group("a/b/c/d")
diff --git a/silx/gui/icons.py b/silx/gui/icons.py
index 07654c1..0108b3a 100644
--- a/silx/gui/icons.py
+++ b/silx/gui/icons.py
@@ -328,7 +328,8 @@ def getQIcon(name):
"""
if name not in _cached_icons:
qfile = getQFile(name)
- icon = qt.QIcon(qfile.fileName())
+ pixmap = qt.QPixmap(qfile.fileName())
+ icon = qt.QIcon(pixmap)
_cached_icons[name] = icon
else:
icon = _cached_icons[name]
diff --git a/silx/gui/plot/ColorBar.py b/silx/gui/plot/ColorBar.py
index 8f4bde2..2db7b79 100644
--- a/silx/gui/plot/ColorBar.py
+++ b/silx/gui/plot/ColorBar.py
@@ -27,7 +27,7 @@
__authors__ = ["H. Payno", "T. Vincent"]
__license__ = "MIT"
-__date__ = "11/04/2017"
+__date__ = "15/02/2018"
import logging
@@ -65,11 +65,12 @@ class ColorBarWidget(qt.QWidget):
:param plot: PlotWidget the colorbar is attached to (optional)
:param str legend: the label to set to the colorbar
"""
+ sigVisibleChanged = qt.Signal(bool)
+ """Emitted when the property `visible` have changed."""
def __init__(self, parent=None, plot=None, legend=None):
self._isConnected = False
self._plot = None
- self._viewAction = None
self._colormap = None
self._data = None
@@ -127,15 +128,18 @@ class ColorBarWidget(qt.QWidget):
self._plot.sigPlotSignal.connect(self._defaultColormapChanged)
self._isConnected = True
+ def setVisible(self, isVisible):
+ # isHidden looks to be always synchronized, while isVisible is not
+ wasHidden = self.isHidden()
+ qt.QWidget.setVisible(self, isVisible)
+ if wasHidden != self.isHidden():
+ self.sigVisibleChanged.emit(not self.isHidden())
+
def showEvent(self, event):
self._connectPlot()
- if self._viewAction is not None:
- self._viewAction.setChecked(True)
def hideEvent(self, event):
self._disconnectPlot()
- if self._viewAction is not None:
- self._viewAction.setChecked(False)
def getColormap(self):
"""
@@ -230,21 +234,6 @@ class ColorBarWidget(qt.QWidget):
and ticks"""
return self._colorScale
- def getToggleViewAction(self):
- """Returns a checkable action controlling this widget's visibility.
-
- :rtype: QAction
- """
- if self._viewAction is None:
- self._viewAction = qt.QAction(self)
- self._viewAction.setText('Colorbar')
- self._viewAction.setIcon(icons.getQIcon('colorbar'))
- self._viewAction.setToolTip('Show/Hide the colorbar')
- self._viewAction.setCheckable(True)
- self._viewAction.setChecked(self.isVisible())
- self._viewAction.toggled[bool].connect(self.setVisible)
- return self._viewAction
-
class _VerticalLegend(qt.QLabel):
"""Display vertically the given text
@@ -405,8 +394,8 @@ class ColorScaleBar(qt.QWidget):
:param val: if True, set the labels visible, otherwise set it not visible
"""
- self._maxLabel.show() if val is True else self._maxLabel.hide()
- self._minLabel.show() if val is True else self._minLabel.hide()
+ self._minLabel.setVisible(val)
+ self._maxLabel.setVisible(val)
def _updateMinMax(self):
"""Update the min and max label if we are in the case of the
@@ -533,12 +522,7 @@ class _ColorScale(qt.QWidget):
return
indices = numpy.linspace(0., 1., self._NB_CONTROL_POINTS)
- colormapDisp = Colormap.Colormap(name=colormap.getName(),
- normalization=Colormap.Colormap.LINEAR,
- vmin=None,
- vmax=None,
- colors=colormap.getColormapLUT())
- colors = colormapDisp.applyToData(indices)
+ colors = colormap.getNColors(nbColors=self._NB_CONTROL_POINTS)
self._gradient = qt.QLinearGradient(0, 1, 0, 0)
self._gradient.setCoordinateMode(qt.QGradient.StretchToDeviceMode)
self._gradient.setStops(
@@ -784,7 +768,7 @@ class _TickBar(qt.QWidget):
if self._norm == Colormap.Colormap.LINEAR:
return 1 - (val - self._vmin) / (self._vmax - self._vmin)
elif self._norm == Colormap.Colormap.LOGARITHM:
- return 1 - (numpy.log10(val) - numpy.log10(self._vmin))/(numpy.log10(self._vmax) - numpy.log(self._vmin))
+ return 1 - (numpy.log10(val) - numpy.log10(self._vmin)) / (numpy.log10(self._vmax) - numpy.log(self._vmin))
else:
raise ValueError('Norm is not recognized')
diff --git a/silx/gui/plot/Colormap.py b/silx/gui/plot/Colormap.py
index abe8546..9adf0d4 100644
--- a/silx/gui/plot/Colormap.py
+++ b/silx/gui/plot/Colormap.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -29,7 +29,7 @@ from __future__ import absolute_import
__authors__ = ["T. Vincent", "H.Payno"]
__license__ = "MIT"
-__date__ = "05/12/2016"
+__date__ = "08/01/2018"
from silx.gui import qt
import copy as copy_mdl
@@ -37,6 +37,7 @@ import numpy
from .matplotlib import Colormap as MPLColormap
import logging
from silx.math.combo import min_max
+from silx.utils.exceptions import NotEditableError
_logger = logging.getLogger(__file__)
@@ -62,7 +63,7 @@ class Colormap(qt.QObject):
Nx3 or Nx4 numpy array of RGB(A) colors,
either uint8 or float in [0, 1].
If 'name' is None, then this array is used as the colormap.
- :param str norm: Normalization: 'linear' (default) or 'log'
+ :param str normalization: Normalization: 'linear' (default) or 'log'
:param float vmin:
Lower bound of the colormap or None for autoscale (default)
:param float vmax:
@@ -79,6 +80,7 @@ class Colormap(qt.QObject):
"""Tuple of managed normalizations"""
sigChanged = qt.Signal()
+ """Signal emitted when the colormap has changed."""
def __init__(self, name='gray', colors=None, normalization=LINEAR, vmin=None, vmax=None):
qt.QObject.__init__(self)
@@ -98,10 +100,11 @@ class Colormap(qt.QObject):
self._normalization = str(normalization)
self._vmin = float(vmin) if vmin is not None else None
self._vmax = float(vmax) if vmax is not None else None
+ self._editable = True
def isAutoscale(self):
"""Return True if both min and max are in autoscale mode"""
- return self._vmin is None or self._vmax is None
+ return self._vmin is None and self._vmax is None
def getName(self):
"""Return the name of the colormap
@@ -115,35 +118,69 @@ class Colormap(qt.QObject):
else:
self._colors = numpy.array(colors, copy=True)
+ def getNColors(self, nbColors=None):
+ """Returns N colors computed by sampling the colormap regularly.
+
+ :param nbColors:
+ The number of colors in the returned array or None for the default value.
+ The default value is 256 for colormap with a name (see :meth:`setName`) and
+ it is the size of the LUT for colormap defined with :meth:`setColormapLUT`.
+ :type nbColors: int or None
+ :return: 2D array of uint8 of shape (nbColors, 4)
+ :rtype: numpy.ndarray
+ """
+ # Handle default value for nbColors
+ if nbColors is None:
+ lut = self.getColormapLUT()
+ if lut is not None: # In this case uses LUT length
+ nbColors = len(lut)
+ else: # Default to 256
+ nbColors = 256
+
+ nbColors = int(nbColors)
+
+ colormap = self.copy()
+ colormap.setNormalization(Colormap.LINEAR)
+ colormap.setVRange(vmin=None, vmax=None)
+ colors = colormap.applyToData(
+ numpy.arange(nbColors, dtype=numpy.int))
+ return colors
+
def setName(self, name):
- """Set the name of the colormap and load the colors corresponding to
- the name
+ """Set the name of the colormap to use.
- :param str name: the name of the colormap (should be in ['gray',
+ :param str name: The name of the colormap.
+ At least the following names are supported: 'gray',
'reversed gray', 'temperature', 'red', 'green', 'blue', 'jet',
- 'viridis', 'magma', 'inferno', 'plasma']
+ 'viridis', 'magma', 'inferno', 'plasma'.
"""
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
assert name in self.getSupportedColormaps()
self._name = str(name)
self._colors = None
self.sigChanged.emit()
def getColormapLUT(self):
- """Return the list of colors for the colormap. None if not setted
-
- :return: the list of colors for the colormap. None if not setted
- :rtype: numpy.ndarray
+ """Return the list of colors for the colormap or None if not set
+
+ :return: the list of colors for the colormap or None if not set
+ :rtype: numpy.ndarray or None
"""
- return self._colors
+ if self._colors is None:
+ return None
+ else:
+ return numpy.array(self._colors, copy=True)
def setColormapLUT(self, colors):
- """
- Set the colors of the colormap.
+ """Set the colors of the colormap.
:param numpy.ndarray colors: the colors of the LUT
- .. warning: this will set the value of name to an empty string
+ .. warning: this will set the value of name to None
"""
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
self._setColors(colors)
if len(colors) is 0:
self._colors = None
@@ -153,7 +190,7 @@ class Colormap(qt.QObject):
def getNormalization(self):
"""Return the normalization of the colormap ('log' or 'linear')
-
+
:return: the normalization of the colormap
:rtype: str
"""
@@ -164,12 +201,14 @@ class Colormap(qt.QObject):
:param str norm: the norm to set
"""
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
self._normalization = str(norm)
self.sigChanged.emit()
def getVMin(self):
"""Return the lower bound of the colormap
-
+
:return: the lower bound of the colormap
:rtype: float or None
"""
@@ -182,10 +221,12 @@ class Colormap(qt.QObject):
(default)
value)
"""
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
if vmin is not None:
- if self._vmax is not None and vmin >= self._vmax:
- err = "Can't set vmin because vmin >= vmax."
- err += "vmin = %s, vmax = %s" %(vmin, self._vmax)
+ if self._vmax is not None and vmin > self._vmax:
+ err = "Can't set vmin because vmin >= vmax. " \
+ "vmin = %s, vmax = %s" % (vmin, self._vmax)
raise ValueError(err)
self._vmin = vmin
@@ -193,7 +234,7 @@ class Colormap(qt.QObject):
def getVMax(self):
"""Return the upper bounds of the colormap or None
-
+
:return: the upper bounds of the colormap or None
:rtype: float or None
"""
@@ -205,15 +246,35 @@ class Colormap(qt.QObject):
:param float vmax: Upper bounds of the colormap or None for autoscale
(default)
"""
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
if vmax is not None:
- if self._vmin is not None and vmax <= self._vmin:
- err = "Can't set vmax because vmax <= vmin."
- err += "vmin = %s, vmax = %s" %(self._vmin, vmax)
+ if self._vmin is not None and vmax < self._vmin:
+ err = "Can't set vmax because vmax <= vmin. " \
+ "vmin = %s, vmax = %s" % (self._vmin, vmax)
raise ValueError(err)
self._vmax = vmax
self.sigChanged.emit()
+ def isEditable(self):
+ """ Return if the colormap is editable or not
+
+ :return: editable state of the colormap
+ :rtype: bool
+ """
+ return self._editable
+
+ def setEditable(self, editable):
+ """
+ Set the editable state of the colormap
+
+ :param bool editable: is the colormap editable
+ """
+ assert type(editable) is bool
+ self._editable = editable
+ self.sigChanged.emit()
+
def getColormapRange(self, data=None):
"""Return (vmin, vmax)
@@ -267,20 +328,24 @@ class Colormap(qt.QObject):
return vmin, vmax
def setVRange(self, vmin, vmax):
- """
- Set bounds to the colormap
+ """Set the bounds of the colormap
:param vmin: Lower bound of the colormap or None for autoscale
(default)
:param vmax: Upper bounds of the colormap or None for autoscale
(default)
"""
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
if vmin is not None and vmax is not None:
- if vmin >= vmax:
- err = "Can't set vmin and vmax because vmin >= vmax"
- err += "vmin = %s, vmax = %s" %(vmin, self._vmax)
+ if vmin > vmax:
+ err = "Can't set vmin and vmax because vmin >= vmax " \
+ "vmin = %s, vmax = %s" % (vmin, vmax)
raise ValueError(err)
+ if self._vmin == vmin and self._vmax == vmax:
+ return
+
self._vmin = vmin
self._vmax = vmax
self.sigChanged.emit()
@@ -322,6 +387,8 @@ class Colormap(qt.QObject):
:param dict dic: the colormap as a dictionary
"""
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
name = dic['name'] if 'name' in dic else None
colors = dic['colors'] if 'colors' in dic else None
vmin = dic['vmin'] if 'vmin' in dic else None
@@ -361,9 +428,9 @@ class Colormap(qt.QObject):
return colormap
def copy(self):
- """
+ """Return a copy of the Colormap.
- :return: a copy of the Colormap object
+ :rtype: silx.gui.plot.Colormap.Colormap
"""
return Colormap(name=self._name,
colors=copy_mdl.copy(self._colors),
@@ -408,3 +475,115 @@ class Colormap(qt.QObject):
numpy.array_equal(self.getColormapLUT(), other.getColormapLUT())
)
+ _SERIAL_VERSION = 1
+
+ def restoreState(self, byteArray):
+ """
+ Read the colormap state from a QByteArray.
+
+ :param qt.QByteArray byteArray: Stream containing the state
+ :return: True if the restoration sussseed
+ :rtype: bool
+ """
+ if self.isEditable() is False:
+ raise NotEditableError('Colormap is not editable')
+ stream = qt.QDataStream(byteArray, qt.QIODevice.ReadOnly)
+
+ className = stream.readQString()
+ if className != self.__class__.__name__:
+ _logger.warning("Classname mismatch. Found %s." % className)
+ return False
+
+ version = stream.readUInt32()
+ if version != self._SERIAL_VERSION:
+ _logger.warning("Serial version mismatch. Found %d." % version)
+ return False
+
+ name = stream.readQString()
+ isNull = stream.readBool()
+ if not isNull:
+ vmin = stream.readQVariant()
+ else:
+ vmin = None
+ isNull = stream.readBool()
+ if not isNull:
+ vmax = stream.readQVariant()
+ else:
+ vmax = None
+ normalization = stream.readQString()
+
+ # emit change event only once
+ old = self.blockSignals(True)
+ try:
+ self.setName(name)
+ self.setNormalization(normalization)
+ self.setVRange(vmin, vmax)
+ finally:
+ self.blockSignals(old)
+ self.sigChanged.emit()
+ return True
+
+ def saveState(self):
+ """
+ Save state of the colomap into a QDataStream.
+
+ :rtype: qt.QByteArray
+ """
+ data = qt.QByteArray()
+ stream = qt.QDataStream(data, qt.QIODevice.WriteOnly)
+
+ stream.writeQString(self.__class__.__name__)
+ stream.writeUInt32(self._SERIAL_VERSION)
+ stream.writeQString(self.getName())
+ stream.writeBool(self.getVMin() is None)
+ if self.getVMin() is not None:
+ stream.writeQVariant(self.getVMin())
+ stream.writeBool(self.getVMax() is None)
+ if self.getVMax() is not None:
+ stream.writeQVariant(self.getVMax())
+ stream.writeQString(self.getNormalization())
+ return data
+
+
+_PREFERRED_COLORMAPS = DEFAULT_COLORMAPS
+"""
+Tuple of preferred colormap names accessed with :meth:`preferredColormaps`.
+"""
+
+
+def preferredColormaps():
+ """Returns the name of the preferred colormaps.
+
+ This list is used by widgets allowing to change the colormap
+ like the :class:`ColormapDialog` as a subset of colormap choices.
+
+ :rtype: tuple of str
+ """
+ return _PREFERRED_COLORMAPS
+
+
+def setPreferredColormaps(colormaps):
+ """Set the list of preferred colormap names.
+
+ Warning: If a colormap name is not available
+ it will be removed from the list.
+
+ :param colormaps: Not empty list of colormap names
+ :type colormaps: iterable of str
+ :raise ValueError: if the list of available preferred colormaps is empty.
+ """
+ supportedColormaps = Colormap.getSupportedColormaps()
+ colormaps = tuple(
+ cmap for cmap in colormaps if cmap in supportedColormaps)
+ if len(colormaps) == 0:
+ raise ValueError("Cannot set preferred colormaps to an empty list")
+
+ global _PREFERRED_COLORMAPS
+ _PREFERRED_COLORMAPS = colormaps
+
+
+# Initialize preferred colormaps
+setPreferredColormaps(('gray', 'reversed gray',
+ 'temperature', 'red', 'green', 'blue', 'jet',
+ 'viridis', 'magma', 'inferno', 'plasma',
+ 'hsv'))
diff --git a/silx/gui/plot/ColormapDialog.py b/silx/gui/plot/ColormapDialog.py
index 748dd72..4aefab6 100644
--- a/silx/gui/plot/ColormapDialog.py
+++ b/silx/gui/plot/ColormapDialog.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -31,12 +31,14 @@ To run the following sample code, a QApplication must be initialized.
Create the colormap dialog and set the colormap description and data range:
>>> from silx.gui.plot.ColormapDialog import ColormapDialog
+>>> from silx.gui.plot.Colormap import Colormap
>>> dialog = ColormapDialog()
+>>> colormap = Colormap(name='red', normalization='log',
+... vmin=1., vmax=2.)
->>> dialog.setColormap(name='red', normalization='log',
-... autoscale=False, vmin=1., vmax=2.)
->>> dialog.setDataRange(1., 100.) # This scale the width of the plot area
+>>> dialog.setColormap(colormap)
+>>> colormap.setVRange(1., 100.) # This scale the width of the plot area
>>> dialog.show()
Get the colormap description (compatible with :class:`Plot`) from the dialog:
@@ -59,9 +61,9 @@ The updates of the colormap description are also available through the signal:
from __future__ import division
-__authors__ = ["V.A. Sole", "T. Vincent"]
+__authors__ = ["V.A. Sole", "T. Vincent", "H. Payno"]
__license__ = "MIT"
-__date__ = "02/10/2017"
+__date__ = "09/02/2018"
import logging
@@ -69,13 +71,162 @@ import logging
import numpy
from .. import qt
-from .Colormap import Colormap
+from .Colormap import Colormap, preferredColormaps
from . import PlotWidget
from silx.gui.widgets.FloatEdit import FloatEdit
+import weakref
+from silx.math.combo import min_max
+from silx.third_party import enum
+from silx.gui import icons
+from silx.math.histogram import Histogramnd
_logger = logging.getLogger(__name__)
+_colormapIconPreview = {}
+
+
+class _BoundaryWidget(qt.QWidget):
+ """Widget to edit a boundary of the colormap (vmin, vmax)"""
+ sigValueChanged = qt.Signal(object)
+ """Signal emitted when value is changed"""
+
+ def __init__(self, parent=None, value=0.0):
+ qt.QWidget.__init__(self, parent=None)
+ self.setLayout(qt.QHBoxLayout())
+ self.layout().setContentsMargins(0, 0, 0, 0)
+ self._numVal = FloatEdit(parent=self, value=value)
+ self.layout().addWidget(self._numVal)
+ self._autoCB = qt.QCheckBox('auto', parent=self)
+ self.layout().addWidget(self._autoCB)
+ self._autoCB.setChecked(False)
+
+ self._autoCB.toggled.connect(self._autoToggled)
+ self.sigValueChanged = self._autoCB.toggled
+ self.textEdited = self._numVal.textEdited
+ self.editingFinished = self._numVal.editingFinished
+ self._dataValue = None
+
+ def isAutoChecked(self):
+ return self._autoCB.isChecked()
+
+ def getValue(self):
+ return None if self._autoCB.isChecked() else self._numVal.value()
+
+ def getFiniteValue(self):
+ if not self._autoCB.isChecked():
+ return self._numVal.value()
+ elif self._dataValue is None:
+ return self._numVal.value()
+ else:
+ return self._dataValue
+
+ def _autoToggled(self, enabled):
+ self._numVal.setEnabled(not enabled)
+ self._updateDisplayedText()
+
+ def _updateDisplayedText(self):
+ # if dataValue is finite
+ if self._autoCB.isChecked() and self._dataValue is not None:
+ old = self._numVal.blockSignals(True)
+ self._numVal.setValue(self._dataValue)
+ self._numVal.blockSignals(old)
+
+ def setDataValue(self, dataValue):
+ self._dataValue = dataValue
+ self._updateDisplayedText()
+
+ def setFiniteValue(self, value):
+ assert(value is not None)
+ old = self._numVal.blockSignals(True)
+ self._numVal.setValue(value)
+ self._numVal.blockSignals(old)
+
+ def setValue(self, value, isAuto=False):
+ self._autoCB.setChecked(isAuto or value is None)
+ if value is not None:
+ self._numVal.setValue(value)
+ self._updateDisplayedText()
+
+
+class _ColormapNameCombox(qt.QComboBox):
+ def __init__(self, parent=None):
+ qt.QComboBox.__init__(self, parent)
+ self.__initItems()
+
+ ORIGINAL_NAME = qt.Qt.UserRole + 1
+
+ def __initItems(self):
+ for colormapName in preferredColormaps():
+ index = self.count()
+ self.addItem(str.title(colormapName))
+ self.setItemIcon(index, self.getIconPreview(colormapName))
+ self.setItemData(index, colormapName, role=self.ORIGINAL_NAME)
+
+ def getIconPreview(self, colormapName):
+ """Return an icon preview from a LUT name.
+
+ This icons are cached into a global structure.
+
+ :param str colormapName: str
+ :rtype: qt.QIcon
+ """
+ if colormapName not in _colormapIconPreview:
+ icon = self.createIconPreview(colormapName)
+ _colormapIconPreview[colormapName] = icon
+ return _colormapIconPreview[colormapName]
+
+ def createIconPreview(self, colormapName):
+ """Create and return an icon preview from a LUT name.
+
+ This icons are cached into a global structure.
+
+ :param str colormapName: Name of the LUT
+ :rtype: qt.QIcon
+ """
+ colormap = Colormap(colormapName)
+ size = 32
+ lut = colormap.getNColors(size)
+ if lut is None or len(lut) == 0:
+ return qt.QIcon()
+
+ pixmap = qt.QPixmap(size, size)
+ painter = qt.QPainter(pixmap)
+ for i in range(size):
+ rgb = lut[i]
+ r, g, b = rgb[0], rgb[1], rgb[2]
+ painter.setPen(qt.QColor(r, g, b))
+ painter.drawPoint(qt.QPoint(i, 0))
+
+ painter.drawPixmap(0, 1, size, size - 1, pixmap, 0, 0, size, 1)
+ painter.end()
+
+ return qt.QIcon(pixmap)
+
+ def getCurrentName(self):
+ return self.itemData(self.currentIndex(), self.ORIGINAL_NAME)
+
+ def findColormap(self, name):
+ return self.findData(name, role=self.ORIGINAL_NAME)
+
+ def setCurrentName(self, name):
+ index = self.findColormap(name)
+ if index < 0:
+ index = self.count()
+ self.addItem(str.title(name))
+ self.setItemIcon(index, self.getIconPreview(name))
+ self.setItemData(index, name, role=self.ORIGINAL_NAME)
+ self.setCurrentIndex(index)
+
+
+@enum.unique
+class _DataInPlotMode(enum.Enum):
+ """Enum for each mode of display of the data in the plot."""
+ NONE = 'none'
+ RANGE = 'range'
+ HISTOGRAM = 'histogram'
+
+
class ColormapDialog(qt.QDialog):
"""A QDialog widget to set the colormap.
@@ -83,57 +234,62 @@ class ColormapDialog(qt.QDialog):
:param str title: The QDialog title
"""
- sigColormapChanged = qt.Signal(Colormap)
- """Signal triggered when the colormap is changed.
-
- It provides a dict describing the colormap to the slot.
- This dict can be used with :class:`Plot`.
- """
+ visibleChanged = qt.Signal(bool)
+ """This event is sent when the dialog visibility change"""
def __init__(self, parent=None, title="Colormap Dialog"):
qt.QDialog.__init__(self, parent)
self.setWindowTitle(title)
+ self._colormap = None
+ self._data = None
+ self._dataInPlotMode = _DataInPlotMode.RANGE
+
+ self._ignoreColormapChange = False
+ """Used as a semaphore to avoid editing the colormap object when we are
+ only attempt to display it.
+ Used instead of n connect and disconnect of the sigChanged. The
+ disconnection to sigChanged was also limiting when this colormapdialog
+ is used in the colormapaction and associated to the activeImageChanged.
+ (because the activeImageChanged is send when the colormap changed and
+ the self.setcolormap is a callback)
+ """
+
self._histogramData = None
- self._dataRange = None
self._minMaxWasEdited = False
+ self._initialRange = None
+
+ self._dataRange = None
+ """If defined 3-tuple containing information from a data:
+ minimum, positive minimum, maximum"""
- colormaps = [
- 'gray', 'reversed gray',
- 'temperature', 'red', 'green', 'blue', 'jet',
- 'viridis', 'magma', 'inferno', 'plasma']
- if 'hsv' in Colormap.getSupportedColormaps():
- colormaps.append('hsv')
- self._colormapList = tuple(colormaps)
+ self._colormapStoredState = None
# Make the GUI
vLayout = qt.QVBoxLayout(self)
- formWidget = qt.QWidget()
+ formWidget = qt.QWidget(parent=self)
vLayout.addWidget(formWidget)
formLayout = qt.QFormLayout(formWidget)
formLayout.setContentsMargins(10, 10, 10, 10)
formLayout.setSpacing(0)
# Colormap row
- self._comboBoxColormap = qt.QComboBox()
- for cmap in self._colormapList:
- # Capitalize first letters
- cmap = ' '.join(w[0].upper() + w[1:] for w in cmap.split())
- self._comboBoxColormap.addItem(cmap)
- self._comboBoxColormap.activated[int].connect(self._notify)
+ self._comboBoxColormap = _ColormapNameCombox(parent=formWidget)
+ self._comboBoxColormap.currentIndexChanged[int].connect(self._updateName)
formLayout.addRow('Colormap:', self._comboBoxColormap)
# Normalization row
self._normButtonLinear = qt.QRadioButton('Linear')
self._normButtonLinear.setChecked(True)
self._normButtonLog = qt.QRadioButton('Log')
+ self._normButtonLog.toggled.connect(self._activeLogNorm)
normButtonGroup = qt.QButtonGroup(self)
normButtonGroup.setExclusive(True)
normButtonGroup.addButton(self._normButtonLinear)
normButtonGroup.addButton(self._normButtonLog)
- normButtonGroup.buttonClicked[int].connect(self._notify)
+ self._normButtonLinear.toggled[bool].connect(self._updateLinearNorm)
normLayout = qt.QHBoxLayout()
normLayout.setContentsMargins(0, 0, 0, 0)
@@ -143,51 +299,124 @@ class ColormapDialog(qt.QDialog):
formLayout.addRow('Normalization:', normLayout)
- # Range row
- self._rangeAutoscaleButton = qt.QCheckBox('Autoscale')
- self._rangeAutoscaleButton.setChecked(True)
- self._rangeAutoscaleButton.toggled.connect(self._autoscaleToggled)
- self._rangeAutoscaleButton.clicked.connect(self._notify)
- formLayout.addRow('Range:', self._rangeAutoscaleButton)
-
# Min row
- self._minValue = FloatEdit(parent=self, value=1.)
- self._minValue.setEnabled(False)
+ self._minValue = _BoundaryWidget(parent=self, value=1.0)
self._minValue.textEdited.connect(self._minMaxTextEdited)
self._minValue.editingFinished.connect(self._minEditingFinished)
+ self._minValue.sigValueChanged.connect(self._updateMinMax)
formLayout.addRow('\tMin:', self._minValue)
# Max row
- self._maxValue = FloatEdit(parent=self, value=10.)
- self._maxValue.setEnabled(False)
+ self._maxValue = _BoundaryWidget(parent=self, value=10.0)
self._maxValue.textEdited.connect(self._minMaxTextEdited)
+ self._maxValue.sigValueChanged.connect(self._updateMinMax)
self._maxValue.editingFinished.connect(self._maxEditingFinished)
formLayout.addRow('\tMax:', self._maxValue)
# Add plot for histogram
+ self._plotToolbar = qt.QToolBar(self)
+ self._plotToolbar.setFloatable(False)
+ self._plotToolbar.setMovable(False)
+ self._plotToolbar.setIconSize(qt.QSize(8, 8))
+ self._plotToolbar.setStyleSheet("QToolBar { border: 0px }")
+ self._plotToolbar.setOrientation(qt.Qt.Vertical)
+
+ group = qt.QActionGroup(self._plotToolbar)
+ group.setExclusive(True)
+
+ action = qt.QAction("Nothing", self)
+ action.setToolTip("No range nor histogram are displayed. No extra computation have to be done.")
+ action.setIcon(icons.getQIcon('colormap-none'))
+ action.setCheckable(True)
+ action.setData(_DataInPlotMode.NONE)
+ action.setChecked(action.data() == self._dataInPlotMode)
+ self._plotToolbar.addAction(action)
+ group.addAction(action)
+ action = qt.QAction("Data range", self)
+ action.setToolTip("Display the data range within the colormap range. A fast data processing have to be done.")
+ action.setIcon(icons.getQIcon('colormap-range'))
+ action.setCheckable(True)
+ action.setData(_DataInPlotMode.RANGE)
+ action.setChecked(action.data() == self._dataInPlotMode)
+ self._plotToolbar.addAction(action)
+ group.addAction(action)
+ action = qt.QAction("Histogram", self)
+ action.setToolTip("Display the data histogram within the colormap range. A slow data processing have to be done. ")
+ action.setIcon(icons.getQIcon('colormap-histogram'))
+ action.setCheckable(True)
+ action.setData(_DataInPlotMode.HISTOGRAM)
+ action.setChecked(action.data() == self._dataInPlotMode)
+ self._plotToolbar.addAction(action)
+ group.addAction(action)
+ group.triggered.connect(self._displayDataInPlotModeChanged)
+
+ self._plotBox = qt.QWidget(self)
self._plotInit()
- vLayout.addWidget(self._plot)
- # Close button
- buttonsWidget = qt.QWidget()
- vLayout.addWidget(buttonsWidget)
+ plotBoxLayout = qt.QHBoxLayout()
+ plotBoxLayout.setContentsMargins(0, 0, 0, 0)
+ plotBoxLayout.setSpacing(2)
+ plotBoxLayout.addWidget(self._plotToolbar)
+ plotBoxLayout.addWidget(self._plot)
+ plotBoxLayout.setSizeConstraint(qt.QLayout.SetMinimumSize)
+ self._plotBox.setLayout(plotBoxLayout)
+ vLayout.addWidget(self._plotBox)
+
+ # define modal buttons
+ types = qt.QDialogButtonBox.Ok | qt.QDialogButtonBox.Cancel
+ self._buttonsModal = qt.QDialogButtonBox(parent=self)
+ self._buttonsModal.setStandardButtons(types)
+ self.layout().addWidget(self._buttonsModal)
+ self._buttonsModal.accepted.connect(self.accept)
+ self._buttonsModal.rejected.connect(self.reject)
+
+ # define non modal buttons
+ types = qt.QDialogButtonBox.Close | qt.QDialogButtonBox.Reset
+ self._buttonsNonModal = qt.QDialogButtonBox(parent=self)
+ self._buttonsNonModal.setStandardButtons(types)
+ self.layout().addWidget(self._buttonsNonModal)
+ self._buttonsNonModal.button(qt.QDialogButtonBox.Close).clicked.connect(self.accept)
+ self._buttonsNonModal.button(qt.QDialogButtonBox.Reset).clicked.connect(self.resetColormap)
+
+ # Set the colormap to default values
+ self.setColormap(Colormap(name='gray', normalization='linear',
+ vmin=None, vmax=None))
- buttonsLayout = qt.QHBoxLayout(buttonsWidget)
+ self.setModal(self.isModal())
- okButton = qt.QPushButton('OK')
- okButton.clicked.connect(self.accept)
- buttonsLayout.addWidget(okButton)
+ vLayout.setSizeConstraint(qt.QLayout.SetMinimumSize)
+ self.setFixedSize(self.sizeHint())
+ self._applyColormap()
- cancelButton = qt.QPushButton('Cancel')
- cancelButton.clicked.connect(self.reject)
- buttonsLayout.addWidget(cancelButton)
+ def showEvent(self, event):
+ self.visibleChanged.emit(True)
+ super(ColormapDialog, self).showEvent(event)
- # colormap window can not be resized
- self.setFixedSize(vLayout.minimumSize())
+ def closeEvent(self, event):
+ if not self.isModal():
+ self.accept()
+ super(ColormapDialog, self).closeEvent(event)
- # Set the colormap to default values
- self.setColormap(name='gray', normalization='linear',
- autoscale=True, vmin=1., vmax=10.)
+ def hideEvent(self, event):
+ self.visibleChanged.emit(False)
+ super(ColormapDialog, self).hideEvent(event)
+
+ def close(self):
+ self.accept()
+ qt.QDialog.close(self)
+
+ def setModal(self, modal):
+ assert type(modal) is bool
+ self._buttonsNonModal.setVisible(not modal)
+ self._buttonsModal.setVisible(modal)
+ qt.QDialog.setModal(self, modal)
+
+ def exec_(self):
+ wasModal = self.isModal()
+ self.setModal(True)
+ result = super(ColormapDialog, self).exec_()
+ self.setModal(wasModal)
+ return result
def _plotInit(self):
"""Init the plot to display the range and the values"""
@@ -199,51 +428,63 @@ class ColormapDialog(qt.QDialog):
self._plot.setActiveCurveHandling(False)
self._plot.setMinimumSize(qt.QSize(250, 200))
self._plot.sigPlotSignal.connect(self._plotSlot)
- self._plot.hide()
self._plotUpdate()
+ def sizeHint(self):
+ return self.layout().minimumSize()
+
def _plotUpdate(self, updateMarkers=True):
"""Update the plot content
:param bool updateMarkers: True to update markers, False otherwith
"""
- dataRange = self.getDataRange()
-
- if dataRange is None:
- if self._plot.isVisibleTo(self):
- self._plot.setVisible(False)
- self.setFixedSize(self.layout().minimumSize())
+ colormap = self.getColormap()
+ if colormap is None:
+ if self._plotBox.isVisibleTo(self):
+ self._plotBox.setVisible(False)
+ self.setFixedSize(self.sizeHint())
return
- if not self._plot.isVisibleTo(self):
- self._plot.setVisible(True)
- self.setFixedSize(self.layout().minimumSize())
+ if not self._plotBox.isVisibleTo(self):
+ self._plotBox.setVisible(True)
+ self.setFixedSize(self.sizeHint())
- dataMin, dataMax = dataRange
- marge = (abs(dataMax) + abs(dataMin)) / 6.0
- minmd = dataMin - marge
- maxpd = dataMax + marge
+ minData, maxData = self._minValue.getFiniteValue(), self._maxValue.getFiniteValue()
+ if minData > maxData:
+ # avoid a full collapse
+ minData, maxData = maxData, minData
+ minimum = minData
+ maximum = maxData
- start, end = self._minValue.value(), self._maxValue.value()
+ if self._dataRange is not None:
+ minRange = self._dataRange[0]
+ maxRange = self._dataRange[2]
+ minimum = min(minimum, minRange)
+ maximum = max(maximum, maxRange)
- if start <= end:
- x = [minmd, start, end, maxpd]
- y = [0, 0, 1, 1]
+ if self._histogramData is not None:
+ minHisto = self._histogramData[1][0]
+ maxHisto = self._histogramData[1][-1]
+ minimum = min(minimum, minHisto)
+ maximum = max(maximum, maxHisto)
- else:
- x = [minmd, end, start, maxpd]
- y = [1, 1, 0, 0]
-
- # Display the colormap on the side
- # colormap = {'name': self.getColormap()['name'],
- # 'normalization': self.getColormap()['normalization'],
- # 'autoscale': True, 'vmin': 1., 'vmax': 256.}
- # self._plot.addImage((1 + numpy.arange(256)).reshape(256, -1),
- # xScale=(minmd - marge, marge),
- # yScale=(1., 2./256.),
- # legend='colormap',
- # colormap=colormap)
+ marge = abs(maximum - minimum) / 6.0
+ if marge < 0.0001:
+ # Smaller that the QLineEdit precision
+ marge = 0.0001
+
+ minView, maxView = minimum - marge, maximum + marge
+
+ if updateMarkers:
+ # Save the state in we are not moving the markers
+ self._initialRange = minView, maxView
+ elif self._initialRange is not None:
+ minView = min(minView, self._initialRange[0])
+ maxView = max(maxView, self._initialRange[1])
+
+ x = [minView, minData, maxData, maxView]
+ y = [0, 0, 1, 1]
self._plot.addCurve(x, y,
legend="ConstrainedCurve",
@@ -252,22 +493,24 @@ class ColormapDialog(qt.QDialog):
linestyle='-',
resetzoom=False)
- draggable = not self._rangeAutoscaleButton.isChecked()
-
if updateMarkers:
+ minDraggable = (self._colormap().isEditable() and
+ not self._minValue.isAutoChecked())
self._plot.addXMarker(
- self._minValue.value(),
+ self._minValue.getFiniteValue(),
legend='Min',
text='Min',
- draggable=draggable,
+ draggable=minDraggable,
color='blue',
constraint=self._plotMinMarkerConstraint)
+ maxDraggable = (self._colormap().isEditable() and
+ not self._maxValue.isAutoChecked())
self._plot.addXMarker(
- self._maxValue.value(),
+ self._maxValue.getFiniteValue(),
legend='Max',
text='Max',
- draggable=draggable,
+ draggable=maxDraggable,
color='blue',
constraint=self._plotMaxMarkerConstraint)
@@ -275,11 +518,11 @@ class ColormapDialog(qt.QDialog):
def _plotMinMarkerConstraint(self, x, y):
"""Constraint of the min marker"""
- return min(x, self._maxValue.value()), y
+ return min(x, self._maxValue.getFiniteValue()), y
def _plotMaxMarkerConstraint(self, x, y):
"""Constraint of the max marker"""
- return max(x, self._minValue.value()), y
+ return max(x, self._minValue.getFiniteValue()), y
def _plotSlot(self, event):
"""Handle events from the plot"""
@@ -293,10 +536,139 @@ class ColormapDialog(qt.QDialog):
# This will recreate the markers while interacting...
# It might break if marker interaction is changed
if event['event'] == 'markerMoved':
- self._notify()
+ self._initialRange = None
+ self._updateMinMax()
else:
self._plotUpdate(updateMarkers=False)
+ @staticmethod
+ def computeDataRange(data):
+ """Compute the data range as used by :meth:`setDataRange`.
+
+ :param data: The data to process
+ :rtype: Tuple(float, float, float)
+ """
+ if data is None or len(data) == 0:
+ return None, None, None
+
+ dataRange = min_max(data, min_positive=True, finite=True)
+ if dataRange.minimum is None:
+ # Only non-finite data
+ dataRange = None
+
+ if dataRange is not None:
+ min_positive = dataRange.min_positive
+ if min_positive is None:
+ min_positive = float('nan')
+ dataRange = dataRange.minimum, min_positive, dataRange.maximum
+
+ if dataRange is None or len(dataRange) != 3:
+ qt.QMessageBox.warning(
+ None, "No Data",
+ "Image data does not contain any real value")
+ dataRange = 1., 1., 10.
+
+ return dataRange
+
+ @staticmethod
+ def computeHistogram(data):
+ """Compute the data histogram as used by :meth:`setHistogram`.
+
+ :param data: The data to process
+ :rtype: Tuple(List(float),List(float)
+ """
+ _data = data
+ if _data.ndim == 3: # RGB(A) images
+ _logger.info('Converting current image from RGB(A) to grayscale\
+ in order to compute the intensity distribution')
+ _data = (_data[:, :, 0] * 0.299 +
+ _data[:, :, 1] * 0.587 +
+ _data[:, :, 2] * 0.114)
+
+ if len(_data) == 0:
+ return None, None
+
+ xmin, xmax = min_max(_data, min_positive=False, finite=True)
+ nbins = min(256, int(numpy.sqrt(_data.size)))
+ data_range = xmin, xmax
+
+ # bad hack: get 256 bins in the case we have a B&W
+ if numpy.issubdtype(_data.dtype, numpy.integer):
+ if nbins > xmax - xmin:
+ nbins = xmax - xmin
+
+ nbins = max(2, nbins)
+ _data = _data.ravel().astype(numpy.float32)
+
+ histogram = Histogramnd(_data, n_bins=nbins, histo_range=data_range)
+ return histogram.histo, histogram.edges[0]
+
+ def _getData(self):
+ if self._data is None:
+ return None
+ return self._data()
+
+ def setData(self, data):
+ """Store the data as a weakref.
+
+ According to the state of the dialog, the data will be used to display
+ the data range or the histogram of the data using :meth:`setDataRange`
+ and :meth:`setHistogram`
+ """
+ oldData = self._getData()
+ if oldData is data:
+ return
+
+ if data is None:
+ self.setDataRange()
+ self.setHistogram()
+ self._data = None
+ return
+
+ self._data = weakref.ref(data, self._dataAboutToFinalize)
+
+ self._updateDataInPlot()
+
+ def _setDataInPlotMode(self, mode):
+ if self._dataInPlotMode == mode:
+ return
+ self._dataInPlotMode = mode
+ self._updateDataInPlot()
+
+ def _displayDataInPlotModeChanged(self, action):
+ mode = action.data()
+ self._setDataInPlotMode(mode)
+
+ def _updateDataInPlot(self):
+ data = self._getData()
+ if data is None:
+ return
+
+ mode = self._dataInPlotMode
+
+ if mode == _DataInPlotMode.NONE:
+ self.setHistogram()
+ self.setDataRange()
+ elif mode == _DataInPlotMode.RANGE:
+ result = self.computeDataRange(data)
+ self.setHistogram()
+ self.setDataRange(*result)
+ elif mode == _DataInPlotMode.HISTOGRAM:
+ # The histogram should be done in a worker thread
+ result = self.computeHistogram(data)
+ self.setHistogram(*result)
+ self.setDataRange()
+
+ def _colormapAboutToFinalize(self, weakrefColormap):
+ """Callback when the data weakref is about to be finalized."""
+ if self._colormap is weakrefColormap:
+ self.setColormap(None)
+
+ def _dataAboutToFinalize(self, weakrefData):
+ """Callback when the data weakref is about to be finalized."""
+ if self._data is weakrefData:
+ self.setData(None)
+
def getHistogram(self):
"""Returns the counts and bin edges of the displayed histogram.
@@ -312,136 +684,243 @@ class ColormapDialog(qt.QDialog):
"""Set the histogram to display.
This update the data range with the bounds of the bins.
- See :meth:`setDataRange`.
:param hist: array-like of counts or None to hide histogram
:param bin_edges: array-like of bins edges or None to hide histogram
"""
if hist is None or bin_edges is None:
self._histogramData = None
- self._plot.remove(legend='Histogram', kind='curve')
- self.setDataRange() # Remove data range
-
+ self._plot.remove(legend='Histogram', kind='histogram')
else:
hist = numpy.array(hist, copy=True)
bin_edges = numpy.array(bin_edges, copy=True)
self._histogramData = hist, bin_edges
-
- # For now, draw the histogram as a curve
- # using bin centers and normalised counts
- bins_center = 0.5 * (bin_edges[:-1] + bin_edges[1:])
norm_hist = hist / max(hist)
- self._plot.addCurve(bins_center, norm_hist,
- legend="Histogram",
- color='gray',
- symbol='',
- linestyle='-',
- fill=True)
+ self._plot.addHistogram(norm_hist,
+ bin_edges,
+ legend="Histogram",
+ color='gray',
+ align='center',
+ fill=True)
+ self._updateMinMaxData()
- # Update the data range
- self.setDataRange(bin_edges[0], bin_edges[-1])
+ def getColormap(self):
+ """Return the colormap description as a :class:`.Colormap`.
- def getDataRange(self):
- """Returns the data range used for the histogram area.
+ """
+ if self._colormap is None:
+ return None
+ return self._colormap()
- :return: (dataMin, dataMax) or None if no data range is set
- :rtype: 2-tuple of float
+ def resetColormap(self):
"""
- return self._dataRange
+ Reset the colormap state before modification.
- def setDataRange(self, min_=None, max_=None):
+ ..note :: the colormap reference state is the state when set or the
+ state when validated
+ """
+ colormap = self.getColormap()
+ if colormap is not None and self._colormapStoredState is not None:
+ if self._colormap()._toDict() != self._colormapStoredState:
+ self._ignoreColormapChange = True
+ colormap._setFromDict(self._colormapStoredState)
+ self._ignoreColormapChange = False
+ self._applyColormap()
+
+ def setDataRange(self, minimum=None, positiveMin=None, maximum=None):
"""Set the range of data to use for the range of the histogram area.
- :param float min_: The min of the data or None to disable range.
- :param float max_: The max of the data or None to disable range.
+ :param float minimum: The minimum of the data
+ :param float positiveMin: The positive minimum of the data
+ :param float maximum: The maximum of the data
"""
- if min_ is None or max_ is None:
+ if minimum is None or positiveMin is None or maximum is None:
self._dataRange = None
- self._plotUpdate()
-
+ self._plot.remove(legend='Range', kind='histogram')
else:
- min_, max_ = float(min_), float(max_)
- assert min_ <= max_
- self._dataRange = min_, max_
- if self._rangeAutoscaleButton.isChecked():
- self._minValue.setValue(min_)
- self._maxValue.setValue(max_)
- self._notify()
- else:
- self._plotUpdate()
+ hist = numpy.array([1])
+ bin_edges = numpy.array([minimum, maximum])
+ self._plot.addHistogram(hist,
+ bin_edges,
+ legend="Range",
+ color='gray',
+ align='center',
+ fill=True)
+ self._dataRange = minimum, positiveMin, maximum
+ self._updateMinMaxData()
+
+ def _updateMinMaxData(self):
+ """Update the min and max of the data according to the data range and
+ the histogram preset."""
+ colormap = self.getColormap()
+
+ minimum = float("+inf")
+ maximum = float("-inf")
+
+ if colormap is not None and colormap.getNormalization() == colormap.LOGARITHM:
+ # find a range in the positive part of the data
+ if self._dataRange is not None:
+ minimum = min(minimum, self._dataRange[1])
+ maximum = max(maximum, self._dataRange[2])
+ if self._histogramData is not None:
+ positives = list(filter(lambda x: x > 0, self._histogramData[1]))
+ if len(positives) > 0:
+ minimum = min(minimum, positives[0])
+ maximum = max(maximum, positives[-1])
+ else:
+ if self._dataRange is not None:
+ minimum = min(minimum, self._dataRange[0])
+ maximum = max(maximum, self._dataRange[2])
+ if self._histogramData is not None:
+ minimum = min(minimum, self._histogramData[1][0])
+ maximum = max(maximum, self._histogramData[1][-1])
+
+ if not numpy.isfinite(minimum):
+ minimum = None
+ if not numpy.isfinite(maximum):
+ maximum = None
+
+ self._minValue.setDataValue(minimum)
+ self._maxValue.setDataValue(maximum)
+ self._plotUpdate()
- def getColormap(self):
- """Return the colormap description as a :class:`.Colormap`.
+ def accept(self):
+ self.storeCurrentState()
+ qt.QDialog.accept(self)
+ def storeCurrentState(self):
+ """
+ save the current value sof the colormap if the user want to undo is
+ modifications
"""
- isNormLinear = self._normButtonLinear.isChecked()
- if self._rangeAutoscaleButton.isChecked():
- vmin = None
- vmax = None
+ colormap = self.getColormap()
+ if colormap is not None:
+ self._colormapStoredState = colormap._toDict()
else:
- vmin = self._minValue.value()
- vmax = self._maxValue.value()
- norm = Colormap.LINEAR if isNormLinear else Colormap.LOGARITHM
- colormap = Colormap(
- name=str(self._comboBoxColormap.currentText()).lower(),
- normalization=norm,
- vmin=vmin,
- vmax=vmax)
- return colormap
-
- def setColormap(self, name=None, normalization=None,
- autoscale=None, vmin=None, vmax=None, colors=None):
- """Set the colormap description
+ self._colormapStoredState = None
- If some arguments are not provided, the current values are used.
+ def reject(self):
+ self.resetColormap()
+ qt.QDialog.reject(self)
- :param str name: The name of the colormap
- :param str normalization: 'linear' or 'log'
- :param bool autoscale: Toggle colormap range autoscale
- :param float vmin: The min value, ignored if autoscale is True
- :param float vmax: The max value, ignored if autoscale is True
+ def setColormap(self, colormap):
+ """Set the colormap description
+
+ :param :class:`Colormap` colormap: the colormap to edit
"""
- if name is not None:
- assert name in self._colormapList
- index = self._colormapList.index(name)
- self._comboBoxColormap.setCurrentIndex(index)
-
- if normalization is not None:
- assert normalization in Colormap.NORMALIZATIONS
- self._normButtonLinear.setChecked(normalization == Colormap.LINEAR)
- self._normButtonLog.setChecked(normalization == Colormap.LOGARITHM)
-
- if vmin is not None:
- self._minValue.setValue(vmin)
-
- if vmax is not None:
- self._maxValue.setValue(vmax)
-
- if autoscale is not None:
- self._rangeAutoscaleButton.setChecked(autoscale)
- if autoscale:
- dataRange = self.getDataRange()
- if dataRange is not None:
- self._minValue.setValue(dataRange[0])
- self._maxValue.setValue(dataRange[1])
-
- # Do it once for all the changes
- self._notify()
-
- def _notify(self, *args, **kwargs):
- """Emit the signal for colormap change"""
+ assert colormap is None or isinstance(colormap, Colormap)
+ if self._ignoreColormapChange is True:
+ return
+
+ oldColormap = self.getColormap()
+ if oldColormap is colormap:
+ return
+ if oldColormap is not None:
+ oldColormap.sigChanged.disconnect(self._applyColormap)
+
+ if colormap is not None:
+ colormap.sigChanged.connect(self._applyColormap)
+ colormap = weakref.ref(colormap, self._colormapAboutToFinalize)
+
+ self._colormap = colormap
+ self.storeCurrentState()
+ self._updateResetButton()
+ self._applyColormap()
+
+ def _updateResetButton(self):
+ resetButton = self._buttonsNonModal.button(qt.QDialogButtonBox.Reset)
+ rStateEnabled = False
+ colormap = self.getColormap()
+ if colormap is not None and colormap.isEditable():
+ # can reset only in the case the colormap changed
+ rStateEnabled = colormap._toDict() != self._colormapStoredState
+ resetButton.setEnabled(rStateEnabled)
+
+ def _applyColormap(self):
+ self._updateResetButton()
+ if self._ignoreColormapChange is True:
+ return
+
+ colormap = self.getColormap()
+ if colormap is None:
+ self._comboBoxColormap.setEnabled(False)
+ self._normButtonLinear.setEnabled(False)
+ self._normButtonLog.setEnabled(False)
+ self._minValue.setEnabled(False)
+ self._maxValue.setEnabled(False)
+ else:
+ self._ignoreColormapChange = True
+
+ if colormap.getName() is not None:
+ name = colormap.getName()
+ self._comboBoxColormap.setCurrentName(name)
+ self._comboBoxColormap.setEnabled(self._colormap().isEditable())
+
+ assert colormap.getNormalization() in Colormap.NORMALIZATIONS
+ self._normButtonLinear.setChecked(
+ colormap.getNormalization() == Colormap.LINEAR)
+ self._normButtonLog.setChecked(
+ colormap.getNormalization() == Colormap.LOGARITHM)
+ vmin = colormap.getVMin()
+ vmax = colormap.getVMax()
+ dataRange = colormap.getColormapRange()
+ self._normButtonLinear.setEnabled(self._colormap().isEditable())
+ self._normButtonLog.setEnabled(self._colormap().isEditable())
+ self._minValue.setValue(vmin or dataRange[0], isAuto=vmin is None)
+ self._maxValue.setValue(vmax or dataRange[1], isAuto=vmax is None)
+ self._minValue.setEnabled(self._colormap().isEditable())
+ self._maxValue.setEnabled(self._colormap().isEditable())
+ self._ignoreColormapChange = False
+
self._plotUpdate()
- self.sigColormapChanged.emit(self.getColormap())
-
- def _autoscaleToggled(self, checked):
- """Handle autoscale changes by enabling/disabling min/max fields"""
- self._minValue.setEnabled(not checked)
- self._maxValue.setEnabled(not checked)
- if checked:
- dataRange = self.getDataRange()
- if dataRange is not None:
- self._minValue.setValue(dataRange[0])
- self._maxValue.setValue(dataRange[1])
+
+ def _updateMinMax(self):
+ if self._ignoreColormapChange is True:
+ return
+
+ vmin = self._minValue.getFiniteValue()
+ vmax = self._maxValue.getFiniteValue()
+ if vmax is not None and vmin is not None and vmax < vmin:
+ # If only one autoscale is checked constraints are too strong
+ # We have to edit a user value anyway it is not requested
+ # TODO: It would be better IMO to disable the auto checkbox before
+ # this case occur (valls)
+ cmin = self._minValue.isAutoChecked()
+ cmax = self._maxValue.isAutoChecked()
+ if cmin is False:
+ self._minValue.setFiniteValue(vmax)
+ if cmax is False:
+ self._maxValue.setFiniteValue(vmin)
+
+ vmin = self._minValue.getValue()
+ vmax = self._maxValue.getValue()
+ self._ignoreColormapChange = True
+ colormap = self._colormap()
+ if colormap is not None:
+ colormap.setVRange(vmin, vmax)
+ self._ignoreColormapChange = False
+ self._plotUpdate()
+ self._updateResetButton()
+
+ def _updateName(self):
+ if self._ignoreColormapChange is True:
+ return
+
+ if self._colormap():
+ self._ignoreColormapChange = True
+ self._colormap().setName(
+ self._comboBoxColormap.getCurrentName())
+ self._ignoreColormapChange = False
+
+ def _updateLinearNorm(self, isNormLinear):
+ if self._ignoreColormapChange is True:
+ return
+
+ if self._colormap():
+ self._ignoreColormapChange = True
+ norm = Colormap.LINEAR if isNormLinear else Colormap.LOGARITHM
+ self._colormap().setNormalization(norm)
+ self._ignoreColormapChange = False
def _minMaxTextEdited(self, text):
"""Handle _minValue and _maxValue textEdited signal"""
@@ -457,9 +936,10 @@ class ColormapDialog(qt.QDialog):
self._minMaxWasEdited = False
# Fix start value
- if self._minValue.value() > self._maxValue.value():
- self._minValue.setValue(self._maxValue.value())
- self._notify()
+ if (self._maxValue.getValue() is not None and
+ self._minValue.getValue() > self._maxValue.getValue()):
+ self._minValue.setValue(self._maxValue.getValue())
+ self._updateMinMax()
def _maxEditingFinished(self):
"""Handle _maxValue editingFinished signal
@@ -471,9 +951,10 @@ class ColormapDialog(qt.QDialog):
self._minMaxWasEdited = False
# Fix end value
- if self._minValue.value() > self._maxValue.value():
- self._maxValue.setValue(self._minValue.value())
- self._notify()
+ if (self._minValue.getValue() is not None and
+ self._minValue.getValue() > self._maxValue.getValue()):
+ self._maxValue.setValue(self._minValue.getValue())
+ self._updateMinMax()
def keyPressEvent(self, event):
"""Override key handling.
@@ -488,3 +969,13 @@ class ColormapDialog(qt.QDialog):
else:
# Use QDialog keyPressEvent
super(ColormapDialog, self).keyPressEvent(event)
+
+ def _activeLogNorm(self, isLog):
+ if self._ignoreColormapChange is True:
+ return
+ if self._colormap():
+ self._ignoreColormapChange = True
+ norm = Colormap.LOGARITHM if isLog is True else Colormap.LINEAR
+ self._colormap().setNormalization(norm)
+ self._ignoreColormapChange = False
+ self._updateMinMaxData()
diff --git a/silx/gui/plot/ComplexImageView.py b/silx/gui/plot/ComplexImageView.py
index 1463293..ebff175 100644
--- a/silx/gui/plot/ComplexImageView.py
+++ b/silx/gui/plot/ComplexImageView.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -32,144 +32,22 @@ from __future__ import absolute_import
__authors__ = ["Vincent Favre-Nicolin", "T. Vincent"]
__license__ = "MIT"
-__date__ = "02/10/2017"
+__date__ = "19/01/2018"
import logging
+import collections
import numpy
from .. import qt, icons
from .PlotWindow import Plot2D
-from .Colormap import Colormap
from . import items
+from .items import ImageComplexData
from silx.gui.widgets.FloatEdit import FloatEdit
_logger = logging.getLogger(__name__)
-_PHASE_COLORMAP = Colormap(
- name='hsv',
- vmin=-numpy.pi,
- vmax=numpy.pi)
-"""Colormap to use for phase"""
-
-# Complex colormap functions
-
-def _phase2rgb(data):
- """Creates RGBA image with colour-coded phase.
-
- :param numpy.ndarray data: The data to convert
- :return: Array of RGBA colors
- :rtype: numpy.ndarray
- """
- if data.size == 0:
- return numpy.zeros((0, 0, 4), dtype=numpy.uint8)
-
- phase = numpy.angle(data)
- return _PHASE_COLORMAP.applyToData(phase)
-
-
-def _complex2rgbalog(data, amin=0., dlogs=2, smax=None):
- """Returns RGBA colors: colour-coded phases and log10(amplitude) in alpha.
-
- :param numpy.ndarray data: the complex data array to convert to RGBA
- :param float amin: the minimum value for the alpha channel
- :param float dlogs: amplitude range displayed, in log10 units
- :param float smax:
- if specified, all values above max will be displayed with an alpha=1
- """
- if data.size == 0:
- return numpy.zeros((0, 0, 4), dtype=numpy.uint8)
-
- rgba = _phase2rgb(data)
- sabs = numpy.absolute(data)
- if smax is not None:
- sabs[sabs > smax] = smax
- a = numpy.log10(sabs + 1e-20)
- a -= a.max() - dlogs # display dlogs orders of magnitude
- rgba[..., 3] = 255 * (amin + a / dlogs * (1 - amin) * (a > 0))
- return rgba
-
-
-def _complex2rgbalin(data, gamma=1.0, smax=None):
- """Returns RGBA colors: colour-coded phase and linear amplitude in alpha.
-
- :param numpy.ndarray data:
- :param float gamma: Optional exponent gamma applied to the amplitude
- :param float smax:
- """
- if data.size == 0:
- return numpy.zeros((0, 0, 4), dtype=numpy.uint8)
-
- rgba = _phase2rgb(data)
- a = numpy.absolute(data)
- if smax is not None:
- a[a > smax] = smax
- a /= a.max()
- rgba[..., 3] = 255 * a**gamma
- return rgba
-
-
-# Dedicated plot item
-
-class _ImageComplexData(items.ImageData):
- """Specific plot item to force colormap when using complex colormap.
-
- This is returning the specific colormap when displaying
- colored phase + amplitude.
- """
-
- def __init__(self):
- super(_ImageComplexData, self).__init__()
- self._readOnlyColormap = False
- self._mode = 'absolute'
- self._colormaps = { # Default colormaps for all modes
- 'absolute': Colormap(),
- 'phase': _PHASE_COLORMAP.copy(),
- 'real': Colormap(),
- 'imaginary': Colormap(),
- 'amplitude_phase': _PHASE_COLORMAP.copy(),
- 'log10_amplitude_phase': _PHASE_COLORMAP.copy(),
- }
-
- _READ_ONLY_MODES = 'amplitude_phase', 'log10_amplitude_phase'
- """Modes that requires a read-only colormap."""
-
- def setVisualizationMode(self, mode):
- """Set the visualization mode to use.
-
- :param str mode:
- """
- mode = str(mode)
- assert mode in self._colormaps
-
- if mode != self._mode:
- # Save current colormap
- self._colormaps[self._mode] = self.getColormap()
- self._mode = mode
-
- # Set colormap for new mode
- self.setColormap(self._colormaps[mode])
-
- def getVisualizationMode(self):
- """Returns the visualization mode in use."""
- return self._mode
-
- def _isReadOnlyColormap(self):
- """Returns True if colormap should not be modified."""
- return self.getVisualizationMode() in self._READ_ONLY_MODES
-
- def setColormap(self, colormap):
- if not self._isReadOnlyColormap():
- super(_ImageComplexData, self).setColormap(colormap)
-
- def getColormap(self):
- if self._isReadOnlyColormap():
- return _PHASE_COLORMAP.copy()
- else:
- return super(_ImageComplexData, self).getColormap()
-
-
# Widgets
class _AmplitudeRangeDialog(qt.QDialog):
@@ -291,13 +169,19 @@ class _ComplexDataToolButton(qt.QToolButton):
:param plot: The :class:`ComplexImageView` to control
"""
- _MODES = [
- ('absolute', 'math-amplitude', 'Amplitude'),
- ('phase', 'math-phase', 'Phase'),
- ('real', 'math-real', 'Real part'),
- ('imaginary', 'math-imaginary', 'Imaginary part'),
- ('amplitude_phase', 'math-phase-color', 'Amplitude and Phase'),
- ('log10_amplitude_phase', 'math-phase-color-log', 'Log10(Amp.) and Phase')]
+ _MODES = collections.OrderedDict([
+ (ImageComplexData.Mode.ABSOLUTE, ('math-amplitude', 'Amplitude')),
+ (ImageComplexData.Mode.SQUARE_AMPLITUDE,
+ ('math-square-amplitude', 'Square amplitude')),
+ (ImageComplexData.Mode.PHASE, ('math-phase', 'Phase')),
+ (ImageComplexData.Mode.REAL, ('math-real', 'Real part')),
+ (ImageComplexData.Mode.IMAGINARY,
+ ('math-imaginary', 'Imaginary part')),
+ (ImageComplexData.Mode.AMPLITUDE_PHASE,
+ ('math-phase-color', 'Amplitude and Phase')),
+ (ImageComplexData.Mode.LOG10_AMPLITUDE_PHASE,
+ ('math-phase-color-log', 'Log10(Amp.) and Phase'))
+ ])
_RANGE_DIALOG_TEXT = 'Set Amplitude Range...'
@@ -311,8 +195,10 @@ class _ComplexDataToolButton(qt.QToolButton):
menu.triggered.connect(self._triggered)
self.setMenu(menu)
- for _, icon, text in self._MODES:
+ for mode, info in self._MODES.items():
+ icon, text = info
action = qt.QAction(icons.getQIcon(icon), text, self)
+ action.setData(mode)
action.setIconVisibleInMenu(True)
menu.addAction(action)
@@ -328,13 +214,10 @@ class _ComplexDataToolButton(qt.QToolButton):
def _modeChanged(self, mode):
"""Handle change of visualization modes"""
- for actionMode, icon, text in self._MODES:
- if actionMode == mode:
- self.setIcon(icons.getQIcon(icon))
- self.setToolTip('Display the ' + text.lower())
- break
-
- self._rangeDialogAction.setEnabled(mode == 'log10_amplitude_phase')
+ icon, text = self._MODES[mode]
+ self.setIcon(icons.getQIcon(icon))
+ self.setToolTip('Display the ' + text.lower())
+ self._rangeDialogAction.setEnabled(mode == ImageComplexData.Mode.LOG10_AMPLITUDE_PHASE)
def _triggered(self, action):
"""Handle triggering of menu actions"""
@@ -360,9 +243,9 @@ class _ComplexDataToolButton(qt.QToolButton):
dialog.sigRangeChanged.disconnect(self._rangeChanged)
else: # update mode
- for mode, _, text in self._MODES:
- if actionText == text:
- self._plot2DComplex.setVisualizationMode(mode)
+ mode = action.data()
+ if isinstance(mode, ImageComplexData.Mode):
+ self._plot2DComplex.setVisualizationMode(mode)
def _rangeChanged(self, range_):
"""Handle updates of range in the dialog"""
@@ -375,10 +258,13 @@ class ComplexImageView(qt.QWidget):
:param parent: See :class:`QMainWindow`
"""
+ Mode = ImageComplexData.Mode
+ """Also expose the modes inside the class"""
+
sigDataChanged = qt.Signal()
"""Signal emitted when data has changed."""
- sigVisualizationModeChanged = qt.Signal(str)
+ sigVisualizationModeChanged = qt.Signal(object)
"""Signal emitted when the visualization mode has changed.
It provides the new visualization mode.
@@ -389,11 +275,6 @@ class ComplexImageView(qt.QWidget):
if parent is None:
self.setWindowTitle('ComplexImageView')
- self._mode = 'absolute'
- self._amplitudeRangeInfo = None, 2
- self._data = numpy.zeros((0, 0), dtype=numpy.complex)
- self._displayedData = numpy.zeros((0, 0), dtype=numpy.float)
-
self._plot2D = Plot2D(self)
layout = qt.QHBoxLayout(self)
@@ -403,10 +284,9 @@ class ComplexImageView(qt.QWidget):
self.setLayout(layout)
# Create and add image to the plot
- self._plotImage = _ImageComplexData()
+ self._plotImage = ImageComplexData()
self._plotImage._setLegend('__ComplexImageView__complex_image__')
- self._plotImage.setData(self._displayedData)
- self._plotImage.setVisualizationMode(self._mode)
+ self._plotImage.sigItemChanged.connect(self._itemChanged)
self._plot2D._add(self._plotImage)
self._plot2D.setActiveImage(self._plotImage.getLegend())
@@ -416,57 +296,18 @@ class ComplexImageView(qt.QWidget):
self._plot2D.insertToolBar(self._plot2D.getProfileToolbar(), toolBar)
+ def _itemChanged(self, event):
+ """Handle item changed signal"""
+ if event is items.ItemChangedType.DATA:
+ self.sigDataChanged.emit()
+ elif event is items.ItemChangedType.VISUALIZATION_MODE:
+ mode = self.getVisualizationMode()
+ self.sigVisualizationModeChanged.emit(mode)
+
def getPlot(self):
"""Return the PlotWidget displaying the data"""
return self._plot2D
- def _convertData(self, data, mode):
- """Convert complex data according to provided mode.
-
- :param numpy.ndarray data: The complex data to convert
- :param str mode: The visualization mode
- :return: The data corresponding to the mode
- :rtype: 2D numpy.ndarray of float or RGBA image
- """
- if mode == 'absolute':
- return numpy.absolute(data)
- elif mode == 'phase':
- return numpy.angle(data)
- elif mode == 'real':
- return numpy.real(data)
- elif mode == 'imaginary':
- return numpy.imag(data)
- elif mode == 'amplitude_phase':
- return _complex2rgbalin(data)
- elif mode == 'log10_amplitude_phase':
- max_, delta = self._getAmplitudeRangeInfo()
- return _complex2rgbalog(data, dlogs=delta, smax=max_)
- else:
- _logger.error(
- 'Unsupported conversion mode: %s, fallback to absolute',
- str(mode))
- return numpy.absolute(data)
-
- def _updatePlot(self):
- """Update the image in the plot"""
-
- mode = self.getVisualizationMode()
-
- self.getPlot().getColormapAction().setDisabled(
- mode in ('amplitude_phase', 'log10_amplitude_phase'))
-
- self._plotImage.setVisualizationMode(mode)
-
- image = self.getDisplayedData(copy=False)
- if mode in ('amplitude_phase', 'log10_amplitude_phase'):
- # Combined view
- absolute = numpy.absolute(self.getData(copy=False))
- self._plotImage.setData(
- absolute, alternative=image, copy=False)
- else:
- self._plotImage.setData(
- image, alternative=None, copy=False)
-
def setData(self, data=None, copy=True):
"""Set the complex data to display.
@@ -476,22 +317,13 @@ class ComplexImageView(qt.QWidget):
"""
if data is None:
data = numpy.zeros((0, 0), dtype=numpy.complex)
- else:
- data = numpy.array(data, copy=copy)
-
- assert data.ndim == 2
- if data.dtype.kind != 'c': # Convert to complex
- data = numpy.array(data, dtype=numpy.complex)
- shape_changed = (self._data.shape != data.shape)
- self._data = data
- self._displayedData = self._convertData(
- data, self.getVisualizationMode())
-
- self._updatePlot()
- if shape_changed:
- self.getPlot().resetZoom()
- self.sigDataChanged.emit()
+ previousData = self._plotImage.getComplexData(copy=False)
+
+ self._plotImage.setData(data, copy=copy)
+
+ if previousData.shape != data.shape:
+ self.getPlot().resetZoom()
def getData(self, copy=True):
"""Get the currently displayed complex data.
@@ -501,7 +333,7 @@ class ComplexImageView(qt.QWidget):
:return: The complex data array.
:rtype: numpy.ndarray of complex with 2 dimensions
"""
- return numpy.array(self._data, copy=copy)
+ return self._plotImage.getComplexData(copy=copy)
def getDisplayedData(self, copy=True):
"""Returns the displayed data depending on the visualization mode
@@ -512,7 +344,12 @@ class ComplexImageView(qt.QWidget):
False to return internal data (do not modify!)
:rtype: numpy.ndarray of float with 2 dims or RGBA image (uint8).
"""
- return numpy.array(self._displayedData, copy=copy)
+ mode = self.getVisualizationMode()
+ if mode in (self.Mode.AMPLITUDE_PHASE,
+ self.Mode.LOG10_AMPLITUDE_PHASE):
+ return self._plotImage.getRgbaImageData(copy=copy)
+ else:
+ return self._plotImage.getData(copy=copy)
@staticmethod
def getSupportedVisualizationModes():
@@ -530,12 +367,7 @@ class ComplexImageView(qt.QWidget):
:rtype: tuple of str
"""
- return ('absolute',
- 'phase',
- 'real',
- 'imaginary',
- 'amplitude_phase',
- 'log10_amplitude_phase')
+ return tuple(ImageComplexData.Mode)
def setVisualizationMode(self, mode):
"""Set the mode of visualization of the complex data.
@@ -545,20 +377,14 @@ class ComplexImageView(qt.QWidget):
:param str mode: The mode to use.
"""
- assert mode in self.getSupportedVisualizationModes()
- if mode != self._mode:
- self._mode = mode
- self._displayedData = self._convertData(
- self.getData(copy=False), mode)
- self._updatePlot()
- self.sigVisualizationModeChanged.emit(mode)
+ self._plotImage.setVisualizationMode(mode)
def getVisualizationMode(self):
"""Get the current visualization mode of the complex data.
- :rtype: str
+ :rtype: Mode
"""
- return self._mode
+ return self._plotImage.getVisualizationMode()
def _setAmplitudeRangeInfo(self, max_=None, delta=2):
"""Set the amplitude range to display for 'log10_amplitude_phase' mode.
@@ -567,39 +393,35 @@ class ComplexImageView(qt.QWidget):
If None it autoscales to data max.
:param float delta: Delta range in log10 to display
"""
- self._amplitudeRangeInfo = max_, float(delta)
- mode = self.getVisualizationMode()
- if mode == 'log10_amplitude_phase':
- self._displayedData = self._convertData(
- self.getData(copy=False), mode)
- self._updatePlot()
+ self._plotImage._setAmplitudeRangeInfo(max_, delta)
def _getAmplitudeRangeInfo(self):
"""Returns the amplitude range to use for 'log10_amplitude_phase' mode.
:return: (max, delta), if max is None, then it autoscales to data max
:rtype: 2-tuple"""
- return self._amplitudeRangeInfo
+ return self._plotImage._getAmplitudeRangeInfo()
# Image item proxy
- def setColormap(self, colormap):
+ def setColormap(self, colormap, mode=None):
"""Set the colormap to use for amplitude, phase, real or imaginary.
WARNING: This colormap is not used when displaying both
amplitude and phase.
- :param Colormap colormap: The colormap
+ :param ~silx.gui.plot.Colormap.Colormap colormap: The colormap
+ :param Mode mode: If specified, set the colormap of this specific mode
"""
- self._plotImage.setColormap(colormap)
+ self._plotImage.setColormap(colormap, mode)
- def getColormap(self):
+ def getColormap(self, mode=None):
"""Returns the colormap used to display the data.
- :rtype: Colormap
+ :param Mode mode: If specified, set the colormap of this specific mode
+ :rtype: ~silx.gui.plot.Colormap.Colormap
"""
- # Returns internal colormap and bypass forcing colormap
- return items.ImageData.getColormap(self._plotImage)
+ return self._plotImage.getColormap(mode=mode)
def getOrigin(self):
"""Returns the offset from origin at which to display the image.
diff --git a/silx/gui/plot/CurvesROIWidget.py b/silx/gui/plot/CurvesROIWidget.py
index 4b10cd6..ccb6866 100644
--- a/silx/gui/plot/CurvesROIWidget.py
+++ b/silx/gui/plot/CurvesROIWidget.py
@@ -46,7 +46,7 @@ ROI are defined by :
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "27/06/2017"
+__date__ = "13/11/2017"
from collections import OrderedDict
@@ -57,6 +57,8 @@ import sys
import numpy
from silx.io import dictdump
+from silx.utils import deprecation
+
from .. import icons, qt
@@ -84,10 +86,14 @@ class CurvesROIWidget(qt.QWidget):
'rowheader'
"""
- def __init__(self, parent=None, name=None):
+ sigROISignal = qt.Signal(object)
+
+ def __init__(self, parent=None, name=None, plot=None):
super(CurvesROIWidget, self).__init__(parent)
if name is not None:
self.setWindowTitle(name)
+ assert plot is not None
+ self.plot = plot
layout = qt.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
@@ -151,6 +157,19 @@ class CurvesROIWidget(qt.QWidget):
self.saveButton.clicked.connect(self._save)
self.roiTable.sigROITableSignal.connect(self._forward)
+ self.currentROI = None
+ self._middleROIMarkerFlag = False
+ self._isConnected = False # True if connected to plot signals
+ self._isInit = False
+
+ def showEvent(self, event):
+ self._visibilityChangedHandler(visible=True)
+ qt.QWidget.showEvent(self, event)
+
+ def hideEvent(self, event):
+ self._visibilityChangedHandler(visible=False)
+ qt.QWidget.hideEvent(self, event)
+
@property
def roiFileDir(self):
"""The directory from which to load/save ROI from/to files."""
@@ -214,6 +233,19 @@ class CurvesROIWidget(qt.QWidget):
return OrderedDict([(name, roidict[name]) for name in ordered_roilist])
+ def setMiddleROIMarkerFlag(self, flag=True):
+ """Activate or deactivate middle marker.
+
+ This allows shifting both min and max limits at once, by dragging
+ a marker located in the middle.
+
+ :param bool flag: True to activate middle ROI marker
+ """
+ if flag:
+ self._middleROIMarkerFlag = True
+ else:
+ self._middleROIMarkerFlag = False
+
def _add(self):
"""Add button clicked handler"""
ddict = {}
@@ -365,6 +397,322 @@ class CurvesROIWidget(qt.QWidget):
"""Set the header text of this widget"""
self.headerLabel.setText("<b>%s<\b>" % text)
+ def _roiSignal(self, ddict):
+ """Handle ROI widget signal"""
+ _logger.debug("CurvesROIWidget._roiSignal %s", str(ddict))
+ if ddict['event'] == "AddROI":
+ xmin, xmax = self.plot.getXAxis().getLimits()
+ fromdata = xmin + 0.25 * (xmax - xmin)
+ todata = xmin + 0.75 * (xmax - xmin)
+ self.plot.remove('ROI min', kind='marker')
+ self.plot.remove('ROI max', kind='marker')
+ if self._middleROIMarkerFlag:
+ self.plot.remove('ROI middle', kind='marker')
+ roiList, roiDict = self.roiTable.getROIListAndDict()
+ nrois = len(roiList)
+ if nrois == 0:
+ newroi = "ICR"
+ fromdata, dummy0, todata, dummy1 = self._getAllLimits()
+ draggable = False
+ color = 'black'
+ else:
+ for i in range(nrois):
+ i += 1
+ newroi = "newroi %d" % i
+ if newroi not in roiList:
+ break
+ color = 'blue'
+ draggable = True
+ self.plot.addXMarker(fromdata,
+ legend='ROI min',
+ text='ROI min',
+ color=color,
+ draggable=draggable)
+ self.plot.addXMarker(todata,
+ legend='ROI max',
+ text='ROI max',
+ color=color,
+ draggable=draggable)
+ if draggable and self._middleROIMarkerFlag:
+ pos = 0.5 * (fromdata + todata)
+ self.plot.addXMarker(pos,
+ legend='ROI middle',
+ text="",
+ color='yellow',
+ draggable=draggable)
+ roiList.append(newroi)
+ roiDict[newroi] = {}
+ if newroi == "ICR":
+ roiDict[newroi]['type'] = "Default"
+ else:
+ roiDict[newroi]['type'] = self.plot.getXAxis().getLabel()
+ roiDict[newroi]['from'] = fromdata
+ roiDict[newroi]['to'] = todata
+ self.roiTable.fillFromROIDict(roilist=roiList,
+ roidict=roiDict,
+ currentroi=newroi)
+ self.currentROI = newroi
+ self.calculateRois()
+ elif ddict['event'] in ['DelROI', "ResetROI"]:
+ self.plot.remove('ROI min', kind='marker')
+ self.plot.remove('ROI max', kind='marker')
+ if self._middleROIMarkerFlag:
+ self.plot.remove('ROI middle', kind='marker')
+ roiList, roiDict = self.roiTable.getROIListAndDict()
+ roiDictKeys = list(roiDict.keys())
+ if len(roiDictKeys):
+ currentroi = roiDictKeys[0]
+ else:
+ # create again the ICR
+ ddict = {"event": "AddROI"}
+ return self._roiSignal(ddict)
+
+ self.roiTable.fillFromROIDict(roilist=roiList,
+ roidict=roiDict,
+ currentroi=currentroi)
+ self.currentROI = currentroi
+
+ elif ddict['event'] == 'LoadROI':
+ self.calculateRois()
+
+ elif ddict['event'] == 'selectionChanged':
+ _logger.debug("Selection changed")
+ self.roilist, self.roidict = self.roiTable.getROIListAndDict()
+ fromdata = ddict['roi']['from']
+ todata = ddict['roi']['to']
+ self.plot.remove('ROI min', kind='marker')
+ self.plot.remove('ROI max', kind='marker')
+ if ddict['key'] == 'ICR':
+ draggable = False
+ color = 'black'
+ else:
+ draggable = True
+ color = 'blue'
+ self.plot.addXMarker(fromdata,
+ legend='ROI min',
+ text='ROI min',
+ color=color,
+ draggable=draggable)
+ self.plot.addXMarker(todata,
+ legend='ROI max',
+ text='ROI max',
+ color=color,
+ draggable=draggable)
+ if draggable and self._middleROIMarkerFlag:
+ pos = 0.5 * (fromdata + todata)
+ self.plot.addXMarker(pos,
+ legend='ROI middle',
+ text="",
+ color='yellow',
+ draggable=True)
+ self.currentROI = ddict['key']
+ if ddict['colheader'] in ['From', 'To']:
+ dict0 = {}
+ dict0['event'] = "SetActiveCurveEvent"
+ dict0['legend'] = self.plot.getActiveCurve(just_legend=1)
+ self.plot.setActiveCurve(dict0['legend'])
+ elif ddict['colheader'] == 'Raw Counts':
+ pass
+ elif ddict['colheader'] == 'Net Counts':
+ pass
+ else:
+ self._emitCurrentROISignal()
+
+ else:
+ _logger.debug("Unknown or ignored event %s", ddict['event'])
+
+ def _getAllLimits(self):
+ """Retrieve the limits based on the curves."""
+ curves = self.plot.getAllCurves()
+ if not curves:
+ return 1.0, 1.0, 100., 100.
+
+ xmin, ymin = None, None
+ xmax, ymax = None, None
+
+ for curve in curves:
+ x = curve.getXData(copy=False)
+ y = curve.getYData(copy=False)
+ if xmin is None:
+ xmin = x.min()
+ else:
+ xmin = min(xmin, x.min())
+ if xmax is None:
+ xmax = x.max()
+ else:
+ xmax = max(xmax, x.max())
+ if ymin is None:
+ ymin = y.min()
+ else:
+ ymin = min(ymin, y.min())
+ if ymax is None:
+ ymax = y.max()
+ else:
+ ymax = max(ymax, y.max())
+
+ return xmin, ymin, xmax, ymax
+
+ @deprecation.deprecated(replacement="calculateRois",
+ reason="CamelCase convention")
+ def calculateROIs(self, *args, **kw):
+ self.calculateRois(*args, **kw)
+
+ def calculateRois(self, roiList=None, roiDict=None):
+ """Compute ROI information"""
+ if roiList is None or roiDict is None:
+ roiList, roiDict = self.roiTable.getROIListAndDict()
+
+ activeCurve = self.plot.getActiveCurve(just_legend=False)
+ if activeCurve is None:
+ xproc = None
+ yproc = None
+ self.setHeader()
+ else:
+ x = activeCurve.getXData(copy=False)
+ y = activeCurve.getYData(copy=False)
+ legend = activeCurve.getLegend()
+ idx = numpy.argsort(x, kind='mergesort')
+ xproc = numpy.take(x, idx)
+ yproc = numpy.take(y, idx)
+ self.setHeader('ROIs of %s' % legend)
+
+ for key in roiList:
+ if key == 'ICR':
+ if xproc is not None:
+ roiDict[key]['from'] = xproc.min()
+ roiDict[key]['to'] = xproc.max()
+ else:
+ roiDict[key]['from'] = 0
+ roiDict[key]['to'] = -1
+ fromData = roiDict[key]['from']
+ toData = roiDict[key]['to']
+ if xproc is not None:
+ idx = numpy.nonzero((fromData <= xproc) &
+ (xproc <= toData))[0]
+ if len(idx):
+ xw = xproc[idx]
+ yw = yproc[idx]
+ rawCounts = yw.sum(dtype=numpy.float)
+ deltaX = xw[-1] - xw[0]
+ deltaY = yw[-1] - yw[0]
+ if deltaX > 0.0:
+ slope = (deltaY / deltaX)
+ background = yw[0] + slope * (xw - xw[0])
+ netCounts = (rawCounts -
+ background.sum(dtype=numpy.float))
+ else:
+ netCounts = 0.0
+ else:
+ rawCounts = 0.0
+ netCounts = 0.0
+ roiDict[key]['rawcounts'] = rawCounts
+ roiDict[key]['netcounts'] = netCounts
+ else:
+ roiDict[key].pop('rawcounts', None)
+ roiDict[key].pop('netcounts', None)
+
+ self.roiTable.fillFromROIDict(
+ roilist=roiList,
+ roidict=roiDict,
+ currentroi=self.currentROI if self.currentROI in roiList else None)
+
+ def _emitCurrentROISignal(self):
+ ddict = {}
+ ddict['event'] = "currentROISignal"
+ _roiList, roiDict = self.roiTable.getROIListAndDict()
+ if self.currentROI in roiDict:
+ ddict['ROI'] = roiDict[self.currentROI]
+ else:
+ self.currentROI = None
+ ddict['current'] = self.currentROI
+ self.sigROISignal.emit(ddict)
+
+ def _handleROIMarkerEvent(self, ddict):
+ """Handle plot signals related to marker events."""
+ if ddict['event'] == 'markerMoved':
+
+ label = ddict['label']
+ if label not in ['ROI min', 'ROI max', 'ROI middle']:
+ return
+
+ roiList, roiDict = self.roiTable.getROIListAndDict()
+ if self.currentROI is None:
+ return
+ if self.currentROI not in roiDict:
+ return
+ x = ddict['x']
+
+ if label == 'ROI min':
+ roiDict[self.currentROI]['from'] = x
+ if self._middleROIMarkerFlag:
+ pos = 0.5 * (roiDict[self.currentROI]['to'] +
+ roiDict[self.currentROI]['from'])
+ self.plot.addXMarker(pos,
+ legend='ROI middle',
+ text='',
+ color='yellow',
+ draggable=True)
+ elif label == 'ROI max':
+ roiDict[self.currentROI]['to'] = x
+ if self._middleROIMarkerFlag:
+ pos = 0.5 * (roiDict[self.currentROI]['to'] +
+ roiDict[self.currentROI]['from'])
+ self.plot.addXMarker(pos,
+ legend='ROI middle',
+ text='',
+ color='yellow',
+ draggable=True)
+ elif label == 'ROI middle':
+ delta = x - 0.5 * (roiDict[self.currentROI]['from'] +
+ roiDict[self.currentROI]['to'])
+ roiDict[self.currentROI]['from'] += delta
+ roiDict[self.currentROI]['to'] += delta
+ self.plot.addXMarker(roiDict[self.currentROI]['from'],
+ legend='ROI min',
+ text='ROI min',
+ color='blue',
+ draggable=True)
+ self.plot.addXMarker(roiDict[self.currentROI]['to'],
+ legend='ROI max',
+ text='ROI max',
+ color='blue',
+ draggable=True)
+ else:
+ return
+ self.calculateRois(roiList, roiDict)
+ self._emitCurrentROISignal()
+
+ def _visibilityChangedHandler(self, visible):
+ """Handle widget's visibility updates.
+
+ It is connected to plot signals only when visible.
+ """
+ if visible:
+ if not self._isInit:
+ # Deferred ROI widget init finalization
+ self._isInit = True
+ self.sigROIWidgetSignal.connect(self._roiSignal)
+ # initialize with the ICR
+ self._roiSignal({'event': "AddROI"})
+
+ if not self._isConnected:
+ self.plot.sigPlotSignal.connect(self._handleROIMarkerEvent)
+ self.plot.sigActiveCurveChanged.connect(
+ self._activeCurveChanged)
+ self._isConnected = True
+
+ self.calculateRois()
+ else:
+ if self._isConnected:
+ self.plot.sigPlotSignal.disconnect(self._handleROIMarkerEvent)
+ self.plot.sigActiveCurveChanged.disconnect(
+ self._activeCurveChanged)
+ self._isConnected = False
+
+ def _activeCurveChanged(self, *args):
+ """Recompute ROIs when active curve changed."""
+ self.calculateRois()
+
class ROITable(qt.QTableWidget):
"""Table widget displaying ROI information.
@@ -622,6 +970,9 @@ class CurvesROIDockWidget(qt.QDockWidget):
:param name: See :class:`QDockWidget`
"""
sigROISignal = qt.Signal(object)
+ """Deprecated signal for backward compatibility with silx < 0.7.
+ Prefer connecting directly to :attr:`CurvesRoiWidget.sigRoiSignal`
+ """
def __init__(self, parent=None, plot=None, name=None):
super(CurvesROIDockWidget, self).__init__(name, parent)
@@ -629,25 +980,24 @@ class CurvesROIDockWidget(qt.QDockWidget):
assert plot is not None
self.plot = plot
- self.currentROI = None
- self._middleROIMarkerFlag = False
-
- self._isConnected = False # True if connected to plot signals
- self._isInit = False
-
- self.roiWidget = CurvesROIWidget(self, name)
+ self.roiWidget = CurvesROIWidget(self, name, plot=plot)
"""Main widget of type :class:`CurvesROIWidget`"""
# convenience methods to offer a simpler API allowing to ignore
# the details of the underlying implementation
- self.calculateROIs = self.calculateRois
+ # (ALL DEPRECATED)
+ self.calculateROIs = self.calculateRois = self.roiWidget.calculateRois
self.setRois = self.roiWidget.setRois
self.getRois = self.roiWidget.getRois
+ self.roiWidget.sigROISignal.connect(self._forwardSigROISignal)
+ self.currentROI = self.roiWidget.currentROI
self.layout().setContentsMargins(0, 0, 0, 0)
self.setWidget(self.roiWidget)
- self.visibilityChanged.connect(self._visibilityChangedHandler)
+ def _forwardSigROISignal(self, ddict):
+ # emit deprecated signal for backward compatibility (silx < 0.7)
+ self.sigROISignal.emit(ddict)
def toggleViewAction(self):
"""Returns a checkable action that shows or closes this widget.
@@ -658,320 +1008,10 @@ class CurvesROIDockWidget(qt.QDockWidget):
action.setIcon(icons.getQIcon('plot-roi'))
return action
- def _visibilityChangedHandler(self, visible):
- """Handle widget's visibilty updates.
-
- It is connected to plot signals only when visible.
- """
- if visible:
- if not self._isInit:
- # Deferred ROI widget init finalization
- self._isInit = True
- self.roiWidget.sigROIWidgetSignal.connect(self._roiSignal)
- # initialize with the ICR
- self._roiSignal({'event': "AddROI"})
-
- if not self._isConnected:
- self.plot.sigPlotSignal.connect(self._handleROIMarkerEvent)
- self.plot.sigActiveCurveChanged.connect(
- self._activeCurveChanged)
- self._isConnected = True
-
- self.calculateROIs()
- else:
- if self._isConnected:
- self.plot.sigPlotSignal.disconnect(self._handleROIMarkerEvent)
- self.plot.sigActiveCurveChanged.disconnect(
- self._activeCurveChanged)
- self._isConnected = False
-
- def _handleROIMarkerEvent(self, ddict):
- """Handle plot signals related to marker events."""
- if ddict['event'] == 'markerMoved':
-
- label = ddict['label']
- if label not in ['ROI min', 'ROI max', 'ROI middle']:
- return
-
- roiList, roiDict = self.roiWidget.getROIListAndDict()
- if self.currentROI is None:
- return
- if self.currentROI not in roiDict:
- return
- x = ddict['x']
-
- if label == 'ROI min':
- roiDict[self.currentROI]['from'] = x
- if self._middleROIMarkerFlag:
- pos = 0.5 * (roiDict[self.currentROI]['to'] +
- roiDict[self.currentROI]['from'])
- self.plot.addXMarker(pos,
- legend='ROI middle',
- text='',
- color='yellow',
- draggable=True)
- elif label == 'ROI max':
- roiDict[self.currentROI]['to'] = x
- if self._middleROIMarkerFlag:
- pos = 0.5 * (roiDict[self.currentROI]['to'] +
- roiDict[self.currentROI]['from'])
- self.plot.addXMarker(pos,
- legend='ROI middle',
- text='',
- color='yellow',
- draggable=True)
- elif label == 'ROI middle':
- delta = x - 0.5 * (roiDict[self.currentROI]['from'] +
- roiDict[self.currentROI]['to'])
- roiDict[self.currentROI]['from'] += delta
- roiDict[self.currentROI]['to'] += delta
- self.plot.addXMarker(roiDict[self.currentROI]['from'],
- legend='ROI min',
- text='ROI min',
- color='blue',
- draggable=True)
- self.plot.addXMarker(roiDict[self.currentROI]['to'],
- legend='ROI max',
- text='ROI max',
- color='blue',
- draggable=True)
- else:
- return
- self.calculateROIs(roiList, roiDict)
- self._emitCurrentROISignal()
-
- def _roiSignal(self, ddict):
- """Handle ROI widget signal"""
- _logger.debug("PlotWindow._roiSignal %s", str(ddict))
- if ddict['event'] == "AddROI":
- xmin, xmax = self.plot.getXAxis().getLimits()
- fromdata = xmin + 0.25 * (xmax - xmin)
- todata = xmin + 0.75 * (xmax - xmin)
- self.plot.remove('ROI min', kind='marker')
- self.plot.remove('ROI max', kind='marker')
- if self._middleROIMarkerFlag:
- self.remove('ROI middle', kind='marker')
- roiList, roiDict = self.roiWidget.getROIListAndDict()
- nrois = len(roiList)
- if nrois == 0:
- newroi = "ICR"
- fromdata, dummy0, todata, dummy1 = self._getAllLimits()
- draggable = False
- color = 'black'
- else:
- for i in range(nrois):
- i += 1
- newroi = "newroi %d" % i
- if newroi not in roiList:
- break
- color = 'blue'
- draggable = True
- self.plot.addXMarker(fromdata,
- legend='ROI min',
- text='ROI min',
- color=color,
- draggable=draggable)
- self.plot.addXMarker(todata,
- legend='ROI max',
- text='ROI max',
- color=color,
- draggable=draggable)
- if draggable and self._middleROIMarkerFlag:
- pos = 0.5 * (fromdata + todata)
- self.plot.addXMarker(pos,
- legend='ROI middle',
- text="",
- color='yellow',
- draggable=draggable)
- roiList.append(newroi)
- roiDict[newroi] = {}
- if newroi == "ICR":
- roiDict[newroi]['type'] = "Default"
- else:
- roiDict[newroi]['type'] = self.plot.getXAxis().getLabel()
- roiDict[newroi]['from'] = fromdata
- roiDict[newroi]['to'] = todata
- self.roiWidget.fillFromROIDict(roilist=roiList,
- roidict=roiDict,
- currentroi=newroi)
- self.currentROI = newroi
- self.calculateROIs()
- elif ddict['event'] in ['DelROI', "ResetROI"]:
- self.plot.remove('ROI min', kind='marker')
- self.plot.remove('ROI max', kind='marker')
- if self._middleROIMarkerFlag:
- self.plot.remove('ROI middle', kind='marker')
- roiList, roiDict = self.roiWidget.getROIListAndDict()
- roiDictKeys = list(roiDict.keys())
- if len(roiDictKeys):
- currentroi = roiDictKeys[0]
- else:
- # create again the ICR
- ddict = {"event": "AddROI"}
- return self._roiSignal(ddict)
-
- self.roiWidget.fillFromROIDict(roilist=roiList,
- roidict=roiDict,
- currentroi=currentroi)
- self.currentROI = currentroi
-
- elif ddict['event'] == 'LoadROI':
- self.calculateROIs()
-
- elif ddict['event'] == 'selectionChanged':
- _logger.debug("Selection changed")
- self.roilist, self.roidict = self.roiWidget.getROIListAndDict()
- fromdata = ddict['roi']['from']
- todata = ddict['roi']['to']
- self.plot.remove('ROI min', kind='marker')
- self.plot.remove('ROI max', kind='marker')
- if ddict['key'] == 'ICR':
- draggable = False
- color = 'black'
- else:
- draggable = True
- color = 'blue'
- self.plot.addXMarker(fromdata,
- legend='ROI min',
- text='ROI min',
- color=color,
- draggable=draggable)
- self.plot.addXMarker(todata,
- legend='ROI max',
- text='ROI max',
- color=color,
- draggable=draggable)
- if draggable and self._middleROIMarkerFlag:
- pos = 0.5 * (fromdata + todata)
- self.plot.addXMarker(pos,
- legend='ROI middle',
- text="",
- color='yellow',
- draggable=True)
- self.currentROI = ddict['key']
- if ddict['colheader'] in ['From', 'To']:
- dict0 = {}
- dict0['event'] = "SetActiveCurveEvent"
- dict0['legend'] = self.plot.getActiveCurve(just_legend=1)
- self.plot.setActiveCurve(dict0['legend'])
- elif ddict['colheader'] == 'Raw Counts':
- pass
- elif ddict['colheader'] == 'Net Counts':
- pass
- else:
- self._emitCurrentROISignal()
-
- else:
- _logger.debug("Unknown or ignored event %s", ddict['event'])
-
- def _activeCurveChanged(self, *args):
- """Recompute ROIs when active curve changed."""
- self.calculateROIs()
-
- def calculateRois(self, roiList=None, roiDict=None):
- """Compute ROI information"""
- if roiList is None or roiDict is None:
- roiList, roiDict = self.roiWidget.getROIListAndDict()
-
- activeCurve = self.plot.getActiveCurve(just_legend=False)
- if activeCurve is None:
- xproc = None
- yproc = None
- self.roiWidget.setHeader()
- else:
- x = activeCurve.getXData(copy=False)
- y = activeCurve.getYData(copy=False)
- legend = activeCurve.getLegend()
- idx = numpy.argsort(x, kind='mergesort')
- xproc = numpy.take(x, idx)
- yproc = numpy.take(y, idx)
- self.roiWidget.setHeader('ROIs of %s' % legend)
-
- for key in roiList:
- if key == 'ICR':
- if xproc is not None:
- roiDict[key]['from'] = xproc.min()
- roiDict[key]['to'] = xproc.max()
- else:
- roiDict[key]['from'] = 0
- roiDict[key]['to'] = -1
- fromData = roiDict[key]['from']
- toData = roiDict[key]['to']
- if xproc is not None:
- idx = numpy.nonzero((fromData <= xproc) &
- (xproc <= toData))[0]
- if len(idx):
- xw = xproc[idx]
- yw = yproc[idx]
- rawCounts = yw.sum(dtype=numpy.float)
- deltaX = xw[-1] - xw[0]
- deltaY = yw[-1] - yw[0]
- if deltaX > 0.0:
- slope = (deltaY / deltaX)
- background = yw[0] + slope * (xw - xw[0])
- netCounts = (rawCounts -
- background.sum(dtype=numpy.float))
- else:
- netCounts = 0.0
- else:
- rawCounts = 0.0
- netCounts = 0.0
- roiDict[key]['rawcounts'] = rawCounts
- roiDict[key]['netcounts'] = netCounts
- else:
- roiDict[key].pop('rawcounts', None)
- roiDict[key].pop('netcounts', None)
-
- self.roiWidget.fillFromROIDict(
- roilist=roiList,
- roidict=roiDict,
- currentroi=self.currentROI if self.currentROI in roiList else None)
-
- def _emitCurrentROISignal(self):
- ddict = {}
- ddict['event'] = "currentROISignal"
- _roiList, roiDict = self.roiWidget.getROIListAndDict()
- if self.currentROI in roiDict:
- ddict['ROI'] = roiDict[self.currentROI]
- else:
- self.currentROI = None
- ddict['current'] = self.currentROI
- self.sigROISignal.emit(ddict)
-
- def _getAllLimits(self):
- """Retrieve the limits based on the curves."""
- curves = self.plot.getAllCurves()
- if not curves:
- return 1.0, 1.0, 100., 100.
-
- xmin, ymin = None, None
- xmax, ymax = None, None
-
- for curve in curves:
- x = curve.getXData(copy=False)
- y = curve.getYData(copy=False)
- if xmin is None:
- xmin = x.min()
- else:
- xmin = min(xmin, x.min())
- if xmax is None:
- xmax = x.max()
- else:
- xmax = max(xmax, x.max())
- if ymin is None:
- ymin = y.min()
- else:
- ymin = min(ymin, y.min())
- if ymax is None:
- ymax = y.max()
- else:
- ymax = max(ymax, y.max())
-
- return xmin, ymin, xmax, ymax
-
def showEvent(self, event):
"""Make sure this widget is raised when it is shown
(when it is first created as a tab in PlotWindow or when it is shown
again after hiding).
"""
self.raise_()
+ qt.QDockWidget.showEvent(self, event)
diff --git a/silx/gui/plot/Interaction.py b/silx/gui/plot/Interaction.py
index f09b9bc..358af74 100644
--- a/silx/gui/plot/Interaction.py
+++ b/silx/gui/plot/Interaction.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2016 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
diff --git a/silx/gui/plot/PlotToolButtons.py b/silx/gui/plot/PlotToolButtons.py
index 430489d..fc5fcf4 100644
--- a/silx/gui/plot/PlotToolButtons.py
+++ b/silx/gui/plot/PlotToolButtons.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -22,13 +22,14 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""This module provides a set of QToolButton to use with :class:`.PlotWidget`.
+"""This module provides a set of QToolButton to use with
+:class:`~silx.gui.plot.PlotWidget`.
The following QToolButton are available:
-- :class:`AspectToolButton`
-- :class:`YAxisOriginToolButton`
-- :class:`ProfileToolButton`
+- :class:`.AspectToolButton`
+- :class:`.YAxisOriginToolButton`
+- :class:`.ProfileToolButton`
"""
@@ -46,7 +47,7 @@ _logger = logging.getLogger(__name__)
class PlotToolButton(qt.QToolButton):
- """A QToolButton connected to a :class:`.PlotWidget`.
+ """A QToolButton connected to a :class:`~silx.gui.plot.PlotWidget`.
"""
def __init__(self, parent=None, plot=None):
@@ -93,6 +94,7 @@ class PlotToolButton(qt.QToolButton):
class AspectToolButton(PlotToolButton):
+ """Tool button to switch keep aspect ratio of a plot"""
STATE = None
"""Lazy loaded states used to feed AspectToolButton"""
@@ -159,6 +161,7 @@ class AspectToolButton(PlotToolButton):
class YAxisOriginToolButton(PlotToolButton):
+ """Tool button to switch the Y axis orientation of a plot."""
STATE = None
"""Lazy loaded states used to feed YAxisOriginToolButton"""
diff --git a/silx/gui/plot/PlotTools.py b/silx/gui/plot/PlotTools.py
index ed62d48..7fadfd2 100644
--- a/silx/gui/plot/PlotTools.py
+++ b/silx/gui/plot/PlotTools.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -83,10 +83,10 @@ class PositionInfo(qt.QWidget):
>>> plot.show() # To display the PlotWindow with the position widget
:param plot: The PlotWidget this widget is displaying data coords from.
- :param converters: List of name to display and conversion function from
- (x, y) in data coords to displayed value.
- If None, the default, it displays X and Y.
- :type converters: Iterable of 2-tuple (str, function)
+ :param converters:
+ List of 2-tuple: name to display and conversion function from (x, y)
+ in data coords to displayed value.
+ If None, the default, it displays X and Y.
:param parent: Parent widget
"""
diff --git a/silx/gui/plot/PlotWidget.py b/silx/gui/plot/PlotWidget.py
index 5bf2b59..3641b8c 100644
--- a/silx/gui/plot/PlotWidget.py
+++ b/silx/gui/plot/PlotWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -23,151 +23,7 @@
# ###########################################################################*/
"""Qt widget providing plot API for 1D and 2D data.
-Widget with plot API for 1D and 2D data.
-
The :class:`PlotWidget` implements the plot API initially provided in PyMca.
-
-Plot Events
------------
-
-The :class:`PlotWidget` sends some event to the registered callback
-(See :meth:`PlotWidget.setCallback`).
-Those events are sent as a dictionary with a key 'event' describing the kind
-of event.
-
-Drawing events
-..............
-
-'drawingProgress' and 'drawingFinished' events are sent during drawing
-interaction (See :meth:`PlotWidget.setInteractiveMode`).
-
-- 'event': 'drawingProgress' or 'drawingFinished'
-- 'parameters': dict of parameters used by the drawing mode.
- It has the following keys: 'shape', 'label', 'color'.
- See :meth:`PlotWidget.setInteractiveMode`.
-- 'points': Points (x, y) in data coordinates of the drawn shape.
- For 'hline' and 'vline', it is the 2 points defining the line.
- For 'line' and 'rectangle', it is the coordinates of the start
- drawing point and the latest drawing point.
- For 'polygon', it is the coordinates of all points of the shape.
-- 'type': The type of drawing in 'line', 'hline', 'polygon', 'rectangle',
- 'vline'.
-- 'xdata' and 'ydata': X coords and Y coords of shape points in data
- coordinates (as in 'points').
-
-When the type is 'rectangle', the following additional keys are provided:
-
-- 'x' and 'y': The origin of the rectangle in data coordinates
-- 'widht' and 'height': The size of the rectangle in data coordinates
-
-
-Mouse events
-............
-
-'mouseMoved', 'mouseClicked' and 'mouseDoubleClicked' events are sent for
-mouse events.
-
-They provide the following keys:
-
-- 'event': 'mouseMoved', 'mouseClicked' or 'mouseDoubleClicked'
-- 'button': the mouse button that was pressed in 'left', 'middle', 'right'
-- 'x' and 'y': The mouse position in data coordinates
-- 'xpixel' and 'ypixel': The mouse position in pixels
-
-
-Marker events
-.............
-
-'hover', 'markerClicked', 'markerMoving' and 'markerMoved' events are
-sent during interaction with markers.
-
-'hover' is sent when the mouse cursor is over a marker.
-'markerClicker' is sent when the user click on a selectable marker.
-'markerMoving' and 'markerMoved' are sent when a draggable marker is moved.
-
-They provide the following keys:
-
-- 'event': 'hover', 'markerClicked', 'markerMoving' or 'markerMoved'
-- 'button': the mouse button that is pressed in 'left', 'middle', 'right'
-- 'draggable': True if the marker is draggable, False otherwise
-- 'label': The legend associated with the clicked image or curve
-- 'selectable': True if the marker is selectable, False otherwise
-- 'type': 'marker'
-- 'x' and 'y': The mouse position in data coordinates
-- 'xdata' and 'ydata': The marker position in data coordinates
-
-'markerClicked' and 'markerMoving' events have a 'xpixel' and a 'ypixel'
-additional keys, that provide the mouse position in pixels.
-
-
-Image and curve events
-......................
-
-'curveClicked' and 'imageClicked' events are sent when a selectable curve
-or image is clicked.
-
-Both share the following keys:
-
-- 'event': 'curveClicked' or 'imageClicked'
-- 'button': the mouse button that was pressed in 'left', 'middle', 'right'
-- 'label': The legend associated with the clicked image or curve
-- 'type': The type of item in 'curve', 'image'
-- 'x' and 'y': The clicked position in data coordinates
-- 'xpixel' and 'ypixel': The clicked position in pixels
-
-'curveClicked' events have a 'xdata' and a 'ydata' additional keys, that
-provide the coordinates of the picked points of the curve.
-There can be more than one point of the curve being picked, and if a line of
-the curve is picked, only the first point of the line is included in the list.
-
-'imageClicked' have a 'col' and a 'row' additional keys, that provide
-the column and row index in the image array that was clicked.
-
-
-Limits changed events
-.....................
-
-'limitsChanged' events are sent when the limits of the plot are changed.
-This can results from user interaction or API calls.
-
-It provides the following keys:
-
-- 'event': 'limitsChanged'
-- 'source': id of the widget that emitted this event.
-- 'xdata': Range of X in graph coordinates: (xMin, xMax).
-- 'ydata': Range of Y in graph coordinates: (yMin, yMax).
-- 'y2data': Range of right axis in graph coordinates (y2Min, y2Max) or None.
-
-Plot state change events
-........................
-
-The following events are emitted when the plot is modified.
-They provide the new state:
-
-- 'setGraphCursor' event with a 'state' key (bool)
-- 'setGraphGrid' event with a 'which' key (str), see :meth:`setGraphGrid`
-- 'setKeepDataAspectRatio' event with a 'state' key (bool)
-
-A 'contentChanged' event is triggered when the content of the plot is updated.
-It provides the following keys:
-
-- 'action': The change of the plot: 'add' or 'remove'
-- 'kind': The kind of primitive changed: 'curve', 'image', 'item' or 'marker'
-- 'legend': The legend of the primitive changed.
-
-'activeCurveChanged' and 'activeImageChanged' events with the following keys:
-
-- 'legend': Name (str) of the current active item or None if no active item.
-- 'previous': Name (str) of the previous active item or None if no item was
- active. It is the same as 'legend' if 'updated' == True
-- 'updated': (bool) True if active item name did not changed,
- but active item data or style was updated.
-
-'interactiveModeChanged' event with a 'source' key identifying the object
-setting the interactive mode.
-
-'defaultColormapChanged' event is triggered when the default colormap of
-the plot is updated.
"""
from __future__ import division
@@ -264,7 +120,8 @@ class PlotWidget(qt.QMainWindow):
"""Signal for all events of the plot.
The signal information is provided as a dict.
- See :class:`PlotWidget` for documentation of the content of the dict.
+ See the :ref:`plot signal documentation page <plot_signal>` for
+ information about the content of the dict
"""
sigSetKeepDataAspectRatio = qt.Signal(bool)
@@ -574,7 +431,7 @@ class PlotWidget(qt.QMainWindow):
item._setPlot(self)
if item.isVisible():
self._itemRequiresUpdate(item)
- if isinstance(item, (items.Curve, items.ImageBase)):
+ if isinstance(item, items.DATA_ITEMS):
self._invalidateDataRange() # TODO handle this automatically
self._notifyContentChanged(item)
@@ -964,7 +821,7 @@ class PlotWidget(qt.QMainWindow):
:param colormap: Description of the :class:`.Colormap` to use
(or None).
This is ignored if data is a RGB(A) image.
- :type colormap: Colormap or dict (old API )
+ :type colormap: Union[silx.gui.plot.Colormap.Colormap, dict]
:param pixmap: Pixmap representation of the data (if any)
:type pixmap: (nrows, ncolumns, RGBA) ubyte array or None (default)
:param str xlabel: X axis label to show when this curve is active,
@@ -1107,8 +964,8 @@ class PlotWidget(qt.QMainWindow):
:param numpy.ndarray y: The data corresponding to the y coordinates
:param numpy.ndarray value: The data value associated with each point
:param str legend: The legend to be associated to the scatter (or None)
- :param Colormap colormap: The :class:`.Colormap`. to be used for the
- scatter (or None)
+ :param silx.gui.plot.Colormap.Colormap colormap:
+ The :class:`.Colormap`. to be used for the scatter (or None)
:param info: User-defined information associated to the curve
:param str symbol: Symbol to be drawn at each (x, y) position::
@@ -2407,9 +2264,10 @@ class PlotWidget(qt.QMainWindow):
It only affects future calls to :meth:`addImage` without the colormap
parameter.
- :param Colormap colormap: The description of the default colormap, or
- None to set the :class:`.Colormap` to a linear
- autoscale gray colormap.
+ :param silx.gui.plot.Colormap.Colormap colormap:
+ The description of the default colormap, or
+ None to set the :class:`.Colormap` to a linear
+ autoscale gray colormap.
"""
if colormap is None:
colormap = Colormap(name='gray',
@@ -2533,6 +2391,7 @@ class PlotWidget(qt.QMainWindow):
if ddict['event'] in ["legendClicked", "curveClicked"]:
if ddict['button'] == "left":
self.setActiveCurve(ddict['label'])
+ qt.QToolTip.showText(self.cursor().pos(), ddict['label'])
def saveGraph(self, filename, fileFormat=None, dpi=None, **kw):
"""Save a snapshot of the plot.
@@ -2817,7 +2676,7 @@ class PlotWidget(qt.QMainWindow):
def test(mark):
return True
- markers = self._backend.pickItems(x, y)
+ markers = self._backend.pickItems(x, y, kinds=('marker',))
legends = [m['legend'] for m in markers if m['kind'] == 'marker']
for legend in reversed(legends):
@@ -2852,7 +2711,8 @@ class PlotWidget(qt.QMainWindow):
To use for interaction implementation.
- :param float x: X position in pixelsparam float y: Y position in pixels
+ :param float x: X position in pixels
+ :param float y: Y position in pixels
:param test: A callable to call for each picked item to filter
picked items. If None (default), do not filter items.
"""
@@ -2860,7 +2720,7 @@ class PlotWidget(qt.QMainWindow):
def test(i):
return True
- allItems = self._backend.pickItems(x, y)
+ allItems = self._backend.pickItems(x, y, kinds=('curve', 'image'))
allItems = [item for item in allItems
if item['kind'] in ['curve', 'image']]
diff --git a/silx/gui/plot/PlotWindow.py b/silx/gui/plot/PlotWindow.py
index a23db04..5c7e661 100644
--- a/silx/gui/plot/PlotWindow.py
+++ b/silx/gui/plot/PlotWindow.py
@@ -29,7 +29,7 @@ The :class:`PlotWindow` is a subclass of :class:`.PlotWidget`.
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "17/08/2017"
+__date__ = "15/02/2018"
import collections
import logging
@@ -41,6 +41,7 @@ from . import actions
from . import items
from .actions import medfilt as actions_medfilt
from .actions import fit as actions_fit
+from .actions import control as actions_control
from .actions import histogram as actions_histogram
from . import PlotToolButtons
from .PlotTools import PositionInfo
@@ -112,6 +113,10 @@ class PlotWindow(PlotWidget):
self._legendsDockWidget = None
self._curvesROIDockWidget = None
self._maskToolsDockWidget = None
+ self._consoleDockWidget = None
+
+ # Create color bar, hidden by default for backward compatibility
+ self._colorbar = ColorBarWidget(parent=self, plot=self)
# Init actions
self.group = qt.QActionGroup(self)
@@ -168,6 +173,12 @@ class PlotWindow(PlotWidget):
self.colormapAction.setVisible(colormap)
self.addAction(self.colormapAction)
+ self.colorbarAction = self.group.addAction(
+ actions_control.ColorBarAction(self, self))
+ self.colorbarAction.setVisible(False)
+ self.addAction(self.colorbarAction)
+ self._colorbar.setVisible(False)
+
self.keepDataAspectRatioButton = PlotToolButtons.AspectToolButton(
parent=self, plot=self)
self.keepDataAspectRatioButton.setVisible(aspectRatio)
@@ -219,10 +230,6 @@ class PlotWindow(PlotWidget):
self._panWithArrowKeysAction = None
self._crosshairAction = None
- # Create color bar, hidden by default for backward compatibility
- self._colorbar = ColorBarWidget(parent=self, plot=self)
- self._colorbar.setVisible(False)
-
# Make colorbar background white
self._colorbar.setAutoFillBackground(True)
palette = self._colorbar.palette()
@@ -301,11 +308,11 @@ class PlotWindow(PlotWidget):
"""
return bool(self.getMaskToolsDockWidget().setSelectionMask(mask))
- def _toggleConsoleVisibility(self, is_checked=False):
+ def _toggleConsoleVisibility(self, isChecked=False):
"""Create IPythonDockWidget if needed,
show it or hide it."""
# create widget if needed (first call)
- if not hasattr(self, '_consoleDockWidget'):
+ if self._consoleDockWidget is None:
available_vars = {"plt": self}
banner = "The variable 'plt' is available. Use the 'whos' "
banner += "and 'help(plt)' commands for more information.\n\n"
@@ -314,10 +321,11 @@ class PlotWindow(PlotWidget):
custom_banner=banner,
parent=self)
self.addTabbedDockWidget(self._consoleDockWidget)
- self._consoleDockWidget.visibilityChanged.connect(
+ # self._consoleDockWidget.setVisible(True)
+ self._consoleDockWidget.toggleViewAction().toggled.connect(
self.getConsoleAction().setChecked)
- self._consoleDockWidget.setVisible(is_checked)
+ self._consoleDockWidget.setVisible(isChecked)
def _createToolBar(self, title, parent):
"""Create a QToolBar from the QAction of the PlotWindow.
@@ -427,16 +435,22 @@ class PlotWindow(PlotWidget):
return self._legendsDockWidget
@property
- @deprecated(replacement="getCurvesRoiDockWidget()", since_version="0.4.0")
+ @deprecated(replacement="getCurvesRoiWidget()", since_version="0.4.0")
def curvesROIDockWidget(self):
return self.getCurvesRoiDockWidget()
def getCurvesRoiDockWidget(self):
- """DockWidget with curves' ROI panel (lazy-loaded).
+ # Undocumented for a "soft deprecation" in version 0.7.0
+ # (still used internally for lazy loading)
+ if self._curvesROIDockWidget is None:
+ self._curvesROIDockWidget = CurvesROIDockWidget(
+ plot=self, name='Regions Of Interest')
+ self._curvesROIDockWidget.hide()
+ self.addTabbedDockWidget(self._curvesROIDockWidget)
+ return self._curvesROIDockWidget
- The widget returned is a :class:`CurvesROIDockWidget`.
- Its central widget is a :class:`CurvesROIWidget`
- accessible as :attr:`CurvesROIDockWidget.roiWidget`.
+ def getCurvesRoiWidget(self):
+ """Return the :class:`CurvesROIWidget`.
:class:`silx.gui.plot.CurvesROIWidget.CurvesROIWidget` offers a getter
and a setter for the ROI data:
@@ -444,12 +458,7 @@ class PlotWindow(PlotWidget):
- :meth:`CurvesROIWidget.getRois`
- :meth:`CurvesROIWidget.setRois`
"""
- if self._curvesROIDockWidget is None:
- self._curvesROIDockWidget = CurvesROIDockWidget(
- plot=self, name='Regions Of Interest')
- self._curvesROIDockWidget.hide()
- self.addTabbedDockWidget(self._curvesROIDockWidget)
- return self._curvesROIDockWidget
+ return self.getCurvesRoiDockWidget().roiWidget
@property
@deprecated(replacement="getMaskToolsDockWidget()", since_version="0.4.0")
@@ -695,6 +704,16 @@ class PlotWindow(PlotWidget):
"""
return self._medianFilter2DAction
+ def getColorBarAction(self):
+ """Action toggling the colorbar show/hide action
+
+ .. warning:: to show/hide the plot colorbar call directly the ColorBar
+ widget using getColorBarWidget()
+
+ :rtype: actions.PlotAction
+ """
+ return self.colorbarAction
+
class Plot1D(PlotWindow):
"""PlotWindow with tools specific for curves.
@@ -756,6 +775,7 @@ class Plot2D(PlotWindow):
self.profile = ProfileToolBar(plot=self)
self.addToolBar(self.profile)
+ self.colorbarAction.setVisible(True)
self.getColorBarWidget().setVisible(True)
# Put colorbar action after colormap action
@@ -763,9 +783,6 @@ class Plot2D(PlotWindow):
for index, action in enumerate(actions):
if action is self.getColormapAction():
break
- self.toolBar().insertAction(
- actions[index + 1],
- self.getColorBarWidget().getToggleViewAction())
def _getImageValue(self, x, y):
"""Get status bar value of top most image at position (x, y)
diff --git a/silx/gui/plot/Profile.py b/silx/gui/plot/Profile.py
index 4a74fa7..f61412d 100644
--- a/silx/gui/plot/Profile.py
+++ b/silx/gui/plot/Profile.py
@@ -660,23 +660,23 @@ class ProfileToolBar(qt.QToolBar):
winGeom = self.window().frameGeometry()
qapp = qt.QApplication.instance()
screenGeom = qapp.desktop().availableGeometry(self)
-
spaceOnLeftSide = winGeom.left()
spaceOnRightSide = screenGeom.width() - winGeom.right()
profileWindowWidth = profileMainWindow.frameGeometry().width()
- if (profileWindowWidth < spaceOnRightSide or
- spaceOnRightSide > spaceOnLeftSide):
+ if (profileWindowWidth < spaceOnRightSide):
# Place profile on the right
profileMainWindow.move(winGeom.right(), winGeom.top())
- else:
- # Not enough place on the right, place profile on the left
+ elif(profileWindowWidth < spaceOnLeftSide):
+ # Place profile on the left
profileMainWindow.move(
- max(0, winGeom.left() - profileWindowWidth), winGeom.top())
+ max(0, winGeom.left() - profileWindowWidth), winGeom.top())
profileMainWindow.show()
+ profileMainWindow.raise_()
else:
self.getProfilePlot().show()
+ self.getProfilePlot().raise_()
def hideProfileWindow(self):
"""Hide profile window.
diff --git a/silx/gui/plot/StackView.py b/silx/gui/plot/StackView.py
index 938447b..1fb188c 100644
--- a/silx/gui/plot/StackView.py
+++ b/silx/gui/plot/StackView.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -69,7 +69,7 @@ Example::
__authors__ = ["P. Knobel", "H. Payno"]
__license__ = "MIT"
-__date__ = "11/09/2017"
+__date__ = "15/02/2018"
import numpy
@@ -82,6 +82,7 @@ from .PlotTools import LimitsToolBar
from .Profile import Profile3DToolBar
from ..widgets.FrameBrowser import HorizontalSliderWithBrowser
+from silx.gui.plot.actions import control as actions_control
from silx.utils.array_like import DatasetView, ListOfImages
from silx.math import calibration
from silx.utils.deprecation import deprecated_warning
@@ -245,9 +246,8 @@ class StackView(qt.QMainWindow):
for index, action in enumerate(actions):
if action is self._plot.getColormapAction():
break
- self._plot.toolBar().insertAction(
- actions[index + 1],
- self._plot.getColorBarWidget().getToggleViewAction())
+ self._colorbarAction = actions_control.ColorBarAction(self._plot, self._plot)
+ self._plot.toolBar().insertAction(actions[index + 1], self._colorbarAction)
def _plotCallback(self, eventDict):
"""Callback for plot events.
@@ -652,7 +652,7 @@ class StackView(qt.QMainWindow):
when the volume is rotated (when different axes are selected as the
X and Y axes).
- :param list(str) labels: 3 labels corresponding to the 3 dimensions
+ :param List[str] labels: 3 labels corresponding to the 3 dimensions
of the data volumes.
"""
@@ -972,6 +972,16 @@ class StackView(qt.QMainWindow):
"""
return self._plot.getActiveImage(just_legend=just_legend)
+ def getColorBarAction(self):
+ """Returns the action managing the visibility of the colorbar.
+
+ .. warning:: to show/hide the plot colorbar call directly the ColorBar
+ widget using getColorBarWidget()
+
+ :rtype: QAction
+ """
+ return self._colorbarAction
+
def remove(self, legend=None,
kind=('curve', 'image', 'item', 'marker')):
"""See :meth:`Plot.Plot.remove`"""
@@ -1102,7 +1112,7 @@ class StackViewMainWindow(StackView):
menu.addSeparator()
menu.addAction(self._plot.resetZoomAction)
menu.addAction(self._plot.colormapAction)
- menu.addAction(self._plot.getColorBarWidget().getToggleViewAction())
+ menu.addAction(self.getColorBarAction())
menu.addAction(actions.control.KeepAspectRatioAction(self._plot, self))
menu.addAction(actions.control.YAxisInvertedAction(self._plot, self))
diff --git a/silx/gui/plot/_utils/test/test_ticklayout.py b/silx/gui/plot/_utils/test/test_ticklayout.py
index 8c67620..927ffb6 100644
--- a/silx/gui/plot/_utils/test/test_ticklayout.py
+++ b/silx/gui/plot/_utils/test/test_ticklayout.py
@@ -27,12 +27,13 @@ from __future__ import absolute_import, division, unicode_literals
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "18/10/2016"
+__date__ = "17/01/2018"
import unittest
+import numpy
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
from silx.gui.plot._utils import ticklayout
@@ -40,6 +41,19 @@ from silx.gui.plot._utils import ticklayout
class TestTickLayout(ParametricTestCase):
"""Test ticks layout algorithms"""
+ def testTicks(self):
+ """Test of :func:`ticks`"""
+ tests = { # (vmin, vmax): ref_ticks
+ (1., 1.): (1.,),
+ (0.5, 10.5): (2.0, 4.0, 6.0, 8.0, 10.0),
+ (0.001, 0.005): (0.001, 0.002, 0.003, 0.004, 0.005)
+ }
+
+ for (vmin, vmax), ref_ticks in tests.items():
+ with self.subTest(vmin=vmin, vmax=vmax):
+ ticks, labels = ticklayout.ticks(vmin, vmax)
+ self.assertTrue(numpy.allclose(ticks, ref_ticks))
+
def testNiceNumbers(self):
"""Minimalistic tests of :func:`niceNumbers`"""
tests = { # (vmin, vmax): ref_ticks
diff --git a/silx/gui/plot/_utils/ticklayout.py b/silx/gui/plot/_utils/ticklayout.py
index 5f4b636..6e9f654 100644
--- a/silx/gui/plot/_utils/ticklayout.py
+++ b/silx/gui/plot/_utils/ticklayout.py
@@ -109,7 +109,7 @@ def ticks(vMin, vMax, nbTicks=5):
"""Returns tick positions and labels using nice numbers algorithm.
This enforces ticks to be within [vMin, vMax] range.
- It returns at least 2 ticks.
+ It returns at least 1 tick (when vMin == vMax).
:param float vMin: The min value on the axis
:param float vMax: The max value on the axis
@@ -117,13 +117,19 @@ def ticks(vMin, vMax, nbTicks=5):
:returns: tick positions and corresponding text labels
:rtype: 2-tuple: list of float, list of string
"""
- start, end, step, nfrac = niceNumbers(vMin, vMax, nbTicks)
- positions = [t for t in _frange(start, end, step) if vMin <= t <= vMax]
+ assert vMin <= vMax
+ if vMin == vMax:
+ positions = [vMin]
+ nfrac = 0
+
+ else:
+ start, end, step, nfrac = niceNumbers(vMin, vMax, nbTicks)
+ positions = [t for t in _frange(start, end, step) if vMin <= t <= vMax]
- # Makes sure there is at least 2 ticks
- if len(positions) < 2:
- positions = [vMin, vMax]
- nfrac = numberOfDigits(vMax - vMin)
+ # Makes sure there is at least 2 ticks
+ if len(positions) < 2:
+ positions = [vMin, vMax]
+ nfrac = numberOfDigits(vMax - vMin)
# Generate labels
format_ = '%g' if nfrac == 0 else '%.{}f'.format(nfrac)
diff --git a/silx/gui/plot/actions/PlotAction.py b/silx/gui/plot/actions/PlotAction.py
index 6eb9ba3..2983775 100644
--- a/silx/gui/plot/actions/PlotAction.py
+++ b/silx/gui/plot/actions/PlotAction.py
@@ -32,10 +32,9 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "20/04/2017"
+__date__ = "03/01/2018"
-from collections import OrderedDict
import weakref
from silx.gui import icons
from silx.gui import qt
diff --git a/silx/gui/plot/actions/__init__.py b/silx/gui/plot/actions/__init__.py
index 73829cd..930c728 100644
--- a/silx/gui/plot/actions/__init__.py
+++ b/silx/gui/plot/actions/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -22,10 +22,14 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""This package provides a set of QActions to use with :class:`PlotWidget`
+"""This package provides a set of QAction to use with
+:class:`~silx.gui.plot.PlotWidget`
-It also contains the :class:'.PlotAction' (Base class for QAction that operates
-on a PlotWidget)
+Those actions are useful to add menu items or toolbar items
+that interact with a :class:`~silx.gui.plot.PlotWidget`.
+
+It provides a base class used to define new plot actions:
+:class:`~silx.gui.plot.actions.PlotAction`.
"""
__authors__ = ["H. Payno"]
diff --git a/silx/gui/plot/actions/control.py b/silx/gui/plot/actions/control.py
index 23e710e..ac6dc2f 100644
--- a/silx/gui/plot/actions/control.py
+++ b/silx/gui/plot/actions/control.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -50,11 +50,10 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "27/06/2017"
+__date__ = "15/02/2018"
from . import PlotAction
import logging
-import numpy
from silx.gui.plot import items
from silx.gui.plot.ColormapDialog import ColormapDialog
from silx.gui.plot._utils import applyZoomToPlot as _applyZoomToPlot
@@ -327,67 +326,112 @@ class ColormapAction(PlotAction):
plot, icon='colormap', text='Colormap',
tooltip="Change colormap",
triggered=self._actionTriggered,
- checkable=False, parent=parent)
+ checkable=True, parent=parent)
+ self.plot.sigActiveImageChanged.connect(self._updateColormap)
+
+ def setColorDialog(self, colorDialog):
+ """Set a specific color dialog instead of using the default dialog."""
+ assert(colorDialog is not None)
+ assert(self._dialog is None)
+ self._dialog = colorDialog
+ self._dialog.visibleChanged.connect(self._dialogVisibleChanged)
+ self.setChecked(self._dialog.isVisible())
+
+ @staticmethod
+ def _createDialog(parent):
+ """Create the dialog if not already existing
+
+ :parent QWidget parent: Parent of the new colormap
+ :rtype: ColormapDialog
+ """
+ dialog = ColormapDialog(parent=parent)
+ dialog.setModal(False)
+ return dialog
def _actionTriggered(self, checked=False):
"""Create a cmap dialog and update active image and default cmap."""
- # Create the dialog if not already existing
if self._dialog is None:
- self._dialog = ColormapDialog()
+ self._dialog = self._createDialog(self.plot)
+ self._dialog.visibleChanged.connect(self._dialogVisibleChanged)
+
+ # Run the dialog listening to colormap change
+ if checked is True:
+ self._dialog.show()
+ self._updateColormap()
+ else:
+ self._dialog.hide()
+
+ def _dialogVisibleChanged(self, isVisible):
+ self.setChecked(isVisible)
+ def _updateColormap(self):
+ if self._dialog is None:
+ return
image = self.plot.getActiveImage()
- if not isinstance(image, items.ColormapMixIn):
- # No active image or active image is RGBA,
- # set dialog from default info
- colormap = self.plot.getDefaultColormap()
- self._dialog.setHistogram() # Reset histogram and range if any
+ if isinstance(image, items.ImageComplexData):
+ # Specific init for complex images
+ colormap = image.getColormap()
- else:
+ mode = image.getVisualizationMode()
+ if mode in (items.ImageComplexData.Mode.AMPLITUDE_PHASE,
+ items.ImageComplexData.Mode.LOG10_AMPLITUDE_PHASE):
+ data = image.getData(
+ copy=False, mode=items.ImageComplexData.Mode.PHASE)
+ else:
+ data = image.getData(copy=False)
+
+ # Set histogram and range if any
+ self._dialog.setData(data)
+
+ elif isinstance(image, items.ColormapMixIn):
# Set dialog from active image
colormap = image.getColormap()
-
data = image.getData(copy=False)
+ # Set histogram and range if any
+ self._dialog.setData(data)
- goodData = data[numpy.isfinite(data)]
- if goodData.size > 0:
- dataMin = goodData.min()
- dataMax = goodData.max()
- else:
- qt.QMessageBox.warning(
- None, "No Data",
- "Image data does not contain any real value")
- dataMin, dataMax = 1., 10.
-
- self._dialog.setHistogram() # Reset histogram if any
- self._dialog.setDataRange(dataMin, dataMax)
- # The histogram should be done in a worker thread
- # hist, bin_edges = numpy.histogram(goodData, bins=256)
- # self._dialog.setHistogram(hist, bin_edges)
-
- self._dialog.setColormap(name=colormap.getName(),
- normalization=colormap.getNormalization(),
- autoscale=colormap.isAutoscale(),
- vmin=colormap.getVMin(),
- vmax=colormap.getVMax(),
- colors=colormap.getColormapLUT())
+ else:
+ # No active image or active image is RGBA,
+ # set dialog from default info
+ colormap = self.plot.getDefaultColormap()
+ # Reset histogram and range if any
+ self._dialog.setData(None)
- # Run the dialog listening to colormap change
- self._dialog.sigColormapChanged.connect(self._colormapChanged)
- result = self._dialog.exec_()
- self._dialog.sigColormapChanged.disconnect(self._colormapChanged)
+ self._dialog.setColormap(colormap)
- if not result: # Restore the previous colormap
- self._colormapChanged(colormap)
- def _colormapChanged(self, colormap):
- # Update default colormap
- self.plot.setDefaultColormap(colormap)
+class ColorBarAction(PlotAction):
+ """QAction opening the ColorBarWidget of the specified plot.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+ def __init__(self, plot, parent=None):
+ self._dialog = None # To store an instance of ColormapDialog
+ super(ColorBarAction, self).__init__(
+ plot, icon='colorbar', text='Colorbar',
+ tooltip="Show/Hide the colorbar",
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ colorBarWidget = self.plot.getColorBarWidget()
+ old = self.blockSignals(True)
+ self.setChecked(colorBarWidget.isVisibleTo(self.plot))
+ self.blockSignals(old)
+ colorBarWidget.sigVisibleChanged.connect(self._widgetVisibleChanged)
+
+ def _widgetVisibleChanged(self, isVisible):
+ """Callback when the colorbar `visible` property change."""
+ if self.isChecked() == isVisible:
+ return
+ self.setChecked(isVisible)
- # Update active image colormap
- activeImage = self.plot.getActiveImage()
- if isinstance(activeImage, items.ColormapMixIn):
- activeImage.setColormap(colormap)
+ def _actionTriggered(self, checked=False):
+ """Create a cmap dialog and update active image and default cmap."""
+ colorBarWidget = self.plot.getColorBarWidget()
+ if not colorBarWidget.isHidden() == checked:
+ return
+ self.plot.getColorBarWidget().setVisible(checked)
class KeepAspectRatioAction(PlotAction):
diff --git a/silx/gui/plot/actions/fit.py b/silx/gui/plot/actions/fit.py
index d7256ab..5ca649c 100644
--- a/silx/gui/plot/actions/fit.py
+++ b/silx/gui/plot/actions/fit.py
@@ -36,7 +36,7 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "28/06/2017"
+__date__ = "03/01/2018"
from . import PlotAction
import logging
@@ -111,7 +111,7 @@ class FitAction(PlotAction):
if histo is None and curve is None:
# ambiguous case, we need to ask which plot item to fit
- isd = ItemsSelectionDialog(plot=self.plot)
+ isd = ItemsSelectionDialog(parent=self.plot, plot=self.plot)
isd.setWindowTitle("Select item to be fitted")
isd.setItemsSelectionMode(qt.QTableWidget.SingleSelection)
isd.setAvailableKinds(["curve", "histogram"])
diff --git a/silx/gui/plot/actions/histogram.py b/silx/gui/plot/actions/histogram.py
index a4a91e9..40ef873 100644
--- a/silx/gui/plot/actions/histogram.py
+++ b/silx/gui/plot/actions/histogram.py
@@ -39,6 +39,7 @@ __license__ = "MIT"
from . import PlotAction
from silx.math.histogram import Histogramnd
+from silx.math.combo import min_max
import numpy
import logging
from silx.gui import qt
@@ -107,8 +108,7 @@ class PixelIntensitiesHistoAction(PlotAction):
image[:, :, 1] * 0.587 +
image[:, :, 2] * 0.114)
- xmin = numpy.nanmin(image)
- xmax = numpy.nanmax(image)
+ xmin, xmax = min_max(image, min_positive=False, finite=True)
nbins = min(1024, int(numpy.sqrt(image.size)))
data_range = xmin, xmax
diff --git a/silx/gui/plot/actions/io.py b/silx/gui/plot/actions/io.py
index 50410e3..d6d5909 100644
--- a/silx/gui/plot/actions/io.py
+++ b/silx/gui/plot/actions/io.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -37,10 +37,11 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "27/06/2017"
+__date__ = "02/02/2018"
from . import PlotAction
from silx.io.utils import save1D, savespec
+from silx.io.nxdata import save_NXdata
import logging
import sys
from collections import OrderedDict
@@ -59,6 +60,10 @@ else:
_logger = logging.getLogger(__name__)
+_NEXUS_HDF5_EXT = [".nx5", ".nxs", ".hdf", ".hdf5", ".cxi", ".h5"]
+_NEXUS_HDF5_EXT_STR = ' '.join(['*' + ext for ext in _NEXUS_HDF5_EXT])
+
+
class SaveAction(PlotAction):
"""QAction for saving Plot content.
@@ -89,12 +94,15 @@ class SaveAction(PlotAction):
('Curve as OMNIC CSV (*.csv)',
{'fmt': '%.7E', 'delimiter': ',', 'header': False}),
('Curve as SpecFile (*.dat)',
- {'fmt': '%.7g', 'delimiter': '', 'header': False})
+ {'fmt': '%.10g', 'delimiter': '', 'header': False})
))
CURVE_FILTER_NPY = 'Curve as NumPy binary file (*.npy)'
- CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [CURVE_FILTER_NPY]
+ CURVE_FILTER_NXDATA = 'Curve as NXdata (%s)' % _NEXUS_HDF5_EXT_STR
+
+ CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [CURVE_FILTER_NPY,
+ CURVE_FILTER_NXDATA]
ALL_CURVES_FILTERS = ("All curves as SpecFile (*.dat)", )
@@ -107,6 +115,7 @@ class SaveAction(PlotAction):
IMAGE_FILTER_CSV_TAB = 'Image data as tab-separated CSV (*.csv)'
IMAGE_FILTER_RGB_PNG = 'Image as PNG (*.png)'
IMAGE_FILTER_RGB_TIFF = 'Image as TIFF (*.tif)'
+ IMAGE_FILTER_NXDATA = 'Image as NXdata (%s)' % _NEXUS_HDF5_EXT_STR
IMAGE_FILTERS = (IMAGE_FILTER_EDF,
IMAGE_FILTER_TIFF,
IMAGE_FILTER_NUMPY,
@@ -115,7 +124,11 @@ class SaveAction(PlotAction):
IMAGE_FILTER_CSV_SEMICOLON,
IMAGE_FILTER_CSV_TAB,
IMAGE_FILTER_RGB_PNG,
- IMAGE_FILTER_RGB_TIFF)
+ IMAGE_FILTER_RGB_TIFF,
+ IMAGE_FILTER_NXDATA)
+
+ SCATTER_FILTER_NXDATA = 'Scatter as NXdata (%s)' % _NEXUS_HDF5_EXT_STR
+ SCATTER_FILTERS = (SCATTER_FILTER_NXDATA, )
def __init__(self, plot, parent=None):
super(SaveAction, self).__init__(
@@ -183,7 +196,7 @@ class SaveAction(PlotAction):
csvdelim = filter_['delimiter']
autoheader = filter_['header']
else:
- # .npy
+ # .npy or nxdata
fmt, csvdelim, autoheader = ("", "", False)
# If curve has no associated label, get the default from the plot
@@ -194,6 +207,19 @@ class SaveAction(PlotAction):
if ylabel is None:
ylabel = self.plot.getYAxis().getLabel()
+ if nameFilter == self.CURVE_FILTER_NXDATA:
+ return save_NXdata(
+ filename,
+ signal=curve.getYData(copy=False),
+ axes=[curve.getXData(copy=False)],
+ signal_name="y",
+ axes_names=["x"],
+ signal_long_name=ylabel,
+ axes_long_names=[xlabel],
+ signal_errors=curve.getYErrorData(copy=False),
+ axes_errors=[curve.getXErrorData(copy=True)],
+ title=self.plot.getGraphTitle())
+
try:
save1D(filename,
curve.getXData(copy=False),
@@ -226,11 +252,13 @@ class SaveAction(PlotAction):
curve = curves[0]
scanno = 1
try:
+ xlabel = curve.getXLabel() or self.plot.getGraphXLabel()
+ ylabel = curve.getYLabel() or self.plot.getGraphYLabel(curve.getYAxis())
specfile = savespec(filename,
curve.getXData(copy=False),
curve.getYData(copy=False),
- curve.getXLabel(),
- curve.getYLabel(),
+ xlabel,
+ ylabel,
fmt="%.7g", scan_number=1, mode="w",
write_file_header=True,
close_file=False)
@@ -241,12 +269,14 @@ class SaveAction(PlotAction):
for curve in curves[1:]:
try:
scanno += 1
+ xlabel = curve.getXLabel() or self.plot.getGraphXLabel()
+ ylabel = curve.getYLabel() or self.plot.getGraphYLabel(curve.getYAxis())
specfile = savespec(specfile,
curve.getXData(copy=False),
curve.getYData(copy=False),
- curve.getXLabel(),
- curve.getYLabel(),
- fmt="%.7g", scan_number=scanno, mode="w",
+ xlabel,
+ ylabel,
+ fmt="%.7g", scan_number=scanno,
write_file_header=False,
close_file=False)
except IOError:
@@ -294,6 +324,24 @@ class SaveAction(PlotAction):
return False
return True
+ elif nameFilter == self.IMAGE_FILTER_NXDATA:
+ xorigin, yorigin = image.getOrigin()
+ xscale, yscale = image.getScale()
+ xaxis = xorigin + xscale * numpy.arange(data.shape[1])
+ yaxis = yorigin + yscale * numpy.arange(data.shape[0])
+ xlabel = image.getXLabel() or self.plot.getGraphXLabel()
+ ylabel = image.getYLabel() or self.plot.getGraphYLabel()
+ interpretation = "image" if len(data.shape) == 2 else "rgba-image"
+
+ return save_NXdata(filename,
+ signal=data,
+ axes=[yaxis, xaxis],
+ signal_name="image",
+ axes_names=["y", "x"],
+ axes_long_names=[ylabel, xlabel],
+ title=self.plot.getGraphTitle(),
+ interpretation=interpretation)
+
elif nameFilter in (self.IMAGE_FILTER_ASCII,
self.IMAGE_FILTER_CSV_COMMA,
self.IMAGE_FILTER_CSV_SEMICOLON,
@@ -343,6 +391,45 @@ class SaveAction(PlotAction):
return False
+ def _saveScatter(self, filename, nameFilter):
+ """Save an image from the plot.
+
+ :param str filename: The name of the file to write
+ :param str nameFilter: The selected name filter
+ :return: False if format is not supported or save failed,
+ True otherwise.
+ """
+ if nameFilter not in self.SCATTER_FILTERS:
+ return False
+
+ if nameFilter == self.SCATTER_FILTER_NXDATA:
+ scatter = self.plot.getScatter()
+ # TODO: we could get all scatters on this plot and concatenate their (x, y, values)
+ x = scatter.getXData(copy=False)
+ y = scatter.getYData(copy=False)
+ z = scatter.getValueData(copy=False)
+
+ xerror = scatter.getXErrorData(copy=False)
+ if isinstance(xerror, float):
+ xerror = xerror * numpy.ones(x.shape, dtype=numpy.float32)
+
+ yerror = scatter.getYErrorData(copy=False)
+ if isinstance(yerror, float):
+ yerror = yerror * numpy.ones(x.shape, dtype=numpy.float32)
+
+ xlabel = self.plot.getGraphXLabel()
+ ylabel = self.plot.getGraphYLabel()
+
+ return save_NXdata(
+ filename,
+ signal=z,
+ axes=[x, y],
+ signal_name="values",
+ axes_names=["x", "y"],
+ axes_long_names=[xlabel, ylabel],
+ axes_errors=[xerror, yerror],
+ title=self.plot.getGraphTitle())
+
def _actionTriggered(self, checked=False):
"""Handle save action."""
# Set-up filters
@@ -359,6 +446,11 @@ class SaveAction(PlotAction):
if len(self.plot.getAllCurves()) > 1:
filters.extend(self.ALL_CURVES_FILTERS)
+ # Add scatter filters if there is a scatter
+ # todo: CSV
+ if self.plot.getScatter() is not None:
+ filters.extend(self.SCATTER_FILTERS)
+
filters.extend(self.SNAPSHOT_FILTERS)
# Create and run File dialog
@@ -378,10 +470,19 @@ class SaveAction(PlotAction):
dialog.close()
# Forces the filename extension to match the chosen filter
- extension = nameFilter.split()[-1][2:-1]
- if (len(filename) <= len(extension) or
- filename[-len(extension):].lower() != extension.lower()):
- filename += extension
+ if "NXdata" in nameFilter:
+ has_allowed_ext = False
+ for ext in _NEXUS_HDF5_EXT:
+ if (len(filename) > len(ext) and
+ filename[-len(ext):].lower() == ext.lower()):
+ has_allowed_ext = True
+ if not has_allowed_ext:
+ filename += ".h5"
+ else:
+ default_extension = nameFilter.split()[-1][2:-1]
+ if (len(filename) <= len(default_extension) or
+ filename[-len(default_extension):].lower() != default_extension.lower()):
+ filename += default_extension
# Handle save
if nameFilter in self.SNAPSHOT_FILTERS:
@@ -392,6 +493,8 @@ class SaveAction(PlotAction):
return self._saveCurves(filename, nameFilter)
elif nameFilter in self.IMAGE_FILTERS:
return self._saveImage(filename, nameFilter)
+ elif nameFilter in self.SCATTER_FILTERS:
+ return self._saveScatter(filename, nameFilter)
else:
_logger.warning('Unsupported file filter: %s', nameFilter)
return False
diff --git a/silx/gui/plot/actions/medfilt.py b/silx/gui/plot/actions/medfilt.py
index 3305d1b..4284a8b 100644
--- a/silx/gui/plot/actions/medfilt.py
+++ b/silx/gui/plot/actions/medfilt.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -39,7 +39,7 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "24/05/2017"
+__date__ = "03/01/2018"
from . import PlotAction
from silx.gui.widgets.MedianFilterDialog import MedianFilterDialog
@@ -67,7 +67,7 @@ class MedianFilterAction(PlotAction):
self._originalImage = None
self._legend = None
self._filteredImage = None
- self._popup = MedianFilterDialog(parent=None)
+ self._popup = MedianFilterDialog(parent=plot)
self._popup.sigFilterOptChanged.connect(self._updateFilter)
self.plot.sigActiveImageChanged.connect(self._updateActiveImage)
self._updateActiveImage()
@@ -101,7 +101,7 @@ class MedianFilterAction(PlotAction):
self.plot.sigActiveImageChanged.connect(self._updateActiveImage)
def _computeFilteredImage(self, kernelWidth, conditional):
- raise NotImplemented('MedianFilterAction is a an abstract class')
+ raise NotImplementedError('MedianFilterAction is a an abstract class')
def getFilteredImage(self):
"""
diff --git a/silx/gui/plot/backends/BackendBase.py b/silx/gui/plot/backends/BackendBase.py
index 12561b2..45bf785 100644
--- a/silx/gui/plot/backends/BackendBase.py
+++ b/silx/gui/plot/backends/BackendBase.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -189,7 +189,7 @@ class BackendBase(object):
def addMarker(self, x, y, legend, text, color,
selectable, draggable,
- symbol, constraint, overlay):
+ symbol, constraint):
"""Add a point, vertical line or horizontal line marker to the plot.
:param float x: Horizontal position of the marker in graph coordinates.
@@ -221,9 +221,6 @@ class BackendBase(object):
:type constraint: None or a callable that takes the coordinates of
the current cursor position in the plot as input
and that returns the filtered coordinates.
- :param bool overlay: True if marker is an overlay (Default: False).
- This allows for rendering optimization if this
- marker is changed often.
:return: Handle used by the backend to univocally access the marker
"""
return legend
@@ -270,11 +267,13 @@ class BackendBase(object):
"""
pass
- def pickItems(self, x, y):
+ def pickItems(self, x, y, kinds):
"""Get a list of items at a pixel position.
:param float x: The x pixel coord where to pick.
:param float y: The y pixel coord where to pick.
+ :param List[str] kind: List of item kinds to pick.
+ Supported kinds: 'marker', 'curve', 'image'.
:return: All picked items from back to front.
One dict per item,
with 'kind' key in 'curve', 'marker', 'image';
diff --git a/silx/gui/plot/backends/BackendMatplotlib.py b/silx/gui/plot/backends/BackendMatplotlib.py
index b41f20e..f9a1fe5 100644
--- a/silx/gui/plot/backends/BackendMatplotlib.py
+++ b/silx/gui/plot/backends/BackendMatplotlib.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -58,6 +58,59 @@ from . import BackendBase
from .._utils import FLOAT32_MINPOS
+class _MarkerContainer(Container):
+ """Marker artists container supporting draw/remove and text position update
+
+ :param artists:
+ Iterable with either one Line2D or a Line2D and a Text.
+ The use of an iterable if enforced by Container being
+ a subclass of tuple that defines a specific __new__.
+ :param x: X coordinate of the marker (None for horizontal lines)
+ :param y: Y coordinate of the marker (None for vertical lines)
+ """
+
+ def __init__(self, artists, x, y):
+ self.line = artists[0]
+ self.text = artists[1] if len(artists) > 1 else None
+ self.x = x
+ self.y = y
+
+ Container.__init__(self, artists)
+
+ def draw(self, *args, **kwargs):
+ """artist-like draw to broadcast draw to line and text"""
+ self.line.draw(*args, **kwargs)
+ if self.text is not None:
+ self.text.draw(*args, **kwargs)
+
+ def updateMarkerText(self, xmin, xmax, ymin, ymax):
+ """Update marker text position and visibility according to plot limits
+
+ :param xmin: X axis lower limit
+ :param xmax: X axis upper limit
+ :param ymin: Y axis lower limit
+ :param ymax: Y axis upprt limit
+ """
+ if self.text is not None:
+ visible = ((self.x is None or xmin <= self.x <= xmax) and
+ (self.y is None or ymin <= self.y <= ymax))
+ self.text.set_visible(visible)
+
+ if self.x is not None and self.y is None: # vertical line
+ delta = abs(ymax - ymin)
+ if ymin > ymax:
+ ymax = ymin
+ ymax -= 0.005 * delta
+ self.text.set_y(ymax)
+
+ if self.x is None and self.y is not None: # Horizontal line
+ delta = abs(xmax - xmin)
+ if xmin > xmax:
+ xmax = xmin
+ xmax -= 0.005 * delta
+ self.text.set_x(xmax)
+
+
class BackendMatplotlib(BackendBase.BackendBase):
"""Base class for Matplotlib backend without a FigureCanvas.
@@ -356,10 +409,13 @@ class BackendMatplotlib(BackendBase.BackendBase):
self.ax.add_patch(item)
elif shape in ('polygon', 'polylines'):
- xView = xView.reshape(1, -1)
- yView = yView.reshape(1, -1)
- item = Polygon(numpy.vstack((xView, yView)).T,
- closed=(shape == 'polygon'),
+ points = numpy.array((xView, yView)).T
+ if shape == 'polygon':
+ closed = True
+ else: # shape == 'polylines'
+ closed = numpy.all(numpy.equal(points[0], points[-1]))
+ item = Polygon(points,
+ closed=closed,
fill=False,
label=legend,
color=color)
@@ -381,9 +437,14 @@ class BackendMatplotlib(BackendBase.BackendBase):
def addMarker(self, x, y, legend, text, color,
selectable, draggable,
- symbol, constraint, overlay):
+ symbol, constraint):
legend = "__MARKER__" + legend
+ textArtist = None
+
+ xmin, xmax = self.getGraphXLimits()
+ ymin, ymax = self.getGraphYLimits(axis='left')
+
if x is not None and y is not None:
line = self.ax.plot(x, y, label=legend,
linestyle=" ",
@@ -392,49 +453,35 @@ class BackendMatplotlib(BackendBase.BackendBase):
markersize=10.)[-1]
if text is not None:
- xtmp, ytmp = self.ax.transData.transform_point((x, y))
- inv = self.ax.transData.inverted()
- xtmp, ytmp = inv.transform_point((xtmp, ytmp))
-
if symbol is None:
valign = 'baseline'
else:
valign = 'top'
text = " " + text
- line._infoText = self.ax.text(x, ytmp, text,
- color=color,
- horizontalalignment='left',
- verticalalignment=valign)
+ textArtist = self.ax.text(x, y, text,
+ color=color,
+ horizontalalignment='left',
+ verticalalignment=valign)
elif x is not None:
line = self.ax.axvline(x, label=legend, color=color)
if text is not None:
- text = " " + text
- ymin, ymax = self.getGraphYLimits(axis='left')
- delta = abs(ymax - ymin)
- if ymin > ymax:
- ymax = ymin
- ymax -= 0.005 * delta
- line._infoText = self.ax.text(x, ymax, text,
- color=color,
- horizontalalignment='left',
- verticalalignment='top')
+ # Y position will be updated in updateMarkerText call
+ textArtist = self.ax.text(x, 1., " " + text,
+ color=color,
+ horizontalalignment='left',
+ verticalalignment='top')
elif y is not None:
line = self.ax.axhline(y, label=legend, color=color)
if text is not None:
- text = " " + text
- xmin, xmax = self.getGraphXLimits()
- delta = abs(xmax - xmin)
- if xmin > xmax:
- xmax = xmin
- xmax -= 0.005 * delta
- line._infoText = self.ax.text(xmax, y, text,
- color=color,
- horizontalalignment='right',
- verticalalignment='top')
+ # X position will be updated in updateMarkerText call
+ textArtist = self.ax.text(1., y, " " + text,
+ color=color,
+ horizontalalignment='right',
+ verticalalignment='top')
else:
raise RuntimeError('A marker must at least have one coordinate')
@@ -442,19 +489,29 @@ class BackendMatplotlib(BackendBase.BackendBase):
if selectable or draggable:
line.set_picker(5)
- if overlay:
- line.set_animated(True)
- self._overlays.add(line)
+ # All markers are overlays
+ line.set_animated(True)
+ if textArtist is not None:
+ textArtist.set_animated(True)
+
+ artists = [line] if textArtist is None else [line, textArtist]
+ container = _MarkerContainer(artists, x, y)
+ container.updateMarkerText(xmin, xmax, ymin, ymax)
+ self._overlays.add(container)
- return line
+ return container
+
+ def _updateMarkers(self):
+ xmin, xmax = self.ax.get_xbound()
+ ymin, ymax = self.ax.get_ybound()
+ for item in self._overlays:
+ if isinstance(item, _MarkerContainer):
+ item.updateMarkerText(xmin, xmax, ymin, ymax)
# Remove methods
def remove(self, item):
# Warning: It also needs to remove extra stuff if added as for markers
- if hasattr(item, "_infoText"): # For markers text
- item._infoText.remove()
- item._infoText = None
self._overlays.discard(item)
try:
item.remove()
@@ -562,6 +619,8 @@ class BackendMatplotlib(BackendBase.BackendBase):
else:
self.ax.set_ylim(max(ymin, ymax), min(ymin, ymax))
+ self._updateMarkers()
+
def getGraphXLimits(self):
if self._dirtyLimits and self.isKeepDataAspectRatio():
self.replot() # makes sure we get the right limits
@@ -570,6 +629,7 @@ class BackendMatplotlib(BackendBase.BackendBase):
def setGraphXLimits(self, xmin, xmax):
self._dirtyLimits = True
self.ax.set_xlim(min(xmin, xmax), max(xmin, xmax))
+ self._updateMarkers()
def getGraphYLimits(self, axis):
assert axis in ('left', 'right')
@@ -607,6 +667,8 @@ class BackendMatplotlib(BackendBase.BackendBase):
else:
ax.set_ylim(ymax, ymin)
+ self._updateMarkers()
+
# Graph axes
def setXAxisLogarithmic(self, flag):
@@ -814,7 +876,7 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
self._picked.append({'kind': 'curve', 'legend': label,
'indices': event.ind})
- def pickItems(self, x, y):
+ def pickItems(self, x, y, kinds):
self._picked = []
# Weird way to do an explicit picking: Simulate a button press event
@@ -822,7 +884,8 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
cid = self.mpl_connect('pick_event', self._onPick)
self.fig.pick(mouseEvent)
self.mpl_disconnect(cid)
- picked = self._picked
+
+ picked = [p for p in self._picked if p['kind'] in kinds]
self._picked = None
return picked
@@ -882,6 +945,10 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
xLimits, yLimits, yRightLimits = self._limitsBeforeResize
self._limitsBeforeResize = None
+ if (xLimits != self.ax.get_xbound() or
+ yLimits != self.ax.get_ybound()):
+ self._updateMarkers()
+
if xLimits != self.ax.get_xbound():
self._plot.getXAxis()._emitLimitsChanged()
if yLimits != self.ax.get_ybound():
@@ -889,6 +956,7 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
if yRightLimits != self.ax2.get_ybound():
self._plot.getYAxis(axis='right')._emitLimitsChanged()
+
self._drawOverlays()
def replot(self):
diff --git a/silx/gui/plot/backends/BackendOpenGL.py b/silx/gui/plot/backends/BackendOpenGL.py
index c70b03a..3c18f4f 100644
--- a/silx/gui/plot/backends/BackendOpenGL.py
+++ b/silx/gui/plot/backends/BackendOpenGL.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2014-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2014-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -892,11 +892,13 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
for item in self._items.values():
shape2D = item.get('_shape2D')
if shape2D is None:
+ closed = item['shape'] != 'polylines'
shape2D = Shape2D(tuple(zip(item['x'], item['y'])),
fill=item['fill'],
fillColor=item['color'],
stroke=True,
- strokeColor=item['color'])
+ strokeColor=item['color'],
+ strokeClosed=closed)
item['_shape2D'] = shape2D
if ((isXLog and shape2D.xMin < FLOAT32_MINPOS) or
@@ -1032,17 +1034,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
data = numpy.array(data, dtype=numpy.float32, order='C')
colormapIsLog = colormap.getNormalization() == 'log'
-
cmapRange = colormap.getColormapRange(data=data)
-
- # Retrieve colormap LUT from name and color array
- colormapDisp = Colormap(name=colormap.getName(),
- normalization=Colormap.LINEAR,
- vmin=0,
- vmax=255,
- colors=colormap.getColormapLUT())
- colormapLut = colormapDisp.applyToData(
- numpy.arange(256, dtype=numpy.uint8))
+ colormapLut = colormap.getNColors(nbColors=256)
image = GLPlotColormap(data,
origin,
@@ -1087,7 +1080,8 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
def addItem(self, x, y, legend, shape, color, fill, overlay, z):
# TODO handle overlay
- if shape not in ('polygon', 'rectangle', 'line', 'vline', 'hline'):
+ if shape not in ('polygon', 'rectangle', 'line',
+ 'vline', 'hline', 'polylines'):
raise NotImplementedError("Unsupported shape {0}".format(shape))
x = numpy.array(x, copy=False)
@@ -1107,6 +1101,9 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
raise RuntimeError(
'Cannot add item with Y <= 0 with Y axis log scale')
+ # Ignore fill for polylines to mimic matplotlib
+ fill = fill if shape != 'polylines' else False
+
self._items[legend] = {
'shape': shape,
'color': Colors.rgba(color),
@@ -1119,8 +1116,7 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
def addMarker(self, x, y, legend, text, color,
selectable, draggable,
- symbol, constraint, overlay):
- # TODO handle overlay
+ symbol, constraint):
if symbol is None:
symbol = '+'
@@ -1227,90 +1223,93 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
self._plotFrame.size[1] - self._plotFrame.margins.bottom - 1)
return xPlot, yPlot
- def pickItems(self, x, y):
+ def pickItems(self, x, y, kinds):
picked = []
dataPos = self.pixelToData(x, y, axis='left', check=True)
if dataPos is not None:
# Pick markers
- for marker in reversed(list(self._markers.values())):
- pixelPos = self.dataToPixel(
- marker['x'], marker['y'], axis='left', check=False)
- if pixelPos is None: # negative coord on a log axis
- continue
-
- if marker['x'] is None: # Horizontal line
- pt1 = self.pixelToData(
- x, y - self._PICK_OFFSET, axis='left', check=False)
- pt2 = self.pixelToData(
- x, y + self._PICK_OFFSET, axis='left', check=False)
- isPicked = (min(pt1[1], pt2[1]) <= marker['y'] <=
- max(pt1[1], pt2[1]))
-
- elif marker['y'] is None: # Vertical line
- pt1 = self.pixelToData(
- x - self._PICK_OFFSET, y, axis='left', check=False)
- pt2 = self.pixelToData(
- x + self._PICK_OFFSET, y, axis='left', check=False)
- isPicked = (min(pt1[0], pt2[0]) <= marker['x'] <=
- max(pt1[0], pt2[0]))
-
- else:
- isPicked = (
- numpy.fabs(x - pixelPos[0]) <= self._PICK_OFFSET and
- numpy.fabs(y - pixelPos[1]) <= self._PICK_OFFSET)
-
- if isPicked:
- picked.append(dict(kind='marker',
- legend=marker['legend']))
-
- # Pick image and curves
- for item in self._plotContent.zOrderedPrimitives(reverse=True):
- if isinstance(item, (GLPlotColormap, GLPlotRGBAImage)):
- pickedPos = item.pick(*dataPos)
- if pickedPos is not None:
- picked.append(dict(kind='image',
- legend=item.info['legend']))
-
- elif isinstance(item, GLPlotCurve2D):
- offset = self._PICK_OFFSET
- if item.marker is not None:
- offset = max(item.markerSize / 2., offset)
- if item.lineStyle is not None:
- offset = max(item.lineWidth / 2., offset)
-
- yAxis = item.info['yAxis']
-
- inAreaPos = self._mouseInPlotArea(x - offset, y - offset)
- dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1],
- axis=yAxis, check=True)
- if dataPos is None:
+ if 'marker' in kinds:
+ for marker in reversed(list(self._markers.values())):
+ pixelPos = self.dataToPixel(
+ marker['x'], marker['y'], axis='left', check=False)
+ if pixelPos is None: # negative coord on a log axis
continue
- xPick0, yPick0 = dataPos
- inAreaPos = self._mouseInPlotArea(x + offset, y + offset)
- dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1],
- axis=yAxis, check=True)
- if dataPos is None:
- continue
- xPick1, yPick1 = dataPos
+ if marker['x'] is None: # Horizontal line
+ pt1 = self.pixelToData(
+ x, y - self._PICK_OFFSET, axis='left', check=False)
+ pt2 = self.pixelToData(
+ x, y + self._PICK_OFFSET, axis='left', check=False)
+ isPicked = (min(pt1[1], pt2[1]) <= marker['y'] <=
+ max(pt1[1], pt2[1]))
+
+ elif marker['y'] is None: # Vertical line
+ pt1 = self.pixelToData(
+ x - self._PICK_OFFSET, y, axis='left', check=False)
+ pt2 = self.pixelToData(
+ x + self._PICK_OFFSET, y, axis='left', check=False)
+ isPicked = (min(pt1[0], pt2[0]) <= marker['x'] <=
+ max(pt1[0], pt2[0]))
- if xPick0 < xPick1:
- xPickMin, xPickMax = xPick0, xPick1
else:
- xPickMin, xPickMax = xPick1, xPick0
+ isPicked = (
+ numpy.fabs(x - pixelPos[0]) <= self._PICK_OFFSET and
+ numpy.fabs(y - pixelPos[1]) <= self._PICK_OFFSET)
- if yPick0 < yPick1:
- yPickMin, yPickMax = yPick0, yPick1
- else:
- yPickMin, yPickMax = yPick1, yPick0
-
- pickedIndices = item.pick(xPickMin, yPickMin,
- xPickMax, yPickMax)
- if pickedIndices:
- picked.append(dict(kind='curve',
- legend=item.info['legend'],
- indices=pickedIndices))
+ if isPicked:
+ picked.append(dict(kind='marker',
+ legend=marker['legend']))
+
+ # Pick image and curves
+ if 'image' in kinds or 'curve' in kinds:
+ for item in self._plotContent.zOrderedPrimitives(reverse=True):
+ if ('image' in kinds and
+ isinstance(item, (GLPlotColormap, GLPlotRGBAImage))):
+ pickedPos = item.pick(*dataPos)
+ if pickedPos is not None:
+ picked.append(dict(kind='image',
+ legend=item.info['legend']))
+
+ elif 'curve' in kinds and isinstance(item, GLPlotCurve2D):
+ offset = self._PICK_OFFSET
+ if item.marker is not None:
+ offset = max(item.markerSize / 2., offset)
+ if item.lineStyle is not None:
+ offset = max(item.lineWidth / 2., offset)
+
+ yAxis = item.info['yAxis']
+
+ inAreaPos = self._mouseInPlotArea(x - offset, y - offset)
+ dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1],
+ axis=yAxis, check=True)
+ if dataPos is None:
+ continue
+ xPick0, yPick0 = dataPos
+
+ inAreaPos = self._mouseInPlotArea(x + offset, y + offset)
+ dataPos = self.pixelToData(inAreaPos[0], inAreaPos[1],
+ axis=yAxis, check=True)
+ if dataPos is None:
+ continue
+ xPick1, yPick1 = dataPos
+
+ if xPick0 < xPick1:
+ xPickMin, xPickMax = xPick0, xPick1
+ else:
+ xPickMin, xPickMax = xPick1, xPick0
+
+ if yPick0 < yPick1:
+ yPickMin, yPickMax = yPick0, yPick1
+ else:
+ yPickMin, yPickMax = yPick1, yPick0
+
+ pickedIndices = item.pick(xPickMin, yPickMin,
+ xPickMax, yPickMax)
+ if pickedIndices:
+ picked.append(dict(kind='curve',
+ legend=item.info['legend'],
+ indices=pickedIndices))
return picked
diff --git a/silx/gui/plot/backends/glutils/GLPlotCurve.py b/silx/gui/plot/backends/glutils/GLPlotCurve.py
index 4433613..124a3da 100644
--- a/silx/gui/plot/backends/glutils/GLPlotCurve.py
+++ b/silx/gui/plot/backends/glutils/GLPlotCurve.py
@@ -606,7 +606,7 @@ class _Points2D(object):
""",
ASTERISK: """
float alphaSymbol(vec2 coord, float size) {
- /* Combining +, x and cirle */
+ /* Combining +, x and circle */
vec2 d_plus = abs(size * (coord - vec2(0.5, 0.5)));
vec2 pos = floor(size * coord) + 0.5;
vec2 d_x = abs(pos.x + vec2(- pos.y, pos.y - size));
diff --git a/silx/gui/plot/backends/glutils/PlotImageFile.py b/silx/gui/plot/backends/glutils/PlotImageFile.py
index f028ee8..83c7ae0 100644
--- a/silx/gui/plot/backends/glutils/PlotImageFile.py
+++ b/silx/gui/plot/backends/glutils/PlotImageFile.py
@@ -93,7 +93,7 @@ def saveImageToFile(data, fileNameOrObj, fileFormat):
assert fileFormat in ('png', 'ppm', 'svg', 'tiff')
if not hasattr(fileNameOrObj, 'write'):
- if sys.version < "3.0":
+ if sys.version_info < (3, ):
fileObj = open(fileNameOrObj, "wb")
else:
if fileFormat in ('png', 'ppm', 'tiff'):
diff --git a/silx/gui/plot/items/__init__.py b/silx/gui/plot/items/__init__.py
index bf39c87..e7957ac 100644
--- a/silx/gui/plot/items/__init__.py
+++ b/silx/gui/plot/items/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -35,6 +35,7 @@ __date__ = "22/06/2017"
from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, # noqa
SymbolMixIn, ColorMixIn, YAxisMixIn, FillMixIn, # noqa
AlphaMixIn, LineMixIn, ItemChangedType) # noqa
+from .complex import ImageComplexData # noqa
from .curve import Curve # noqa
from .histogram import Histogram # noqa
from .image import ImageBase, ImageData, ImageRgba, MaskImageData # noqa
@@ -42,3 +43,7 @@ from .shape import Shape # noqa
from .scatter import Scatter # noqa
from .marker import Marker, XMarker, YMarker # noqa
from .axis import Axis, XAxis, YAxis, YRightAxis
+
+DATA_ITEMS = ImageComplexData, Curve, Histogram, ImageBase, Scatter
+"""Classes of items representing data and to consider to compute data bounds.
+"""
diff --git a/silx/gui/plot/items/axis.py b/silx/gui/plot/items/axis.py
index ff36512..d7e6eff 100644
--- a/silx/gui/plot/items/axis.py
+++ b/silx/gui/plot/items/axis.py
@@ -27,7 +27,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "30/08/2017"
+__date__ = "06/12/2017"
import logging
from ... import qt
@@ -66,7 +66,7 @@ class Axis(qt.QObject):
"""Signal emitted when axis autoscale has changed"""
sigLimitsChanged = qt.Signal(float, float)
- """Signal emitted when axis autoscale has changed"""
+ """Signal emitted when axis limits have changed"""
def __init__(self, plot):
"""Constructor
@@ -262,7 +262,7 @@ class Axis(qt.QObject):
def setLimitsConstraints(self, minPos=None, maxPos=None):
"""
- Set a constaints on the position of the axes.
+ Set a constraint on the position of the axes.
:param float minPos: Minimum allowed axis value.
:param float maxPos: Maximum allowed axis value.
@@ -283,7 +283,7 @@ class Axis(qt.QObject):
def setRangeConstraints(self, minRange=None, maxRange=None):
"""
- Set a constaints on the position of the axes.
+ Set a constraint on the position of the axes.
:param float minRange: Minimum allowed left-to-right span across the
view
diff --git a/silx/gui/plot/items/complex.py b/silx/gui/plot/items/complex.py
new file mode 100644
index 0000000..ba57e85
--- /dev/null
+++ b/silx/gui/plot/items/complex.py
@@ -0,0 +1,356 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides the :class:`ImageComplexData` of the :class:`Plot`.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["Vincent Favre-Nicolin", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "19/01/2018"
+
+
+import logging
+import numpy
+
+from silx.third_party import enum
+
+from ..Colormap import Colormap
+from .core import ColormapMixIn, ItemChangedType
+from .image import ImageBase
+
+
+_logger = logging.getLogger(__name__)
+
+
+# Complex colormap functions
+
+def _phase2rgb(colormap, data):
+ """Creates RGBA image with colour-coded phase.
+
+ :param Colormap colormap: The colormap to use
+ :param numpy.ndarray data: The data to convert
+ :return: Array of RGBA colors
+ :rtype: numpy.ndarray
+ """
+ if data.size == 0:
+ return numpy.zeros((0, 0, 4), dtype=numpy.uint8)
+
+ phase = numpy.angle(data)
+ return colormap.applyToData(phase)
+
+
+def _complex2rgbalog(phaseColormap, data, amin=0., dlogs=2, smax=None):
+ """Returns RGBA colors: colour-coded phases and log10(amplitude) in alpha.
+
+ :param Colormap phaseColormap: Colormap to use for the phase
+ :param numpy.ndarray data: the complex data array to convert to RGBA
+ :param float amin: the minimum value for the alpha channel
+ :param float dlogs: amplitude range displayed, in log10 units
+ :param float smax:
+ if specified, all values above max will be displayed with an alpha=1
+ """
+ if data.size == 0:
+ return numpy.zeros((0, 0, 4), dtype=numpy.uint8)
+
+ rgba = _phase2rgb(phaseColormap, data)
+ sabs = numpy.absolute(data)
+ if smax is not None:
+ sabs[sabs > smax] = smax
+ a = numpy.log10(sabs + 1e-20)
+ a -= a.max() - dlogs # display dlogs orders of magnitude
+ rgba[..., 3] = 255 * (amin + a / dlogs * (1 - amin) * (a > 0))
+ return rgba
+
+
+def _complex2rgbalin(phaseColormap, data, gamma=1.0, smax=None):
+ """Returns RGBA colors: colour-coded phase and linear amplitude in alpha.
+
+ :param Colormap phaseColormap: Colormap to use for the phase
+ :param numpy.ndarray data:
+ :param float gamma: Optional exponent gamma applied to the amplitude
+ :param float smax:
+ """
+ if data.size == 0:
+ return numpy.zeros((0, 0, 4), dtype=numpy.uint8)
+
+ rgba = _phase2rgb(phaseColormap, data)
+ a = numpy.absolute(data)
+ if smax is not None:
+ a[a > smax] = smax
+ a /= a.max()
+ rgba[..., 3] = 255 * a**gamma
+ return rgba
+
+
+class ImageComplexData(ImageBase, ColormapMixIn):
+ """Specific plot item to force colormap when using complex colormap.
+
+ This is returning the specific colormap when displaying
+ colored phase + amplitude.
+ """
+
+ class Mode(enum.Enum):
+ """Identify available display mode for complex"""
+ ABSOLUTE = 'absolute'
+ PHASE = 'phase'
+ REAL = 'real'
+ IMAGINARY = 'imaginary'
+ AMPLITUDE_PHASE = 'amplitude_phase'
+ LOG10_AMPLITUDE_PHASE = 'log10_amplitude_phase'
+ SQUARE_AMPLITUDE = 'square_amplitude'
+
+ def __init__(self):
+ ImageBase.__init__(self)
+ ColormapMixIn.__init__(self)
+ self._data = numpy.zeros((0, 0), dtype=numpy.complex64)
+ self._dataByModesCache = {}
+ self._mode = self.Mode.ABSOLUTE
+ self._amplitudeRangeInfo = None, 2
+
+ # Use default from ColormapMixIn
+ colormap = super(ImageComplexData, self).getColormap()
+
+ phaseColormap = Colormap(
+ name='hsv',
+ vmin=-numpy.pi,
+ vmax=numpy.pi)
+ phaseColormap.setEditable(False)
+
+ self._colormaps = { # Default colormaps for all modes
+ self.Mode.ABSOLUTE: colormap,
+ self.Mode.PHASE: phaseColormap,
+ self.Mode.REAL: colormap,
+ self.Mode.IMAGINARY: colormap,
+ self.Mode.AMPLITUDE_PHASE: phaseColormap,
+ self.Mode.LOG10_AMPLITUDE_PHASE: phaseColormap,
+ self.Mode.SQUARE_AMPLITUDE: colormap,
+ }
+
+ def _addBackendRenderer(self, backend):
+ """Update backend renderer"""
+ plot = self.getPlot()
+ assert plot is not None
+ if not self._isPlotLinear(plot):
+ # Do not render with non linear scales
+ return None
+
+ mode = self.getVisualizationMode()
+ if mode in (self.Mode.AMPLITUDE_PHASE,
+ self.Mode.LOG10_AMPLITUDE_PHASE):
+ # For those modes, compute RGBA image here
+ colormap = None
+ data = self.getRgbaImageData(copy=False)
+ else:
+ colormap = self.getColormap()
+ data = self.getData(copy=False)
+
+ if data.size == 0:
+ return None # No data to display
+
+ return backend.addImage(data,
+ legend=self.getLegend(),
+ origin=self.getOrigin(),
+ scale=self.getScale(),
+ z=self.getZValue(),
+ selectable=self.isSelectable(),
+ draggable=self.isDraggable(),
+ colormap=colormap,
+ alpha=self.getAlpha())
+
+
+ def setVisualizationMode(self, mode):
+ """Set the visualization mode to use.
+
+ :param Mode mode:
+ """
+ assert isinstance(mode, self.Mode)
+ assert mode in self._colormaps
+
+ if mode != self._mode:
+ self._mode = mode
+
+ self._updated(ItemChangedType.VISUALIZATION_MODE)
+
+ # Send data updated as value returned by getData has changed
+ self._updated(ItemChangedType.DATA)
+
+ # Update ColormapMixIn colormap
+ colormap = self._colormaps[self._mode]
+ if colormap is not super(ImageComplexData, self).getColormap():
+ super(ImageComplexData, self).setColormap(colormap)
+
+ def getVisualizationMode(self):
+ """Returns the visualization mode in use.
+
+ :rtype: Mode
+ """
+ return self._mode
+
+ def _setAmplitudeRangeInfo(self, max_=None, delta=2):
+ """Set the amplitude range to display for 'log10_amplitude_phase' mode.
+
+ :param max_: Max of the amplitude range.
+ If None it autoscales to data max.
+ :param float delta: Delta range in log10 to display
+ """
+ self._amplitudeRangeInfo = max_, float(delta)
+ self._updated(ItemChangedType.VISUALIZATION_MODE)
+
+ def _getAmplitudeRangeInfo(self):
+ """Returns the amplitude range to use for 'log10_amplitude_phase' mode.
+
+ :return: (max, delta), if max is None, then it autoscales to data max
+ :rtype: 2-tuple"""
+ return self._amplitudeRangeInfo
+
+ def setColormap(self, colormap, mode=None):
+ """Set the colormap for this specific mode.
+
+ :param ~silx.gui.plot.Colormap.Colormap colormap: The colormap
+ :param Mode mode:
+ If specified, set the colormap of this specific mode.
+ Default: current mode.
+ """
+ if mode is None:
+ mode = self.getVisualizationMode()
+
+ self._colormaps[mode] = colormap
+ if mode is self.getVisualizationMode():
+ super(ImageComplexData, self).setColormap(colormap)
+ else:
+ self._updated(ItemChangedType.COLORMAP)
+
+ def getColormap(self, mode=None):
+ """Get the colormap for the (current) mode.
+
+ :param Mode mode:
+ If specified, get the colormap of this specific mode.
+ Default: current mode.
+ :rtype: ~silx.gui.plot.Colormap.Colormap
+ """
+ if mode is None:
+ mode = self.getVisualizationMode()
+
+ return self._colormaps[mode]
+
+ def setData(self, data, copy=True):
+ """"Set the image complex data
+
+ :param numpy.ndarray data: 2D array of complex with 2 dimensions (h, w)
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ """
+ data = numpy.array(data, copy=copy)
+ assert data.ndim == 2
+ if not numpy.issubdtype(data.dtype, numpy.complexfloating):
+ _logger.warning(
+ 'Image is not complex, converting it to complex to plot it.')
+ data = numpy.array(data, dtype=numpy.complex64)
+
+ self._data = data
+ self._dataByModesCache = {}
+
+ # TODO hackish data range implementation
+ if self.isVisible():
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
+
+ self._updated(ItemChangedType.DATA)
+
+ def getComplexData(self, copy=True):
+ """Returns the image complex data
+
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :rtype: numpy.ndarray of complex
+ """
+ return numpy.array(self._data, copy=copy)
+
+ def getData(self, copy=True, mode=None):
+ """Returns the image data corresponding to (current) mode.
+
+ The returned data is always floats, to get the complex data, use
+ :meth:`getComplexData`.
+
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :param Mode mode:
+ If specified, get data corresponding to the mode.
+ Default: Current mode.
+ :rtype: numpy.ndarray of float
+ """
+ if mode is None:
+ mode = self.getVisualizationMode()
+
+ if mode not in self._dataByModesCache:
+ # Compute data for mode and store it in cache
+ complexData = self.getComplexData(copy=False)
+ if mode is self.Mode.PHASE:
+ data = numpy.angle(complexData)
+ elif mode is self.Mode.REAL:
+ data = numpy.real(complexData)
+ elif mode is self.Mode.IMAGINARY:
+ data = numpy.imag(complexData)
+ elif mode in (self.Mode.ABSOLUTE,
+ self.Mode.LOG10_AMPLITUDE_PHASE,
+ self.Mode.AMPLITUDE_PHASE):
+ data = numpy.absolute(complexData)
+ elif mode is self.Mode.SQUARE_AMPLITUDE:
+ data = numpy.absolute(complexData) ** 2
+ else:
+ _logger.error(
+ 'Unsupported conversion mode: %s, fallback to absolute',
+ str(mode))
+ data = numpy.absolute(complexData)
+
+ self._dataByModesCache[mode] = data
+
+ return numpy.array(self._dataByModesCache[mode], copy=copy)
+
+ def getRgbaImageData(self, copy=True, mode=None):
+ """Get the displayed RGB(A) image for (current) mode
+
+ :param bool copy: Ignored for this class
+ :param Mode mode:
+ If specified, get data corresponding to the mode.
+ Default: Current mode.
+ :rtype: numpy.ndarray of uint8 of shape (height, width, 4)
+ """
+ if mode is None:
+ mode = self.getVisualizationMode()
+
+ colormap = self.getColormap(mode=mode)
+ if mode is self.Mode.AMPLITUDE_PHASE:
+ data = self.getComplexData(copy=False)
+ return _complex2rgbalin(colormap, data)
+ elif mode is self.Mode.LOG10_AMPLITUDE_PHASE:
+ data = self.getComplexData(copy=False)
+ max_, delta = self._getAmplitudeRangeInfo()
+ return _complex2rgbalog(colormap, data, dlogs=delta, smax=max_)
+ else:
+ data = self.getData(copy=False, mode=mode)
+ return colormap.applyToData(data)
diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py
index 34ac700..bcb6dd1 100644
--- a/silx/gui/plot/items/core.py
+++ b/silx/gui/plot/items/core.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -115,6 +115,9 @@ class ItemChangedType(enum.Enum):
OVERLAY = 'overlayChanged'
"""Item's overlay state changed flag."""
+ VISUALIZATION_MODE = 'visualizationModeChanged'
+ """Item's visualization mode changed flag."""
+
class Item(qt.QObject):
"""Description of an item of the plot"""
@@ -136,7 +139,7 @@ class Item(qt.QObject):
"""
def __init__(self):
- super(Item, self).__init__()
+ qt.QObject.__init__(self)
self._dirty = True
self._plotRef = None
self._visible = True
@@ -312,7 +315,24 @@ class Item(qt.QObject):
# Mix-in classes ##############################################################
-class LabelsMixIn(object):
+class ItemMixInBase(qt.QObject):
+ """Base class for Item mix-in"""
+
+ def _updated(self, event=None, checkVisibility=True):
+ """This is implemented in :class:`Item`.
+
+ Mark the item as dirty (i.e., needing update).
+ This also triggers Plot.replot.
+
+ :param event: The event to send to :attr:`sigItemChanged` signal.
+ :param bool checkVisibility: True to only mark as dirty if visible,
+ False to always mark as dirty.
+ """
+ raise RuntimeError(
+ "Issue with Mix-In class inheritance order")
+
+
+class LabelsMixIn(ItemMixInBase):
"""Mix-in class for items with x and y labels
Setters are private, otherwise it needs to check the plot
@@ -352,7 +372,7 @@ class LabelsMixIn(object):
self._ylabel = str(label)
-class DraggableMixIn(object):
+class DraggableMixIn(ItemMixInBase):
"""Mix-in class for draggable items"""
def __init__(self):
@@ -375,7 +395,7 @@ class DraggableMixIn(object):
self._draggable = bool(draggable)
-class ColormapMixIn(object):
+class ColormapMixIn(ItemMixInBase):
"""Mix-in class for items with colormap"""
def __init__(self):
@@ -389,7 +409,7 @@ class ColormapMixIn(object):
def setColormap(self, colormap):
"""Set the colormap of this image
- :param Colormap colormap: colormap description
+ :param silx.gui.plot.Colormap.Colormap colormap: colormap description
"""
if isinstance(colormap, dict):
colormap = Colormap._fromDict(colormap)
@@ -406,7 +426,7 @@ class ColormapMixIn(object):
self._updated(ItemChangedType.COLORMAP)
-class SymbolMixIn(object):
+class SymbolMixIn(ItemMixInBase):
"""Mix-in class for items with symbol type"""
_DEFAULT_SYMBOL = ''
@@ -415,10 +435,49 @@ class SymbolMixIn(object):
_DEFAULT_SYMBOL_SIZE = 6.0
"""Default marker size of the item"""
+ _SUPPORTED_SYMBOLS = collections.OrderedDict((
+ ('o', 'Circle'),
+ ('d', 'Diamond'),
+ ('s', 'Square'),
+ ('+', 'Plus'),
+ ('x', 'Cross'),
+ ('.', 'Point'),
+ (',', 'Pixel'),
+ ('', 'None')))
+ """Dict of supported symbols"""
+
def __init__(self):
self._symbol = self._DEFAULT_SYMBOL
self._symbol_size = self._DEFAULT_SYMBOL_SIZE
+ @classmethod
+ def getSupportedSymbols(cls):
+ """Returns the list of supported symbol names.
+
+ :rtype: tuple of str
+ """
+ return tuple(cls._SUPPORTED_SYMBOLS.keys())
+
+ @classmethod
+ def getSupportedSymbolNames(cls):
+ """Returns the list of supported symbol human-readable names.
+
+ :rtype: tuple of str
+ """
+ return tuple(cls._SUPPORTED_SYMBOLS.values())
+
+ def getSymbolName(self, symbol=None):
+ """Returns human-readable name for a symbol.
+
+ :param str symbol: The symbol from which to get the name.
+ Default: current symbol.
+ :rtype: str
+ :raise KeyError: if symbol is not in :meth:`getSupportedSymbols`.
+ """
+ if symbol is None:
+ symbol = self.getSymbol()
+ return self._SUPPORTED_SYMBOLS[symbol]
+
def getSymbol(self):
"""Return the point marker type.
@@ -441,11 +500,19 @@ class SymbolMixIn(object):
See :meth:`getSymbol`.
- :param str symbol: Marker type
+ :param str symbol: Marker type or marker name
"""
- assert symbol in ('o', '.', ',', '+', 'x', 'd', 's', '', None)
if symbol is None:
symbol = self._DEFAULT_SYMBOL
+
+ elif symbol not in self.getSupportedSymbols():
+ for symbolCode, name in self._SUPPORTED_SYMBOLS.items():
+ if name.lower() == symbol.lower():
+ symbol = symbolCode
+ break
+ else:
+ raise ValueError('Unsupported symbol %s' % str(symbol))
+
if symbol != self._symbol:
self._symbol = symbol
self._updated(ItemChangedType.SYMBOL)
@@ -471,7 +538,7 @@ class SymbolMixIn(object):
self._updated(ItemChangedType.SYMBOL_SIZE)
-class LineMixIn(object):
+class LineMixIn(ItemMixInBase):
"""Mix-in class for item with line"""
_DEFAULT_LINEWIDTH = 1.
@@ -531,7 +598,7 @@ class LineMixIn(object):
self._updated(ItemChangedType.LINE_STYLE)
-class ColorMixIn(object):
+class ColorMixIn(ItemMixInBase):
"""Mix-in class for item with color"""
_DEFAULT_COLOR = (0., 0., 0., 1.)
@@ -570,7 +637,7 @@ class ColorMixIn(object):
self._updated(ItemChangedType.COLOR)
-class YAxisMixIn(object):
+class YAxisMixIn(ItemMixInBase):
"""Mix-in class for item with yaxis"""
_DEFAULT_YAXIS = 'left'
@@ -600,7 +667,7 @@ class YAxisMixIn(object):
self._updated(ItemChangedType.YAXIS)
-class FillMixIn(object):
+class FillMixIn(ItemMixInBase):
"""Mix-in class for item with fill"""
def __init__(self):
@@ -624,7 +691,7 @@ class FillMixIn(object):
self._updated(ItemChangedType.FILL)
-class AlphaMixIn(object):
+class AlphaMixIn(ItemMixInBase):
"""Mix-in class for item with opacity"""
def __init__(self):
diff --git a/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py
index acf7bf6..99a916a 100644
--- a/silx/gui/plot/items/image.py
+++ b/silx/gui/plot/items/image.py
@@ -28,7 +28,7 @@ of the :class:`Plot`.
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "27/06/2017"
+__date__ = "20/10/2017"
from collections import Sequence
@@ -38,7 +38,6 @@ import numpy
from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn,
AlphaMixIn, ItemChangedType)
-from ..Colors import applyColormapToData
_logger = logging.getLogger(__name__)
@@ -62,7 +61,7 @@ def _convertImageToRgba32(image, copy=True):
assert image.shape[-1] in (3, 4)
# Convert type to uint8
- if image.dtype.name != 'uin8':
+ if image.dtype.name != 'uint8':
if image.dtype.kind == 'f': # Float in [0, 1]
image = (numpy.clip(image, 0., 1.) * 255).astype(numpy.uint8)
elif image.dtype.kind == 'b': # boolean
@@ -334,7 +333,7 @@ class ImageData(ImageBase, ColormapMixIn):
_logger.warning(
'Converting boolean image to int8 to plot it.')
data = numpy.array(data, copy=False, dtype=numpy.int8)
- elif numpy.issubdtype(data.dtype, numpy.complex):
+ elif numpy.iscomplexobj(data):
_logger.warning(
'Converting complex image to absolute value to plot it.')
data = numpy.absolute(data)
diff --git a/silx/gui/plot/items/marker.py b/silx/gui/plot/items/marker.py
index 5f930b7..8f79033 100644
--- a/silx/gui/plot/items/marker.py
+++ b/silx/gui/plot/items/marker.py
@@ -69,8 +69,7 @@ class _BaseMarker(Item, DraggableMixIn, ColorMixIn):
selectable=self.isSelectable(),
draggable=self.isDraggable(),
symbol=symbol,
- constraint=self.getConstraint(),
- overlay=self.isOverlay())
+ constraint=self.getConstraint())
def isOverlay(self):
"""Return true if marker is drawn as an overlay.
diff --git a/silx/gui/plot/matplotlib/Colormap.py b/silx/gui/plot/matplotlib/Colormap.py
index a86d76e..d035605 100644
--- a/silx/gui/plot/matplotlib/Colormap.py
+++ b/silx/gui/plot/matplotlib/Colormap.py
@@ -168,70 +168,16 @@ def getScalarMappable(colormap, data=None):
colors = colors.astype(numpy.float32) / 255.
cmap = matplotlib.colors.ListedColormap(colors)
+ vmin, vmax = colormap.getColormapRange(data)
if colormap.getNormalization().startswith('log'):
- vmin, vmax = None, None
- if not colormap.isAutoscale():
- if colormap.getVMin() > 0.:
- vmin = colormap.getVMin()
- if colormap.getVMax() > 0.:
- vmax = colormap.getVMax()
-
- if vmin is None or vmax is None:
- _logger.warning('Log colormap with negative bounds, ' +
- 'changing bounds to positive ones.')
- elif vmin > vmax:
- _logger.warning('Colormap bounds are inverted.')
- vmin, vmax = vmax, vmin
-
- # Set unset/negative bounds to positive bounds
- if vmin is None or vmax is None:
- # Convert to numpy array
- data = numpy.array(data if data is not None else [], copy=False)
-
- if data.size > 0:
- finiteData = data[numpy.isfinite(data)]
- posData = finiteData[finiteData > 0]
- if vmax is None:
- # 1. as an ultimate fallback
- vmax = posData.max() if posData.size > 0 else 1.
- if vmin is None:
- vmin = posData.min() if posData.size > 0 else vmax
- if vmin > vmax:
- vmin = vmax
- else:
- vmin, vmax = 1., 1.
-
norm = matplotlib.colors.LogNorm(vmin, vmax)
-
else: # Linear normalization
- if colormap.isAutoscale():
- # Convert to numpy array
- data = numpy.array(data if data is not None else [], copy=False)
-
- if data.size == 0:
- vmin, vmax = 1., 1.
- else:
- finiteData = data[numpy.isfinite(data)]
- if finiteData.size > 0:
- vmin = finiteData.min()
- vmax = finiteData.max()
- else:
- vmin, vmax = 1., 1.
-
- else:
- vmin = colormap.getVMin()
- vmax = colormap.getVMax()
- if vmin > vmax:
- _logger.warning('Colormap bounds are inverted.')
- vmin, vmax = vmax, vmin
-
norm = matplotlib.colors.Normalize(vmin, vmax)
return matplotlib.cm.ScalarMappable(norm=norm, cmap=cmap)
-def applyColormapToData(data,
- colormap):
+def applyColormapToData(data, colormap):
"""Apply a colormap to the data and returns the RGBA image
This supports data of any dimensions (not only of dimension 2).
diff --git a/silx/gui/plot/matplotlib/__init__.py b/silx/gui/plot/matplotlib/__init__.py
index be9cb9a..384d049 100644
--- a/silx/gui/plot/matplotlib/__init__.py
+++ b/silx/gui/plot/matplotlib/__init__.py
@@ -59,6 +59,11 @@ elif qt.BINDING == 'PyQt4':
matplotlib.rcParams['backend'] = 'Qt4Agg'
import matplotlib.backends.backend_qt4agg as backend
+elif qt.BINDING == 'PySide2':
+ matplotlib.rcParams['backend'] = 'Qt5Agg'
+ matplotlib.rcParams['backend.qt5'] = 'PySide2'
+ import matplotlib.backends.backend_qt5agg as backend
+
elif qt.BINDING == 'PyQt5':
matplotlib.rcParams['backend'] = 'Qt5Agg'
import matplotlib.backends.backend_qt5agg as backend
diff --git a/silx/gui/plot/test/__init__.py b/silx/gui/plot/test/__init__.py
index 07338b6..154a70a 100644
--- a/silx/gui/plot/test/__init__.py
+++ b/silx/gui/plot/test/__init__.py
@@ -24,7 +24,7 @@
# ###########################################################################*/
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "04/08/2017"
+__date__ = "28/11/2017"
import unittest
@@ -52,6 +52,7 @@ from . import testUtilsAxis
from . import testLimitConstraints
from . import testComplexImageView
from . import testImageView
+from . import testSaveAction
def suite():
@@ -79,5 +80,6 @@ def suite():
testUtilsAxis.suite(),
testLimitConstraints.suite(),
testComplexImageView.suite(),
- testImageView.suite()])
+ testImageView.suite(),
+ testSaveAction.suite()])
return test_suite
diff --git a/silx/gui/plot/test/testColormap.py b/silx/gui/plot/test/testColormap.py
index aa285d3..4888a7c 100644
--- a/silx/gui/plot/test/testColormap.py
+++ b/silx/gui/plot/test/testColormap.py
@@ -29,12 +29,14 @@ from __future__ import absolute_import
__authors__ = ["H.Payno"]
__license__ = "MIT"
-__date__ = "05/12/2016"
+__date__ = "17/01/2018"
import unittest
import numpy
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
from silx.gui.plot.Colormap import Colormap
+from silx.gui.plot.Colormap import preferredColormaps, setPreferredColormaps
+from silx.utils.exceptions import NotEditableError
class TestDictAPI(unittest.TestCase):
@@ -134,14 +136,11 @@ class TestDictAPI(unittest.TestCase):
'autoscale': False
}
with self.assertRaises(ValueError):
- colormapObject = Colormap._fromDict(clm_dict)
+ Colormap._fromDict(clm_dict)
class TestObjectAPI(ParametricTestCase):
"""Test the new Object API of the colormap"""
- def setUp(self):
- signalHasBeenEmitting = False
-
def testVMinVMax(self):
"""Test getter and setter associated to vmin and vmax values"""
vmin = 1.0
@@ -277,10 +276,73 @@ class TestObjectAPI(ParametricTestCase):
self.assertEqual(image.shape[-1], 4)
self.assertEqual(image.shape[:-1], data.shape)
+ def testGetNColors(self):
+ """Test getNColors method"""
+ # specific LUT
+ colormap = Colormap(name=None,
+ colors=((0, 0, 0), (1, 1, 1)),
+ vmin=1000,
+ vmax=2000)
+ colors = colormap.getNColors()
+ self.assertTrue(numpy.all(numpy.equal(
+ colors,
+ ((0, 0, 0, 255), (255, 255, 255, 255)))))
+
+ def testEditableMode(self):
+ """Make sure the colormap will raise NotEditableError when try to
+ change a colormap not editable"""
+ colormap = Colormap()
+ colormap.setEditable(False)
+ with self.assertRaises(NotEditableError):
+ colormap.setVRange(0., 1.)
+ with self.assertRaises(NotEditableError):
+ colormap.setVMin(1.)
+ with self.assertRaises(NotEditableError):
+ colormap.setVMax(1.)
+ with self.assertRaises(NotEditableError):
+ colormap.setNormalization(Colormap.LOGARITHM)
+ with self.assertRaises(NotEditableError):
+ colormap.setName('magma')
+ with self.assertRaises(NotEditableError):
+ colormap.setColormapLUT(numpy.array([0, 1]))
+ with self.assertRaises(NotEditableError):
+ colormap._setFromDict(colormap._toDict())
+ state = colormap.saveState()
+ with self.assertRaises(NotEditableError):
+ colormap.restoreState(state)
+
+
+class TestPreferredColormaps(unittest.TestCase):
+ """Test get|setPreferredColormaps functions"""
+
+ def setUp(self):
+ # Save preferred colormaps
+ self._colormaps = preferredColormaps()
+
+ def tearDown(self):
+ # Restore saved preferred colormaps
+ setPreferredColormaps(self._colormaps)
+
+ def test(self):
+ colormaps = 'viridis', 'magma'
+
+ setPreferredColormaps(colormaps)
+ self.assertEqual(preferredColormaps(), colormaps)
+
+ with self.assertRaises(ValueError):
+ setPreferredColormaps(())
+
+ with self.assertRaises(ValueError):
+ setPreferredColormaps(('This is not a colormap',))
+
+ colormaps = 'red', 'green'
+ setPreferredColormaps(('This is not a colormap',) + colormaps)
+ self.assertEqual(preferredColormaps(), colormaps)
+
def suite():
test_suite = unittest.TestSuite()
- for ui in (TestDictAPI, TestObjectAPI):
+ for ui in (TestDictAPI, TestObjectAPI, TestPreferredColormaps):
test_suite.addTest(
unittest.defaultTestLoader.loadTestsFromTestCase(ui))
diff --git a/silx/gui/plot/test/testColormapDialog.py b/silx/gui/plot/test/testColormapDialog.py
index d016548..8087369 100644
--- a/silx/gui/plot/test/testColormapDialog.py
+++ b/silx/gui/plot/test/testColormapDialog.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -26,7 +26,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "05/12/2016"
+__date__ = "17/01/2018"
import doctest
@@ -35,6 +35,12 @@ import unittest
from silx.gui.test.utils import qWaitForWindowExposedAndActivate
from silx.gui import qt
from silx.gui.plot import ColormapDialog
+from silx.gui.test.utils import TestCaseQt
+from silx.gui.plot.Colormap import Colormap, preferredColormaps
+from silx.utils.testutils import ParametricTestCase
+from silx.gui.plot.PlotWindow import PlotWindow
+
+import numpy.random
# Makes sure a QApplication exists
@@ -58,9 +64,320 @@ cmapDocTestSuite = doctest.DocTestSuite(ColormapDialog, tearDown=_tearDownQt)
"""Test suite of tests from the module's docstrings."""
+class TestColormapDialog(TestCaseQt, ParametricTestCase):
+ """Test the ColormapDialog."""
+ def setUp(self):
+ TestCaseQt.setUp(self)
+ ParametricTestCase.setUp(self)
+ self.colormap = Colormap(name='gray', vmin=10.0, vmax=20.0,
+ normalization='linear')
+
+ self.colormapDiag = ColormapDialog.ColormapDialog()
+ self.colormapDiag.setAttribute(qt.Qt.WA_DeleteOnClose)
+
+ def tearDown(self):
+ del self.colormapDiag
+ ParametricTestCase.tearDown(self)
+ TestCaseQt.tearDown(self)
+
+ def testGUIEdition(self):
+ """Make sure the colormap is correctly edited and also that the
+ modification are correctly updated if an other colormapdialog is
+ editing the same colormap"""
+ colormapDiag2 = ColormapDialog.ColormapDialog()
+ colormapDiag2.setColormap(self.colormap)
+ self.colormapDiag.setColormap(self.colormap)
+
+ self.colormapDiag._comboBoxColormap.setCurrentName('red')
+ self.colormapDiag._normButtonLog.setChecked(True)
+ self.assertTrue(self.colormap.getName() == 'red')
+ self.assertTrue(self.colormapDiag.getColormap().getName() == 'red')
+ self.assertTrue(self.colormap.getNormalization() == 'log')
+ self.assertTrue(self.colormap.getVMin() == 10)
+ self.assertTrue(self.colormap.getVMax() == 20)
+ # checked second colormap dialog
+ self.assertTrue(colormapDiag2._comboBoxColormap.getCurrentName() == 'red')
+ self.assertTrue(colormapDiag2._normButtonLog.isChecked())
+ self.assertTrue(int(colormapDiag2._minValue.getValue()) == 10)
+ self.assertTrue(int(colormapDiag2._maxValue.getValue()) == 20)
+ colormapDiag2.close()
+
+ def testGUIModalOk(self):
+ """Make sure the colormap is modified if gone through accept"""
+ assert self.colormap.isAutoscale() is False
+ self.colormapDiag.setModal(True)
+ self.colormapDiag.show()
+ self.colormapDiag.setColormap(self.colormap)
+ self.assertTrue(self.colormap.getVMin() is not None)
+ self.colormapDiag._minValue.setValue(None)
+ self.assertTrue(self.colormap.getVMin() is None)
+ self.colormapDiag._maxValue.setValue(None)
+ self.mouseClick(
+ widget=self.colormapDiag._buttonsModal.button(qt.QDialogButtonBox.Ok),
+ button=qt.Qt.LeftButton
+ )
+ self.assertTrue(self.colormap.getVMin() is None)
+ self.assertTrue(self.colormap.getVMax() is None)
+ self.assertTrue(self.colormap.isAutoscale() is True)
+
+ def testGUIModalCancel(self):
+ """Make sure the colormap is not modified if gone through reject"""
+ assert self.colormap.isAutoscale() is False
+ self.colormapDiag.setModal(True)
+ self.colormapDiag.show()
+ self.colormapDiag.setColormap(self.colormap)
+ self.assertTrue(self.colormap.getVMin() is not None)
+ self.colormapDiag._minValue.setValue(None)
+ self.assertTrue(self.colormap.getVMin() is None)
+ self.mouseClick(
+ widget=self.colormapDiag._buttonsModal.button(qt.QDialogButtonBox.Cancel),
+ button=qt.Qt.LeftButton
+ )
+ self.assertTrue(self.colormap.getVMin() is not None)
+
+ def testGUIModalClose(self):
+ assert self.colormap.isAutoscale() is False
+ self.colormapDiag.setModal(False)
+ self.colormapDiag.show()
+ self.colormapDiag.setColormap(self.colormap)
+ self.assertTrue(self.colormap.getVMin() is not None)
+ self.colormapDiag._minValue.setValue(None)
+ self.assertTrue(self.colormap.getVMin() is None)
+ self.mouseClick(
+ widget=self.colormapDiag._buttonsNonModal.button(qt.QDialogButtonBox.Close),
+ button=qt.Qt.LeftButton
+ )
+ self.assertTrue(self.colormap.getVMin() is None)
+
+ def testGUIModalReset(self):
+ assert self.colormap.isAutoscale() is False
+ self.colormapDiag.setModal(False)
+ self.colormapDiag.show()
+ self.colormapDiag.setColormap(self.colormap)
+ self.assertTrue(self.colormap.getVMin() is not None)
+ self.colormapDiag._minValue.setValue(None)
+ self.assertTrue(self.colormap.getVMin() is None)
+ self.mouseClick(
+ widget=self.colormapDiag._buttonsNonModal.button(qt.QDialogButtonBox.Reset),
+ button=qt.Qt.LeftButton
+ )
+ self.assertTrue(self.colormap.getVMin() is not None)
+ self.colormapDiag.close()
+
+ def testGUIClose(self):
+ """Make sure the colormap is modify if go through reject"""
+ assert self.colormap.isAutoscale() is False
+ self.colormapDiag.show()
+ self.colormapDiag.setColormap(self.colormap)
+ self.assertTrue(self.colormap.getVMin() is not None)
+ self.colormapDiag._minValue.setValue(None)
+ self.assertTrue(self.colormap.getVMin() is None)
+ self.colormapDiag.close()
+ 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')
+ for norm in (Colormap.NORMALIZATIONS):
+ for autoscale in (True, False):
+ if autoscale is True:
+ self.colormap.setVRange(None, None)
+ else:
+ self.colormap.setVRange(11, 101)
+ 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.assertTrue(
+ self.colormapDiag._comboBoxColormap.getCurrentName() == 'red')
+ self.assertTrue(
+ self.colormapDiag._minValue.isAutoChecked() == autoscale)
+ self.assertTrue(
+ self.colormapDiag._maxValue.isAutoChecked() == autoscale)
+ if autoscale is False:
+ self.assertTrue(self.colormapDiag._minValue.getValue() == 11)
+ self.assertTrue(self.colormapDiag._maxValue.getValue() == 101)
+ self.assertTrue(self.colormapDiag._minValue.isEnabled())
+ self.assertTrue(self.colormapDiag._maxValue.isEnabled())
+ else:
+ self.assertFalse(self.colormapDiag._minValue._numVal.isEnabled())
+ self.assertFalse(self.colormapDiag._maxValue._numVal.isEnabled())
+
+ def testColormapDel(self):
+ """Check behavior if the colormap has been deleted outside. For now
+ we make sure the colormap is still running and nothing more"""
+ self.colormapDiag.setColormap(self.colormap)
+ self.colormapDiag.show()
+ del self.colormap
+ self.assertTrue(self.colormapDiag.getColormap() is None)
+ self.colormapDiag._comboBoxColormap.setCurrentName('blue')
+
+ def testColormapEditedOutside(self):
+ """Make sure the GUI is still up to date if the colormap is modified
+ outside"""
+ self.colormapDiag.setColormap(self.colormap)
+ self.colormapDiag.show()
+
+ self.colormap.setName('red')
+ self.assertTrue(
+ self.colormapDiag._comboBoxColormap.getCurrentName() == 'red')
+ self.colormap.setNormalization(Colormap.LOGARITHM)
+ self.assertFalse(self.colormapDiag._normButtonLinear.isChecked())
+ self.colormap.setVRange(11, 201)
+ self.assertTrue(self.colormapDiag._minValue.getValue() == 11)
+ self.assertTrue(self.colormapDiag._maxValue.getValue() == 201)
+ self.assertTrue(self.colormapDiag._minValue._numVal.isEnabled())
+ self.assertTrue(self.colormapDiag._maxValue._numVal.isEnabled())
+ self.assertFalse(self.colormapDiag._minValue.isAutoChecked())
+ self.assertFalse(self.colormapDiag._maxValue.isAutoChecked())
+ self.colormap.setVRange(None, None)
+ self.assertFalse(self.colormapDiag._minValue._numVal.isEnabled())
+ self.assertFalse(self.colormapDiag._maxValue._numVal.isEnabled())
+ self.assertTrue(self.colormapDiag._minValue.isAutoChecked())
+ self.assertTrue(self.colormapDiag._maxValue.isAutoChecked())
+
+ def testSetColormapScenario(self):
+ """Test of a simple scenario of a colormap dialog editing several
+ colormap"""
+ colormap1 = Colormap(name='gray', vmin=10.0, vmax=20.0,
+ normalization='linear')
+ colormap2 = Colormap(name='red', vmin=10.0, vmax=20.0,
+ normalization='log')
+ colormap3 = Colormap(name='blue', vmin=None, vmax=None,
+ normalization='linear')
+ self.colormapDiag.setColormap(self.colormap)
+ self.colormapDiag.setColormap(colormap1)
+ del colormap1
+ self.colormapDiag.setColormap(colormap2)
+ del colormap2
+ self.colormapDiag.setColormap(colormap3)
+ del colormap3
+
+ def testNotPreferredColormap(self):
+ """Test that the colormapEditor is able to edit a colormap which is not
+ part of the 'prefered colormap'
+ """
+ def getFirstNotPreferredColormap():
+ cms = Colormap.getSupportedColormaps()
+ preferred = preferredColormaps()
+ for cm in cms:
+ if cm not in preferred:
+ return cm
+ return None
+
+ colormapName = getFirstNotPreferredColormap()
+ assert colormapName is not None
+ colormap = Colormap(name=colormapName)
+ self.colormapDiag.setColormap(colormap)
+ self.colormapDiag.show()
+ cb = self.colormapDiag._comboBoxColormap
+ self.assertTrue(cb.getCurrentName() == colormapName)
+ cb.setCurrentIndex(0)
+ index = cb.findColormap(colormapName)
+ assert index is not 0 # if 0 then the rest of the test has no sense
+ cb.setCurrentIndex(index)
+ self.assertTrue(cb.getCurrentName() == colormapName)
+
+ def testColormapEditableMode(self):
+ """Test that the colormapDialog is correctly updated when changing the
+ colormap editable status"""
+ colormap = Colormap(normalization='linear', vmin=1.0, vmax=10.0)
+ self.colormapDiag.setColormap(colormap)
+ for editable in (True, False):
+ with self.subTest(editable=editable):
+ colormap.setEditable(editable)
+ self.assertTrue(
+ self.colormapDiag._comboBoxColormap.isEnabled() is editable)
+ self.assertTrue(
+ self.colormapDiag._minValue.isEnabled() is editable)
+ self.assertTrue(
+ self.colormapDiag._maxValue.isEnabled() is editable)
+ self.assertTrue(
+ self.colormapDiag._normButtonLinear.isEnabled() is editable)
+ self.assertTrue(
+ self.colormapDiag._normButtonLog.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.setChecked(True)
+ resetButton = self.colormapDiag._buttonsNonModal.button(qt.QDialogButtonBox.Reset)
+ self.assertTrue(resetButton.isEnabled())
+ colormap.setEditable(False)
+ self.assertFalse(resetButton.isEnabled())
+
+
+class TestColormapAction(TestCaseQt):
+ def setUp(self):
+ TestCaseQt.setUp(self)
+ self.plot = PlotWindow()
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+
+ self.colormap1 = Colormap(name='blue', vmin=0.0, vmax=1.0,
+ normalization='linear')
+ self.colormap2 = Colormap(name='red', vmin=10.0, vmax=100.0,
+ normalization='log')
+ self.defaultColormap = self.plot.getDefaultColormap()
+
+ self.plot.getColormapAction()._actionTriggered(checked=True)
+ self.colormapDialog = self.plot.getColormapAction()._dialog
+ self.colormapDialog.setAttribute(qt.Qt.WA_DeleteOnClose)
+
+ def tearDown(self):
+ self.colormapDialog.close()
+ self.plot.close()
+ del self.colormapDialog
+ del self.plot
+ TestCaseQt.tearDown(self)
+
+ def testActiveColormap(self):
+ self.assertTrue(self.colormapDialog.getColormap() is self.defaultColormap)
+
+ self.plot.addImage(data=numpy.random.rand(10, 10), legend='img1',
+ replace=False, origin=(0, 0),
+ colormap=self.colormap1)
+ self.plot.setActiveImage('img1')
+ self.assertTrue(self.colormapDialog.getColormap() is self.colormap1)
+
+ self.plot.addImage(data=numpy.random.rand(10, 10), legend='img2',
+ replace=False, origin=(0, 0),
+ colormap=self.colormap2)
+ self.plot.addImage(data=numpy.random.rand(10, 10), legend='img3',
+ replace=False, origin=(0, 0))
+
+ self.plot.setActiveImage('img3')
+ self.assertTrue(self.colormapDialog.getColormap() is self.defaultColormap)
+ self.plot.getActiveImage().setColormap(self.colormap2)
+ self.assertTrue(self.colormapDialog.getColormap() is self.colormap2)
+
+ self.plot.remove('img2')
+ self.plot.remove('img3')
+ self.plot.remove('img1')
+ self.assertTrue(self.colormapDialog.getColormap() is self.defaultColormap)
+
+ def testShowHideColormapDialog(self):
+ self.plot.getColormapAction()._actionTriggered(checked=False)
+ self.assertFalse(self.plot.getColormapAction().isChecked())
+ self.plot.getColormapAction()._actionTriggered(checked=True)
+ self.assertTrue(self.plot.getColormapAction().isChecked())
+ self.plot.addImage(data=numpy.random.rand(10, 10), legend='img1',
+ replace=False, origin=(0, 0),
+ colormap=self.colormap1)
+ self.colormap1.setName('red')
+ self.plot.getColormapAction()._actionTriggered()
+ self.colormap1.setName('blue')
+ self.colormapDialog.close()
+ self.assertFalse(self.plot.getColormapAction().isChecked())
+
+
def suite():
test_suite = unittest.TestSuite()
test_suite.addTest(cmapDocTestSuite)
+ for testClass in (TestColormapDialog, TestColormapAction):
+ test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(
+ testClass))
return test_suite
diff --git a/silx/gui/plot/test/testColors.py b/silx/gui/plot/test/testColors.py
index 18f0902..4d617eb 100644
--- a/silx/gui/plot/test/testColors.py
+++ b/silx/gui/plot/test/testColors.py
@@ -26,13 +26,13 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "05/12/2016"
+__date__ = "17/01/2018"
import numpy
import unittest
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
from silx.gui.plot import Colors
from silx.gui.plot.Colormap import Colormap
diff --git a/silx/gui/plot/test/testComplexImageView.py b/silx/gui/plot/test/testComplexImageView.py
index f8ec370..1933a95 100644
--- a/silx/gui/plot/test/testComplexImageView.py
+++ b/silx/gui/plot/test/testComplexImageView.py
@@ -26,14 +26,14 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "14/09/2017"
+__date__ = "17/01/2018"
import unittest
import logging
import numpy
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
from silx.gui.plot import ComplexImageView
from .utils import PlotWidgetTestCase
diff --git a/silx/gui/plot/test/testCurvesROIWidget.py b/silx/gui/plot/test/testCurvesROIWidget.py
index 716960a..0fd2456 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 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -24,9 +24,9 @@
# ###########################################################################*/
"""Basic tests for CurvesROIWidget"""
-__authors__ = ["T. Vincent"]
+__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "15/05/2017"
+__date__ = "16/11/2017"
import logging
@@ -105,6 +105,19 @@ class TestCurvesROIWidget(TestCaseQt):
del self.tmpFile
+ def testMiddleMarker(self):
+ """Test with middle marker enabled"""
+ self.widget.roiWidget.setMiddleROIMarkerFlag(True)
+
+ # Add a ROI
+ self.mouseClick(self.widget.roiWidget.addButton, qt.Qt.LeftButton)
+
+ xleftMarker = self.plot._getMarker(legend='ROI min').getXPosition()
+ xMiddleMarker = self.plot._getMarker(legend='ROI middle').getXPosition()
+ xRightMarker = self.plot._getMarker(legend='ROI max').getXPosition()
+ self.assertAlmostEqual(xMiddleMarker,
+ xleftMarker + (xRightMarker - xleftMarker) / 2.)
+
def testCalculation(self):
x = numpy.arange(100.)
y = numpy.arange(100.)
diff --git a/silx/gui/plot/test/testItem.py b/silx/gui/plot/test/testItem.py
index 8c15bb7..1ba09c6 100644
--- a/silx/gui/plot/test/testItem.py
+++ b/silx/gui/plot/test/testItem.py
@@ -58,7 +58,7 @@ class TestSigItemChangedSignal(PlotWidgetTestCase):
curve.setData(numpy.arange(100), numpy.arange(100))
# SymbolMixIn
- curve.setSymbol('o')
+ curve.setSymbol('Circle')
curve.setSymbol('d')
curve.setSymbolSize(20)
@@ -220,10 +220,28 @@ class TestSigItemChangedSignal(PlotWidgetTestCase):
(ItemChangedType.DATA,)])
+class TestSymbol(PlotWidgetTestCase):
+ """Test item's symbol """
+
+ def test(self):
+ """Test sigItemChanged for curve"""
+ self.plot.addCurve(numpy.arange(10), numpy.arange(10), legend='test')
+ curve = self.plot.getCurve('test')
+
+ # SymbolMixIn
+ curve.setSymbol('o')
+ name = curve.getSymbolName()
+ self.assertEqual('Circle', name)
+
+ name = curve.getSymbolName('d')
+ self.assertEqual('Diamond', name)
+
+
def suite():
test_suite = unittest.TestSuite()
loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
test_suite.addTest(loadTests(TestSigItemChangedSignal))
+ test_suite.addTest(loadTests(TestSymbol))
return test_suite
diff --git a/silx/gui/plot/test/testMaskToolsWidget.py b/silx/gui/plot/test/testMaskToolsWidget.py
index 191bbe0..40c1db3 100644
--- a/silx/gui/plot/test/testMaskToolsWidget.py
+++ b/silx/gui/plot/test/testMaskToolsWidget.py
@@ -26,7 +26,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "01/09/2017"
+__date__ = "17/01/2018"
import logging
@@ -36,7 +36,8 @@ import unittest
import numpy
from silx.gui import qt
-from silx.test.utils import temp_dir, ParametricTestCase
+from silx.test.utils import temp_dir
+from silx.utils.testutils import ParametricTestCase
from silx.gui.test.utils import getQToolButtonFromAction
from silx.gui.plot import PlotWindow, MaskToolsWidget
from .utils import PlotWidgetTestCase
@@ -84,8 +85,10 @@ class TestMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
pos0 = xCenter, yCenter
pos1 = xCenter + offset, yCenter + offset
+ self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=pos0)
self.mousePress(plot, qt.Qt.LeftButton, pos=pos0)
+ self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=pos1)
self.mouseRelease(plot, qt.Qt.LeftButton, pos=pos1)
@@ -194,7 +197,7 @@ class TestMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
self.assertIsNot(toolButton, None)
self.mouseClick(toolButton, qt.Qt.LeftButton)
- self.maskWidget.pencilSpinBox.setValue(10)
+ self.maskWidget.pencilSpinBox.setValue(30)
self.qapp.processEvents()
# mask
diff --git a/silx/gui/plot/test/testPlotTools.py b/silx/gui/plot/test/testPlotTools.py
index a08a18a..3d5849f 100644
--- a/silx/gui/plot/test/testPlotTools.py
+++ b/silx/gui/plot/test/testPlotTools.py
@@ -26,13 +26,13 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "01/09/2017"
+__date__ = "17/01/2018"
import numpy
import unittest
-from silx.test.utils import ParametricTestCase, TestLogging
+from silx.utils.testutils import ParametricTestCase, TestLogging
from silx.gui.test.utils import (
qWaitForWindowExposedAndActivate, TestCaseQt, getQToolButtonFromAction)
from silx.gui import qt
diff --git a/silx/gui/plot/test/testPlotWidget.py b/silx/gui/plot/test/testPlotWidget.py
index ccee428..72617e5 100644
--- a/silx/gui/plot/test/testPlotWidget.py
+++ b/silx/gui/plot/test/testPlotWidget.py
@@ -26,17 +26,17 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "01/09/2017"
+__date__ = "26/01/2018"
import unittest
import logging
import numpy
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
from silx.gui.test.utils import SignalListener
from silx.gui.test.utils import TestCaseQt
-from silx.test import utils
+from silx.utils import testutils
from silx.utils import deprecation
from silx.gui import qt
@@ -77,9 +77,9 @@ class TestPlotWidget(PlotWidgetTestCase, ParametricTestCase):
self.assertEqual(self.plot.getYAxis().getLabel(), ylabel)
def _checkLimits(self,
- expectedXLim=None,
- expectedYLim=None,
- expectedRatio=None):
+ expectedXLim=None,
+ expectedYLim=None,
+ expectedRatio=None):
"""Assert that limits are as expected"""
xlim = self.plot.getXAxis().getLimits()
ylim = self.plot.getYAxis().getLimits()
@@ -132,13 +132,11 @@ class TestPlotWidget(PlotWidgetTestCase, ParametricTestCase):
# Resize with aspect ratio
self.plot.setKeepDataAspectRatio(True)
- listener.clear() # Clean-up received signal
self.qapp.processEvents()
- self.assertEqual(listener.callCount(), 0) # No event when redrawing
+ listener.clear() # Clean-up received signal
self.plot.resize(200, 200)
self.qapp.processEvents()
-
self.assertNotEqual(listener.callCount(), 0)
@@ -723,7 +721,7 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
if getter is not None:
self.assertEqual(getter(), expected)
- @utils.test_logging(deprecation.depreclog.name, warning=2)
+ @testutils.test_logging(deprecation.depreclog.name, warning=2)
def testOldPlotAxis_Logarithmic(self):
"""Test silx API prior to silx 0.6"""
x = self.plot.getXAxis()
@@ -762,7 +760,7 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
self.assertEqual(self.plot.isYAxisLogarithmic(), False)
self.assertEqual(listener.arguments(callIndex=-1), ("y", False))
- @utils.test_logging(deprecation.depreclog.name, warning=2)
+ @testutils.test_logging(deprecation.depreclog.name, warning=2)
def testOldPlotAxis_AutoScale(self):
"""Test silx API prior to silx 0.6"""
x = self.plot.getXAxis()
@@ -801,7 +799,7 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
self.assertEqual(self.plot.isYAxisAutoScale(), True)
self.assertEqual(listener.arguments(callIndex=-1), ("y", True))
- @utils.test_logging(deprecation.depreclog.name, warning=1)
+ @testutils.test_logging(deprecation.depreclog.name, warning=1)
def testOldPlotAxis_Inverted(self):
"""Test silx API prior to silx 0.6"""
x = self.plot.getXAxis()
diff --git a/silx/gui/plot/test/testPlotWidgetNoBackend.py b/silx/gui/plot/test/testPlotWidgetNoBackend.py
index 3094a20..0d0ddc4 100644
--- a/silx/gui/plot/test/testPlotWidgetNoBackend.py
+++ b/silx/gui/plot/test/testPlotWidgetNoBackend.py
@@ -26,12 +26,12 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "27/06/2017"
+__date__ = "17/01/2018"
import unittest
from functools import reduce
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
import numpy
diff --git a/silx/gui/plot/test/testProfile.py b/silx/gui/plot/test/testProfile.py
index 43d3329..28d9669 100644
--- a/silx/gui/plot/test/testProfile.py
+++ b/silx/gui/plot/test/testProfile.py
@@ -26,12 +26,12 @@
__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "23/02/2017"
+__date__ = "17/01/2018"
import numpy
import unittest
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
from silx.gui.test.utils import (
TestCaseQt, getQToolButtonFromAction)
from silx.gui import qt
diff --git a/silx/gui/plot/test/testSaveAction.py b/silx/gui/plot/test/testSaveAction.py
new file mode 100644
index 0000000..4dfe373
--- /dev/null
+++ b/silx/gui/plot/test/testSaveAction.py
@@ -0,0 +1,97 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Test the plot's save action (consistency of output)"""
+
+__authors__ = ["P. Knobel"]
+__license__ = "MIT"
+__date__ = "28/11/2017"
+
+
+import unittest
+import tempfile
+import os
+
+from silx.gui.plot import PlotWidget
+from silx.gui.plot.actions.io import SaveAction
+
+
+class TestSaveAction(unittest.TestCase):
+
+ def setUp(self):
+ self.plot = PlotWidget(backend='none')
+ self.saveAction = SaveAction(plot=self.plot)
+
+ self.tempdir = tempfile.mkdtemp()
+ self.out_fname = os.path.join(self.tempdir, "out.dat")
+
+ def tearDown(self):
+ os.unlink(self.out_fname)
+ os.rmdir(self.tempdir)
+
+ def testSaveMultipleCurvesAsSpec(self):
+ """Test that labels are properly used."""
+ self.plot.setGraphXLabel("graph x label")
+ self.plot.setGraphYLabel("graph y label")
+
+ self.plot.addCurve([0, 1], [1, 2], "curve with labels",
+ xlabel="curve0 X", ylabel="curve0 Y")
+ self.plot.addCurve([-1, 3], [-6, 2], "curve with X label",
+ xlabel="curve1 X")
+ self.plot.addCurve([-2, 0], [8, 12], "curve with Y label",
+ ylabel="curve2 Y")
+ self.plot.addCurve([3, 1], [7, 6], "curve with no labels")
+
+ self.saveAction._saveCurves(self.out_fname,
+ SaveAction.ALL_CURVES_FILTERS[0]) # "All curves as SpecFile (*.dat)"
+
+ with open(self.out_fname, "rb") as f:
+ file_content = f.read()
+ if hasattr(file_content, "decode"):
+ file_content = file_content.decode()
+
+ # case with all curve labels specified
+ self.assertIn("#S 1 curve0 Y", file_content)
+ self.assertIn("#L curve0 X curve0 Y", file_content)
+
+ # graph X&Y labels are used when no curve label is specified
+ self.assertIn("#S 2 graph y label", file_content)
+ self.assertIn("#L curve1 X graph y label", file_content)
+
+ self.assertIn("#S 3 curve2 Y", file_content)
+ self.assertIn("#L graph x label curve2 Y", file_content)
+
+ self.assertIn("#S 4 graph y label", file_content)
+ self.assertIn("#L graph x label graph y label", file_content)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestSaveAction))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testScatterMaskToolsWidget.py b/silx/gui/plot/test/testScatterMaskToolsWidget.py
index 178274a..0342c8f 100644
--- a/silx/gui/plot/test/testScatterMaskToolsWidget.py
+++ b/silx/gui/plot/test/testScatterMaskToolsWidget.py
@@ -26,7 +26,7 @@
__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "01/09/2017"
+__date__ = "17/01/2018"
import logging
@@ -36,7 +36,8 @@ import unittest
import numpy
from silx.gui import qt
-from silx.test.utils import temp_dir, ParametricTestCase
+from silx.test.utils import temp_dir
+from silx.utils.testutils import ParametricTestCase
from silx.gui.test.utils import getQToolButtonFromAction
from silx.gui.plot import PlotWindow, ScatterMaskToolsWidget
from .utils import PlotWidgetTestCase
@@ -86,8 +87,10 @@ class TestScatterMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
pos0 = xCenter, yCenter
pos1 = xCenter + offset, yCenter + offset
+ self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=pos0)
self.mousePress(plot, qt.Qt.LeftButton, pos=pos0)
+ self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=pos1)
self.mouseRelease(plot, qt.Qt.LeftButton, pos=pos1)
@@ -197,7 +200,7 @@ class TestScatterMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
self.assertIsNot(toolButton, None)
self.mouseClick(toolButton, qt.Qt.LeftButton)
- self.maskWidget.pencilSpinBox.setValue(10)
+ self.maskWidget.pencilSpinBox.setValue(30)
self.qapp.processEvents()
# mask
diff --git a/silx/gui/plot/test/testUtilsAxis.py b/silx/gui/plot/test/testUtilsAxis.py
index 6702b00..3f19dcd 100644
--- a/silx/gui/plot/test/testUtilsAxis.py
+++ b/silx/gui/plot/test/testUtilsAxis.py
@@ -26,18 +26,20 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "04/08/2017"
+__date__ = "14/02/2018"
import unittest
from silx.gui.plot import PlotWidget
+from silx.gui.test.utils import TestCaseQt
from silx.gui.plot.utils.axis import SyncAxes
-class TestAxisSync(unittest.TestCase):
+class TestAxisSync(TestCaseQt):
"""Tests AxisSync class"""
def setUp(self):
+ TestCaseQt.setUp(self)
self.plot1 = PlotWidget()
self.plot2 = PlotWidget()
self.plot3 = PlotWidget()
@@ -46,6 +48,7 @@ class TestAxisSync(unittest.TestCase):
self.plot1 = None
self.plot2 = None
self.plot3 = None
+ TestCaseQt.tearDown(self)
def testMoveFirstAxis(self):
"""Test synchronization after construction"""
@@ -85,6 +88,22 @@ class TestAxisSync(unittest.TestCase):
self.assertNotEqual(self.plot2.getXAxis().getLimits(), (10, 500))
self.assertNotEqual(self.plot3.getXAxis().getLimits(), (10, 500))
+ def testAxisDestruction(self):
+ """Test synchronization when an axis disappear"""
+ _sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()])
+
+ # Destroy the plot is possible
+ import weakref
+ plot = weakref.ref(self.plot2)
+ self.plot2 = None
+ result = self.qWaitForDestroy(plot)
+ if not result:
+ # We can't test
+ self.skipTest("Object not destroyed")
+
+ self.plot1.getXAxis().setLimits(10, 500)
+ self.assertEqual(self.plot3.getXAxis().getLimits(), (10, 500))
+
def testStop(self):
"""Test synchronization after calling stop"""
sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()])
diff --git a/silx/gui/plot/test/utils.py b/silx/gui/plot/test/utils.py
index ef547c6..ec9bc7c 100644
--- a/silx/gui/plot/test/utils.py
+++ b/silx/gui/plot/test/utils.py
@@ -26,17 +26,15 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "01/09/2017"
+__date__ = "26/01/2018"
import logging
-import contextlib
from silx.gui.test.utils import TestCaseQt
from silx.gui import qt
from silx.gui.plot import PlotWidget
-from silx.gui.plot.backends.BackendMatplotlib import BackendMatplotlibQt
logger = logging.getLogger(__name__)
@@ -48,9 +46,10 @@ class PlotWidgetTestCase(TestCaseQt):
plot attribute is the PlotWidget created for the test.
"""
+ __screenshot_already_taken = False
+
def __init__(self, methodName='runTest'):
TestCaseQt.__init__(self, methodName=methodName)
- self.__mousePos = None
def _createPlot(self):
return PlotWidget()
@@ -79,116 +78,16 @@ class PlotWidgetTestCase(TestCaseQt):
logger.error("Plot is still alive")
def tearDown(self):
+ if not self._currentTestSucceeded():
+ # MPL is the only widget which uses the real system mouse.
+ # In case of a the windows is outside of the screen, minimzed,
+ # overlapped by a system popup, the MPL widget will not receive the
+ # mouse event.
+ # Taking a screenshot help debuging this cases in the continuous
+ # integration environement.
+ if not PlotWidgetTestCase.__screenshot_already_taken:
+ PlotWidgetTestCase.__screenshot_already_taken = True
+ self.logScreenShot()
self.qapp.processEvents()
self._waitForPlotClosed()
super(PlotWidgetTestCase, self).tearDown()
-
- def _logMplEvents(self, event):
- self.__mplEvents.append(event)
-
- @contextlib.contextmanager
- def _waitForMplEvent(self, plot, mplEventType):
- """Check if an event was received by the MPL backend.
-
- :param PlotWidget plot: A plot widget or a MPL plot backend
- :param str mplEventType: MPL event type
- :raises RuntimeError: When the event did not happen
- """
- self.__mplEvents = []
- if isinstance(plot, BackendMatplotlibQt):
- backend = plot
- else:
- backend = plot._backend
-
- callbackId = backend.mpl_connect(mplEventType, self._logMplEvents)
- received = False
- yield
- for _ in range(100):
- if len(self.__mplEvents) > 0:
- received = True
- break
- self.qWait(10)
- backend.mpl_disconnect(callbackId)
- del self.__mplEvents
- if not received:
- self.logScreenShot()
- raise RuntimeError("MPL event %s expected but nothing received" % mplEventType)
-
- def _haveMplEvent(self, widget, pos):
- """Check if the widget at this position is a matplotlib widget."""
- if isinstance(pos, qt.QPoint):
- pass
- else:
- pos = qt.QPoint(pos[0], pos[1])
- pos = widget.mapTo(widget.window(), pos)
- target = widget.window().childAt(pos)
-
- # Check if the target is a MPL container
- backend = target
- if hasattr(target, "_backend"):
- backend = target._backend
- haveEvent = isinstance(backend, BackendMatplotlibQt)
- return haveEvent
-
- def _patchPos(self, widget, pos):
- """Return a real position relative to the widget.
-
- If pos is None, the returned value is the center of the widget,
- as the default behaviour of functions like QTest.mouseMove.
- Else the position is returned as it is.
- """
- if pos is None:
- pos = widget.size() / 2
- pos = pos.width(), pos.height()
- return pos
-
- def _checkMouseMove(self, widget, pos):
- """Returns true if the position differe from the current position of
- the cursor"""
- pos = qt.QPoint(pos[0], pos[1])
- pos = widget.mapTo(widget.window(), pos)
- willMove = pos != self.__mousePos
- self.__mousePos = pos
- return willMove
-
- def mouseMove(self, widget, pos=None, delay=-1):
- """Override TestCaseQt to wait while MPL did not reveive the expected
- event"""
- pos = self._patchPos(widget, pos)
- willMove = self._checkMouseMove(widget, pos)
- hadMplEvents = self._haveMplEvent(widget, self.__mousePos)
- willHaveMplEvents = self._haveMplEvent(widget, pos)
- if (not hadMplEvents and not willHaveMplEvents) or not willMove:
- return TestCaseQt.mouseMove(self, widget, pos=pos, delay=delay)
- with self._waitForMplEvent(widget, "motion_notify_event"):
- TestCaseQt.mouseMove(self, widget, pos=pos, delay=delay)
-
- def mouseClick(self, widget, button, modifier=None, pos=None, delay=-1):
- """Override TestCaseQt to wait while MPL did not reveive the expected
- event"""
- pos = self._patchPos(widget, pos)
- self._checkMouseMove(widget, pos)
- if not self._haveMplEvent(widget, pos):
- return TestCaseQt.mouseClick(self, widget, button, modifier=modifier, pos=pos, delay=delay)
- with self._waitForMplEvent(widget, "button_release_event"):
- TestCaseQt.mouseClick(self, widget, button, modifier=modifier, pos=pos, delay=delay)
-
- def mousePress(self, widget, button, modifier=None, pos=None, delay=-1):
- """Override TestCaseQt to wait while MPL did not reveive the expected
- event"""
- pos = self._patchPos(widget, pos)
- self._checkMouseMove(widget, pos)
- if not self._haveMplEvent(widget, pos):
- return TestCaseQt.mousePress(self, widget, button, modifier=modifier, pos=pos, delay=delay)
- with self._waitForMplEvent(widget, "button_press_event"):
- TestCaseQt.mousePress(self, widget, button, modifier=modifier, pos=pos, delay=delay)
-
- def mouseRelease(self, widget, button, modifier=None, pos=None, delay=-1):
- """Override TestCaseQt to wait while MPL did not reveive the expected
- event"""
- pos = self._patchPos(widget, pos)
- self._checkMouseMove(widget, pos)
- if not self._haveMplEvent(widget, pos):
- return TestCaseQt.mouseRelease(self, widget, button, modifier=modifier, pos=pos, delay=delay)
- with self._waitForMplEvent(widget, "button_release_event"):
- TestCaseQt.mouseRelease(self, widget, button, modifier=modifier, pos=pos, delay=delay)
diff --git a/silx/gui/plot/utils/axis.py b/silx/gui/plot/utils/axis.py
index f7ec711..80e1dc4 100644
--- a/silx/gui/plot/utils/axis.py
+++ b/silx/gui/plot/utils/axis.py
@@ -27,12 +27,13 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "04/08/2017"
+__date__ = "23/02/2018"
import functools
import logging
from contextlib import contextmanager
-from silx.utils import weakref
+import weakref
+import silx.utils.weakref as silxWeakref
_logger = logging.getLogger(__name__)
@@ -63,14 +64,20 @@ class SyncAxes(object):
:param bool syncDirection: Synchronize axes direction
"""
object.__init__(self)
- self.__axes = []
self.__locked = False
+ self.__axes = []
self.__syncLimits = syncLimits
self.__syncScale = syncScale
self.__syncDirection = syncDirection
- self.__callbacks = []
+ self.__callbacks = None
+
+ qtCallback = silxWeakref.WeakMethodProxy(self.__deleteAxisQt)
+ for axis in axes:
+ ref = weakref.ref(axis)
+ self.__axes.append(ref)
+ callback = functools.partial(qtCallback, ref)
+ axis.destroyed.connect(callback)
- self.__axes.extend(axes)
self.start()
def start(self):
@@ -80,54 +87,71 @@ class SyncAxes(object):
After that, any changes to any axes will be used to synchronize other
axes.
"""
- if len(self.__callbacks) != 0:
+ if self.__callbacks is not None:
raise RuntimeError("Axes already synchronized")
+ self.__callbacks = {}
# register callback for further sync
- for axis in self.__axes:
+ for refAxis in self.__axes:
+ axis = refAxis()
+ callbacks = []
if self.__syncLimits:
# the weakref is needed to be able ignore self references
- callback = weakref.WeakMethodProxy(self.__axisLimitsChanged)
- callback = functools.partial(callback, axis)
+ callback = silxWeakref.WeakMethodProxy(self.__axisLimitsChanged)
+ callback = functools.partial(callback, refAxis)
sig = axis.sigLimitsChanged
sig.connect(callback)
- self.__callbacks.append((sig, callback))
+ callbacks.append(("sigLimitsChanged", callback))
if self.__syncScale:
# the weakref is needed to be able ignore self references
- callback = weakref.WeakMethodProxy(self.__axisScaleChanged)
- callback = functools.partial(callback, axis)
+ callback = silxWeakref.WeakMethodProxy(self.__axisScaleChanged)
+ callback = functools.partial(callback, refAxis)
sig = axis.sigScaleChanged
sig.connect(callback)
- self.__callbacks.append((sig, callback))
+ callbacks.append(("sigScaleChanged", callback))
if self.__syncDirection:
# the weakref is needed to be able ignore self references
- callback = weakref.WeakMethodProxy(self.__axisInvertedChanged)
- callback = functools.partial(callback, axis)
+ callback = silxWeakref.WeakMethodProxy(self.__axisInvertedChanged)
+ callback = functools.partial(callback, refAxis)
sig = axis.sigInvertedChanged
sig.connect(callback)
- self.__callbacks.append((sig, callback))
+ callbacks.append(("sigInvertedChanged", callback))
+
+ self.__callbacks[refAxis] = callbacks
# sync the current state
- mainAxis = self.__axes[0]
+ refMainAxis = self.__axes[0]
+ mainAxis = refMainAxis()
if self.__syncLimits:
- self.__axisLimitsChanged(mainAxis, *mainAxis.getLimits())
+ self.__axisLimitsChanged(refMainAxis, *mainAxis.getLimits())
if self.__syncScale:
- self.__axisScaleChanged(mainAxis, mainAxis.getScale())
+ self.__axisScaleChanged(refMainAxis, mainAxis.getScale())
if self.__syncDirection:
- self.__axisInvertedChanged(mainAxis, mainAxis.isInverted())
+ self.__axisInvertedChanged(refMainAxis, mainAxis.isInverted())
+
+ def __deleteAxis(self, ref):
+ _logger.debug("Delete axes ref %s", ref)
+ self.__axes.remove(ref)
+ del self.__callbacks[ref]
+
+ def __deleteAxisQt(self, ref, qobject):
+ self.__deleteAxis(ref)
def stop(self):
"""Stop the synchronization of the axes"""
- if len(self.__callbacks) == 0:
+ if self.__callbacks is None:
raise RuntimeError("Axes not synchronized")
- for sig, callback in self.__callbacks:
- sig.disconnect(callback)
- self.__callbacks = []
+ for ref, callbacks in self.__callbacks.items():
+ axes = ref()
+ for sigName, callback in callbacks:
+ sig = getattr(axes, sigName)
+ sig.disconnect(callback)
+ self.__callbacks = None
def __del__(self):
"""Destructor"""
# clean up references
- if len(self.__callbacks) != 0:
+ if self.__callbacks is not None:
self.stop()
@contextmanager
@@ -138,6 +162,7 @@ class SyncAxes(object):
def __otherAxes(self, changedAxis):
for axis in self.__axes:
+ axis = axis()
if axis is changedAxis:
continue
yield axis
@@ -145,6 +170,7 @@ class SyncAxes(object):
def __axisLimitsChanged(self, changedAxis, vmin, vmax):
if self.__locked:
return
+ changedAxis = changedAxis()
with self.__inhibitSignals():
for axis in self.__otherAxes(changedAxis):
axis.setLimits(vmin, vmax)
@@ -152,6 +178,7 @@ class SyncAxes(object):
def __axisScaleChanged(self, changedAxis, scale):
if self.__locked:
return
+ changedAxis = changedAxis()
with self.__inhibitSignals():
for axis in self.__otherAxes(changedAxis):
axis.setScale(scale)
@@ -159,6 +186,7 @@ class SyncAxes(object):
def __axisInvertedChanged(self, changedAxis, isInverted):
if self.__locked:
return
+ changedAxis = changedAxis()
with self.__inhibitSignals():
for axis in self.__otherAxes(changedAxis):
axis.setInverted(isInverted)
diff --git a/silx/gui/plot3d/ParamTreeView.py b/silx/gui/plot3d/ParamTreeView.py
new file mode 100644
index 0000000..a352627
--- /dev/null
+++ b/silx/gui/plot3d/ParamTreeView.py
@@ -0,0 +1,541 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""
+This module provides a :class:`QTreeView` dedicated to display plot3d models.
+
+This module contains:
+- :class:`ParamTreeView`: A QTreeView specific for plot3d parameters and scene.
+- :class:`ParameterTreeDelegate`: The delegate for :class:`ParamTreeView`.
+- A set of specific editors used by :class:`ParameterTreeDelegate`:
+ :class:`FloatEditor`, :class:`Vector3DEditor`,
+ :class:`Vector4DEditor`, :class:`IntSliderEditor`, :class:`BooleanEditor`
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "05/12/2017"
+
+
+import sys
+
+from silx.third_party import six
+
+from .. import qt
+from ..widgets.FloatEdit import FloatEdit as _FloatEdit
+from ._model import visitQAbstractItemModel
+
+
+class FloatEditor(_FloatEdit):
+ """Editor widget for float.
+
+ :param parent: The widget's parent
+ :param float value: The initial editor value
+ """
+
+ valueChanged = qt.Signal(float)
+ """Signal emitted when the float value has changed"""
+
+ def __init__(self, parent=None, value=None):
+ super(FloatEditor, self).__init__(parent, value)
+ self.setAlignment(qt.Qt.AlignLeft)
+ self.editingFinished.connect(self._emit)
+
+ def _emit(self):
+ self.valueChanged.emit(self.value)
+
+ value = qt.Property(float,
+ fget=_FloatEdit.value,
+ fset=_FloatEdit.setValue,
+ user=True,
+ notify=valueChanged)
+ """Qt user property of the float value this widget edits"""
+
+
+class Vector3DEditor(qt.QWidget):
+ """Editor widget for QVector3D.
+
+ :param parent: The widget's parent
+ :param flags: The widgets's flags
+ """
+
+ valueChanged = qt.Signal(qt.QVector3D)
+ """Signal emitted when the QVector3D value has changed"""
+
+ def __init__(self, parent=None, flags=qt.Qt.Widget):
+ super(Vector3DEditor, self).__init__(parent, flags)
+ layout = qt.QHBoxLayout(self)
+ # layout.setSpacing(0)
+ layout.setContentsMargins(0, 0, 0, 0)
+ self.setLayout(layout)
+ self._xEdit = _FloatEdit(parent=self, value=0.)
+ self._xEdit.setAlignment(qt.Qt.AlignLeft)
+ # self._xEdit.editingFinished.connect(self._emit)
+ self._yEdit = _FloatEdit(parent=self, value=0.)
+ self._yEdit.setAlignment(qt.Qt.AlignLeft)
+ # self._yEdit.editingFinished.connect(self._emit)
+ self._zEdit = _FloatEdit(parent=self, value=0.)
+ self._zEdit.setAlignment(qt.Qt.AlignLeft)
+ # self._zEdit.editingFinished.connect(self._emit)
+ layout.addWidget(qt.QLabel('x:'))
+ layout.addWidget(self._xEdit)
+ layout.addWidget(qt.QLabel('y:'))
+ layout.addWidget(self._yEdit)
+ layout.addWidget(qt.QLabel('z:'))
+ layout.addWidget(self._zEdit)
+ layout.addStretch(1)
+
+ def _emit(self):
+ vector = self.value
+ self.valueChanged.emit(vector)
+
+ def getValue(self):
+ """Returns the QVector3D value of this widget
+
+ :rtype: QVector3D
+ """
+ return qt.QVector3D(
+ self._xEdit.value(), self._yEdit.value(), self._zEdit.value())
+
+ def setValue(self, value):
+ """Set the QVector3D value
+
+ :param QVector3D value: The new value
+ """
+ self._xEdit.setValue(value.x())
+ self._yEdit.setValue(value.y())
+ self._zEdit.setValue(value.z())
+ self.valueChanged.emit(value)
+
+ value = qt.Property(qt.QVector3D,
+ fget=getValue,
+ fset=setValue,
+ user=True,
+ notify=valueChanged)
+ """Qt user property of the QVector3D value this widget edits"""
+
+
+class Vector4DEditor(qt.QWidget):
+ """Editor widget for QVector4D.
+
+ :param parent: The widget's parent
+ :param flags: The widgets's flags
+ """
+
+ valueChanged = qt.Signal(qt.QVector4D)
+ """Signal emitted when the QVector4D value has changed"""
+
+ def __init__(self, parent=None, flags=qt.Qt.Widget):
+ super(Vector4DEditor, self).__init__(parent, flags)
+ layout = qt.QHBoxLayout(self)
+ # layout.setSpacing(0)
+ layout.setContentsMargins(0, 0, 0, 0)
+ self.setLayout(layout)
+ self._xEdit = _FloatEdit(parent=self, value=0.)
+ self._xEdit.setAlignment(qt.Qt.AlignLeft)
+ # self._xEdit.editingFinished.connect(self._emit)
+ self._yEdit = _FloatEdit(parent=self, value=0.)
+ self._yEdit.setAlignment(qt.Qt.AlignLeft)
+ # self._yEdit.editingFinished.connect(self._emit)
+ self._zEdit = _FloatEdit(parent=self, value=0.)
+ self._zEdit.setAlignment(qt.Qt.AlignLeft)
+ # self._zEdit.editingFinished.connect(self._emit)
+ self._wEdit = _FloatEdit(parent=self, value=0.)
+ self._wEdit.setAlignment(qt.Qt.AlignLeft)
+ # self._wEdit.editingFinished.connect(self._emit)
+ layout.addWidget(qt.QLabel('x:'))
+ layout.addWidget(self._xEdit)
+ layout.addWidget(qt.QLabel('y:'))
+ layout.addWidget(self._yEdit)
+ layout.addWidget(qt.QLabel('z:'))
+ layout.addWidget(self._zEdit)
+ layout.addWidget(qt.QLabel('w:'))
+ layout.addWidget(self._wEdit)
+ layout.addStretch(1)
+
+ def _emit(self):
+ vector = self.value
+ self.valueChanged.emit(vector)
+
+ def getValue(self):
+ """Returns the QVector4D value of this widget
+
+ :rtype: QVector4D
+ """
+ return qt.QVector4D(self._xEdit.value(), self._yEdit.value(),
+ self._zEdit.value(), self._wEdit.value())
+
+ def setValue(self, value):
+ """Set the QVector4D value
+
+ :param QVector4D value: The new value
+ """
+ self._xEdit.setValue(value.x())
+ self._yEdit.setValue(value.y())
+ self._zEdit.setValue(value.z())
+ self._wEdit.setValue(value.w())
+ self.valueChanged.emit(value)
+
+ value = qt.Property(qt.QVector4D,
+ fget=getValue,
+ fset=setValue,
+ user=True,
+ notify=valueChanged)
+ """Qt user property of the QVector4D value this widget edits"""
+
+
+class IntSliderEditor(qt.QSlider):
+ """Slider editor widget for integer.
+
+ Note: Tracking is disabled.
+
+ :param parent: The widget's parent
+ """
+
+ def __init__(self, parent=None):
+ super(IntSliderEditor, self).__init__(parent)
+ self.setOrientation(qt.Qt.Horizontal)
+ self.setSingleStep(1)
+ self.setRange(0, 255)
+ self.setValue(0)
+
+
+class BooleanEditor(qt.QCheckBox):
+ """Checkbox editor for bool.
+
+ This is a QCheckBox with white background.
+
+ :param parent: The widget's parent
+ """
+
+ def __init__(self, parent=None):
+ super(BooleanEditor, self).__init__(parent)
+ self.setStyleSheet("background: white;")
+
+
+class ParameterTreeDelegate(qt.QStyledItemDelegate):
+ """TreeView delegate specific to plot3d scene and object parameter tree.
+
+ It provides additional editors.
+
+ :param parent: Delegate's parent
+ """
+
+ EDITORS = {
+ bool: BooleanEditor,
+ float: FloatEditor,
+ qt.QVector3D: Vector3DEditor,
+ qt.QVector4D: Vector4DEditor,
+ }
+ """Specific editors for different type of data"""
+
+ def __init__(self, parent=None):
+ super(ParameterTreeDelegate, self).__init__(parent)
+
+ def _fixVariant(self, data):
+ """Fix PyQt4 zero vectors being stored as QPyNullVariant.
+
+ :param data: Data retrieved from the model
+ :return: Corresponding object
+ """
+ if qt.BINDING == 'PyQt4' and isinstance(data, qt.QPyNullVariant):
+ typeName = data.typeName()
+ if typeName == 'QVector3D':
+ data = qt.QVector3D()
+ elif typeName == 'QVector4D':
+ data = qt.QVector4D()
+ return data
+
+ def paint(self, painter, option, index):
+ """See :meth:`QStyledItemDelegate.paint`"""
+ data = index.data(qt.Qt.DisplayRole)
+ data = self._fixVariant(data)
+
+ if isinstance(data, (qt.QVector3D, qt.QVector4D)):
+ if isinstance(data, qt.QVector3D):
+ text = '(x: %g; y: %g; z: %g)' % (data.x(), data.y(), data.z())
+ elif isinstance(data, qt.QVector4D):
+ text = '(%g; %g; %g; %g)' % (data.x(), data.y(), data.z(), data.w())
+ else:
+ text = ''
+
+ painter.save()
+ painter.setRenderHint(qt.QPainter.Antialiasing, True)
+
+ # Select palette color group
+ colorGroup = qt.QPalette.Inactive
+ if option.state & qt.QStyle.State_Active:
+ colorGroup = qt.QPalette.Active
+ if not option.state & qt.QStyle.State_Enabled:
+ colorGroup = qt.QPalette.Disabled
+
+ # Draw background if selected
+ if option.state & qt.QStyle.State_Selected:
+ brush = option.palette.brush(colorGroup,
+ qt.QPalette.Highlight)
+ painter.fillRect(option.rect, brush)
+
+ # Draw text
+ if option.state & qt.QStyle.State_Selected:
+ colorRole = qt.QPalette.HighlightedText
+ else:
+ colorRole = qt.QPalette.WindowText
+ color = option.palette.color(colorGroup, colorRole)
+ painter.setPen(qt.QPen(color))
+ painter.drawText(option.rect, qt.Qt.AlignLeft, text)
+
+ painter.restore()
+
+ # The following commented code does the same as QPainter based code
+ # but it does not work with PySide
+ # self.initStyleOption(option, index)
+ # option.text = text
+ # widget = option.widget
+ # style = qt.QApplication.style() if not widget else widget.style()
+ # style.drawControl(qt.QStyle.CE_ItemViewItem, option, painter, widget)
+
+ else:
+ super(ParameterTreeDelegate, self).paint(painter, option, index)
+
+ def _commit(self, *args):
+ """Commit data to the model from editors"""
+ sender = self.sender()
+ self.commitData.emit(sender)
+
+ def editorEvent(self, event, model, option, index):
+ """See :meth:`QStyledItemDelegate.editorEvent`"""
+ if (event.type() == qt.QEvent.MouseButtonPress and
+ isinstance(index.data(qt.Qt.EditRole), qt.QColor)):
+ initialColor = index.data(qt.Qt.EditRole)
+
+ def callback(color):
+ theModel = index.model()
+ theModel.setData(index, color, qt.Qt.EditRole)
+
+ dialog = qt.QColorDialog(self.parent())
+ # dialog.setOption(qt.QColorDialog.ShowAlphaChannel, True)
+ if sys.platform == 'darwin':
+ # Use of native color dialog on macos might cause problems
+ dialog.setOption(qt.QColorDialog.DontUseNativeDialog, True)
+ dialog.setCurrentColor(initialColor)
+ dialog.currentColorChanged.connect(callback)
+ if dialog.exec_() == qt.QDialog.Rejected:
+ # Reset color
+ dialog.setCurrentColor(initialColor)
+
+ return True
+ else:
+ return super(ParameterTreeDelegate, self).editorEvent(
+ event, model, option, index)
+
+ def createEditor(self, parent, option, index):
+ """See :meth:`QStyledItemDelegate.createEditor`"""
+ data = index.data(qt.Qt.EditRole)
+ data = self._fixVariant(data)
+ editorHint = index.data(qt.Qt.UserRole)
+
+ if callable(editorHint):
+ editor = editorHint()
+ assert isinstance(editor, qt.QWidget)
+ editor.setParent(parent)
+
+ elif isinstance(data, (int, float)) and editorHint is not None:
+ # Use a slider
+ editor = IntSliderEditor(parent)
+ range_ = editorHint
+ editor.setRange(*range_)
+ editor.sliderReleased.connect(self._commit)
+
+ elif isinstance(data, six.string_types) and editorHint is not None:
+ # Use a combo box
+ editor = qt.QComboBox(parent)
+ if data not in editorHint:
+ editor.addItem(data)
+ editor.addItems(editorHint)
+
+ index = editor.findText(data)
+ editor.setCurrentIndex(index)
+
+ editor.currentIndexChanged.connect(self._commit)
+
+ else:
+ # Handle overridden editors from Python
+ # Mimic Qt C++ implementation
+ for type_, editorClass in self.EDITORS.items():
+ if isinstance(data, type_):
+ editor = editorClass(parent)
+ metaObject = editor.metaObject()
+ userProperty = metaObject.userProperty()
+ if userProperty.isValid() and userProperty.hasNotifySignal():
+ notifySignal = userProperty.notifySignal()
+ if hasattr(notifySignal, 'signature'): # Qt4
+ signature = notifySignal.signature()
+ else:
+ signature = bytes(notifySignal.methodSignature())
+
+ if hasattr(signature, 'decode'): # For PySide with python3
+ signature = signature.decode('ascii')
+ signalName = signature.split('(')[0]
+
+ signal = getattr(editor, signalName)
+ signal.connect(self._commit)
+ break
+
+ else: # Default handling for default types
+ return super(ParameterTreeDelegate, self).createEditor(
+ parent, option, index)
+
+ editor.setAutoFillBackground(True)
+ return editor
+
+ def setModelData(self, editor, model, index):
+ """See :meth:`QStyledItemDelegate.setModelData`"""
+ if isinstance(editor, tuple(self.EDITORS.values())):
+ # Special handling of Python classes
+ # Translation of QStyledItemDelegate::setModelData to Python
+ # To make it work with Python QVariant wrapping/unwrapping
+ name = editor.metaObject().userProperty().name()
+ if not name:
+ pass # TODO handle the case of missing user property
+ if name:
+ if hasattr(editor, name):
+ value = getattr(editor, name)
+ else:
+ value = editor.property(name)
+ model.setData(index, value, qt.Qt.EditRole)
+
+ else:
+ super(ParameterTreeDelegate, self).setModelData(editor, model, index)
+
+
+class ParamTreeView(qt.QTreeView):
+ """QTreeView specific to handle plot3d scene and object parameters.
+
+ It provides additional editors and specific creation of persistent editors.
+
+ :param parent: The widget's parent.
+ """
+
+ def __init__(self, parent=None):
+ super(ParamTreeView, self).__init__(parent)
+
+ header = self.header()
+ header.setMinimumSectionSize(128) # For colormap pixmaps
+ if hasattr(header, 'setSectionResizeMode'): # Qt5
+ header.setSectionResizeMode(qt.QHeaderView.ResizeToContents)
+ else: # Qt4
+ header.setResizeMode(qt.QHeaderView.ResizeToContents)
+
+ delegate = ParameterTreeDelegate()
+ self.setItemDelegate(delegate)
+
+ self.setSelectionBehavior(qt.QAbstractItemView.SelectRows)
+ self.setSelectionMode(qt.QAbstractItemView.SingleSelection)
+
+ self.expanded.connect(self._expanded)
+
+ self.setEditTriggers(qt.QAbstractItemView.CurrentChanged |
+ qt.QAbstractItemView.DoubleClicked)
+
+ self.__persistentEditors = set()
+
+ def _openEditorForIndex(self, index):
+ """Check if it has to open a persistent editor for a specific cell.
+
+ :param QModelIndex index: The cell index
+ """
+ if index.flags() & qt.Qt.ItemIsEditable:
+ data = index.data(qt.Qt.EditRole)
+ editorHint = index.data(qt.Qt.UserRole)
+ if (isinstance(data, bool) or
+ callable(editorHint) or
+ (isinstance(data, (float, int)) and editorHint)):
+ self.openPersistentEditor(index)
+ self.__persistentEditors.add(index)
+
+ def _openEditors(self, parent=qt.QModelIndex()):
+ """Open persistent editors in a subtree starting at parent.
+
+ :param QModelIndex parent: The root of the subtree to process.
+ """
+ model = self.model()
+ if model is not None:
+ for index in visitQAbstractItemModel(model, parent):
+ self._openEditorForIndex(index)
+
+ def setModel(self, model):
+ """Set the model this TreeView is displaying
+
+ :param QAbstractItemModel model:
+ """
+ super(ParamTreeView, self).setModel(model)
+ self._openEditors()
+
+ def rowsInserted(self, parent, start, end):
+ """See :meth:`QTreeView.rowsInserted`"""
+ super(ParamTreeView, self).rowsInserted(parent, start, end)
+ model = self.model()
+ if model is not None:
+ for row in range(start, end+1):
+ self._openEditorForIndex(model.index(row, 1, parent))
+ self._openEditors(model.index(row, 0, parent))
+
+ def _expanded(self, index):
+ """Handle QTreeView expanded signal"""
+ name = index.data(qt.Qt.DisplayRole)
+ if name == 'Transform':
+ rotateIndex = self.model().index(1, 0, index)
+ self.setExpanded(rotateIndex, True)
+
+ def dataChanged(self, topLeft, bottomRight, roles=()):
+ """Handle model dataChanged signal eventually closing editors"""
+ if roles: # Qt 5
+ super(ParamTreeView, self).dataChanged(topLeft, bottomRight, roles)
+ else: # Qt4 compatibility
+ super(ParamTreeView, self).dataChanged(topLeft, bottomRight)
+ if not roles or qt.Qt.UserRole in roles: # Check editorHint update
+ for row in range(topLeft.row(), bottomRight.row() + 1):
+ for column in range(topLeft.column(), bottomRight.column() + 1):
+ index = topLeft.sibling(row, column)
+ if index.isValid():
+ if self._isPersistentEditorOpen(index):
+ self.closePersistentEditor(index)
+ self._openEditorForIndex(index)
+
+ def _isPersistentEditorOpen(self, index):
+ """Returns True if a persistent editor is opened for index
+
+ :param QModelIndex index:
+ :rtype: bool
+ """
+ return index in self.__persistentEditors
+
+ def selectionCommand(self, index, event=None):
+ """Filter out selection of not selectable items"""
+ if index.flags() & qt.Qt.ItemIsSelectable:
+ return super(ParamTreeView, self).selectionCommand(index, event)
+ else:
+ return qt.QItemSelectionModel.NoUpdate
diff --git a/silx/gui/plot3d/Plot3DWidget.py b/silx/gui/plot3d/Plot3DWidget.py
index aae3955..15e2356 100644
--- a/silx/gui/plot3d/Plot3DWidget.py
+++ b/silx/gui/plot3d/Plot3DWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -54,12 +54,26 @@ class _OverviewViewport(scene.Viewport):
:param Camera camera: The camera to track.
"""
+ _SIZE = 100
+ """Size in pixels of the overview square"""
+
def __init__(self, camera=None):
super(_OverviewViewport, self).__init__()
- self.size = 100, 100
+ self.size = self._SIZE, self._SIZE
+ self.background = None # Disable clear
self.scene.transforms = [transform.Scale(2.5, 2.5, 2.5)]
+ # Add a point to draw the background (in a group with depth mask)
+ backgroundPoint = primitives.ColorPoints(
+ x=0., y=0., z=0.,
+ color=(1., 1., 1., 0.5),
+ size=self._SIZE)
+ backgroundPoint.marker = 'o'
+ noDepthGroup = primitives.GroupNoDepth(mask=True, notest=True)
+ noDepthGroup.children.append(backgroundPoint)
+ self.scene.children.append(noDepthGroup)
+
axes = primitives.Axes()
self.scene.children.append(axes)
@@ -86,6 +100,12 @@ class Plot3DWidget(glu.OpenGLWidget):
"""Signal emitted when the interactive mode has changed
"""
+ sigStyleChanged = qt.Signal(str)
+ """Signal emitted when the style of the scene has changed
+
+ It provides the updated property.
+ """
+
def __init__(self, parent=None, f=qt.Qt.WindowFlags()):
self._firstRender = True
@@ -108,7 +128,6 @@ class Plot3DWidget(glu.OpenGLWidget):
# Main viewport
self.viewport = scene.Viewport()
- self.viewport.background = 0.2, 0.2, 0.2, 1.
self._sceneScale = transform.Scale(1., 1., 1.)
self.viewport.scene.transforms = [self._sceneScale,
@@ -143,18 +162,27 @@ class Plot3DWidget(glu.OpenGLWidget):
self.viewport,
orbitAroundCenter=False,
mode='position',
- scaleTransform=self._sceneScale)
+ scaleTransform=self._sceneScale,
+ selectCB=None)
elif mode == 'pan':
self.eventHandler = interaction.PanCameraControl(
self.viewport,
+ orbitAroundCenter=False,
mode='position',
scaleTransform=self._sceneScale,
selectCB=None)
+ elif isinstance(mode, interaction.StateMachine):
+ self.eventHandler = mode
+
else:
raise ValueError('Unsupported interactive mode %s', str(mode))
+ if (self.eventHandler is not None and
+ qt.QApplication.keyboardModifiers() & qt.Qt.ControlModifier):
+ self.eventHandler.handleEvent('keyPress', qt.Qt.Key_Control)
+
self.sigInteractiveModeChanged.emit()
def getInteractiveMode(self):
@@ -208,8 +236,9 @@ class Plot3DWidget(glu.OpenGLWidget):
QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8
"""
color = rgba(color)
- self.viewport.background = color
- self.overview.background = color[0]*0.5, color[1]*0.5, color[2]*0.5, 1.
+ if color != self.viewport.background:
+ self.viewport.background = color
+ self.sigStyleChanged.emit('backgroundColor')
def getBackgroundColor(self):
"""Returns the RGBA background color (QColor)."""
@@ -233,6 +262,7 @@ class Plot3DWidget(glu.OpenGLWidget):
self._window.viewports = [self.viewport, self.overview]
else:
self._window.viewports = [self.viewport]
+ self.sigStyleChanged.emit('orientationIndicatorVisible')
def centerScene(self):
"""Position the center of the scene at the center of rotation."""
@@ -315,7 +345,7 @@ class Plot3DWidget(glu.OpenGLWidget):
self.eventHandler.handleEvent('wheel', xpixel, ypixel, angle)
def keyPressEvent(self, event):
- keycode = event.key()
+ keyCode = event.key()
# No need to accept QKeyEvent
converter = {
@@ -324,7 +354,7 @@ class Plot3DWidget(glu.OpenGLWidget):
qt.Qt.Key_Up: 'up',
qt.Qt.Key_Down: 'down'
}
- direction = converter.get(keycode, None)
+ direction = converter.get(keyCode, None)
if direction is not None:
if event.modifiers() == qt.Qt.ControlModifier:
self.viewport.camera.rotate(direction)
@@ -334,9 +364,23 @@ class Plot3DWidget(glu.OpenGLWidget):
self.viewport.orbitCamera(direction)
else:
+ if (keyCode == qt.Qt.Key_Control and
+ self.eventHandler is not None and
+ self.isValid()):
+ self.eventHandler.handleEvent('keyPress', keyCode)
+
# Key not handled, call base class implementation
super(Plot3DWidget, self).keyPressEvent(event)
+ def keyReleaseEvent(self, event):
+ """Catch Ctrl key release"""
+ keyCode = event.key()
+ if (keyCode == qt.Qt.Key_Control and
+ self.eventHandler is not None and
+ self.isValid()):
+ self.eventHandler.handleEvent('keyRelease', keyCode)
+ super(Plot3DWidget, self).keyReleaseEvent(event)
+
# Mouse events #
_MOUSE_BTNS = {1: 'left', 2: 'right', 4: 'middle'}
diff --git a/silx/gui/plot3d/Plot3DWindow.py b/silx/gui/plot3d/Plot3DWindow.py
index d8c393e..331eca2 100644
--- a/silx/gui/plot3d/Plot3DWindow.py
+++ b/silx/gui/plot3d/Plot3DWindow.py
@@ -35,9 +35,7 @@ __date__ = "26/01/2017"
from silx.gui import qt
from .Plot3DWidget import Plot3DWidget
-from .actions.viewpoint import RotateViewport
-from .tools import OutputToolBar, InteractiveModeToolBar
-from .tools import ViewpointToolButton
+from .tools import OutputToolBar, InteractiveModeToolBar, ViewpointToolBar
class Plot3DWindow(qt.QMainWindow):
@@ -52,20 +50,11 @@ class Plot3DWindow(qt.QMainWindow):
self._plot3D = Plot3DWidget()
self.setCentralWidget(self._plot3D)
- toolbar = InteractiveModeToolBar(parent=self)
- toolbar.setPlot3DWidget(self._plot3D)
- self.addToolBar(toolbar)
- self.addActions(toolbar.actions())
-
- toolbar = qt.QToolBar(self)
- toolbar.addWidget(ViewpointToolButton(plot3D=self._plot3D))
- toolbar.addAction(RotateViewport(parent=toolbar, plot3d=self._plot3D))
- self.addToolBar(toolbar)
-
- toolbar = OutputToolBar(parent=self)
- toolbar.setPlot3DWidget(self._plot3D)
- self.addToolBar(toolbar)
- self.addActions(toolbar.actions())
+ for klass in (InteractiveModeToolBar, ViewpointToolBar, OutputToolBar):
+ toolbar = klass(parent=self)
+ toolbar.setPlot3DWidget(self._plot3D)
+ self.addToolBar(toolbar)
+ self.addActions(toolbar.actions())
def getPlot3DWidget(self):
"""Get the :class:`Plot3DWidget` of this window"""
diff --git a/silx/gui/plot3d/SFViewParamTree.py b/silx/gui/plot3d/SFViewParamTree.py
index e67c17e..314e5a1 100644
--- a/silx/gui/plot3d/SFViewParamTree.py
+++ b/silx/gui/plot3d/SFViewParamTree.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -102,7 +102,7 @@ class SubjectItem(qt.QStandardItem):
Overloaded method from QStandardItem. The pushData keyword tells
the item to push data to the subject if the role is equal to EditRole.
This is useful to let this method know if the setData method was called
- internaly or from the view.
+ internally or from the view.
:param value: the value ti set to data
:param role: role in the item
@@ -355,6 +355,183 @@ class HighlightColorItem(ColorItem):
return self.subject.getHighlightColor()
+class _LightDirectionAngleBaseItem(SubjectItem):
+ """Base class for directional light angle item."""
+ editable = True
+ persistent = True
+
+ def _init(self):
+ pass
+
+ def getSignals(self):
+ """Override to provide signals to listen"""
+ raise NotImplementedError("MUST be implemented in subclass")
+
+ def _pullData(self):
+ """Override in subclass to get current angle"""
+ raise NotImplementedError("MUST be implemented in subclass")
+
+ def _pushData(self, value, role=qt.Qt.UserRole):
+ """Override in subclass to set the angle"""
+ raise NotImplementedError("MUST be implemented in subclass")
+
+ def getEditor(self, parent, option, index):
+ editor = qt.QSlider(parent)
+ editor.setOrientation(qt.Qt.Horizontal)
+ editor.setMinimum(-90)
+ editor.setMaximum(90)
+ editor.setValue(self._pullData())
+
+ # Wrapping call in lambda is a workaround for PySide with Python 3
+ editor.valueChanged.connect(
+ lambda value: self._pushData(value))
+
+ return editor
+
+ def setEditorData(self, editor):
+ editor.setValue(self._pullData())
+ return True
+
+ def _setModelData(self, editor):
+ value = editor.value()
+ self._pushData(value)
+ return True
+
+
+class LightAzimuthAngleItem(_LightDirectionAngleBaseItem):
+ """Light direction azimuth angle item."""
+
+ def getSignals(self):
+ return self.subject.sigAzimuthAngleChanged
+
+ def _pullData(self):
+ return self.subject.getAzimuthAngle()
+
+ def _pushData(self, value, role=qt.Qt.UserRole):
+ self.subject.setAzimuthAngle(value)
+
+
+class LightAltitudeAngleItem(_LightDirectionAngleBaseItem):
+ """Light direction altitude angle item."""
+
+ def getSignals(self):
+ return self.subject.sigAltitudeAngleChanged
+
+ def _pullData(self):
+ return self.subject.getAltitudeAngle()
+
+ def _pushData(self, value, role=qt.Qt.UserRole):
+ self.subject.setAltitudeAngle(value)
+
+
+class _DirectionalLightProxy(qt.QObject):
+ """Proxy to handle directional light with angles rather than vector.
+ """
+
+ sigAzimuthAngleChanged = qt.Signal()
+ """Signal sent when the azimuth angle has changed."""
+
+ sigAltitudeAngleChanged = qt.Signal()
+ """Signal sent when altitude angle has changed."""
+
+ def __init__(self, light):
+ super(_DirectionalLightProxy, self).__init__()
+ self._light = light
+ light.addListener(self._directionUpdated)
+ self._azimuth = 0.
+ self._altitude = 0.
+
+ def getAzimuthAngle(self):
+ """Returns the signed angle in the horizontal plane.
+
+ Unit: degrees.
+ The 0 angle corresponds to the axis perpendicular to the screen.
+
+ :rtype: float
+ """
+ return self._azimuth
+
+ def getAltitudeAngle(self):
+ """Returns the signed vertical angle from the horizontal plane.
+
+ Unit: degrees.
+ Range: [-90, +90]
+
+ :rtype: float
+ """
+ return self._altitude
+
+ def setAzimuthAngle(self, angle):
+ """Set the horizontal angle.
+
+ :param float angle: Angle from -z axis in zx plane in degrees.
+ """
+ if angle != self._azimuth:
+ self._azimuth = angle
+ self._updateLight()
+ self.sigAzimuthAngleChanged.emit()
+
+ def setAltitudeAngle(self, angle):
+ """Set the horizontal angle.
+
+ :param float angle: Angle from -z axis in zy plane in degrees.
+ """
+ if angle != self._altitude:
+ self._altitude = angle
+ self._updateLight()
+ self.sigAltitudeAngleChanged.emit()
+
+ def _directionUpdated(self, *args, **kwargs):
+ """Handle light direction update in the scene"""
+ # Invert direction to manipulate the 'source' pointing to
+ # the center of the viewport
+ x, y, z = - self._light.direction
+
+ # Horizontal plane is plane xz
+ azimuth = numpy.degrees(numpy.arctan2(x, z))
+ altitude = numpy.degrees(numpy.pi/2. - numpy.arccos(y))
+
+ if (abs(azimuth - self.getAzimuthAngle()) > 0.01 and
+ abs(abs(altitude) - 90.) >= 0.001): # Do not update when at zenith
+ self.setAzimuthAngle(azimuth)
+
+ if abs(altitude - self.getAltitudeAngle()) > 0.01:
+ self.setAltitudeAngle(altitude)
+
+ def _updateLight(self):
+ """Update light direction in the scene"""
+ azimuth = numpy.radians(self._azimuth)
+ delta = numpy.pi/2. - numpy.radians(self._altitude)
+ z = - numpy.sin(delta) * numpy.cos(azimuth)
+ x = - numpy.sin(delta) * numpy.sin(azimuth)
+ y = - numpy.cos(delta)
+ self._light.direction = x, y, z
+
+
+class DirectionalLightGroup(SubjectItem):
+ """
+ Root Item for the directional light
+ """
+
+ def __init__(self,subject, *args):
+ self._light = _DirectionalLightProxy(
+ subject.getPlot3DWidget().viewport.light)
+
+ super(DirectionalLightGroup, self).__init__(subject, *args)
+
+ def _init(self):
+
+ nameItem = qt.QStandardItem('Azimuth')
+ nameItem.setEditable(False)
+ valueItem = LightAzimuthAngleItem(self._light)
+ self.appendRow([nameItem, valueItem])
+
+ nameItem = qt.QStandardItem('Altitude')
+ nameItem.setEditable(False)
+ valueItem = LightAltitudeAngleItem(self._light)
+ self.appendRow([nameItem, valueItem])
+
+
class BoundingBoxItem(SubjectItem):
"""Bounding box, axes labels and grid visibility item.
@@ -402,14 +579,20 @@ class ViewSettingsItem(qt.QStandardItem):
self.setEditable(False)
- classes = (BackgroundColorItem, ForegroundColorItem,
+ classes = (BackgroundColorItem,
+ ForegroundColorItem,
HighlightColorItem,
- BoundingBoxItem, OrientationIndicatorItem)
+ BoundingBoxItem,
+ OrientationIndicatorItem)
for cls in classes:
titleItem = qt.QStandardItem(cls.itemName)
titleItem.setEditable(False)
self.appendRow([titleItem, cls(subject)])
+ nameItem = DirectionalLightGroup(subject, 'Light Direction')
+ valueItem = qt.QStandardItem()
+ self.appendRow([nameItem, valueItem])
+
# Data information ############################################################
@@ -421,7 +604,7 @@ class DataChangedItem(SubjectItem):
def getSignals(self):
subject = self.subject
if subject:
- return subject.sigDataChanged
+ return subject.sigDataChanged, subject.sigTransformChanged
return None
def _init(self):
@@ -463,6 +646,17 @@ class ScaleItem(DataChangedItem):
return ((scale is not None) and str(scale)) or 'N/A'
+class MatrixItem(DataChangedItem):
+
+ def __init__(self, subject, row, *args):
+ self.__row = row
+ super(MatrixItem, self).__init__(subject, *args)
+
+ def _pullData(self):
+ matrix = self.subject.getTransformMatrix()
+ return str(matrix[self.__row])
+
+
class DataSetItem(qt.QStandardItem):
def __init__(self, subject, *args):
@@ -471,12 +665,27 @@ class DataSetItem(qt.QStandardItem):
self.setEditable(False)
- klasses = [DataTypeItem, DataShapeItem, OffsetItem, ScaleItem]
+ klasses = [DataTypeItem, DataShapeItem, OffsetItem]
for klass in klasses:
titleItem = qt.QStandardItem(klass.itemName)
titleItem.setEditable(False)
self.appendRow([titleItem, klass(subject)])
+ matrixItem = qt.QStandardItem('matrix')
+ matrixItem.setEditable(False)
+ valueItem = qt.QStandardItem()
+ self.appendRow([matrixItem, valueItem])
+
+ for row in range(3):
+ titleItem = qt.QStandardItem()
+ titleItem.setEditable(False)
+ valueItem = MatrixItem(subject, row)
+ matrixItem.appendRow([titleItem, valueItem])
+
+ titleItem = qt.QStandardItem(ScaleItem.itemName)
+ titleItem.setEditable(False)
+ self.appendRow([titleItem, ScaleItem(subject)])
+
# Isosurface ##################################################################
@@ -559,9 +768,17 @@ class IsoSurfaceLevelItem(SubjectItem):
return [subject.sigLevelChanged,
subject.sigVisibilityChanged]
+ def getEditor(self, parent, option, index):
+ return FloatEdit(parent)
+
def setEditorData(self, editor):
+ editor.setValue(self._pullData())
return False
+ def _setModelData(self, editor):
+ self._pushData(editor.value())
+ return True
+
def _pullData(self):
return self.subject.getLevel()
@@ -1004,9 +1221,10 @@ class PlaneOrientationItem(SubjectItem):
return [self.subject.getCutPlanes()[0].sigPlaneChanged]
def _pullData(self):
- currentNormal = self.subject.getCutPlanes()[0].getNormal()
+ currentNormal = self.subject.getCutPlanes()[0].getNormal(
+ coordinates='scene')
for _, text, _, normal in self._PLANE_ACTIONS:
- if numpy.array_equal(normal, currentNormal):
+ if numpy.allclose(normal, currentNormal):
return text
return ''
@@ -1023,7 +1241,7 @@ class PlaneOrientationItem(SubjectItem):
def __editorChanged(self, index):
normal = self._PLANE_ACTIONS[index][3]
plane = self.subject.getCutPlanes()[0]
- plane.setNormal(normal)
+ plane.setNormal(normal, coordinates='scene')
plane.moveToCenter()
def setEditorData(self, editor):
@@ -1069,6 +1287,35 @@ class PlaneInterpolationItem(SubjectItem):
self.subject.getCutPlanes()[0].setInterpolation(interpolation)
+class PlaneDisplayBelowMinItem(SubjectItem):
+ """Toggle whether to display or not values <= colormap min of the cut plane
+
+ Item is checkable
+ """
+
+ def _init(self):
+ display = self.subject.getCutPlanes()[0].getDisplayValuesBelowMin()
+ self.setCheckable(True)
+ self.setCheckState(
+ qt.Qt.Checked if display else qt.Qt.Unchecked)
+ self.setData(self._pullData(), role=qt.Qt.DisplayRole, pushData=False)
+
+ def getSignals(self):
+ return [self.subject.getCutPlanes()[0].sigTransparencyChanged]
+
+ def leftClicked(self):
+ checked = self.checkState() == qt.Qt.Checked
+ self._setDisplayValuesBelowMin(checked)
+
+ def _pullData(self):
+ display = self.subject.getCutPlanes()[0].getDisplayValuesBelowMin()
+ self._setDisplayValuesBelowMin(display)
+ return "Displayed" if display else "Hidden"
+
+ def _setDisplayValuesBelowMin(self, display):
+ self.subject.getCutPlanes()[0].setDisplayValuesBelowMin(display)
+
+
class PlaneColormapItem(ColormapBase):
"""
colormap name item.
@@ -1241,6 +1488,11 @@ class PlaneGroup(SubjectItem):
valueItem = PlaneMaxRangeItem(self.subject)
self.appendRow([nameItem, valueItem])
+ nameItem = qt.QStandardItem('Values<=Min')
+ nameItem.setEditable(False)
+ valueItem = PlaneDisplayBelowMinItem(self.subject)
+ self.appendRow([nameItem, valueItem])
+
class PlaneVisibleItem(SubjectItem):
"""
diff --git a/silx/gui/plot3d/ScalarFieldView.py b/silx/gui/plot3d/ScalarFieldView.py
index 6a4d9d4..a41999b 100644
--- a/silx/gui/plot3d/ScalarFieldView.py
+++ b/silx/gui/plot3d/ScalarFieldView.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -56,48 +56,6 @@ from .tools import InteractiveModeToolBar
_logger = logging.getLogger(__name__)
-class _BoundedGroup(scene.Group):
- """Group with data bounds"""
-
- _shape = None # To provide a default value without overriding __init__
-
- @property
- def shape(self):
- """Data shape (depth, height, width) of this group or None"""
- return self._shape
-
- @shape.setter
- def shape(self, shape):
- if shape is None:
- self._shape = None
- else:
- depth, height, width = shape
- self._shape = float(depth), float(height), float(width)
-
- @property
- def size(self):
- """Data size (width, height, depth) of this group or None"""
- shape = self.shape
- if shape is None:
- return None
- else:
- return shape[2], shape[1], shape[0]
-
- @size.setter
- def size(self, size):
- if size is None:
- self.shape = None
- else:
- self.shape = size[2], size[1], size[0]
-
- def _bounds(self, dataBounds=False):
- if dataBounds and self.size is not None:
- return numpy.array(((0., 0., 0.), self.size),
- dtype=numpy.float32)
- else:
- return super(_BoundedGroup, self)._bounds(dataBounds)
-
-
class Isosurface(qt.QObject):
"""Class representing an iso-surface
@@ -272,24 +230,26 @@ class SelectedRegion(object):
:param arrayRange: Range of the selection in the array
((zmin, zmax), (ymin, ymax), (xmin, xmax))
+ :param dataBBox: Bounding box of the selection in data coordinates
+ ((xmin, xmax), (ymin, ymax), (zmin, zmax))
:param translation: Offset from array to data coordinates (ox, oy, oz)
:param scale: Scale from array to data coordinates (sx, sy, sz)
"""
- def __init__(self, arrayRange,
+ def __init__(self, arrayRange, dataBBox,
translation=(0., 0., 0.),
scale=(1., 1., 1.)):
self._arrayRange = numpy.array(arrayRange, copy=True, dtype=numpy.int)
assert self._arrayRange.shape == (3, 2)
assert numpy.all(self._arrayRange[:, 1] >= self._arrayRange[:, 0])
+
+ self._dataRange = dataBBox
+
self._translation = numpy.array(translation, dtype=numpy.float32)
assert self._translation.shape == (3,)
self._scale = numpy.array(scale, dtype=numpy.float32)
assert self._scale.shape == (3,)
- self._dataRange = (self._translation.reshape(3, -1) +
- self._arrayRange[::-1] * self._scale.reshape(3, -1))
-
def getArrayRange(self):
"""Returns array ranges of the selection: 3x2 array of int
@@ -311,6 +271,10 @@ class SelectedRegion(object):
def getDataRange(self):
"""Range in the data coordinates of the selection: 3x2 array of float
+ When the transform matrix is not the identity matrix
+ (e.g., rotation, skew) the returned range is the one of the selected
+ region bounding box in data coordinates.
+
:return: A numpy array with ((xmin, xmax), (ymin, ymax), (zmin, zmax))
:rtype: numpy.ndarray
"""
@@ -336,7 +300,8 @@ class SelectedRegion(object):
class CutPlane(qt.QObject):
"""Class representing a cutting plane
- :param ScalarFieldView sfView: Widget in which the cut plane is applied.
+ :param ~silx.gui.plot3d.ScalarFieldView.ScalarFieldView sfView:
+ Widget in which the cut plane is applied.
"""
sigVisibilityChanged = qt.Signal(bool)
@@ -357,6 +322,12 @@ class CutPlane(qt.QObject):
This signal provides the new colormap.
"""
+ sigTransparencyChanged = qt.Signal()
+ """Signal emitted when the transparency of the plane has changed.
+
+ This signal is emitted when calling :meth:`setDisplayValuesBelowMin`.
+ """
+
sigInterpolationChanged = qt.Signal(str)
"""Signal emitted when the cut plane interpolation has changed
@@ -367,12 +338,22 @@ class CutPlane(qt.QObject):
super(CutPlane, self).__init__(parent=sfView)
self._dataRange = None
+ self._visible = False
+
+ self.__syncPlane = True
- self._plane = cutplane.CutPlane(normal=(0, 1, 0))
- self._plane.alpha = 1.
- self._plane.visible = self._visible = False
- self._plane.addListener(self._planeChanged)
- self._plane.plane.addListener(self._planePositionChanged)
+ # Plane stroke on the outer bounding box
+ self._planeStroke = primitives.PlaneInGroup(normal=(0, 1, 0))
+ self._planeStroke.visible = self._visible
+ self._planeStroke.addListener(self._planeChanged)
+ self._planeStroke.plane.addListener(self._planePositionChanged)
+
+ # Plane with texture on the data bounding box
+ self._dataPlane = cutplane.CutPlane(normal=(0, 1, 0))
+ self._dataPlane.strokeVisible = False
+ self._dataPlane.alpha = 1.
+ self._dataPlane.visible = self._visible
+ self._dataPlane.plane.addListener(self._planePositionChanged)
self._colormap = Colormap(
name='gray', normalization='linear', vmin=None, vmax=None)
@@ -380,14 +361,40 @@ class CutPlane(qt.QObject):
self._updateSceneColormap()
sfView.sigDataChanged.connect(self._sfViewDataChanged)
+ sfView.sigTransformChanged.connect(self._sfViewTransformChanged)
+
+ def _get3DPrimitives(self):
+ """Return the cut plane scene node."""
+ return self._planeStroke, self._dataPlane
+
+ def _keepPlaneInBBox(self):
+ """Makes sure the plane intersect its parent bounding box if any"""
+ bounds = self._planeStroke.parent.bounds(dataBounds=True)
+ if bounds is not None:
+ self._planeStroke.plane.point = numpy.clip(
+ self._planeStroke.plane.point,
+ a_min=bounds[0], a_max=bounds[1])
+
+ @staticmethod
+ def _syncPlanes(master, slave):
+ """Move slave PlaneInGroup so that it is coplanar with master.
+
+ :param PlaneInGroup master: Reference PlaneInGroup
+ :param PlaneInGroup slave: PlaneInGroup to align
+ """
+ masterToSlave = transform.StaticTransformList([
+ slave.objectToSceneTransform.inverse(),
+ master.objectToSceneTransform])
- def _get3DPrimitive(self):
- """Return the cut plane scene node"""
- return self._plane
+ point = masterToSlave.transformPoint(
+ master.plane.point)
+ normal = masterToSlave.transformNormal(
+ master.plane.normal)
+ slave.plane.setPlane(point, normal)
def _sfViewDataChanged(self):
"""Handle data change in the ScalarFieldView this plane belongs to"""
- self._plane.setData(self.sender().getData(), copy=False)
+ self._dataPlane.setData(self.sender().getData(), copy=False)
# Store data range info as 3-tuple of values
self._dataRange = self.sender().getDataRange()
@@ -398,6 +405,15 @@ class CutPlane(qt.QObject):
if self.getColormap().isAutoscale():
self._updateSceneColormap()
+ self._keepPlaneInBBox()
+
+ def _sfViewTransformChanged(self):
+ """Handle transform changed in the ScalarFieldView"""
+ self._keepPlaneInBBox()
+ self._syncPlanes(master=self._planeStroke,
+ slave=self._dataPlane)
+ self.sigPlaneChanged.emit()
+
def _planeChanged(self, source, *args, **kwargs):
"""Handle events from the plane primitive"""
# Using _visible for now, until scene as more info in events
@@ -407,68 +423,144 @@ class CutPlane(qt.QObject):
def _planePositionChanged(self, source, *args, **kwargs):
"""Handle update of cut plane position and normal"""
- if self._plane.visible:
- self.sigPlaneChanged.emit()
+ if self.__syncPlane:
+ self.__syncPlane = False
+ if source is self._planeStroke.plane:
+ self._syncPlanes(master=self._planeStroke,
+ slave=self._dataPlane)
+ elif source is self._dataPlane.plane:
+ self._syncPlanes(master=self._dataPlane,
+ slave=self._planeStroke)
+ else:
+ _logger.error('Received an unknown object %s',
+ str(source))
+
+ if self._planeStroke.visible or self._dataPlane.visible:
+ self.sigPlaneChanged.emit()
+
+ self.__syncPlane = True
# Plane position
def moveToCenter(self):
"""Move cut plane to center of data set"""
- self._plane.moveToCenter()
+ self._planeStroke.moveToCenter()
def isValid(self):
"""Returns whether the cut plane is defined or not (bool)"""
- return self._plane.isValid
+ return self._planeStroke.isValid
+
+ def _plane(self, coordinates='array'):
+ """Returns the scene plane to set.
+
+ :param str coordinates: The coordinate system to use:
+ Either 'scene' or 'array' (default)
+ :rtype: Plane
+ :raise ValueError: If coordinates is not correct
+ """
+ if coordinates == 'scene':
+ return self._planeStroke.plane
+ elif coordinates == 'array':
+ return self._dataPlane.plane
+ else:
+ raise ValueError(
+ 'Unsupported coordinates: %s' % str(coordinates))
- def getNormal(self):
+ def getNormal(self, coordinates='array'):
"""Returns the normal of the plane (as a unit vector)
+ :param str coordinates: The coordinate system to use:
+ Either 'scene' or 'array' (default)
:return: Normal (nx, ny, nz), vector is 0 if no plane is defined
:rtype: numpy.ndarray
+ :raise ValueError: If coordinates is not correct
"""
- return self._plane.plane.normal
+ return self._plane(coordinates).normal
- def setNormal(self, normal):
- """Set the normal of the plane
+ def setNormal(self, normal, coordinates='array'):
+ """Set the normal of the plane.
:param normal: 3-tuple of float: nx, ny, nz
+ :param str coordinates: The coordinate system to use:
+ Either 'scene' or 'array' (default)
+ :raise ValueError: If coordinates is not correct
"""
- self._plane.plane.normal = normal
+ self._plane(coordinates).normal = normal
- def getPoint(self):
- """Returns a point on the plane
+ def getPoint(self, coordinates='array'):
+ """Returns a point on the plane.
+ :param str coordinates: The coordinate system to use:
+ Either 'scene' or 'array' (default)
:return: (x, y, z)
:rtype: numpy.ndarray
+ :raise ValueError: If coordinates is not correct
+ """
+ return self._plane(coordinates).point
+
+ def setPoint(self, point, constraint=True, coordinates='array'):
+ """Set a point contained in the plane.
+
+ Warning: The plane might not intersect the bounding box of the data.
+
+ :param point: (x, y, z) position
+ :type point: 3-tuple of float
+ :param bool constraint:
+ True (default) to make sure the plane intersect data bounding box,
+ False to set the plane without any constraint.
+ :raise ValueError: If coordinates is not correc
"""
- return self._plane.plane.point
+ self._plane(coordinates).point = point
+ if constraint:
+ self._keepPlaneInBBox()
- def getParameters(self):
+ def getParameters(self, coordinates='array'):
"""Returns the plane equation parameters: a*x + b*y + c*z + d = 0
+ :param str coordinates: The coordinate system to use:
+ Either 'scene' or 'array' (default)
:return: Plane equation parameters: (a, b, c, d)
:rtype: numpy.ndarray
+ :raise ValueError: If coordinates is not correct
+ """
+ return self._plane(coordinates).parameters
+
+ def setParameters(self, parameters, constraint=True, coordinates='array'):
+ """Set the plane equation parameters: a*x + b*y + c*z + d = 0
+
+ Warning: The plane might not intersect the bounding box of the data.
+
+ :param parameters: (a, b, c, d) plane equation parameters.
+ :type parameters: 4-tuple of float
+ :param bool constraint:
+ True (default) to make sure the plane intersect data bounding box,
+ False to set the plane without any constraint.
+ :raise ValueError: If coordinates is not correc
"""
- return self._plane.plane.parameters
+ self._plane(coordinates).parameters = parameters
+ if constraint:
+ self._keepPlaneInBBox()
# Visibility
def isVisible(self):
"""Returns True if the plane is visible, False otherwise"""
- return self._plane.visible
+ return self._planeStroke.visible
def setVisible(self, visible):
"""Set the visibility of the plane
:param bool visible: True to make plane visible
"""
- self._plane.visible = visible
+ visible = bool(visible)
+ self._planeStroke.visible = visible
+ self._dataPlane.visible = visible
# Border stroke
def getStrokeColor(self):
"""Returns the color of the plane border (QColor)"""
- return qt.QColor.fromRgbF(*self._plane.color)
+ return qt.QColor.fromRgbF(*self._planeStroke.color)
def setStrokeColor(self, color):
"""Set the color of the plane border.
@@ -477,7 +569,9 @@ class CutPlane(qt.QObject):
:type color:
QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8
"""
- self._plane.color = rgba(color)
+ color = rgba(color)
+ self._planeStroke.color = color
+ self._dataPlane.color = color
# Data
@@ -501,7 +595,7 @@ class CutPlane(qt.QObject):
:return: 'nearest' or 'linear'
:rtype: str
"""
- return self._plane.interpolation
+ return self._dataPlane.interpolation
def setInterpolation(self, interpolation):
"""Set the interpolation used to display to cut plane
@@ -511,7 +605,7 @@ class CutPlane(qt.QObject):
:param str interpolation: 'nearest' or 'linear'
"""
if interpolation != self.getInterpolation():
- self._plane.interpolation = interpolation
+ self._dataPlane.interpolation = interpolation
self.sigInterpolationChanged.emit(interpolation)
# Colormap
@@ -527,11 +621,29 @@ class CutPlane(qt.QObject):
# """
# self._plane.alpha = alpha
+ def getDisplayValuesBelowMin(self):
+ """Return whether values <= colormap min are displayed or not.
+
+ :rtype: bool
+ """
+ return self._dataPlane.colormap.displayValuesBelowMin
+
+ def setDisplayValuesBelowMin(self, display):
+ """Set whether to display values <= colormap min.
+
+ :param bool display: True to show values below min,
+ False to discard them
+ """
+ display = bool(display)
+ if display != self.getDisplayValuesBelowMin():
+ self._dataPlane.colormap.displayValuesBelowMin = display
+ self.sigTransparencyChanged.emit()
+
def getColormap(self):
"""Returns the colormap set by :meth:`setColormap`.
:return: The colormap
- :rtype: Colormap
+ :rtype: ~silx.gui.plot.Colormap.Colormap
"""
return self._colormap
@@ -548,7 +660,7 @@ class CutPlane(qt.QObject):
:param name: Name of the colormap in
'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue'.
Or Colormap object.
- :type name: str or Colormap
+ :type name: str or ~silx.gui.plot.Colormap.Colormap
:param str norm: Colormap mapping: 'linear' or 'log'.
:param float vmin: The minimum value of the range or None for autoscale
:param float vmax: The maximum value of the range or None for autoscale
@@ -578,21 +690,14 @@ class CutPlane(qt.QObject):
:return: 2-tuple of float
"""
- return self._plane.colormap.range_
+ return self._dataPlane.colormap.range_
def _updateSceneColormap(self):
"""Synchronizes scene's colormap with Colormap object"""
colormap = self.getColormap()
- sceneCMap = self._plane.colormap
+ sceneCMap = self._dataPlane.colormap
- indices = numpy.linspace(0., 1., 256)
- colormapDisp = Colormap(name=colormap.getName(),
- normalization=Colormap.LINEAR,
- vmin=None,
- vmax=None,
- colors=colormap.getColormapLUT())
- colors = colormapDisp.applyToData(indices)
- sceneCMap.colormap = colors
+ sceneCMap.colormap = colormap.getNColors()
sceneCMap.norm = colormap.getNormalization()
range_ = colormap.getColormapRange(data=self._dataRange)
@@ -614,14 +719,14 @@ class _CutPlaneImage(object):
def __init__(self, cutPlane):
# Init attributes with default values
self._isValid = False
- self._data = numpy.array([])
+ self._data = numpy.zeros((0, 0), dtype=numpy.float32)
+ self._index = 0
self._xLabel = ''
self._yLabel = ''
self._normalLabel = ''
- self._scale = 1., 1.
- self._translation = 0., 0.
- self._index = 0
- self._position = 0.
+ self._scale = float('nan'), float('nan')
+ self._translation = float('nan'), float('nan')
+ self._position = float('nan')
sfView = cutPlane.parent()
if not sfView or not cutPlane.isValid():
@@ -633,19 +738,30 @@ class _CutPlaneImage(object):
_logger.info("No data available")
return
- normal = cutPlane.getNormal()
- point = numpy.array(cutPlane.getPoint(), dtype=numpy.int)
+ normal = cutPlane.getNormal(coordinates='array')
+ point = cutPlane.getPoint(coordinates='array')
- if numpy.all(numpy.equal(normal, (1., 0., 0.))):
- index = max(0, min(point[0], data.shape[2] - 1))
+ if numpy.linalg.norm(numpy.cross(normal, (1., 0., 0.))) < 0.0017:
+ if not 0 <= point[0] <= data.shape[2]:
+ _logger.info("Plane outside dataset")
+ return
+ index = max(0, min(int(point[0]), data.shape[2] - 1))
slice_ = data[:, :, index]
xAxisIndex, yAxisIndex, normalAxisIndex = 1, 2, 0 # y, z, x
- elif numpy.all(numpy.equal(normal, (0., 1., 0.))):
- index = max(0, min(point[1], data.shape[1] - 1))
+
+ elif numpy.linalg.norm(numpy.cross(normal, (0., 1., 0.))) < 0.0017:
+ if not 0 <= point[1] <= data.shape[1]:
+ _logger.info("Plane outside dataset")
+ return
+ index = max(0, min(int(point[1]), data.shape[1] - 1))
slice_ = numpy.transpose(data[:, index, :])
xAxisIndex, yAxisIndex, normalAxisIndex = 2, 0, 1 # z, x, y
- elif numpy.all(numpy.equal(normal, (0., 0., 1.))):
- index = max(0, min(point[2], data.shape[0] - 1))
+
+ elif numpy.linalg.norm(numpy.cross(normal, (0., 0., 1.))) < 0.0017:
+ if not 0 <= point[2] <= data.shape[0]:
+ _logger.info("Plane outside dataset")
+ return
+ index = max(0, min(int(point[2]), data.shape[0] - 1))
slice_ = data[index, :, :]
xAxisIndex, yAxisIndex, normalAxisIndex = 0, 1, 2 # x, y, z
else:
@@ -657,21 +773,25 @@ class _CutPlaneImage(object):
self._isValid = True
self._data = numpy.array(slice_, copy=True)
+ self._index = index
- labels = sfView.getAxesLabels()
- scale = sfView.getScale()
- translation = sfView.getTranslation()
+ # Only store extra information when no transform matrix is set
+ # Otherwise this information can be meaningless
+ if numpy.all(numpy.equal(sfView.getTransformMatrix(),
+ numpy.identity(3, dtype=numpy.float32))):
+ labels = sfView.getAxesLabels()
+ self._xLabel = labels[xAxisIndex]
+ self._yLabel = labels[yAxisIndex]
+ self._normalLabel = labels[normalAxisIndex]
- self._xLabel = labels[xAxisIndex]
- self._yLabel = labels[yAxisIndex]
- self._normalLabel = labels[normalAxisIndex]
+ scale = sfView.getScale()
+ self._scale = scale[xAxisIndex], scale[yAxisIndex]
- self._scale = scale[xAxisIndex], scale[yAxisIndex]
- self._translation = translation[xAxisIndex], translation[yAxisIndex]
+ translation = sfView.getTranslation()
+ self._translation = translation[xAxisIndex], translation[yAxisIndex]
- self._index = index
- self._position = float(index * scale[normalAxisIndex] +
- translation[normalAxisIndex])
+ self._position = float(index * scale[normalAxisIndex] +
+ translation[normalAxisIndex])
def isValid(self):
"""Returns True if the cut plane image is defined (bool)"""
@@ -727,6 +847,13 @@ class ScalarFieldView(Plot3DWindow):
sigDataChanged = qt.Signal()
"""Signal emitted when the scalar data field has changed."""
+ sigTransformChanged = qt.Signal()
+ """Signal emitted when the transformation has changed.
+
+ It is emitted by :meth:`setTranslation`, :meth:`setTransformMatrix`,
+ :meth:`setScale`.
+ """
+
sigSelectedRegionChanged = qt.Signal(object)
"""Signal emitted when the selected region has changed.
@@ -745,6 +872,7 @@ class ScalarFieldView(Plot3DWindow):
# Transformations
self._dataScale = transform.Scale()
self._dataTranslate = transform.Translate()
+ self._dataTransform = transform.Matrix() # default to identity
self._foregroundColor = 1., 1., 1., 1.
self._highlightColor = 0.7, 0.7, 0., 1.
@@ -752,8 +880,13 @@ class ScalarFieldView(Plot3DWindow):
self._data = None
self._dataRange = None
- self._group = _BoundedGroup()
- self._group.transforms = [self._dataTranslate, self._dataScale]
+ self._group = primitives.BoundedGroup()
+ self._group.transforms = [
+ self._dataTranslate, self._dataTransform, self._dataScale]
+
+ self._bbox = axes.LabelledAxes()
+ self._bbox.children = [self._group]
+ self.getPlot3DWidget().viewport.scene.children.append(self._bbox)
self._selectionBox = primitives.Box()
self._selectionBox.strokeSmooth = False
@@ -766,7 +899,9 @@ class ScalarFieldView(Plot3DWindow):
self._cutPlane = CutPlane(sfView=self)
self._cutPlane.sigVisibilityChanged.connect(
self._planeVisibilityChanged)
- self._group.children.append(self._cutPlane._get3DPrimitive())
+ planeStroke, dataPlane = self._cutPlane._get3DPrimitives()
+ self._bbox.children.append(planeStroke)
+ self._group.children.append(dataPlane)
self._isogroup = primitives.GroupDepthOffset()
self._isogroup.transforms = [
@@ -781,10 +916,6 @@ class ScalarFieldView(Plot3DWindow):
]
self._group.children.append(self._isogroup)
- self._bbox = axes.LabelledAxes()
- self._bbox.children = [self._group]
- self.getPlot3DWidget().viewport.scene.children.append(self._bbox)
-
self._initPanPlaneAction()
self._updateColors()
@@ -932,9 +1063,10 @@ class ScalarFieldView(Plot3DWindow):
"""Creates and init the pan plane action"""
self._panPlaneAction = qt.QAction(self)
self._panPlaneAction.setIcon(icons.getQIcon('3d-plane-pan'))
- self._panPlaneAction.setText('plane')
+ self._panPlaneAction.setText('Pan plane')
self._panPlaneAction.setCheckable(True)
- self._panPlaneAction.setToolTip('pan the cutting plane')
+ self._panPlaneAction.setToolTip(
+ 'Pan the cutting plane. Press <b>Ctrl</b> to rotate the scene.')
self._panPlaneAction.setEnabled(False)
self._panPlaneAction.triggered[bool].connect(self._planeActionTriggered)
@@ -972,24 +1104,21 @@ class ScalarFieldView(Plot3DWindow):
sceneScale = self.getPlot3DWidget().viewport.scene.transforms[0]
if mode == 'plane':
- self.getPlot3DWidget().setInteractiveMode(None)
-
- self.getPlot3DWidget().eventHandler = \
- interaction.PanPlaneZoomOnWheelControl(
- self.getPlot3DWidget().viewport,
- self._cutPlane._get3DPrimitive(),
- mode='position',
- scaleTransform=sceneScale)
- else:
- self.getPlot3DWidget().setInteractiveMode(mode)
+ mode = interaction.PanPlaneZoomOnWheelControl(
+ self.getPlot3DWidget().viewport,
+ self._cutPlane._get3DPrimitives()[0],
+ mode='position',
+ orbitAroundCenter=False,
+ scaleTransform=sceneScale)
+
+ self.getPlot3DWidget().setInteractiveMode(mode)
self._updateColors()
def getInteractiveMode(self):
"""Returns the current interaction mode, see :meth:`setInteractiveMode`
"""
- if (isinstance(self.getPlot3DWidget().eventHandler,
- interaction.PanPlaneZoomOnWheelControl) or
- self.getPlot3DWidget().eventHandler is None):
+ if isinstance(self.getPlot3DWidget().eventHandler,
+ interaction.PanPlaneZoomOnWheelControl):
return 'plane'
else:
return self.getPlot3DWidget().getInteractiveMode()
@@ -1085,6 +1214,7 @@ class ScalarFieldView(Plot3DWindow):
scale = numpy.array((sx, sy, sz), dtype=numpy.float32)
if not numpy.all(numpy.equal(scale, self.getScale())):
self._dataScale.scale = scale
+ self.sigTransformChanged.emit()
self.centerScene() # Reset viewpoint
def getScale(self):
@@ -1102,6 +1232,7 @@ class ScalarFieldView(Plot3DWindow):
translation = numpy.array((x, y, z), dtype=numpy.float32)
if not numpy.all(numpy.equal(translation, self.getTranslation())):
self._dataTranslate.translation = translation
+ self.sigTransformChanged.emit()
self.centerScene() # Reset viewpoint
def getTranslation(self):
@@ -1109,6 +1240,28 @@ class ScalarFieldView(Plot3DWindow):
"""
return self._dataTranslate.translation
+ def setTransformMatrix(self, matrix3x3):
+ """Set the transform matrix applied to the data.
+
+ :param numpy.ndarray matrix: 3x3 transform matrix
+ """
+ matrix3x3 = numpy.array(matrix3x3, copy=True, dtype=numpy.float32)
+ if not numpy.all(numpy.equal(matrix3x3, self.getTransformMatrix())):
+ matrix = numpy.identity(4, dtype=numpy.float32)
+ matrix[:3, :3] = matrix3x3
+ self._dataTransform.setMatrix(matrix)
+ self.sigTransformChanged.emit()
+ self.centerScene() # Reset viewpoint
+
+ def getTransformMatrix(self):
+ """Returns the transform matrix applied to the data.
+
+ See :meth:`setTransformMatrix`.
+
+ :rtype: numpy.ndarray
+ """
+ return self._dataTransform.getMatrix()[:3, :3]
+
# Axes labels
def isBoundingBoxVisible(self):
@@ -1123,7 +1276,8 @@ class ScalarFieldView(Plot3DWindow):
:param bool visible: True to show axes, False to hide
"""
- self._bbox.boxVisible = bool(visible)
+ visible = bool(visible)
+ self._bbox.boxVisible = visible
def setAxesLabels(self, xlabel=None, ylabel=None, zlabel=None):
"""Set the text labels of the axes.
@@ -1297,7 +1451,9 @@ class ScalarFieldView(Plot3DWindow):
if self._selectedRange is None:
return None
else:
- return SelectedRegion(self._selectedRange,
+ dataBBox = self._group.transforms.transformBounds(
+ self._selectedRange[::-1].T).T
+ return SelectedRegion(self._selectedRange, dataBBox,
translation=self.getTranslation(),
scale=self.getScale())
diff --git a/silx/gui/plot3d/SceneWidget.py b/silx/gui/plot3d/SceneWidget.py
new file mode 100644
index 0000000..4e75515
--- /dev/null
+++ b/silx/gui/plot3d/SceneWidget.py
@@ -0,0 +1,646 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides a widget to view data sets in 3D."""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "26/10/2017"
+
+import numpy
+import weakref
+
+from silx.third_party import enum
+from .. import qt
+from ..plot.Colors import rgba
+
+from .Plot3DWidget import Plot3DWidget
+from . import items
+from .scene import interaction
+from ._model import SceneModel, visitQAbstractItemModel
+from ._model.items import Item3DRow
+
+
+__all__ = ['items', 'SceneWidget']
+
+
+class _SceneSelectionHighlightManager(object):
+ """Class controlling the highlight of the selection in a SceneWidget
+
+ :param ~silx.gui.plot3d.SceneWidget.SceneSelection:
+ """
+
+ def __init__(self, selection):
+ assert isinstance(selection, SceneSelection)
+ self._sceneWidget = weakref.ref(selection.parent())
+
+ self._enabled = True
+ self._previousBBoxState = None
+
+ self.__selectItem(selection.getCurrentItem())
+ selection.sigCurrentChanged.connect(self.__currentChanged)
+
+ def isEnabled(self):
+ """Returns True if highlight of selection in enabled.
+
+ :rtype: bool
+ """
+ return self._enabled
+
+ def setEnabled(self, enabled=True):
+ """Activate/deactivate selection highlighting
+
+ :param bool enabled: True (default) to enable selection highlighting
+ """
+ enabled = bool(enabled)
+ if enabled != self._enabled:
+ self._enabled = enabled
+
+ sceneWidget = self.getSceneWidget()
+ if sceneWidget is not None:
+ selection = sceneWidget.selection()
+ current = selection.getCurrentItem()
+
+ if enabled:
+ self.__selectItem(current)
+ selection.sigCurrentChanged.connect(self.__currentChanged)
+
+ else: # disabled
+ self.__unselectItem(current)
+ selection.sigCurrentChanged.disconnect(
+ self.__currentChanged)
+
+ def getSceneWidget(self):
+ """Returns the SceneWidget this class controls highlight for.
+
+ :rtype: ~silx.gui.plot3d.SceneWidget.SceneWidget
+ """
+ return self._sceneWidget()
+
+ def __selectItem(self, current):
+ """Highlight given item.
+
+ :param ~silx.gui.plot3d.items.Item3D current: New current or None
+ """
+ if current is None:
+ return
+
+ sceneWidget = self.getSceneWidget()
+ if sceneWidget is None:
+ return
+
+ if isinstance(current, items.DataItem3D):
+ self._previousBBoxState = current.isBoundingBoxVisible()
+ current.setBoundingBoxVisible(True)
+ current._setForegroundColor(sceneWidget.getHighlightColor())
+ current.sigItemChanged.connect(self.__selectedChanged)
+
+ def __unselectItem(self, current):
+ """Remove highlight of given item.
+
+ :param ~silx.gui.plot3d.items.Item3D current:
+ Currently highlighted item
+ """
+ if current is None:
+ return
+
+ sceneWidget = self.getSceneWidget()
+ if sceneWidget is None:
+ return
+
+ # Restore bbox visibility and color
+ current.sigItemChanged.disconnect(self.__selectedChanged)
+ if (self._previousBBoxState is not None and
+ isinstance(current, items.DataItem3D)):
+ current.setBoundingBoxVisible(self._previousBBoxState)
+ current._setForegroundColor(sceneWidget.getForegroundColor())
+
+ def __currentChanged(self, current, previous):
+ """Handle change of current item in the selection
+
+ :param ~silx.gui.plot3d.items.Item3D current: New current or None
+ :param ~silx.gui.plot3d.items.Item3D previous: Previous current or None
+ """
+ self.__unselectItem(previous)
+ self.__selectItem(current)
+
+ def __selectedChanged(self, event):
+ """Handle updates of selected item bbox.
+
+ If bbox gets changed while selected, do not restore state.
+
+ :param event:
+ """
+ if event == items.Item3DChangedType.BOUNDING_BOX_VISIBLE:
+ self._previousBBoxState = None
+
+
+@enum.unique
+class HighlightMode(enum.Enum):
+ """:class:`SceneSelection` highlight modes"""
+
+ NONE = 'noHighlight'
+ """Do not highlight selected item"""
+
+ BOUNDING_BOX = 'boundingBox'
+ """Highlight selected item bounding box"""
+
+
+class SceneSelection(qt.QObject):
+ """Object managing a :class:`SceneWidget` selection
+
+ :param SceneWidget parent:
+ """
+
+ NO_SELECTION = 0
+ """Flag for no item selected"""
+
+ sigCurrentChanged = qt.Signal(object, object)
+ """This signal is emitted whenever the current item changes.
+
+ It provides the current and previous items.
+ Either of those can be :attr:`NO_SELECTION`.
+ """
+
+ def __init__(self, parent=None):
+ super(SceneSelection, self).__init__(parent)
+ self.__current = None # Store weakref to current item
+ self.__selectionModel = None # Store sync selection model
+ self.__syncInProgress = False # True during model synchronization
+
+ self.__highlightManager = _SceneSelectionHighlightManager(self)
+
+ def getHighlightMode(self):
+ """Returns current selection highlight mode.
+
+ Either NONE or BOUNDING_BOX.
+
+ :rtype: HighlightMode
+ """
+ if self.__highlightManager.isEnabled():
+ return HighlightMode.BOUNDING_BOX
+ else:
+ return HighlightMode.NONE
+
+ def setHighlightMode(self, mode):
+ """Set selection highlighting mode
+
+ :param HighlightMode mode: The mode to use
+ """
+ assert isinstance(mode, HighlightMode)
+ self.__highlightManager.setEnabled(mode == HighlightMode.BOUNDING_BOX)
+
+ def getCurrentItem(self):
+ """Returns the current item in the scene or None.
+
+ :rtype: Union[~silx.gui.plot3d.items.Item3D, None]
+ """
+ return None if self.__current is None else self.__current()
+
+ def setCurrentItem(self, item):
+ """Set the current item in the scene.
+
+ :param Union[Item3D, None] item:
+ The new item to select or None to clear the selection.
+ :raise ValueError: If the item is not the widget's scene
+ """
+ previous = self.getCurrentItem()
+ if previous is not None:
+ previous.sigItemChanged.disconnect(self.__currentChanged)
+
+ if item is None:
+ self.__current = None
+
+ elif isinstance(item, items.Item3D):
+ parent = self.parent()
+ assert isinstance(parent, SceneWidget)
+
+ sceneGroup = parent.getSceneGroup()
+ if item is sceneGroup or item.root() is sceneGroup:
+ item.sigItemChanged.connect(self.__currentChanged)
+ self.__current = weakref.ref(item)
+ else:
+ raise ValueError(
+ 'Item is not in this SceneWidget: %s' % str(item))
+
+ else:
+ raise ValueError(
+ 'Not an Item3D: %s' % str(item))
+
+ current = self.getCurrentItem()
+ if current is not previous:
+ self.sigCurrentChanged.emit(current, previous)
+ self.__updateSelectionModel()
+
+ def __currentChanged(self, event):
+ """Handle updates of the selected item"""
+ if event == items.Item3DChangedType.ROOT_ITEM:
+ item = self.sender()
+ if item.root() != self.getSceneGroup():
+ self.setSelectedItem(None)
+
+ # Synchronization with QItemSelectionModel
+
+ def _getSyncSelectionModel(self):
+ """Returns the QItemSelectionModel this selection is synchronized with.
+
+ :rtype: Union[QItemSelectionModel, None]
+ """
+ return self.__selectionModel
+
+ def _setSyncSelectionModel(self, selectionModel):
+ """Synchronizes this selection object with a selection model.
+
+ :param Union[QItemSelectionModel, None] selectionModel:
+ :raise ValueError: If the selection model does not correspond
+ to the same :class:`SceneWidget`
+ """
+ if (not isinstance(selectionModel, qt.QItemSelectionModel) or
+ not isinstance(selectionModel.model(), SceneModel) or
+ selectionModel.model().sceneWidget() is not self.parent()):
+ raise ValueError("Expecting a QItemSelectionModel "
+ "attached to the same SceneWidget")
+
+ # Disconnect from previous selection model
+ previousSelectionModel = self._getSyncSelectionModel()
+ if previousSelectionModel is not None:
+ previousSelectionModel.selectionChanged.disconnect(
+ self.__selectionModelSelectionChanged)
+
+ self.__selectionModel = selectionModel
+
+ if selectionModel is not None:
+ # Connect to new selection model
+ selectionModel.selectionChanged.connect(
+ self.__selectionModelSelectionChanged)
+ self.__updateSelectionModel()
+
+ def __selectionModelSelectionChanged(self, selected, deselected):
+ """Handle QItemSelectionModel selection updates.
+
+ :param QItemSelection selected:
+ :param QItemSelection deselected:
+ """
+ if self.__syncInProgress:
+ return
+
+ indices = selected.indexes()
+ if not indices:
+ item = None
+
+ else: # Select the first selected item
+ index = indices[0]
+ itemRow = index.internalPointer()
+ if isinstance(itemRow, Item3DRow):
+ item = itemRow.item()
+ else:
+ item = None
+
+ self.setCurrentItem(item)
+
+ def __updateSelectionModel(self):
+ """Sync selection model when current item has been updated"""
+ selectionModel = self._getSyncSelectionModel()
+ if selectionModel is None:
+ return
+
+ currentItem = self.getCurrentItem()
+
+ if currentItem is None:
+ selectionModel.clear()
+
+ else:
+ # visit the model to find selectable index corresponding to item
+ model = selectionModel.model()
+ for index in visitQAbstractItemModel(model):
+ itemRow = index.internalPointer()
+ if (isinstance(itemRow, Item3DRow) and
+ itemRow.item() is currentItem and
+ index.flags() & qt.Qt.ItemIsSelectable):
+ # This is the item we are looking for: select it in the model
+ self.__syncInProgress = True
+ selectionModel.select(
+ index, qt.QItemSelectionModel.Clear |
+ qt.QItemSelectionModel.Select |
+ qt.QItemSelectionModel.Current)
+ self.__syncInProgress = False
+ break
+
+
+class SceneWidget(Plot3DWidget):
+ """Widget displaying data sets in 3D"""
+
+ def __init__(self, parent=None):
+ super(SceneWidget, self).__init__(parent)
+ self._model = None # Store lazy-loaded model
+ self._selection = None # Store lazy-loaded SceneSelection
+ self._items = []
+
+ self._textColor = 1., 1., 1., 1.
+ self._foregroundColor = 1., 1., 1., 1.
+ self._highlightColor = 0.7, 0.7, 0., 1.
+
+ self._sceneGroup = items.GroupWithAxesItem(parent=self)
+ self._sceneGroup.setLabel('Data')
+
+ self.viewport.scene.children.append(self._sceneGroup._getScenePrimitive())
+
+ def model(self):
+ """Returns the model corresponding the scene of this widget
+
+ :rtype: SceneModel
+ """
+ if self._model is None:
+ # Lazy-loading of the model
+ self._model = SceneModel(parent=self)
+ return self._model
+
+ def selection(self):
+ """Returns the object managing selection in the scene
+
+ :rtype: SceneSelection
+ """
+ if self._selection is None:
+ # Lazy-loading of the SceneSelection
+ self._selection = SceneSelection(parent=self)
+ return self._selection
+
+ def getSceneGroup(self):
+ """Returns the root group of the scene
+
+ :rtype: GroupItem
+ """
+ return self._sceneGroup
+
+ # Interactive modes
+
+ def _handleSelectionChanged(self, current, previous):
+ """Handle change of selection to update interactive mode"""
+ if self.getInteractiveMode() == 'panSelectedPlane':
+ if isinstance(current, items.PlaneMixIn):
+ # Update pan plane to use new selected plane
+ self.setInteractiveMode('panSelectedPlane')
+
+ else: # Switch to rotate scene if new selection is not a plane
+ self.setInteractiveMode('rotate')
+
+ def setInteractiveMode(self, mode):
+ """Set the interactive mode.
+
+ 'panSelectedPlane' mode set plane panning if a plane is selected,
+ otherwise it fall backs to 'rotate'.
+
+ :param str mode:
+ The interactive mode: 'rotate', 'pan', 'panSelectedPlane' or None
+ """
+ if self.getInteractiveMode() == 'panSelectedPlane':
+ self.selection().sigCurrentChanged.disconnect(
+ self._handleSelectionChanged)
+
+ if mode == 'panSelectedPlane':
+ selected = self.selection().getCurrentItem()
+
+ if isinstance(selected, items.PlaneMixIn):
+ mode = interaction.PanPlaneZoomOnWheelControl(
+ self.viewport,
+ selected._getPlane(),
+ mode='position',
+ orbitAroundCenter=False,
+ scaleTransform=self._sceneScale)
+
+ self.selection().sigCurrentChanged.connect(
+ self._handleSelectionChanged)
+
+ else: # No selected plane, fallback to rotate scene
+ mode = 'rotate'
+
+ super(SceneWidget, self).setInteractiveMode(mode)
+
+ def getInteractiveMode(self):
+ """Returns the interactive mode in use.
+
+ :rtype: str
+ """
+ if isinstance(self.eventHandler, interaction.PanPlaneZoomOnWheelControl):
+ return 'panSelectedPlane'
+ else:
+ return super(SceneWidget, self).getInteractiveMode()
+
+ # Add/remove items
+
+ def add3DScalarField(self, data, copy=True, index=None):
+ """Add 3D scalar data volume to :class:`SceneWidget` content.
+
+ Dataset order is zyx (i.e., first dimension is z).
+
+ :param data: 3D array
+ :type data: 3D numpy.ndarray of float32 with shape at least (2, 2, 2)
+ :param bool copy:
+ True (default) to make a copy,
+ False to avoid copy (DO NOT MODIFY data afterwards)
+ :param int index: The index at which to place the item.
+ By default it is appended to the end of the list.
+ :return: The newly created scalar volume item
+ :rtype: items.ScalarField3D
+ """
+ volume = items.ScalarField3D()
+ volume.setData(data, copy=copy)
+ self.addItem(volume, index)
+ return volume
+
+ def add3DScatter(self, x, y, z, value, copy=True, index=None):
+ """Add 3D scatter data to :class:`SceneWidget` content.
+
+ :param numpy.ndarray x: Array of X coordinates (single value not accepted)
+ :param y: Points Y coordinate (array-like or single value)
+ :param z: Points Z coordinate (array-like or single value)
+ :param value: Points values (array-like or single value)
+ :param bool copy:
+ True (default) to copy the data,
+ False to use provided data (do not modify!)
+ :param int index: The index at which to place the item.
+ By default it is appended to the end of the list.
+ :return: The newly created 3D scatter item
+ :rtype: items.Scatter3D
+ """
+ scatter3d = items.Scatter3D()
+ scatter3d.setData(x=x, y=y, z=z, value=value, copy=copy)
+ self.addItem(scatter3d, index)
+ return scatter3d
+
+ def add2DScatter(self, x, y, value, copy=True, index=None):
+ """Add 2D scatter data to :class:`SceneWidget` content.
+
+ Provided arrays must have the same length.
+
+ :param numpy.ndarray x: X coordinates (array-like)
+ :param numpy.ndarray y: Y coordinates (array-like)
+ :param value: Points value: array-like or single scalar
+ :param bool copy: True (default) to copy the data,
+ False to use as is (do not modify!).
+ :param int index: The index at which to place the item.
+ By default it is appended to the end of the list.
+ :return: The newly created 2D scatter item
+ :rtype: items.Scatter2D
+ """
+ scatter2d = items.Scatter2D()
+ scatter2d.setData(x=x, y=y, value=value, copy=copy)
+ self.addItem(scatter2d, index)
+ return scatter2d
+
+ def addImage(self, data, copy=True, index=None):
+ """Add a 2D data or RGB(A) image to :class:`SceneWidget` content.
+
+ 2D data is casted to float32.
+ RGBA supported formats are: float32 in [0, 1] and uint8.
+
+ :param numpy.ndarray data: Image as a 2D data array or
+ RGBA image as a 3D array (height, width, channels)
+ :param bool copy: True (default) to copy the data,
+ False to use as is (do not modify!).
+ :param int index: The index at which to place the item.
+ By default it is appended to the end of the list.
+ :return: The newly created image item
+ :rtype: items.ImageData or items.ImageRgba
+ :raise ValueError: For arrays of unsupported dimensions
+ """
+ data = numpy.array(data, copy=False)
+ if data.ndim == 2:
+ image = items.ImageData()
+ elif data.ndim == 3:
+ image = items.ImageRgba()
+ else:
+ raise ValueError("Unsupported array dimensions: %d" % data.ndim)
+ image.setData(data, copy=copy)
+ self.addItem(image, index)
+ return image
+
+ def addItem(self, item, index=None):
+ """Add an item to :class:`SceneWidget` content
+
+ :param Item3D item: The item to add
+ :param int index: The index at which to place the item.
+ By default it is appended to the end of the list.
+ :raise ValueError: If the item is already in the :class:`SceneWidget`.
+ """
+ return self.getSceneGroup().addItem(item, index)
+
+ def removeItem(self, item):
+ """Remove an item from :class:`SceneWidget` content.
+
+ :param Item3D item: The item to remove from the scene
+ :raises ValueError: If the item does not belong to the group
+ """
+ return self.getSceneGroup().removeItem(item)
+
+ def getItems(self):
+ """Returns the list of :class:`SceneWidget` items.
+
+ Only items in the top-level group are returned.
+
+ :rtype: tuple
+ """
+ return self.getSceneGroup().getItems()
+
+ def clearItems(self):
+ """Remove all item from :class:`SceneWidget`."""
+ return self.getSceneGroup().clear()
+
+ # Colors
+
+ def getTextColor(self):
+ """Return color used for text
+
+ :rtype: QColor"""
+ return qt.QColor.fromRgbF(*self._textColor)
+
+ def setTextColor(self, color):
+ """Set the text color.
+
+ :param color: RGB color: name, #RRGGBB or RGB values
+ :type color:
+ QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8
+ """
+ color = rgba(color)
+ if color != self._textColor:
+ self._textColor = color
+
+ # Update text color
+ # TODO make entry point in Item3D for this
+ bbox = self._sceneGroup._getScenePrimitive()
+ bbox.tickColor = color
+
+ self.sigStyleChanged.emit('textColor')
+
+ def getForegroundColor(self):
+ """Return color used for bounding box
+
+ :rtype: QColor
+ """
+ return qt.QColor.fromRgbF(*self._foregroundColor)
+
+ def setForegroundColor(self, color):
+ """Set the foreground color.
+
+ :param color: RGB color: name, #RRGGBB or RGB values
+ :type color:
+ QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8
+ """
+ color = rgba(color)
+ if color != self._foregroundColor:
+ self._foregroundColor = color
+
+ # Update scene items
+ selected = self.selection().getCurrentItem()
+ for item in self.getSceneGroup().visit(included=True):
+ if item is not selected:
+ item._setForegroundColor(color)
+
+ self.sigStyleChanged.emit('foregroundColor')
+
+ def getHighlightColor(self):
+ """Return color used for highlighted item bounding box
+
+ :rtype: QColor
+ """
+ return qt.QColor.fromRgbF(*self._highlightColor)
+
+ def setHighlightColor(self, color):
+ """Set highlighted item color.
+
+ :param color: RGB color: name, #RRGGBB or RGB values
+ :type color:
+ QColor, str or array-like of 3 or 4 float in [0., 1.] or uint8
+ """
+ color = rgba(color)
+ if color != self._highlightColor:
+ self._highlightColor = color
+
+ selected = self.selection().getCurrentItem()
+ if selected is not None:
+ selected._setForegroundColor(color)
+
+ self.sigStyleChanged.emit('highlightColor')
diff --git a/silx/gui/plot3d/SceneWindow.py b/silx/gui/plot3d/SceneWindow.py
new file mode 100644
index 0000000..5121a17
--- /dev/null
+++ b/silx/gui/plot3d/SceneWindow.py
@@ -0,0 +1,192 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides a QMainWindow with a 3D SceneWidget and toolbars.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "29/11/2017"
+
+
+from ...gui import qt, icons
+
+from .actions.mode import InteractiveModeAction
+from .SceneWidget import SceneWidget
+from .tools import OutputToolBar, InteractiveModeToolBar, ViewpointToolBar
+from .tools.GroupPropertiesWidget import GroupPropertiesWidget
+
+from .ParamTreeView import ParamTreeView
+
+# Imported here for convenience
+from . import items # noqa
+
+
+__all__ = ['items', 'SceneWidget', 'SceneWindow']
+
+
+class _PanPlaneAction(InteractiveModeAction):
+ """QAction to set plane pan interaction on a Plot3DWidget
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, plot3d=None):
+ super(_PanPlaneAction, self).__init__(
+ parent, 'panSelectedPlane', plot3d)
+ self.setIcon(icons.getQIcon('3d-plane-pan'))
+ self.setText('Pan plane')
+ self.setCheckable(True)
+ self.setToolTip(
+ 'Pan selected plane. Press <b>Ctrl</b> to rotate the scene.')
+
+ def _planeChanged(self, event):
+ """Handle plane updates"""
+ if event in (items.ItemChangedType.VISIBLE,
+ items.ItemChangedType.POSITION):
+ plane = self.sender()
+
+ isPlaneInteractive = \
+ plane._getPlane().plane.isPlane and plane.isVisible()
+
+ if isPlaneInteractive != self.isEnabled():
+ self.setEnabled(isPlaneInteractive)
+ mode = 'panSelectedPlane' if isPlaneInteractive else 'rotate'
+ self.getPlot3DWidget().setInteractiveMode(mode)
+
+ def _selectionChanged(self, current, previous):
+ """Handle selected object change"""
+ if isinstance(previous, items.PlaneMixIn):
+ previous.sigItemChanged.disconnect(self._planeChanged)
+
+ if isinstance(current, items.PlaneMixIn):
+ current.sigItemChanged.connect(self._planeChanged)
+ self.setEnabled(True)
+ self.getPlot3DWidget().setInteractiveMode('panSelectedPlane')
+ else:
+ self.setEnabled(False)
+
+ def setPlot3DWidget(self, widget):
+ previous = self.getPlot3DWidget()
+ if isinstance(previous, SceneWidget):
+ previous.selection().sigCurrentChanged.disconnect(
+ self._selectionChanged)
+ self._selectionChanged(
+ None, previous.selection().getCurrentItem())
+
+ super(_PanPlaneAction, self).setPlot3DWidget(widget)
+
+ if isinstance(widget, SceneWidget):
+ self._selectionChanged(widget.selection().getCurrentItem(), None)
+ widget.selection().sigCurrentChanged.connect(
+ self._selectionChanged)
+
+
+class SceneWindow(qt.QMainWindow):
+ """OpenGL 3D scene widget with toolbars."""
+
+ def __init__(self, parent=None):
+ super(SceneWindow, self).__init__(parent)
+ if parent is not None:
+ # behave as a widget
+ self.setWindowFlags(qt.Qt.Widget)
+
+ self._sceneWidget = SceneWidget()
+ self.setCentralWidget(self._sceneWidget)
+
+ self._interactiveModeToolBar = InteractiveModeToolBar(parent=self)
+ panPlaneAction = _PanPlaneAction(self, plot3d=self._sceneWidget)
+ self._interactiveModeToolBar.addAction(panPlaneAction)
+
+ self._viewpointToolBar = ViewpointToolBar(parent=self)
+ self._outputToolBar = OutputToolBar(parent=self)
+
+ for toolbar in (self._interactiveModeToolBar,
+ self._viewpointToolBar,
+ self._outputToolBar):
+ toolbar.setPlot3DWidget(self._sceneWidget)
+ self.addToolBar(toolbar)
+ self.addActions(toolbar.actions())
+
+ self._paramTreeView = ParamTreeView()
+ self._paramTreeView.setModel(self._sceneWidget.model())
+
+ selectionModel = self._paramTreeView.selectionModel()
+ self._sceneWidget.selection()._setSyncSelectionModel(
+ selectionModel)
+
+ paramDock = qt.QDockWidget()
+ paramDock.setWindowTitle('Object parameters')
+ paramDock.setWidget(self._paramTreeView)
+ self.addDockWidget(qt.Qt.RightDockWidgetArea, paramDock)
+
+ self._sceneGroupResetWidget = GroupPropertiesWidget()
+ self._sceneGroupResetWidget.setGroup(
+ self._sceneWidget.getSceneGroup())
+
+ resetDock = qt.QDockWidget()
+ resetDock.setWindowTitle('Global parameters')
+ resetDock.setWidget(self._sceneGroupResetWidget)
+ self.addDockWidget(qt.Qt.RightDockWidgetArea, resetDock)
+ self.tabifyDockWidget(paramDock, resetDock)
+
+ paramDock.raise_()
+
+ def getSceneWidget(self):
+ """Returns the SceneWidget of this window.
+
+ :rtype: ~silx.gui.plot3d.SceneWidget.SceneWidget
+ """
+ return self._sceneWidget
+
+ def getParamTreeView(self):
+ """Returns the :class:`ParamTreeView` of this window.
+
+ :rtype: ParamTreeView
+ """
+ return self._paramTreeView
+
+ def getInteractiveModeToolBar(self):
+ """Returns the interactive mode toolbar.
+
+ :rtype: InteractiveModeToolBar
+ """
+ return self._interactiveModeToolBar
+
+ def getViewpointToolBar(self):
+ """Returns the viewpoint toolbar.
+
+ :rtype: ViewpointToolBar
+ """
+ return self._viewpointToolBar
+
+ def getOutputToolBar(self):
+ """Returns the output toolbar.
+
+ :rtype: OutputToolBar
+ """
+ return self._outputToolBar
diff --git a/silx/gui/plot3d/_model/__init__.py b/silx/gui/plot3d/_model/__init__.py
new file mode 100644
index 0000000..4b16e32
--- /dev/null
+++ b/silx/gui/plot3d/_model/__init__.py
@@ -0,0 +1,35 @@
+# 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 package provides :class:`SceneWidget` content and parameters model.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "11/01/2018"
+
+from .model import SceneModel, visitQAbstractItemModel # noqa
diff --git a/silx/gui/plot3d/_model/core.py b/silx/gui/plot3d/_model/core.py
new file mode 100644
index 0000000..e8e0820
--- /dev/null
+++ b/silx/gui/plot3d/_model/core.py
@@ -0,0 +1,372 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""
+This module provides base classes to implement models for 3D scene content.
+"""
+
+from __future__ import absolute_import, division
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "11/01/2018"
+
+
+import collections
+import weakref
+
+from ....utils.weakref import WeakMethodProxy
+from ... import qt
+
+
+class BaseRow(qt.QObject):
+ """Base class for rows of the tree model.
+
+ The root node parent MUST be set to the QAbstractItemModel it belongs to.
+ By default item is enabled.
+
+ :param children: Iterable of BaseRow to start with (not signaled)
+ """
+
+ def __init__(self, children=()):
+ self.__modelRef = None
+ self.__parentRef = None
+ super(BaseRow, self).__init__()
+ self.__children = []
+ for row in children:
+ assert isinstance(row, BaseRow)
+ row.setParent(self)
+ self.__children.append(row)
+ self.__flags = collections.defaultdict(lambda: qt.Qt.ItemIsEnabled)
+ self.__tooltip = None
+
+ def setParent(self, parent):
+ """Override :meth:`QObject.setParent` to cache model and parent"""
+ self.__parentRef = None if parent is None else weakref.ref(parent)
+
+ if isinstance(parent, qt.QAbstractItemModel):
+ model = parent
+ elif isinstance(parent, BaseRow):
+ model = parent.model()
+ else:
+ model = None
+
+ self._updateModel(model)
+
+ super(BaseRow, self).setParent(parent)
+
+ def parent(self):
+ """Override :meth:`QObject.setParent` to use cached parent
+
+ :rtype: Union[QObject, None]"""
+ return self.__parentRef() if self.__parentRef is not None else None
+
+ def _updateModel(self, model):
+ """Update the model this row belongs to"""
+ if model != self.model():
+ self.__modelRef = weakref.ref(model) if model is not None else None
+ for child in self.children():
+ child._updateModel(model)
+
+ def model(self):
+ """Return the model this node belongs to or None if not in a model.
+
+ :rtype: Union[QAbstractItemModel, None]
+ """
+ return self.__modelRef() if self.__modelRef is not None else None
+
+ def index(self, column=0):
+ """Return corresponding index in the model or None if not in a model.
+
+ :param int column: The column to make the index for
+ :rtype: Union[QModelIndex, None]
+ """
+ parent = self.parent()
+ model = self.model()
+
+ if model is None: # Not in a model
+ return None
+ elif parent is model: # Root node
+ return qt.QModelIndex()
+ else:
+ index = parent.index()
+ row = parent.children().index(self)
+ return model.index(row, column, index)
+
+ def columnCount(self):
+ """Returns number of columns (default: 2)
+
+ :rtype: int
+ """
+ return 2
+
+ def children(self):
+ """Returns the list of children nodes
+
+ :rtype: tuple of Node
+ """
+ return tuple(self.__children)
+
+ def rowCount(self):
+ """Returns number of rows
+
+ :rtype: int
+ """
+ return len(self.__children)
+
+ def addRow(self, row, index=None):
+ """Add a node to the children
+
+ :param BaseRow row: The node to add
+ :param int index: The index at which to insert it or
+ None to append
+ """
+ if index is None:
+ index = self.rowCount()
+ assert index <= self.rowCount()
+
+ model = self.model()
+
+ if model is not None:
+ parent = self.index()
+ model.beginInsertRows(parent, index, index)
+
+ self.__children.insert(index, row)
+ row.setParent(self)
+
+ if model is not None:
+ model.endInsertRows()
+
+ def removeRow(self, row):
+ """Remove a row from the children list.
+
+ It removes either a node or a row index.
+
+ :param row: BaseRow object or index of row to remove
+ :type row: Union[BaseRow, int]
+ """
+ if isinstance(row, BaseRow):
+ row = self.__children.index(row)
+ else:
+ row = int(row)
+ assert row < self.rowCount()
+
+ model = self.model()
+
+ if model is not None:
+ index = self.index()
+ model.beginRemoveRows(index, row, row)
+
+ node = self.__children.pop(row)
+ node.setParent(None)
+
+ if model is not None:
+ model.endRemoveRows()
+
+ def data(self, column, role):
+ """Returns data for given column and role
+
+ :param int column: Column index for this row
+ :param int role: The role to get
+ :return: Corresponding data (Default: None)
+ """
+ if role == qt.Qt.ToolTipRole and self.__tooltip is not None:
+ return self.__tooltip
+ else:
+ return None
+
+ def setData(self, column, value, role):
+ """Set data for given column and role
+
+ :param int column: Column index for this row
+ :param value: The data to set
+ :param int role: The role to set
+ :return: True on success, False on failure
+ :rtype: bool
+ """
+ return False
+
+ def setToolTip(self, tooltip):
+ """Set the tooltip of the whole row.
+
+ If None there is no tooltip.
+
+ :param Union[str, None] tooltip:
+ """
+ self.__tooltip = tooltip
+
+ def setFlags(self, flags, column=None):
+ """Set the static flags to return.
+
+ Default is ItemIsEnabled for all columns.
+
+ :param int column: The column for which to set the flags
+ :param flags: Item flags
+ """
+ if column is None:
+ self.__flags = collections.defaultdict(lambda: flags)
+ else:
+ self.__flags[column] = flags
+
+ def flags(self, column):
+ """Returns flags for given column
+
+ :rtype: int
+ """
+ return self.__flags[column]
+
+
+class StaticRow(BaseRow):
+ """Row with static data.
+
+ :param tuple display: List of data for DisplayRole for each column
+ :param dict roles: Optional mapping of roles to list of data.
+ :param children: Iterable of BaseRow to start with (not signaled)
+ """
+
+ def __init__(self, display=('', None), roles=None, children=()):
+ super(StaticRow, self).__init__(children)
+ self._dataByRoles = {} if roles is None else roles
+ self._dataByRoles[qt.Qt.DisplayRole] = display
+
+ def data(self, column, role):
+ if role in self._dataByRoles:
+ data = self._dataByRoles[role]
+ if column < len(data):
+ return data[column]
+ return super(StaticRow, self).data(column, role)
+
+ def columnCount(self):
+ return len(self._dataByRoles[qt.Qt.DisplayRole])
+
+
+class ProxyRow(BaseRow):
+ """Provides a node to proxy a data accessible through functions.
+
+ Warning: Only weak reference are kept on fget and fset.
+
+ :param str name: The name of this node
+ :param callable fget: A callable returning the data
+ :param callable fset:
+ An optional callable setting the data with data as a single argument.
+ :param notify:
+ An optional signal emitted when data has changed.
+ :param callable toModelData:
+ An optional callable to convert from fget
+ callable to data returned by the model.
+ :param callable fromModelData:
+ An optional callable converting data provided to the model to
+ data for fset.
+ :param editorHint: Data to provide as UserRole for editor selection/setup
+ """
+
+ def __init__(self,
+ name='',
+ fget=None,
+ fset=None,
+ notify=None,
+ toModelData=None,
+ fromModelData=None,
+ editorHint=None):
+
+ super(ProxyRow, self).__init__()
+ self.__name = name
+ self.__editorHint = editorHint
+
+ assert fget is not None
+ self._fget = WeakMethodProxy(fget)
+ self._fset = WeakMethodProxy(fset) if fset is not None else None
+ if fset is not None:
+ self.setFlags(qt.Qt.ItemIsEnabled | qt.Qt.ItemIsEditable, 1)
+ self._toModelData = toModelData
+ self._fromModelData = fromModelData
+
+ if notify is not None:
+ notify.connect(self._notified) # TODO support sigItemChanged flags
+
+ def _notified(self, *args, **kwargs):
+ """Send update to the model upon signal notifications"""
+ index = self.index(column=1)
+ model = self.model()
+ if model is not None:
+ model.dataChanged.emit(index, index)
+
+ def data(self, column, role):
+ if column == 0:
+ if role == qt.Qt.DisplayRole:
+ return self.__name
+
+ elif column == 1:
+ if role == qt.Qt.UserRole: # EditorHint
+ return self.__editorHint
+ elif role == qt.Qt.DisplayRole or (role == qt.Qt.EditRole and
+ self._fset is not None):
+ data = self._fget()
+ if self._toModelData is not None:
+ data = self._toModelData(data)
+ return data
+
+ return super(ProxyRow, self).data(column, role)
+
+ def setData(self, column, value, role):
+ if role == qt.Qt.EditRole and self._fset is not None:
+ if self._fromModelData is not None:
+ value = self._fromModelData(value)
+ self._fset(value)
+ return True
+
+ return super(ProxyRow, self).setData(column, value, role)
+
+
+class ColorProxyRow(ProxyRow):
+ """Provides a proxy to a QColor property.
+
+ The color is returned through the decorative role.
+
+ See :class:`ProxyRow`
+ """
+
+ def data(self, column, role):
+ if column == 1: # Show color as decoration, not text
+ if role == qt.Qt.DisplayRole:
+ return None
+ if role == qt.Qt.DecorationRole:
+ role = qt.Qt.DisplayRole
+ return super(ColorProxyRow, self).data(column, role)
+
+
+class AngleDegreeRow(ProxyRow):
+ """ProxyRow patching display of column 1 to add degree symbol
+
+ See :class:`ProxyRow`
+ """
+
+ def __init__(self, *args, **kwargs):
+ super(AngleDegreeRow, self).__init__(*args, **kwargs)
+
+ def data(self, column, role):
+ if column == 1 and role == qt.Qt.DisplayRole:
+ return u'%g°' % super(AngleDegreeRow, self).data(column, role)
+ else:
+ return super(AngleDegreeRow, self).data(column, role)
diff --git a/silx/gui/plot3d/_model/items.py b/silx/gui/plot3d/_model/items.py
new file mode 100644
index 0000000..7009ea1
--- /dev/null
+++ b/silx/gui/plot3d/_model/items.py
@@ -0,0 +1,1388 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""
+This module provides base classes to implement models for 3D scene content
+"""
+
+from __future__ import absolute_import, division
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "11/01/2018"
+
+
+import functools
+import weakref
+
+import numpy
+
+from silx.third_party import six
+
+from ..._utils import convertArrayToQImage
+from ...plot.Colormap import preferredColormaps
+from ... import qt, icons
+from .. import items
+from ..items.volume import Isosurface, CutPlane
+
+
+from .core import AngleDegreeRow, BaseRow, ColorProxyRow, ProxyRow, StaticRow
+
+
+class _DirectionalLightProxy(qt.QObject):
+ """Proxy to handle directional light with angles rather than vector.
+ """
+
+ sigAzimuthAngleChanged = qt.Signal()
+ """Signal sent when the azimuth angle has changed."""
+
+ sigAltitudeAngleChanged = qt.Signal()
+ """Signal sent when altitude angle has changed."""
+
+ def __init__(self, light):
+ super(_DirectionalLightProxy, self).__init__()
+ self._light = light
+ light.addListener(self._directionUpdated)
+ self._azimuth = 0.
+ self._altitude = 0.
+
+ def getAzimuthAngle(self):
+ """Returns the signed angle in the horizontal plane.
+
+ Unit: degrees.
+ The 0 angle corresponds to the axis perpendicular to the screen.
+
+ :rtype: float
+ """
+ return self._azimuth
+
+ def getAltitudeAngle(self):
+ """Returns the signed vertical angle from the horizontal plane.
+
+ Unit: degrees.
+ Range: [-90, +90]
+
+ :rtype: float
+ """
+ return self._altitude
+
+ def setAzimuthAngle(self, angle):
+ """Set the horizontal angle.
+
+ :param float angle: Angle from -z axis in zx plane in degrees.
+ """
+ if angle != self._azimuth:
+ self._azimuth = angle
+ self._updateLight()
+ self.sigAzimuthAngleChanged.emit()
+
+ def setAltitudeAngle(self, angle):
+ """Set the horizontal angle.
+
+ :param float angle: Angle from -z axis in zy plane in degrees.
+ """
+ if angle != self._altitude:
+ self._altitude = angle
+ self._updateLight()
+ self.sigAltitudeAngleChanged.emit()
+
+ def _directionUpdated(self, *args, **kwargs):
+ """Handle light direction update in the scene"""
+ # Invert direction to manipulate the 'source' pointing to
+ # the center of the viewport
+ x, y, z = - self._light.direction
+
+ # Horizontal plane is plane xz
+ azimuth = numpy.degrees(numpy.arctan2(x, z))
+ altitude = numpy.degrees(numpy.pi/2. - numpy.arccos(y))
+
+ if (abs(azimuth - self.getAzimuthAngle()) > 0.01 and
+ abs(abs(altitude) - 90.) >= 0.001): # Do not update when at zenith
+ self.setAzimuthAngle(azimuth)
+
+ if abs(altitude - self.getAltitudeAngle()) > 0.01:
+ self.setAltitudeAngle(altitude)
+
+ def _updateLight(self):
+ """Update light direction in the scene"""
+ azimuth = numpy.radians(self._azimuth)
+ delta = numpy.pi/2. - numpy.radians(self._altitude)
+ z = - numpy.sin(delta) * numpy.cos(azimuth)
+ x = - numpy.sin(delta) * numpy.sin(azimuth)
+ y = - numpy.cos(delta)
+ self._light.direction = x, y, z
+
+
+class Settings(StaticRow):
+ """Subtree for :class:`SceneWidget` style parameters.
+
+ :param SceneWidget sceneWidget: The widget to control
+ """
+
+ def __init__(self, sceneWidget):
+ background = ColorProxyRow(
+ name='Background',
+ fget=sceneWidget.getBackgroundColor,
+ fset=sceneWidget.setBackgroundColor,
+ notify=sceneWidget.sigStyleChanged)
+
+ foreground = ColorProxyRow(
+ name='Foreground',
+ fget=sceneWidget.getForegroundColor,
+ fset=sceneWidget.setForegroundColor,
+ notify=sceneWidget.sigStyleChanged)
+
+ text = ColorProxyRow(
+ name='Text',
+ fget=sceneWidget.getTextColor,
+ fset=sceneWidget.setTextColor,
+ notify=sceneWidget.sigStyleChanged)
+
+ highlight = ColorProxyRow(
+ name='Highlight',
+ fget=sceneWidget.getHighlightColor,
+ fset=sceneWidget.setHighlightColor,
+ notify=sceneWidget.sigStyleChanged)
+
+ axesIndicator = ProxyRow(
+ name='Axes Indicator',
+ fget=sceneWidget.isOrientationIndicatorVisible,
+ fset=sceneWidget.setOrientationIndicatorVisible,
+ notify=sceneWidget.sigStyleChanged)
+
+ # Light direction
+
+ self._lightProxy = _DirectionalLightProxy(sceneWidget.viewport.light)
+
+ azimuthNode = ProxyRow(
+ name='Azimuth',
+ fget=self._lightProxy.getAzimuthAngle,
+ fset=self._lightProxy.setAzimuthAngle,
+ notify=self._lightProxy.sigAzimuthAngleChanged,
+ editorHint=(-90, 90))
+
+ altitudeNode = ProxyRow(
+ name='Altitude',
+ fget=self._lightProxy.getAltitudeAngle,
+ fset=self._lightProxy.setAltitudeAngle,
+ notify=self._lightProxy.sigAltitudeAngleChanged,
+ editorHint=(-90, 90))
+
+ lightDirection = StaticRow(('Light Direction', None),
+ children=(azimuthNode, altitudeNode))
+
+ # Settings row
+ children = (background, foreground, text, highlight,
+ axesIndicator, lightDirection)
+ super(Settings, self).__init__(('Settings', None), children=children)
+
+
+class Item3DRow(StaticRow):
+ """Represents an :class:`Item3D` with checkable visibility
+
+ :param Item3D item: The scene item to represent.
+ :param str name: The optional name of the item
+ """
+
+ def __init__(self, item, name=None):
+ if name is None:
+ name = item.getLabel()
+ super(Item3DRow, self).__init__((name, None))
+
+ self.setFlags(
+ self.flags(0) | qt.Qt.ItemIsUserCheckable | qt.Qt.ItemIsSelectable,
+ 0)
+ self.setFlags(self.flags(1) | qt.Qt.ItemIsSelectable, 1)
+
+ self._item = weakref.ref(item)
+ item.sigItemChanged.connect(self._itemChanged)
+
+ def _itemChanged(self, event):
+ """Handle visibility change"""
+ if event == items.ItemChangedType.VISIBLE:
+ model = self.model()
+ if model is not None:
+ index = self.index(column=1)
+ model.dataChanged.emit(index, index)
+
+ def item(self):
+ """Returns the :class:`Item3D` item or None"""
+ return self._item()
+
+ def data(self, column, role):
+ if column == 0 and role == qt.Qt.CheckStateRole:
+ item = self.item()
+ if item is not None and item.isVisible():
+ return qt.Qt.Checked
+ else:
+ return qt.Qt.Unchecked
+ elif column == 0 and role == qt.Qt.DecorationRole:
+ return icons.getQIcon('item-3dim')
+ else:
+ return super(Item3DRow, self).data(column, role)
+
+ def setData(self, column, value, role):
+ if column == 0 and role == qt.Qt.CheckStateRole:
+ item = self.item()
+ if item is not None:
+ item.setVisible(value == qt.Qt.Checked)
+ return True
+ else:
+ return False
+ return super(Item3DRow, self).setData(column, value, role)
+
+
+class DataItem3DBoundingBoxRow(ProxyRow):
+ """Represents :class:`DataItem3D` bounding box visibility
+
+ :param DataItem3D item: The item for which to display/control bounding box
+ """
+
+ def __init__(self, item):
+ super(DataItem3DBoundingBoxRow, self).__init__(
+ name='Bounding box',
+ fget=item.isBoundingBoxVisible,
+ fset=item.setBoundingBoxVisible,
+ notify=item.sigItemChanged)
+
+
+class MatrixProxyRow(ProxyRow):
+ """Proxy for a row of a DataItem3D 3x3 matrix transform
+
+ :param DataItem3D item:
+ :param int index: Matrix row index
+ """
+
+ def __init__(self, item, index):
+ self._item = weakref.ref(item)
+ self._index = index
+
+ super(MatrixProxyRow, self).__init__(
+ name='',
+ fget=self._getMatrixRow,
+ fset=self._setMatrixRow,
+ notify=item.sigItemChanged)
+
+ def _getMatrixRow(self):
+ """Returns the matrix row.
+
+ :rtype: QVector3D
+ """
+ item = self._item()
+ if item is not None:
+ matrix = item.getMatrix()
+ return qt.QVector3D(*matrix[self._index, :])
+ else:
+ return None
+
+ def _setMatrixRow(self, row):
+ """Set the row of the matrix
+
+ :param QVector3D row: Row values to set
+ """
+ item = self._item()
+ if item is not None:
+ matrix = item.getMatrix()
+ matrix[self._index, :] = row.x(), row.y(), row.z()
+ item.setMatrix(matrix)
+
+ def data(self, column, role):
+ data = super(MatrixProxyRow, self).data(column, role)
+
+ if column == 1 and role == qt.Qt.DisplayRole:
+ # Convert QVector3D to text
+ data = "%g; %g; %g" % (data.x(), data.y(), data.z())
+
+ return data
+
+
+class DataItem3DTransformRow(StaticRow):
+ """Represents :class:`DataItem3D` transform parameters
+
+ :param DataItem3D item: The item for which to display/control transform
+ """
+
+ _ROTATION_CENTER_OPTIONS = 'Origin', 'Lower', 'Center', 'Upper'
+
+ def __init__(self, item):
+ super(DataItem3DTransformRow, self).__init__(('Transform', None))
+ self._item = weakref.ref(item)
+
+ translation = ProxyRow(name='Translation',
+ fget=item.getTranslation,
+ fset=self._setTranslation,
+ notify=item.sigItemChanged,
+ toModelData=lambda data: qt.QVector3D(*data))
+ self.addRow(translation)
+
+ # Here to keep a reference
+ self._xSetCenter = functools.partial(self._setCenter, index=0)
+ self._ySetCenter = functools.partial(self._setCenter, index=1)
+ self._zSetCenter = functools.partial(self._setCenter, index=2)
+
+ rotateCenter = StaticRow(
+ ('Center', None),
+ children=(
+ ProxyRow(name='X axis',
+ fget=item.getRotationCenter,
+ fset=self._xSetCenter,
+ notify=item.sigItemChanged,
+ toModelData=functools.partial(
+ self._centerToModelData, index=0),
+ editorHint=self._ROTATION_CENTER_OPTIONS),
+ ProxyRow(name='Y axis',
+ fget=item.getRotationCenter,
+ fset=self._ySetCenter,
+ notify=item.sigItemChanged,
+ toModelData=functools.partial(
+ self._centerToModelData, index=1),
+ editorHint=self._ROTATION_CENTER_OPTIONS),
+ ProxyRow(name='Z axis',
+ fget=item.getRotationCenter,
+ fset=self._zSetCenter,
+ notify=item.sigItemChanged,
+ toModelData=functools.partial(
+ self._centerToModelData, index=2),
+ editorHint=self._ROTATION_CENTER_OPTIONS),
+ ))
+
+ rotate = StaticRow(
+ ('Rotation', None),
+ children=(
+ AngleDegreeRow(name='Angle',
+ fget=item.getRotation,
+ fset=self._setAngle,
+ notify=item.sigItemChanged,
+ toModelData=lambda data: data[0]),
+ ProxyRow(name='Axis',
+ fget=item.getRotation,
+ fset=self._setAxis,
+ notify=item.sigItemChanged,
+ toModelData=lambda data: qt.QVector3D(*data[1])),
+ rotateCenter
+ ))
+ self.addRow(rotate)
+
+ scale = ProxyRow(name='Scale',
+ fget=item.getScale,
+ fset=self._setScale,
+ notify=item.sigItemChanged,
+ toModelData=lambda data: qt.QVector3D(*data))
+ self.addRow(scale)
+
+ matrix = StaticRow(
+ ('Matrix', None),
+ children=(MatrixProxyRow(item, 0),
+ MatrixProxyRow(item, 1),
+ MatrixProxyRow(item, 2)))
+ self.addRow(matrix)
+
+ def item(self):
+ """Returns the :class:`Item3D` item or None"""
+ return self._item()
+
+ @staticmethod
+ def _centerToModelData(center, index):
+ """Convert rotation center information from scene to model.
+
+ :param center: The center info from the scene
+ :param int index: dimension to convert
+ """
+ value = center[index]
+ if isinstance(value, six.string_types):
+ return value.title()
+ elif value == 0.:
+ return 'Origin'
+ else:
+ return six.text_type(value)
+
+ def _setCenter(self, value, index):
+ """Set one dimension of the rotation center.
+
+ :param value: Value received through the model.
+ :param int index: dimension to set
+ """
+ item = self.item()
+ if item is not None:
+ if value == 'Origin':
+ value = 0.
+ elif value not in self._ROTATION_CENTER_OPTIONS:
+ value = float(value)
+ else:
+ value = value.lower()
+
+ center = list(item.getRotationCenter())
+ center[index] = value
+ item.setRotationCenter(*center)
+
+ def _setAngle(self, angle):
+ """Set rotation angle.
+
+ :param float angle:
+ """
+ item = self.item()
+ if item is not None:
+ _, axis = item.getRotation()
+ item.setRotation(angle, axis)
+
+ def _setAxis(self, axis):
+ """Set rotation axis.
+
+ :param QVector3D axis:
+ """
+ item = self.item()
+ if item is not None:
+ angle, _ = item.getRotation()
+ item.setRotation(angle, (axis.x(), axis.y(), axis.z()))
+
+ def _setTranslation(self, translation):
+ """Set translation transform.
+
+ :param QVector3D translation:
+ """
+ item = self.item()
+ if item is not None:
+ item.setTranslation(translation.x(), translation.y(), translation.z())
+
+ def _setScale(self, scale):
+ """Set scale transform.
+
+ :param QVector3D scale:
+ """
+ item = self.item()
+ if item is not None:
+ item.setScale(scale.x(), scale.y(), scale.z())
+
+
+class GroupItemRow(Item3DRow):
+ """Represents a :class:`GroupItem` with transforms and children
+
+ :param GroupItem item: The scene group to represent.
+ :param str name: The optional name of the group
+ """
+
+ _CHILDREN_ROW_OFFSET = 2
+ """Number of rows for group parameters. Children are added after"""
+
+ def __init__(self, item, name=None):
+ super(GroupItemRow, self).__init__(item, name)
+ self.addRow(DataItem3DBoundingBoxRow(item))
+ self.addRow(DataItem3DTransformRow(item))
+
+ item.sigItemAdded.connect(self._itemAdded)
+ item.sigItemRemoved.connect(self._itemRemoved)
+
+ for child in item.getItems():
+ self.addRow(nodeFromItem(child))
+
+ def _itemAdded(self, item):
+ """Handle item addition to the group and add it to the model.
+
+ :param Item3D item: added item
+ """
+ group = self.item()
+ if group is None:
+ return
+
+ row = group.getItems().index(item)
+ self.addRow(nodeFromItem(item), row + self._CHILDREN_ROW_OFFSET)
+
+ def _itemRemoved(self, item):
+ """Handle item removal from the group and remove it from the model.
+
+ :param Item3D item: removed item
+ """
+ group = self.item()
+ if group is None:
+ return
+
+ # Find item
+ for row in self.children():
+ if row.item() is item:
+ self.removeRow(row)
+ break # Got it
+ else:
+ raise RuntimeError("Model does not correspond to scene content")
+
+
+class InterpolationRow(ProxyRow):
+ """Represents :class:`InterpolationMixIn` property.
+
+ :param Item3D item: Scene item with interpolation property
+ """
+
+ def __init__(self, item):
+ modes = [mode.title() for mode in item.INTERPOLATION_MODES]
+ super(InterpolationRow, self).__init__(
+ name='Interpolation',
+ fget=item.getInterpolation,
+ fset=item.setInterpolation,
+ notify=item.sigItemChanged,
+ toModelData=lambda mode: mode.title(),
+ fromModelData=lambda mode: mode.lower(),
+ editorHint=modes)
+
+
+class _ColormapBaseProxyRow(ProxyRow):
+ """Base class for colormap model row
+
+ This class handle synchronization and signals from the item and the colormap
+ """
+
+ _sigColormapChanged = qt.Signal()
+ """Signal used internally to notify colormap (or data) update"""
+
+ def __init__(self, item, *args, **kwargs):
+ self._dataRange = None
+ self._item = weakref.ref(item)
+ self._colormap = item.getColormap()
+
+ ProxyRow.__init__(self, *args, **kwargs)
+
+ self._colormap.sigChanged.connect(self._colormapChanged)
+ item.sigItemChanged.connect(self._itemChanged)
+ self._sigColormapChanged.connect(self._modelUpdated)
+
+ def item(self):
+ """Returns the :class:`ColormapMixIn` item or None"""
+ return self._item()
+
+ def _getColormapRange(self):
+ """Returns the range of the colormap for the current data.
+
+ :return: Colormap range (min, max)
+ """
+ if self._dataRange is None:
+ item = self.item()
+ if item is not None and self._colormap is not None:
+ if hasattr(item, 'getDataRange'):
+ data = item.getDataRange()
+ else:
+ data = item.getData(copy=False)
+
+ self._dataRange = self._colormap.getColormapRange(data)
+
+ else: # Fallback
+ self._dataRange = 1, 100
+ return self._dataRange
+
+ def _modelUpdated(self, *args, **kwargs):
+ """Emit dataChanged in the model"""
+ topLeft = self.index(column=0)
+ bottomRight = self.index(column=1)
+ model = self.model()
+ if model is not None:
+ model.dataChanged.emit(topLeft, bottomRight)
+
+ def _colormapChanged(self):
+ self._sigColormapChanged.emit()
+
+ def _itemChanged(self, event):
+ """Handle change of colormap or data in the item.
+
+ :param ItemChangedType event:
+ """
+ if event == items.ItemChangedType.COLORMAP:
+ self._sigColormapChanged.emit()
+ if self._colormap is not None:
+ self._colormap.sigChanged.disconnect(self._colormapChanged)
+
+ item = self.item()
+ if item is not None:
+ self._colormap = item.getColormap()
+ self._colormap.sigChanged.connect(self._colormapChanged)
+ else:
+ self._colormap = None
+
+ elif event == items.ItemChangedType.DATA:
+ self._dataRange = None
+ self._sigColormapChanged.emit()
+
+
+class _ColormapBoundRow(_ColormapBaseProxyRow):
+ """ProxyRow for colormap min or max
+
+ :param ColormapMixIn item: The item to handle
+ :param str name: Name of the raw
+ :param int index: 0 for Min and 1 of Max
+ """
+
+ def __init__(self, item, name, index):
+ self._index = index
+ _ColormapBaseProxyRow.__init__(
+ self,
+ item,
+ name=name,
+ fget=self._getBound,
+ fset=self._setBound)
+
+ self.setToolTip('Colormap %s bound:\n'
+ 'Check to set bound manually, '
+ 'uncheck for autoscale' % name.lower())
+
+ def _getRawBound(self):
+ """Proxy to get raw colormap bound
+
+ :rtype: float or None
+ """
+ if self._colormap is None:
+ return None
+ elif self._index == 0:
+ return self._colormap.getVMin()
+ else: # self._index == 1
+ return self._colormap.getVMax()
+
+ def _getBound(self):
+ """Proxy to get colormap effective bound value
+
+ :rtype: float
+ """
+ if self._colormap is not None:
+ bound = self._getRawBound()
+
+ if bound is None:
+ bound = self._getColormapRange()[self._index]
+ return bound
+ else:
+ return 1. # Fallback
+
+ def _setBound(self, value):
+ """Proxy to set colormap bound.
+
+ :param float value:
+ """
+ if self._colormap is not None:
+ if self._index == 0:
+ min_ = value
+ max_ = self._colormap.getVMax()
+ else: # self._index == 1
+ min_ = self._colormap.getVMin()
+ max_ = value
+
+ if max_ is not None and min_ is not None and min_ > max_:
+ min_, max_ = max_, min_
+ self._colormap.setVRange(min_, max_)
+
+ def flags(self, column):
+ if column == 0:
+ return qt.Qt.ItemIsEnabled | qt.Qt.ItemIsUserCheckable
+
+ elif column == 1:
+ if self._getRawBound() is not None:
+ flags = qt.Qt.ItemIsEditable | qt.Qt.ItemIsEnabled
+ else:
+ flags = qt.Qt.NoItemFlags # Disabled if autoscale
+ return flags
+
+ else: # Never event
+ return super(_ColormapBoundRow, self).flags(column)
+
+ def data(self, column, role):
+ if column == 0 and role == qt.Qt.CheckStateRole:
+ if self._getRawBound() is None:
+ return qt.Qt.Unchecked
+ else:
+ return qt.Qt.Checked
+
+ else:
+ return super(_ColormapBoundRow, self).data(column, role)
+
+ def setData(self, column, value, role):
+ if column == 0 and role == qt.Qt.CheckStateRole:
+ if self._colormap is not None:
+ bound = self._getBound() if value == qt.Qt.Checked else None
+ self._setBound(bound)
+ return True
+ else:
+ return False
+
+ return super(_ColormapBoundRow, self).setData(column, value, role)
+
+
+class ColormapRow(_ColormapBaseProxyRow):
+ """Represents :class:`ColormapMixIn` property.
+
+ :param Item3D item: Scene item with colormap property
+ """
+
+ def __init__(self, item):
+ super(ColormapRow, self).__init__(
+ item,
+ name='Colormap',
+ fget=self._get)
+
+ self._colormapImage = None
+
+ self._colormapsMapping = {}
+ for cmap in preferredColormaps():
+ self._colormapsMapping[cmap.title()] = cmap
+
+ self.addRow(ProxyRow(
+ name='Name',
+ fget=self._getName,
+ fset=self._setName,
+ notify=self._sigColormapChanged,
+ editorHint=list(self._colormapsMapping.keys())))
+
+ norms = [norm.title() for norm in self._colormap.NORMALIZATIONS]
+ self.addRow(ProxyRow(
+ name='Normalization',
+ fget=self._getNormalization,
+ fset=self._setNormalization,
+ notify=self._sigColormapChanged,
+ editorHint=norms))
+
+ self.addRow(_ColormapBoundRow(item, name='Min.', index=0))
+ self.addRow(_ColormapBoundRow(item, name='Max.', index=1))
+
+ self._sigColormapChanged.connect(self._updateColormapImage)
+
+ def _get(self):
+ """Getter for ProxyRow subclass"""
+ return None
+
+ def _getName(self):
+ """Proxy for :meth:`Colormap.getName`"""
+ if self._colormap is not None:
+ return self._colormap.getName().title()
+ else:
+ return ''
+
+ def _setName(self, name):
+ """Proxy for :meth:`Colormap.setName`"""
+ # Convert back from titled to name if possible
+ if self._colormap is not None:
+ name = self._colormapsMapping.get(name, name)
+ self._colormap.setName(name)
+
+ def _getNormalization(self):
+ """Proxy for :meth:`Colormap.getNormalization`"""
+ if self._colormap is not None:
+ return self._colormap.getNormalization().title()
+ else:
+ return ''
+
+ def _setNormalization(self, normalization):
+ """Proxy for :meth:`Colormap.setNormalization`"""
+ if self._colormap is not None:
+ return self._colormap.setNormalization(normalization.lower())
+
+ def _updateColormapImage(self, *args, **kwargs):
+ """Notify colormap update to update the image in the tree"""
+ if self._colormapImage is not None:
+ self._colormapImage = None
+ model = self.model()
+ if model is not None:
+ index = self.index(column=1)
+ model.dataChanged.emit(index, index)
+
+ def data(self, column, role):
+ if column == 1 and role == qt.Qt.DecorationRole:
+ if self._colormapImage is None:
+ image = numpy.zeros((16, 130, 3), dtype=numpy.uint8)
+ image[1:-1, 1:-1] = self._colormap.getNColors(image.shape[1] - 2)[:, :3]
+ self._colormapImage = convertArrayToQImage(image)
+ return self._colormapImage
+
+ return super(ColormapRow, self).data(column, role)
+
+
+class SymbolRow(ProxyRow):
+ """Represents :class:`SymbolMixIn` symbol property.
+
+ :param Item3D item: Scene item with symbol property
+ """
+
+ def __init__(self, item):
+ names = [item.getSymbolName(s) for s in item.getSupportedSymbols()]
+ super(SymbolRow, self).__init__(
+ name='Marker',
+ fget=item.getSymbolName,
+ fset=item.setSymbol,
+ notify=item.sigItemChanged,
+ editorHint=names)
+
+
+class SymbolSizeRow(ProxyRow):
+ """Represents :class:`SymbolMixIn` symbol size property.
+
+ :param Item3D item: Scene item with symbol size property
+ """
+
+ def __init__(self, item):
+ super(SymbolSizeRow, self).__init__(
+ name='Marker size',
+ fget=item.getSymbolSize,
+ fset=item.setSymbolSize,
+ notify=item.sigItemChanged,
+ editorHint=(1, 20)) # TODO link with OpenGL max point size
+
+
+class PlaneRow(ProxyRow):
+ """Represents :class:`PlaneMixIn` property.
+
+ :param Item3D item: Scene item with plane equation property
+ """
+
+ def __init__(self, item):
+ super(PlaneRow, self).__init__(
+ name='Equation',
+ fget=item.getParameters,
+ fset=item.setParameters,
+ notify=item.sigItemChanged,
+ toModelData=lambda data: qt.QVector4D(*data),
+ fromModelData=lambda data: (data.x(), data.y(), data.z(), data.w()))
+ self._item = weakref.ref(item)
+
+ def data(self, column, role):
+ if column == 1 and role == qt.Qt.DisplayRole:
+ item = self._item()
+ if item is not None:
+ params = item.getParameters()
+ return ('%gx %+gy %+gz %+g = 0' %
+ (params[0], params[1], params[2], params[3]))
+ return super(PlaneRow, self).data(column, role)
+
+
+class RemoveIsosurfaceRow(BaseRow):
+ """Class for Isosurface Delete button
+
+ :param Isosurface isosurface: The isosurface item to attach the button to.
+ """
+
+ def __init__(self, isosurface):
+ super(RemoveIsosurfaceRow, self).__init__()
+ self._isosurface = weakref.ref(isosurface)
+
+ def createEditor(self):
+ """Specific editor factory provided to the model"""
+ editor = qt.QWidget()
+ layout = qt.QHBoxLayout(editor)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ removeBtn = qt.QToolButton()
+ removeBtn.setText('Delete')
+ removeBtn.setToolButtonStyle(qt.Qt.ToolButtonTextOnly)
+ layout.addWidget(removeBtn)
+ removeBtn.clicked.connect(self._removeClicked)
+
+ layout.addStretch(1)
+ return editor
+
+ def isosurface(self):
+ """Returns the controlled isosurface
+
+ :rtype: Isosurface
+ """
+ return self._isosurface()
+
+ def data(self, column, role):
+ if column == 0 and role == qt.Qt.UserRole: # editor hint
+ return self.createEditor
+
+ return super(RemoveIsosurfaceRow, self).data(column, role)
+
+ def flags(self, column):
+ flags = super(RemoveIsosurfaceRow, self).flags(column)
+ if column == 0:
+ flags |= qt.Qt.ItemIsEditable
+ return flags
+
+ def _removeClicked(self):
+ """Handle Delete button clicked"""
+ isosurface = self.isosurface()
+ if isosurface is not None:
+ scalarField3D = isosurface.parent()
+ if scalarField3D is not None:
+ scalarField3D.removeIsosurface(isosurface)
+
+
+class IsosurfaceRow(Item3DRow):
+ """Represents an :class:`Isosurface` item.
+
+ :param Isosurface item: Isosurface item
+ """
+
+ _LEVEL_SLIDER_RANGE = 0, 1000
+ """Range given as editor hint"""
+
+ def __init__(self, item):
+ super(IsosurfaceRow, self).__init__(item, name=item.getLevel())
+
+ self.setFlags(self.flags(1) | qt.Qt.ItemIsEditable, 1)
+
+ item.sigItemChanged.connect(self._levelChanged)
+
+ self.addRow(ProxyRow(
+ name='Level',
+ fget=self._getValueForLevelSlider,
+ fset=self._setLevelFromSliderValue,
+ notify=item.sigItemChanged,
+ editorHint=self._LEVEL_SLIDER_RANGE))
+
+ self.addRow(ColorProxyRow(
+ name='Color',
+ fget=self._rgbColor,
+ fset=self._setRgbColor,
+ notify=item.sigItemChanged))
+
+ self.addRow(ProxyRow(
+ name='Opacity',
+ fget=self._opacity,
+ fset=self._setOpacity,
+ notify=item.sigItemChanged,
+ editorHint=(0, 255)))
+
+ self.addRow(RemoveIsosurfaceRow(item))
+
+ def _getValueForLevelSlider(self):
+ """Convert iso level to slider value.
+
+ :rtype: int
+ """
+ item = self.item()
+ if item is not None:
+ scalarField3D = item.parent()
+ if scalarField3D is not None:
+ dataRange = scalarField3D.getDataRange()
+ if dataRange is not None:
+ dataMin, dataMax = dataRange[0], dataRange[-1]
+ offset = (item.getLevel() - dataMin) / (dataMax - dataMin)
+
+ sliderMin, sliderMax = self._LEVEL_SLIDER_RANGE
+ value = sliderMin + (sliderMax - sliderMin) * offset
+ return value
+ return 0
+
+ def _setLevelFromSliderValue(self, value):
+ """Convert slider value to isolevel.
+
+ :param int value:
+ """
+ item = self.item()
+ if item is not None:
+ scalarField3D = item.parent()
+ if scalarField3D is not None:
+ dataRange = scalarField3D.getDataRange()
+ if dataRange is not None:
+ sliderMin, sliderMax = self._LEVEL_SLIDER_RANGE
+ offset = (value - sliderMin) / (sliderMax - sliderMin)
+
+ dataMin, dataMax = dataRange[0], dataRange[-1]
+ level = dataMin + (dataMax - dataMin) * offset
+ item.setLevel(level)
+
+ def _rgbColor(self):
+ """Proxy to get the isosurface's RGB color without transparency
+
+ :rtype: QColor
+ """
+ item = self.item()
+ if item is None:
+ return None
+ else:
+ color = item.getColor()
+ color.setAlpha(255)
+ return color
+
+ def _setRgbColor(self, color):
+ """Proxy to set the isosurface's RGB color without transparency
+
+ :param QColor color:
+ """
+ item = self.item()
+ if item is not None:
+ color.setAlpha(item.getColor().alpha())
+ item.setColor(color)
+
+ def _opacity(self):
+ """Proxy to get the isosurface's transparency
+
+ :rtype: int
+ """
+ item = self.item()
+ return 255 if item is None else item.getColor().alpha()
+
+ def _setOpacity(self, opacity):
+ """Proxy to set the isosurface's transparency.
+
+ :param int opacity:
+ """
+ item = self.item()
+ if item is not None:
+ color = item.getColor()
+ color.setAlpha(opacity)
+ item.setColor(color)
+
+ def _levelChanged(self, event):
+ """Handle isosurface level changed and notify model
+
+ :param ItemChangedType event:
+ """
+ if event == items.Item3DChangedType.ISO_LEVEL:
+ model = self.model()
+ if model is not None:
+ index = self.index(column=1)
+ model.dataChanged.emit(index, index)
+
+ def data(self, column, role):
+ if column == 0: # Show color as decoration, not text
+ if role == qt.Qt.DisplayRole:
+ return None
+ elif role == qt.Qt.DecorationRole:
+ return self._rgbColor()
+
+ elif column == 1 and role in (qt.Qt.DisplayRole, qt.Qt.EditRole):
+ item = self.item()
+ return None if item is None else item.getLevel()
+
+ return super(IsosurfaceRow, self).data(column, role)
+
+ def setData(self, column, value, role):
+ if column == 1 and role == qt.Qt.EditRole:
+ item = self.item()
+ if item is not None:
+ item.setLevel(value)
+ return True
+
+ return super(IsosurfaceRow, self).setData(column, value, role)
+
+
+class AddIsosurfaceRow(BaseRow):
+ """Class for Isosurface create button
+
+ :param ScalarField3D scalarField3D:
+ The ScalarField3D item to attach the button to.
+ """
+
+ def __init__(self, scalarField3D):
+ super(AddIsosurfaceRow, self).__init__()
+ self._scalarField3D = weakref.ref(scalarField3D)
+
+ def createEditor(self):
+ """Specific editor factory provided to the model"""
+ editor = qt.QWidget()
+ layout = qt.QHBoxLayout(editor)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ addBtn = qt.QToolButton()
+ addBtn.setText('+')
+ addBtn.setToolButtonStyle(qt.Qt.ToolButtonTextOnly)
+ layout.addWidget(addBtn)
+ addBtn.clicked.connect(self._addClicked)
+
+ layout.addStretch(1)
+ return editor
+
+ def scalarField3D(self):
+ """Returns the controlled ScalarField3D
+
+ :rtype: ScalarField3D
+ """
+ return self._scalarField3D()
+
+ def data(self, column, role):
+ if column == 0 and role == qt.Qt.UserRole: # editor hint
+ return self.createEditor
+
+ return super(AddIsosurfaceRow, self).data(column, role)
+
+ def flags(self, column):
+ flags = super(AddIsosurfaceRow, self).flags(column)
+ if column == 0:
+ flags |= qt.Qt.ItemIsEditable
+ return flags
+
+ def _addClicked(self):
+ """Handle Delete button clicked"""
+ scalarField3D = self.scalarField3D()
+ if scalarField3D is not None:
+ dataRange = scalarField3D.getDataRange()
+ if dataRange is None:
+ dataRange = 0., 1.
+
+ scalarField3D.addIsosurface(
+ numpy.mean((dataRange[0], dataRange[-1])),
+ '#0000FF')
+
+
+class ScalarField3DIsoSurfacesRow(StaticRow):
+ """Represents :class:`ScalarFieldView`'s isosurfaces
+
+ :param ScalarFieldView scalarField3D: ScalarFieldView to control
+ """
+
+ def __init__(self, scalarField3D):
+ super(ScalarField3DIsoSurfacesRow, self).__init__(
+ ('Isosurfaces', None))
+ self._scalarField3D = weakref.ref(scalarField3D)
+
+ scalarField3D.sigIsosurfaceAdded.connect(self._isosurfaceAdded)
+ scalarField3D.sigIsosurfaceRemoved.connect(self._isosurfaceRemoved)
+
+ for item in scalarField3D.getIsosurfaces():
+ self.addRow(nodeFromItem(item))
+
+ self.addRow(AddIsosurfaceRow(scalarField3D))
+
+ def scalarField3D(self):
+ """Returns the controlled ScalarField3D
+
+ :rtype: ScalarField3D
+ """
+ return self._scalarField3D()
+
+ def _isosurfaceAdded(self, item):
+ """Handle isosurface addition
+
+ :param Isosurface item: added isosurface
+ """
+ scalarField3D = self.scalarField3D()
+ if scalarField3D is None:
+ return
+
+ row = scalarField3D.getIsosurfaces().index(item)
+ self.addRow(nodeFromItem(item), row)
+
+ def _isosurfaceRemoved(self, item):
+ """Handle isosurface removal
+
+ :param Isosurface item: removed isosurface
+ """
+ scalarField3D = self.scalarField3D()
+ if scalarField3D is None:
+ return
+
+ # Find item
+ for row in self.children():
+ if row.item() is item:
+ self.removeRow(row)
+ break # Got it
+ else:
+ raise RuntimeError("Model does not correspond to scene content")
+
+
+class Scatter2DPropertyMixInRow(object):
+ """Mix-in class that enable/disable row according to Scatter2D mode.
+
+ :param Scatter2D item:
+ :param str propertyName: Name of the Scatter2D property of this row
+ """
+
+ def __init__(self, item, propertyName):
+ assert propertyName in ('lineWidth', 'symbol', 'symbolSize')
+ self.__propertyName = propertyName
+
+ self.__isEnabled = item.isPropertyEnabled(propertyName)
+ self.__updateFlags()
+
+ item.sigItemChanged.connect(self.__itemChanged)
+
+ def data(self, column, role):
+ if column == 1 and not self.__isEnabled:
+ # Discard data and editorHint if disabled
+ return None
+ else:
+ return super(Scatter2DPropertyMixInRow, self).data(column, role)
+
+ def __updateFlags(self):
+ """Update model flags"""
+ if self.__isEnabled:
+ self.setFlags(qt.Qt.ItemIsEnabled, 0)
+ self.setFlags(qt.Qt.ItemIsEnabled | qt.Qt.ItemIsEditable, 1)
+ else:
+ self.setFlags(qt.Qt.NoItemFlags)
+
+ def __itemChanged(self, event):
+ """Set flags to enable/disable the row"""
+ if event == items.ItemChangedType.VISUALIZATION_MODE:
+ item = self.sender()
+ if item is not None: # This occurs with PySide/python2.7
+ self.__isEnabled = item.isPropertyEnabled(self.__propertyName)
+ self.__updateFlags()
+
+ # Notify model
+ model = self.model()
+ if model is not None:
+ begin = self.index(column=0)
+ end = self.index(column=1)
+ model.dataChanged.emit(begin, end)
+
+
+class Scatter2DSymbolRow(Scatter2DPropertyMixInRow, SymbolRow):
+ """Specific class for Scatter2D symbol.
+
+ It is enabled/disabled according to visualization mode.
+
+ :param Scatter2D item:
+ """
+
+ def __init__(self, item):
+ SymbolRow.__init__(self, item)
+ Scatter2DPropertyMixInRow.__init__(self, item, 'symbol')
+
+
+class Scatter2DSymbolSizeRow(Scatter2DPropertyMixInRow, SymbolSizeRow):
+ """Specific class for Scatter2D symbol size.
+
+ It is enabled/disabled according to visualization mode.
+
+ :param Scatter2D item:
+ """
+
+ def __init__(self, item):
+ SymbolSizeRow.__init__(self, item)
+ Scatter2DPropertyMixInRow.__init__(self, item, 'symbolSize')
+
+
+class Scatter2DLineWidth(Scatter2DPropertyMixInRow, ProxyRow):
+ """Specific class for Scatter2D symbol size.
+
+ It is enabled/disabled according to visualization mode.
+
+ :param Scatter2D item:
+ """
+
+ def __init__(self, item):
+ # TODO link editorHint with OpenGL max line width
+ ProxyRow.__init__(self,
+ name='Line width',
+ fget=item.getLineWidth,
+ fset=item.setLineWidth,
+ notify=item.sigItemChanged,
+ editorHint=(1, 10))
+ Scatter2DPropertyMixInRow.__init__(self, item, 'lineWidth')
+
+
+def initScatter2DNode(node, item):
+ """Specific node init for Scatter2D to set order of parameters
+
+ :param Item3DRow node: The model node to setup
+ :param Scatter2D item: The Scatter2D the node is representing
+ """
+ node.addRow(ProxyRow(
+ name='Mode',
+ fget=item.getVisualization,
+ fset=item.setVisualization,
+ notify=item.sigItemChanged,
+ editorHint=[m.title() for m in item.supportedVisualizations()],
+ toModelData=lambda data: data.title(),
+ fromModelData=lambda data: data.lower()))
+
+ node.addRow(ProxyRow(
+ name='Height map',
+ fget=item.isHeightMap,
+ fset=item.setHeightMap,
+ notify=item.sigItemChanged))
+
+ node.addRow(ColormapRow(item))
+
+ node.addRow(Scatter2DSymbolRow(item))
+ node.addRow(Scatter2DSymbolSizeRow(item))
+
+ node.addRow(Scatter2DLineWidth(item))
+
+
+def initScalarField3DNode(node, item):
+ """Specific node init for ScalarField3D
+
+ :param Item3DRow node: The model node to setup
+ :param ScalarField3D item: The ScalarField3D the node is representing
+ """
+ node.addRow(nodeFromItem(item.getCutPlanes()[0])) # Add cut plane
+ node.addRow(ScalarField3DIsoSurfacesRow(item))
+
+
+def initScalarField3DCutPlaneNode(node, item):
+ """Specific node init for ScalarField3D CutPlane
+
+ :param Item3DRow node: The model node to setup
+ :param CutPlane item: The CutPlane the node is representing
+ """
+ node.addRow(PlaneRow(item))
+
+ node.addRow(ColormapRow(item))
+
+ node.addRow(ProxyRow(
+ name='Values<=Min',
+ fget=item.getDisplayValuesBelowMin,
+ fset=item.setDisplayValuesBelowMin,
+ notify=item.sigItemChanged))
+
+ node.addRow(InterpolationRow(item))
+
+
+NODE_SPECIFIC_INIT = [ # class, init(node, item)
+ (items.Scatter2D, initScatter2DNode),
+ (items.ScalarField3D, initScalarField3DNode),
+ (CutPlane, initScalarField3DCutPlaneNode),
+]
+"""List of specific node init for different item class"""
+
+
+def nodeFromItem(item):
+ """Create :class:`Item3DRow` subclass corresponding to item
+
+ :param Item3D item: The item fow which to create the node
+ :rtype: Item3DRow
+ """
+ assert isinstance(item, items.Item3D)
+
+ # Item with specific model row class
+ if isinstance(item, (items.GroupItem, items.GroupWithAxesItem)):
+ return GroupItemRow(item)
+ elif isinstance(item, Isosurface):
+ return IsosurfaceRow(item)
+
+ # Create Item3DRow and populate it
+ node = Item3DRow(item)
+
+ if isinstance(item, items.DataItem3D):
+ node.addRow(DataItem3DBoundingBoxRow(item))
+ node.addRow(DataItem3DTransformRow(item))
+
+ # Specific extra init
+ for cls, specificInit in NODE_SPECIFIC_INIT:
+ if isinstance(item, cls):
+ specificInit(node, item)
+ break
+
+ else: # Generic case: handle mixins
+ for cls in item.__class__.__mro__:
+ if cls is items.ColormapMixIn:
+ node.addRow(ColormapRow(item))
+
+ elif cls is items.InterpolationMixIn:
+ node.addRow(InterpolationRow(item))
+
+ elif cls is items.SymbolMixIn:
+ node.addRow(SymbolRow(item))
+ node.addRow(SymbolSizeRow(item))
+
+ elif cls is items.PlaneMixIn:
+ node.addRow(PlaneRow(item))
+
+ return node
diff --git a/silx/gui/plot3d/_model/model.py b/silx/gui/plot3d/_model/model.py
new file mode 100644
index 0000000..186838f
--- /dev/null
+++ b/silx/gui/plot3d/_model/model.py
@@ -0,0 +1,184 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""
+This module provides the :class:`SceneWidget` content and parameters model.
+"""
+
+from __future__ import absolute_import, division
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "11/01/2018"
+
+
+import weakref
+
+from ... import qt
+
+from .core import BaseRow
+from .items import Settings, nodeFromItem
+
+
+def visitQAbstractItemModel(model, parent=qt.QModelIndex()):
+ """Iterate over indices in the model starting from parent
+
+ It iterates column by column and row by row
+ (i.e., from left to right and from top to bottom).
+ Parent are returned before their children.
+ It only iterates through the children for the first column of a row.
+
+ :param QAbstractItemModel model: The model to visit
+ :param QModelIndex parent:
+ Index from which to start visiting the model.
+ Default: start from the root
+ """
+ assert isinstance(model, qt.QAbstractItemModel)
+ assert isinstance(parent, qt.QModelIndex)
+ assert parent.model() is model or not parent.isValid()
+
+ for row in range(model.rowCount(parent)):
+ for column in range(model.columnCount(parent)):
+ index = model.index(row, column, parent)
+ yield index
+
+ index = model.index(row, 0, parent)
+ for index in visitQAbstractItemModel(model, index):
+ yield index
+
+
+class Root(BaseRow):
+ """Root node of :class:`SceneWidget` parameters.
+
+ It has two children:
+ - Settings
+ - Scene group
+ """
+
+ def __init__(self, model, sceneWidget):
+ super(Root, self).__init__()
+ self._sceneWidget = weakref.ref(sceneWidget)
+ self.setParent(model) # Needed for Root
+
+ def children(self):
+ sceneWidget = self._sceneWidget()
+ if sceneWidget is None:
+ return ()
+ else:
+ return super(Root, self).children()
+
+
+class SceneModel(qt.QAbstractItemModel):
+ """Model of a :class:`SceneWidget`.
+
+ :param SceneWidget parent: The SceneWidget this model represents.
+ """
+
+ def __init__(self, parent):
+ self._sceneWidget = weakref.ref(parent)
+
+ super(SceneModel, self).__init__(parent)
+ self._root = Root(self, parent)
+ self._root.addRow(Settings(parent))
+ self._root.addRow(nodeFromItem(parent.getSceneGroup()))
+
+ def sceneWidget(self):
+ """Returns the :class:`SceneWidget` this model represents.
+
+ In case the widget has already been deleted, it returns None
+
+ :rtype: SceneWidget
+ """
+ return self._sceneWidget()
+
+ def _itemFromIndex(self, index):
+ """Returns the corresponding :class:`Node` or :class:`Item3D`.
+
+ :param QModelIndex index:
+ :rtype: Node or Item3D
+ """
+ return index.internalPointer() if index.isValid() else self._root
+
+ def index(self, row, column, parent=qt.QModelIndex()):
+ """See :meth:`QAbstractItemModel.index`"""
+ if column >= self.columnCount(parent) or row >= self.rowCount(parent):
+ return qt.QModelIndex()
+
+ item = self._itemFromIndex(parent)
+ return self.createIndex(row, column, item.children()[row])
+
+ def parent(self, index):
+ """See :meth:`QAbstractItemModel.parent`"""
+ if not index.isValid():
+ return qt.QModelIndex()
+
+ item = self._itemFromIndex(index)
+ parent = item.parent()
+
+ ancestor = parent.parent()
+
+ if ancestor is not self: # root node
+ children = ancestor.children()
+ row = children.index(parent)
+ return self.createIndex(row, 0, parent)
+
+ return qt.QModelIndex()
+
+ def rowCount(self, parent=qt.QModelIndex()):
+ """See :meth:`QAbstractItemModel.rowCount`"""
+ item = self._itemFromIndex(parent)
+ return item.rowCount()
+
+ def columnCount(self, parent=qt.QModelIndex()):
+ """See :meth:`QAbstractItemModel.columnCount`"""
+ item = self._itemFromIndex(parent)
+ return item.columnCount()
+
+ def data(self, index, role=qt.Qt.DisplayRole):
+ """See :meth:`QAbstractItemModel.data`"""
+ item = self._itemFromIndex(index)
+ column = index.column()
+ return item.data(column, role)
+
+ def setData(self, index, value, role=qt.Qt.EditRole):
+ """See :meth:`QAbstractItemModel.setData`"""
+ item = self._itemFromIndex(index)
+ column = index.column()
+ if item.setData(column, value, role):
+ self.dataChanged.emit(index, index)
+ return True
+ return False
+
+ def flags(self, index):
+ """See :meth:`QAbstractItemModel.flags`"""
+ item = self._itemFromIndex(index)
+ column = index.column()
+ return item.flags(column)
+
+ def headerData(self, section, orientation, role=qt.Qt.DisplayRole):
+ """See :meth:`QAbstractItemModel.headerData`"""
+ if orientation == qt.Qt.Horizontal and role == qt.Qt.DisplayRole:
+ return 'Item' if section == 0 else 'Value'
+ else:
+ return None
diff --git a/silx/gui/plot3d/actions/Plot3DAction.py b/silx/gui/plot3d/actions/Plot3DAction.py
index a1faaea..94b9572 100644
--- a/silx/gui/plot3d/actions/Plot3DAction.py
+++ b/silx/gui/plot3d/actions/Plot3DAction.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -44,7 +44,8 @@ class Plot3DAction(qt.QAction):
"""QAction associated to a Plot3DWidget
:param parent: See :class:`QAction`
- :param Plot3DWidget plot3d: Plot3DWidget the action is associated with
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
def __init__(self, parent, plot3d=None):
@@ -55,7 +56,8 @@ class Plot3DAction(qt.QAction):
def setPlot3DWidget(self, widget):
"""Set the Plot3DWidget this action is associated with
- :param Plot3DWidget widget: The Plot3DWidget to use
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget widget:
+ The Plot3DWidget to use
"""
self._plot3d = None if widget is None else weakref.ref(widget)
@@ -64,6 +66,6 @@ class Plot3DAction(qt.QAction):
If no widget is associated, it returns None.
- :rtype: qt.QWidget
+ :rtype: QWidget
"""
return None if self._plot3d is None else self._plot3d()
diff --git a/silx/gui/plot3d/actions/io.py b/silx/gui/plot3d/actions/io.py
index 18f91b4..5126000 100644
--- a/silx/gui/plot3d/actions/io.py
+++ b/silx/gui/plot3d/actions/io.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -54,7 +54,8 @@ class CopyAction(Plot3DAction):
"""QAction to provide copy of a Plot3DWidget
:param parent: See :class:`QAction`
- :param Plot3DWidget plot3d: Plot3DWidget the action is associated with
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
def __init__(self, parent, plot3d=None):
@@ -81,7 +82,8 @@ class SaveAction(Plot3DAction):
"""QAction to provide save snapshot of a Plot3DWidget
:param parent: See :class:`QAction`
- :param Plot3DWidget plot3d: Plot3DWidget the action is associated with
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
def __init__(self, parent, plot3d=None):
@@ -135,7 +137,8 @@ class PrintAction(Plot3DAction):
"""QAction to provide printing of a Plot3DWidget
:param parent: See :class:`QAction`
- :param Plot3DWidget plot3d: Plot3DWidget the action is associated with
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
def __init__(self, parent, plot3d=None):
@@ -152,7 +155,7 @@ class PrintAction(Plot3DAction):
def getPrinter(self):
"""Return the QPrinter instance used for printing.
- :rtype: qt.QPrinter
+ :rtype: QPrinter
"""
# TODO This is a hack to sync with silx plot PrintAction
# This needs to be centralized
@@ -201,6 +204,8 @@ class VideoAction(Plot3DAction):
The scene is rotated 360 degrees around a vertical axis.
:param parent: Action parent see :class:`QAction`.
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
PNG_SERIE_FILTER = 'Serie of PNG files (*.png)'
diff --git a/silx/gui/plot3d/actions/mode.py b/silx/gui/plot3d/actions/mode.py
index a06b9a8..b591290 100644
--- a/silx/gui/plot3d/actions/mode.py
+++ b/silx/gui/plot3d/actions/mode.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -48,7 +48,8 @@ class InteractiveModeAction(Plot3DAction):
:param parent: See :class:`QAction`
:param str interaction: The interactive mode this action controls
- :param Plot3DWidget plot3d: Plot3DWidget the action is associated with
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
def __init__(self, parent, interaction, plot3d=None):
@@ -100,7 +101,8 @@ class RotateArcballAction(InteractiveModeAction):
"""QAction to set arcball rotation interaction on a Plot3DWidget
:param parent: See :class:`QAction`
- :param Plot3DWidget plot3d: Plot3DWidget the action is associated with
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
def __init__(self, parent, plot3d=None):
@@ -108,14 +110,15 @@ class RotateArcballAction(InteractiveModeAction):
self.setIcon(getQIcon('rotate-3d'))
self.setText('Rotate')
- self.setToolTip('Rotate the view')
+ self.setToolTip('Rotate the view. Press <b>Ctrl</b> to pan.')
class PanAction(InteractiveModeAction):
"""QAction to set pan interaction on a Plot3DWidget
:param parent: See :class:`QAction`
- :param Plot3DWidget plot3d: Plot3DWidget the action is associated with
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
def __init__(self, parent, plot3d=None):
@@ -123,4 +126,4 @@ class PanAction(InteractiveModeAction):
self.setIcon(getQIcon('pan'))
self.setText('Pan')
- self.setToolTip('Pan the view')
+ self.setToolTip('Pan the view. Press <b>Ctrl</b> to rotate.')
diff --git a/silx/gui/plot3d/actions/viewpoint.py b/silx/gui/plot3d/actions/viewpoint.py
index 6aa7400..d764c40 100644
--- a/silx/gui/plot3d/actions/viewpoint.py
+++ b/silx/gui/plot3d/actions/viewpoint.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -45,11 +45,144 @@ from .Plot3DAction import Plot3DAction
_logger = logging.getLogger(__name__)
-class RotateViewport(Plot3DAction):
+class _SetViewpointAction(Plot3DAction):
+ """Base class for actions setting a Plot3DWidget viewpoint
+
+ :param parent: See :class:`QAction`
+ :param str face: The name of the predefined viewpoint
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, face, plot3d=None):
+ super(_SetViewpointAction, self).__init__(parent, plot3d)
+ assert face in ('side', 'front', 'back', 'left', 'right', 'top', 'bottom')
+ self._face = face
+
+ self.setIconVisibleInMenu(True)
+ self.setCheckable(False)
+ self.triggered[bool].connect(self._triggered)
+
+ def _triggered(self, checked=False):
+ plot3d = self.getPlot3DWidget()
+ if plot3d is None:
+ _logger.error(
+ 'Cannot start/stop rotation, no associated Plot3DWidget')
+ else:
+ plot3d.viewport.camera.extrinsic.reset(face=self._face)
+ plot3d.centerScene()
+
+
+class FrontViewpointAction(_SetViewpointAction):
+ """QAction to set Plot3DWidget viewpoint to look from the front
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, plot3d=None):
+ super(FrontViewpointAction, self).__init__(parent, 'front', plot3d)
+
+ self.setIcon(getQIcon('cube-front'))
+ self.setText('Front')
+ self.setToolTip('View along the -Z axis')
+
+
+class BackViewpointAction(_SetViewpointAction):
+ """QAction to set Plot3DWidget viewpoint to look from the back
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, plot3d=None):
+ super(BackViewpointAction, self).__init__(parent, 'back', plot3d)
+
+ self.setIcon(getQIcon('cube-back'))
+ self.setText('Back')
+ self.setToolTip('View along the +Z axis')
+
+
+class LeftViewpointAction(_SetViewpointAction):
+ """QAction to set Plot3DWidget viewpoint to look from the left
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, plot3d=None):
+ super(LeftViewpointAction, self).__init__(parent, 'left', plot3d)
+
+ self.setIcon(getQIcon('cube-left'))
+ self.setText('Left')
+ self.setToolTip('View along the +X axis')
+
+
+class RightViewpointAction(_SetViewpointAction):
+ """QAction to set Plot3DWidget viewpoint to look from the right
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, plot3d=None):
+ super(RightViewpointAction, self).__init__(parent, 'right', plot3d)
+
+ self.setIcon(getQIcon('cube-right'))
+ self.setText('Right')
+ self.setToolTip('View along the -X axis')
+
+
+class TopViewpointAction(_SetViewpointAction):
+ """QAction to set Plot3DWidget viewpoint to look from the top
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, plot3d=None):
+ super(TopViewpointAction, self).__init__(parent, 'top', plot3d)
+
+ self.setIcon(getQIcon('cube-top'))
+ self.setText('Top')
+ self.setToolTip('View along the -Y axis')
+
+
+class BottomViewpointAction(_SetViewpointAction):
+ """QAction to set Plot3DWidget viewpoint to look from the bottom
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, plot3d=None):
+ super(BottomViewpointAction, self).__init__(parent, 'bottom', plot3d)
+
+ self.setIcon(getQIcon('cube-bottom'))
+ self.setText('Bottom')
+ self.setToolTip('View along the +Y axis')
+
+
+class SideViewpointAction(_SetViewpointAction):
+ """QAction to set Plot3DWidget viewpoint to look from the side
+
+ :param parent: See :class:`QAction`
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
+ """
+ def __init__(self, parent, plot3d=None):
+ super(SideViewpointAction, self).__init__(parent, 'side', plot3d)
+
+ self.setIcon(getQIcon('cube'))
+ self.setText('Side')
+ self.setToolTip('Side view')
+
+
+class RotateViewpoint(Plot3DAction):
"""QAction to rotate the scene of a Plot3DWidget
:param parent: See :class:`QAction`
- :param Plot3DWidget plot3d: Plot3DWidget the action is associated with
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget plot3d:
+ Plot3DWidget the action is associated with
"""
_TIMEOUT_MS = 50
@@ -59,7 +192,7 @@ class RotateViewport(Plot3DAction):
"""Rotation speed of the animation"""
def __init__(self, parent, plot3d=None):
- super(RotateViewport, self).__init__(parent, plot3d)
+ super(RotateViewpoint, self).__init__(parent, plot3d)
self._previousTime = None
diff --git a/silx/gui/plot3d/items/__init__.py b/silx/gui/plot3d/items/__init__.py
new file mode 100644
index 0000000..b50ea5a
--- /dev/null
+++ b/silx/gui/plot3d/items/__init__.py
@@ -0,0 +1,43 @@
+# 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 package provides classes that describes :class:`.SceneWidget` content.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/11/2017"
+
+
+from .core import DataItem3D, Item3D, GroupItem, GroupWithAxesItem # noqa
+from .core import ItemChangedType, Item3DChangedType # noqa
+from .mixins import (ColormapMixIn, InterpolationMixIn, # noqa
+ PlaneMixIn, SymbolMixIn) # noqa
+from .clipplane import ClipPlane # noqa
+from .image import ImageData, ImageRgba # noqa
+from .mesh import Mesh # noqa
+from .scatter import Scatter2D, Scatter3D # noqa
+from .volume import ScalarField3D # noqa
diff --git a/silx/gui/plot3d/items/clipplane.py b/silx/gui/plot3d/items/clipplane.py
new file mode 100644
index 0000000..a5ba0e6
--- /dev/null
+++ b/silx/gui/plot3d/items/clipplane.py
@@ -0,0 +1,50 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides a scene clip plane class.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/11/2017"
+
+
+from ..scene import primitives
+
+from .core import Item3D
+from .mixins import PlaneMixIn
+
+
+class ClipPlane(Item3D, PlaneMixIn):
+ """Represents a clipping plane that clips following items within the group.
+
+ For now only on clip plane is allowed at once in a scene.
+ """
+
+ def __init__(self, parent=None):
+ plane = primitives.ClipPlane()
+ Item3D.__init__(self, parent=parent, primitive=plane)
+ PlaneMixIn.__init__(self, plane=plane)
diff --git a/silx/gui/plot3d/items/core.py b/silx/gui/plot3d/items/core.py
new file mode 100644
index 0000000..e549e59
--- /dev/null
+++ b/silx/gui/plot3d/items/core.py
@@ -0,0 +1,622 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides the base class for items of the :class:`.SceneWidget`.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/11/2017"
+
+from collections import defaultdict
+
+import numpy
+
+from silx.third_party import enum, six
+
+from ... import qt
+from ...plot.items import ItemChangedType
+from .. import scene
+from ..scene import axes, primitives, transform
+
+
+@enum.unique
+class Item3DChangedType(enum.Enum):
+ """Type of modification provided by :attr:`Item3D.sigItemChanged` signal."""
+
+ INTERPOLATION = 'interpolationChanged'
+ """Item3D image interpolation changed flag."""
+
+ TRANSFORM = 'transformChanged'
+ """Item3D transform changed flag."""
+
+ HEIGHT_MAP = 'heightMapChanged'
+ """Item3D height map changed flag."""
+
+ ISO_LEVEL = 'isoLevelChanged'
+ """Isosurface level changed flag."""
+
+ LABEL = 'labelChanged'
+ """Item's label changed flag."""
+
+ BOUNDING_BOX_VISIBLE = 'boundingBoxVisibleChanged'
+ """Item's bounding box visibility changed"""
+
+ ROOT_ITEM = 'rootItemChanged'
+ """Item's root changed flag."""
+
+
+class Item3D(qt.QObject):
+ """Base class representing an item in the scene.
+
+ :param parent: The View widget this item belongs to.
+ :param primitive: An optional primitive to use as scene primitive
+ """
+
+ _LABEL_INDICES = defaultdict(int)
+ """Store per class label indices"""
+
+ sigItemChanged = qt.Signal(object)
+ """Signal emitted when an item's property has changed.
+
+ It provides a flag describing which property of the item has changed.
+ See :class:`ItemChangedType` and :class:`Item3DChangedType`
+ for flags description.
+ """
+
+ def __init__(self, parent, primitive=None):
+ qt.QObject.__init__(self, parent)
+
+ if primitive is None:
+ primitive = scene.Group()
+
+ self._primitive = primitive
+
+ self.__syncForegroundColor()
+
+ labelIndex = self._LABEL_INDICES[self.__class__]
+ self._label = six.text_type(self.__class__.__name__)
+ if labelIndex != 0:
+ self._label += u' %d' % labelIndex
+ self._LABEL_INDICES[self.__class__] += 1
+
+ if isinstance(parent, Item3D):
+ parent.sigItemChanged.connect(self.__parentItemChanged)
+
+ def setParent(self, parent):
+ """Override set parent to handle root item change"""
+ previousParent = self.parent()
+ if isinstance(previousParent, Item3D):
+ previousParent.sigItemChanged.disconnect(self.__parentItemChanged)
+
+ super(Item3D, self).setParent(parent)
+
+ if isinstance(parent, Item3D):
+ parent.sigItemChanged.connect(self.__parentItemChanged)
+
+ self._updated(Item3DChangedType.ROOT_ITEM)
+
+ def __parentItemChanged(self, event):
+ """Handle updates of the parent if it is an Item3D
+
+ :param Item3DChangedType event:
+ """
+ if event == Item3DChangedType.ROOT_ITEM:
+ self._updated(Item3DChangedType.ROOT_ITEM)
+
+ def root(self):
+ """Returns the root of the scene this item belongs to.
+
+ The root is the up-most Item3D in the scene tree hierarchy.
+
+ :rtype: Union[Item3D, None]
+ """
+ root = None
+ ancestor = self.parent()
+ while isinstance(ancestor, Item3D):
+ root = ancestor
+ ancestor = ancestor.parent()
+
+ return root
+
+ def _getScenePrimitive(self):
+ """Return the group containing the item rendering"""
+ return self._primitive
+
+ def _updated(self, event=None):
+ """Handle MixIn class updates.
+
+ :param event: The event to send to :attr:`sigItemChanged` signal.
+ """
+ if event == Item3DChangedType.ROOT_ITEM:
+ self.__syncForegroundColor()
+
+ if event is not None:
+ self.sigItemChanged.emit(event)
+
+ # Label
+
+ def getLabel(self):
+ """Returns the label associated to this item.
+
+ :rtype: str
+ """
+ return self._label
+
+ def setLabel(self, label):
+ """Set the label associated to this item.
+
+ :param str label:
+ """
+ label = six.text_type(label)
+ if label != self._label:
+ self._label = label
+ self._updated(Item3DChangedType.LABEL)
+
+ # Visibility
+
+ def isVisible(self):
+ """Returns True if item is visible, else False
+
+ :rtype: bool
+ """
+ return self._getScenePrimitive().visible
+
+ def setVisible(self, visible=True):
+ """Set the visibility of the item in the scene.
+
+ :param bool visible: True (default) to show the item, False to hide
+ """
+ visible = bool(visible)
+ primitive = self._getScenePrimitive()
+ if visible != primitive.visible:
+ primitive.visible = visible
+ self._updated(ItemChangedType.VISIBLE)
+
+ # Foreground color
+
+ def _setForegroundColor(self, color):
+ """Set the foreground color of the item.
+
+ The default implementation does nothing, override it in subclass.
+
+ :param color: RGBA color
+ :type color: tuple of 4 float in [0., 1.]
+ """
+ if hasattr(super(Item3D, self), '_setForegroundColor'):
+ super(Item3D, self)._setForegroundColor(color)
+
+ def __syncForegroundColor(self):
+ """Retrieve foreground color from parent and update this item"""
+ # Look-up for SceneWidget to get its foreground color
+ root = self.root()
+ if root is not None:
+ widget = root.parent()
+ if isinstance(widget, qt.QWidget):
+ self._setForegroundColor(
+ widget.getForegroundColor().getRgbF())
+
+
+class DataItem3D(Item3D):
+ """Base class representing a data item with transform in the scene.
+
+ :param parent: The View widget this item belongs to.
+ :param Union[GroupBBox, None] group:
+ The scene group to use for rendering
+ """
+
+ def __init__(self, parent, group=None):
+ if group is None:
+ group = primitives.GroupBBox()
+
+ # Set-up bounding box
+ group.boxVisible = False
+ group.axesVisible = False
+ else:
+ assert isinstance(group, primitives.GroupBBox)
+
+ Item3D.__init__(self, parent=parent, primitive=group)
+
+ # Transformations
+ self._translate = transform.Translate()
+ self._rotateForwardTranslation = transform.Translate()
+ self._rotate = transform.Rotate()
+ self._rotateBackwardTranslation = transform.Translate()
+ self._translateFromRotationCenter = transform.Translate()
+ self._matrix = transform.Matrix()
+ self._scale = transform.Scale()
+ # Group transforms to do to data before rotation
+ # This is useful to handle rotation center relative to bbox
+ self._transformObjectToRotate = transform.TransformList(
+ [self._matrix, self._scale])
+ self._transformObjectToRotate.addListener(self._updateRotationCenter)
+
+ self._rotationCenter = 0., 0., 0.
+
+ self._getScenePrimitive().transforms = [
+ self._translate,
+ self._rotateForwardTranslation,
+ self._rotate,
+ self._rotateBackwardTranslation,
+ self._transformObjectToRotate]
+
+ def _updated(self, event=None):
+ """Handle MixIn class updates.
+
+ :param event: The event to send to :attr:`sigItemChanged` signal.
+ """
+ if event == ItemChangedType.DATA:
+ self._updateRotationCenter()
+ super(DataItem3D, self)._updated(event)
+
+ # Transformations
+
+ def setScale(self, sx=1., sy=1., sz=1.):
+ """Set the scale of the item in the scene.
+
+ :param float sx: Scale factor along the X axis
+ :param float sy: Scale factor along the Y axis
+ :param float sz: Scale factor along the Z axis
+ """
+ scale = numpy.array((sx, sy, sz), dtype=numpy.float32)
+ if not numpy.all(numpy.equal(scale, self.getScale())):
+ self._scale.scale = scale
+ self._updated(Item3DChangedType.TRANSFORM)
+
+ def getScale(self):
+ """Returns the scales provided by :meth:`setScale`.
+
+ :rtype: numpy.ndarray
+ """
+ return self._scale.scale
+
+ def setTranslation(self, x=0., y=0., z=0.):
+ """Set the translation of the origin of the item in the scene.
+
+ :param float x: Offset of the data origin on the X axis
+ :param float y: Offset of the data origin on the Y axis
+ :param float z: Offset of the data origin on the Z axis
+ """
+ translation = numpy.array((x, y, z), dtype=numpy.float32)
+ if not numpy.all(numpy.equal(translation, self.getTranslation())):
+ self._translate.translation = translation
+ self._updated(Item3DChangedType.TRANSFORM)
+
+ def getTranslation(self):
+ """Returns the offset set by :meth:`setTranslation`.
+
+ :rtype: numpy.ndarray
+ """
+ return self._translate.translation
+
+ _ROTATION_CENTER_TAGS = 'lower', 'center', 'upper'
+
+ def _updateRotationCenter(self, *args, **kwargs):
+ """Update rotation center relative to bounding box"""
+ center = []
+ for index, position in enumerate(self.getRotationCenter()):
+ # Patch position relative to bounding box
+ if position in self._ROTATION_CENTER_TAGS:
+ bounds = self._getScenePrimitive().bounds(
+ transformed=False, dataBounds=True)
+ bounds = self._transformObjectToRotate.transformBounds(bounds)
+
+ if bounds is None:
+ position = 0.
+ elif position == 'lower':
+ position = bounds[0, index]
+ elif position == 'center':
+ position = 0.5 * (bounds[0, index] + bounds[1, index])
+ elif position == 'upper':
+ position = bounds[1, index]
+
+ center.append(position)
+
+ if not numpy.all(numpy.equal(
+ center, self._rotateForwardTranslation.translation)):
+ self._rotateForwardTranslation.translation = center
+ self._rotateBackwardTranslation.translation = \
+ - self._rotateForwardTranslation.translation
+ self._updated(Item3DChangedType.TRANSFORM)
+
+ def setRotationCenter(self, x=0., y=0., z=0.):
+ """Set the center of rotation of the item.
+
+ Position of the rotation center is either a float
+ for an absolute position or one of the following
+ string to define a position relative to the item's bounding box:
+ 'lower', 'center', 'upper'
+
+ :param x: rotation center position on the X axis
+ :rtype: float or str
+ :param y: rotation center position on the Y axis
+ :rtype: float or str
+ :param z: rotation center position on the Z axis
+ :rtype: float or str
+ """
+ center = []
+ for position in (x, y, z):
+ if isinstance(position, six.string_types):
+ assert position in self._ROTATION_CENTER_TAGS
+ else:
+ position = float(position)
+ center.append(position)
+ center = tuple(center)
+
+ if center != self._rotationCenter:
+ self._rotationCenter = center
+ self._updateRotationCenter()
+
+ def getRotationCenter(self):
+ """Returns the rotation center set by :meth:`setRotationCenter`.
+
+ :rtype: 3-tuple of float or str
+ """
+ return self._rotationCenter
+
+ def setRotation(self, angle=0., axis=(0., 0., 1.)):
+ """Set the rotation of the item in the scene
+
+ :param float angle: The rotation angle in degrees.
+ :param axis: The (x, y, z) coordinates of the rotation axis.
+ """
+ axis = numpy.array(axis, dtype=numpy.float32)
+ assert axis.ndim == 1
+ assert axis.size == 3
+ if (self._rotate.angle != angle or
+ not numpy.all(numpy.equal(axis, self._rotate.axis))):
+ self._rotate.setAngleAxis(angle, axis)
+ self._updated(Item3DChangedType.TRANSFORM)
+
+ def getRotation(self):
+ """Returns the rotation set by :meth:`setRotation`.
+
+ :return: (angle, axis)
+ :rtype: 2-tuple (float, numpy.ndarray)
+ """
+ return self._rotate.angle, self._rotate.axis
+
+ def setMatrix(self, matrix=None):
+ """Set the transform matrix
+
+ :param numpy.ndarray matrix: 3x3 transform matrix
+ """
+ matrix4x4 = numpy.identity(4, dtype=numpy.float32)
+
+ if matrix is not None:
+ matrix = numpy.array(matrix, dtype=numpy.float32)
+ assert matrix.shape == (3, 3)
+ matrix4x4[:3, :3] = matrix
+
+ if not numpy.all(numpy.equal(matrix4x4, self._matrix.getMatrix())):
+ self._matrix.setMatrix(matrix4x4)
+ self._updated(Item3DChangedType.TRANSFORM)
+
+ def getMatrix(self):
+ """Returns the matrix set by :meth:`setMatrix`
+
+ :return: 3x3 matrix
+ :rtype: numpy.ndarray"""
+ return self._matrix.getMatrix(copy=True)[:3, :3]
+
+ # Bounding box
+
+ def _setForegroundColor(self, color):
+ """Set the color of the bounding box
+
+ :param color: RGBA color as 4 floats in [0, 1]
+ """
+ self._getScenePrimitive().color = color
+ super(DataItem3D, self)._setForegroundColor(color)
+
+ def isBoundingBoxVisible(self):
+ """Returns item's bounding box visibility.
+
+ :rtype: bool
+ """
+ return self._getScenePrimitive().boxVisible
+
+ def setBoundingBoxVisible(self, visible):
+ """Set item's bounding box visibility.
+
+ :param bool visible:
+ True to show the bounding box, False (default) to hide it
+ """
+ visible = bool(visible)
+ primitive = self._getScenePrimitive()
+ if visible != primitive.boxVisible:
+ primitive.boxVisible = visible
+ self._updated(Item3DChangedType.BOUNDING_BOX_VISIBLE)
+
+
+class _BaseGroupItem(DataItem3D):
+ """Base class for group of items sharing a common transform."""
+
+ sigItemAdded = qt.Signal(object)
+ """Signal emitted when a new item is added to the group.
+
+ The newly added item is provided by this signal
+ """
+
+ sigItemRemoved = qt.Signal(object)
+ """Signal emitted when an item is removed from the group.
+
+ The removed item is provided by this signal.
+ """
+
+ def __init__(self, parent=None, group=None):
+ """Base class representing a group of items in the scene.
+
+ :param parent: The View widget this item belongs to.
+ :param Union[GroupBBox, None] group:
+ The scene group to use for rendering
+ """
+ DataItem3D.__init__(self, parent=parent, group=group)
+ self._items = []
+
+ def addItem(self, item, index=None):
+ """Add an item to the group
+
+ :param Item3D item: The item to add
+ :param int index: The index at which to place the item.
+ By default it is appended to the end of the list.
+ :raise ValueError: If the item is already in the group.
+ """
+ assert isinstance(item, Item3D)
+ assert item.parent() in (None, self)
+
+ if item in self.getItems():
+ raise ValueError("Item3D already in group: %s" % item)
+
+ item.setParent(self)
+ if index is None:
+ self._getScenePrimitive().children.append(
+ item._getScenePrimitive())
+ self._items.append(item)
+ else:
+ self._getScenePrimitive().children.insert(
+ index, item._getScenePrimitive())
+ self._items.insert(index, item)
+ self.sigItemAdded.emit(item)
+
+ def getItems(self):
+ """Returns the list of items currently present in the group.
+
+ :rtype: tuple
+ """
+ return tuple(self._items)
+
+ def removeItem(self, item):
+ """Remove an item from the scene.
+
+ :param Item3D item: The item to remove from the scene
+ :raises ValueError: If the item does not belong to the group
+ """
+ if item not in self.getItems():
+ raise ValueError("Item3D not in group: %s" % str(item))
+
+ self._getScenePrimitive().children.remove(item._getScenePrimitive())
+ self._items.remove(item)
+ item.setParent(None)
+ self.sigItemRemoved.emit(item)
+
+ def clearItems(self):
+ """Remove all item from the group."""
+ for item in self.getItems():
+ self.removeItem(item)
+
+ def visit(self, included=True):
+ """Generator visiting the group content.
+
+ It traverses the group sub-tree in a top-down left-to-right way.
+
+ :param bool included: True (default) to include self in visit
+ """
+ if included:
+ yield self
+ for child in self.getItems():
+ yield child
+ if hasattr(child, 'visit'):
+ for item in child.visit(included=False):
+ yield item
+
+
+class GroupItem(_BaseGroupItem):
+ """Group of items sharing a common transform."""
+
+ def __init__(self, parent=None):
+ super(GroupItem, self).__init__(parent=parent)
+
+
+class GroupWithAxesItem(_BaseGroupItem):
+ """
+ Group of items sharing a common transform surrounded with labelled axes.
+ """
+
+ def __init__(self, parent=None):
+ """Class representing a group of items in the scene with labelled axes.
+
+ :param parent: The View widget this item belongs to.
+ """
+ super(GroupWithAxesItem, self).__init__(parent=parent,
+ group=axes.LabelledAxes())
+
+ # Axes labels
+
+ def setAxesLabels(self, xlabel=None, ylabel=None, zlabel=None):
+ """Set the text labels of the axes.
+
+ :param str xlabel: Label of the X axis, None to leave unchanged.
+ :param str ylabel: Label of the Y axis, None to leave unchanged.
+ :param str zlabel: Label of the Z axis, None to leave unchanged.
+ """
+ labelledAxes = self._getScenePrimitive()
+ if xlabel is not None:
+ labelledAxes.xlabel = xlabel
+
+ if ylabel is not None:
+ labelledAxes.ylabel = ylabel
+
+ if zlabel is not None:
+ labelledAxes.zlabel = zlabel
+
+ class _Labels(tuple):
+ """Return type of :meth:`getAxesLabels`"""
+
+ def getXLabel(self):
+ """Label of the X axis (str)"""
+ return self[0]
+
+ def getYLabel(self):
+ """Label of the Y axis (str)"""
+ return self[1]
+
+ def getZLabel(self):
+ """Label of the Z axis (str)"""
+ return self[2]
+
+ def getAxesLabels(self):
+ """Returns the text labels of the axes
+
+ >>> group = GroupWithAxesItem()
+ >>> group.setAxesLabels(xlabel='X')
+
+ You can get the labels either as a 3-tuple:
+
+ >>> xlabel, ylabel, zlabel = group.getAxesLabels()
+
+ Or as an object with methods getXLabel, getYLabel and getZLabel:
+
+ >>> labels = group.getAxesLabels()
+ >>> labels.getXLabel()
+ ... 'X'
+
+ :return: object describing the labels
+ """
+ labelledAxes = self._getScenePrimitive()
+ return self._Labels((labelledAxes.xlabel,
+ labelledAxes.ylabel,
+ labelledAxes.zlabel))
diff --git a/silx/gui/plot3d/items/image.py b/silx/gui/plot3d/items/image.py
new file mode 100644
index 0000000..9e8bf1e
--- /dev/null
+++ b/silx/gui/plot3d/items/image.py
@@ -0,0 +1,126 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides 2D data and RGB(A) image item class.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/11/2017"
+
+import numpy
+
+from ..scene import primitives
+from .core import DataItem3D, ItemChangedType
+from .mixins import ColormapMixIn, InterpolationMixIn
+
+
+class ImageData(DataItem3D, ColormapMixIn, InterpolationMixIn):
+ """Description of a 2D image data.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ def __init__(self, parent=None):
+ DataItem3D.__init__(self, parent=parent)
+ ColormapMixIn.__init__(self)
+ InterpolationMixIn.__init__(self)
+
+ self._data = numpy.zeros((0, 0), dtype=numpy.float32)
+
+ self._image = primitives.ImageData(self._data)
+ self._getScenePrimitive().children.append(self._image)
+
+ # Connect scene primitive to mix-in class
+ ColormapMixIn._setSceneColormap(self, self._image.colormap)
+ InterpolationMixIn._setPrimitive(self, self._image)
+
+ def setData(self, data, copy=True):
+ """Set the image data to display.
+
+ The data will be casted to float32.
+
+ :param numpy.ndarray data: The image data
+ :param bool copy: True (default) to copy the data,
+ False to use as is (do not modify!).
+ """
+ self._image.setData(data, copy=copy)
+ ColormapMixIn._setRangeFromData(self, self.getData(copy=False))
+ self._updated(ItemChangedType.DATA)
+
+ def getData(self, copy=True):
+ """Get the image data.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get internal representation (do not modify!).
+ :rtype: numpy.ndarray
+ :return: The image data
+ """
+ return self._image.getData(copy=copy)
+
+
+class ImageRgba(DataItem3D, InterpolationMixIn):
+ """Description of a 2D data RGB(A) image.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ def __init__(self, parent=None):
+ DataItem3D.__init__(self, parent=parent)
+ InterpolationMixIn.__init__(self)
+
+ self._data = numpy.zeros((0, 0, 3), dtype=numpy.float32)
+
+ self._image = primitives.ImageRgba(self._data)
+ self._getScenePrimitive().children.append(self._image)
+
+ # Connect scene primitive to mix-in class
+ InterpolationMixIn._setPrimitive(self, self._image)
+
+ def setData(self, data, copy=True):
+ """Set the RGB(A) image data to display.
+
+ Supported array format: float32 in [0, 1], uint8.
+
+ :param numpy.ndarray data:
+ The RGBA image data as an array of shape (H, W, Channels)
+ :param bool copy: True (default) to copy the data,
+ False to use as is (do not modify!).
+ """
+ self._image.setData(data, copy=copy)
+ self._updated(ItemChangedType.DATA)
+
+ def getData(self, copy=True):
+ """Get the image data.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get internal representation (do not modify!).
+ :rtype: numpy.ndarray
+ :return: The image data
+ """
+ return self._image.getData(copy=copy)
diff --git a/silx/gui/plot3d/items/mesh.py b/silx/gui/plot3d/items/mesh.py
new file mode 100644
index 0000000..8535728
--- /dev/null
+++ b/silx/gui/plot3d/items/mesh.py
@@ -0,0 +1,145 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides regular mesh item class.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/11/2017"
+
+import numpy
+
+from ..scene import primitives
+from .core import DataItem3D, ItemChangedType
+
+
+class Mesh(DataItem3D):
+ """Description of mesh.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ def __init__(self, parent=None):
+ DataItem3D.__init__(self, parent=parent)
+ self._mesh = None
+
+ def setData(self,
+ position,
+ color,
+ normal=None,
+ mode='triangles',
+ copy=True):
+ """Set mesh geometry data.
+
+ Supported drawing modes are:
+
+ - For points: 'points'
+ - For lines: 'lines', 'line_strip', 'loop'
+ - For triangles: 'triangles', 'triangle_strip', 'fan'
+
+ :param numpy.ndarray position:
+ Position (x, y, z) of each vertex as a (N, 3) array
+ :param numpy.ndarray color: Colors for each point or a single color
+ :param numpy.ndarray normal: Normals for each point or None (default)
+ :param str mode: The drawing mode.
+ :param bool copy: True (default) to copy the data,
+ False to use as is (do not modify!).
+ """
+ self._getScenePrimitive().children = [] # Remove any previous mesh
+
+ if position is None or len(position) == 0:
+ self._mesh = 0
+ else:
+ self._mesh = primitives.Mesh3D(
+ position, color, normal, mode=mode, copy=copy)
+ self._getScenePrimitive().children.append(self._mesh)
+
+ self.sigItemChanged.emit(ItemChangedType.DATA)
+
+ def getData(self, copy=True):
+ """Get the mesh geometry.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get internal representation (do not modify!).
+ :return: The positions, colors, normals and mode
+ :rtype: tuple of numpy.ndarray
+ """
+ return (self.getPositionData(copy=copy),
+ self.getColorData(copy=copy),
+ self.getNormalData(copy=copy),
+ self.getDrawMode())
+
+ def getPositionData(self, copy=True):
+ """Get the mesh vertex positions.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get internal representation (do not modify!).
+ :return: The (x, y, z) positions as a (N, 3) array
+ :rtype: numpy.ndarray
+ """
+ if self._mesh is None:
+ return numpy.empty((0, 3), dtype=numpy.float32)
+ else:
+ return self._mesh.getAttribute('position', copy=copy)
+
+ def getColorData(self, copy=True):
+ """Get the mesh vertex colors.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get internal representation (do not modify!).
+ :return: The RGBA colors as a (N, 4) array or a single color
+ :rtype: numpy.ndarray
+ """
+ if self._mesh is None:
+ return numpy.empty((0, 4), dtype=numpy.float32)
+ else:
+ return self._mesh.getAttribute('color', copy=copy)
+
+ def getNormalData(self, copy=True):
+ """Get the mesh vertex normals.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get internal representation (do not modify!).
+ :return: The normals as a (N, 3) array, a single normal or None
+ :rtype: numpy.ndarray or None
+ """
+ if self._mesh is None:
+ return None
+ else:
+ return self._mesh.getAttribute('normal', copy=copy)
+
+ def getDrawMode(self):
+ """Get mesh rendering mode.
+
+ :return: The drawing mode of this primitive
+ :rtype: str
+ """
+ return self._mesh.drawMode
diff --git a/silx/gui/plot3d/items/mixins.py b/silx/gui/plot3d/items/mixins.py
new file mode 100644
index 0000000..41ad3c3
--- /dev/null
+++ b/silx/gui/plot3d/items/mixins.py
@@ -0,0 +1,302 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides mix-in classes for :class:`Item3D`.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/11/2017"
+
+
+import collections
+import numpy
+
+from silx.math.combo import min_max
+
+from ...plot.items.core import ItemMixInBase
+from ...plot.items.core import ColormapMixIn as _ColormapMixIn
+from ...plot.items.core import SymbolMixIn as _SymbolMixIn
+from ...plot.Colors import rgba
+
+from ..scene import primitives
+from .core import Item3DChangedType, ItemChangedType
+
+
+class InterpolationMixIn(ItemMixInBase):
+ """Mix-in class for image interpolation mode
+
+ :param str mode: 'linear' (default) or 'nearest'
+ :param primitive:
+ scene object for which to sync interpolation mode.
+ This object MUST have an interpolation property that is updated.
+ """
+
+ NEAREST_INTERPOLATION = 'nearest'
+ """Nearest interpolation mode (see :meth:`setInterpolation`)"""
+
+ LINEAR_INTERPOLATION = 'linear'
+ """Linear interpolation mode (see :meth:`setInterpolation`)"""
+
+ INTERPOLATION_MODES = NEAREST_INTERPOLATION, LINEAR_INTERPOLATION
+ """Supported interpolation modes for :meth:`setInterpolation`"""
+
+ def __init__(self, mode=NEAREST_INTERPOLATION, primitive=None):
+ self.__primitive = primitive
+ self._syncPrimitiveInterpolation()
+
+ self.__interpolationMode = None
+ self.setInterpolation(mode)
+
+ def _setPrimitive(self, primitive):
+
+ """Set the scene object for which to sync interpolation"""
+ self.__primitive = primitive
+ self._syncPrimitiveInterpolation()
+
+ def _syncPrimitiveInterpolation(self):
+ """Synchronize scene object's interpolation"""
+ if self.__primitive is not None:
+ self.__primitive.interpolation = self.getInterpolation()
+
+ def setInterpolation(self, mode):
+ """Set image interpolation mode
+
+ :param str mode: 'nearest' or 'linear'
+ """
+ mode = str(mode)
+ assert mode in self.INTERPOLATION_MODES
+ if mode != self.__interpolationMode:
+ self.__interpolationMode = mode
+ self._syncPrimitiveInterpolation()
+ self._updated(Item3DChangedType.INTERPOLATION)
+
+ def getInterpolation(self):
+ """Returns the interpolation mode set by :meth:`setInterpolation`
+
+ :rtype: str
+ """
+ return self.__interpolationMode
+
+
+class ColormapMixIn(_ColormapMixIn):
+ """Mix-in class for Item3D object with a colormap
+
+ :param sceneColormap:
+ The plot3d scene colormap to sync with Colormap object.
+ """
+
+ def __init__(self, sceneColormap=None):
+ super(ColormapMixIn, self).__init__()
+
+ self._dataRange = None
+ self.__sceneColormap = sceneColormap
+ self._syncSceneColormap()
+
+ self.sigItemChanged.connect(self.__colormapUpdated)
+
+ def __colormapUpdated(self, event):
+ """Handle colormap updates"""
+ if event == ItemChangedType.COLORMAP:
+ self._syncSceneColormap()
+
+ 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 len(data) == 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
+
+ if self.getColormap().isAutoscale():
+ self._syncSceneColormap()
+
+ def _setSceneColormap(self, sceneColormap):
+ """Set the scene colormap to sync with Colormap object.
+
+ :param sceneColormap:
+ The plot3d scene colormap to sync with Colormap object.
+ """
+ self.__sceneColormap = sceneColormap
+ self._syncSceneColormap()
+
+ def _getSceneColormap(self):
+ """Returns scene colormap that is sync"""
+ return self.__sceneColormap
+
+ def _syncSceneColormap(self):
+ """Synchronizes scene's colormap with Colormap object"""
+ if self.__sceneColormap is not None:
+ colormap = self.getColormap()
+
+ self.__sceneColormap.colormap = colormap.getNColors()
+ self.__sceneColormap.norm = colormap.getNormalization()
+ range_ = colormap.getColormapRange(data=self._dataRange)
+ self.__sceneColormap.range_ = range_
+
+
+class SymbolMixIn(_SymbolMixIn):
+ """Mix-in class for symbol and symbolSize properties for Item3D"""
+
+ _DEFAULT_SYMBOL = 'o'
+ _DEFAULT_SYMBOL_SIZE = 7.0
+ _SUPPORTED_SYMBOLS = collections.OrderedDict((
+ ('o', 'Circle'),
+ ('d', 'Diamond'),
+ ('s', 'Square'),
+ ('+', 'Plus'),
+ ('x', 'Cross'),
+ ('*', 'Star'),
+ ('|', 'Vertical Line'),
+ ('_', 'Horizontal Line'),
+ ('.', 'Point'),
+ (',', 'Pixel')))
+
+ def _getSceneSymbol(self):
+ """Returns a symbol name and size suitable for scene primitives.
+
+ :return: (symbol, size)
+ """
+ symbol = self.getSymbol()
+ size = self.getSymbolSize()
+ if symbol == ',': # pixel
+ return 's', 1.
+ elif symbol == '.': # point
+ # Size as in plot OpenGL backend, mimic matplotlib
+ return 'o', numpy.ceil(0.5 * size) + 1.
+ else:
+ return symbol, size
+
+
+class PlaneMixIn(ItemMixInBase):
+ """Mix-in class for plane items (based on PlaneInGroup primitive)"""
+
+ def __init__(self, plane):
+ assert isinstance(plane, primitives.PlaneInGroup)
+ self.__plane = plane
+ self.__plane.alpha = 1.
+ self.__plane.addListener(self._planeChanged)
+ self.__plane.plane.addListener(self._planePositionChanged)
+
+ def _getPlane(self):
+ """Returns plane primitive
+
+ :rtype: primitives.PlaneInGroup
+ """
+ return self.__plane
+
+ def _planeChanged(self, source, *args, **kwargs):
+ """Handle events from the plane primitive"""
+ # Sync visibility
+ if source.visible != self.isVisible():
+ self.setVisible(source.visible)
+
+ def _planePositionChanged(self, source, *args, **kwargs):
+ """Handle update of cut plane position and normal"""
+ if self.__plane.visible: # TODO send even if hidden? or send also when showing if moved while hidden
+ self._updated(ItemChangedType.POSITION)
+
+ # Plane position
+
+ def moveToCenter(self):
+ """Move cut plane to center of data set"""
+ self.__plane.moveToCenter()
+
+ def isValid(self):
+ """Returns whether the cut plane is defined or not (bool)"""
+ return self.__plane.isValid
+
+ def getNormal(self):
+ """Returns the normal of the plane (as a unit vector)
+
+ :return: Normal (nx, ny, nz), vector is 0 if no plane is defined
+ :rtype: numpy.ndarray
+ """
+ return self.__plane.plane.normal
+
+ def setNormal(self, normal):
+ """Set the normal of the plane
+
+ :param normal: 3-tuple of float: nx, ny, nz
+ """
+ self.__plane.plane.normal = normal
+
+ def getPoint(self):
+ """Returns a point on the plane
+
+ :return: (x, y, z)
+ :rtype: numpy.ndarray
+ """
+ return self.__plane.plane.point
+
+ def setPoint(self, point):
+ """Set a point contained in the plane.
+
+ Warning: The plane might not intersect the bounding box of the data.
+
+ :param point: (x, y, z) position
+ :type point: 3-tuple of float
+ """
+ self.__plane.plane.point = point # TODO rework according to PR #1303
+
+ def getParameters(self):
+ """Returns the plane equation parameters: a*x + b*y + c*z + d = 0
+
+ :return: Plane equation parameters: (a, b, c, d)
+ :rtype: numpy.ndarray
+ """
+ return self.__plane.plane.parameters
+
+ def setParameters(self, parameters):
+ """Set the plane equation parameters: a*x + b*y + c*z + d = 0
+
+ Warning: The plane might not intersect the bounding box of the data.
+ The given parameters will be normalized.
+
+ :param parameters: (a, b, c, d) equation parameters
+ """
+ self.__plane.plane.parameters = parameters
+
+ # Border stroke
+
+ def _setForegroundColor(self, color):
+ """Set the color of the plane border.
+
+ :param color: RGBA color as 4 floats in [0, 1]
+ """
+ self.__plane.color = rgba(color)
+ if hasattr(super(PlaneMixIn, self), '_setForegroundColor'):
+ super(PlaneMixIn, self)._setForegroundColor(color)
diff --git a/silx/gui/plot3d/items/scatter.py b/silx/gui/plot3d/items/scatter.py
new file mode 100644
index 0000000..5eea455
--- /dev/null
+++ b/silx/gui/plot3d/items/scatter.py
@@ -0,0 +1,474 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides 2D and 3D scatter data item class.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/11/2017"
+
+import collections
+import logging
+import sys
+import numpy
+
+from ..scene import function, primitives, utils
+
+from .core import DataItem3D, Item3DChangedType, ItemChangedType
+from .mixins import ColormapMixIn, SymbolMixIn
+
+
+_logger = logging.getLevelName(__name__)
+
+
+class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn):
+ """Description of a 3D scatter plot.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ # TODO supports different size for each point
+
+ def __init__(self, parent=None):
+ DataItem3D.__init__(self, parent=parent)
+ ColormapMixIn.__init__(self)
+ SymbolMixIn.__init__(self)
+
+ noData = numpy.zeros((0, 1), dtype=numpy.float32)
+ symbol, size = self._getSceneSymbol()
+ self._scatter = primitives.Points(
+ x=noData, y=noData, z=noData, value=noData, size=size)
+ self._scatter.marker = symbol
+ self._getScenePrimitive().children.append(self._scatter)
+
+ # Connect scene primitive to mix-in class
+ ColormapMixIn._setSceneColormap(self, self._scatter.colormap)
+
+ def _updated(self, event=None):
+ """Handle mix-in class updates"""
+ if event in (ItemChangedType.SYMBOL, ItemChangedType.SYMBOL_SIZE):
+ symbol, size = self._getSceneSymbol()
+ self._scatter.marker = symbol
+ self._scatter.setAttribute('size', size, copy=True)
+
+ super(Scatter3D, self)._updated(event)
+
+ def setData(self, x, y, z, value, copy=True):
+ """Set the data of the scatter plot
+
+ :param numpy.ndarray x: Array of X coordinates (single value not accepted)
+ :param y: Points Y coordinate (array-like or single value)
+ :param z: Points Z coordinate (array-like or single value)
+ :param value: Points values (array-like or single value)
+ :param bool copy:
+ True (default) to copy the data,
+ False to use provided data (do not modify!)
+ """
+ self._scatter.setAttribute('x', x, copy=copy)
+ self._scatter.setAttribute('y', y, copy=copy)
+ self._scatter.setAttribute('z', z, copy=copy)
+ self._scatter.setAttribute('value', value, copy=copy)
+
+ ColormapMixIn._setRangeFromData(self, self.getValues(copy=False))
+ self._updated(ItemChangedType.DATA)
+
+ def getData(self, copy=True):
+ """Returns data as provided to :meth:`setData`.
+
+ :param bool copy: True to get a copy,
+ False to return internal data (do not modify!)
+ :return: (x, y, z, value)
+ """
+ return (self.getXData(copy),
+ self.getYData(copy),
+ self.getZData(copy),
+ self.getValues(copy))
+
+ def getXData(self, copy=True):
+ """Returns X data coordinates.
+
+ :param bool copy: True to get a copy,
+ False to return internal array (do not modify!)
+ :return: X coordinates
+ :rtype: numpy.ndarray
+ """
+ return self._scatter.getAttribute('x', copy=copy)
+
+ def getYData(self, copy=True):
+ """Returns Y data coordinates.
+
+ :param bool copy: True to get a copy,
+ False to return internal array (do not modify!)
+ :return: Y coordinates
+ :rtype: numpy.ndarray
+ """
+ return self._scatter.getAttribute('y', copy=copy)
+
+ def getZData(self, copy=True):
+ """Returns Z data coordinates.
+
+ :param bool copy: True to get a copy,
+ False to return internal array (do not modify!)
+ :return: Z coordinates
+ :rtype: numpy.ndarray
+ """
+ return self._scatter.getAttribute('z', copy=copy)
+
+ def getValues(self, copy=True):
+ """Returns data values.
+
+ :param bool copy: True to get a copy,
+ False to return internal array (do not modify!)
+ :return: data values
+ :rtype: numpy.ndarray
+ """
+ return self._scatter.getAttribute('value', copy=copy)
+
+
+class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn):
+ """2D scatter data with settable visualization mode.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ _VISUALIZATION_PROPERTIES = {
+ 'points': ('symbol', 'symbolSize'),
+ 'lines': ('lineWidth',),
+ 'solid': (),
+ }
+ """Dict {visualization mode: property names used in this mode}"""
+
+ def __init__(self, parent=None):
+ DataItem3D.__init__(self, parent=parent)
+ ColormapMixIn.__init__(self)
+ SymbolMixIn.__init__(self)
+
+ self._visualizationMode = 'points'
+ self._heightMap = False
+ self._lineWidth = 1.
+
+ self._x = numpy.zeros((0,), dtype=numpy.float32)
+ self._y = numpy.zeros((0,), dtype=numpy.float32)
+ self._value = numpy.zeros((0,), dtype=numpy.float32)
+
+ self._cachedLinesIndices = None
+ self._cachedTrianglesIndices = None
+
+ # Connect scene primitive to mix-in class
+ ColormapMixIn._setSceneColormap(self, function.Colormap())
+
+ def _updated(self, event=None):
+ """Handle mix-in class updates"""
+ if event in (ItemChangedType.SYMBOL, ItemChangedType.SYMBOL_SIZE):
+ symbol, size = self._getSceneSymbol()
+ for child in self._getScenePrimitive().children:
+ if isinstance(child, primitives.Points):
+ child.marker = symbol
+ child.setAttribute('size', size, copy=True)
+
+ elif event == ItemChangedType.VISIBLE:
+ # TODO smart update?, need dirty flags
+ self._updateScene()
+
+ super(Scatter2D, self)._updated(event)
+
+ def supportedVisualizations(self):
+ """Returns the list of supported visualization modes.
+
+ See :meth:`setVisualizationModes`
+
+ :rtype: tuple of str
+ """
+ return tuple(self._VISUALIZATION_PROPERTIES.keys())
+
+ def setVisualization(self, mode):
+ """Set the visualization mode of the data.
+
+ Supported visualization modes are:
+
+ - 'points': For scatter plot representation
+ - 'lines': For Delaunay tesselation-based wireframe representation
+ - 'solid': For Delaunay tesselation-based solid surface representation
+
+ :param str mode: Mode of representation to use
+ """
+ mode = str(mode)
+ assert mode in self.supportedVisualizations()
+
+ if mode != self.getVisualization():
+ self._visualizationMode = mode
+ self._updateScene()
+ self._updated(ItemChangedType.VISUALIZATION_MODE)
+
+ def getVisualization(self):
+ """Returns the current visualization mode.
+
+ See :meth:`setVisualization`
+
+ :rtype: str
+ """
+ return self._visualizationMode
+
+ def isPropertyEnabled(self, name, visualization=None):
+ """Returns true if the property is used with visualization mode.
+
+ :param str name: The name of the property to check, in:
+ 'lineWidth', 'symbol', 'symbolSize'
+ :param str visualization:
+ The visualization mode for which to get the info.
+ By default, it is the current visualization mode.
+ :return:
+ """
+ assert name in ('lineWidth', 'symbol', 'symbolSize')
+ if visualization is None:
+ visualization = self.getVisualization()
+ assert visualization in self.supportedVisualizations()
+ return name in self._VISUALIZATION_PROPERTIES[visualization]
+
+ def setHeightMap(self, heightMap):
+ """Set whether to display the data has a height map or not.
+
+ When displayed as a height map, the data values are used as
+ z coordinates.
+
+ :param bool heightMap:
+ True to display a height map,
+ False to display as 2D data with z=0
+ """
+ heightMap = bool(heightMap)
+ if heightMap != self.isHeightMap():
+ self._heightMap = heightMap
+ self._updateScene()
+ self._updated(Item3DChangedType.HEIGHT_MAP)
+
+ def isHeightMap(self):
+ """Returns True if data is displayed as a height map.
+
+ :rtype: bool
+ """
+ return self._heightMap
+
+ def getLineWidth(self):
+ """Return the curve line width in pixels (float)"""
+ return self._lineWidth
+
+ def setLineWidth(self, width):
+ """Set the width in pixel of the curve line
+
+ See :meth:`getLineWidth`.
+
+ :param float width: Width in pixels
+ """
+ width = float(width)
+ assert width >= 1.
+ if width != self._lineWidth:
+ self._lineWidth = width
+ for child in self._getScenePrimitive().children:
+ if hasattr(child, 'lineWidth'):
+ child.lineWidth = width
+ self._updated(ItemChangedType.LINE_WIDTH)
+
+ def setData(self, x, y, value, copy=True):
+ """Set the data represented by this item.
+
+ Provided arrays must have the same length.
+
+ :param numpy.ndarray x: X coordinates (array-like)
+ :param numpy.ndarray y: Y coordinates (array-like)
+ :param value: Points value: array-like or single scalar
+ :param bool copy:
+ True (default) to make a copy of the data,
+ False to avoid copy if possible (do not modify the arrays).
+ """
+ x = numpy.array(
+ x, copy=copy, dtype=numpy.float32, order='C').reshape(-1)
+ y = numpy.array(
+ y, copy=copy, dtype=numpy.float32, order='C').reshape(-1)
+ assert len(x) == len(y)
+
+ if isinstance(value, collections.Iterable):
+ value = numpy.array(
+ value, copy=copy, dtype=numpy.float32, order='C').reshape(-1)
+ assert len(value) == len(x)
+ else: # Single scalar
+ value = numpy.array((float(value),), dtype=numpy.float32)
+
+ self._x = x
+ self._y = y
+ self._value = value
+
+ # Reset cache
+ self._cachedLinesIndices = None
+ self._cachedTrianglesIndices = None
+
+ # Store data range info
+ ColormapMixIn._setRangeFromData(self, self.getValues(copy=False))
+
+ self._updateScene()
+
+ self._updated(ItemChangedType.DATA)
+
+ def getData(self, copy=True):
+ """Returns data as provided to :meth:`setData`.
+
+ :param bool copy: True to get a copy,
+ False to return internal data (do not modify!)
+ :return: (x, y, value)
+ """
+ return (self.getXData(copy=copy),
+ self.getYData(copy=copy),
+ self.getValues(copy=copy))
+
+ def getXData(self, copy=True):
+ """Returns X data coordinates.
+
+ :param bool copy: True to get a copy,
+ False to return internal array (do not modify!)
+ :return: X coordinates
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(self._x, copy=copy)
+
+ def getYData(self, copy=True):
+ """Returns Y data coordinates.
+
+ :param bool copy: True to get a copy,
+ False to return internal array (do not modify!)
+ :return: Y coordinates
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(self._y, copy=copy)
+
+ def getValues(self, copy=True):
+ """Returns data values.
+
+ :param bool copy: True to get a copy,
+ False to return internal array (do not modify!)
+ :return: data values
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(self._value, copy=copy)
+
+ def _updateScene(self):
+ self._getScenePrimitive().children = [] # Remove previous primitives
+
+ if not self.isVisible():
+ return # Update when visible
+
+ x, y, value = self.getData(copy=False)
+ if len(x) == 0:
+ return # Nothing to display
+
+ mode = self.getVisualization()
+ heightMap = self.isHeightMap()
+
+ if mode == 'points':
+ z = value if heightMap else 0.
+ symbol, size = self._getSceneSymbol()
+ primitive = primitives.Points(
+ x=x, y=y, z=z, value=value,
+ size=size,
+ colormap=self._getSceneColormap())
+ primitive.marker = symbol
+
+ else:
+ # TODO run delaunay in a thread
+ # Compute lines/triangles indices if not cached
+ if self._cachedTrianglesIndices is None:
+ coordinates = numpy.array((x, y)).T
+
+ if len(coordinates) > 3:
+ # Enough points to try a Delaunay tesselation
+
+ # Lazy loading of Delaunay
+ from silx.third_party.scipy_spatial import Delaunay as _Delaunay
+
+ try:
+ tri = _Delaunay(coordinates)
+ except RuntimeError:
+ _logger.error("Delaunay tesselation failed: %s",
+ sys.exc_info()[1])
+ return None
+
+ self._cachedTrianglesIndices = numpy.ravel(
+ tri.simplices.astype(numpy.uint32))
+
+ else:
+ # 3 or less points: Draw one triangle
+ self._cachedTrianglesIndices = \
+ numpy.arange(3, dtype=numpy.uint32) % len(coordinates)
+
+ if mode == 'lines' and self._cachedLinesIndices is None:
+ # Compute line indices
+ self._cachedLinesIndices = utils.triangleToLineIndices(
+ self._cachedTrianglesIndices, unicity=True)
+
+ if mode == 'lines':
+ indices = self._cachedLinesIndices
+ renderMode = 'lines'
+ else:
+ indices = self._cachedTrianglesIndices
+ renderMode = 'triangles'
+
+ # TODO supports x, y instead of copy
+ if heightMap:
+ if len(value) == 1:
+ value = numpy.ones_like(x) * value
+ coordinates = numpy.array((x, y, value), dtype=numpy.float32).T
+ else:
+ coordinates = numpy.array((x, y), dtype=numpy.float32).T
+
+ # TODO option to enable/disable light, cache normals
+ # TODO smooth surface
+ if mode == 'solid':
+ if heightMap:
+ coordinates = coordinates[indices]
+ if len(value) > 1:
+ value = value[indices]
+ triangleNormals = utils.trianglesNormal(coordinates)
+ normal = numpy.empty((len(triangleNormals) * 3, 3),
+ dtype=numpy.float32)
+ normal[0::3, :] = triangleNormals
+ normal[1::3, :] = triangleNormals
+ normal[2::3, :] = triangleNormals
+ indices = None
+ else:
+ normal = (0., 0., 1.)
+ else:
+ normal = None
+
+ primitive = primitives.ColormapMesh3D(
+ coordinates,
+ value.reshape(-1, 1), # Makes it a 2D array
+ normal=normal,
+ colormap=self._getSceneColormap(),
+ indices=indices,
+ mode=renderMode)
+ primitive.lineWidth = self.getLineWidth()
+ primitive.lineSmooth = False
+
+ self._getScenePrimitive().children = [primitive]
diff --git a/silx/gui/plot3d/items/volume.py b/silx/gui/plot3d/items/volume.py
new file mode 100644
index 0000000..a1f40f7
--- /dev/null
+++ b/silx/gui/plot3d/items/volume.py
@@ -0,0 +1,463 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides 3D array item class and its sub-items.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "15/11/2017"
+
+import logging
+import time
+import numpy
+
+from silx.math.combo import min_max
+from silx.math.marchingcubes import MarchingCubes
+
+from ... import qt
+from ...plot.Colors import rgba
+
+from ..scene import cutplane, primitives, transform
+
+from .core import DataItem3D, Item3D, ItemChangedType, Item3DChangedType
+from .mixins import ColormapMixIn, InterpolationMixIn, PlaneMixIn
+
+
+_logger = logging.getLogger(__name__)
+
+
+class CutPlane(Item3D, ColormapMixIn, InterpolationMixIn, PlaneMixIn):
+ """Class representing a cutting plane in a :class:`ScalarField3D` item.
+
+ :param parent: 3D Data set in which the cut plane is applied.
+ """
+
+ def __init__(self, parent):
+ plane = cutplane.CutPlane(normal=(0, 1, 0))
+
+ Item3D.__init__(self, parent=parent)
+ ColormapMixIn.__init__(self)
+ InterpolationMixIn.__init__(self)
+ PlaneMixIn.__init__(self, plane=plane)
+
+ self._dataRange = None
+
+ self._getScenePrimitive().children = [plane]
+
+ # Connect scene primitive to mix-in class
+ ColormapMixIn._setSceneColormap(self, plane.colormap)
+ InterpolationMixIn._setPrimitive(self, plane)
+
+ parent.sigItemChanged.connect(self._parentChanged)
+
+ def _parentChanged(self, event):
+ """Handle data change in the parent this plane belongs to"""
+ if event == ItemChangedType.DATA:
+ self._getPlane().setData(self.sender().getData(), copy=False)
+
+ # Store data range info as 3-tuple of values
+ self._dataRange = self.sender().getDataRange()
+
+ self.sigItemChanged.emit(ItemChangedType.DATA)
+
+ # Colormap
+
+ def getDisplayValuesBelowMin(self):
+ """Return whether values <= colormap min are displayed or not.
+
+ :rtype: bool
+ """
+ return self._getPlane().colormap.displayValuesBelowMin
+
+ def setDisplayValuesBelowMin(self, display):
+ """Set whether to display values <= colormap min.
+
+ :param bool display: True to show values below min,
+ False to discard them
+ """
+ display = bool(display)
+ if display != self.getDisplayValuesBelowMin():
+ self._getPlane().colormap.displayValuesBelowMin = display
+ self.sigItemChanged.emit(ItemChangedType.ALPHA)
+
+ def getDataRange(self):
+ """Return the range of the data as a 3-tuple of values.
+
+ positive min is NaN if no data is positive.
+
+ :return: (min, positive min, max) or None.
+ """
+ return self._dataRange
+
+
+class Isosurface(Item3D):
+ """Class representing an iso-surface in a :class:`ScalarField3D` item.
+
+ :param parent: The DataItem3D this iso-surface belongs to
+ """
+
+ def __init__(self, parent):
+ Item3D.__init__(self, parent=parent)
+ self._level = float('nan')
+ self._autoLevelFunction = None
+ self._color = rgba('#FFD700FF')
+ self._data = None
+
+ # TODO register to ScalarField3D signal instead?
+ def _setData(self, data, copy=True):
+ """Set the data set from which to build the iso-surface.
+
+ :param numpy.ndarray data: The 3D data set or None
+ :param bool copy: True to make a copy, False to use as is if possible
+ """
+ if data is None:
+ self._data = None
+ else:
+ self._data = numpy.array(data, copy=copy, order='C')
+
+ self._updateScenePrimitive()
+
+ def getLevel(self):
+ """Return the level of this iso-surface (float)"""
+ return self._level
+
+ def setLevel(self, level):
+ """Set the value at which to build the iso-surface.
+
+ Setting this value reset auto-level function
+
+ :param float level: The value at which to build the iso-surface
+ """
+ self._autoLevelFunction = None
+ level = float(level)
+ if level != self._level:
+ self._level = level
+ self._updateScenePrimitive()
+ self._updated(Item3DChangedType.ISO_LEVEL)
+
+ def isAutoLevel(self):
+ """True if iso-level is rebuild for each data set."""
+ return self.getAutoLevelFunction() is not None
+
+ def getAutoLevelFunction(self):
+ """Return the function computing the iso-level (callable or None)"""
+ return self._autoLevelFunction
+
+ def setAutoLevelFunction(self, autoLevel):
+ """Set the function used to compute the iso-level.
+
+ WARNING: The function might get called in a thread.
+
+ :param callable autoLevel:
+ A function taking a 3D numpy.ndarray of float32 and returning
+ a float used as iso-level.
+ Example: numpy.mean(data) + numpy.std(data)
+ """
+ assert callable(autoLevel)
+ self._autoLevelFunction = autoLevel
+ self._updateScenePrimitive()
+
+ def getColor(self):
+ """Return the color of this iso-surface (QColor)"""
+ return qt.QColor.fromRgbF(*self._color)
+
+ def setColor(self, color):
+ """Set the color of the iso-surface
+
+ :param color: RGBA color of the isosurface
+ :type color: QColor, str or array-like of 4 float in [0., 1.]
+ """
+ color = rgba(color)
+ if color != self._color:
+ self._color = color
+ primitive = self._getScenePrimitive()
+ if len(primitive.children) != 0:
+ primitive.children[0].setAttribute('color', self._color)
+ self._updated(ItemChangedType.COLOR)
+
+ def _updateScenePrimitive(self):
+ """Update underlying mesh"""
+ self._getScenePrimitive().children = []
+
+ if self._data is None:
+ if self.isAutoLevel():
+ self._level = float('nan')
+
+ else:
+ if self.isAutoLevel():
+ st = time.time()
+ try:
+ level = float(self.getAutoLevelFunction()(self._data))
+
+ except Exception:
+ module_ = self.getAutoLevelFunction().__module__
+ name = self.getAutoLevelFunction().__name__
+ _logger.error(
+ "Error while executing iso level function %s.%s",
+ module_,
+ name,
+ exc_info=True)
+ level = float('nan')
+
+ else:
+ _logger.info(
+ 'Computed iso-level in %f s.', time.time() - st)
+
+ if level != self._level:
+ self._level = level
+ self._updated(Item3DChangedType.ISO_LEVEL)
+
+ if not numpy.isfinite(self._level):
+ return
+
+ st = time.time()
+ vertices, normals, indices = MarchingCubes(
+ self._data,
+ isolevel=self._level)
+ _logger.info('Computed iso-surface in %f s.', time.time() - st)
+
+ if len(vertices) == 0:
+ return
+ else:
+ mesh = primitives.Mesh3D(vertices,
+ colors=self._color,
+ normals=normals,
+ mode='triangles',
+ indices=indices)
+ self._getScenePrimitive().children = [mesh]
+
+
+class ScalarField3D(DataItem3D):
+ """3D scalar field on a regular grid.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ def __init__(self, parent=None):
+ DataItem3D.__init__(self, parent=parent)
+
+ # Gives this item the shape of the data, no matter
+ # of the isosurface/cut plane size
+ self._boundedGroup = primitives.BoundedGroup()
+
+ # Store iso-surfaces
+ self._isosurfaces = []
+
+ self._data = None
+ self._dataRange = None
+
+ self._cutPlane = CutPlane(parent=self)
+ self._cutPlane.setVisible(False)
+
+ self._isogroup = primitives.GroupDepthOffset()
+ self._isogroup.transforms = [
+ # Convert from z, y, x from marching cubes to x, y, z
+ transform.Matrix((
+ (0., 0., 1., 0.),
+ (0., 1., 0., 0.),
+ (1., 0., 0., 0.),
+ (0., 0., 0., 1.))),
+ # Offset to match cutting plane coords
+ transform.Translate(0.5, 0.5, 0.5)
+ ]
+
+ self._getScenePrimitive().children = [
+ self._boundedGroup,
+ self._cutPlane._getScenePrimitive(),
+ self._isogroup]
+
+ def setData(self, data, copy=True):
+ """Set the 3D scalar data represented by this item.
+
+ Dataset order is zyx (i.e., first dimension is z).
+
+ :param data: 3D array
+ :type data: 3D numpy.ndarray of float32 with shape at least (2, 2, 2)
+ :param bool copy:
+ True (default) to make a copy,
+ False to avoid copy (DO NOT MODIFY data afterwards)
+ """
+ if data is None:
+ self._data = None
+ self._dataRange = None
+ self._boundedGroup.shape = None
+
+ else:
+ data = numpy.array(data, copy=copy, dtype=numpy.float32, order='C')
+ assert data.ndim == 3
+ assert min(data.shape) >= 2
+
+ self._data = data
+
+ # Store data range info
+ dataRange = min_max(self._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
+
+ self._boundedGroup.shape = self._data.shape
+
+ # Update iso-surfaces
+ for isosurface in self.getIsosurfaces():
+ isosurface._setData(self._data, copy=False)
+
+ self._updated(ItemChangedType.DATA)
+
+ def getData(self, copy=True):
+ """Return 3D dataset.
+
+ :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)
+ """
+ if self._data is None:
+ return None
+ else:
+ return numpy.array(self._data, copy=copy)
+
+ def getDataRange(self):
+ """Return the range of the data as a 3-tuple of values.
+
+ positive min is NaN if no data is positive.
+
+ :return: (min, positive min, max) or None.
+ """
+ return self._dataRange
+
+ # Cut Plane
+
+ def getCutPlanes(self):
+ """Return an iterable of all :class:`CutPlane` of this item.
+
+ This includes hidden cut planes.
+
+ For now, there is always one cut plane.
+ """
+ return (self._cutPlane,)
+
+ # Handle iso-surfaces
+
+ # TODO rename to sigItemAdded|Removed?
+ sigIsosurfaceAdded = qt.Signal(object)
+ """Signal emitted when a new iso-surface is added to the view.
+
+ The newly added iso-surface is provided by this signal
+ """
+
+ sigIsosurfaceRemoved = qt.Signal(object)
+ """Signal emitted when an iso-surface is removed from the view
+
+ The removed iso-surface is provided by this signal.
+ """
+
+ def addIsosurface(self, level, color):
+ """Add an isosurface to this item.
+
+ :param level:
+ The value at which to build the iso-surface or a callable
+ (e.g., a function) taking a 3D numpy.ndarray as input and
+ returning a float.
+ Example: numpy.mean(data) + numpy.std(data)
+ :type level: float or callable
+ :param color: RGBA color of the isosurface
+ :type color: str or array-like of 4 float in [0., 1.]
+ :return: isosurface object
+ :rtype: ~silx.gui.plot3d.items.volume.Isosurface
+ """
+ isosurface = Isosurface(parent=self)
+ isosurface.setColor(color)
+ if callable(level):
+ isosurface.setAutoLevelFunction(level)
+ else:
+ isosurface.setLevel(level)
+ isosurface._setData(self._data, copy=False)
+ isosurface.sigItemChanged.connect(self._isosurfaceItemChanged)
+
+ self._isosurfaces.append(isosurface)
+
+ self._updateIsosurfaces()
+
+ self.sigIsosurfaceAdded.emit(isosurface)
+ return isosurface
+
+ def getIsosurfaces(self):
+ """Return an iterable of all :class:`.Isosurface` instance of this item"""
+ return tuple(self._isosurfaces)
+
+ def removeIsosurface(self, isosurface):
+ """Remove an iso-surface from this item.
+
+ :param ~silx.gui.plot3d.Plot3DWidget.Isosurface isosurface:
+ The isosurface object to remove
+ """
+ if isosurface not in self.getIsosurfaces():
+ _logger.warning(
+ "Try to remove isosurface that is not in the list: %s",
+ str(isosurface))
+ else:
+ isosurface.sigItemChanged.disconnect(self._isosurfaceItemChanged)
+ self._isosurfaces.remove(isosurface)
+ self._updateIsosurfaces()
+ self.sigIsosurfaceRemoved.emit(isosurface)
+
+ def clearIsosurfaces(self):
+ """Remove all :class:`.Isosurface` instances from this item."""
+ for isosurface in self.getIsosurfaces():
+ self.removeIsosurface(isosurface)
+
+ def _isosurfaceItemChanged(self, event):
+ """Handle update of isosurfaces upon level changed"""
+ if event == Item3DChangedType.ISO_LEVEL:
+ self._updateIsosurfaces()
+
+ def _updateIsosurfaces(self):
+ """Handle updates of iso-surfaces level and add/remove"""
+ # Sorting using minus, this supposes data 'object' to be max values
+ sortedIso = sorted(self.getIsosurfaces(),
+ key=lambda isosurface: - isosurface.getLevel())
+ self._isogroup.children = [iso._getScenePrimitive() for iso in sortedIso]
+
+ def visit(self, included=True):
+ """Generator visiting the ScalarField3D content.
+
+ It first access cut planes and then isosurface
+
+ :param bool included: True (default) to include self in visit
+ """
+ if included:
+ yield self
+ for cutPlane in self.getCutPlanes():
+ yield cutPlane
+ for isosurface in self.getIsosurfaces():
+ yield isosurface
diff --git a/silx/gui/plot3d/scene/__init__.py b/silx/gui/plot3d/scene/__init__.py
index 25a7171..9671725 100644
--- a/silx/gui/plot3d/scene/__init__.py
+++ b/silx/gui/plot3d/scene/__init__.py
@@ -29,6 +29,6 @@ __license__ = "MIT"
__date__ = "08/11/2016"
-from .core import Elem, Group, PrivateGroup # noqa
+from .core import Base, Elem, Group, PrivateGroup # noqa
from .viewport import Viewport # noqa
from .window import Window # noqa
diff --git a/silx/gui/plot3d/scene/axes.py b/silx/gui/plot3d/scene/axes.py
index 520ef3e..e35e5e1 100644
--- a/silx/gui/plot3d/scene/axes.py
+++ b/silx/gui/plot3d/scene/axes.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -87,6 +87,23 @@ class LabelledAxes(primitives.GroupBBox):
# Sync color
self.tickColor = 1., 1., 1., 1.
+ def _updateBoxAndAxes(self):
+ """Update bbox and axes position and size according to children.
+
+ Overridden from GroupBBox
+ """
+ super(LabelledAxes, self)._updateBoxAndAxes()
+
+ bounds = self._group.bounds(dataBounds=True)
+ if bounds is not None:
+ tx, ty, tz = (bounds[1] - bounds[0]) / 2.
+ else:
+ tx, ty, tz = 0.5, 0.5, 0.5
+
+ self._xlabel.transforms[-1].tx = tx
+ self._ylabel.transforms[-1].ty = ty
+ self._zlabel.transforms[-1].tz = tz
+
@property
def tickColor(self):
"""Color of ticks and text labels.
diff --git a/silx/gui/plot3d/scene/camera.py b/silx/gui/plot3d/scene/camera.py
index 8cc279d..acc5899 100644
--- a/silx/gui/plot3d/scene/camera.py
+++ b/silx/gui/plot3d/scene/camera.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -252,8 +252,9 @@ class Camera(transform.Transform):
:param float fovy: Vertical field-of-view in degrees.
:param float near: The near clipping plane Z coord (strictly positive).
:param float far: The far clipping plane Z coord (> near).
- :param size: Viewport's size used to compute the aspect ratio.
- :type size: 2-tuple of float (width, height).
+ :param size:
+ Viewport's size used to compute the aspect ratio (width, height).
+ :type size: 2-tuple of float
:param position: Coordinates of the point of view.
:type position: numpy.ndarray-like of 3 float32.
:param direction: Sight direction vector.
diff --git a/silx/gui/plot3d/scene/cutplane.py b/silx/gui/plot3d/scene/cutplane.py
index 79b4168..08a9899 100644
--- a/silx/gui/plot3d/scene/cutplane.py
+++ b/silx/gui/plot3d/scene/cutplane.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -29,7 +29,7 @@ from __future__ import absolute_import, division, unicode_literals
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "05/10/2016"
+__date__ = "11/01/2018"
import string
import numpy
@@ -53,6 +53,7 @@ class ColormapMesh3D(Geometry):
uniform mat4 transformMat;
//uniform mat3 matrixInvTranspose;
uniform vec3 dataScale;
+ uniform vec3 texCoordsOffset;
varying vec4 vCameraPosition;
varying vec3 vPosition;
@@ -64,7 +65,7 @@ class ColormapMesh3D(Geometry):
vCameraPosition = transformMat * vec4(position, 1.0);
//vNormal = matrixInvTranspose * normalize(normal);
vPosition = position;
- vTexCoords = dataScale * position;
+ vTexCoords = dataScale * position + texCoordsOffset;
vNormal = normal;
gl_Position = matrix * vec4(position, 1.0);
}
@@ -113,6 +114,8 @@ class ColormapMesh3D(Geometry):
normal=normal)
self.isBackfaceVisible = True
+ self.textureOffset = 0., 0., 0.
+ """Offset to add to texture coordinates"""
def setData(self, data, copy=True):
data = numpy.array(data, copy=copy, order='C')
@@ -209,6 +212,7 @@ class ColormapMesh3D(Geometry):
shape = self._data.shape
scales = 1./shape[2], 1./shape[1], 1./shape[0]
gl.glUniform3f(program.uniforms['dataScale'], *scales)
+ gl.glUniform3f(program.uniforms['texCoordsOffset'], *self.textureOffset)
gl.glUniform1i(program.uniforms['data'], self._texture.texUnit)
@@ -275,21 +279,13 @@ class CutPlane(PlaneInGroup):
self._interpolation = interpolation
if self._mesh is not None:
self._mesh.interpolation = interpolation
+ self.notify()
def prepareGL2(self, ctx):
if self.isValid:
contourVertices = self.contourVertices
- if (self.interpolation == 'nearest' and
- contourVertices is not None and len(contourVertices)):
- # Avoid cut plane co-linear with array bin edges
- for index, normal in enumerate(((1., 0., 0.), (0., 1., 0.), (0., 0., 1.))):
- if (numpy.all(numpy.equal(self.plane.normal, normal)) and
- int(self.plane.point[index]) == self.plane.point[index]):
- contourVertices += self.plane.normal * 0.01 # Add an offset
- break
-
if self._mesh is None and self._data is not None:
self._mesh = ColormapMesh3D(contourVertices,
normal=self.plane.normal,
@@ -298,7 +294,7 @@ class CutPlane(PlaneInGroup):
mode='fan',
colormap=self.colormap)
self._mesh.alpha = self._alpha
- self._interpolation = self.interpolation
+ self._mesh.interpolation = self.interpolation
self._children.insert(0, self._mesh)
if self._mesh is not None:
@@ -310,6 +306,23 @@ class CutPlane(PlaneInGroup):
self._mesh.setAttribute('normal', self.plane.normal)
self._mesh.setAttribute('position', contourVertices)
+ needTextureOffset = False
+ if self.interpolation == 'nearest':
+ # If cut plane is co-linear with array bin edges add texture offset
+ planePt = self.plane.point
+ for index, normal in enumerate(((1., 0., 0.),
+ (0., 1., 0.),
+ (0., 0., 1.))):
+ if (numpy.all(numpy.equal(self.plane.normal, normal)) and
+ int(planePt[index]) == planePt[index]):
+ needTextureOffset = True
+ break
+
+ if needTextureOffset:
+ self._mesh.textureOffset = self.plane.normal * 1e-6
+ else:
+ self._mesh.textureOffset = 0., 0., 0.
+
super(CutPlane, self).prepareGL2(ctx)
def renderGL2(self, ctx):
@@ -348,10 +361,11 @@ class CutPlane(PlaneInGroup):
return cachevertices
# Cache is not OK, rebuild it
- boxvertices = bounds[0] + Box._vertices.copy()*(bounds[1] - bounds[0])
- lineindices = Box._lineIndices
+ boxVertices = Box.getVertices(copy=True)
+ boxVertices = bounds[0] + boxVertices * (bounds[1] - bounds[0])
+ lineIndices = Box.getLineIndices(copy=False)
vertices = utils.boxPlaneIntersect(
- boxvertices, lineindices, self.plane.normal, self.plane.point)
+ boxVertices, lineIndices, self.plane.normal, self.plane.point)
self._cache = bounds, vertices if len(vertices) != 0 else None
diff --git a/silx/gui/plot3d/scene/function.py b/silx/gui/plot3d/scene/function.py
index 73cdb72..ba4c4ca 100644
--- a/silx/gui/plot3d/scene/function.py
+++ b/silx/gui/plot3d/scene/function.py
@@ -33,6 +33,7 @@ __date__ = "08/11/2016"
import contextlib
import logging
+import string
import numpy
from ... import _glutils
@@ -296,9 +297,8 @@ class DirectionalLight(event.Notifier, ProgramFunction):
class Colormap(event.Notifier, ProgramFunction):
- # TODO use colors for out-of-bound values, for <=0 with log, for nan
- decl = """
+ _declTemplate = string.Template("""
uniform struct {
sampler2D texture;
bool isLog;
@@ -321,9 +321,17 @@ class Colormap(event.Notifier, ProgramFunction):
value = clamp(cmap.oneOverRange * (value - cmap.min), 0.0, 1.0);
}
+ $discard
+
vec4 color = texture2D(cmap.texture, vec2(value, 0.5));
return color;
}
+ """)
+
+ _discardCode = """
+ if (value == 0.) {
+ discard;
+ }
"""
call = "colormap"
@@ -346,7 +354,10 @@ class Colormap(event.Notifier, ProgramFunction):
super(Colormap, self).__init__()
# Init privates to default
- self._colormap, self._norm, self._range = None, 'linear', (1., 10.)
+ self._colormap = None
+ self._norm = 'linear'
+ self._range = 1., 10.
+ self._displayValuesBelowMin = True
self._texture = None
self._update_texture = True
@@ -363,6 +374,12 @@ class Colormap(event.Notifier, ProgramFunction):
self.range_ = range_
@property
+ def decl(self):
+ """Source code of the function declaration"""
+ return self._declTemplate.substitute(
+ discard="" if self.displayValuesBelowMin else self._discardCode)
+
+ @property
def colormap(self):
"""Color look-up table to use."""
return numpy.array(self._colormap, copy=True)
@@ -420,6 +437,19 @@ class Colormap(event.Notifier, ProgramFunction):
self._range = range_
self.notify()
+ @property
+ def displayValuesBelowMin(self):
+ """True to display values below colormap min, False to discard them.
+ """
+ return self._displayValuesBelowMin
+
+ @displayValuesBelowMin.setter
+ def displayValuesBelowMin(self, displayValuesBelowMin):
+ displayValuesBelowMin = bool(displayValuesBelowMin)
+ if self._displayValuesBelowMin != displayValuesBelowMin:
+ self._displayValuesBelowMin = displayValuesBelowMin
+ self.notify()
+
def setupProgram(self, context, program):
"""Sets-up uniforms of a program using this shader function.
diff --git a/silx/gui/plot3d/scene/interaction.py b/silx/gui/plot3d/scene/interaction.py
index 2911b2c..e5cfb6d 100644
--- a/silx/gui/plot3d/scene/interaction.py
+++ b/silx/gui/plot3d/scene/interaction.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -33,6 +33,7 @@ __date__ = "25/07/2016"
import logging
import numpy
+from silx.gui import qt
from silx.gui.plot.Interaction import \
StateMachine, State, LEFT_BTN, RIGHT_BTN # , MIDDLE_BTN
@@ -380,16 +381,16 @@ 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:
+ for eventHandler in self.machine.currentEventHandler:
+ 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:
- consumeevent = eventHandler.handleEvent(*args)
- if consumeevent:
+ for eventHandler in self.machine.currentEventHandler:
+ consumeEvent = eventHandler.handleEvent(*args)
+ if consumeEvent:
break
def onMove(self, x, y):
@@ -424,8 +425,10 @@ class FocusManager(StateMachine):
def onWheel(self, x, y, angleInDegrees):
self.eventHandler.handleEvent('wheel', x, y, angleInDegrees)
- def __init__(self, eventHandlers=()):
- self.eventHandlers = list(eventHandlers)
+ def __init__(self, eventHandlers=(), ctrlEventHandlers=None):
+ self.defaultEventHandlers = eventHandlers
+ self.ctrlEventHandlers = ctrlEventHandlers
+ self.currentEventHandler = self.defaultEventHandlers
states = {
'idle': FocusManager.Idle,
@@ -433,31 +436,48 @@ class FocusManager(StateMachine):
}
super(FocusManager, self).__init__(states, 'idle')
+ def onKeyPress(self, key):
+ if key == qt.Qt.Key_Control and self.ctrlEventHandlers is not None:
+ self.currentEventHandler = self.ctrlEventHandlers
+
+ def onKeyRelease(self, key):
+ if key == qt.Qt.Key_Control:
+ self.currentEventHandler = self.defaultEventHandlers
+
def cancel(self):
- for handler in self.eventHandlers:
+ for handler in self.currentEventHandler:
handler.cancel()
# CameraControl ###############################################################
class RotateCameraControl(FocusManager):
- """Combine wheel and rotate state machine."""
+ """Combine wheel and rotate state machine for left button
+ and pan when ctrl is pressed
+ """
def __init__(self, viewport,
orbitAroundCenter=False,
- mode='center', scaleTransform=None):
+ mode='center', scaleTransform=None,
+ selectCB=None):
handlers = (CameraWheel(viewport, mode, scaleTransform),
CameraRotate(viewport, orbitAroundCenter, LEFT_BTN))
- super(RotateCameraControl, self).__init__(handlers)
+ ctrlHandlers = (CameraWheel(viewport, mode, scaleTransform),
+ CameraSelectPan(viewport, LEFT_BTN, selectCB))
+ super(RotateCameraControl, self).__init__(handlers, ctrlHandlers)
class PanCameraControl(FocusManager):
- """Combine wheel, selectPan and rotate state machine."""
+ """Combine wheel, selectPan and rotate state machine for left button
+ and rotate when ctrl is pressed"""
def __init__(self, viewport,
+ orbitAroundCenter=False,
mode='center', scaleTransform=None,
selectCB=None):
handlers = (CameraWheel(viewport, mode, scaleTransform),
CameraSelectPan(viewport, LEFT_BTN, selectCB))
- super(PanCameraControl, self).__init__(handlers)
+ ctrlHandlers = (CameraWheel(viewport, mode, scaleTransform),
+ CameraRotate(viewport, orbitAroundCenter, LEFT_BTN))
+ super(PanCameraControl, self).__init__(handlers, ctrlHandlers)
class CameraControl(FocusManager):
@@ -675,7 +695,11 @@ class PanPlaneRotateCameraControl(FocusManager):
class PanPlaneZoomOnWheelControl(FocusManager):
"""Combine zoom on wheel and pan plane state machines."""
def __init__(self, viewport, plane,
- mode='center', scaleTransform=None):
+ mode='center',
+ orbitAroundCenter=False,
+ scaleTransform=None):
handlers = (CameraWheel(viewport, mode, scaleTransform),
PlanePan(viewport, plane, LEFT_BTN))
- super(PanPlaneZoomOnWheelControl, self).__init__(handlers)
+ ctrlHandlers = (CameraWheel(viewport, mode, scaleTransform),
+ CameraRotate(viewport, orbitAroundCenter, LEFT_BTN))
+ super(PanPlaneZoomOnWheelControl, self).__init__(handlers, ctrlHandlers)
diff --git a/silx/gui/plot3d/scene/primitives.py b/silx/gui/plot3d/scene/primitives.py
index fc38e09..abf7dd4 100644
--- a/silx/gui/plot3d/scene/primitives.py
+++ b/silx/gui/plot3d/scene/primitives.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -47,6 +47,7 @@ from . import event
from . import core
from . import transform
from . import utils
+from .function import Colormap
_logger = logging.getLogger(__name__)
@@ -60,6 +61,7 @@ class Geometry(core.Elem):
lines, line_strip, loop, triangles, triangle_strip, fan
:param indices: Array of vertex indices or None
:param bool copy: True (default) to copy the data, False to use as is.
+ :param str attrib0: Name of the attribute that MUST be an array.
:param attributes: Provide list of attributes as extra parameters.
"""
@@ -91,12 +93,21 @@ class Geometry(core.Elem):
_TRIANGLE_MODES = 'triangles', 'triangle_strip', 'fan'
- def __init__(self, mode, indices=None, copy=True, **attributes):
+ def __init__(self,
+ mode,
+ indices=None,
+ copy=True,
+ attrib0='position',
+ **attributes):
super(Geometry, self).__init__()
+ self._attrib0 = str(attrib0)
+
self._vbos = {} # Store current vbos
self._unsyncAttributes = [] # Store attributes to copy to vbos
self.__bounds = None # Cache object's bounds
+ # Attribute names defining the object bounds
+ self.__boundsAttributeNames = (self._attrib0,)
assert mode in self._MODES
self._mode = mode
@@ -116,9 +127,16 @@ class Geometry(core.Elem):
nbvertices = len(self._indices)
else:
nbvertices = self.nbVertices
- assert nbvertices >= mincheck
- if modulocheck != 0:
- assert (nbvertices % modulocheck) == 0
+
+ if nbvertices != 0:
+ assert nbvertices >= mincheck
+ if modulocheck != 0:
+ assert (nbvertices % modulocheck) == 0
+
+ @property
+ def drawMode(self):
+ """Kind of primitive to render, in :attr:`_MODES` (str)"""
+ return self._mode
@staticmethod
def _glReadyArray(array, copy=True):
@@ -134,10 +152,19 @@ class Geometry(core.Elem):
# Makes sure it is an array
array = numpy.array(array, copy=False)
- # Cast all float to float32
dtype = None
- if numpy.dtype(array.dtype).kind == 'f':
+ if array.dtype.kind == 'f' and array.dtype.itemsize != 4:
+ # Cast to float32
+ _logger.info('Cast array to float32')
dtype = numpy.float32
+ elif array.dtype.itemsize > 4:
+ # Cast (u)int64 to (u)int32
+ if array.dtype.kind == 'i':
+ _logger.info('Cast array to int32')
+ dtype = numpy.int32
+ elif array.dtype.kind == 'u':
+ _logger.info('Cast array to uint32')
+ dtype = numpy.uint32
return numpy.array(array, dtype=dtype, order='C', copy=copy)
@@ -152,6 +179,11 @@ class Geometry(core.Elem):
return len(array)
return None
+ @property
+ def attrib0(self):
+ """Attribute name that MUST be an array (str)"""
+ return self._attrib0
+
def setAttribute(self, name, array, copy=True):
"""Set attribute with provided array.
@@ -169,29 +201,33 @@ class Geometry(core.Elem):
array = self._glReadyArray(array, copy=copy)
if name not in self._ATTR_INFO:
- _logger.info('Not checking attibute %s dimensions', name)
+ _logger.info('Not checking attribute %s dimensions', name)
else:
checks = self._ATTR_INFO[name]
- if (len(array.shape) == 1 and checks['lastDim'] == (1,) and
+ if (array.ndim == 1 and checks['lastDim'] == (1,) and
len(array) > 1):
array = array.reshape((len(array), 1))
# Checks
- assert len(array.shape) in checks['dims'], "Attr %s" % name
+ assert array.ndim in checks['dims'], "Attr %s" % name
assert array.shape[-1] in checks['lastDim'], "Attr %s" % name
+ # Makes sure attrib0 is considered as an array of values
+ if name == self.attrib0 and array.ndim == 1:
+ array.shape = 1, -1
+
# Check length against another attribute array
# Causes problems when updating
# nbVertices = self.nbVertices
- # if len(array.shape) == 2 and nbVertices is not None:
+ # if array.ndim == 2 and nbVertices is not None:
# assert len(array) == nbVertices
self._attributes[name] = array
- if len(array.shape) == 2: # Store this in a VBO
+ if array.ndim == 2: # Store this in a VBO
self._unsyncAttributes.append(name)
- if name == 'position': # Reset bounds
+ if name in self.boundsAttributeNames: # Reset bounds
self.__bounds = None
self.notify()
@@ -238,7 +274,7 @@ class Geometry(core.Elem):
array = self._attributes[name]
assert array is not None
- if len(array.shape) == 1:
+ if array.ndim == 1:
assert len(array) in (1, 2, 3, 4)
gl.glDisableVertexAttribArray(attribute)
_glVertexAttribFunc = getattr(
@@ -273,6 +309,7 @@ class Geometry(core.Elem):
# This might be a costy check
assert indices.max() < self.nbVertices
self._indices = indices
+ self.notify()
def getIndices(self, copy=True):
"""Returns the numpy.ndarray corresponding to the indices.
@@ -287,16 +324,59 @@ class Geometry(core.Elem):
else:
return numpy.array(self._indices, copy=copy)
+ @property
+ def boundsAttributeNames(self):
+ """Tuple of attribute names defining the bounds of the object.
+
+ Attributes name are taken in the given order to compute the
+ (x, y, z) the bounding box, e.g.::
+
+ geometry.boundsAttributeNames = 'position'
+ geometry.boundsAttributeNames = 'x', 'y', 'z'
+ """
+ return self.__boundsAttributeNames
+
+ @boundsAttributeNames.setter
+ def boundsAttributeNames(self, names):
+ self.__boundsAttributeNames = tuple(str(name) for name in names)
+ self.__bounds = None
+ self.notify()
+
def _bounds(self, dataBounds=False):
if self.__bounds is None:
+ if len(self.boundsAttributeNames) == 0:
+ return None # No bounds
+
self.__bounds = numpy.zeros((2, 3), dtype=numpy.float32)
- # Support vertex with to 2 to 4 coordinates
- positions = self._attributes['position']
- self.__bounds[0, :positions.shape[1]] = \
- numpy.nanmin(positions, axis=0)[:3]
- self.__bounds[1, :positions.shape[1]] = \
- numpy.nanmax(positions, axis=0)[:3]
+
+ # Coordinates defined in one or more attributes
+ index = 0
+ for name in self.boundsAttributeNames:
+ if index == 3:
+ _logger.error("Too many attributes defining bounds")
+ break
+
+ attribute = self._attributes[name]
+ assert attribute.ndim in (1, 2)
+ if attribute.ndim == 1: # Single value
+ min_ = attribute
+ max_ = attribute
+ else: # Array of values, compute min/max
+ min_ = numpy.nanmin(attribute, axis=0)
+ max_ = numpy.nanmax(attribute, axis=0)
+
+ toCopy = min(len(min_), 3-index)
+ if toCopy != len(min_):
+ _logger.error("Attribute defining bounds"
+ " has too many dimensions")
+
+ self.__bounds[0, index:index+toCopy] = min_[:toCopy]
+ self.__bounds[1, index:index+toCopy] = max_[:toCopy]
+
+ index += toCopy
+
self.__bounds[numpy.isnan(self.__bounds)] = 0. # Avoid NaNs
+
return self.__bounds.copy()
def prepareGL2(self, ctx):
@@ -593,9 +673,7 @@ class Box(core.PrivateGroup):
(0., 0., 1.), (1., 0., 1.), (1., 1., 1.), (0., 1., 1.)),
dtype=numpy.float32)
- def __init__(self, size=(1., 1., 1.),
- stroke=(1., 1., 1., 1.),
- fill=(1., 1., 1., 0.)):
+ def __init__(self, stroke=(1., 1., 1., 1.), fill=(1., 1., 1., 0.)):
super(Box, self).__init__()
self._fill = Mesh3D(self._vertices,
@@ -613,8 +691,27 @@ class Box(core.PrivateGroup):
self._children = [self._stroke, self._fill]
- self._size = None
- self.size = size
+ self._size = 1., 1., 1.
+
+ @classmethod
+ def getLineIndices(cls, copy=True):
+ """Returns 2D array of Box lines indices
+
+ :param copy: True (default) to get a copy,
+ False to get internal array (Do not modify!)
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(cls._lineIndices, copy=copy)
+
+ @classmethod
+ def getVertices(cls, copy=True):
+ """Returns 2D array of Box corner coordinates.
+
+ :param copy: True (default) to get a copy,
+ False to get internal array (Do not modify!)
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(cls._vertices, copy=copy)
@property
def size(self):
@@ -712,6 +809,23 @@ class Axes(Lines):
super(Axes, self).__init__(self._vertices,
colors=self._colors,
width=3.)
+ self._size = 1., 1., 1.
+
+ @property
+ def size(self):
+ """Size of the axes (sx, sy, sz)"""
+ return self._size
+
+ @size.setter
+ def size(self, size):
+ assert len(size) == 3
+ size = tuple(size)
+ if size != self.size:
+ self._size = size
+ self.setAttribute(
+ 'position',
+ self._vertices * numpy.array(size, dtype=numpy.float32))
+ self.notify()
class BoxWithAxes(Lines):
@@ -752,6 +866,7 @@ class BoxWithAxes(Lines):
indices=self._lineIndices,
colors=colors,
width=2.)
+ self._size = 1., 1., 1.
self.color = color
@property
@@ -769,6 +884,22 @@ class BoxWithAxes(Lines):
colors[len(self._axesColors):, :] = self._color
self.setAttribute('color', colors) # Do the notification
+ @property
+ def size(self):
+ """Size of the axes (sx, sy, sz)"""
+ return self._size
+
+ @size.setter
+ def size(self, size):
+ assert len(size) == 3
+ size = tuple(size)
+ if size != self.size:
+ self._size = size
+ self.setAttribute(
+ 'position',
+ self._vertices * numpy.array(size, dtype=numpy.float32))
+ self.notify()
+
class PlaneInGroup(core.PrivateGroup):
"""A plane using its parent bounds to display a contour.
@@ -788,6 +919,7 @@ class PlaneInGroup(core.PrivateGroup):
self._color = None
self.color = 1., 1., 1., 1. # Set _color
self._width = 2.
+ self._strokeVisible = True
self._plane = utils.Plane(point, normal)
self._plane.addListener(self._planeChanged)
@@ -825,6 +957,17 @@ class PlaneInGroup(core.PrivateGroup):
if self._outline is not None:
self._outline.width = self._width # Sync width
+ @property
+ def strokeVisible(self):
+ """Whether surrounding stroke is visible or not (bool)."""
+ return self._strokeVisible
+
+ @strokeVisible.setter
+ def strokeVisible(self, visible):
+ self._strokeVisible = bool(visible)
+ if self._outline is not None:
+ self._outline.visible = self._strokeVisible
+
# Plane access
@property
@@ -865,10 +1008,11 @@ class PlaneInGroup(core.PrivateGroup):
return cachevertices
# Cache is not OK, rebuild it
- boxvertices = bounds[0] + Box._vertices.copy()*(bounds[1] - bounds[0])
- lineindices = Box._lineIndices
+ boxVertices = Box.getVertices(copy=True)
+ boxVertices = bounds[0] + boxVertices * (bounds[1] - bounds[0])
+ lineIndices = Box.getLineIndices(copy=False)
vertices = utils.boxPlaneIntersect(
- boxvertices, lineindices, self.plane.normal, self.plane.point)
+ boxVertices, lineIndices, self.plane.normal, self.plane.point)
self._cache = bounds, vertices if len(vertices) != 0 else None
@@ -894,6 +1038,7 @@ class PlaneInGroup(core.PrivateGroup):
mode='loop',
colors=self.color)
self._outline.width = self._width
+ self._outline.visible = self._strokeVisible
self._children.append(self._outline)
# Update vertices, TODO only when necessary
@@ -906,303 +1051,362 @@ class PlaneInGroup(core.PrivateGroup):
super(PlaneInGroup, self).renderGL2(ctx)
+class BoundedGroup(core.Group):
+ """Group with data bounds"""
+
+ _shape = None # To provide a default value without overriding __init__
+
+ @property
+ def shape(self):
+ """Data shape (depth, height, width) of this group or None"""
+ return self._shape
+
+ @shape.setter
+ def shape(self, shape):
+ if shape is None:
+ self._shape = None
+ else:
+ depth, height, width = shape
+ self._shape = float(depth), float(height), float(width)
+
+ @property
+ def size(self):
+ """Data size (width, height, depth) of this group or None"""
+ shape = self.shape
+ if shape is None:
+ return None
+ else:
+ return shape[2], shape[1], shape[0]
+
+ @size.setter
+ def size(self, size):
+ if size is None:
+ self.shape = None
+ else:
+ self.shape = size[2], size[1], size[0]
+
+ def _bounds(self, dataBounds=False):
+ if dataBounds and self.size is not None:
+ return numpy.array(((0., 0., 0.), self.size),
+ dtype=numpy.float32)
+ else:
+ return super(BoundedGroup, self)._bounds(dataBounds)
+
+
# Points ######################################################################
-_POINTS_ATTR_INFO = Geometry._ATTR_INFO.copy()
-_POINTS_ATTR_INFO.update(value={'dims': (1, 2), 'lastDim': (1,)},
- size={'dims': (1, 2), 'lastDim': (1,)},
- symbol={'dims': (1, 2), 'lastDim': (1,)})
+class _Points(Geometry):
+ """Base class to render a set of points."""
+
+ DIAMOND = 'd'
+ CIRCLE = 'o'
+ SQUARE = 's'
+ PLUS = '+'
+ X_MARKER = 'x'
+ ASTERISK = '*'
+ H_LINE = '_'
+ V_LINE = '|'
+
+ SUPPORTED_MARKERS = (DIAMOND, CIRCLE, SQUARE, PLUS,
+ X_MARKER, ASTERISK, H_LINE, V_LINE)
+ """List of supported markers:
+
+ - 'd' diamond
+ - 'o' circle
+ - 's' square
+ - '+' cross
+ - 'x' x-cross
+ - '*' asterisk
+ - '_' horizontal line
+ - '|' vertical line
+ """
+ _MARKER_FUNCTIONS = {
+ DIAMOND: """
+ float alphaSymbol(vec2 coord, float size) {
+ vec2 centerCoord = abs(coord - vec2(0.5, 0.5));
+ float f = centerCoord.x + centerCoord.y;
+ return clamp(size * (0.5 - f), 0.0, 1.0);
+ }
+ """,
+ CIRCLE: """
+ float alphaSymbol(vec2 coord, float size) {
+ float radius = 0.5;
+ float r = distance(coord, vec2(0.5, 0.5));
+ return clamp(size * (radius - r), 0.0, 1.0);
+ }
+ """,
+ SQUARE: """
+ float alphaSymbol(vec2 coord, float size) {
+ return 1.0;
+ }
+ """,
+ PLUS: """
+ float alphaSymbol(vec2 coord, float size) {
+ vec2 d = abs(size * (coord - vec2(0.5, 0.5)));
+ if (min(d.x, d.y) < 0.5) {
+ return 1.0;
+ } else {
+ return 0.0;
+ }
+ }
+ """,
+ X_MARKER: """
+ float alphaSymbol(vec2 coord, float size) {
+ vec2 pos = floor(size * coord) + 0.5;
+ vec2 d_x = abs(pos.x + vec2(- pos.y, pos.y - size));
+ if (min(d_x.x, d_x.y) <= 0.5) {
+ return 1.0;
+ } else {
+ return 0.0;
+ }
+ }
+ """,
+ ASTERISK: """
+ float alphaSymbol(vec2 coord, float size) {
+ /* Combining +, x and circle */
+ vec2 d_plus = abs(size * (coord - vec2(0.5, 0.5)));
+ vec2 pos = floor(size * coord) + 0.5;
+ vec2 d_x = abs(pos.x + vec2(- pos.y, pos.y - size));
+ if (min(d_plus.x, d_plus.y) < 0.5) {
+ return 1.0;
+ } else if (min(d_x.x, d_x.y) <= 0.5) {
+ float r = distance(coord, vec2(0.5, 0.5));
+ return clamp(size * (0.5 - r), 0.0, 1.0);
+ } else {
+ return 0.0;
+ }
+ }
+ """,
+ H_LINE: """
+ float alphaSymbol(vec2 coord, float size) {
+ float dy = abs(size * (coord.y - 0.5));
+ if (dy < 0.5) {
+ return 1.0;
+ } else {
+ return 0.0;
+ }
+ }
+ """,
+ V_LINE: """
+ float alphaSymbol(vec2 coord, float size) {
+ float dx = abs(size * (coord.x - 0.5));
+ if (dx < 0.5) {
+ return 1.0;
+ } else {
+ return 0.0;
+ }
+ }
+ """
+ }
-class Points(Geometry):
- """A set of data points with an associated value and size."""
- _shaders = ("""
+ _shaders = (string.Template("""
#version 120
- attribute vec3 position;
- attribute float symbol;
- attribute float value;
+ attribute float x;
+ attribute float y;
+ attribute float z;
+ attribute $valueType value;
attribute float size;
uniform mat4 matrix;
uniform mat4 transformMat;
- uniform vec2 valRange;
-
varying vec4 vCameraPosition;
- varying float vSymbol;
- varying float vNormValue;
+ varying $valueType vValue;
varying float vSize;
void main(void)
{
- vSymbol = symbol;
-
- vNormValue = clamp((value - valRange.x) / (valRange.y - valRange.x),
- 0.0, 1.0);
+ vValue = value;
- bool isValueInRange = value >= valRange.x && value <= valRange.y;
- if (isValueInRange) {
- gl_Position = matrix * vec4(position, 1.0);
- } else {
- gl_Position = vec4(2.0, 0.0, 0.0, 1.0); /* Get clipped */
- }
- vCameraPosition = transformMat * vec4(position, 1.0);
+ vec4 positionVec4 = vec4(x, y, z, 1.0);
+ gl_Position = matrix * positionVec4;
+ vCameraPosition = transformMat * positionVec4;
gl_PointSize = size;
vSize = size;
}
- """,
+ """),
string.Template("""
#version 120
varying vec4 vCameraPosition;
varying float vSize;
- varying float vSymbol;
- varying float vNormValue;
-
- $clippinDecl
-
- /* Circle */
- #define SYMBOL_CIRCLE 1.0
+ varying $valueType vValue;
- float alphaCircle(vec2 coord, float size) {
- float radius = 0.5;
- float r = distance(coord, vec2(0.5, 0.5));
- return clamp(size * (radius - r), 0.0, 1.0);
- }
+ $valueToColorDecl
- /* Half lines */
- #define SYMBOL_H_LINE 2.0
- #define LEFT 1.0
- #define RIGHT 2.0
- #define SYMBOL_V_LINE 3.0
- #define UP 1.0
- #define DOWN 2.0
+ $clippingDecl
- float alphaLine(vec2 coord, float size, float direction)
- {
- vec2 delta = abs(size * (coord - 0.5));
-
- if (direction == SYMBOL_H_LINE) {
- return (delta.y < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_H_LINE + LEFT) {
- return (coord.x <= 0.5 && delta.y < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_H_LINE + RIGHT) {
- return (coord.x >= 0.5 && delta.y < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_V_LINE) {
- return (delta.x < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_V_LINE + UP) {
- return (coord.y <= 0.5 && delta.x < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_V_LINE + DOWN) {
- return (coord.y >= 0.5 && delta.x < 0.5) ? 1.0 : 0.0;
- }
- return 1.0;
- }
+ $alphaSymbolDecl
void main(void)
{
$clippingCall(vCameraPosition);
- gl_FragColor = vec4(0.5 * vNormValue + 0.5, 0.0, 0.0, 1.0);
-
- float alpha = 1.0;
- float symbol = floor(vSymbol);
- if (1 == 1) { //symbol == SYMBOL_CIRCLE) {
- alpha = alphaCircle(gl_PointCoord, vSize);
- }
- else if (symbol >= SYMBOL_H_LINE &&
- symbol <= (SYMBOL_V_LINE + DOWN)) {
- alpha = alphaLine(gl_PointCoord, vSize, symbol);
- }
+ float alpha = alphaSymbol(gl_PointCoord, vSize);
if (alpha == 0.0) {
discard;
}
+
+ gl_FragColor = $valueToColorCall(vValue);
gl_FragColor.a *= alpha;
}
"""))
- _ATTR_INFO = _POINTS_ATTR_INFO
+ _ATTR_INFO = {
+ 'x': {'dims': (1, 2), 'lastDim': (1,)},
+ 'y': {'dims': (1, 2), 'lastDim': (1,)},
+ 'z': {'dims': (1, 2), 'lastDim': (1,)},
+ 'size': {'dims': (1, 2), 'lastDim': (1,)},
+ }
- # TODO Add colormap, light?
+ def __init__(self, x, y, z, value, size=1., indices=None):
+ super(_Points, self).__init__('points', indices,
+ x=x,
+ y=y,
+ z=z,
+ value=value,
+ size=size,
+ attrib0='x')
+ self.boundsAttributeNames = 'x', 'y', 'z'
+ self._marker = 'o'
- def __init__(self, vertices, values=0., sizes=1., indices=None,
- symbols=0.,
- minValue=None, maxValue=None):
- super(Points, self).__init__('points', indices,
- position=vertices,
- value=values,
- size=sizes,
- symbol=symbols)
+ @property
+ def marker(self):
+ """The marker symbol used to display the scatter plot (str)
- values = self._attributes['value']
- self._minValue = values.min() if minValue is None else minValue
- self._maxValue = values.max() if maxValue is None else maxValue
+ See :attr:`SUPPORTED_MARKERS` for the list of supported marker string.
+ """
+ return self._marker
+
+ @marker.setter
+ def marker(self, marker):
+ marker = str(marker)
+ assert marker in self.SUPPORTED_MARKERS
+ if marker != self._marker:
+ self._marker = marker
+ self.notify()
- minValue = event.notifyProperty('_minValue')
- maxValue = event.notifyProperty('_maxValue')
+ def _shaderValueDefinition(self):
+ """Type definition, fragment shader declaration, fragment shader call
+ """
+ raise NotImplementedError(
+ "This method must be implemented in subclass")
+
+ def _renderGL2PreDrawHook(self, ctx, program):
+ """Override in subclass to run code before calling gl draw"""
+ pass
def renderGL2(self, ctx):
- fragment = self._shaders[1].substitute(
+ valueType, valueToColorDecl, valueToColorCall = \
+ self._shaderValueDefinition()
+ vertexShader = self._shaders[0].substitute(
+ valueType=valueType)
+ fragmentShader = self._shaders[1].substitute(
clippingDecl=ctx.clipper.fragDecl,
- clippingCall=ctx.clipper.fragCall)
- prog = ctx.glCtx.prog(self._shaders[0], fragment)
- prog.use()
+ clippingCall=ctx.clipper.fragCall,
+ valueType=valueType,
+ valueToColorDecl=valueToColorDecl,
+ valueToColorCall=valueToColorCall,
+ alphaSymbolDecl=self._MARKER_FUNCTIONS[self.marker])
+ program = ctx.glCtx.prog(vertexShader, fragmentShader,
+ attrib0=self.attrib0)
+ program.use()
gl.glEnable(gl.GL_VERTEX_PROGRAM_POINT_SIZE) # OpenGL 2
gl.glEnable(gl.GL_POINT_SPRITE) # OpenGL 2
# gl.glEnable(gl.GL_PROGRAM_POINT_SIZE)
- prog.setUniformMatrix('matrix', ctx.objectToNDC.matrix)
- prog.setUniformMatrix('transformMat',
- ctx.objectToCamera.matrix,
- safe=True)
-
- ctx.clipper.setupProgram(ctx, prog)
-
- gl.glUniform2f(prog.uniforms['valRange'], self.minValue, self.maxValue)
-
- self._draw(prog)
-
-
-class ColorPoints(Geometry):
- """A set of points with an associated color and size."""
+ program.setUniformMatrix('matrix', ctx.objectToNDC.matrix)
+ program.setUniformMatrix('transformMat',
+ ctx.objectToCamera.matrix,
+ safe=True)
- _shaders = ("""
- #version 120
+ ctx.clipper.setupProgram(ctx, program)
- attribute vec3 position;
- attribute float symbol;
- attribute vec4 color;
- attribute float size;
+ self._renderGL2PreDrawHook(ctx, program)
- uniform mat4 matrix;
- uniform mat4 transformMat;
+ self._draw(program)
- varying vec4 vCameraPosition;
- varying float vSymbol;
- varying vec4 vColor;
- varying float vSize;
- void main(void)
- {
- vCameraPosition = transformMat * vec4(position, 1.0);
- vSymbol = symbol;
- vColor = color;
- gl_Position = matrix * vec4(position, 1.0);
- gl_PointSize = size;
- vSize = size;
- }
- """,
- string.Template("""
- #version 120
-
- varying vec4 vCameraPosition;
- varying float vSize;
- varying float vSymbol;
- varying vec4 vColor;
+class Points(_Points):
+ """A set of data points with an associated value and size."""
- $clippingDecl;
+ _ATTR_INFO = _Points._ATTR_INFO.copy()
+ _ATTR_INFO.update({'value': {'dims': (1, 2), 'lastDim': (1,)}})
- /* Circle */
- #define SYMBOL_CIRCLE 1.0
+ def __init__(self, x, y, z, value=0., size=1.,
+ indices=None, colormap=None):
+ super(Points, self).__init__(x=x,
+ y=y,
+ z=z,
+ indices=indices,
+ size=size,
+ value=value)
- float alphaCircle(vec2 coord, float size) {
- float radius = 0.5;
- float r = distance(coord, vec2(0.5, 0.5));
- return clamp(size * (radius - r), 0.0, 1.0);
- }
+ self._colormap = colormap or Colormap() # Default colormap
+ self._colormap.addListener(self._cmapChanged)
- /* Half lines */
- #define SYMBOL_H_LINE 2.0
- #define LEFT 1.0
- #define RIGHT 2.0
- #define SYMBOL_V_LINE 3.0
- #define UP 1.0
- #define DOWN 2.0
+ @property
+ def colormap(self):
+ """The colormap used to render the image"""
+ return self._colormap
- float alphaLine(vec2 coord, float size, float direction)
- {
- vec2 delta = abs(size * (coord - 0.5));
+ def _cmapChanged(self, source, *args, **kwargs):
+ """Broadcast colormap changes"""
+ self.notify(*args, **kwargs)
- if (direction == SYMBOL_H_LINE) {
- return (delta.y < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_H_LINE + LEFT) {
- return (coord.x <= 0.5 && delta.y < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_H_LINE + RIGHT) {
- return (coord.x >= 0.5 && delta.y < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_V_LINE) {
- return (delta.x < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_V_LINE + UP) {
- return (coord.y <= 0.5 && delta.x < 0.5) ? 1.0 : 0.0;
- }
- else if (direction == SYMBOL_V_LINE + DOWN) {
- return (coord.y >= 0.5 && delta.x < 0.5) ? 1.0 : 0.0;
- }
- return 1.0;
- }
+ def _shaderValueDefinition(self):
+ """Type definition, fragment shader declaration, fragment shader call
+ """
+ return 'float', self.colormap.decl, self.colormap.call
- void main(void)
- {
- $clippingCall(vCameraPosition);
+ def _renderGL2PreDrawHook(self, ctx, program):
+ """Set-up colormap before calling gl draw"""
+ self.colormap.setupProgram(ctx, program)
- gl_FragColor = vColor;
- float alpha = 1.0;
- float symbol = floor(vSymbol);
- if (1 == 1) { //symbol == SYMBOL_CIRCLE) {
- alpha = alphaCircle(gl_PointCoord, vSize);
- }
- else if (symbol >= SYMBOL_H_LINE &&
- symbol <= (SYMBOL_V_LINE + DOWN)) {
- alpha = alphaLine(gl_PointCoord, vSize, symbol);
- }
- if (alpha == 0.0) {
- discard;
- }
- gl_FragColor.a *= alpha;
- }
- """))
+class ColorPoints(_Points):
+ """A set of points with an associated color and size."""
- _ATTR_INFO = _POINTS_ATTR_INFO
+ _ATTR_INFO = _Points._ATTR_INFO.copy()
+ _ATTR_INFO.update({'value': {'dims': (1, 2), 'lastDim': (4,)}})
- def __init__(self, vertices, colors=(1., 1., 1., 1.), sizes=1.,
- indices=None, symbols=0.,
- minValue=None, maxValue=None):
- super(ColorPoints, self).__init__('points', indices,
- position=vertices,
- color=colors,
- size=sizes,
- symbol=symbols)
+ def __init__(self, x, y, z, color=(1., 1., 1., 1.), size=1.,
+ indices=None):
+ super(ColorPoints, self).__init__(x=x,
+ y=y,
+ z=z,
+ indices=indices,
+ size=size,
+ value=color)
- def renderGL2(self, ctx):
- fragment = self._shaders[1].substitute(
- clippingDecl=ctx.clipper.fragDecl,
- clippingCall=ctx.clipper.fragCall)
- prog = ctx.glCtx.prog(self._shaders[0], fragment)
- prog.use()
+ def _shaderValueDefinition(self):
+ """Type definition, fragment shader declaration, fragment shader call
+ """
+ return 'vec4', '', ''
- gl.glEnable(gl.GL_VERTEX_PROGRAM_POINT_SIZE) # OpenGL 2
- gl.glEnable(gl.GL_POINT_SPRITE) # OpenGL 2
- # gl.glEnable(gl.GL_PROGRAM_POINT_SIZE)
+ def setColor(self, color, copy=True):
+ """Set colors
- prog.setUniformMatrix('matrix', ctx.objectToNDC.matrix)
- prog.setUniformMatrix('transformMat',
- ctx.objectToCamera.matrix,
- safe=True)
+ :param color: Single RGBA color or
+ 2D array of color of length number of points
+ :param bool copy: True to copy colors (default),
+ False to use provided array (Do not modify!)
+ """
+ self.setAttribute('value', color, copy=copy)
- ctx.clipper.setupProgram(ctx, prog)
+ def getColor(self, copy=True):
+ """Returns the color or array of colors of the points.
- self._draw(prog)
+ :param copy: True to get a copy (default),
+ False to return internal array (Do not modify!)
+ :return: Color or array of colors
+ :rtype: numpy.ndarray
+ """
+ return self.getAttribute('value', copy=copy)
class GridPoints(Geometry):
@@ -1560,12 +1764,14 @@ class Mesh3D(Geometry):
colors,
normals=None,
mode='triangles',
- indices=None):
+ indices=None,
+ copy=True):
assert mode in self._TRIANGLE_MODES
super(Mesh3D, self).__init__(mode, indices,
position=positions,
normal=normals,
- color=colors)
+ color=colors,
+ copy=copy)
self._culling = None
@@ -1620,6 +1826,435 @@ class Mesh3D(Geometry):
gl.glDisable(gl.GL_CULL_FACE)
+class ColormapMesh3D(Geometry):
+ """A 3D mesh with color computed from a colormap"""
+
+ _shaders = ("""
+ attribute vec3 position;
+ attribute vec3 normal;
+ attribute float value;
+
+ uniform mat4 matrix;
+ uniform mat4 transformMat;
+ //uniform mat3 matrixInvTranspose;
+
+ varying vec4 vCameraPosition;
+ varying vec3 vPosition;
+ varying vec3 vNormal;
+ varying float vValue;
+
+ void main(void)
+ {
+ vCameraPosition = transformMat * vec4(position, 1.0);
+ //vNormal = matrixInvTranspose * normalize(normal);
+ vPosition = position;
+ vNormal = normal;
+ vValue = value;
+ gl_Position = matrix * vec4(position, 1.0);
+ }
+ """,
+ string.Template("""
+ varying vec4 vCameraPosition;
+ varying vec3 vPosition;
+ varying vec3 vNormal;
+ varying float vValue;
+
+ $colormapDecl
+ $clippingDecl
+ $lightingFunction
+
+ void main(void)
+ {
+ $clippingCall(vCameraPosition);
+
+ vec4 color = $colormapCall(vValue);
+ gl_FragColor = $lightingCall(color, vPosition, vNormal);
+ }
+ """))
+
+ def __init__(self,
+ position,
+ value,
+ colormap=None,
+ normal=None,
+ mode='triangles',
+ indices=None):
+ super(ColormapMesh3D, self).__init__(mode, indices,
+ position=position,
+ normal=normal,
+ value=value)
+
+ self._lineWidth = 1.0
+ self._lineSmooth = True
+ self._culling = None
+ self._colormap = colormap or Colormap() # Default colormap
+ self._colormap.addListener(self._cmapChanged)
+
+ lineWidth = event.notifyProperty('_lineWidth', converter=float,
+ doc="Width of the line in pixels.")
+
+ lineSmooth = event.notifyProperty(
+ '_lineSmooth',
+ converter=bool,
+ doc="Smooth line rendering enabled (bool, default: True)")
+
+ @property
+ def culling(self):
+ """Face culling (str)
+
+ One of 'back', 'front' or None.
+ """
+ return self._culling
+
+ @culling.setter
+ def culling(self, culling):
+ assert culling in ('back', 'front', None)
+ if culling != self._culling:
+ self._culling = culling
+ self.notify()
+
+ @property
+ def colormap(self):
+ """The colormap used to render the image"""
+ return self._colormap
+
+ def _cmapChanged(self, source, *args, **kwargs):
+ """Broadcast colormap changes"""
+ self.notify(*args, **kwargs)
+
+ def renderGL2(self, ctx):
+ if 'normal' in self._attributes:
+ self._renderGL2(ctx)
+ else: # Disable lighting
+ with self.viewport.light.turnOff():
+ self._renderGL2(ctx)
+
+ def _renderGL2(self, ctx):
+ fragment = self._shaders[1].substitute(
+ clippingDecl=ctx.clipper.fragDecl,
+ clippingCall=ctx.clipper.fragCall,
+ lightingFunction=ctx.viewport.light.fragmentDef,
+ lightingCall=ctx.viewport.light.fragmentCall,
+ colormapDecl=self.colormap.decl,
+ colormapCall=self.colormap.call)
+ program = ctx.glCtx.prog(self._shaders[0], fragment)
+ program.use()
+
+ ctx.viewport.light.setupProgram(ctx, program)
+ ctx.clipper.setupProgram(ctx, program)
+ self.colormap.setupProgram(ctx, program)
+
+ if self.culling is not None:
+ cullFace = gl.GL_FRONT if self.culling == 'front' else gl.GL_BACK
+ gl.glCullFace(cullFace)
+ gl.glEnable(gl.GL_CULL_FACE)
+
+ program.setUniformMatrix('matrix', ctx.objectToNDC.matrix)
+ program.setUniformMatrix('transformMat',
+ ctx.objectToCamera.matrix,
+ safe=True)
+
+ if self.drawMode in self._LINE_MODES:
+ gl.glLineWidth(self.lineWidth)
+ with gl.enabled(gl.GL_LINE_SMOOTH, self.lineSmooth):
+ self._draw(program)
+ else:
+ self._draw(program)
+
+ if self.culling is not None:
+ gl.glDisable(gl.GL_CULL_FACE)
+
+
+# ImageData ##################################################################
+
+class _Image(Geometry):
+ """Base class for ImageData and ImageRgba"""
+
+ _shaders = ("""
+ attribute vec2 position;
+
+ uniform mat4 matrix;
+ uniform mat4 transformMat;
+ uniform vec2 dataScale;
+
+ varying vec4 vCameraPosition;
+ varying vec3 vPosition;
+ varying vec3 vNormal;
+ varying vec2 vTexCoords;
+
+ void main(void)
+ {
+ vec4 positionVec4 = vec4(position, 0.0, 1.0);
+ vCameraPosition = transformMat * positionVec4;
+ vPosition = positionVec4.xyz;
+ vTexCoords = dataScale * position;
+ gl_Position = matrix * positionVec4;
+ }
+ """,
+ string.Template("""
+ varying vec4 vCameraPosition;
+ varying vec3 vPosition;
+ varying vec2 vTexCoords;
+ uniform sampler2D data;
+ uniform float alpha;
+
+ $imageDecl
+
+ $clippingDecl
+
+ $lightingFunction
+
+ void main(void)
+ {
+ vec4 color = imageColor(data, vTexCoords);
+ color.a = alpha;
+
+ $clippingCall(vCameraPosition);
+
+ vec3 normal = vec3(0.0, 0.0, 1.0);
+ gl_FragColor = $lightingCall(color, vPosition, normal);
+ }
+ """))
+
+ _UNIT_SQUARE = numpy.array(((0., 0.), (1., 0.), (0., 1.), (1., 1.)),
+ dtype=numpy.float32)
+
+ def __init__(self, data, copy=True):
+ super(_Image, self).__init__(mode='triangle_strip',
+ position=self._UNIT_SQUARE)
+
+ self._texture = None
+ self._update_texture = True
+ self._update_texture_filter = False
+ self._data = None
+ self.setData(data, copy)
+ self._alpha = 1.
+ self._interpolation = 'linear'
+
+ self.isBackfaceVisible = True
+
+ def setData(self, data, copy=True):
+ assert isinstance(data, numpy.ndarray)
+
+ if copy:
+ data = numpy.array(data, copy=True)
+
+ self._data = data
+ self._update_texture = True
+ # By updating the position rather than always using a unit square
+ # we benefit from Geometry bounds handling
+ self.setAttribute('position', self._UNIT_SQUARE * self._data.shape[:2])
+ self.notify()
+
+ def getData(self, copy=True):
+ return numpy.array(self._data, copy=copy)
+
+ @property
+ def interpolation(self):
+ """The texture interpolation mode: 'linear' or 'nearest'"""
+ return self._interpolation
+
+ @interpolation.setter
+ def interpolation(self, interpolation):
+ assert interpolation in ('linear', 'nearest')
+ self._interpolation = interpolation
+ self._update_texture_filter = True
+ self.notify()
+
+ @property
+ def alpha(self):
+ """Transparency of the image, float in [0, 1]"""
+ return self._alpha
+
+ @alpha.setter
+ def alpha(self, alpha):
+ self._alpha = float(alpha)
+ self.notify()
+
+ def _textureFormat(self):
+ """Implement this method to provide texture internal format and format
+
+ :return: 2-tuple of gl flags (internalFormat, format)
+ """
+ raise NotImplementedError(
+ "This method must be implemented in a subclass")
+
+ def prepareGL2(self, ctx):
+ if self._texture is None or self._update_texture:
+ if self._texture is not None:
+ self._texture.discard()
+
+ if self.interpolation == 'nearest':
+ filter_ = gl.GL_NEAREST
+ else:
+ filter_ = gl.GL_LINEAR
+ self._update_texture = False
+ self._update_texture_filter = False
+ if self._data.size == 0:
+ self._texture = None
+ else:
+ internalFormat, format_ = self._textureFormat()
+ self._texture = _glutils.Texture(
+ internalFormat,
+ self._data,
+ format_,
+ minFilter=filter_,
+ magFilter=filter_,
+ wrap=gl.GL_CLAMP_TO_EDGE)
+
+ if self._update_texture_filter and self._texture is not None:
+ self._update_texture_filter = False
+ if self.interpolation == 'nearest':
+ filter_ = gl.GL_NEAREST
+ else:
+ filter_ = gl.GL_LINEAR
+ self._texture.minFilter = filter_
+ self._texture.magFilter = filter_
+
+ super(_Image, self).prepareGL2(ctx)
+
+ def renderGL2(self, ctx):
+ if self._texture is None:
+ return # Nothing to render
+
+ with self.viewport.light.turnOff():
+ self._renderGL2(ctx)
+
+ def _renderGL2PreDrawHook(self, ctx, program):
+ """Override in subclass to run code before calling gl draw"""
+ pass
+
+ def _shaderImageColorDecl(self):
+ """Returns fragment shader imageColor function declaration"""
+ raise NotImplementedError(
+ "This method must be implemented in a subclass")
+
+ def _renderGL2(self, ctx):
+ fragment = self._shaders[1].substitute(
+ clippingDecl=ctx.clipper.fragDecl,
+ clippingCall=ctx.clipper.fragCall,
+ lightingFunction=ctx.viewport.light.fragmentDef,
+ lightingCall=ctx.viewport.light.fragmentCall,
+ imageDecl=self._shaderImageColorDecl()
+ )
+ program = ctx.glCtx.prog(self._shaders[0], fragment)
+ program.use()
+
+ ctx.viewport.light.setupProgram(ctx, program)
+
+ if not self.isBackfaceVisible:
+ gl.glCullFace(gl.GL_BACK)
+ gl.glEnable(gl.GL_CULL_FACE)
+
+ program.setUniformMatrix('matrix', ctx.objectToNDC.matrix)
+ program.setUniformMatrix('transformMat',
+ ctx.objectToCamera.matrix,
+ safe=True)
+ gl.glUniform1f(program.uniforms['alpha'], self._alpha)
+
+ shape = self._data.shape
+ gl.glUniform2f(program.uniforms['dataScale'], 1./shape[0], 1./shape[1])
+
+ gl.glUniform1i(program.uniforms['data'], self._texture.texUnit)
+
+ ctx.clipper.setupProgram(ctx, program)
+
+ self._texture.bind()
+
+ self._renderGL2PreDrawHook(ctx, program)
+
+ self._draw(program)
+
+ if not self.isBackfaceVisible:
+ gl.glDisable(gl.GL_CULL_FACE)
+
+
+class ImageData(_Image):
+ """Display a 2x2 data array with a texture."""
+
+ _imageDecl = string.Template("""
+ $colormapDecl
+
+ vec4 imageColor(sampler2D data, vec2 texCoords) {
+ float value = texture2D(data, texCoords).r;
+ vec4 color = $colormapCall(value);
+ return color;
+ }
+ """)
+
+ def __init__(self, data, copy=True, colormap=None):
+ super(ImageData, self).__init__(data, copy=copy)
+
+ self._colormap = colormap or Colormap() # Default colormap
+ self._colormap.addListener(self._cmapChanged)
+
+ def setData(self, data, copy=True):
+ data = numpy.array(data, copy=copy, order='C', dtype=numpy.float32)
+ # TODO support (u)int8|16
+ assert data.ndim == 2
+
+ super(ImageData, self).setData(data, copy=False)
+
+ @property
+ def colormap(self):
+ """The colormap used to render the image"""
+ return self._colormap
+
+ def _cmapChanged(self, source, *args, **kwargs):
+ """Broadcast colormap changes"""
+ self.notify(*args, **kwargs)
+
+ def _textureFormat(self):
+ return gl.GL_R32F, gl.GL_RED
+
+ def _renderGL2PreDrawHook(self, ctx, program):
+ self.colormap.setupProgram(ctx, program)
+
+ def _shaderImageColorDecl(self):
+ return self._imageDecl.substitute(
+ colormapDecl=self.colormap.decl,
+ colormapCall=self.colormap.call)
+
+
+# ImageRgba ##################################################################
+
+class ImageRgba(_Image):
+ """Display a 2x2 RGBA image with a texture.
+
+ Supports images of float in [0, 1] and uint8.
+ """
+
+ _imageDecl = """
+ vec4 imageColor(sampler2D data, vec2 texCoords) {
+ vec4 color = texture2D(data, texCoords);
+ return color;
+ }
+ """
+
+ def __init__(self, data, copy=True):
+ super(ImageRgba, self).__init__(data, copy=copy)
+
+ def setData(self, data, copy=True):
+ data = numpy.array(data, copy=copy, order='C')
+ assert data.ndim == 3
+ assert data.shape[2] in (3, 4)
+ if data.dtype.kind == 'f':
+ if data.dtype != numpy.dtype(numpy.float32):
+ _logger.warning("Converting image data to float32")
+ data = numpy.array(data, dtype=numpy.float32, copy=False)
+ else:
+ assert data.dtype == numpy.dtype(numpy.uint8)
+
+ super(ImageRgba, self).setData(data, copy=False)
+
+ def _textureFormat(self):
+ format_ = gl.GL_RGBA if self._data.shape[2] == 4 else gl.GL_RGB
+ return format_, format_
+
+ def _shaderImageColorDecl(self):
+ return self._imageDecl
+
+
# Group ######################################################################
# TODO lighting, clipping as groups?
@@ -1686,6 +2321,29 @@ class GroupDepthOffset(core.Group):
# TODO issue with picking in depth buffer!
+class GroupNoDepth(core.Group):
+ """A group rendering its children without writing to the depth buffer
+
+ :param bool mask: True (default) to disable writing in the depth buffer
+ :param bool notest: True (default) to disable depth test
+ """
+
+ def __init__(self, children=(), mask=True, notest=True):
+ super(GroupNoDepth, self).__init__(children)
+ self._mask = bool(mask)
+ self._notest = bool(notest)
+
+ def renderGL2(self, ctx):
+ if self._mask:
+ gl.glDepthMask(gl.GL_FALSE)
+
+ with gl.disabled(gl.GL_DEPTH_TEST, disable=self._notest):
+ super(GroupNoDepth, self).renderGL2(ctx)
+
+ if self._mask:
+ gl.glDepthMask(gl.GL_TRUE)
+
+
class GroupBBox(core.PrivateGroup):
"""A group displaying a bounding box around the children."""
@@ -1693,26 +2351,42 @@ class GroupBBox(core.PrivateGroup):
super(GroupBBox, self).__init__()
self._group = core.Group(children)
- self._boxTransforms = transform.TransformList(
- (transform.Translate(), transform.Scale()))
+ self._boxTransforms = transform.TransformList((transform.Translate(),))
+ # Using 1 of 3 primitives to render axes and/or bounding box
+ # To avoid z-fighting between axes and bounding box
self._boxWithAxes = BoxWithAxes(color)
self._boxWithAxes.smooth = False
self._boxWithAxes.transforms = self._boxTransforms
- self._children = [self._boxWithAxes, self._group]
+ self._box = Box(stroke=color, fill=(1., 1., 1., 0.))
+ self._box.strokeSmooth = False
+ self._box.transforms = self._boxTransforms
+ self._box.visible = False
+
+ self._axes = Axes()
+ self._axes.smooth = False
+ self._axes.transforms = self._boxTransforms
+ self._axes.visible = False
+
+ self.strokeWidth = 2.
+
+ self._children = [self._boxWithAxes, self._box, self._axes, self._group]
def _updateBoxAndAxes(self):
"""Update bbox and axes position and size according to children."""
bounds = self._group.bounds(dataBounds=True)
if bounds is not None:
origin = bounds[0]
- scale = [(d if d != 0. else 1.) for d in bounds[1] - bounds[0]]
+ size = bounds[1] - bounds[0]
else:
- origin, scale = (0., 0., 0.), (1., 1., 1.)
+ origin, size = (0., 0., 0.), (1., 1., 1.)
self._boxTransforms[0].translation = origin
- self._boxTransforms[1].scale = scale
+
+ self._boxWithAxes.size = size
+ self._box.size = size
+ self._axes.size = size
def _bounds(self, dataBounds=False):
self._updateBoxAndAxes()
@@ -1732,17 +2406,62 @@ class GroupBBox(core.PrivateGroup):
def children(self, iterable):
self._group.children = iterable
- # Give access to box color
+ # Give access to box color and stroke width
@property
def color(self):
"""The RGBA color to use for the box: 4 float in [0, 1]"""
- return self._boxWithAxes.color
+ return self._box.strokeColor
@color.setter
def color(self, color):
+ self._box.strokeColor = color
self._boxWithAxes.color = color
+ @property
+ def strokeWidth(self):
+ """The width of the stroke lines in pixels (float)"""
+ return self._box.strokeWidth
+
+ @strokeWidth.setter
+ def strokeWidth(self, width):
+ width = float(width)
+ self._box.strokeWidth = width
+ self._boxWithAxes.width = width
+ self._axes.width = width
+
+ # Toggle axes visibility
+
+ def _updateBoxAndAxesVisibility(self, axesVisible, boxVisible):
+ """Update visible flags of box and axes primitives accordingly.
+
+ :param bool axesVisible: True to display axes
+ :param bool boxVisible: True to display bounding box
+ """
+ self._boxWithAxes.visible = boxVisible and axesVisible
+ self._box.visible = boxVisible and not axesVisible
+ self._axes.visible = not boxVisible and axesVisible
+
+ @property
+ def axesVisible(self):
+ """Whether axes are displayed or not (bool)"""
+ return self._boxWithAxes.visible or self._axes.visible
+
+ @axesVisible.setter
+ def axesVisible(self, visible):
+ self._updateBoxAndAxesVisibility(axesVisible=bool(visible),
+ boxVisible=self.boxVisible)
+
+ @property
+ def boxVisible(self):
+ """Whether bounding box is displayed or not (bool)"""
+ return self._boxWithAxes.visible or self._box.visible
+
+ @boxVisible.setter
+ def boxVisible(self, visible):
+ self._updateBoxAndAxesVisibility(axesVisible=self.axesVisible,
+ boxVisible=bool(visible))
+
# Clipping Plane ##############################################################
diff --git a/silx/gui/plot3d/scene/test/test_utils.py b/silx/gui/plot3d/scene/test/test_utils.py
index 65c2407..4a2d515 100644
--- a/silx/gui/plot3d/scene/test/test_utils.py
+++ b/silx/gui/plot3d/scene/test/test_utils.py
@@ -27,11 +27,11 @@ from __future__ import absolute_import, division, unicode_literals
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "25/07/2016"
+__date__ = "17/01/2018"
import unittest
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
import numpy
diff --git a/silx/gui/plot3d/scene/transform.py b/silx/gui/plot3d/scene/transform.py
index 71a6b74..4061e81 100644
--- a/silx/gui/plot3d/scene/transform.py
+++ b/silx/gui/plot3d/scene/transform.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -317,7 +317,7 @@ class Transform(event.Notifier):
def transformPoint(self, point, direct=True, perspectiveDivide=False):
"""Apply the transform to a point.
- If len(point) == 3, apply persective divide if possible.
+ If len(point) == 3, apply perspective divide if possible.
:param point: Array-like vector of 3 or 4 coordinates.
:param bool direct: Whether to apply the direct (True, the default)
@@ -757,8 +757,9 @@ class _Projection(Transform):
:param float near: Distance to the near plane.
:param float far: Distance to the far plane.
:param bool checkDepthExtent: Toggle checks near > 0 and far > near.
- :param size: Viewport's size used to compute the aspect ratio.
- :type size: 2-tuple of float (width, height).
+ :param size:
+ Viewport's size used to compute the aspect ratio (width, height).
+ :type size: 2-tuple of float
"""
def __init__(self, near, far, checkDepthExtent=False, size=(1., 1.)):
@@ -833,8 +834,9 @@ class Orthographic(_Projection):
:param float top: Coord of the top clipping plane.
:param float near: Distance to the near plane.
:param float far: Distance to the far plane.
- :param size: Viewport's size used to compute the aspect ratio.
- :type size: 2-tuple of float (width, height).
+ :param size:
+ Viewport's size used to compute the aspect ratio (width, height).
+ :type size: 2-tuple of float
"""
def __init__(self, left=0., right=1., bottom=1., top=0., near=-1., far=1.,
@@ -923,8 +925,9 @@ class Ortho2DWidget(_Projection):
:param float near: Z coordinate of the near clipping plane.
:param float far: Z coordinante of the far clipping plane.
- :param size: Viewport's size used to compute the aspect ratio.
- :type size: 2-tuple of float (width, height).
+ :param size:
+ Viewport's size used to compute the aspect ratio (width, height).
+ :type size: 2-tuple of float
"""
def __init__(self, near=-1., far=1., size=(1., 1.)):
@@ -942,8 +945,9 @@ class Perspective(_Projection):
:param float fovy: Vertical field-of-view in degrees.
:param float near: The near clipping plane Z coord (stricly positive).
:param float far: The far clipping plane Z coord (> near).
- :param size: Viewport's size used to compute the aspect ratio.
- :type size: 2-tuple of float (width, height).
+ :param size:
+ Viewport's size used to compute the aspect ratio (width, height).
+ :type size: 2-tuple of float
"""
def __init__(self, fovy=90., near=0.1, far=1., size=(1., 1.)):
diff --git a/silx/gui/plot3d/scene/utils.py b/silx/gui/plot3d/scene/utils.py
index 930a087..04abd04 100644
--- a/silx/gui/plot3d/scene/utils.py
+++ b/silx/gui/plot3d/scene/utils.py
@@ -184,6 +184,32 @@ def unindexArrays(mode, indices, *arrays):
return tuple(numpy.ascontiguousarray(data[indices]) for data in arrays)
+def triangleStripToTriangles(strip):
+ """Convert a triangle strip to a set of triangles.
+
+ The order of the corners is inverted for odd triangles.
+
+ :param numpy.ndarray strip:
+ Array of triangle corners of shape (N, 3).
+ N must be at least 3.
+ :return: Equivalent triangles corner as an array of shape (N, 3, 3)
+ :rtype: numpy.ndarray
+ """
+ strip = numpy.array(strip).reshape(-1, 3)
+ assert len(strip) >= 3
+
+ triangles = numpy.empty((len(strip) - 2, 3, 3), dtype=strip.dtype)
+ triangles[0::2, 0] = strip[0:-2:2]
+ triangles[0::2, 1] = strip[1:-1:2]
+ triangles[0::2, 2] = strip[2::2]
+
+ triangles[1::2, 0] = strip[3::2]
+ triangles[1::2, 1] = strip[2:-1:2]
+ triangles[1::2, 2] = strip[1:-2:2]
+
+ return triangles
+
+
def trianglesNormal(positions):
"""Return normal for each triangle.
@@ -514,3 +540,19 @@ class Plane(event.Notifier):
def move(self, step):
"""Move the plane of step along the normal."""
self.point += step * self.normal
+
+ def segmentIntersection(self, s0, s1):
+ """Compute the plane intersection with segment [s0, s1].
+
+ :param s0: First end of the segment
+ :type s0: 1D numpy.ndarray-like of length 3
+ :param s1: Second end of the segment
+ :type s1: 1D numpy.ndarray-like of length 3
+ :return: The intersection points. The number of points goes
+ from 0 (no intersection) to 2 (segment in the plane)
+ :rtype: list of 1D numpy.ndarray
+ """
+ if not self.isPlane:
+ return []
+ else:
+ return segmentPlaneIntersect(s0, s1, self.normal, self.point)
diff --git a/silx/gui/plot3d/scene/viewport.py b/silx/gui/plot3d/scene/viewport.py
index 72e1ea3..0cacbf0 100644
--- a/silx/gui/plot3d/scene/viewport.py
+++ b/silx/gui/plot3d/scene/viewport.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -198,12 +198,17 @@ class Viewport(event.Notifier):
@property
def background(self):
- """Background color of the viewport (4-tuple of float in [0, 1]"""
+ """Viewport's background color (4-tuple of float in [0, 1] or None)
+
+ The background color is used to clear to viewport.
+ If None, the viewport is not cleared
+ """
return self._background
@background.setter
def background(self, color):
- color = rgba(color)
+ if color is not None:
+ color = rgba(color)
if self._background != color:
self._background = color
self._changed()
@@ -295,12 +300,16 @@ class Viewport(event.Notifier):
gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST)
gl.glEnable(gl.GL_LINE_SMOOTH)
- gl.glClearColor(*self.background)
+ if self.background is None:
+ gl.glClear(gl.GL_STENCIL_BUFFER_BIT |
+ gl.GL_DEPTH_BUFFER_BIT)
+ else:
+ gl.glClearColor(*self.background)
- # Prepare OpenGL
- gl.glClear(gl.GL_COLOR_BUFFER_BIT |
- gl.GL_STENCIL_BUFFER_BIT |
- gl.GL_DEPTH_BUFFER_BIT)
+ # Prepare OpenGL
+ gl.glClear(gl.GL_COLOR_BUFFER_BIT |
+ gl.GL_STENCIL_BUFFER_BIT |
+ gl.GL_DEPTH_BUFFER_BIT)
ctx = RenderContext(self, glContext)
self.scene.render(ctx)
@@ -454,11 +463,13 @@ class Viewport(event.Notifier):
winy = oy + height * 0.5 * (1. - ndcY)
return winx, winy
- def _pickNdcZGL(self, x, y):
+ def _pickNdcZGL(self, x, y, offset=0):
"""Retrieve depth from depth buffer and return corresponding NDC Z.
:param int x: In pixels in window coordinates, origin left.
:param int y: In pixels in window coordinates, origin top.
+ :param int offset: Number of pixels to look at around the given pixel
+
:return: Normalize device Z coordinate of depth in [-1, 1]
or None if outside viewport.
:rtype: float or None
@@ -476,10 +487,34 @@ class Viewport(event.Notifier):
# Get depth from depth buffer in [0., 1.]
# Bind used framebuffer to get depth
gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.framebuffer)
- depth = gl.glReadPixels(
- x, y, 1, 1, gl.GL_DEPTH_COMPONENT, gl.GL_FLOAT)[0]
+
+ if offset == 0: # Fast path
+ # glReadPixels is not GL|ES friendly
+ depth = gl.glReadPixels(
+ x, y, 1, 1, gl.GL_DEPTH_COMPONENT, gl.GL_FLOAT)[0]
+ else:
+ offset = abs(int(offset))
+ size = 2*offset + 1
+ depthPatch = gl.glReadPixels(
+ x - offset, y - offset,
+ size, size,
+ gl.GL_DEPTH_COMPONENT, gl.GL_FLOAT)
+ depthPatch = depthPatch.ravel() # Work in 1D
+
+ # TODO cache sortedIndices to avoid computing it each time
+ # Compute distance of each pixels to the center of the patch
+ offsetToCenter = numpy.arange(- offset, offset + 1, dtype=numpy.float32) ** 2
+ sqDistToCenter = numpy.add.outer(offsetToCenter, offsetToCenter)
+
+ # Use distance to center to sort values from the patch
+ sortedIndices = numpy.argsort(sqDistToCenter.ravel())
+ sortedValues = depthPatch[sortedIndices]
+
+ # Take first depth that is not 1 in the sorted values
+ hits = sortedValues[sortedValues != 1.]
+ depth = 1. if len(hits) == 0 else hits[0]
+
gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0)
- # This is not GL|ES friendly
# Z in NDC in [-1., 1.]
return float(depth) * 2. - 1.
diff --git a/silx/gui/plot3d/scene/window.py b/silx/gui/plot3d/scene/window.py
index 3c63c7a..baa76a2 100644
--- a/silx/gui/plot3d/scene/window.py
+++ b/silx/gui/plot3d/scene/window.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -124,18 +124,26 @@ class ContextGL2(Context):
# programs
- def prog(self, vertexShaderSrc, fragmentShaderSrc):
+ def prog(self, vertexShaderSrc, fragmentShaderSrc, attrib0='position'):
"""Cache program within context.
WARNING: No clean-up.
+
+ :param str vertexShaderSrc: Vertex shader source code
+ :param str fragmentShaderSrc: Fragment shader source code
+ :param str attrib0:
+ Attribute's name to bind to position 0 (default: 'position').
+ On some platform, this attribute MUST be active and with an
+ array attached to it in order for the rendering to occur....
"""
assert self.isCurrent
- key = vertexShaderSrc, fragmentShaderSrc
- prog = self._programs.get(key, None)
- if prog is None:
- prog = _glutils.Program(vertexShaderSrc, fragmentShaderSrc)
- self._programs[key] = prog
- return prog
+ key = vertexShaderSrc, fragmentShaderSrc, attrib0
+ program = self._programs.get(key, None)
+ if program is None:
+ program = _glutils.Program(
+ vertexShaderSrc, fragmentShaderSrc, attrib0=attrib0)
+ self._programs[key] = program
+ return program
# VBOs
@@ -318,7 +326,8 @@ class Window(event.Notifier):
"""Returns the raster of the scene as an RGB numpy array
:returns: OpenGL scene RGB bitmap
- :rtype: numpy.ndarray of uint8 of dimension (height, width, 3)
+ as an array of dimension (height, width, 3)
+ :rtype: numpy.ndarray of uint8
"""
height, width = self.shape
image = numpy.empty((height, width, 3), dtype=numpy.uint8)
diff --git a/silx/gui/plot3d/setup.py b/silx/gui/plot3d/setup.py
index bb6eaa5..c477919 100644
--- a/silx/gui/plot3d/setup.py
+++ b/silx/gui/plot3d/setup.py
@@ -32,7 +32,9 @@ from numpy.distutils.misc_util import Configuration
def configuration(parent_package='', top_path=None):
config = Configuration('plot3d', parent_package, top_path)
+ config.add_subpackage('_model')
config.add_subpackage('actions')
+ config.add_subpackage('items')
config.add_subpackage('scene')
config.add_subpackage('tools')
config.add_subpackage('test')
diff --git a/silx/gui/plot3d/test/__init__.py b/silx/gui/plot3d/test/__init__.py
index 2e8c9f4..bd2f7c3 100644
--- a/silx/gui/plot3d/test/__init__.py
+++ b/silx/gui/plot3d/test/__init__.py
@@ -26,12 +26,13 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "05/01/2017"
+__date__ = "09/11/2017"
import logging
import os
import unittest
+from silx.test.utils import test_options
_logger = logging.getLogger(__name__)
@@ -40,15 +41,14 @@ _logger = logging.getLogger(__name__)
def suite():
test_suite = unittest.TestSuite()
- if os.environ.get('WITH_GL_TEST', 'True') == 'False':
+ if not test_options.WITH_GL_TEST:
# Explicitly disabled tests
- _logger.warning(
- "silx.gui.plot3d tests disabled (WITH_GL_TEST=False)")
+ msg = "silx.gui.plot3d tests disabled: %s" % test_options.WITH_GL_TEST_REASON
+ _logger.warning(msg)
class SkipPlot3DTest(unittest.TestCase):
def runTest(self):
- self.skipTest(
- "silx.gui.plot3d tests disabled (WITH_GL_TEST=False)")
+ self.skipTest(test_options.WITH_GL_TEST_REASON)
test_suite.addTest(SkipPlot3DTest())
return test_suite
diff --git a/silx/gui/plot3d/test/testScalarFieldView.py b/silx/gui/plot3d/test/testScalarFieldView.py
index 5ad4051..43d401f 100644
--- a/silx/gui/plot3d/test/testScalarFieldView.py
+++ b/silx/gui/plot3d/test/testScalarFieldView.py
@@ -25,7 +25,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "11/07/2017"
+__date__ = "17/01/2018"
import logging
@@ -33,7 +33,7 @@ import unittest
import numpy
-from silx.test.utils import ParametricTestCase
+from silx.utils.testutils import ParametricTestCase
from silx.gui.test.utils import TestCaseQt
from silx.gui import qt
diff --git a/silx/gui/plot3d/tools/GroupPropertiesWidget.py b/silx/gui/plot3d/tools/GroupPropertiesWidget.py
new file mode 100644
index 0000000..30e11de
--- /dev/null
+++ b/silx/gui/plot3d/tools/GroupPropertiesWidget.py
@@ -0,0 +1,200 @@
+# 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.
+#
+# ###########################################################################*/
+""":class:`GroupPropertiesWidget` allows to reset properties in a GroupItem."""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "11/01/2018"
+
+from ....gui import qt
+from ....gui.plot.Colormap import Colormap
+from ....gui.plot.ColormapDialog import ColormapDialog
+
+from ..items import SymbolMixIn, ColormapMixIn
+
+
+class GroupPropertiesWidget(qt.QWidget):
+ """Set properties of all items in a :class:`GroupItem`
+
+ :param QWidget parent:
+ """
+
+ MAX_MARKER_SIZE = 20
+ """Maximum value for marker size"""
+
+ MAX_LINE_WIDTH = 10
+ """Maximum value for line width"""
+
+ def __init__(self, parent=None):
+ super(GroupPropertiesWidget, self).__init__(parent)
+ self._group = None
+ self.setEnabled(False)
+
+ # Set widgets
+ layout = qt.QFormLayout(self)
+ self.setLayout(layout)
+
+ # Colormap
+ colormapButton = qt.QPushButton('Set...')
+ colormapButton.setToolTip("Set colormap for all items")
+ colormapButton.clicked.connect(self._colormapButtonClicked)
+ layout.addRow('Colormap', colormapButton)
+
+ self._markerComboBox = qt.QComboBox(self)
+ self._markerComboBox.addItems(SymbolMixIn.getSupportedSymbolNames())
+
+ # Marker
+ markerButton = qt.QPushButton('Set')
+ markerButton.setToolTip("Set marker for all items")
+ markerButton.clicked.connect(self._markerButtonClicked)
+
+ markerLayout = qt.QHBoxLayout()
+ markerLayout.setContentsMargins(0, 0, 0, 0)
+ markerLayout.addWidget(self._markerComboBox, 1)
+ markerLayout.addWidget(markerButton, 0)
+
+ layout.addRow('Marker', markerLayout)
+
+ # Marker size
+ self._markerSizeSlider = qt.QSlider()
+ self._markerSizeSlider.setOrientation(qt.Qt.Horizontal)
+ self._markerSizeSlider.setSingleStep(1)
+ self._markerSizeSlider.setRange(1, self.MAX_MARKER_SIZE)
+ self._markerSizeSlider.setValue(1)
+
+ markerSizeButton = qt.QPushButton('Set')
+ markerSizeButton.setToolTip("Set marker size for all items")
+ markerSizeButton.clicked.connect(self._markerSizeButtonClicked)
+
+ markerSizeLayout = qt.QHBoxLayout()
+ markerSizeLayout.setContentsMargins(0, 0, 0, 0)
+ markerSizeLayout.addWidget(qt.QLabel('1'))
+ markerSizeLayout.addWidget(self._markerSizeSlider, 1)
+ markerSizeLayout.addWidget(qt.QLabel(str(self.MAX_MARKER_SIZE)))
+ markerSizeLayout.addWidget(markerSizeButton, 0)
+
+ layout.addRow('Marker Size', markerSizeLayout)
+
+ # Line width
+ self._lineWidthSlider = qt.QSlider()
+ self._lineWidthSlider.setOrientation(qt.Qt.Horizontal)
+ self._lineWidthSlider.setSingleStep(1)
+ self._lineWidthSlider.setRange(1, self.MAX_LINE_WIDTH)
+ self._lineWidthSlider.setValue(1)
+
+ lineWidthButton = qt.QPushButton('Set')
+ lineWidthButton.setToolTip("Set line width for all items")
+ lineWidthButton.clicked.connect(self._lineWidthButtonClicked)
+
+ lineWidthLayout = qt.QHBoxLayout()
+ lineWidthLayout.setContentsMargins(0, 0, 0, 0)
+ lineWidthLayout.addWidget(qt.QLabel('1'))
+ lineWidthLayout.addWidget(self._lineWidthSlider, 1)
+ lineWidthLayout.addWidget(qt.QLabel(str(self.MAX_LINE_WIDTH)))
+ lineWidthLayout.addWidget(lineWidthButton, 0)
+
+ layout.addRow('Line Width', lineWidthLayout)
+
+ self._colormapDialog = None # To store dialog
+ self._colormap = Colormap()
+
+ def getGroup(self):
+ """Returns the :class:`GroupItem` this widget is attached to.
+
+ :rtype: Union[GroupItem, None]
+ """
+ return self._group
+
+ def setGroup(self, group):
+ """Set the :class:`GroupItem` this widget is attached to.
+
+ :param GroupItem group: GroupItem to control (or None)
+ """
+ self._group = group
+ if group is not None:
+ self.setEnabled(True)
+
+ def _colormapButtonClicked(self, checked=False):
+ """Handle colormap button clicked"""
+ group = self.getGroup()
+ if group is None:
+ return
+
+ if self._colormapDialog is None:
+ self._colormapDialog = ColormapDialog(self)
+ self._colormapDialog.setColormap(self._colormap)
+
+ previousColormap = self._colormapDialog.getColormap()
+ if self._colormapDialog.exec_():
+ colormap = self._colormapDialog.getColormap()
+
+ for item in group.visit():
+ if isinstance(item, ColormapMixIn):
+ itemCmap = item.getColormap()
+ cmapName = colormap.getName()
+ if cmapName is not None:
+ itemCmap.setName(colormap.getName())
+ else:
+ itemCmap.setColormapLUT(colormap.getColormapLUT())
+ itemCmap.setNormalization(colormap.getNormalization())
+ itemCmap.setVRange(colormap.getVMin(), colormap.getVMax())
+ else:
+ # Reset colormap
+ self._colormapDialog.setColormap(previousColormap)
+
+ def _markerButtonClicked(self, checked=False):
+ """Handle marker set button clicked"""
+ group = self.getGroup()
+ if group is None:
+ return
+
+ marker = self._markerComboBox.currentText()
+ for item in group.visit():
+ if isinstance(item, SymbolMixIn):
+ item.setSymbol(marker)
+
+ def _markerSizeButtonClicked(self, checked=False):
+ """Handle marker size set button clicked"""
+ group = self.getGroup()
+ if group is None:
+ return
+
+ markerSize = self._markerSizeSlider.value()
+ for item in group.visit():
+ if isinstance(item, SymbolMixIn):
+ item.setSymbolSize(markerSize)
+
+ def _lineWidthButtonClicked(self, checked=False):
+ """Handle line width set button clicked"""
+ group = self.getGroup()
+ if group is None:
+ return
+
+ lineWidth = self._lineWidthSlider.value()
+ for item in group.visit():
+ if hasattr(item, 'setLineWidth'):
+ item.setLineWidth(lineWidth)
diff --git a/silx/gui/plot3d/tools/ViewpointTools.py b/silx/gui/plot3d/tools/ViewpointTools.py
index 1346c1c..0607382 100644
--- a/silx/gui/plot3d/tools/ViewpointTools.py
+++ b/silx/gui/plot3d/tools/ViewpointTools.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -31,105 +31,54 @@ __license__ = "MIT"
__date__ = "08/09/2017"
+import weakref
+
from silx.gui import qt
from silx.gui.icons import getQIcon
-
-
-class _ViewpointActionGroup(qt.QActionGroup):
- """ActionGroup of actions to reset the viewpoint.
-
- As for QActionGroup, add group's actions to the widget with:
- `widget.addActions(actionGroup.actions())`
-
- :param Plot3DWidget plot3D: The widget for which to control the viewpoint
- :param parent: See :class:`QActionGroup`
- """
-
- # Action information: icon name, text, tooltip
- _RESET_CAMERA_ACTIONS = (
- ('cube-front', 'Front', 'View along the -Z axis'),
- ('cube-back', 'Back', 'View along the +Z axis'),
- ('cube-top', 'Top', 'View along the -Y'),
- ('cube-bottom', 'Bottom', 'View along the +Y'),
- ('cube-right', 'Right', 'View along the -X'),
- ('cube-left', 'Left', 'View along the +X'),
- ('cube', 'Side', 'Side view')
- )
-
- def __init__(self, plot3D, parent=None):
- super(_ViewpointActionGroup, self).__init__(parent)
- self.setExclusive(False)
-
- self._plot3D = plot3D
-
- for actionInfo in self._RESET_CAMERA_ACTIONS:
- iconname, text, tooltip = actionInfo
-
- action = qt.QAction(getQIcon(iconname), text, None)
- action.setIconVisibleInMenu(True)
- action.setCheckable(False)
- action.setToolTip(tooltip)
- self.addAction(action)
-
- self.triggered[qt.QAction].connect(self._actionGroupTriggered)
-
- def _actionGroupTriggered(self, action):
- actionname = action.text().lower()
-
- self._plot3D.viewport.camera.extrinsic.reset(face=actionname)
- self._plot3D.centerScene()
-
-
-class ViewpointToolBar(qt.QToolBar):
- """A toolbar providing icons to reset the viewpoint.
-
- :param parent: See :class:`QToolBar`
- :param Plot3DWidget plot3D: The widget to control
- :param str title: Title of the toolbar
- """
-
- def __init__(self, parent=None, plot3D=None, title='Viewpoint control'):
- super(ViewpointToolBar, self).__init__(title, parent)
-
- self._actionGroup = _ViewpointActionGroup(plot3D)
- assert plot3D is not None
- self._plot3D = plot3D
- self.addActions(self._actionGroup.actions())
-
- # Choosing projection disabled for now
- # Add projection combo box
- # comboBoxProjection = qt.QComboBox()
- # comboBoxProjection.addItem('Perspective')
- # comboBoxProjection.addItem('Parallel')
- # comboBoxProjection.setToolTip(
- # 'Choose the projection:'
- # ' perspective or parallel (i.e., orthographic)')
- # comboBoxProjection.currentIndexChanged[(str)].connect(
- # self._comboBoxProjectionCurrentIndexChanged)
- # self.addWidget(qt.QLabel('Projection:'))
- # self.addWidget(comboBoxProjection)
-
- # def _comboBoxProjectionCurrentIndexChanged(self, text):
- # """Projection combo box listener"""
- # self._plot3D.setProjection(
- # 'perspective' if text == 'Perspective' else 'orthographic')
+from .. import actions
class ViewpointToolButton(qt.QToolButton):
"""A toolbutton with a drop-down list of ways to reset the viewpoint.
:param parent: See :class:`QToolButton`
- :param Plot3DWiddget plot3D: The widget to control
"""
- def __init__(self, parent=None, plot3D=None):
+ def __init__(self, parent=None):
super(ViewpointToolButton, self).__init__(parent)
- self._actionGroup = _ViewpointActionGroup(plot3D)
+ self._plot3DRef = None
menu = qt.QMenu(self)
- menu.addActions(self._actionGroup.actions())
+ menu.addAction(actions.viewpoint.FrontViewpointAction(parent=self))
+ menu.addAction(actions.viewpoint.BackViewpointAction(parent=self))
+ menu.addAction(actions.viewpoint.TopViewpointAction(parent=self))
+ menu.addAction(actions.viewpoint.BottomViewpointAction(parent=self))
+ menu.addAction(actions.viewpoint.RightViewpointAction(parent=self))
+ menu.addAction(actions.viewpoint.LeftViewpointAction(parent=self))
+ menu.addAction(actions.viewpoint.SideViewpointAction(parent=self))
+
self.setMenu(menu)
self.setPopupMode(qt.QToolButton.InstantPopup)
self.setIcon(getQIcon('cube'))
self.setToolTip('Reset the viewpoint to a defined position')
+
+ def setPlot3DWidget(self, widget):
+ """Set the Plot3DWidget this toolbar is associated with
+
+ :param ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget widget:
+ The widget to control
+ """
+ self._plot3DRef = None if widget is None else weakref.ref(widget)
+
+ for action in self.menu().actions():
+ action.setPlot3DWidget(widget)
+
+ def getPlot3DWidget(self):
+ """Return the Plot3DWidget associated to this toolbar.
+
+ If no widget is associated, it returns None.
+
+ :rtype: ~silx.gui.plot3d.Plot3DWidget.Plot3DWidget or None
+ """
+ return None if self._plot3DRef is None else self._plot3DRef()
diff --git a/silx/gui/plot3d/tools/__init__.py b/silx/gui/plot3d/tools/__init__.py
index e14f604..c8b8d21 100644
--- a/silx/gui/plot3d/tools/__init__.py
+++ b/silx/gui/plot3d/tools/__init__.py
@@ -28,5 +28,7 @@ __authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "08/09/2017"
-from .toolbars import InteractiveModeToolBar, OutputToolBar
-from .ViewpointTools import ViewpointToolBar, ViewpointToolButton
+from .toolbars import InteractiveModeToolBar # noqa
+from .toolbars import OutputToolBar # noqa
+from .toolbars import ViewpointToolBar # noqa
+from .ViewpointTools import ViewpointToolButton # noqa
diff --git a/silx/gui/plot3d/tools/toolbars.py b/silx/gui/plot3d/tools/toolbars.py
index c8be226..d4f32db 100644
--- a/silx/gui/plot3d/tools/toolbars.py
+++ b/silx/gui/plot3d/tools/toolbars.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -44,40 +44,44 @@ __license__ = "MIT"
__date__ = "06/09/2017"
import logging
+import weakref
from silx.gui import qt
+from .ViewpointTools import ViewpointToolButton
from .. import actions
_logger = logging.getLogger(__name__)
-class InteractiveModeToolBar(qt.QToolBar):
- """Toolbar providing icons to change the interaction mode
+class Plot3DWidgetToolBar(qt.QToolBar):
+ """Base class for toolbar associated to a Plot3DWidget
:param parent: See :class:`QWidget`
:param str title: Title of the toolbar.
"""
- def __init__(self, parent=None, title='Plot3D Interaction'):
- super(InteractiveModeToolBar, self).__init__(title, parent)
+ def __init__(self, parent=None, title=''):
+ super(Plot3DWidgetToolBar, self).__init__(title, parent)
- self._plot3d = None
+ self._plot3DRef = None
- self._rotateAction = actions.mode.RotateArcballAction(parent=self)
- self.addAction(self._rotateAction)
+ def _plot3DWidgetChanged(self, widget):
+ """Handle change of Plot3DWidget and sync actions
- self._panAction = actions.mode.PanAction(parent=self)
- self.addAction(self._panAction)
+ :param Plot3DWidget widget:
+ """
+ for action in self.actions():
+ if isinstance(action, actions.Plot3DAction):
+ action.setPlot3DWidget(widget)
def setPlot3DWidget(self, widget):
"""Set the Plot3DWidget this toolbar is associated with
- :param Plot3DWidget widget: The widget to copy/save/print
+ :param Plot3DWidget widget: The widget to control
"""
- self._plot3d = widget
- self.getRotateAction().setPlot3DWidget(widget)
- self.getPanAction().setPlot3DWidget(widget)
+ self._plot3DRef = None if widget is None else weakref.ref(widget)
+ self._plot3DWidgetChanged(widget)
def getPlot3DWidget(self):
"""Return the Plot3DWidget associated to this toolbar.
@@ -86,7 +90,24 @@ class InteractiveModeToolBar(qt.QToolBar):
:rtype: qt.QWidget
"""
- return self._plot3d
+ return None if self._plot3DRef is None else self._plot3DRef()
+
+
+class InteractiveModeToolBar(Plot3DWidgetToolBar):
+ """Toolbar providing icons to change the interaction mode
+
+ :param parent: See :class:`QWidget`
+ :param str title: Title of the toolbar.
+ """
+
+ def __init__(self, parent=None, title='Plot3D Interaction'):
+ super(InteractiveModeToolBar, self).__init__(parent, title)
+
+ self._rotateAction = actions.mode.RotateArcballAction(parent=self)
+ self.addAction(self._rotateAction)
+
+ self._panAction = actions.mode.PanAction(parent=self)
+ self.addAction(self._panAction)
def getRotateAction(self):
"""Returns the QAction setting rotate interaction of the Plot3DWidget
@@ -103,7 +124,7 @@ class InteractiveModeToolBar(qt.QToolBar):
return self._panAction
-class OutputToolBar(qt.QToolBar):
+class OutputToolBar(Plot3DWidgetToolBar):
"""Toolbar providing icons to copy, save and print the OpenGL scene
:param parent: See :class:`QWidget`
@@ -111,9 +132,7 @@ class OutputToolBar(qt.QToolBar):
"""
def __init__(self, parent=None, title='Plot3D Output'):
- super(OutputToolBar, self).__init__(title, parent)
-
- self._plot3d = None
+ super(OutputToolBar, self).__init__(parent, title)
self._copyAction = actions.io.CopyAction(parent=self)
self.addAction(self._copyAction)
@@ -127,26 +146,6 @@ class OutputToolBar(qt.QToolBar):
self._printAction = actions.io.PrintAction(parent=self)
self.addAction(self._printAction)
- def setPlot3DWidget(self, widget):
- """Set the Plot3DWidget this toolbar is associated with
-
- :param Plot3DWidget widget: The widget to copy/save/print
- """
- self._plot3d = widget
- self.getCopyAction().setPlot3DWidget(widget)
- self.getSaveAction().setPlot3DWidget(widget)
- self.getVideoRecordAction().setPlot3DWidget(widget)
- self.getPrintAction().setPlot3DWidget(widget)
-
- def getPlot3DWidget(self):
- """Return the Plot3DWidget associated to this toolbar.
-
- If no widget is associated, it returns None.
-
- :rtype: qt.QWidget
- """
- return self._plot3d
-
def getCopyAction(self):
"""Returns the QAction performing copy to clipboard of the Plot3DWidget
@@ -174,3 +173,37 @@ class OutputToolBar(qt.QToolBar):
:rtype: qt.QAction
"""
return self._printAction
+
+
+class ViewpointToolBar(Plot3DWidgetToolBar):
+ """A toolbar providing icons to reset the viewpoint.
+
+ :param parent: See :class:`QToolBar`
+ :param str title: Title of the toolbar
+ """
+
+ def __init__(self, parent=None, title='Viewpoint control'):
+ super(ViewpointToolBar, self).__init__(parent, title)
+
+ self._viewpointToolButton = ViewpointToolButton(parent=self)
+ self.addWidget(self._viewpointToolButton)
+ self._rotateViewpointAction = actions.viewpoint.RotateViewpoint(parent=self)
+ self.addAction(self._rotateViewpointAction)
+
+ def _plot3DWidgetChanged(self, widget):
+ self.getViewpointToolButton().setPlot3DWidget(widget)
+ super(ViewpointToolBar, self)._plot3DWidgetChanged(widget)
+
+ def getViewpointToolButton(self):
+ """Returns the ViewpointToolButton to set viewpoint of the Plot3DWidget
+
+ :rtype: ViewpointToolButton
+ """
+ return self._viewpointToolButton
+
+ def getRotateViewpointAction(self):
+ """Returns the QAction to start/stop rotation of the Plot3DWidget
+
+ :rtype: qt.QAction
+ """
+ return self._rotateViewpointAction
diff --git a/silx/gui/qt/_qt.py b/silx/gui/qt/_qt.py
index 0962c21..a54ea67 100644
--- a/silx/gui/qt/_qt.py
+++ b/silx/gui/qt/_qt.py
@@ -29,18 +29,18 @@
- `PySide <http://www.pyside.org>`_.
If a Qt binding is already loaded, it will use it, otherwise the different
-Qt bindings are tried in this order: PyQt4, PySide, PyQt5.
+Qt bindings are tried in this order: PySide2, PyQt4, PySide, PyQt5.
The name of the loaded Qt binding is stored in the BINDING variable.
For an alternative solution providing a structured namespace,
see `qtpy <https://pypi.python.org/pypi/QtPy/>`_ which
-provides the namespace of PyQt5 over PyQt4 and PySide.
+provides the namespace of PyQt5 over PyQt4, PySide and PySide2.
"""
-__authors__ = ["V.A. Sole - ESRF Data Analysis"]
+__authors__ = ["V.A. Sole"]
__license__ = "MIT"
-__date__ = "17/01/2017"
+__date__ = "11/10/2017"
import logging
@@ -52,10 +52,10 @@ _logger = logging.getLogger(__name__)
BINDING = None
-"""The name of the Qt binding in use: 'PyQt5', 'PyQt4' or 'PySide'."""
+"""The name of the Qt binding in use: PyQt5, 'PyQt4, PySide2 or PySide."""
QtBinding = None # noqa
-"""The Qt binding module in use: PyQt5, PyQt4 or PySide."""
+"""The Qt binding module in use: PyQt5, PyQt4, PySide2 or PySide."""
HAS_SVG = False
"""True if Qt provides support for Scalable Vector Graphics (QtSVG)."""
@@ -64,7 +64,10 @@ HAS_OPENGL = False
"""True if Qt provides support for OpenGL (QtOpenGL)."""
# First check for an already loaded wrapper
-if 'PySide.QtCore' in sys.modules:
+if 'PySide2.QtCore' in sys.modules:
+ BINDING = 'PySide2'
+
+elif 'PySide.QtCore' in sys.modules:
BINDING = 'PySide'
elif 'PyQt5.QtCore' in sys.modules:
@@ -83,8 +86,13 @@ else: # Then try Qt bindings
try:
import PyQt5 # noqa
except ImportError:
- raise ImportError(
- 'No Qt wrapper found. Install PyQt4, PyQt5 or PySide.')
+ try:
+ import PySide2 # noqa
+ except ImportError:
+ raise ImportError(
+ 'No Qt wrapper found. Install PyQt4, PyQt5 or PySide2.')
+ else:
+ BINDING = 'PySide2'
else:
BINDING = 'PyQt5'
else:
@@ -96,7 +104,7 @@ else: # Then try Qt bindings
if BINDING == 'PyQt4':
_logger.debug('Using PyQt4 bindings')
- if sys.version < "3.0.0":
+ if sys.version_info < (3, ):
try:
import sip
@@ -201,8 +209,42 @@ elif BINDING == 'PyQt5':
Slot = pyqtSlot
+elif BINDING == 'PySide2':
+ _logger.debug('Using PySide2 bindings')
+ _logger.warning(
+ 'Using PySide2 Qt binding: PySide2 support in silx is experimental!')
+
+ import PySide2 as QtBinding # noqa
+
+ from PySide2.QtCore import * # noqa
+ from PySide2.QtGui import * # noqa
+ from PySide2.QtWidgets import * # noqa
+ from PySide2.QtPrintSupport import * # noqa
+
+ try:
+ from PySide2.QtOpenGL import * # noqa
+ except ImportError:
+ _logger.info("PySide2.QtOpenGL not available")
+ HAS_OPENGL = False
+ else:
+ HAS_OPENGL = True
+
+ try:
+ from PySide2.QtSvg import * # noqa
+ except ImportError:
+ _logger.info("PySide2.QtSvg not available")
+ HAS_SVG = False
+ else:
+ HAS_SVG = True
+
+ # Import loadUi wrapper for PySide2
+ # TODO from ._pyside_dynamic import loadUi # noqa
+
+ pyqtSignal = Signal
+
else:
- raise ImportError('No Qt wrapper found. Install PyQt4, PyQt5 or PySide')
+ raise ImportError('No Qt wrapper found. Install PyQt4, PyQt5, PySide2')
+
# provide a exception handler but not implement it by default
def exceptionHandler(type_, value, trace):
diff --git a/silx/gui/qt/_utils.py b/silx/gui/qt/_utils.py
index 0aa3ef1..be55465 100644
--- a/silx/gui/qt/_utils.py
+++ b/silx/gui/qt/_utils.py
@@ -30,15 +30,34 @@ __license__ = "MIT"
__date__ = "30/11/2016"
import sys
-from ._qt import BINDING, QImageReader
+from . import _qt as qt
def supportedImageFormats():
"""Return a set of string of file format extensions supported by the
Qt runtime."""
- if sys.version_info[0] < 3 or BINDING == 'PySide':
+ if sys.version_info[0] < 3 or qt.BINDING in ('PySide', 'PySide2'):
convert = str
else:
convert = lambda data: str(data, 'ascii')
- formats = QImageReader.supportedImageFormats()
+ formats = qt.QImageReader.supportedImageFormats()
return set([convert(data) for data in formats])
+
+
+__globalThreadPoolInstance = None
+"""Store the own silx global thread pool"""
+
+
+def silxGlobalThreadPool():
+ """"Manage an own QThreadPool to avoid issue on Qt5 Windows with the
+ default Qt global thread pool.
+
+ :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())
+ __globalThreadPoolInstance = tp
+ return __globalThreadPoolInstance
diff --git a/silx/gui/setup.py b/silx/gui/setup.py
index 163cabf..8e8c796 100644
--- a/silx/gui/setup.py
+++ b/silx/gui/setup.py
@@ -24,7 +24,7 @@
# ###########################################################################*/
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "04/05/2017"
+__date__ = "28/11/2017"
from numpy.distutils.misc_util import Configuration
@@ -41,6 +41,7 @@ def configuration(parent_package='', top_path=None):
config.add_subpackage('test')
config.add_subpackage('plot3d')
config.add_subpackage('data')
+ config.add_subpackage('dialog')
return config
diff --git a/silx/gui/test/__init__.py b/silx/gui/test/__init__.py
index 7449860..0d0805f 100644
--- a/silx/gui/test/__init__.py
+++ b/silx/gui/test/__init__.py
@@ -24,13 +24,14 @@
# ###########################################################################*/
__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "05/01/2017"
+__date__ = "28/11/2017"
import logging
import os
import sys
import unittest
+from silx.test.utils import test_options
_logger = logging.getLogger(__name__)
@@ -52,15 +53,14 @@ def suite():
test_suite.addTest(SkipGUITest())
return test_suite
- elif os.environ.get('WITH_QT_TEST', 'True') == 'False':
+ elif not test_options.WITH_QT_TEST:
# Explicitly disabled tests
- _logger.warning(
- "silx.gui tests disabled (env. variable WITH_QT_TEST=False)")
+ msg = "silx.gui tests disabled: %s" % test_options.WITH_QT_TEST_REASON
+ _logger.warning(msg)
class SkipGUITest(unittest.TestCase):
def runTest(self):
- self.skipTest(
- "silx.gui tests disabled (env. variable WITH_QT_TEST=False)")
+ self.skipTest(test_options.WITH_QT_TEST_REASON)
test_suite.addTest(SkipGUITest())
return test_suite
@@ -72,6 +72,7 @@ def suite():
from ..hdf5 import test as test_hdf5
from ..widgets import test as test_widgets
from ..data import test as test_data
+ from ..dialog import test as test_dialog
from . import test_qt
# Console tests disabled due to corruption of python environment
# (see issue #538 on github)
@@ -94,7 +95,6 @@ def suite():
test_plot3d_suite = SkipPlot3DTest
-
test_suite.addTest(test_qt.suite())
test_suite.addTest(test_plot.suite())
test_suite.addTest(test_fit.suite())
@@ -105,4 +105,5 @@ def 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())
return test_suite
diff --git a/silx/gui/test/test_utils.py b/silx/gui/test/test_utils.py
index 4625969..b1cdf0f 100644
--- a/silx/gui/test/test_utils.py
+++ b/silx/gui/test/test_utils.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -29,10 +29,12 @@ __license__ = "MIT"
__date__ = "16/01/2017"
+import threading
import unittest
import numpy
+from silx.third_party.concurrent_futures import wait
from silx.gui import qt
from silx.gui.test.utils import TestCaseQt
@@ -66,10 +68,97 @@ class TestQImageConversion(TestCaseQt):
self.assertTrue(numpy.all(numpy.equal(image, 1)))
+class TestSubmitToQtThread(TestCaseQt):
+ """Test submission of tasks to Qt main thread"""
+
+ def setUp(self):
+ # Reset executor to test lazy-loading in different conditions
+ _utils._executor = None
+ super(TestSubmitToQtThread, self).setUp()
+
+ def _task(self, value1, value2):
+ return value1, value2
+
+ def _taskWithException(self, *args, **kwargs):
+ raise RuntimeError('task exception')
+
+ def testFromMainThread(self):
+ """Call submitToQtMainThread from the main thread"""
+ value1, value2 = 0, 1
+ future = _utils.submitToQtMainThread(self._task, value1, value2=value2)
+ self.assertTrue(future.done())
+ self.assertEqual(future.result(1), (value1, value2))
+ self.assertIsNone(future.exception(1))
+
+ future = _utils.submitToQtMainThread(self._taskWithException)
+ self.assertTrue(future.done())
+ with self.assertRaises(RuntimeError):
+ future.result(1)
+ self.assertIsInstance(future.exception(1), RuntimeError)
+
+ def _threadedTest(self):
+ """Function run in a thread for the tests"""
+ value1, value2 = 0, 1
+ future = _utils.submitToQtMainThread(self._task, value1, value2=value2)
+
+ wait([future], 3)
+
+ self.assertTrue(future.done())
+ self.assertEqual(future.result(1), (value1, value2))
+ self.assertIsNone(future.exception(1))
+
+ future = _utils.submitToQtMainThread(self._taskWithException)
+
+ wait([future], 3)
+
+ self.assertTrue(future.done())
+ with self.assertRaises(RuntimeError):
+ future.result(1)
+ self.assertIsInstance(future.exception(1), RuntimeError)
+
+ def testFromPythonThread(self):
+ """Call submitToQtMainThread from a Python thread"""
+ thread = threading.Thread(target=self._threadedTest)
+ thread.start()
+ for i in range(100): # Loop over for 10 seconds
+ self.qapp.processEvents()
+ thread.join(0.1)
+ if not thread.is_alive():
+ break
+ else:
+ self.fail(('Thread task still running'))
+
+ def testFromQtThread(self):
+ """Call submitToQtMainThread from a Qt thread pool"""
+ class Runner(qt.QRunnable):
+ def __init__(self, fn):
+ super(Runner, self).__init__()
+ self._fn = fn
+
+ def run(self):
+ self._fn()
+
+ def autoDelete(self):
+ return True
+
+ threadPool = qt.silxGlobalThreadPool()
+ runner = Runner(self._threadedTest)
+ threadPool.start(runner)
+ for i in range(100): # Loop over for 10 seconds
+ self.qapp.processEvents()
+ done = threadPool.waitForDone(100)
+ if done:
+ break
+ else:
+ self.fail('Thread pool task still running')
+
+
def suite():
test_suite = unittest.TestSuite()
test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(
TestQImageConversion))
+ test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(
+ TestSubmitToQtThread))
return test_suite
diff --git a/silx/gui/test/utils.py b/silx/gui/test/utils.py
index 19c448a..3eee474 100644
--- a/silx/gui/test/utils.py
+++ b/silx/gui/test/utils.py
@@ -26,7 +26,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "01/09/2017"
+__date__ = "27/02/2018"
import gc
@@ -43,6 +43,8 @@ from silx.gui import qt
if qt.BINDING == 'PySide':
from PySide.QtTest import QTest
+elif qt.BINDING == 'PySide2':
+ from PySide2.QtTest import QTest
elif qt.BINDING == 'PyQt5':
from PyQt5.QtTest import QTest
elif qt.BINDING == 'PyQt4':
@@ -137,11 +139,9 @@ class TestCaseQt(unittest.TestCase):
# Makes sure a QApplication exists and do it once for all
_qapp = qt.QApplication.instance() or qt.QApplication([])
- # Create/delate a QWidget to make sure init of QDesktopWidget
- _dummyWidget = qt.QWidget()
- _dummyWidget.setAttribute(qt.Qt.WA_DeleteOnClose)
- _dummyWidget.show()
- _dummyWidget.close()
+ # Makes sure QDesktopWidget is init
+ # Otherwise it happens randomly during the tests
+ cls._desktopWidget = _qapp.desktop()
_qapp.processEvents()
@classmethod
@@ -163,9 +163,10 @@ class TestCaseQt(unittest.TestCase):
# For Python < 3.4
result = getattr(self, '_outcomeForDoCleanups', self._resultForDoCleanups)
+ skipped = self.id() in [case.id() for case, _ in result.skipped]
error = self.id() in [case.id() for case, _ in result.errors]
failure = self.id() in [case.id() for case, _ in result.failures]
- return not error and not failure
+ return not error and not failure and not skipped
def _checkForUnreleasedWidgets(self):
"""Test fixture checking that no more widgets exists."""
@@ -175,7 +176,7 @@ class TestCaseQt(unittest.TestCase):
if widget not in self.__previousWidgets]
del self.__previousWidgets
- if qt.BINDING == 'PySide':
+ if qt.BINDING in ('PySide', 'PySide2'):
return # Do not test for leaking widgets with PySide
allowedLeakingWidgets = self.allowedLeakingWidgets
@@ -318,24 +319,25 @@ class TestCaseQt(unittest.TestCase):
"""
QTest.qSleep(ms + self.TIMEOUT_WAIT)
- def qWait(self, ms=None):
+ @classmethod
+ def qWait(cls, ms=None):
"""Waits for ms milliseconds, events will be processed.
See QTest.qWait for details.
"""
if ms is None:
- ms = self.DEFAULT_TIMEOUT_WAIT
+ ms = cls.DEFAULT_TIMEOUT_WAIT
if qt.BINDING == 'PySide':
# PySide has no qWait, provide a replacement
timeout = int(ms)
endTimeMS = int(time.time() * 1000) + timeout
while timeout > 0:
- self.qapp.processEvents(qt.QEventLoop.AllEvents,
+ _qapp.processEvents(qt.QEventLoop.AllEvents,
maxtime=timeout)
timeout = endTimeMS - int(time.time() * 1000)
else:
- QTest.qWait(ms + self.TIMEOUT_WAIT)
+ QTest.qWait(ms + cls.TIMEOUT_WAIT)
def qWaitForWindowExposed(self, window, timeout=None):
"""Waits until the window is shown in the screen.
@@ -349,6 +351,57 @@ class TestCaseQt(unittest.TestCase):
return result
+ _qobject_destroyed = False
+
+ @classmethod
+ def _aboutToDestroy(cls):
+ cls._qobject_destroyed = True
+
+ @classmethod
+ def qWaitForDestroy(cls, ref):
+ """
+ Wait for Qt object destruction.
+
+ Use a weakref as parameter to avoid any strong references to the
+ object.
+
+ It have to be used as following. Removing the reference to the object
+ before calling the function looks to be expected, else
+ :meth:`deleteLater` will not work.
+
+ .. code-block:: python
+
+ ref = weakref.ref(self.obj)
+ self.obj = None
+ self.qWaitForDestroy(ref)
+
+ :param weakref ref: A weakref to an object to avoid any reference
+ :return: True if the object was destroyed
+ :rtype: bool
+ """
+ cls._qobject_destroyed = False
+ if qt.BINDING == 'PyQt4':
+ # Without this, QWidget will be still alive on PyQt4
+ # (at least on Windows Python 2.7)
+ # If it is not skipped on PySide, silx.gui.dialog tests will
+ # segfault (at least on Windows Python 2.7)
+ import gc
+ gc.collect()
+ qobject = ref()
+ if qobject is None:
+ return True
+ qobject.destroyed.connect(cls._aboutToDestroy)
+ qobject.deleteLater()
+ qobject = None
+ for _ in range(10):
+ if cls._qobject_destroyed:
+ break
+ cls.qWait(10)
+ else:
+ _logger.debug("Object was not destroyed")
+
+ return ref() is None
+
def logScreenShot(self, level=logging.ERROR):
"""Take a screenshot and log it into the logging system if the
logger is enabled for the expected level.
@@ -454,3 +507,14 @@ def getQToolButtonFromAction(action):
if isinstance(widget, qt.QToolButton):
return widget
return None
+
+
+def findChildren(parent, kind, name=None):
+ if qt.BINDING == "PySide" and name is not None:
+ result = []
+ for obj in parent.findChildren(kind):
+ if obj.objectName() == name:
+ result.append(obj)
+ return result
+ else:
+ return parent.findChildren(kind, name=name)
diff --git a/silx/gui/widgets/FrameBrowser.py b/silx/gui/widgets/FrameBrowser.py
index 6737e9c..a8c0349 100644
--- a/silx/gui/widgets/FrameBrowser.py
+++ b/silx/gui/widgets/FrameBrowser.py
@@ -229,8 +229,6 @@ class HorizontalSliderWithBrowser(qt.QAbstractSlider):
:param QWidget parent: Optional parent widget
"""
- sigIndexChanged = qt.pyqtSignal(object)
-
def __init__(self, parent=None):
qt.QAbstractSlider.__init__(self, parent)
self.setOrientation(qt.Qt.Horizontal)
diff --git a/silx/gui/widgets/MedianFilterDialog.py b/silx/gui/widgets/MedianFilterDialog.py
index 3eddff3..dd4a00d 100644
--- a/silx/gui/widgets/MedianFilterDialog.py
+++ b/silx/gui/widgets/MedianFilterDialog.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -35,8 +35,14 @@ __authors__ = ["H. Payno"]
__license__ = "MIT"
__date__ = "14/02/2017"
+
+import logging
+
from silx.gui import qt
+
+_logger = logging.getLogger(__name__)
+
class MedianFilterDialog(qt.QDialog):
"""QDialog window featuring a :class:`BackgroundWidget`"""
sigFilterOptChanged = qt.Signal(int, bool)
@@ -69,6 +75,6 @@ class MedianFilterDialog(qt.QDialog):
def _filterOptionChanged(self):
"""Call back used when the filter values are changed"""
if self._filterWidth.value()%2 == 0:
- logging.warning('median filter only accept odd values')
+ _logger.warning('median filter only accept odd values')
else:
self.sigFilterOptChanged.emit(self._filterWidth.value(), self._filterOption.isChecked()) \ No newline at end of file
diff --git a/silx/gui/widgets/PeriodicTable.py b/silx/gui/widgets/PeriodicTable.py
index db71483..0233e8c 100644
--- a/silx/gui/widgets/PeriodicTable.py
+++ b/silx/gui/widgets/PeriodicTable.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -634,7 +634,7 @@ class PeriodicTable(qt.QWidget):
objects.
:return: Selected items
- :rtype: list(PeriodicTableItem)
+ :rtype: List[PeriodicTableItem]
"""
return [b.item for b in self._eltButtons.values() if b.isSelected()]
@@ -644,7 +644,7 @@ class PeriodicTable(qt.QWidget):
This causes the sigSelectionChanged signal
to be emitted, even if the selection didn't actually change.
- :param list(str) symbols: List of symbols of elements to be selected
+ :param List[str] symbols: List of symbols of elements to be selected
(e.g. *["Fe", "Hg", "Li"]*)
"""
# accept list of PeriodicTableItems as input, because getSelection
@@ -813,7 +813,7 @@ class PeriodicList(qt.QTreeWidget):
objects.
:return: Selected elements
- :rtype: list(PeriodicTableItem)"""
+ :rtype: List[PeriodicTableItem]"""
return [_defaultTableItems[idx] for idx in range(len(self.tree_items))
if self.tree_items[idx].isSelected()]
diff --git a/silx/gui/widgets/PrintPreview.py b/silx/gui/widgets/PrintPreview.py
index 158d6b7..2b4c433 100644
--- a/silx/gui/widgets/PrintPreview.py
+++ b/silx/gui/widgets/PrintPreview.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -666,8 +666,6 @@ class _GraphicsResizeRectItem(qt.QGraphicsRectItem):
def main():
"""
"""
- import sys
-
if len(sys.argv) < 2:
print("give an image file as parameter please.")
sys.exit(1)
diff --git a/silx/gui/widgets/ThreadPoolPushButton.py b/silx/gui/widgets/ThreadPoolPushButton.py
index 4dba488..949b6ef 100644
--- a/silx/gui/widgets/ThreadPoolPushButton.py
+++ b/silx/gui/widgets/ThreadPoolPushButton.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -201,17 +201,20 @@ class ThreadPoolPushButton(WaitingPushButton):
return
self.__runnerStarted()
runner = self._createRunner(self.__callable, self.__args, self.__kwargs)
- qt.QThreadPool.globalInstance().start(runner)
+ qt.silxGlobalThreadPool().start(runner)
self.__runnerSet.add(runner)
def __releaseRunner(self, runner):
self.__runnerSet.remove(runner)
+ def hasPendingOperations(self):
+ return len(self.__runnerSet) > 0
+
def _createRunner(self, function, args, kwargs):
"""Create a QRunnable from a callable object.
:param callable function: A callable Python object.
- :param list args: List of arguments to call the function.
+ :param List args: List of arguments to call the function.
:param dict kwargs: Dictionary of arguments used to call the function.
:rtpye: qt.QRunnable
"""
@@ -227,7 +230,7 @@ class ThreadPoolPushButton(WaitingPushButton):
WARNING: The callable will be called in a separate thread.
:param callable function: A callable Python object
- :param list args: List of arguments to call the function.
+ :param List args: List of arguments to call the function.
:param dict kwargs: Dictionary of arguments used to call the function.
"""
self.__callable = function
diff --git a/silx/gui/widgets/test/test_threadpoolpushbutton.py b/silx/gui/widgets/test/test_threadpoolpushbutton.py
index 126f8f3..a8618a4 100644
--- a/silx/gui/widgets/test/test_threadpoolpushbutton.py
+++ b/silx/gui/widgets/test/test_threadpoolpushbutton.py
@@ -26,7 +26,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "15/12/2016"
+__date__ = "17/01/2018"
import unittest
@@ -35,7 +35,7 @@ from silx.gui import qt
from silx.gui.test.utils import TestCaseQt
from silx.gui.test.utils import SignalListener
from silx.gui.widgets.ThreadPoolPushButton import ThreadPoolPushButton
-from silx.test.utils import TestLogging
+from silx.utils.testutils import TestLogging
class TestThreadPoolPushButton(TestCaseQt):
@@ -44,6 +44,14 @@ class TestThreadPoolPushButton(TestCaseQt):
super(TestThreadPoolPushButton, self).setUp()
self._result = []
+ def waitForPendingOperations(self, object):
+ for i in range(50):
+ if not object.hasPendingOperations():
+ break
+ self.qWait(10)
+ else:
+ raise RuntimeError("Still waiting for a pending operation")
+
def _trace(self, name, delay=0):
self._result.append(name)
if delay != 0:
@@ -61,27 +69,25 @@ class TestThreadPoolPushButton(TestCaseQt):
button.executeCallable()
time.sleep(0.1)
self.assertListEqual(self._result, ["a"])
- self.qapp.processEvents()
+ self.waitForPendingOperations(button)
def testMultiExecution(self):
button = ThreadPoolPushButton()
button.setCallable(self._trace, "a", 0)
- number = qt.QThreadPool.globalInstance().maxThreadCount() * 2
+ number = qt.silxGlobalThreadPool().maxThreadCount()
for _ in range(number):
button.executeCallable()
- time.sleep(number * 0.01 + 0.1)
+ self.waitForPendingOperations(button)
self.assertListEqual(self._result, ["a"] * number)
- self.qapp.processEvents()
def testSaturateThreadPool(self):
button = ThreadPoolPushButton()
button.setCallable(self._trace, "a", 100)
- number = qt.QThreadPool.globalInstance().maxThreadCount() * 2
+ number = qt.silxGlobalThreadPool().maxThreadCount() * 2
for _ in range(number):
button.executeCallable()
- time.sleep(number * 0.1 + 0.1)
+ self.waitForPendingOperations(button)
self.assertListEqual(self._result, ["a"] * number)
- self.qapp.processEvents()
def testSuccess(self):
listener = SignalListener()