summaryrefslogtreecommitdiff
path: root/silx/gui
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui')
-rw-r--r--silx/gui/_glutils/FramebufferTexture.py78
-rw-r--r--silx/gui/_glutils/OpenGLWidget.py409
-rw-r--r--silx/gui/_glutils/VertexBuffer.py10
-rw-r--r--silx/gui/_glutils/__init__.py1
-rw-r--r--silx/gui/_glutils/font.py32
-rw-r--r--silx/gui/console.py4
-rw-r--r--silx/gui/data/ArrayTableModel.py7
-rw-r--r--silx/gui/data/ArrayTableWidget.py2
-rw-r--r--silx/gui/data/DataViewer.py10
-rw-r--r--silx/gui/data/DataViewerFrame.py12
-rw-r--r--silx/gui/data/DataViews.py209
-rw-r--r--silx/gui/data/Hdf5TableView.py76
-rw-r--r--silx/gui/data/HexaTableView.py278
-rw-r--r--silx/gui/data/NXdataWidgets.py22
-rw-r--r--silx/gui/data/RecordTableView.py10
-rw-r--r--silx/gui/data/TextFormatter.py168
-rw-r--r--silx/gui/data/test/test_dataviewer.py28
-rw-r--r--silx/gui/data/test/test_textformatter.py113
-rw-r--r--silx/gui/fit/BackgroundWidget.py16
-rw-r--r--silx/gui/fit/FitConfig.py6
-rw-r--r--silx/gui/fit/FitWidget.py2
-rw-r--r--silx/gui/hdf5/Hdf5Formatter.py229
-rw-r--r--silx/gui/hdf5/Hdf5HeaderView.py29
-rw-r--r--silx/gui/hdf5/Hdf5Item.py182
-rw-r--r--silx/gui/hdf5/Hdf5Node.py29
-rw-r--r--silx/gui/hdf5/Hdf5TreeModel.py78
-rw-r--r--silx/gui/hdf5/Hdf5TreeView.py85
-rw-r--r--silx/gui/hdf5/NexusSortFilterProxyModel.py5
-rw-r--r--silx/gui/hdf5/_utils.py184
-rw-r--r--silx/gui/hdf5/test/_mock.py130
-rw-r--r--silx/gui/hdf5/test/test_hdf5.py454
-rw-r--r--silx/gui/icons.py54
-rw-r--r--silx/gui/plot/ColorBar.py456
-rw-r--r--silx/gui/plot/Colormap.py410
-rw-r--r--silx/gui/plot/ColormapDialog.py78
-rw-r--r--silx/gui/plot/Colors.py217
-rw-r--r--silx/gui/plot/ComplexImageView.py670
-rw-r--r--silx/gui/plot/CurvesROIWidget.py20
-rw-r--r--silx/gui/plot/ImageView.py157
-rw-r--r--silx/gui/plot/ItemsSelectionDialog.py282
-rw-r--r--silx/gui/plot/LegendSelector.py21
-rw-r--r--silx/gui/plot/LimitsHistory.py83
-rw-r--r--silx/gui/plot/MPLColormap.py1062
-rw-r--r--silx/gui/plot/MaskToolsWidget.py87
-rw-r--r--silx/gui/plot/Plot.py2925
-rw-r--r--silx/gui/plot/PlotActions.py1399
-rw-r--r--silx/gui/plot/PlotInteraction.py79
-rw-r--r--silx/gui/plot/PlotToolButtons.py13
-rw-r--r--silx/gui/plot/PlotTools.py152
-rw-r--r--silx/gui/plot/PlotWidget.py3020
-rw-r--r--silx/gui/plot/PlotWindow.py193
-rw-r--r--silx/gui/plot/PrintPreviewToolButton.py350
-rw-r--r--silx/gui/plot/Profile.py77
-rw-r--r--silx/gui/plot/ScatterMaskToolsWidget.py13
-rw-r--r--silx/gui/plot/StackView.py479
-rw-r--r--silx/gui/plot/_BaseMaskToolsWidget.py124
-rw-r--r--silx/gui/plot/__init__.py7
-rw-r--r--silx/gui/plot/_utils/__init__.py11
-rw-r--r--silx/gui/plot/_utils/panzoom.py154
-rw-r--r--silx/gui/plot/actions/PlotAction.py79
-rw-r--r--silx/gui/plot/actions/__init__.py38
-rw-r--r--silx/gui/plot/actions/control.py549
-rw-r--r--silx/gui/plot/actions/fit.py189
-rw-r--r--silx/gui/plot/actions/histogram.py170
-rw-r--r--silx/gui/plot/actions/io.py538
-rw-r--r--silx/gui/plot/actions/medfilt.py145
-rw-r--r--silx/gui/plot/actions/mode.py100
-rw-r--r--silx/gui/plot/backends/BackendBase.py39
-rw-r--r--silx/gui/plot/backends/BackendMatplotlib.py104
-rw-r--r--silx/gui/plot/backends/BackendOpenGL.py144
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotCurve.py2
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotFrame.py24
-rw-r--r--silx/gui/plot/backends/glutils/GLPlotImage.py56
-rw-r--r--silx/gui/plot/backends/glutils/GLText.py43
-rw-r--r--silx/gui/plot/backends/glutils/PlotImageFile.py14
-rw-r--r--silx/gui/plot/items/__init__.py13
-rw-r--r--silx/gui/plot/items/axis.py477
-rw-r--r--silx/gui/plot/items/core.py182
-rw-r--r--silx/gui/plot/items/curve.py18
-rw-r--r--silx/gui/plot/items/histogram.py36
-rw-r--r--silx/gui/plot/items/image.py75
-rw-r--r--silx/gui/plot/items/marker.py11
-rw-r--r--silx/gui/plot/items/scatter.py10
-rw-r--r--silx/gui/plot/items/shape.py14
-rw-r--r--silx/gui/plot/matplotlib/Colormap.py282
-rw-r--r--silx/gui/plot/matplotlib/ModestImage.py (renamed from silx/gui/plot/backends/ModestImage.py)2
-rw-r--r--silx/gui/plot/matplotlib/__init__.py (renamed from silx/gui/plot/backends/_matplotlib.py)14
-rw-r--r--silx/gui/plot/setup.py5
-rw-r--r--silx/gui/plot/test/__init__.py80
-rw-r--r--silx/gui/plot/test/testColorBar.py227
-rw-r--r--silx/gui/plot/test/testColormap.py291
-rw-r--r--silx/gui/plot/test/testColors.py8
-rw-r--r--silx/gui/plot/test/testComplexImageView.py95
-rw-r--r--silx/gui/plot/test/testCurvesROIWidget.py3
-rw-r--r--silx/gui/plot/test/testItem.py231
-rw-r--r--silx/gui/plot/test/testLegendSelector.py3
-rw-r--r--silx/gui/plot/test/testLimitConstraints.py125
-rw-r--r--silx/gui/plot/test/testMaskToolsWidget.py38
-rw-r--r--silx/gui/plot/test/testPlotInteraction.py27
-rw-r--r--silx/gui/plot/test/testPlotTools.py23
-rw-r--r--silx/gui/plot/test/testPlotWidget.py775
-rw-r--r--silx/gui/plot/test/testPlotWidgetNoBackend.py (renamed from silx/gui/plot/test/testPlot.py)98
-rw-r--r--silx/gui/plot/test/testPlotWindow.py14
-rw-r--r--silx/gui/plot/test/testScatterMaskToolsWidget.py37
-rw-r--r--silx/gui/plot/test/testStackView.py33
-rw-r--r--silx/gui/plot/test/testUtilsAxis.py148
-rw-r--r--silx/gui/plot/test/utils.py194
-rw-r--r--silx/gui/plot/utils/__init__.py30
-rw-r--r--silx/gui/plot/utils/axis.py164
-rw-r--r--silx/gui/plot3d/Plot3DWidget.py203
-rw-r--r--silx/gui/plot3d/Plot3DWindow.py20
-rw-r--r--silx/gui/plot3d/SFViewParamTree.py142
-rw-r--r--silx/gui/plot3d/ScalarFieldView.py270
-rw-r--r--silx/gui/plot3d/__init__.py7
-rw-r--r--silx/gui/plot3d/actions/Plot3DAction.py69
-rw-r--r--silx/gui/plot3d/actions/__init__.py33
-rw-r--r--silx/gui/plot3d/actions/io.py (renamed from silx/gui/plot3d/Plot3DActions.py)44
-rw-r--r--silx/gui/plot3d/actions/mode.py126
-rw-r--r--silx/gui/plot3d/scene/axes.py19
-rw-r--r--silx/gui/plot3d/scene/function.py128
-rw-r--r--silx/gui/plot3d/scene/interaction.py29
-rw-r--r--silx/gui/plot3d/scene/primitives.py7
-rw-r--r--silx/gui/plot3d/scene/viewport.py15
-rw-r--r--silx/gui/plot3d/scene/window.py11
-rw-r--r--silx/gui/plot3d/setup.py2
-rw-r--r--silx/gui/plot3d/test/__init__.py4
-rw-r--r--silx/gui/plot3d/test/testGL.py84
-rw-r--r--silx/gui/plot3d/test/testScalarFieldView.py114
-rw-r--r--silx/gui/plot3d/tools/ViewpointTools.py (renamed from silx/gui/plot3d/ViewpointToolBar.py)29
-rw-r--r--silx/gui/plot3d/tools/__init__.py32
-rw-r--r--silx/gui/plot3d/tools/toolbars.py (renamed from silx/gui/plot3d/Plot3DToolBar.py)85
-rw-r--r--silx/gui/setup.py2
-rw-r--r--silx/gui/test/test_icons.py56
-rw-r--r--silx/gui/test/utils.py36
-rw-r--r--silx/gui/widgets/FloatEdit.py65
-rw-r--r--silx/gui/widgets/FrameBrowser.py4
-rw-r--r--silx/gui/widgets/PeriodicTable.py6
-rw-r--r--silx/gui/widgets/PrintGeometryDialog.py222
-rw-r--r--silx/gui/widgets/PrintPreview.py704
-rw-r--r--silx/gui/widgets/TableWidget.py162
-rw-r--r--silx/gui/widgets/ThreadPoolPushButton.py2
-rw-r--r--silx/gui/widgets/WaitingPushButton.py2
-rw-r--r--silx/gui/widgets/test/__init__.py4
-rw-r--r--silx/gui/widgets/test/test_printpreview.py74
144 files changed, 16243 insertions, 8077 deletions
diff --git a/silx/gui/_glutils/FramebufferTexture.py b/silx/gui/_glutils/FramebufferTexture.py
index b01eb41..cc05080 100644
--- a/silx/gui/_glutils/FramebufferTexture.py
+++ b/silx/gui/_glutils/FramebufferTexture.py
@@ -66,49 +66,48 @@ class FramebufferTexture(object):
self._previousFramebuffer = 0 # Used by with statement
self._name = gl.glGenFramebuffers(1)
- gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self._name)
-
- # Attachments
- gl.glFramebufferTexture2D(gl.GL_FRAMEBUFFER,
- gl.GL_COLOR_ATTACHMENT0,
- gl.GL_TEXTURE_2D,
- self._texture.name,
- 0)
-
- height, width = self._texture.shape
-
- if stencilFormat is not None:
- self._stencilId = gl.glGenRenderbuffers(1)
- gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._stencilId)
- gl.glRenderbufferStorage(gl.GL_RENDERBUFFER,
- stencilFormat,
- width, height)
- gl.glFramebufferRenderbuffer(gl.GL_FRAMEBUFFER,
- gl.GL_STENCIL_ATTACHMENT,
- gl.GL_RENDERBUFFER,
- self._stencilId)
- else:
- self._stencilId = None
- if depthFormat is not None:
- if self._stencilId and depthFormat in self._PACKED_FORMAT:
- self._depthId = self._stencilId
- else:
- self._depthId = gl.glGenRenderbuffers(1)
- gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._depthId)
+ with self: # Bind FBO
+ # Attachments
+ gl.glFramebufferTexture2D(gl.GL_FRAMEBUFFER,
+ gl.GL_COLOR_ATTACHMENT0,
+ gl.GL_TEXTURE_2D,
+ self._texture.name,
+ 0)
+
+ height, width = self._texture.shape
+
+ if stencilFormat is not None:
+ self._stencilId = gl.glGenRenderbuffers(1)
+ gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._stencilId)
gl.glRenderbufferStorage(gl.GL_RENDERBUFFER,
- depthFormat,
+ stencilFormat,
width, height)
- gl.glFramebufferRenderbuffer(gl.GL_FRAMEBUFFER,
- gl.GL_DEPTH_ATTACHMENT,
- gl.GL_RENDERBUFFER,
- self._depthId)
- else:
- self._depthId = None
+ gl.glFramebufferRenderbuffer(gl.GL_FRAMEBUFFER,
+ gl.GL_STENCIL_ATTACHMENT,
+ gl.GL_RENDERBUFFER,
+ self._stencilId)
+ else:
+ self._stencilId = None
+
+ if depthFormat is not None:
+ if self._stencilId and depthFormat in self._PACKED_FORMAT:
+ self._depthId = self._stencilId
+ else:
+ self._depthId = gl.glGenRenderbuffers(1)
+ gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._depthId)
+ gl.glRenderbufferStorage(gl.GL_RENDERBUFFER,
+ depthFormat,
+ width, height)
+ gl.glFramebufferRenderbuffer(gl.GL_FRAMEBUFFER,
+ gl.GL_DEPTH_ATTACHMENT,
+ gl.GL_RENDERBUFFER,
+ self._depthId)
+ else:
+ self._depthId = None
- assert gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER) == \
- gl.GL_FRAMEBUFFER_COMPLETE
- gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0)
+ assert (gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER) ==
+ gl.GL_FRAMEBUFFER_COMPLETE)
@property
def shape(self):
@@ -143,6 +142,7 @@ class FramebufferTexture(object):
def __exit__(self, exctype, excvalue, traceback):
gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self._previousFramebuffer)
+ self._previousFramebuffer = None
def discard(self):
"""Delete associated OpenGL resources including texture"""
diff --git a/silx/gui/_glutils/OpenGLWidget.py b/silx/gui/_glutils/OpenGLWidget.py
new file mode 100644
index 0000000..6cbf8f0
--- /dev/null
+++ b/silx/gui/_glutils/OpenGLWidget.py
@@ -0,0 +1,409 @@
+# 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 package provides a compatibility layer for OpenGL widget.
+
+It provides a compatibility layer for Qt OpenGL widget used in silx
+across Qt<=5.3 QtOpenGL.QGLWidget and QOpenGLWidget.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "26/07/2017"
+
+
+import logging
+import sys
+
+from .. import qt
+from .._glutils import gl
+
+
+_logger = logging.getLogger(__name__)
+
+
+# Probe OpenGL availability and widget
+ERROR = '' # Error message from probing Qt OpenGL support
+_BaseOpenGLWidget = None # Qt OpenGL widget to use
+
+if hasattr(qt, 'QOpenGLWidget'): # PyQt>=5.4
+ _logger.info('Using QOpenGLWidget')
+ _BaseOpenGLWidget = qt.QOpenGLWidget
+
+elif not qt.HAS_OPENGL: # QtOpenGL not installed
+ ERROR = '%s.QtOpenGL not available' % qt.BINDING
+
+elif qt.QApplication.instance() and not qt.QGLFormat.hasOpenGL():
+ # qt.QGLFormat.hasOpenGL MUST be called with a QApplication created
+ # so this is only checked if the QApplication is already created
+ ERROR = 'Qt reports OpenGL not available'
+
+else:
+ _logger.info('Using QGLWidget')
+ _BaseOpenGLWidget = qt.QGLWidget
+
+
+# Internal class wrapping Qt OpenGL widget
+if _BaseOpenGLWidget is None:
+ _logger.error('OpenGL-based widget disabled: %s', ERROR)
+ _OpenGLWidget = None
+
+else:
+ class _OpenGLWidget(_BaseOpenGLWidget):
+ """Wrapper over QOpenGLWidget and QGLWidget"""
+
+ sigOpenGLContextError = qt.Signal(str)
+ """Signal emitted when an OpenGL context error is detected at runtime.
+
+ It provides the error reason as a str.
+ """
+
+ def __init__(self, parent,
+ alphaBufferSize=0,
+ depthBufferSize=24,
+ stencilBufferSize=8,
+ version=(2, 0),
+ f=qt.Qt.WindowFlags()):
+ # True if using QGLWidget, False if using QOpenGLWidget
+ self.__legacy = not hasattr(qt, 'QOpenGLWidget')
+
+ self.__devicePixelRatio = 1.0
+ self.__requestedOpenGLVersion = int(version[0]), int(version[1])
+ self.__isValid = False
+
+ if self.__legacy: # QGLWidget
+ format_ = qt.QGLFormat()
+ format_.setAlphaBufferSize(alphaBufferSize)
+ format_.setAlpha(alphaBufferSize != 0)
+ format_.setDepthBufferSize(depthBufferSize)
+ format_.setDepth(depthBufferSize != 0)
+ format_.setStencilBufferSize(stencilBufferSize)
+ format_.setStencil(stencilBufferSize != 0)
+ format_.setVersion(*self.__requestedOpenGLVersion)
+ format_.setDoubleBuffer(True)
+
+ super(_OpenGLWidget, self).__init__(format_, parent, None, f)
+
+ else: # QOpenGLWidget
+ super(_OpenGLWidget, self).__init__(parent, f)
+
+ format_ = qt.QSurfaceFormat()
+ format_.setAlphaBufferSize(alphaBufferSize)
+ format_.setDepthBufferSize(depthBufferSize)
+ format_.setStencilBufferSize(stencilBufferSize)
+ format_.setVersion(*self.__requestedOpenGLVersion)
+ format_.setSwapBehavior(qt.QSurfaceFormat.DoubleBuffer)
+ self.setFormat(format_)
+
+
+ def getDevicePixelRatio(self):
+ """Returns the ratio device-independent / device pixel size
+
+ It should be either 1.0 or 2.0.
+
+ :return: Scale factor between screen and Qt units
+ :rtype: float
+ """
+ return self.__devicePixelRatio
+
+ def getRequestedOpenGLVersion(self):
+ """Returns the requested OpenGL version.
+
+ :return: (major, minor)
+ :rtype: 2-tuple of int"""
+ return self.__requestedOpenGLVersion
+
+ def getOpenGLVersion(self):
+ """Returns the available OpenGL version.
+
+ :return: (major, minor)
+ :rtype: 2-tuple of int"""
+ if self.__legacy: # QGLWidget
+ supportedVersion = 0, 0
+
+ # Go through all OpenGL version flags checking support
+ flags = self.format().openGLVersionFlags()
+ for version in ((1, 1), (1, 2), (1, 3), (1, 4), (1, 5),
+ (2, 0), (2, 1),
+ (3, 0), (3, 1), (3, 2), (3, 3),
+ (4, 0)):
+ versionFlag = getattr(qt.QGLFormat,
+ 'OpenGL_Version_%d_%d' % version)
+ if not versionFlag & flags:
+ break
+ supportedVersion = version
+ return supportedVersion
+
+ else: # QOpenGLWidget
+ return self.format().version()
+
+ # QOpenGLWidget methods
+
+ def isValid(self):
+ """Returns True if OpenGL is available.
+
+ This adds extra checks to Qt isValid method.
+
+ :rtype: bool
+ """
+ return self.__isValid and super(_OpenGLWidget, self).isValid()
+
+ def defaultFramebufferObject(self):
+ """Returns the framebuffer object handle.
+
+ See :meth:`QOpenGLWidget.defaultFramebufferObject`
+ """
+ if self.__legacy: # QGLWidget
+ return 0
+ else: # QOpenGLWidget
+ return super(_OpenGLWidget, self).defaultFramebufferObject()
+
+ # *GL overridden methods
+
+ def initializeGL(self):
+ parent = self.parent()
+ if parent is None:
+ _logger.error('_OpenGLWidget has no parent')
+ return
+
+ # Check OpenGL version
+ if self.getOpenGLVersion() >= self.getRequestedOpenGLVersion():
+ version = gl.glGetString(gl.GL_VERSION)
+ if version:
+ self.__isValid = True
+ else:
+ errMsg = 'OpenGL not available'
+ if sys.platform.startswith('linux'):
+ errMsg += ': If connected remotely, ' \
+ 'GLX forwarding might be disabled.'
+ _logger.error(errMsg)
+ self.sigOpenGLContextError.emit(errMsg)
+ self.__isValid = False
+
+ else:
+ errMsg = 'OpenGL %d.%d not available' % \
+ self.getRequestedOpenGLVersion()
+ _logger.error('OpenGL widget disabled: %s', errMsg)
+ self.sigOpenGLContextError.emit(errMsg)
+ self.__isValid = False
+
+ if self.isValid():
+ parent.initializeGL()
+
+ def paintGL(self):
+ parent = self.parent()
+ if parent is None:
+ _logger.error('_OpenGLWidget has no parent')
+ return
+
+ if qt.BINDING == 'PyQt5':
+ devicePixelRatio = self.window().windowHandle().devicePixelRatio()
+
+ if devicePixelRatio != self.getDevicePixelRatio():
+ # Update devicePixelRatio and call resizeOpenGL
+ # as resizeGL is not always called.
+ self.__devicePixelRatio = devicePixelRatio
+ self.makeCurrent()
+ parent.resizeGL(self.width(), self.height())
+
+ if self.isValid():
+ parent.paintGL()
+
+ def resizeGL(self, width, height):
+ parent = self.parent()
+ if parent is None:
+ _logger.error('_OpenGLWidget has no parent')
+ return
+
+ if self.isValid():
+ # Call parent resizeGL with device-independent pixel unit
+ # This works over both QGLWidget and QOpenGLWidget
+ parent.resizeGL(self.width(), self.height())
+
+
+class OpenGLWidget(qt.QWidget):
+ """OpenGL widget wrapper over QGLWidget and QOpenGLWidget
+
+ This wrapper API implements a subset of QOpenGLWidget API.
+ The constructor takes a different set of arguments.
+ Methods returning object like :meth:`context` returns either
+ QGL* or QOpenGL* objects.
+
+ :param parent: Parent widget see :class:`QWidget`
+ :param int alphaBufferSize:
+ Size in bits of the alpha channel (default: 0).
+ Set to 0 to disable alpha channel.
+ :param int depthBufferSize:
+ Size in bits of the depth buffer (default: 24).
+ Set to 0 to disable depth buffer.
+ :param int stencilBufferSize:
+ Size in bits of the stencil buffer (default: 8).
+ Set to 0 to disable stencil buffer
+ :param version: Requested OpenGL version (default: (2, 0)).
+ :type version: 2-tuple of int
+ :param f: see :class:`QWidget`
+ """
+
+ def __init__(self, parent=None,
+ alphaBufferSize=0,
+ depthBufferSize=24,
+ stencilBufferSize=8,
+ version=(2, 0),
+ f=qt.Qt.WindowFlags()):
+ super(OpenGLWidget, self).__init__(parent, f)
+
+ layout = qt.QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ self.setLayout(layout)
+
+ if _OpenGLWidget is None:
+ self.__openGLWidget = None
+ label = self._createErrorQLabel(ERROR)
+ self.layout().addWidget(label)
+
+ else:
+ self.__openGLWidget = _OpenGLWidget(
+ parent=self,
+ alphaBufferSize=alphaBufferSize,
+ depthBufferSize=depthBufferSize,
+ stencilBufferSize=stencilBufferSize,
+ version=version,
+ f=f)
+ # Async connection need, otherwise issue when hiding OpenGL
+ # widget while doing the rendering..
+ self.__openGLWidget.sigOpenGLContextError.connect(
+ self._handleOpenGLInitError, qt.Qt.QueuedConnection)
+ self.layout().addWidget(self.__openGLWidget)
+
+ @staticmethod
+ def _createErrorQLabel(error):
+ """Create QLabel displaying error message in place of OpenGL widget
+
+ :param str error: The error message to display"""
+ label = qt.QLabel()
+ label.setText('OpenGL-based widget disabled:\n%s' % error)
+ label.setAlignment(qt.Qt.AlignCenter)
+ label.setWordWrap(True)
+ return label
+
+ def _handleOpenGLInitError(self, error):
+ """Handle runtime errors in OpenGL widget"""
+ if self.__openGLWidget is not None:
+ self.__openGLWidget.setVisible(False)
+ self.__openGLWidget.setParent(None)
+ self.__openGLWidget = None
+
+ label = self._createErrorQLabel(error)
+ self.layout().addWidget(label)
+
+ # Additional API
+
+ def getDevicePixelRatio(self):
+ """Returns the ratio device-independent / device pixel size
+
+ It should be either 1.0 or 2.0.
+
+ :return: Scale factor between screen and Qt units
+ :rtype: float
+ """
+ if self.__openGLWidget is None:
+ return 1.
+ else:
+ return self.__openGLWidget.getDevicePixelRatio()
+
+ def getOpenGLVersion(self):
+ """Returns the available OpenGL version.
+
+ :return: (major, minor)
+ :rtype: 2-tuple of int"""
+ if self.__openGLWidget is None:
+ return 0, 0
+ else:
+ return self.__openGLWidget.getOpenGLVersion()
+
+ # QOpenGLWidget API
+
+ def isValid(self):
+ """Returns True if OpenGL with the requested version is available.
+
+ :rtype: bool
+ """
+ if self.__openGLWidget is None:
+ return False
+ else:
+ return self.__openGLWidget.isValid()
+
+ def context(self):
+ """Return Qt OpenGL context object or None.
+
+ See :meth:`QOpenGLWidget.context` and :meth:`QGLWidget.context`
+ """
+ if self.__openGLWidget is None:
+ return None
+ else:
+ return self.__openGLWidget.context()
+
+ def defaultFramebufferObject(self):
+ """Returns the framebuffer object handle.
+
+ See :meth:`QOpenGLWidget.defaultFramebufferObject`
+ """
+ if self.__openGLWidget is None:
+ return 0
+ else:
+ return self.__openGLWidget.defaultFramebufferObject()
+
+ def makeCurrent(self):
+ """Make the underlying OpenGL widget's context current.
+
+ See :meth:`QOpenGLWidget.makeCurrent`
+ """
+ if self.__openGLWidget is not None:
+ self.__openGLWidget.makeCurrent()
+
+ def update(self):
+ """Async update of the OpenGL widget.
+
+ See :meth:`QOpenGLWidget.update`
+ """
+ if self.__openGLWidget is not None:
+ self.__openGLWidget.update()
+
+ # QOpenGLWidget API to override
+
+ def initializeGL(self):
+ """Override to implement OpenGL initialization."""
+ pass
+
+ def paintGL(self):
+ """Override to implement OpenGL rendering."""
+ pass
+
+ def resizeGL(self, width, height):
+ """Override to implement resize of OpenGL framebuffer.
+
+ :param int width: Width in device-independent pixels
+ :param int height: Height in device-independent pixels
+ """
+ pass
diff --git a/silx/gui/_glutils/VertexBuffer.py b/silx/gui/_glutils/VertexBuffer.py
index 689b543..b74b748 100644
--- a/silx/gui/_glutils/VertexBuffer.py
+++ b/silx/gui/_glutils/VertexBuffer.py
@@ -180,7 +180,7 @@ class VertexBufferAttrib(object):
dimension=1,
offset=0,
stride=0,
- normalisation=False):
+ normalization=False):
self.vbo = vbo
assert type_ in self._GL_TYPES
self.type_ = type_
@@ -189,7 +189,7 @@ class VertexBufferAttrib(object):
self.dimension = dimension
self.offset = offset
self.stride = stride
- self.normalisation = bool(normalisation)
+ self.normalization = bool(normalization)
@property
def itemsize(self):
@@ -200,12 +200,12 @@ class VertexBufferAttrib(object):
def setVertexAttrib(self, attribute):
"""Call glVertexAttribPointer with objects information"""
- normalisation = gl.GL_TRUE if self.normalisation else gl.GL_FALSE
+ normalization = gl.GL_TRUE if self.normalization else gl.GL_FALSE
with self.vbo:
gl.glVertexAttribPointer(attribute,
self.dimension,
self.type_,
- normalisation,
+ normalization,
self.stride,
c_void_p(self.offset))
@@ -216,7 +216,7 @@ class VertexBufferAttrib(object):
self.dimension,
self.offset,
self.stride,
- self.normalisation)
+ self.normalization)
def vertexBuffer(arrays, prefix=None, suffix=None, usage=None):
diff --git a/silx/gui/_glutils/__init__.py b/silx/gui/_glutils/__init__.py
index e86a58f..15e48e1 100644
--- a/silx/gui/_glutils/__init__.py
+++ b/silx/gui/_glutils/__init__.py
@@ -33,6 +33,7 @@ __date__ = "25/07/2016"
# OpenGL convenient functions
+from .OpenGLWidget import OpenGLWidget # noqa
from .Context import getGLContext, setGLContextGetter # noqa
from .FramebufferTexture import FramebufferTexture # noqa
from .Program import Program # noqa
diff --git a/silx/gui/_glutils/font.py b/silx/gui/_glutils/font.py
index 566ae49..2be2c04 100644
--- a/silx/gui/_glutils/font.py
+++ b/silx/gui/_glutils/font.py
@@ -98,27 +98,39 @@ def rasterText(text, font,
_logger.info("Trying to raster empty text, replaced by white space")
text = ' ' # Replace empty text by white space to produce an image
+ if (devicePixelRatio != 1.0 and
+ not hasattr(qt.QImage, 'setDevicePixelRatio')): # Qt 4
+ _logger.error('devicePixelRatio not supported')
+ devicePixelRatio = 1.0
+
if not isinstance(font, qt.QFont):
font = qt.QFont(font, size, weight, italic)
+ # get text size
+ image = qt.QImage(1, 1, qt.QImage.Format_RGB888)
+ painter = qt.QPainter()
+ painter.begin(image)
+ painter.setPen(qt.Qt.white)
+ painter.setFont(font)
+ bounds = painter.boundingRect(
+ qt.QRect(0, 0, 4096, 4096), qt.Qt.TextExpandTabs, text)
+ painter.end()
+
metrics = qt.QFontMetrics(font)
- size = metrics.size(qt.Qt.TextExpandTabs, text)
- bounds = metrics.boundingRect(
- qt.QRect(0, 0, size.width(), size.height()),
- qt.Qt.TextExpandTabs,
- text)
- if (devicePixelRatio != 1.0 and
- not hasattr(qt.QImage, 'setDevicePixelRatio')): # Qt 4
- _logger.error('devicePixelRatio not supported')
- devicePixelRatio = 1.0
+ # This does not provide the correct text bbox on macOS
+ # size = metrics.size(qt.Qt.TextExpandTabs, text)
+ # bounds = metrics.boundingRect(
+ # qt.QRect(0, 0, size.width(), size.height()),
+ # qt.Qt.TextExpandTabs,
+ # text)
# Add extra border and handle devicePixelRatio
width = bounds.width() * devicePixelRatio + 2
# align line size to 32 bits to ease conversion to numpy array
width = 4 * ((width + 3) // 4)
image = qt.QImage(width,
- bounds.height() * devicePixelRatio,
+ bounds.height() * devicePixelRatio + 2,
qt.QImage.Format_RGB888)
if (devicePixelRatio != 1.0 and
hasattr(image, 'setDevicePixelRatio')): # Qt 5
diff --git a/silx/gui/console.py b/silx/gui/console.py
index 13760b4..7812e2d 100644
--- a/silx/gui/console.py
+++ b/silx/gui/console.py
@@ -136,6 +136,8 @@ if qtconsole is None:
class IPythonWidget(RichIPythonWidget):
"""Live IPython console widget.
+ .. image:: img/IPythonWidget.png
+
:param custom_banner: Custom welcome message to be printed at the top of
the console.
"""
@@ -175,6 +177,8 @@ class IPythonDockWidget(qt.QDockWidget):
"""Dock Widget including a :class:`IPythonWidget` inside
a vertical layout.
+ .. image:: img/IPythonDockWidget.png
+
:param available_vars: Dictionary of variables to be pushed to the
console's interactive namespace: ``{"variable_name": object, …}``
:param custom_banner: Custom welcome message to be printed at the top of
diff --git a/silx/gui/data/ArrayTableModel.py b/silx/gui/data/ArrayTableModel.py
index 87a2fc1..ad4d33a 100644
--- a/silx/gui/data/ArrayTableModel.py
+++ b/silx/gui/data/ArrayTableModel.py
@@ -34,7 +34,7 @@ from silx.gui.data.TextFormatter import TextFormatter
__authors__ = ["V.A. Sole"]
__license__ = "MIT"
-__date__ = "24/01/2017"
+__date__ = "27/09/2017"
_logger = logging.getLogger(__name__)
@@ -191,7 +191,7 @@ class ArrayTableModel(qt.QAbstractTableModel):
selection = self._getIndexTuple(index.row(),
index.column())
if role == qt.Qt.DisplayRole:
- return self._formatter.toString(self._array[selection])
+ return self._formatter.toString(self._array[selection], self._array.dtype)
if role == qt.Qt.BackgroundRole and self._bgcolors is not None:
r, g, b = self._bgcolors[selection][0:3]
@@ -296,6 +296,9 @@ class ArrayTableModel(qt.QAbstractTableModel):
elif copy:
# copy requested (default)
self._array = numpy.array(data, copy=True)
+ if hasattr(data, "dtype"):
+ # Avoid to lose the monkey-patched h5py dtype
+ self._array.dtype = data.dtype
elif not _is_array(data):
raise TypeError("data is not a proper array. Try setting" +
" copy=True to convert it into a numpy array" +
diff --git a/silx/gui/data/ArrayTableWidget.py b/silx/gui/data/ArrayTableWidget.py
index ba3fa11..cb8e915 100644
--- a/silx/gui/data/ArrayTableWidget.py
+++ b/silx/gui/data/ArrayTableWidget.py
@@ -230,6 +230,8 @@ class ArrayTableWidget(qt.QWidget):
To select the perspective, use :meth:`setPerspective` or
use :meth:`setFrameAxes`.
To select the frame, use :meth:`setFrameIndex`.
+
+ .. image:: img/ArrayTableWidget.png
"""
def __init__(self, parent=None):
"""
diff --git a/silx/gui/data/DataViewer.py b/silx/gui/data/DataViewer.py
index 3a3ac64..750c654 100644
--- a/silx/gui/data/DataViewer.py
+++ b/silx/gui/data/DataViewer.py
@@ -22,8 +22,8 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""This module defines a widget designed to display data using to most adapted
-view from available ones from silx.
+"""This module defines a widget designed to display data using the most adapted
+view from the ones provided by silx.
"""
from __future__ import division
@@ -35,7 +35,7 @@ from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "26/04/2017"
+__date__ = "03/10/2017"
_logger = logging.getLogger(__name__)
@@ -144,7 +144,7 @@ class DataViewer(qt.QFrame):
DataViews._Hdf5View,
DataViews._NXdataView,
DataViews._Plot1dView,
- DataViews._Plot2dView,
+ DataViews._ImageView,
DataViews._Plot3dView,
DataViews._RawView,
DataViews._StackView,
@@ -201,7 +201,7 @@ class DataViewer(qt.QFrame):
self.__numpySelection.clear()
info = DataViews.DataInfo(self.__data)
axisNames = self.__currentView.axesNames(self.__data, info)
- if info.isArray and self.__data is not None and len(axisNames) > 0:
+ if info.isArray and info.size != 0 and self.__data is not None and axisNames is not None:
self.__useAxisSelection = True
self.__numpySelection.setAxisNames(axisNames)
self.__numpySelection.setCustomAxis(self.__currentView.customAxisNames())
diff --git a/silx/gui/data/DataViewerFrame.py b/silx/gui/data/DataViewerFrame.py
index b48fa7b..e050d4a 100644
--- a/silx/gui/data/DataViewerFrame.py
+++ b/silx/gui/data/DataViewerFrame.py
@@ -27,7 +27,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "10/04/2017"
+__date__ = "21/09/2017"
from silx.gui import qt
from .DataViewer import DataViewer
@@ -79,6 +79,14 @@ class DataViewerFrame(qt.QWidget):
"""Avoid to create views while the instance is not created."""
super(_DataViewer, self)._initializeViews()
+ def _createDefaultViews(self, parent):
+ """Expose the original `createDefaultViews` function"""
+ return super(_DataViewer, self).createDefaultViews()
+
+ def createDefaultViews(self, parent=None):
+ """Allow the DataViewerFrame to override this function"""
+ return self.parent().createDefaultViews(parent)
+
self.__dataViewer = _DataViewer(self)
# initialize views when `self.__dataViewer` is set
self.__dataViewer.initializeViews()
@@ -127,7 +135,7 @@ class DataViewerFrame(qt.QWidget):
:param QWidget parent: QWidget parent of the views
:rtype: list[silx.gui.data.DataViews.DataView]
"""
- return self.__dataViewer.createDefaultViews(parent)
+ return self.__dataViewer._createDefaultViews(parent)
def addView(self, view):
"""Allow to add a view to the dataview.
diff --git a/silx/gui/data/DataViews.py b/silx/gui/data/DataViews.py
index d8d605a..1ad997b 100644
--- a/silx/gui/data/DataViews.py
+++ b/silx/gui/data/DataViews.py
@@ -25,6 +25,7 @@
"""This module defines a views used by :class:`silx.gui.data.DataViewer`.
"""
+from collections import OrderedDict
import logging
import numbers
import numpy
@@ -34,11 +35,11 @@ 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
+from silx.io.nxdata import NXdata, get_attr_as_string
__authors__ = ["V. Valls", "P. Knobel"]
__license__ = "MIT"
-__date__ = "07/04/2017"
+__date__ = "03/10/2017"
_logger = logging.getLogger(__name__)
@@ -52,6 +53,7 @@ RAW_MODE = 40
RAW_ARRAY_MODE = 41
RAW_RECORD_MODE = 42
RAW_SCALAR_MODE = 43
+RAW_HEXA_MODE = 44
STACK_MODE = 50
HDF5_MODE = 60
@@ -62,6 +64,8 @@ def _normalizeData(data):
If the data embed a numpy data or a dataset it is returned.
Else returns the input data."""
if isinstance(data, H5Node):
+ if data.is_broken:
+ return None
return data.h5py_object
return data
@@ -89,11 +93,14 @@ class DataInfo(object):
self.isArray = False
self.interpretation = None
self.isNumeric = False
+ self.isVoid = False
self.isComplex = False
+ self.isBoolean = False
self.isRecord = False
self.isNXdata = False
self.shape = tuple()
self.dim = 0
+ self.size = 0
if data is None:
return
@@ -110,23 +117,32 @@ class DataInfo(object):
self.isArray = False
if silx.io.is_dataset(data):
- self.interpretation = data.attrs.get("interpretation", None)
+ if "interpretation" in data.attrs:
+ self.interpretation = get_attr_as_string(data, "interpretation")
+ else:
+ self.interpretation = None
elif self.isNXdata:
self.interpretation = nxd.interpretation
else:
self.interpretation = None
if hasattr(data, "dtype"):
+ if numpy.issubdtype(data.dtype, numpy.void):
+ # That's a real opaque type, else it is a structured type
+ 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.isBoolean = numpy.issubdtype(data.dtype, numpy.bool_)
elif self.isNXdata:
self.isNumeric = numpy.issubdtype(nxd.signal.dtype,
numpy.number)
self.isComplex = numpy.issubdtype(nxd.signal.dtype, numpy.complex)
+ self.isBoolean = numpy.issubdtype(nxd.signal.dtype, numpy.bool_)
else:
self.isNumeric = isinstance(data, numbers.Number)
self.isComplex = isinstance(data, numbers.Complex)
+ self.isBoolean = isinstance(data, bool)
self.isRecord = False
if hasattr(data, "shape"):
@@ -135,7 +151,13 @@ class DataInfo(object):
self.shape = nxd.signal.shape
else:
self.shape = tuple()
- self.dim = len(self.shape)
+ if self.shape is not None:
+ self.dim = len(self.shape)
+
+ if hasattr(data, "size"):
+ self.size = int(data.size)
+ else:
+ self.size = 1
def normalizeData(self, data):
"""Returns a normalized data if the embed a numpy or a dataset.
@@ -237,12 +259,12 @@ class DataView(object):
def axesNames(self, data, info):
"""Returns names of the expected axes of the view, according to the
- input data.
+ input data. A none value will disable the default axes selectior.
:param data: Data to display
:type data: numpy.ndarray or h5py.Dataset
:param DataInfo info: Pre-computed information on the data
- :rtype: list[str]
+ :rtype: list[str] or None
"""
return []
@@ -276,7 +298,7 @@ class CompositeDataView(DataView):
:param qt.QWidget parent: Parent of the hold widget
"""
super(CompositeDataView, self).__init__(parent, modeId, icon, label)
- self.__views = {}
+ self.__views = OrderedDict()
self.__currentView = None
def addView(self, dataView):
@@ -285,10 +307,9 @@ class CompositeDataView(DataView):
def getBestView(self, data, info):
"""Returns the best view according to priorities."""
- info = DataInfo(data)
views = [(v.getDataPriority(data, info), v) for v in self.__views.keys()]
views = filter(lambda t: t[0] > DataView.UNSUPPORTED, views)
- views = sorted(views, reverse=True)
+ views = sorted(views, key=lambda t: t[0], reverse=True)
if len(views) == 0:
return None
@@ -361,7 +382,7 @@ class _EmptyView(DataView):
DataView.__init__(self, parent, modeId=EMPTY_MODE)
def axesNames(self, data, info):
- return []
+ return None
def createWidget(self, parent):
return qt.QLabel(parent)
@@ -406,6 +427,8 @@ class _Plot1dView(DataView):
return ["y"]
def getDataPriority(self, data, info):
+ if info.size <= 0:
+ return DataView.UNSUPPORTED
if data is None or not info.isArray or not info.isNumeric:
return DataView.UNSUPPORTED
if info.dim < 1:
@@ -434,9 +457,10 @@ class _Plot2dView(DataView):
def createWidget(self, parent):
from silx.gui import plot
widget = plot.Plot2D(parent=parent)
+ widget.getIntensityHistogramAction().setVisible(True)
widget.setKeepDataAspectRatio(True)
- widget.setGraphXLabel('X')
- widget.setGraphYLabel('Y')
+ widget.getXAxis().setLabel('X')
+ widget.getYAxis().setLabel('Y')
return widget
def clear(self):
@@ -459,7 +483,11 @@ class _Plot2dView(DataView):
return ["y", "x"]
def getDataPriority(self, data, info):
- if data is None or not info.isArray or not info.isNumeric:
+ if info.size <= 0:
+ return DataView.UNSUPPORTED
+ if (data is None or
+ not info.isArray or
+ not (info.isNumeric or info.isBoolean)):
return DataView.UNSUPPORTED
if info.dim < 2:
return DataView.UNSUPPORTED
@@ -494,8 +522,15 @@ class _Plot3dView(DataView):
plot = ScalarFieldView.ScalarFieldView(parent)
plot.setAxesLabels(*reversed(self.axesNames(None, None)))
- plot.addIsosurface(
- lambda data: numpy.mean(data) + numpy.std(data), '#FF0000FF')
+
+ def computeIsolevel(data):
+ data = data[numpy.isfinite(data)]
+ if len(data) == 0:
+ return 0
+ else:
+ return numpy.mean(data) + numpy.std(data)
+
+ plot.addIsosurface(computeIsolevel, '#FF0000FF')
# Create a parameter tree for the scalar field view
options = SFViewParamTree.TreeView(plot)
@@ -527,6 +562,8 @@ class _Plot3dView(DataView):
return ["z", "y", "x"]
def getDataPriority(self, data, info):
+ if info.size <= 0:
+ return DataView.UNSUPPORTED
if data is None or not info.isArray or not info.isNumeric:
return DataView.UNSUPPORTED
if info.dim < 3:
@@ -539,6 +576,54 @@ class _Plot3dView(DataView):
return 10
+class _ComplexImageView(DataView):
+ """View displaying data using a ComplexImageView"""
+
+ def __init__(self, parent):
+ super(_ComplexImageView, self).__init__(
+ parent=parent,
+ modeId=PLOT2D_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.getPlot().getIntensityHistogramAction().setVisible(True)
+ widget.getPlot().setKeepDataAspectRatio(True)
+ widget.getXAxis().setLabel('X')
+ widget.getYAxis().setLabel('Y')
+ return widget
+
+ def clear(self):
+ self.getWidget().setData(None)
+
+ def normalizeData(self, data):
+ data = DataView.normalizeData(self, data)
+ return data
+
+ def setData(self, data):
+ data = self.normalizeData(data)
+ self.getWidget().setData(data)
+
+ def axesNames(self, data, info):
+ return ["y", "x"]
+
+ def getDataPriority(self, data, info):
+ if info.size <= 0:
+ return DataView.UNSUPPORTED
+ if data is None or not info.isArray or not info.isComplex:
+ return DataView.UNSUPPORTED
+ if info.dim < 2:
+ return DataView.UNSUPPORTED
+ if info.interpretation == "image":
+ return 1000
+ if info.dim == 2:
+ return 200
+ else:
+ return 190
+
+
class _ArrayView(DataView):
"""View displaying data using a 2d table"""
@@ -562,6 +647,8 @@ class _ArrayView(DataView):
return ["col", "row"]
def getDataPriority(self, data, info):
+ if info.size <= 0:
+ return DataView.UNSUPPORTED
if data is None or not info.isArray or info.isRecord:
return DataView.UNSUPPORTED
if info.dim < 2:
@@ -618,6 +705,8 @@ class _StackView(DataView):
return ["depth", "y", "x"]
def getDataPriority(self, data, info):
+ if info.size <= 0:
+ return DataView.UNSUPPORTED
if data is None or not info.isArray or not info.isNumeric:
return DataView.UNSUPPORTED
if info.dim < 3:
@@ -644,17 +733,21 @@ class _ScalarView(DataView):
self.getWidget().setText("")
def setData(self, data):
- data = self.normalizeData(data)
- if silx.io.is_dataset(data):
- data = data[()]
- text = self.__formatter.toString(data)
+ d = self.normalizeData(data)
+ if silx.io.is_dataset(d):
+ d = d[()]
+ text = self.__formatter.toString(d, data.dtype)
self.getWidget().setText(text)
def axesNames(self, data, info):
return []
def getDataPriority(self, data, info):
+ if info.size <= 0:
+ return DataView.UNSUPPORTED
data = self.normalizeData(data)
+ if info.shape is None:
+ return DataView.UNSUPPORTED
if data is None:
return DataView.UNSUPPORTED
if silx.io.is_group(data):
@@ -681,13 +774,16 @@ class _RecordView(DataView):
data = self.normalizeData(data)
widget = self.getWidget()
widget.setArrayData(data)
- widget.resizeRowsToContents()
- widget.resizeColumnsToContents()
+ if len(data) < 100:
+ widget.resizeRowsToContents()
+ widget.resizeColumnsToContents()
def axesNames(self, data, info):
return ["data"]
def getDataPriority(self, data, info):
+ if info.size <= 0:
+ return DataView.UNSUPPORTED
if info.isRecord:
return 40
if data is None or not info.isArray:
@@ -703,6 +799,36 @@ class _RecordView(DataView):
return DataView.UNSUPPORTED
+class _HexaView(DataView):
+ """View displaying data using text"""
+
+ def __init__(self, parent):
+ DataView.__init__(self, parent, modeId=RAW_HEXA_MODE)
+
+ def createWidget(self, parent):
+ from .HexaTableView import HexaTableView
+ widget = HexaTableView(parent)
+ return widget
+
+ def clear(self):
+ self.getWidget().setArrayData(None)
+
+ def setData(self, data):
+ data = self.normalizeData(data)
+ widget = self.getWidget()
+ widget.setArrayData(data)
+
+ def axesNames(self, data, info):
+ return []
+
+ def getDataPriority(self, data, info):
+ if info.size <= 0:
+ return DataView.UNSUPPORTED
+ if info.isVoid:
+ return 2000
+ return DataView.UNSUPPORTED
+
+
class _Hdf5View(DataView):
"""View displaying data using text"""
@@ -727,7 +853,7 @@ class _Hdf5View(DataView):
widget.setData(data)
def axesNames(self, data, info):
- return []
+ return None
def getDataPriority(self, data, info):
widget = self.getWidget()
@@ -750,11 +876,28 @@ class _RawView(CompositeDataView):
modeId=RAW_MODE,
label="Raw",
icon=icons.getQIcon("view-raw"))
+ self.addView(_HexaView(parent))
self.addView(_ScalarView(parent))
self.addView(_ArrayView(parent))
self.addView(_RecordView(parent))
+class _ImageView(CompositeDataView):
+ """View displaying data as 2D image
+
+ It choose between Plot2D and ComplexImageView widgets
+ """
+
+ def __init__(self, parent):
+ super(_ImageView, self).__init__(
+ parent=parent,
+ modeId=PLOT2D_MODE,
+ label="Image",
+ icon=icons.getQIcon("view-2d"))
+ self.addView(_ComplexImageView(parent))
+ self.addView(_Plot2dView(parent))
+
+
class _NXdataScalarView(DataView):
"""DataView using a table view for displaying NXdata scalars:
0-D signal or n-D signal with *@interpretation=scalar*"""
@@ -806,7 +949,7 @@ class _NXdataCurveView(DataView):
def axesNames(self, data, info):
# disabled (used by default axis selector widget in Hdf5Viewer)
- return []
+ return None
def clear(self):
self.getWidget().clear()
@@ -814,10 +957,10 @@ class _NXdataCurveView(DataView):
def setData(self, data):
data = self.normalizeData(data)
nxd = NXdata(data)
- signal_name = data.attrs["signal"]
+ signal_name = get_attr_as_string(data, "signal")
group_name = data.name
- if nxd.axes_names[-1] is not None:
- x_errors = nxd.get_axis_errors(nxd.axes_names[-1])
+ if nxd.axes_dataset_names[-1] is not None:
+ x_errors = nxd.get_axis_errors(nxd.axes_dataset_names[-1])
else:
x_errors = None
@@ -853,7 +996,7 @@ class _NXdataXYVScatterView(DataView):
def axesNames(self, data, info):
# disabled (used by default axis selector widget in Hdf5Viewer)
- return []
+ return None
def clear(self):
self.getWidget().clear()
@@ -861,7 +1004,7 @@ class _NXdataXYVScatterView(DataView):
def setData(self, data):
data = self.normalizeData(data)
nxd = NXdata(data)
- signal_name = data.attrs["signal"]
+ signal_name = get_attr_as_string(data, "signal")
# signal_errors = nx.errors # not supported
group_name = data.name
x_axis, y_axis = nxd.axes[-2:]
@@ -902,7 +1045,8 @@ class _NXdataImageView(DataView):
return widget
def axesNames(self, data, info):
- return []
+ # disabled (used by default axis selector widget in Hdf5Viewer)
+ return None
def clear(self):
self.getWidget().clear()
@@ -910,7 +1054,7 @@ class _NXdataImageView(DataView):
def setData(self, data):
data = self.normalizeData(data)
nxd = NXdata(data)
- signal_name = data.attrs["signal"]
+ 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:]
@@ -942,7 +1086,8 @@ class _NXdataStackView(DataView):
return widget
def axesNames(self, data, info):
- return []
+ # disabled (used by default axis selector widget in Hdf5Viewer)
+ return None
def clear(self):
self.getWidget().clear()
@@ -950,7 +1095,7 @@ class _NXdataStackView(DataView):
def setData(self, data):
data = self.normalizeData(data)
nxd = NXdata(data)
- signal_name = data.attrs["signal"]
+ signal_name = get_attr_as_string(data, "signal")
group_name = data.name
z_axis, y_axis, x_axis = nxd.axes[-3:]
z_label, y_label, x_label = nxd.axes_names[-3:]
diff --git a/silx/gui/data/Hdf5TableView.py b/silx/gui/data/Hdf5TableView.py
index 5d79907..ba737e3 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__ = "07/04/2017"
+__date__ = "29/09/2017"
import functools
import os.path
@@ -40,6 +40,13 @@ import silx.io
from .TextFormatter import TextFormatter
import silx.gui.hdf5
from silx.gui.widgets import HierarchicalTableView
+from ..hdf5.Hdf5Formatter import Hdf5Formatter
+
+try:
+ import h5py
+except ImportError:
+ h5py = None
+
_logger = logging.getLogger(__name__)
@@ -177,6 +184,7 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
self.__obj = None
self.__data = _TableData(columnCount=4)
self.__formatter = None
+ self.__hdf5Formatter = Hdf5Formatter(self)
formatter = TextFormatter(self)
self.setFormatter(formatter)
self.setObject(data)
@@ -207,7 +215,7 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
value = cell.value()
if callable(value):
value = value(self.__obj)
- return str(value)
+ return value
return None
def flags(self, index):
@@ -248,6 +256,22 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
else:
self.reset()
+ def __formatHdf5Type(self, dataset):
+ """Format the HDF5 type"""
+ return self.__hdf5Formatter.humanReadableHdf5Type(dataset)
+
+ def __formatDType(self, dataset):
+ """Format the numpy dtype"""
+ return self.__hdf5Formatter.humanReadableType(dataset, full=True)
+
+ def __formatShape(self, dataset):
+ """Format the shape"""
+ if dataset.shape is None or len(dataset.shape) <= 1:
+ return self.__hdf5Formatter.humanReadableShape(dataset)
+ size = dataset.size
+ shape = self.__hdf5Formatter.humanReadableShape(dataset)
+ return u"%s = %s" % (shape, size)
+
def __initProperties(self):
"""Initialize the list of available properties according to the defined
h5py-like object."""
@@ -270,26 +294,48 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
else:
objectType = obj.__class__.__name__
self.__data.addHeaderRow(headerLabel="HDF5 %s" % objectType)
- self.__data.addHeaderRow(headerLabel="Path info")
- self.__data.addHeaderValueRow("basename", lambda x: os.path.basename(x.name))
- self.__data.addHeaderValueRow("name", lambda x: x.name)
- if silx.io.is_file(obj):
- self.__data.addHeaderValueRow("filename", lambda x: x.filename)
+ SEPARATOR = "::"
+ self.__data.addHeaderRow(headerLabel="Path info")
if isinstance(obj, silx.gui.hdf5.H5Node):
# helpful informations if the object come from an HDF5 tree
- self.__data.addHeaderValueRow("local_basename", lambda x: x.local_basename)
- self.__data.addHeaderValueRow("local_name", lambda x: x.local_name)
- self.__data.addHeaderValueRow("local_filename", lambda x: x.local_file.filename)
+ self.__data.addHeaderValueRow("Basename", lambda x: x.local_basename)
+ self.__data.addHeaderValueRow("Name", lambda x: x.local_name)
+ local = lambda x: x.local_filename + SEPARATOR + x.local_name
+ self.__data.addHeaderValueRow("Local", local)
+ physical = lambda x: x.physical_filename + SEPARATOR + x.physical_name
+ self.__data.addHeaderValueRow("Physical", physical)
+ else:
+ # it's a real H5py object
+ self.__data.addHeaderValueRow("Basename", lambda x: os.path.basename(x.name))
+ self.__data.addHeaderValueRow("Name", lambda x: x.name)
+ self.__data.addHeaderValueRow("File", lambda x: x.file.filename)
+
+ if hasattr(obj, "path"):
+ # That's a link
+ if hasattr(obj, "filename"):
+ link = lambda x: x.filename + SEPARATOR + x.path
+ else:
+ link = lambda x: x.path
+ self.__data.addHeaderValueRow("Link", link)
+ else:
+ if silx.io.is_file(obj):
+ physical = lambda x: x.filename + SEPARATOR + x.name
+ else:
+ physical = lambda x: x.file.filename + SEPARATOR + x.name
+ self.__data.addHeaderValueRow("Physical", physical)
if hasattr(obj, "dtype"):
+
self.__data.addHeaderRow(headerLabel="Data info")
- self.__data.addHeaderValueRow("dtype", lambda x: x.dtype)
+
+ if h5py is not None and hasattr(obj, "id"):
+ # display the HDF5 type
+ self.__data.addHeaderValueRow("HDF5 type", self.__formatHdf5Type)
+ self.__data.addHeaderValueRow("dtype", self.__formatDType)
if hasattr(obj, "shape"):
- self.__data.addHeaderValueRow("shape", lambda x: x.shape)
- if hasattr(obj, "size"):
- self.__data.addHeaderValueRow("size", lambda x: x.size)
+ self.__data.addHeaderValueRow("shape", self.__formatShape)
if hasattr(obj, "chunks") and obj.chunks is not None:
self.__data.addHeaderValueRow("chunks", lambda x: x.chunks)
@@ -354,6 +400,8 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
if formatter is self.__formatter:
return
+ self.__hdf5Formatter.setTextFormatter(formatter)
+
if qt.qVersion() > "4.6":
self.beginResetModel()
diff --git a/silx/gui/data/HexaTableView.py b/silx/gui/data/HexaTableView.py
new file mode 100644
index 0000000..1b2a7e9
--- /dev/null
+++ b/silx/gui/data/HexaTableView.py
@@ -0,0 +1,278 @@
+# 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 defines model and widget to display raw data using an
+hexadecimal viewer.
+"""
+from __future__ import division
+
+import numpy
+import collections
+from silx.gui import qt
+import silx.io.utils
+from silx.third_party import six
+from silx.gui.widgets.TableWidget import CopySelectedCellsAction
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "27/09/2017"
+
+
+class _VoidConnector(object):
+ """Byte connector to a numpy.void data.
+
+ It uses a cache of 32 x 1KB and a direct read access API from HDF5.
+ """
+
+ def __init__(self, data):
+ self.__cache = collections.OrderedDict()
+ self.__len = data.itemsize
+ self.__data = data
+
+ def __getBuffer(self, bufferId):
+ if bufferId not in self.__cache:
+ pos = bufferId << 10
+ data = self.__data.tobytes()[pos:pos + 1024]
+ self.__cache[bufferId] = data
+ if len(self.__cache) > 32:
+ self.__cache.popitem()
+ else:
+ data = self.__cache[bufferId]
+ return data
+
+ def __getitem__(self, pos):
+ """Returns the value of the byte at the given position.
+
+ :param uint pos: Position of the byte
+ :rtype: int
+ """
+ bufferId = pos >> 10
+ bufferPos = pos & 0b1111111111
+ data = self.__getBuffer(bufferId)
+ value = data[bufferPos]
+ if six.PY2:
+ return ord(value)
+ else:
+ return value
+
+ def __len__(self):
+ """
+ Returns the number of available bytes.
+
+ :rtype: uint
+ """
+ return self.__len
+
+
+class HexaTableModel(qt.QAbstractTableModel):
+ """This data model provides access to a numpy void data.
+
+ Bytes are displayed one by one as a hexadecimal viewer.
+
+ The 16th first columns display bytes as hexadecimal, the last column
+ displays the same data as ASCII.
+
+ :param qt.QObject parent: Parent object
+ :param data: A numpy array or a h5py dataset
+ """
+ def __init__(self, parent=None, data=None):
+ qt.QAbstractTableModel.__init__(self, parent)
+
+ self.__data = None
+ self.__connector = None
+ self.setArrayData(data)
+
+ if hasattr(qt.QFontDatabase, "systemFont"):
+ self.__font = qt.QFontDatabase.systemFont(qt.QFontDatabase.FixedFont)
+ else:
+ self.__font = qt.QFont("Monospace")
+ self.__font.setStyleHint(qt.QFont.TypeWriter)
+ self.__palette = qt.QPalette()
+
+ def rowCount(self, parent_idx=None):
+ """Returns number of rows to be displayed in table"""
+ if self.__connector is None:
+ return 0
+ return ((len(self.__connector) - 1) >> 4) + 1
+
+ def columnCount(self, parent_idx=None):
+ """Returns number of columns to be displayed in table"""
+ return 0x10 + 1
+
+ def data(self, index, role=qt.Qt.DisplayRole):
+ """QAbstractTableModel method to access data values
+ in the format ready to be displayed"""
+ if not index.isValid():
+ return None
+
+ if self.__connector is None:
+ return None
+
+ row = index.row()
+ column = index.column()
+
+ if role == qt.Qt.DisplayRole:
+ if column == 0x10:
+ start = (row << 4)
+ text = ""
+ for i in range(0x10):
+ pos = start + i
+ if pos >= len(self.__connector):
+ break
+ value = self.__connector[pos]
+ if value > 0x20 and value < 0x7F:
+ text += chr(value)
+ else:
+ text += "."
+ return text
+ else:
+ pos = (row << 4) + column
+ if pos < len(self.__connector):
+ value = self.__connector[pos]
+ return "%02X" % value
+ else:
+ return ""
+ elif role == qt.Qt.FontRole:
+ return self.__font
+
+ elif role == qt.Qt.BackgroundColorRole:
+ pos = (row << 4) + column
+ if column != 0x10 and pos >= len(self.__connector):
+ return self.__palette.color(qt.QPalette.Disabled, qt.QPalette.Background)
+ else:
+ return None
+
+ return None
+
+ def headerData(self, section, orientation, role=qt.Qt.DisplayRole):
+ """Returns the 0-based row or column index, for display in the
+ horizontal and vertical headers"""
+ if section == -1:
+ # PyQt4 send -1 when there is columns but no rows
+ return None
+
+ if role == qt.Qt.DisplayRole:
+ if orientation == qt.Qt.Vertical:
+ return "%02X" % (section << 4)
+ if orientation == qt.Qt.Horizontal:
+ if section == 0x10:
+ return "ASCII"
+ else:
+ return "%02X" % section
+ elif role == qt.Qt.FontRole:
+ return self.__font
+ elif role == qt.Qt.TextAlignmentRole:
+ if orientation == qt.Qt.Vertical:
+ return qt.Qt.AlignRight
+ if orientation == qt.Qt.Horizontal:
+ if section == 0x10:
+ return qt.Qt.AlignLeft
+ else:
+ return qt.Qt.AlignCenter
+ return None
+
+ def flags(self, index):
+ """QAbstractTableModel method to inform the view whether data
+ is editable or not.
+ """
+ row = index.row()
+ column = index.column()
+ pos = (row << 4) + column
+ if column != 0x10 and pos >= len(self.__connector):
+ return qt.Qt.NoItemFlags
+ return qt.QAbstractTableModel.flags(self, index)
+
+ def setArrayData(self, data):
+ """Set the data array.
+
+ :param data: A numpy object or a dataset.
+ """
+ if qt.qVersion() > "4.6":
+ self.beginResetModel()
+
+ self.__connector = None
+ self.__data = data
+ if self.__data is not None:
+ if silx.io.utils.is_dataset(self.__data):
+ data = data[()]
+ elif isinstance(self.__data, numpy.ndarray):
+ data = data[()]
+ self.__connector = _VoidConnector(data)
+
+ if qt.qVersion() > "4.6":
+ self.endResetModel()
+ else:
+ self.reset()
+
+ def arrayData(self):
+ """Returns the internal data.
+
+ :rtype: numpy.ndarray of h5py.Dataset
+ """
+ return self.__data
+
+
+class HexaTableView(qt.QTableView):
+ """TableView using HexaTableModel as default model.
+
+ It customs the column size to provide a better layout.
+ """
+ def __init__(self, parent=None):
+ """
+ Constructor
+
+ :param qt.QWidget parent: parent QWidget
+ """
+ qt.QTableView.__init__(self, parent)
+
+ model = HexaTableModel(self)
+ self.setModel(model)
+ self._copyAction = CopySelectedCellsAction(self)
+ self.addAction(self._copyAction)
+
+ def copy(self):
+ self._copyAction.trigger()
+
+ def setArrayData(self, data):
+ """Set the data array.
+
+ :param data: A numpy object or a dataset.
+ """
+ self.model().setArrayData(data)
+ self.__fixHeader()
+
+ def __fixHeader(self):
+ """Update the view according to the state of the auto-resize"""
+ header = self.horizontalHeader()
+ if qt.qVersion() < "5.0":
+ setResizeMode = header.setResizeMode
+ else:
+ setResizeMode = header.setSectionResizeMode
+
+ header.setDefaultSectionSize(30)
+ header.setStretchLastSection(True)
+ for i in range(0x10):
+ setResizeMode(i, qt.QHeaderView.Fixed)
+ setResizeMode(0x10, qt.QHeaderView.Stretch)
diff --git a/silx/gui/data/NXdataWidgets.py b/silx/gui/data/NXdataWidgets.py
index 343c7f9..b820380 100644
--- a/silx/gui/data/NXdataWidgets.py
+++ b/silx/gui/data/NXdataWidgets.py
@@ -26,7 +26,7 @@
"""
__authors__ = ["P. Knobel"]
__license__ = "MIT"
-__date__ = "20/03/2017"
+__date__ = "27/06/2017"
import numpy
@@ -135,8 +135,8 @@ class ArrayCurvePlot(qt.QWidget):
self.selectorDock.show()
self._plot.setGraphTitle(title or "")
- self._plot.setGraphXLabel(self.__axis_name or "X")
- self._plot.setGraphYLabel(self.__signal_name or "Y")
+ self._plot.getXAxis().setLabel(self.__axis_name or "X")
+ self._plot.getYAxis().setLabel(self.__signal_name or "Y")
self._updateCurve()
if not self.__selector_is_connected:
@@ -188,8 +188,8 @@ class ArrayCurvePlot(qt.QWidget):
xerror=self.__axis_errors,
yerror=y_errors)
self._plot.resetZoom()
- self._plot.setGraphXLabel(self.__axis_name)
- self._plot.setGraphYLabel(self.__signal_name)
+ self._plot.getXAxis().setLabel(self.__axis_name)
+ self._plot.getYAxis().setLabel(self.__signal_name)
def clear(self):
self._plot.clear()
@@ -289,8 +289,8 @@ class ArrayImagePlot(qt.QWidget):
self.selectorDock.show()
self._plot.setGraphTitle(title or "")
- self._plot.setGraphXLabel(self.__x_axis_name or "X")
- self._plot.setGraphYLabel(self.__y_axis_name or "Y")
+ self._plot.getXAxis().setLabel(self.__x_axis_name or "X")
+ self._plot.getYAxis().setLabel(self.__y_axis_name or "Y")
self._updateImage()
@@ -352,8 +352,8 @@ class ArrayImagePlot(qt.QWidget):
numpy.ravel(scattery),
numpy.ravel(img),
legend=legend)
- self._plot.setGraphXLabel(self.__x_axis_name)
- self._plot.setGraphYLabel(self.__y_axis_name)
+ self._plot.getXAxis().setLabel(self.__x_axis_name)
+ self._plot.getYAxis().setLabel(self.__y_axis_name)
self._plot.resetZoom()
def clear(self):
@@ -450,8 +450,8 @@ class ArrayStackPlot(qt.QWidget):
self._stack_view.setGraphTitle(title or "")
# by default, the z axis is the image position (dimension not plotted)
- self._stack_view.setGraphXLabel(self.__x_axis_name or "X")
- self._stack_view.setGraphYLabel(self.__y_axis_name or "Y")
+ self._stack_view.getPlot().getXAxis().setLabel(self.__x_axis_name or "X")
+ self._stack_view.getPlot().getYAxis().setLabel(self.__y_axis_name or "Y")
self._updateStack()
diff --git a/silx/gui/data/RecordTableView.py b/silx/gui/data/RecordTableView.py
index ce6a178..54881b7 100644
--- a/silx/gui/data/RecordTableView.py
+++ b/silx/gui/data/RecordTableView.py
@@ -37,7 +37,7 @@ from silx.gui.widgets.TableWidget import CopySelectedCellsAction
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "27/01/2017"
+__date__ = "02/10/2017"
class _MultiLineItem(qt.QItemDelegate):
@@ -206,9 +206,9 @@ class RecordTableModel(qt.QAbstractTableModel):
data = data[key[1]]
if role == qt.Qt.DisplayRole:
- return self.__formatter.toString(data)
+ return self.__formatter.toString(data, dtype=self.__data.dtype)
elif role == qt.Qt.EditRole:
- return self.__editFormatter.toString(data)
+ return self.__editFormatter.toString(data, dtype=self.__data.dtype)
return None
def headerData(self, section, orientation, role=qt.Qt.DisplayRole):
@@ -270,11 +270,11 @@ class RecordTableModel(qt.QAbstractTableModel):
else:
self.__is_array = False
-
self.__fields = []
if data is not None:
if data.dtype.fields is not None:
- for name, (dtype, _index) in data.dtype.fields.items():
+ fields = sorted(data.dtype.fields.items(), key=lambda e: e[1][1])
+ for name, (dtype, _index) in fields:
if dtype.shape != tuple():
keys = itertools.product(*[range(x) for x in dtype.shape])
for key in keys:
diff --git a/silx/gui/data/TextFormatter.py b/silx/gui/data/TextFormatter.py
index f074de5..37e1f48 100644
--- a/silx/gui/data/TextFormatter.py
+++ b/silx/gui/data/TextFormatter.py
@@ -27,14 +27,18 @@ data module to format data as text in the same way."""
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "26/04/2017"
+__date__ = "27/09/2017"
import numpy
import numbers
-import binascii
from silx.third_party import six
from silx.gui import qt
+try:
+ import h5py
+except ImportError:
+ h5py = None
+
class TextFormatter(qt.QObject):
"""Formatter to convert data to string.
@@ -73,11 +77,13 @@ class TextFormatter(qt.QObject):
self.__floatFormat = formatter.floatFormat()
self.__useQuoteForText = formatter.useQuoteForText()
self.__imaginaryUnit = formatter.imaginaryUnit()
+ self.__enumFormat = formatter.enumFormat()
else:
self.__integerFormat = "%d"
self.__floatFormat = "%g"
self.__useQuoteForText = True
self.__imaginaryUnit = u"j"
+ self.__enumFormat = u"%(name)s(%(value)d)"
def integerFormat(self):
"""Returns the format string controlling how the integer data
@@ -162,40 +168,151 @@ class TextFormatter(qt.QObject):
self.__imaginaryUnit = imaginaryUnit
self.formatChanged.emit()
- def toString(self, data):
+ def setEnumFormat(self, value):
+ """Set format string controlling how the enum data are
+ formated by this object.
+
+ :param str value: Format string (e.g. "%(name)s(%(value)d)").
+ This is the C-style format string used by python when formatting
+ strings with the modulus operator.
+ """
+ if self.__enumFormat == value:
+ return
+ self.__enumFormat = value
+ self.formatChanged.emit()
+
+ def enumFormat(self):
+ """Returns the format string controlling how the enum data
+ are formated by this object.
+
+ This is the C-style format string used by python when formatting
+ strings with the modulus operator.
+
+ :rtype: str
+ """
+ return self.__enumFormat
+
+ def __formatText(self, text):
+ if self.__useQuoteForText:
+ text = "\"%s\"" % text.replace("\\", "\\\\").replace("\"", "\\\"")
+ return text
+
+ def __formatBinary(self, data):
+ if isinstance(data, numpy.void):
+ if six.PY2:
+ data = [ord(d) for d in data.item()]
+ else:
+ data = data.item().astype(numpy.uint8)
+ else:
+ data = [ord(d) for d in data]
+ data = ["\\x%02X" % d for d in data]
+ if self.__useQuoteForText:
+ return "b\"%s\"" % "".join(data)
+ else:
+ return "".join(data)
+
+ def __formatSafeAscii(self, data):
+ if six.PY2:
+ data = [ord(d) for d in data]
+ data = [chr(d) if (d > 0x20 and d < 0x7F) else "\\x%02X" % d for d in data]
+ if self.__useQuoteForText:
+ data = [c if c != '"' else "\\" + c for c in data]
+ return "b\"%s\"" % "".join(data)
+ else:
+ return "".join(data)
+
+ def __formatH5pyObject(self, data, dtype):
+ # That's an HDF5 object
+ ref = h5py.check_dtype(ref=dtype)
+ if ref is not None:
+ if bool(data):
+ return "REF"
+ else:
+ return "NULL_REF"
+ vlen = h5py.check_dtype(vlen=dtype)
+ if vlen is not None:
+ if vlen == six.text_type:
+ # HDF5 UTF8
+ 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 None
+
+ def toString(self, data, dtype=None):
"""Format a data into a string using formatter options
:param object data: Data to render
+ :param dtype: enforce a dtype (mostly used to remember the h5py dtype,
+ special h5py dtypes are not propagated from array to items)
:rtype: str
"""
if isinstance(data, tuple):
text = [self.toString(d) for d in data]
return "(" + " ".join(text) + ")"
- elif isinstance(data, (list, numpy.ndarray)):
+ elif isinstance(data, list):
text = [self.toString(d) for d in data]
return "[" + " ".join(text) + "]"
+ elif isinstance(data, (numpy.ndarray)):
+ if dtype is None:
+ dtype = data.dtype
+ if data.shape == ():
+ # it is a scaler
+ return self.toString(data[()], dtype)
+ else:
+ text = [self.toString(d, dtype) for d in data]
+ return "[" + " ".join(text) + "]"
elif isinstance(data, numpy.void):
- dtype = data.dtype
+ if dtype is None:
+ dtype = data.dtype
if data.dtype.fields is not None:
- text = [self.toString(data[f]) for f in dtype.fields]
+ text = [self.toString(data[f], dtype) for f in dtype.fields]
return "(" + " ".join(text) + ")"
- return "0x" + binascii.hexlify(data).decode("ascii")
- elif isinstance(data, (numpy.string_, numpy.object_, bytes)):
- # This have to be done before checking python string inheritance
+ return self.__formatBinary(data)
+ elif isinstance(data, (numpy.unicode_, six.text_type)):
+ return self.__formatText(data)
+ elif isinstance(data, (numpy.string_, six.binary_type)):
+ 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)
+ elif dtype.kind == 'O':
+ if h5py is not None:
+ text = self.__formatH5pyObject(data, dtype)
+ if text is not None:
+ return text
try:
+ # Try ascii/utf-8
text = "%s" % data.decode("utf-8")
- if self.__useQuoteForText:
- text = "\"%s\"" % text.replace("\"", "\\\"")
- return text
+ return self.__formatText(text)
except UnicodeDecodeError:
pass
- return "0x" + binascii.hexlify(data).decode("ascii")
+ return self.__formatBinary(data)
elif isinstance(data, six.string_types):
text = "%s" % data
- if self.__useQuoteForText:
- text = "\"%s\"" % text.replace("\"", "\\\"")
- return text
- elif isinstance(data, (numpy.integer, numbers.Integral)):
+ return self.__formatText(text)
+ elif isinstance(data, (numpy.integer)):
+ if dtype is None:
+ dtype = data.dtype
+ if h5py is not None:
+ enumType = h5py.check_dtype(enum=dtype)
+ if enumType is not None:
+ for key, value in enumType.items():
+ if value == data:
+ result = {}
+ result["name"] = key
+ result["value"] = data
+ return self.__enumFormat % result
+ return self.__integerFormat % data
+ elif isinstance(data, (numbers.Integral)):
return self.__integerFormat % data
elif isinstance(data, (numbers.Real, numpy.floating)):
# It have to be done before complex checking
@@ -219,4 +336,21 @@ class TextFormatter(qt.QObject):
template = self.__floatFormat
params = (data.real)
return template % params
+ elif h5py is not None and isinstance(data, h5py.h5r.Reference):
+ dtype = h5py.special_dtype(ref=h5py.Reference)
+ text = self.__formatH5pyObject(data, dtype)
+ return text
+ elif h5py is not None and isinstance(data, h5py.h5r.RegionReference):
+ dtype = h5py.special_dtype(ref=h5py.RegionReference)
+ text = self.__formatH5pyObject(data, dtype)
+ return text
+ elif isinstance(data, numpy.object_) or dtype is not None:
+ if dtype is None:
+ dtype = data.dtype
+ if h5py is not None:
+ text = self.__formatH5pyObject(data, dtype)
+ if text is not None:
+ return text
+ # That's a numpy object
+ return str(data)
return str(data)
diff --git a/silx/gui/data/test/test_dataviewer.py b/silx/gui/data/test/test_dataviewer.py
index 5a0de0b..dd3114a 100644
--- a/silx/gui/data/test/test_dataviewer.py
+++ b/silx/gui/data/test/test_dataviewer.py
@@ -24,7 +24,7 @@
# ###########################################################################*/
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "10/04/2017"
+__date__ = "22/08/2017"
import os
import tempfile
@@ -42,8 +42,6 @@ from silx.gui.data.DataViewerFrame import DataViewerFrame
from silx.gui.test.utils import SignalListener
from silx.gui.test.utils import TestCaseQt
-from silx.gui.hdf5.test import _mock
-
try:
import h5py
except ImportError:
@@ -111,6 +109,24 @@ class AbstractDataViewerTests(TestCaseQt):
self.assertEqual(DataViewer.RAW_MODE, widget.displayMode())
self.assertIn(DataViewer.PLOT2D_MODE, availableModes)
+ def test_plot_2d_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)
+
+ def test_plot_2d_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)
+
def test_plot_3d_data(self):
data = numpy.arange(3 ** 3)
data.shape = [3] * 3
@@ -212,6 +228,7 @@ class AbstractDataViewerTests(TestCaseQt):
self.assertTrue(view not in widget.availableViews())
self.assertTrue(view not in widget.currentAvailableViews())
+
class TestDataViewer(AbstractDataViewerTests):
def create_widget(self):
return DataViewer()
@@ -225,11 +242,10 @@ class TestDataViewerFrame(AbstractDataViewerTests):
class TestDataView(TestCaseQt):
def createComplexData(self):
- line = [1, 2j, 3+3j, 4]
+ line = [1, 2j, 3 + 3j, 4]
image = [line, line, line, line]
cube = [image, image, image, image]
- data = numpy.array(cube,
- dtype=numpy.complex)
+ data = numpy.array(cube, dtype=numpy.complex)
return data
def createDataViewWithData(self, dataViewClass, data):
diff --git a/silx/gui/data/test/test_textformatter.py b/silx/gui/data/test/test_textformatter.py
index f21e033..2a7a66b 100644
--- a/silx/gui/data/test/test_textformatter.py
+++ b/silx/gui/data/test/test_textformatter.py
@@ -24,13 +24,22 @@
# ###########################################################################*/
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "24/01/2017"
+__date__ = "27/09/2017"
import unittest
+import shutil
+import tempfile
+import numpy
from silx.gui.test.utils import TestCaseQt
from silx.gui.test.utils import SignalListener
from ..TextFormatter import TextFormatter
+from silx.third_party import six
+
+try:
+ import h5py
+except ImportError:
+ h5py = None
class TestTextFormatter(TestCaseQt):
@@ -83,10 +92,108 @@ class TestTextFormatter(TestCaseQt):
self.assertEquals(result, '"toto"')
+class TestTextFormatterWithH5py(TestCaseQt):
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestTextFormatterWithH5py, cls).setUpClass()
+ if h5py is None:
+ raise unittest.SkipTest("h5py is not available")
+
+ cls.tmpDirectory = tempfile.mkdtemp()
+ cls.h5File = h5py.File("%s/formatter.h5" % cls.tmpDirectory, mode="w")
+ cls.formatter = TextFormatter()
+
+ @classmethod
+ def tearDownClass(cls):
+ super(TestTextFormatterWithH5py, cls).tearDownClass()
+ cls.h5File.close()
+ cls.h5File = None
+ shutil.rmtree(cls.tmpDirectory)
+
+ def create_dataset(self, data, dtype=None):
+ testName = "%s" % self.id()
+ dataset = self.h5File.create_dataset(testName, data=data, dtype=dtype)
+ return dataset
+
+ def testAscii(self):
+ d = self.create_dataset(data=b"abc")
+ result = self.formatter.toString(d[()], dtype=d.dtype)
+ self.assertEquals(result, '"abc"')
+
+ def testUnicode(self):
+ d = self.create_dataset(data=u"i\u2661cookies")
+ result = self.formatter.toString(d[()], dtype=d.dtype)
+ self.assertEquals(len(result), 11)
+ self.assertEquals(result, u'"i\u2661cookies"')
+
+ def testBadAscii(self):
+ d = self.create_dataset(data=b"\xF0\x9F\x92\x94")
+ result = self.formatter.toString(d[()], dtype=d.dtype)
+ self.assertEquals(result, 'b"\\xF0\\x9F\\x92\\x94"')
+
+ def testVoid(self):
+ d = self.create_dataset(data=numpy.void(b"abc\xF0"))
+ result = self.formatter.toString(d[()], dtype=d.dtype)
+ self.assertEquals(result, 'b"\\x61\\x62\\x63\\xF0"')
+
+ def testEnum(self):
+ dtype = h5py.special_dtype(enum=('i', {"RED": 0, "GREEN": 1, "BLUE": 42}))
+ d = numpy.array(42, dtype=dtype)
+ d = self.create_dataset(data=d)
+ result = self.formatter.toString(d[()], dtype=d.dtype)
+ self.assertEquals(result, 'BLUE(42)')
+
+ def testRef(self):
+ dtype = h5py.special_dtype(ref=h5py.Reference)
+ d = numpy.array(self.h5File.ref, dtype=dtype)
+ d = self.create_dataset(data=d)
+ result = self.formatter.toString(d[()], dtype=d.dtype)
+ self.assertEquals(result, 'REF')
+
+ def testArrayAscii(self):
+ d = self.create_dataset(data=[b"abc"])
+ result = self.formatter.toString(d[()], dtype=d.dtype)
+ self.assertEquals(result, '["abc"]')
+
+ def testArrayUnicode(self):
+ dtype = h5py.special_dtype(vlen=six.text_type)
+ d = numpy.array([u"i\u2661cookies"], dtype=dtype)
+ d = self.create_dataset(data=d)
+ result = self.formatter.toString(d[()], dtype=d.dtype)
+ self.assertEquals(len(result), 13)
+ self.assertEquals(result, u'["i\u2661cookies"]')
+
+ def testArrayBadAscii(self):
+ d = self.create_dataset(data=[b"\xF0\x9F\x92\x94"])
+ result = self.formatter.toString(d[()], dtype=d.dtype)
+ self.assertEquals(result, '[b"\\xF0\\x9F\\x92\\x94"]')
+
+ def testArrayVoid(self):
+ d = self.create_dataset(data=numpy.void([b"abc\xF0"]))
+ result = self.formatter.toString(d[()], dtype=d.dtype)
+ self.assertEquals(result, '[b"\\x61\\x62\\x63\\xF0"]')
+
+ def testArrayEnum(self):
+ dtype = h5py.special_dtype(enum=('i', {"RED": 0, "GREEN": 1, "BLUE": 42}))
+ d = numpy.array([42, 1, 100], dtype=dtype)
+ d = self.create_dataset(data=d)
+ result = self.formatter.toString(d[()], dtype=d.dtype)
+ self.assertEquals(result, '[BLUE(42) GREEN(1) 100]')
+
+ def testArrayRef(self):
+ dtype = h5py.special_dtype(ref=h5py.Reference)
+ d = numpy.array([self.h5File.ref, None], dtype=dtype)
+ d = self.create_dataset(data=d)
+ result = self.formatter.toString(d[()], dtype=d.dtype)
+ self.assertEquals(result, '[REF NULL_REF]')
+
+
def suite():
+ loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
test_suite = unittest.TestSuite()
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestTextFormatter))
+ test_suite.addTest(loadTests(TestTextFormatter))
+ test_suite.addTest(loadTests(TestTextFormatterWithH5py))
return test_suite
diff --git a/silx/gui/fit/BackgroundWidget.py b/silx/gui/fit/BackgroundWidget.py
index 577a8c7..2171e87 100644
--- a/silx/gui/fit/BackgroundWidget.py
+++ b/silx/gui/fit/BackgroundWidget.py
@@ -26,7 +26,11 @@
# #########################################################################*/
"""This module provides a background configuration widget
:class:`BackgroundWidget` and a corresponding dialog window
-:class:`BackgroundDialog`."""
+:class:`BackgroundDialog`.
+
+.. image:: img/BackgroundDialog.png
+ :height: 300px
+"""
import sys
import numpy
from silx.gui import qt
@@ -35,7 +39,7 @@ from silx.math.fit import filters
__authors__ = ["V.A. Sole", "P. Knobel"]
__license__ = "MIT"
-__date__ = "24/01/2017"
+__date__ = "28/06/2017"
class HorizontalSpacer(qt.QWidget):
@@ -262,7 +266,7 @@ class BackgroundParamWidget(qt.QWidget):
class BackgroundWidget(qt.QWidget):
- """Background configuration widget, with a :class:`PlotWindow`.
+ """Background configuration widget, with a plot to preview the results.
Strip and snip filters parameters can be adjusted using input widgets,
and the computed backgrounds are plotted next to the original data to
@@ -400,7 +404,7 @@ class BackgroundWidget(qt.QWidget):
legend='SNIP Background',
resetzoom=False)
if self._xmin is not None and self._xmax is not None:
- self.graphWidget.setGraphXLimits(xmin=self._xmin, xmax=self._xmax)
+ self.graphWidget.getXAxis().setLimits(self._xmin, self._xmax)
class BackgroundDialog(qt.QDialog):
@@ -467,11 +471,11 @@ class BackgroundDialog(qt.QDialog):
return self.parametersWidget.getParameters()
def setParameters(self, ddict):
- """See :meth:`BackgroundWidget.setParameters`"""
+ """See :meth:`BackgroundWidget.setPrintGeometry`"""
return self.parametersWidget.setParameters(ddict)
def setDefault(self, ddict):
- """Alias for :meth:`setParameters`"""
+ """Alias for :meth:`setPrintGeometry`"""
return self.setParameters(ddict)
diff --git a/silx/gui/fit/FitConfig.py b/silx/gui/fit/FitConfig.py
index 70b6fbe..04e411b 100644
--- a/silx/gui/fit/FitConfig.py
+++ b/silx/gui/fit/FitConfig.py
@@ -307,7 +307,7 @@ class SearchPage(qt.QWidget):
self.yScalingEntry.setToolTip(
"Data values will be multiplied by this value prior to peak" +
" search")
- self.yScalingEntry.setValidator(qt.QDoubleValidator())
+ self.yScalingEntry.setValidator(qt.QDoubleValidator(self))
layout3.addWidget(self.yScalingEntry)
# ----------------------------------------------------
@@ -324,7 +324,7 @@ class SearchPage(qt.QWidget):
"Peak search sensitivity threshold, expressed as a multiple " +
"of the standard deviation of the noise.\nMinimum value is 1 " +
"(to be detected, peak must be higher than the estimated noise)")
- sensivalidator = qt.QDoubleValidator()
+ sensivalidator = qt.QDoubleValidator(self)
sensivalidator.setBottom(1.0)
self.sensitivityEntry.setValidator(sensivalidator)
layout4.addWidget(self.sensitivityEntry)
@@ -418,7 +418,7 @@ class BackgroundPage(qt.QGroupBox):
"Factor used by the strip algorithm to decide whether a sample" +
"value should be stripped.\nThe value must be higher than the " +
"average of the 2 samples at +- w times this factor.\n")
- self.thresholdFactorEntry.setValidator(qt.QDoubleValidator())
+ self.thresholdFactorEntry.setValidator(qt.QDoubleValidator(self))
layout.addWidget(self.thresholdFactorEntry, 2, 1)
self.smoothStripGB = qt.QGroupBox("Apply smoothing prior to strip", self)
diff --git a/silx/gui/fit/FitWidget.py b/silx/gui/fit/FitWidget.py
index a5c3cfd..7012b63 100644
--- a/silx/gui/fit/FitWidget.py
+++ b/silx/gui/fit/FitWidget.py
@@ -87,6 +87,8 @@ class FitWidget(qt.QWidget):
run the estimation, set constraints on parameters and run the actual fit.
The results are displayed in a table.
+
+ .. image:: img/FitWidget.png
"""
sigFitWidgetSignal = qt.Signal(object)
"""This signal is emitted by the estimation and fit methods.
diff --git a/silx/gui/hdf5/Hdf5Formatter.py b/silx/gui/hdf5/Hdf5Formatter.py
new file mode 100644
index 0000000..3a4c1c1
--- /dev/null
+++ b/silx/gui/hdf5/Hdf5Formatter.py
@@ -0,0 +1,229 @@
+# 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 package provides a class sharred by widgets to format HDF5 data as
+text."""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "27/09/2017"
+
+import numpy
+from silx.third_party import six
+from silx.gui import qt
+from silx.gui.data.TextFormatter import TextFormatter
+
+try:
+ import h5py
+except ImportError:
+ h5py = None
+
+
+class Hdf5Formatter(qt.QObject):
+ """Formatter to convert HDF5 data to string.
+ """
+
+ formatChanged = qt.Signal()
+ """Emitted when properties of the formatter change."""
+
+ def __init__(self, parent=None, textFormatter=None):
+ """
+ Constructor
+
+ :param qt.QObject parent: Owner of the object
+ :param TextFormatter formatter: Text formatter
+ """
+ qt.QObject.__init__(self, parent)
+ if textFormatter is not None:
+ self.__formatter = textFormatter
+ else:
+ self.__formatter = TextFormatter(self)
+ self.__formatter.formatChanged.connect(self.__formatChanged)
+
+ def textFormatter(self):
+ """Returns the used text formatter
+
+ :rtype: TextFormatter
+ """
+ return self.__formatter
+
+ def setTextFormatter(self, textFormatter):
+ """Set the text formatter to be used
+
+ :param TextFormatter textFormatter: The text formatter to use
+ """
+ if textFormatter is None:
+ raise ValueError("Formatter expected but None found")
+ if self.__formatter is textFormatter:
+ return
+ self.__formatter.formatChanged.disconnect(self.__formatChanged)
+ self.__formatter = textFormatter
+ self.__formatter.formatChanged.connect(self.__formatChanged)
+ self.__formatChanged()
+
+ def __formatChanged(self):
+ self.formatChanged.emit()
+
+ def humanReadableShape(self, dataset):
+ if dataset.shape is None:
+ return "none"
+ if dataset.shape == tuple():
+ return "scalar"
+ shape = [str(i) for i in dataset.shape]
+ text = u" \u00D7 ".join(shape)
+ return text
+
+ def humanReadableValue(self, dataset):
+ if dataset.shape is None:
+ return "No data"
+
+ dtype = dataset.dtype
+ if dataset.dtype.type == numpy.void:
+ if dtype.fields is None:
+ return "Raw data"
+
+ if dataset.shape == tuple():
+ numpy_object = dataset[()]
+ text = self.__formatter.toString(numpy_object, dtype=dataset.dtype)
+ else:
+ if dataset.size < 5 and dataset.compression is None:
+ numpy_object = dataset[0:5]
+ text = self.__formatter.toString(numpy_object, dtype=dataset.dtype)
+ else:
+ dimension = len(dataset.shape)
+ if dataset.compression is not None:
+ text = "Compressed %dD data" % dimension
+ else:
+ text = "%dD data" % dimension
+ return text
+
+ def humanReadableType(self, dataset, full=False):
+ dtype = dataset.dtype
+ return self.humanReadableDType(dtype, full)
+
+ def humanReadableDType(self, dtype, full=False):
+ if dtype == six.binary_type or numpy.issubdtype(dtype, numpy.string_):
+ text = "string"
+ if full:
+ text = "ASCII " + text
+ return text
+ elif dtype == six.text_type or numpy.issubdtype(dtype, numpy.unicode_):
+ text = "string"
+ if full:
+ text = "UTF-8 " + text
+ return text
+ elif dtype.type == numpy.object_:
+ ref = h5py.check_dtype(ref=dtype)
+ if ref is not None:
+ return "reference"
+ vlen = h5py.check_dtype(vlen=dtype)
+ if vlen is not None:
+ text = self.humanReadableDType(vlen, full=full)
+ if full:
+ text = "variable-length " + text
+ return text
+ return "object"
+ elif dtype.type == numpy.bool_:
+ return "bool"
+ elif dtype.type == numpy.void:
+ if dtype.fields is None:
+ return "opaque"
+ else:
+ if not full:
+ return "compound"
+ else:
+ compound = [d[0] for d in dtype.fields.values()]
+ compound = [self.humanReadableDType(d) for d in compound]
+ return "compound(%s)" % ", ".join(compound)
+ elif numpy.issubdtype(dtype, numpy.integer):
+ if h5py is not None:
+ enumType = h5py.check_dtype(enum=dtype)
+ if enumType is not None:
+ return "enum"
+
+ text = str(dtype.newbyteorder('N'))
+ if full:
+ if dtype.byteorder == "<":
+ text = "Little-endian " + text
+ elif dtype.byteorder == ">":
+ text = "Big-endian " + text
+ elif dtype.byteorder == "=":
+ text = "Native " + text
+
+ dtype = dtype.newbyteorder('N')
+ return text
+
+ def humanReadableHdf5Type(self, dataset):
+ """Format the internal HDF5 type as a string"""
+ t = dataset.id.get_type()
+ class_ = t.get_class()
+ if class_ == h5py.h5t.NO_CLASS:
+ return "NO_CLASS"
+ elif class_ == h5py.h5t.INTEGER:
+ return "INTEGER"
+ elif class_ == h5py.h5t.FLOAT:
+ return "FLOAT"
+ elif class_ == h5py.h5t.TIME:
+ return "TIME"
+ elif class_ == h5py.h5t.STRING:
+ charset = t.get_cset()
+ strpad = t.get_strpad()
+ text = ""
+
+ if strpad == h5py.h5t.STR_NULLTERM:
+ text += "NULLTERM"
+ elif strpad == h5py.h5t.STR_NULLPAD:
+ text += "NULLPAD"
+ elif strpad == h5py.h5t.STR_SPACEPAD:
+ text += "SPACEPAD"
+ else:
+ text += "UNKNOWN_STRPAD"
+
+ if t.is_variable_str():
+ text += " VARIABLE"
+
+ if charset == h5py.h5t.CSET_ASCII:
+ text += " ASCII"
+ elif charset == h5py.h5t.CSET_UTF8:
+ text += " UTF8"
+ else:
+ text += " UNKNOWN_CSET"
+
+ return text + " STRING"
+ elif class_ == h5py.h5t.BITFIELD:
+ return "BITFIELD"
+ elif class_ == h5py.h5t.OPAQUE:
+ return "OPAQUE"
+ elif class_ == h5py.h5t.COMPOUND:
+ return "COMPOUND"
+ elif class_ == h5py.h5t.REFERENCE:
+ return "REFERENCE"
+ elif class_ == h5py.h5t.ENUM:
+ return "ENUM"
+ elif class_ == h5py.h5t.VLEN:
+ return "VLEN"
+ elif class_ == h5py.h5t.ARRAY:
+ return "ARRAY"
+ else:
+ return "UNKNOWN_CLASS"
diff --git a/silx/gui/hdf5/Hdf5HeaderView.py b/silx/gui/hdf5/Hdf5HeaderView.py
index 5912230..7baa6e0 100644
--- a/silx/gui/hdf5/Hdf5HeaderView.py
+++ b/silx/gui/hdf5/Hdf5HeaderView.py
@@ -25,10 +25,11 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "08/11/2016"
+__date__ = "16/06/2017"
from .. import qt
+from .Hdf5TreeModel import Hdf5TreeModel
QTVERSION = qt.qVersion()
@@ -83,19 +84,21 @@ class Hdf5HeaderView(qt.QHeaderView):
setResizeMode = self.setSectionResizeMode
if self.__auto_resize:
- setResizeMode(0, qt.QHeaderView.ResizeToContents)
- setResizeMode(1, qt.QHeaderView.ResizeToContents)
- setResizeMode(2, qt.QHeaderView.ResizeToContents)
- setResizeMode(3, qt.QHeaderView.Interactive)
- setResizeMode(4, qt.QHeaderView.Interactive)
- setResizeMode(5, qt.QHeaderView.ResizeToContents)
+ setResizeMode(Hdf5TreeModel.NAME_COLUMN, qt.QHeaderView.ResizeToContents)
+ setResizeMode(Hdf5TreeModel.TYPE_COLUMN, qt.QHeaderView.ResizeToContents)
+ setResizeMode(Hdf5TreeModel.SHAPE_COLUMN, qt.QHeaderView.ResizeToContents)
+ setResizeMode(Hdf5TreeModel.VALUE_COLUMN, qt.QHeaderView.Interactive)
+ setResizeMode(Hdf5TreeModel.DESCRIPTION_COLUMN, qt.QHeaderView.Interactive)
+ setResizeMode(Hdf5TreeModel.NODE_COLUMN, qt.QHeaderView.ResizeToContents)
+ setResizeMode(Hdf5TreeModel.LINK_COLUMN, qt.QHeaderView.ResizeToContents)
else:
- setResizeMode(0, qt.QHeaderView.Interactive)
- setResizeMode(1, qt.QHeaderView.Interactive)
- setResizeMode(2, qt.QHeaderView.Interactive)
- setResizeMode(3, qt.QHeaderView.Interactive)
- setResizeMode(4, qt.QHeaderView.Interactive)
- setResizeMode(5, qt.QHeaderView.Interactive)
+ setResizeMode(Hdf5TreeModel.NAME_COLUMN, qt.QHeaderView.Interactive)
+ setResizeMode(Hdf5TreeModel.TYPE_COLUMN, qt.QHeaderView.Interactive)
+ setResizeMode(Hdf5TreeModel.SHAPE_COLUMN, qt.QHeaderView.Interactive)
+ setResizeMode(Hdf5TreeModel.VALUE_COLUMN, qt.QHeaderView.Interactive)
+ setResizeMode(Hdf5TreeModel.DESCRIPTION_COLUMN, qt.QHeaderView.Interactive)
+ setResizeMode(Hdf5TreeModel.NODE_COLUMN, qt.QHeaderView.Interactive)
+ setResizeMode(Hdf5TreeModel.LINK_COLUMN, qt.QHeaderView.Interactive)
def setAutoResizeColumns(self, autoResize):
"""Enable/disable auto-resize. When auto-resized, the header take care
diff --git a/silx/gui/hdf5/Hdf5Item.py b/silx/gui/hdf5/Hdf5Item.py
index 40793a4..f131f61 100644
--- a/silx/gui/hdf5/Hdf5Item.py
+++ b/silx/gui/hdf5/Hdf5Item.py
@@ -25,10 +25,9 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "20/01/2017"
+__date__ = "26/09/2017"
-import numpy
import logging
import collections
from .. import qt
@@ -37,6 +36,7 @@ from . import _utils
from .Hdf5Node import Hdf5Node
import silx.io.utils
from silx.gui.data.TextFormatter import TextFormatter
+from ..hdf5.Hdf5Formatter import Hdf5Formatter
_logger = logging.getLogger(__name__)
@@ -47,6 +47,8 @@ except ImportError as e:
raise e
_formatter = TextFormatter()
+_hdf5Formatter = Hdf5Formatter(textFormatter=_formatter)
+# FIXME: The formatter should be an attribute of the Hdf5Model
class Hdf5Item(Hdf5Node):
@@ -55,7 +57,7 @@ class Hdf5Item(Hdf5Node):
tree structure.
"""
- def __init__(self, text, obj, parent, key=None, h5pyClass=None, isBroken=False, populateAll=False):
+ def __init__(self, text, obj, parent, key=None, h5pyClass=None, linkClass=None, populateAll=False):
"""
:param str text: text displayed
:param object obj: Pointer to h5py data. See the `obj` attribute.
@@ -63,9 +65,10 @@ class Hdf5Item(Hdf5Node):
self.__obj = obj
self.__key = key
self.__h5pyClass = h5pyClass
- self.__isBroken = isBroken
+ self.__isBroken = obj is None and h5pyClass is None
self.__error = None
self.__text = text
+ self.__linkClass = linkClass
Hdf5Node.__init__(self, parent, populateAll=populateAll)
@property
@@ -88,16 +91,26 @@ class Hdf5Item(Hdf5Node):
:rtype: h5py.File or h5py.Dataset or h5py.Group
"""
- if self.__h5pyClass is None:
+ if self.__h5pyClass is None and self.obj is not None:
self.__h5pyClass = silx.io.utils.get_h5py_class(self.obj)
return self.__h5pyClass
+ @property
+ def linkClass(self):
+ """Returns the link class object of this node
+
+ :type: h5py.SoftLink or h5py.HardLink or h5py.ExternalLink or None
+ """
+ return self.__linkClass
+
def isGroupObj(self):
"""Returns true if the stored HDF5 object is a group (contains sub
groups or datasets).
:rtype: bool
"""
+ if self.h5pyClass is None:
+ return False
return issubclass(self.h5pyClass, h5py.Group)
def isBrokenObj(self):
@@ -111,6 +124,14 @@ class Hdf5Item(Hdf5Node):
"""
return self.__isBroken
+ def _getFormatter(self):
+ """
+ Returns an Hdf5Formatter
+
+ :rtype: Hdf5Formatter
+ """
+ return _hdf5Formatter
+
def _expectedChildCount(self):
if self.isGroupObj():
return len(self.obj)
@@ -158,6 +179,22 @@ class Hdf5Item(Hdf5Node):
self.__isBroken = True
else:
self.__obj = obj
+ if not self.isGroupObj():
+ try:
+ # pre-fetch of the data
+ if obj.shape is None:
+ pass
+ elif obj.shape == tuple():
+ obj[()]
+ else:
+ if obj.compression is None and obj.size > 0:
+ key = tuple([0] * len(obj.shape))
+ obj[key]
+ except Exception as e:
+ _logger.debug(e, exc_info=True)
+ message = "%s broken. %s" % (self.__obj.name, e.args[0])
+ self.__error = message
+ self.__isBroken = True
self.__key = None
@@ -166,15 +203,15 @@ class Hdf5Item(Hdf5Node):
for name in self.obj:
try:
class_ = self.obj.get(name, getclass=True)
- has_error = False
+ link = self.obj.get(name, getclass=True, getlink=True)
except Exception as e:
- _logger.error("Internal h5py error", exc_info=True)
+ _logger.warn("Internal h5py error", exc_info=True)
+ class_ = None
try:
- class_ = self.obj.get(name, getclass=True, getlink=True)
+ link = self.obj.get(name, getclass=True, getlink=True)
except Exception as e:
- class_ = h5py.HardLink
- has_error = True
- item = Hdf5Item(text=name, obj=None, parent=self, key=name, h5pyClass=class_, isBroken=has_error)
+ link = h5py.HardLink
+ item = Hdf5Item(text=name, obj=None, parent=self, key=name, h5pyClass=class_, linkClass=link)
self.appendChild(item)
def hasChildren(self):
@@ -191,6 +228,8 @@ class Hdf5Item(Hdf5Node):
:rtype: qt.QIcon
"""
+ # Pre-fetch the object, in case it is broken
+ obj = self.obj
style = qt.QApplication.style()
if self.__isBroken:
icon = style.standardIcon(qt.QStyle.SP_MessageBoxCritical)
@@ -205,99 +244,53 @@ class Hdf5Item(Hdf5Node):
elif issubclass(class_, h5py.ExternalLink):
return style.standardIcon(qt.QStyle.SP_FileLinkIcon)
elif issubclass(class_, h5py.Dataset):
- if len(self.obj.shape) < 4:
- name = "item-%ddim" % len(self.obj.shape)
+ if obj.shape is None:
+ name = "item-none"
+ elif len(obj.shape) < 4:
+ name = "item-%ddim" % len(obj.shape)
else:
name = "item-ndim"
- if str(self.obj.dtype) == "object":
- name = "item-object"
icon = icons.getQIcon(name)
return icon
return None
- def _humanReadableShape(self, dataset):
- if dataset.shape == tuple():
- return "scalar"
- shape = [str(i) for i in dataset.shape]
- text = u" \u00D7 ".join(shape)
- return text
-
- def _humanReadableValue(self, dataset):
- if dataset.shape == tuple():
- numpy_object = dataset[()]
- text = _formatter.toString(numpy_object)
- else:
- if dataset.size < 5 and dataset.compression is None:
- numpy_object = dataset[0:5]
- text = _formatter.toString(numpy_object)
- else:
- dimension = len(dataset.shape)
- if dataset.compression is not None:
- text = "Compressed %dD data" % dimension
- else:
- text = "%dD data" % dimension
- return text
-
- def _humanReadableDType(self, dtype, full=False):
- if dtype.type == numpy.string_:
- text = "string"
- elif dtype.type == numpy.unicode_:
- text = "string"
- elif dtype.type == numpy.object_:
- text = "object"
- elif dtype.type == numpy.bool_:
- text = "bool"
- elif dtype.type == numpy.void:
- if dtype.fields is None:
- text = "raw"
- else:
- if not full:
- text = "compound"
- else:
- compound = [d[0] for d in dtype.fields.values()]
- compound = [self._humanReadableDType(d) for d in compound]
- text = "compound(%s)" % ", ".join(compound)
- else:
- text = str(dtype)
- return text
-
- def _humanReadableType(self, dataset, full=False):
- return self._humanReadableDType(dataset.dtype, full)
-
- def _setTooltipAttributes(self, attributeDict):
+ def _createTooltipAttributes(self):
"""
Add key/value attributes that will be displayed in the item tooltip
:param Dict[str,str] attributeDict: Key/value attributes
"""
+ attributeDict = collections.OrderedDict()
+
if issubclass(self.h5pyClass, h5py.Dataset):
- attributeDict["Title"] = "HDF5 Dataset"
+ attributeDict["#Title"] = "HDF5 Dataset"
attributeDict["Name"] = self.basename
attributeDict["Path"] = self.obj.name
- attributeDict["Shape"] = self._humanReadableShape(self.obj)
- attributeDict["Value"] = self._humanReadableValue(self.obj)
- attributeDict["Data type"] = self._humanReadableType(self.obj, full=True)
+ 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):
- attributeDict["Title"] = "HDF5 Group"
+ attributeDict["#Title"] = "HDF5 Group"
attributeDict["Name"] = self.basename
attributeDict["Path"] = self.obj.name
elif issubclass(self.h5pyClass, h5py.File):
- attributeDict["Title"] = "HDF5 File"
+ attributeDict["#Title"] = "HDF5 File"
attributeDict["Name"] = self.basename
attributeDict["Path"] = "/"
elif isinstance(self.obj, h5py.ExternalLink):
- attributeDict["Title"] = "HDF5 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):
- attributeDict["Title"] = "HDF5 Soft Link"
+ attributeDict["#Title"] = "HDF5 Soft Link"
attributeDict["Name"] = self.basename
attributeDict["Path"] = self.obj.name
attributeDict["Linked path"] = self.obj.path
else:
pass
+ return attributeDict
def _getDefaultTooltip(self):
"""Returns the default tooltip
@@ -308,10 +301,8 @@ class Hdf5Item(Hdf5Node):
self.obj # lazy loading of the object
return self.__error
- attrs = collections.OrderedDict()
- self._setTooltipAttributes(attrs)
-
- title = attrs.pop("Title", None)
+ attrs = self._createTooltipAttributes()
+ title = attrs.pop("#Title", None)
if len(attrs) > 0:
tooltip = _utils.htmlFromDict(attrs, title=title)
else:
@@ -342,7 +333,7 @@ class Hdf5Item(Hdf5Node):
return ""
class_ = self.h5pyClass
if issubclass(class_, h5py.Dataset):
- text = self._humanReadableType(self.obj)
+ text = self._getFormatter().humanReadableType(self.obj)
else:
text = ""
return text
@@ -361,7 +352,7 @@ class Hdf5Item(Hdf5Node):
class_ = self.h5pyClass
if not issubclass(class_, h5py.Dataset):
return ""
- return self._humanReadableShape(self.obj)
+ return self._getFormatter().humanReadableShape(self.obj)
return None
def dataValue(self, role):
@@ -375,7 +366,7 @@ class Hdf5Item(Hdf5Node):
return ""
if not issubclass(self.h5pyClass, h5py.Dataset):
return ""
- return self._humanReadableValue(self.obj)
+ return self._getFormatter().humanReadableValue(self.obj)
return None
def dataDescription(self, role):
@@ -412,10 +403,41 @@ class Hdf5Item(Hdf5Node):
if role == qt.Qt.TextAlignmentRole:
return qt.Qt.AlignTop | qt.Qt.AlignLeft
if role == qt.Qt.DisplayRole:
+ if self.isBrokenObj():
+ return ""
class_ = self.h5pyClass
text = class_.__name__.split(".")[-1]
return text
if role == qt.Qt.ToolTipRole:
class_ = self.h5pyClass
+ if class_ is None:
+ return ""
return "Class name: %s" % self.__class__
return None
+
+ def dataLink(self, role):
+ """Data for the link column
+
+ Overwrite it to implement the content of the 'link' column.
+
+ :rtype: qt.QVariant
+ """
+ if role == qt.Qt.DecorationRole:
+ return None
+ if role == qt.Qt.TextAlignmentRole:
+ return qt.Qt.AlignTop | qt.Qt.AlignLeft
+ if role == qt.Qt.DisplayRole:
+ link = self.linkClass
+ if link is None:
+ return ""
+ elif link is h5py.ExternalLink:
+ return "External"
+ elif link is h5py.SoftLink:
+ return "Soft"
+ elif link is h5py.HardLink:
+ return ""
+ else:
+ return link.__name__
+ if role == qt.Qt.ToolTipRole:
+ return None
+ return None
diff --git a/silx/gui/hdf5/Hdf5Node.py b/silx/gui/hdf5/Hdf5Node.py
index 31bb097..0fcb407 100644
--- a/silx/gui/hdf5/Hdf5Node.py
+++ b/silx/gui/hdf5/Hdf5Node.py
@@ -25,7 +25,9 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "23/09/2016"
+__date__ = "16/06/2017"
+
+import weakref
class Hdf5Node(object):
@@ -43,7 +45,9 @@ class Hdf5Node(object):
everything is lazy loaded.
"""
self.__child = None
- self.__parent = parent
+ self.__parent = None
+ if parent is not None:
+ self.__parent = weakref.ref(parent)
if populateAll:
self.__child = []
self._populateChild(populateAll=True)
@@ -54,7 +58,12 @@ class Hdf5Node(object):
:rtype: Hdf5Node
"""
- return self.__parent
+ if self.__parent is None:
+ return None
+ parent = self.__parent()
+ if parent is None:
+ self.__parent = parent
+ return parent
def setParent(self, parent):
"""Redefine the parent of the node.
@@ -63,7 +72,10 @@ class Hdf5Node(object):
:param Hdf5Node parent: The new parent
"""
- self.__parent = parent
+ if parent is None:
+ self.__parent = None
+ else:
+ self.__parent = weakref.ref(parent)
def appendChild(self, child):
"""Append a child to the node.
@@ -208,3 +220,12 @@ class Hdf5Node(object):
:rtype: qt.QVariant
"""
return None
+
+ def dataLink(self, role):
+ """Data for the link column
+
+ Overwrite it to implement the content of the 'link' column.
+
+ :rtype: qt.QVariant
+ """
+ return None
diff --git a/silx/gui/hdf5/Hdf5TreeModel.py b/silx/gui/hdf5/Hdf5TreeModel.py
index fb5de06..41fa91c 100644
--- a/silx/gui/hdf5/Hdf5TreeModel.py
+++ b/silx/gui/hdf5/Hdf5TreeModel.py
@@ -25,7 +25,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "19/12/2016"
+__date__ = "22/09/2017"
import os
@@ -71,6 +71,25 @@ else:
return x
+def _createRootLabel(h5obj):
+ """
+ Create label for the very first npde of the tree.
+
+ :param h5obj: The h5py object to display in the GUI
+ :type h5obj: h5py-like object
+ :rtpye: str
+ """
+ if silx_io.is_file(h5obj):
+ label = os.path.basename(h5obj.filename)
+ else:
+ filename = os.path.basename(h5obj.file.filename)
+ path = h5obj.name
+ if path.startswith("/"):
+ path = path[1:]
+ label = "%s::%s" % (filename, path)
+ return label
+
+
class LoadingItemRunnable(qt.QRunnable):
"""Runner to process item loading from a file"""
@@ -107,12 +126,7 @@ class LoadingItemRunnable(qt.QRunnable):
:param h5py.File h5obj: The h5py object to display in the GUI
:rtpye: Hdf5Node
"""
- if silx_io.is_file(h5obj):
- text = os.path.basename(h5obj.filename)
- else:
- filename = os.path.basename(h5obj.file.filename)
- path = h5obj.name
- text = "%s::%s" % (filename, path)
+ text = _createRootLabel(h5obj)
item = Hdf5Item(text=text, obj=h5obj, parent=oldItem.parent, populateAll=True)
return item
@@ -121,6 +135,7 @@ class LoadingItemRunnable(qt.QRunnable):
"""Process the file loading. The worker is used as holder
of the data and the signal. The result is sent as a signal.
"""
+ h5file = None
try:
h5file = silx_io.open(self.filename)
newItem = self.__loadItemTree(self.oldItem, h5file)
@@ -129,6 +144,8 @@ class LoadingItemRunnable(qt.QRunnable):
# Should be logged
error = e
newItem = None
+ if h5file is not None:
+ h5file.close()
# Take care of None value in case of PySide
newItem = _wrapNone(newItem)
@@ -174,6 +191,9 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
NODE_COLUMN = 5
"""Column id containing HDF5 node type"""
+ LINK_COLUMN = 6
+ """Column id containing HDF5 link type"""
+
COLUMN_IDS = [
NAME_COLUMN,
TYPE_COLUMN,
@@ -181,20 +201,21 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
VALUE_COLUMN,
DESCRIPTION_COLUMN,
NODE_COLUMN,
+ LINK_COLUMN,
]
"""List of logical columns available"""
def __init__(self, parent=None):
super(Hdf5TreeModel, self).__init__(parent)
- self.treeView = parent
- self.header_labels = [None] * 6
+ self.header_labels = [None] * len(self.COLUMN_IDS)
self.header_labels[self.NAME_COLUMN] = 'Name'
self.header_labels[self.TYPE_COLUMN] = 'Type'
self.header_labels[self.SHAPE_COLUMN] = 'Shape'
self.header_labels[self.VALUE_COLUMN] = 'Value'
self.header_labels[self.DESCRIPTION_COLUMN] = 'Description'
self.header_labels[self.NODE_COLUMN] = 'Node'
+ self.header_labels[self.LINK_COLUMN] = 'Link'
# Create items
self.__root = Hdf5Node()
@@ -205,14 +226,36 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
self.__animatedIcon.iconChanged.connect(self.__updateLoadingItems)
self.__runnerSet = set([])
- # store used icons to avoid to avoid the cache to release it
+ # store used icons to avoid the cache to release it
self.__icons = []
+ self.__icons.append(icons.getQIcon("item-none"))
self.__icons.append(icons.getQIcon("item-0dim"))
self.__icons.append(icons.getQIcon("item-1dim"))
self.__icons.append(icons.getQIcon("item-2dim"))
self.__icons.append(icons.getQIcon("item-3dim"))
self.__icons.append(icons.getQIcon("item-ndim"))
- self.__icons.append(icons.getQIcon("item-object"))
+
+ 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__()
+
+ 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 = []
def __updateLoadingItems(self, icon):
for i in range(self.__root.childCount()):
@@ -240,6 +283,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
self.__root.removeChildAtIndex(row)
self.endRemoveRows()
if newItem is not None:
+ self.__openedFiles.append(newItem.obj)
self.beginInsertRows(rootIndex, row, row)
self.__root.insertChild(row, newItem)
self.endInsertRows()
@@ -423,11 +467,13 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
return node.dataDescription(role)
elif index.column() == self.NODE_COLUMN:
return node.dataNode(role)
+ elif index.column() == self.LINK_COLUMN:
+ return node.dataLink(role)
else:
return None
def columnCount(self, parent=qt.QModelIndex()):
- return len(self.header_labels)
+ return len(self.COLUMN_IDS)
def hasChildren(self, parent=qt.QModelIndex()):
node = self.nodeFromIndex(parent)
@@ -536,12 +582,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
or any other class of h5py file structure.
"""
if text is None:
- if silx_io.is_file(h5pyObject):
- text = os.path.basename(h5pyObject.filename)
- else:
- filename = os.path.basename(h5pyObject.file.filename)
- path = h5pyObject.name
- text = "%s::%s" % (filename, path)
+ text = _createRootLabel(h5pyObject)
if row == -1:
row = self.__root.childCount()
self.insertNode(row, Hdf5Item(text=text, obj=h5pyObject, parent=self.__root))
@@ -572,6 +613,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
"""
try:
h5file = silx_io.open(filename)
+ self.__openedFiles.append(h5file)
self.insertH5pyObject(h5file, row=row)
except IOError:
_logger.debug("File '%s' can't be read.", filename, exc_info=True)
diff --git a/silx/gui/hdf5/Hdf5TreeView.py b/silx/gui/hdf5/Hdf5TreeView.py
index 09f6fcf..0a4198e 100644
--- a/silx/gui/hdf5/Hdf5TreeView.py
+++ b/silx/gui/hdf5/Hdf5TreeView.py
@@ -25,7 +25,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "27/09/2016"
+__date__ = "20/09/2017"
import logging
@@ -43,6 +43,8 @@ _logger = logging.getLogger(__name__)
class Hdf5TreeView(qt.QTreeView):
"""TreeView which allow to browse HDF5 file structure.
+ .. image:: img/Hdf5TreeView.png
+
It provides columns width auto-resizing and additional
signals.
@@ -192,6 +194,87 @@ class Hdf5TreeView(qt.QTreeView):
continue
yield _utils.H5Node(item)
+ def setSelectedH5Node(self, h5Object):
+ """
+ Select the specified node of the tree using an h5py node.
+
+ - If the item is found, parent items are expended, and then the item
+ is selected.
+ - 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
+ """
+ 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:
+ # Update the GUI
+ for index in foundIndices[:-1]:
+ self.expand(index)
+ self.setCurrentIndex(foundIndices[-1])
+
def mousePressEvent(self, event):
"""Override mousePressEvent to provide a consistante compatible API
between Qt4 and Qt5
diff --git a/silx/gui/hdf5/NexusSortFilterProxyModel.py b/silx/gui/hdf5/NexusSortFilterProxyModel.py
index 9a4268c..49a22d3 100644
--- a/silx/gui/hdf5/NexusSortFilterProxyModel.py
+++ b/silx/gui/hdf5/NexusSortFilterProxyModel.py
@@ -25,7 +25,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "12/04/2017"
+__date__ = "16/06/2017"
import logging
@@ -86,7 +86,8 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel):
def __isNXentry(self, node):
"""Returns true if the node is an NXentry"""
- if not issubclass(node.h5pyClass, h5py.Group):
+ class_ = node.h5pyClass
+ if class_ is None or not issubclass(node.h5pyClass, h5py.Group):
return False
nxClass = node.obj.attrs.get("NX_class", None)
return nxClass == "NXentry"
diff --git a/silx/gui/hdf5/_utils.py b/silx/gui/hdf5/_utils.py
index af9c79f..048aa20 100644
--- a/silx/gui/hdf5/_utils.py
+++ b/silx/gui/hdf5/_utils.py
@@ -28,11 +28,10 @@ package `silx.gui.hdf5` package.
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "26/04/2017"
+__date__ = "29/09/2017"
import logging
-import numpy
from .. import qt
import silx.io.utils
from silx.utils.html import escape
@@ -138,10 +137,61 @@ class H5Node(object):
:param Hdf5Item h5py_item: An Hdf5Item
"""
self.__h5py_object = h5py_item.obj
+ self.__h5py_target = None
self.__h5py_item = h5py_item
def __getattr__(self, name):
- return object.__getattribute__(self.__h5py_object, name)
+ if hasattr(self.__h5py_object, name):
+ attr = getattr(self.__h5py_object, name)
+ return attr
+ raise AttributeError("H5Node has no attribute %s" % name)
+
+ def __get_target(self, obj):
+ """
+ Return the actual physical target of the provided object.
+
+ Objects can contains links in the middle of the path, this function
+ check each groups and remove this prefix in case of the link by the
+ link of the path.
+
+ :param obj: A valid h5py object (File, group or dataset)
+ :type obj: h5py.Dataset or h5py.Group or h5py.File
+ :rtype: h5py.Dataset or h5py.Group or h5py.File
+ """
+ elements = obj.name.split("/")
+ if obj.name == "/":
+ return obj
+ elif obj.name.startswith("/"):
+ elements.pop(0)
+ path = ""
+ while len(elements) > 0:
+ e = elements.pop(0)
+ path = path + "/" + e
+ link = obj.parent.get(path, getlink=True)
+
+ if isinstance(link, h5py.ExternalLink):
+ subpath = "/".join(elements)
+ external_obj = obj.parent.get(self.basename + "/" + subpath)
+ return self.__get_target(external_obj)
+ elif silx.io.utils.is_softlink(link):
+ # Restart from this stat
+ path = ""
+ root_elements = link.path.split("/")
+ if link.path == "/":
+ root_elements = []
+ elif link.path.startswith("/"):
+ root_elements.pop(0)
+ for name in reversed(root_elements):
+ elements.insert(0, name)
+
+ return obj.file[path]
+
+ @property
+ def h5py_target(self):
+ if self.__h5py_target is not None:
+ return self.__h5py_target
+ self.__h5py_target = self.__get_target(self.__h5py_object)
+ return self.__h5py_target
@property
def h5py_object(self):
@@ -170,8 +220,18 @@ class H5Node(object):
return self.__h5py_object.name.split("/")[-1]
@property
+ def is_broken(self):
+ """Returns true if the node is a broken link.
+
+ :rtype: bool
+ """
+ if self.__h5py_item is None:
+ raise RuntimeError("h5py_item is not defined")
+ return self.__h5py_item.isBrokenObj()
+
+ @property
def local_name(self):
- """Returns the local path of this h5py node.
+ """Returns the path from the master file root to this node.
For links, this path is not equal to the h5py one.
@@ -183,34 +243,46 @@ class H5Node(object):
result = []
item = self.__h5py_item
while item is not None:
- if issubclass(item.h5pyClass, h5py.File):
+ # stop before the root item (item without parent)
+ if item.parent.parent is None:
+ name = item.obj.name
+ if name != "/":
+ result.append(item.obj.name)
break
- result.append(item.basename)
+ else:
+ result.append(item.basename)
item = item.parent
if item is None:
raise RuntimeError("The item does not have parent holding h5py.File")
if result == []:
return "/"
- result.append("")
+ if not result[-1].startswith("/"):
+ result.append("")
result.reverse()
- return "/".join(result)
+ name = "/".join(result)
+ return name
- def __file_item(self):
- """Returns the parent item holding the :class:`h5py.File` object
+ def __get_local_file(self):
+ """Returns the file of the root of this tree
:rtype: h5py.File
- :raises RuntimeException: If no file are found
"""
item = self.__h5py_item
- while item is not None:
- if issubclass(item.h5pyClass, h5py.File):
- return item
+ while item.parent.parent is not None:
+ class_ = item.h5pyClass
+ if class_ is not None and issubclass(class_, h5py.File):
+ break
item = item.parent
- raise RuntimeError("The item does not have parent holding h5py.File")
+
+ class_ = item.h5pyClass
+ if class_ is not None and issubclass(class_, h5py.File):
+ return item.obj
+ else:
+ return item.obj.file
@property
def local_file(self):
- """Returns the local :class:`h5py.File` object.
+ """Returns the master file in which is this node.
For path containing external links, this file is not equal to the h5py
one.
@@ -218,12 +290,11 @@ class H5Node(object):
:rtype: h5py.File
:raises RuntimeException: If no file are found
"""
- item = self.__file_item()
- return item.obj
+ return self.__get_local_file()
@property
def local_filename(self):
- """Returns the local filename of the h5py node.
+ """Returns the filename from the master file of this node.
For path containing external links, this path is not equal to the
filename provided by h5py.
@@ -235,13 +306,84 @@ class H5Node(object):
@property
def local_basename(self):
- """Returns the local filename of the h5py node.
+ """Returns the basename from the master file root to this node.
For path containing links, this basename can be different than the
basename provided by h5py.
:rtype: str
"""
- if issubclass(self.__h5py_item.h5pyClass, h5py.File):
+ class_ = self.__h5py_item.h5pyClass
+ if class_ is not None and issubclass(class_, h5py.File):
return ""
return self.__h5py_item.basename
+
+ @property
+ def physical_file(self):
+ """Returns the physical file in which is this node.
+
+ .. versionadded:: 0.6
+
+ :rtype: h5py.File
+ :raises RuntimeError: If no file are found
+ """
+ if isinstance(self.__h5py_object, h5py.ExternalLink):
+ # It means the link is broken
+ raise RuntimeError("No file node found")
+ if isinstance(self.__h5py_object, h5py.SoftLink):
+ # It means the link is broken
+ return self.local_file
+
+ physical_obj = self.h5py_target
+ return physical_obj.file
+
+ @property
+ def physical_name(self):
+ """Returns the path from the location this h5py node is physically
+ stored.
+
+ For broken links, this filename can be different from the
+ filename provided by h5py.
+
+ :rtype: str
+ """
+ if isinstance(self.__h5py_object, h5py.ExternalLink):
+ # It means the link is broken
+ return self.__h5py_object.path
+ if isinstance(self.__h5py_object, h5py.SoftLink):
+ # It means the link is broken
+ return self.__h5py_object.path
+
+ physical_obj = self.h5py_target
+ return physical_obj.name
+
+ @property
+ def physical_filename(self):
+ """Returns the filename from the location this h5py node is physically
+ stored.
+
+ For broken links, this filename can be different from the
+ filename provided by h5py.
+
+ :rtype: str
+ """
+ if isinstance(self.__h5py_object, h5py.ExternalLink):
+ # It means the link is broken
+ return self.__h5py_object.filename
+ if isinstance(self.__h5py_object, h5py.SoftLink):
+ # It means the link is broken
+ return self.local_file.filename
+
+ return self.physical_file.filename
+
+ @property
+ def physical_basename(self):
+ """Returns the basename from the location this h5py node is physically
+ stored.
+
+ For broken links, this basename can be different from the
+ basename provided by h5py.
+
+ :rtype: str
+ """
+ return self.physical_name.split("/")[-1]
diff --git a/silx/gui/hdf5/test/_mock.py b/silx/gui/hdf5/test/_mock.py
deleted file mode 100644
index eada590..0000000
--- a/silx/gui/hdf5/test/_mock.py
+++ /dev/null
@@ -1,130 +0,0 @@
-# coding: utf-8
-# /*##########################################################################
-#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-#
-# ###########################################################################*/
-"""Mock for silx.gui.hdf5 module"""
-
-__authors__ = ["V. Valls"]
-__license__ = "MIT"
-__date__ = "12/04/2017"
-
-
-import numpy
-try:
- import h5py
-except ImportError:
- h5py = None
-
-
-class Node(object):
-
- def __init__(self, basename, parent, h5py_class):
- self.basename = basename
- self.h5py_class = h5py_class
- self.attrs = {}
- self.parent = parent
- if parent is not None:
- self.parent._add(self)
-
- @property
- def name(self):
- if self.parent is None:
- return self.basename
- if self.parent.name == "":
- return self.basename
- return self.parent.name + "/" + self.basename
-
- @property
- def file(self):
- if self.parent is None:
- return self
- return self.parent.file
-
-
-class Group(Node):
- """Mock an h5py Group"""
-
- def __init__(self, name, parent, h5py_class=h5py.Group):
- super(Group, self).__init__(name, parent, h5py_class)
- self.__items = {}
-
- def _add(self, node):
- self.__items[node.basename] = node
-
- def __getitem__(self, key):
- return self.__items[key]
-
- def __iter__(self):
- for k in self.__items:
- yield k
-
- def __len__(self):
- return len(self.__items)
-
- def get(self, name, getclass=False, getlink=False):
- result = self.__items[name]
- if getclass:
- return result.h5py_class
- return result
-
- def create_dataset(self, name, data):
- return Dataset(name, self, data)
-
- def create_group(self, name):
- return Group(name, self)
-
- def create_NXentry(self, name):
- group = Group(name, self)
- group.attrs["NX_class"] = "NXentry"
- return group
-
-
-class File(Group):
- """Mock an h5py File"""
-
- def __init__(self, filename):
- super(File, self).__init__("", None, h5py.File)
- self.filename = filename
-
-
-class Dataset(Node):
- """Mock an h5py Dataset"""
-
- def __init__(self, name, parent, value):
- super(Dataset, self).__init__(name, parent, h5py.Dataset)
- self.__value = value
- self.shape = self.__value.shape
- self.dtype = self.__value.dtype
- self.size = self.__value.size
- self.compression = None
- self.compression_opts = None
-
- def __getitem__(self, key):
- if not isinstance(self.__value, numpy.ndarray):
- if key == tuple():
- return self.__value
- elif key == Ellipsis:
- return numpy.array(self.__value)
- else:
- raise ValueError("Bad key")
- return self.__value[key]
diff --git a/silx/gui/hdf5/test/test_hdf5.py b/silx/gui/hdf5/test/test_hdf5.py
index 3bf4897..8e375f2 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__ = "12/04/2017"
+__date__ = "22/09/2017"
import time
@@ -34,11 +34,12 @@ import os
import unittest
import tempfile
import numpy
+import shutil
from contextlib import contextmanager
from silx.gui import qt
from silx.gui.test.utils import TestCaseQt
from silx.gui import hdf5
-from . import _mock
+from silx.io import commonh5
try:
import h5py
@@ -54,6 +55,13 @@ class _Holder(object):
_called += 1
+def create_NXentry(group, name):
+ attrs = {"NX_class": "NXentry"}
+ node = commonh5.Group(name, parent=group, attrs=attrs)
+ group.add_node(node)
+ return node
+
+
class TestHdf5TreeModel(TestCaseQt):
def setUp(self):
@@ -124,14 +132,14 @@ class TestHdf5TreeModel(TestCaseQt):
h5File.close()
def testInsertObject(self):
- h5 = _mock.File("/foo/bar/1.mock")
+ h5 = commonh5.File("/foo/bar/1.mock", "w")
model = hdf5.Hdf5TreeModel()
self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
model.insertH5pyObject(h5)
self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
def testRemoveObject(self):
- h5 = _mock.File("/foo/bar/1.mock")
+ h5 = commonh5.File("/foo/bar/1.mock", "w")
model = hdf5.Hdf5TreeModel()
self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
model.insertH5pyObject(h5)
@@ -223,7 +231,7 @@ class TestHdf5TreeModel(TestCaseQt):
return model.data(index, qt.Qt.DisplayRole)
def testFileData(self):
- h5 = _mock.File("/foo/bar/1.mock")
+ h5 = commonh5.File("/foo/bar/1.mock", "w")
model = hdf5.Hdf5TreeModel()
model.insertH5pyObject(h5)
displayed = self.getRowDataAsDict(model, row=0)
@@ -236,7 +244,7 @@ class TestHdf5TreeModel(TestCaseQt):
self.assertEquals(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "File")
def testGroupData(self):
- h5 = _mock.File("/foo/bar/1.mock")
+ h5 = commonh5.File("/foo/bar/1.mock", "w")
d = h5.create_group("foo")
d.attrs["desc"] = "fooo"
@@ -252,9 +260,9 @@ class TestHdf5TreeModel(TestCaseQt):
self.assertEquals(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "Group")
def testDatasetData(self):
- h5 = _mock.File("/foo/bar/1.mock")
+ h5 = commonh5.File("/foo/bar/1.mock", "w")
value = numpy.array([1, 2, 3])
- d = h5.create_dataset("foo", value)
+ d = h5.create_dataset("foo", data=value)
model = hdf5.Hdf5TreeModel()
model.insertH5pyObject(d)
@@ -269,8 +277,8 @@ class TestHdf5TreeModel(TestCaseQt):
def testDropLastAsFirst(self):
model = hdf5.Hdf5TreeModel()
- h5_1 = _mock.File("/foo/bar/1.mock")
- h5_2 = _mock.File("/foo/bar/2.mock")
+ h5_1 = commonh5.File("/foo/bar/1.mock", "w")
+ h5_2 = commonh5.File("/foo/bar/2.mock", "w")
model.insertH5pyObject(h5_1)
model.insertH5pyObject(h5_2)
self.assertEquals(self.getItemName(model, 0), "1.mock")
@@ -283,8 +291,8 @@ class TestHdf5TreeModel(TestCaseQt):
def testDropFirstAsLast(self):
model = hdf5.Hdf5TreeModel()
- h5_1 = _mock.File("/foo/bar/1.mock")
- h5_2 = _mock.File("/foo/bar/2.mock")
+ h5_1 = commonh5.File("/foo/bar/1.mock", "w")
+ h5_2 = commonh5.File("/foo/bar/2.mock", "w")
model.insertH5pyObject(h5_1)
model.insertH5pyObject(h5_2)
self.assertEquals(self.getItemName(model, 0), "1.mock")
@@ -297,7 +305,7 @@ class TestHdf5TreeModel(TestCaseQt):
def testRootParent(self):
model = hdf5.Hdf5TreeModel()
- h5_1 = _mock.File("/foo/bar/1.mock")
+ h5_1 = commonh5.File("/foo/bar/1.mock", "w")
model.insertH5pyObject(h5_1)
index = model.index(0, 0, qt.QModelIndex())
index = model.parent(index)
@@ -318,10 +326,10 @@ class TestNexusSortFilterProxyModel(TestCaseQt):
def testNXentryStartTime(self):
"""Test NXentry with start_time"""
model = hdf5.Hdf5TreeModel()
- h5 = _mock.File("/foo/bar/1.mock")
- h5.create_NXentry("a").create_dataset("start_time", numpy.string_("2015"))
- h5.create_NXentry("b").create_dataset("start_time", numpy.string_("2013"))
- h5.create_NXentry("c").create_dataset("start_time", numpy.string_("2014"))
+ h5 = commonh5.File("/foo/bar/1.mock", "w")
+ create_NXentry(h5, "a").create_dataset("start_time", data=numpy.string_("2015"))
+ create_NXentry(h5, "b").create_dataset("start_time", data=numpy.string_("2013"))
+ create_NXentry(h5, "c").create_dataset("start_time", data=numpy.string_("2014"))
model.insertH5pyObject(h5)
proxy = hdf5.NexusSortFilterProxyModel()
@@ -333,10 +341,10 @@ class TestNexusSortFilterProxyModel(TestCaseQt):
def testNXentryStartTimeInArray(self):
"""Test NXentry with start_time"""
model = hdf5.Hdf5TreeModel()
- h5 = _mock.File("/foo/bar/1.mock")
- h5.create_NXentry("a").create_dataset("start_time", numpy.array([numpy.string_("2015")]))
- h5.create_NXentry("b").create_dataset("start_time", numpy.array([numpy.string_("2013")]))
- h5.create_NXentry("c").create_dataset("start_time", numpy.array([numpy.string_("2014")]))
+ h5 = commonh5.File("/foo/bar/1.mock", "w")
+ create_NXentry(h5, "a").create_dataset("start_time", data=numpy.array([numpy.string_("2015")]))
+ create_NXentry(h5, "b").create_dataset("start_time", data=numpy.array([numpy.string_("2013")]))
+ create_NXentry(h5, "c").create_dataset("start_time", data=numpy.array([numpy.string_("2014")]))
model.insertH5pyObject(h5)
proxy = hdf5.NexusSortFilterProxyModel()
@@ -348,10 +356,10 @@ class TestNexusSortFilterProxyModel(TestCaseQt):
def testNXentryEndTimeInArray(self):
"""Test NXentry with end_time"""
model = hdf5.Hdf5TreeModel()
- h5 = _mock.File("/foo/bar/1.mock")
- h5.create_NXentry("a").create_dataset("end_time", numpy.array([numpy.string_("2015")]))
- h5.create_NXentry("b").create_dataset("end_time", numpy.array([numpy.string_("2013")]))
- h5.create_NXentry("c").create_dataset("end_time", numpy.array([numpy.string_("2014")]))
+ h5 = commonh5.File("/foo/bar/1.mock", "w")
+ create_NXentry(h5, "a").create_dataset("end_time", data=numpy.array([numpy.string_("2015")]))
+ create_NXentry(h5, "b").create_dataset("end_time", data=numpy.array([numpy.string_("2013")]))
+ create_NXentry(h5, "c").create_dataset("end_time", data=numpy.array([numpy.string_("2014")]))
model.insertH5pyObject(h5)
proxy = hdf5.NexusSortFilterProxyModel()
@@ -363,10 +371,10 @@ class TestNexusSortFilterProxyModel(TestCaseQt):
def testNXentryName(self):
"""Test NXentry without start_time or end_time"""
model = hdf5.Hdf5TreeModel()
- h5 = _mock.File("/foo/bar/1.mock")
- h5.create_NXentry("a")
- h5.create_NXentry("c")
- h5.create_NXentry("b")
+ h5 = commonh5.File("/foo/bar/1.mock", "w")
+ create_NXentry(h5, "a")
+ create_NXentry(h5, "c")
+ create_NXentry(h5, "b")
model.insertH5pyObject(h5)
proxy = hdf5.NexusSortFilterProxyModel()
@@ -378,10 +386,10 @@ class TestNexusSortFilterProxyModel(TestCaseQt):
def testStartTime(self):
"""If it is not NXentry, start_time is not used"""
model = hdf5.Hdf5TreeModel()
- h5 = _mock.File("/foo/bar/1.mock")
- h5.create_group("a").create_dataset("start_time", numpy.string_("2015"))
- h5.create_group("b").create_dataset("start_time", numpy.string_("2013"))
- h5.create_group("c").create_dataset("start_time", numpy.string_("2014"))
+ h5 = commonh5.File("/foo/bar/1.mock", "w")
+ h5.create_group("a").create_dataset("start_time", data=numpy.string_("2015"))
+ h5.create_group("b").create_dataset("start_time", data=numpy.string_("2013"))
+ h5.create_group("c").create_dataset("start_time", data=numpy.string_("2014"))
model.insertH5pyObject(h5)
proxy = hdf5.NexusSortFilterProxyModel()
@@ -392,7 +400,7 @@ class TestNexusSortFilterProxyModel(TestCaseQt):
def testName(self):
model = hdf5.Hdf5TreeModel()
- h5 = _mock.File("/foo/bar/1.mock")
+ h5 = commonh5.File("/foo/bar/1.mock", "w")
h5.create_group("a")
h5.create_group("c")
h5.create_group("b")
@@ -406,7 +414,7 @@ class TestNexusSortFilterProxyModel(TestCaseQt):
def testNumber(self):
model = hdf5.Hdf5TreeModel()
- h5 = _mock.File("/foo/bar/1.mock")
+ h5 = commonh5.File("/foo/bar/1.mock", "w")
h5.create_group("a1")
h5.create_group("a20")
h5.create_group("a3")
@@ -420,7 +428,7 @@ class TestNexusSortFilterProxyModel(TestCaseQt):
def testMultiNumber(self):
model = hdf5.Hdf5TreeModel()
- h5 = _mock.File("/foo/bar/1.mock")
+ h5 = commonh5.File("/foo/bar/1.mock", "w")
h5.create_group("a1-1")
h5.create_group("a20-1")
h5.create_group("a3-1")
@@ -436,7 +444,7 @@ class TestNexusSortFilterProxyModel(TestCaseQt):
def testUnconsistantTypes(self):
model = hdf5.Hdf5TreeModel()
- h5 = _mock.File("/foo/bar/1.mock")
+ h5 = commonh5.File("/foo/bar/1.mock", "w")
h5.create_group("aaa100")
h5.create_group("100aaa")
model.insertH5pyObject(h5)
@@ -448,11 +456,235 @@ class TestNexusSortFilterProxyModel(TestCaseQt):
self.assertListEqual(names, ["100aaa", "aaa100"])
-class TestHdf5(TestCaseQt):
+class TestH5Node(TestCaseQt):
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestH5Node, cls).setUpClass()
+ if h5py is None:
+ raise unittest.SkipTest("h5py is not available")
+
+ cls.tmpDirectory = tempfile.mkdtemp()
+ cls.h5Filename = cls.createResource(cls.tmpDirectory)
+ cls.h5File = h5py.File(cls.h5Filename, mode="r")
+ cls.model = cls.createModel(cls.h5File)
+
+ @classmethod
+ def createResource(cls, directory):
+ filename = os.path.join(directory, "base.h5")
+ externalFilename = os.path.join(directory, "base__external.h5")
+
+ externalh5 = h5py.File(externalFilename, mode="w")
+ externalh5["target/dataset"] = 50
+ externalh5["target/link"] = h5py.SoftLink("/target/dataset")
+ externalh5.close()
+
+ h5 = h5py.File(filename, mode="w")
+ h5["group/dataset"] = 50
+ h5["link/soft_link"] = h5py.SoftLink("/group/dataset")
+ h5["link/soft_link_to_group"] = h5py.SoftLink("/group")
+ h5["link/soft_link_to_link"] = h5py.SoftLink("/link/soft_link")
+ h5["link/soft_link_to_file"] = h5py.SoftLink("/")
+ h5["link/external_link"] = h5py.ExternalLink(externalFilename, "/target/dataset")
+ h5["link/external_link_to_link"] = h5py.ExternalLink(externalFilename, "/target/link")
+ h5["broken_link/external_broken_file"] = h5py.ExternalLink(externalFilename + "_not_exists", "/target/link")
+ h5["broken_link/external_broken_link"] = h5py.ExternalLink(externalFilename, "/target/not_exists")
+ h5["broken_link/soft_broken_link"] = h5py.SoftLink("/group/not_exists")
+ h5["broken_link/soft_link_to_broken_link"] = h5py.SoftLink("/group/not_exists")
+ h5.close()
+
+ return filename
+
+ @classmethod
+ def createModel(cls, h5pyFile):
+ model = hdf5.Hdf5TreeModel()
+ model.insertH5pyObject(h5pyFile)
+ return model
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.model = None
+ cls.h5File.close()
+ shutil.rmtree(cls.tmpDirectory)
+ super(TestH5Node, cls).tearDownClass()
+
+ def getIndexFromPath(self, model, path):
+ """
+ :param qt.QAbstractItemModel: model
+ """
+ index = qt.QModelIndex()
+ for name in path:
+ for row in range(model.rowCount(index)):
+ i = model.index(row, 0, index)
+ label = model.data(i)
+ if label == name:
+ index = i
+ break
+ else:
+ raise RuntimeError("Path not found")
+ return index
+
+ def getH5NodeFromPath(self, model, path):
+ index = self.getIndexFromPath(model, path)
+ item = model.data(index, hdf5.Hdf5TreeModel.H5PY_ITEM_ROLE)
+ h5node = hdf5.H5Node(item)
+ return h5node
+
+ def testFile(self):
+ path = ["base.h5"]
+ h5node = self.getH5NodeFromPath(self.model, path)
+
+ self.assertEqual(h5node.physical_filename, h5node.local_filename)
+ self.assertIn("base.h5", h5node.physical_filename)
+ self.assertEqual(h5node.physical_basename, "")
+ self.assertEqual(h5node.physical_name, "/")
+ self.assertEqual(h5node.local_basename, "")
+ self.assertEqual(h5node.local_name, "/")
+
+ def testGroup(self):
+ path = ["base.h5", "group"]
+ h5node = self.getH5NodeFromPath(self.model, path)
+
+ self.assertEqual(h5node.physical_filename, h5node.local_filename)
+ self.assertIn("base.h5", h5node.physical_filename)
+ self.assertEqual(h5node.physical_basename, "group")
+ self.assertEqual(h5node.physical_name, "/group")
+ self.assertEqual(h5node.local_basename, "group")
+ self.assertEqual(h5node.local_name, "/group")
+
+ def testDataset(self):
+ path = ["base.h5", "group", "dataset"]
+ h5node = self.getH5NodeFromPath(self.model, path)
+
+ self.assertEqual(h5node.physical_filename, h5node.local_filename)
+ self.assertIn("base.h5", h5node.physical_filename)
+ self.assertEqual(h5node.physical_basename, "dataset")
+ self.assertEqual(h5node.physical_name, "/group/dataset")
+ self.assertEqual(h5node.local_basename, "dataset")
+ self.assertEqual(h5node.local_name, "/group/dataset")
+
+ def testSoftLink(self):
+ path = ["base.h5", "link", "soft_link"]
+ h5node = self.getH5NodeFromPath(self.model, path)
+
+ self.assertEqual(h5node.physical_filename, h5node.local_filename)
+ self.assertIn("base.h5", h5node.physical_filename)
+ self.assertEqual(h5node.physical_basename, "dataset")
+ self.assertEqual(h5node.physical_name, "/group/dataset")
+ self.assertEqual(h5node.local_basename, "soft_link")
+ self.assertEqual(h5node.local_name, "/link/soft_link")
+
+ def testSoftLinkToLink(self):
+ path = ["base.h5", "link", "soft_link_to_link"]
+ h5node = self.getH5NodeFromPath(self.model, path)
+
+ self.assertEqual(h5node.physical_filename, h5node.local_filename)
+ self.assertIn("base.h5", h5node.physical_filename)
+ self.assertEqual(h5node.physical_basename, "dataset")
+ self.assertEqual(h5node.physical_name, "/group/dataset")
+ self.assertEqual(h5node.local_basename, "soft_link_to_link")
+ self.assertEqual(h5node.local_name, "/link/soft_link_to_link")
+
+ def testExternalLink(self):
+ path = ["base.h5", "link", "external_link"]
+ h5node = self.getH5NodeFromPath(self.model, path)
+
+ self.assertNotEqual(h5node.physical_filename, h5node.local_filename)
+ self.assertIn("base.h5", h5node.local_filename)
+ self.assertIn("base__external.h5", h5node.physical_filename)
+ self.assertEqual(h5node.physical_basename, "dataset")
+ self.assertEqual(h5node.physical_name, "/target/dataset")
+ self.assertEqual(h5node.local_basename, "external_link")
+ self.assertEqual(h5node.local_name, "/link/external_link")
+
+ def testExternalLinkToLink(self):
+ path = ["base.h5", "link", "external_link_to_link"]
+ h5node = self.getH5NodeFromPath(self.model, path)
+
+ self.assertNotEqual(h5node.physical_filename, h5node.local_filename)
+ self.assertIn("base.h5", h5node.local_filename)
+ self.assertIn("base__external.h5", h5node.physical_filename)
+
+ self.assertNotEqual(h5node.physical_filename, h5node.local_filename)
+ self.assertEqual(h5node.physical_basename, "dataset")
+ self.assertEqual(h5node.physical_name, "/target/dataset")
+ self.assertEqual(h5node.local_basename, "external_link_to_link")
+ self.assertEqual(h5node.local_name, "/link/external_link_to_link")
+
+ def testExternalBrokenFile(self):
+ path = ["base.h5", "broken_link", "external_broken_file"]
+ h5node = self.getH5NodeFromPath(self.model, path)
+
+ self.assertNotEqual(h5node.physical_filename, h5node.local_filename)
+ self.assertIn("base.h5", h5node.local_filename)
+ self.assertIn("not_exists", h5node.physical_filename)
+ self.assertEqual(h5node.physical_basename, "link")
+ self.assertEqual(h5node.physical_name, "/target/link")
+ self.assertEqual(h5node.local_basename, "external_broken_file")
+ self.assertEqual(h5node.local_name, "/broken_link/external_broken_file")
+
+ def testExternalBrokenLink(self):
+ path = ["base.h5", "broken_link", "external_broken_link"]
+ h5node = self.getH5NodeFromPath(self.model, path)
+
+ self.assertNotEqual(h5node.physical_filename, h5node.local_filename)
+ self.assertIn("base.h5", h5node.local_filename)
+ self.assertIn("__external", h5node.physical_filename)
+ self.assertEqual(h5node.physical_basename, "not_exists")
+ self.assertEqual(h5node.physical_name, "/target/not_exists")
+ self.assertEqual(h5node.local_basename, "external_broken_link")
+ self.assertEqual(h5node.local_name, "/broken_link/external_broken_link")
+
+ def testSoftBrokenLink(self):
+ path = ["base.h5", "broken_link", "soft_broken_link"]
+ h5node = self.getH5NodeFromPath(self.model, path)
+
+ self.assertEqual(h5node.physical_filename, h5node.local_filename)
+ self.assertIn("base.h5", h5node.physical_filename)
+ self.assertEqual(h5node.physical_basename, "not_exists")
+ self.assertEqual(h5node.physical_name, "/group/not_exists")
+ self.assertEqual(h5node.local_basename, "soft_broken_link")
+ self.assertEqual(h5node.local_name, "/broken_link/soft_broken_link")
+
+ def testSoftLinkToBrokenLink(self):
+ path = ["base.h5", "broken_link", "soft_link_to_broken_link"]
+ h5node = self.getH5NodeFromPath(self.model, path)
+
+ self.assertEqual(h5node.physical_filename, h5node.local_filename)
+ self.assertIn("base.h5", h5node.physical_filename)
+ self.assertEqual(h5node.physical_basename, "not_exists")
+ self.assertEqual(h5node.physical_name, "/group/not_exists")
+ self.assertEqual(h5node.local_basename, "soft_link_to_broken_link")
+ self.assertEqual(h5node.local_name, "/broken_link/soft_link_to_broken_link")
+
+ def testDatasetFromSoftLinkToGroup(self):
+ path = ["base.h5", "link", "soft_link_to_group", "dataset"]
+ h5node = self.getH5NodeFromPath(self.model, path)
+
+ self.assertEqual(h5node.physical_filename, h5node.local_filename)
+ self.assertIn("base.h5", h5node.physical_filename)
+ self.assertEqual(h5node.physical_basename, "dataset")
+ self.assertEqual(h5node.physical_name, "/group/dataset")
+ self.assertEqual(h5node.local_basename, "dataset")
+ self.assertEqual(h5node.local_name, "/link/soft_link_to_group/dataset")
+
+ def testDatasetFromSoftLinkToFile(self):
+ path = ["base.h5", "link", "soft_link_to_file", "link", "soft_link_to_group", "dataset"]
+ h5node = self.getH5NodeFromPath(self.model, path)
+
+ self.assertEqual(h5node.physical_filename, h5node.local_filename)
+ self.assertIn("base.h5", h5node.physical_filename)
+ self.assertEqual(h5node.physical_basename, "dataset")
+ self.assertEqual(h5node.physical_name, "/group/dataset")
+ self.assertEqual(h5node.local_basename, "dataset")
+ self.assertEqual(h5node.local_name, "/link/soft_link_to_file/link/soft_link_to_group/dataset")
+
+
+class TestHdf5TreeView(TestCaseQt):
"""Test to check that icons module."""
def setUp(self):
- super(TestHdf5, self).setUp()
+ super(TestHdf5TreeView, self).setUp()
if h5py is None:
self.skipTest("h5py is not available")
@@ -464,15 +696,147 @@ class TestHdf5(TestCaseQt):
view = hdf5.Hdf5TreeView()
view._createContextMenu(qt.QPoint(0, 0))
+ def testSelection_Simple(self):
+ tree = commonh5.File("/foo/bar/1.mock", "w")
+ item = tree.create_group("a/b/c/d")
+ item.create_group("e").create_group("f")
+
+ model = hdf5.Hdf5TreeModel()
+ model.insertH5pyObject(tree)
+ view = hdf5.Hdf5TreeView()
+ view.setModel(model)
+ view.setSelectedH5Node(item)
+
+ selected = list(view.selectedH5Nodes())[0]
+ self.assertIs(item, selected.h5py_object)
+
+ def testSelection_NotFound(self):
+ tree2 = commonh5.File("/foo/bar/2.mock", "w")
+ tree = commonh5.File("/foo/bar/1.mock", "w")
+ item = tree.create_group("a/b/c/d")
+ item.create_group("e").create_group("f")
+
+ model = hdf5.Hdf5TreeModel()
+ model.insertH5pyObject(tree)
+ view = hdf5.Hdf5TreeView()
+ view.setModel(model)
+ view.setSelectedH5Node(tree2)
+
+ selection = list(view.selectedH5Nodes())
+ self.assertEqual(len(selection), 0)
+
+ def testSelection_ManyGroupFromSameFile(self):
+ tree = commonh5.File("/foo/bar/1.mock", "w")
+ group1 = tree.create_group("a1")
+ group2 = tree.create_group("a2")
+ group3 = tree.create_group("a3")
+ group1.create_group("b/c/d")
+ item = group2.create_group("b/c/d")
+ group3.create_group("b/c/d")
+
+ model = hdf5.Hdf5TreeModel()
+ model.insertH5pyObject(group1)
+ model.insertH5pyObject(group2)
+ model.insertH5pyObject(group3)
+ view = hdf5.Hdf5TreeView()
+ view.setModel(model)
+ view.setSelectedH5Node(item)
+
+ selected = list(view.selectedH5Nodes())[0]
+ self.assertIs(item, selected.h5py_object)
+
+ def testSelection_RootFromSubTree(self):
+ tree = commonh5.File("/foo/bar/1.mock", "w")
+ group = tree.create_group("a1")
+ group.create_group("b/c/d")
+
+ model = hdf5.Hdf5TreeModel()
+ model.insertH5pyObject(group)
+ view = hdf5.Hdf5TreeView()
+ view.setModel(model)
+ view.setSelectedH5Node(group)
+
+ selected = list(view.selectedH5Nodes())[0]
+ self.assertIs(group, selected.h5py_object)
+
+ def testSelection_FileFromSubTree(self):
+ tree = commonh5.File("/foo/bar/1.mock", "w")
+ group = tree.create_group("a1")
+ group.create_group("b").create_group("b").create_group("d")
+
+ model = hdf5.Hdf5TreeModel()
+ model.insertH5pyObject(group)
+ view = hdf5.Hdf5TreeView()
+ view.setModel(model)
+ view.setSelectedH5Node(tree)
+
+ selection = list(view.selectedH5Nodes())
+ self.assertEquals(len(selection), 0)
+
+ def testSelection_Tree(self):
+ tree1 = commonh5.File("/foo/bar/1.mock", "w")
+ tree2 = commonh5.File("/foo/bar/2.mock", "w")
+ tree3 = commonh5.File("/foo/bar/3.mock", "w")
+ tree1.create_group("a/b/c")
+ tree2.create_group("a/b/c")
+ tree3.create_group("a/b/c")
+ item = tree2
+
+ model = hdf5.Hdf5TreeModel()
+ model.insertH5pyObject(tree1)
+ model.insertH5pyObject(tree2)
+ model.insertH5pyObject(tree3)
+ view = hdf5.Hdf5TreeView()
+ view.setModel(model)
+ view.setSelectedH5Node(item)
+
+ selected = list(view.selectedH5Nodes())[0]
+ self.assertIs(item, selected.h5py_object)
+
+ def testSelection_RecurssiveLink(self):
+ """
+ Recurssive link selection
+
+ This example is not really working as expected cause commonh5 do not
+ support recurssive links.
+ But item.name == "/a/b" and the result is found.
+ """
+ tree = commonh5.File("/foo/bar/1.mock", "w")
+ group = tree.create_group("a")
+ group.add_node(commonh5.SoftLink("b", "/"))
+
+ item = tree["/a/b/a/b/a/b/a/b/a/b/a/b/a/b/a/b"]
+
+ model = hdf5.Hdf5TreeModel()
+ model.insertH5pyObject(tree)
+ view = hdf5.Hdf5TreeView()
+ view.setModel(model)
+ view.setSelectedH5Node(item)
+
+ selected = list(view.selectedH5Nodes())[0]
+ self.assertEqual(item.name, selected.h5py_object.name)
+
+ def testSelection_SelectNone(self):
+ tree = commonh5.File("/foo/bar/1.mock", "w")
+
+ model = hdf5.Hdf5TreeModel()
+ model.insertH5pyObject(tree)
+ view = hdf5.Hdf5TreeView()
+ view.setModel(model)
+ view.setSelectedH5Node(tree)
+ view.setSelectedH5Node(None)
+
+ selection = list(view.selectedH5Nodes())
+ self.assertEqual(len(selection), 0)
+
def suite():
test_suite = unittest.TestSuite()
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestHdf5TreeModel))
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestNexusSortFilterProxyModel))
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestHdf5))
+ loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loadTests(TestHdf5TreeModel))
+ test_suite.addTest(loadTests(TestNexusSortFilterProxyModel))
+ test_suite.addTest(loadTests(TestHdf5TreeView))
+ test_suite.addTest(loadTests(TestH5Node))
return test_suite
diff --git a/silx/gui/icons.py b/silx/gui/icons.py
index eaf83b8..07654c1 100644
--- a/silx/gui/icons.py
+++ b/silx/gui/icons.py
@@ -29,15 +29,16 @@ Use :func:`getQIcon` to create Qt QIcon from the name identifying an icon.
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "25/04/2017"
+__date__ = "06/09/2017"
+import os
import logging
import weakref
from . import qt
-from silx.resources import resource_filename
+import silx.resources
from silx.utils import weakref as silxweakref
-from silx.utils.decorators import deprecated
+from silx.utils.deprecation import deprecated
_logger = logging.getLogger(__name__)
@@ -192,7 +193,7 @@ class MultiImageAnimatedIcon(AbstractAnimatedIcon):
self.__frames = []
for i in range(100):
try:
- pixmap = getQPixmap("animated/%s-%02d" % (filename, i))
+ pixmap = getQPixmap("%s/%02d" % (filename, i))
except ValueError:
break
icon = qt.QIcon(pixmap)
@@ -258,13 +259,22 @@ def getWaitIcon():
def getAnimatedIcon(name):
- """Create an AbstractAnimatedIcon from a name.
+ """Create an AbstractAnimatedIcon from a resource name.
+
+ The resource name can be prefixed by the name of a resource directory. For
+ example "silx:foo.png" identify the resource "foo.png" from the resource
+ directory "silx".
+
+ If no prefix are specified, the file with be returned from the silx
+ resource directory with a specific path "gui/icons".
+
+ See also :func:`silx.resources.register_resource_directory`.
Try to load a mng or a gif file, then try to load a multi-image animated
icon.
- In Qt5 mng or gif are not used. It does not take care very well of the
- transparency.
+ In Qt5 mng or gif are not used, because the transparency is not very well
+ managed.
:param str name: Name of the icon, in one of the defined icons
in this module.
@@ -302,6 +312,15 @@ def getAnimatedIcon(name):
def getQIcon(name):
"""Create a QIcon from its name.
+ The resource name can be prefixed by the name of a resource directory. For
+ example "silx:foo.png" identify the resource "foo.png" from the resource
+ directory "silx".
+
+ If no prefix are specified, the file with be returned from the silx
+ resource directory with a specific path "gui/icons".
+
+ See also :func:`silx.resources.register_resource_directory`.
+
:param str name: Name of the icon, in one of the defined icons
in this module.
:return: Corresponding QIcon
@@ -319,6 +338,15 @@ def getQIcon(name):
def getQPixmap(name):
"""Create a QPixmap from its name.
+ The resource name can be prefixed by the name of a resource directory. For
+ example "silx:foo.png" identify the resource "foo.png" from the resource
+ directory "silx".
+
+ If no prefix are specified, the file with be returned from the silx
+ resource directory with a specific path "gui/icons".
+
+ See also :func:`silx.resources.register_resource_directory`.
+
:param str name: Name of the icon, in one of the defined icons
in this module.
:return: Corresponding QPixmap
@@ -332,6 +360,15 @@ def getQFile(name):
"""Create a QFile from an icon name. Filename is found
according to supported Qt formats.
+ The resource name can be prefixed by the name of a resource directory. For
+ example "silx:foo.png" identify the resource "foo.png" from the resource
+ directory "silx".
+
+ If no prefix are specified, the file with be returned from the silx
+ resource directory with a specific path "gui/icons".
+
+ See also :func:`silx.resources.register_resource_directory`.
+
:param str name: Name of the icon, in one of the defined icons
in this module.
:return: Corresponding QFile
@@ -353,7 +390,8 @@ def getQFile(name):
for format_ in _supported_formats:
format_ = str(format_)
- filename = resource_filename('gui/icons/%s.%s' % (name, format_))
+ filename = silx.resources._resource_filename('%s.%s' % (name, format_),
+ default_directory=os.path.join('gui', 'icons'))
qfile = qt.QFile(filename)
if qfile.exists():
return qfile
diff --git a/silx/gui/plot/ColorBar.py b/silx/gui/plot/ColorBar.py
index 93e3c36..8f4bde2 100644
--- a/silx/gui/plot/ColorBar.py
+++ b/silx/gui/plot/ColorBar.py
@@ -33,11 +33,8 @@ __date__ = "11/04/2017"
import logging
import numpy
from ._utils import ticklayout
-from ._utils import clipColormapLogRange
-
-
-from .. import qt
-from silx.gui.plot import Colors
+from .. import qt, icons
+from silx.gui.plot import Colormap
_logger = logging.getLogger(__name__)
@@ -66,12 +63,17 @@ class ColorBarWidget(qt.QWidget):
:param parent: See :class:`QWidget`
:param plot: PlotWidget the colorbar is attached to (optional)
- :param str legend: the label to set to the colormap
+ :param str legend: the label to set to the colorbar
"""
def __init__(self, parent=None, plot=None, legend=None):
- super(ColorBarWidget, self).__init__(parent)
+ self._isConnected = False
self._plot = None
+ self._viewAction = None
+ self._colormap = None
+ self._data = None
+
+ super(ColorBarWidget, self).__init__(parent)
self.__buildGUI()
self.setLegend(legend)
@@ -90,8 +92,6 @@ class ColorBarWidget(qt.QWidget):
self.layout().addWidget(self.legend)
self.layout().setSizeConstraint(qt.QLayout.SetMinAndMaxSize)
- self.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Expanding)
- self.layout().setContentsMargins(0, 0, 0, 0)
def getPlot(self):
"""Returns the :class:`Plot` associated to this widget or None"""
@@ -100,46 +100,75 @@ class ColorBarWidget(qt.QWidget):
def setPlot(self, plot):
"""Associate a plot to the ColorBar
- :param plot: the plot to associate with the colorbar. If None will remove
- any connection with a previous plot.
+ :param plot: the plot to associate with the colorbar.
+ If None will remove any connection with a previous plot.
"""
- # removing previous plot if any
- if self._plot is not None:
- self._plot.sigActiveImageChanged.disconnect(self._activeImageChanged)
-
- # setting the new plot
+ self._disconnectPlot()
self._plot = plot
- if self._plot is not None:
+ self._connectPlot()
+
+ def _disconnectPlot(self):
+ """Disconnect from Plot signals"""
+ if self._plot is not None and self._isConnected:
+ self._isConnected = False
+ self._plot.sigActiveImageChanged.disconnect(
+ self._activeImageChanged)
+ self._plot.sigPlotSignal.disconnect(self._defaultColormapChanged)
+
+ def _connectPlot(self):
+ """Connect to Plot signals"""
+ if self._plot is not None and not self._isConnected:
+ activeImageLegend = self._plot.getActiveImage(just_legend=True)
+ if activeImageLegend is None: # Show plot default colormap
+ self._syncWithDefaultColormap()
+ else: # Show active image colormap
+ self._activeImageChanged(None, activeImageLegend)
self._plot.sigActiveImageChanged.connect(self._activeImageChanged)
- self._activeImageChanged(self._plot.getActiveImage(just_legend=True))
+ self._plot.sigPlotSignal.connect(self._defaultColormapChanged)
+ self._isConnected = True
+
+ 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):
- """Return the colormap displayed in the colorbar as a dict.
+ """
+
+ :return: the :class:`.Colormap` colormap displayed in the colorbar.
- It returns None if no colormap is set.
- See :class:`silx.gui.plot.Plot` documentation for the description of the colormap
- dict description.
"""
- return self._colormap.copy()
+ return self.getColorScaleBar().getColormap()
- def setColormap(self, colormap):
+ def setColormap(self, colormap, data=None):
"""Set the colormap to be displayed.
- :param dict colormap: The colormap to apply on the ColorBarWidget
+ :param colormap: The colormap to apply on the
+ ColorBarWidget
+ :type colormap: :class:`.Colormap`
+ :param numpy.ndarray data: the data to display, needed if the colormap
+ require an autoscale
"""
+ self._data = data
+ self.getColorScaleBar().setColormap(colormap=colormap,
+ data=data)
+ if self._colormap is not None:
+ self._colormap.sigChanged.disconnect(self._colormapHasChanged)
self._colormap = colormap
- if self._colormap is None:
- return
-
- if self._colormap['normalization'] not in ('log', 'linear'):
- raise ValueError('Wrong normalization %s' % self._colormap['normalization'])
+ if self._colormap is not None:
+ self._colormap.sigChanged.connect(self._colormapHasChanged)
- if self._colormap['normalization'] is 'log':
- if self._colormap['vmin'] < 1. or self._colormap['vmax'] < 1.:
- _logger.warning('Log colormap with bound <= 1: changing bounds.')
- clipColormapLogRange(colormap)
-
- self.getColorScaleBar().setColormap(self._colormap)
+ def _colormapHasChanged(self):
+ """handler of the Colormap.sigChanged signal
+ """
+ assert self._colormap is not None
+ self.setColormap(colormap=self._colormap,
+ data=self._data)
def setLegend(self, legend):
"""Set the legend displayed along the colorbar
@@ -150,7 +179,7 @@ class ColorBarWidget(qt.QWidget):
self.legend.hide()
self.legend.setText("")
else:
- assert(type(legend) is str)
+ assert type(legend) is str
self.legend.show()
self.legend.setText(legend)
@@ -163,10 +192,10 @@ class ColorBarWidget(qt.QWidget):
"""
return self.legend.getText()
- def _activeImageChanged(self, legend):
+ def _activeImageChanged(self, previous, legend):
"""Handle plot active curve changed"""
- if legend is None: # No active image, display default colormap
- self._syncWithDefaultColormap()
+ if legend is None: # No active image, display no colormap
+ self.setColormap(colormap=None)
return
# Sync with active image
@@ -174,32 +203,25 @@ class ColorBarWidget(qt.QWidget):
# RGB(A) image, display default colormap
if image.ndim != 2:
- self._syncWithDefaultColormap()
+ self.setColormap(colormap=None)
return
# data image, sync with image colormap
# do we need the copy here : used in the case we are changing
# vmin and vmax but should have already be done by the plot
- cmap = self._plot.getActiveImage().getColormap().copy()
- if cmap['autoscale']:
- if cmap['normalization'] == 'log':
- data = image[
- numpy.logical_and(image > 0, numpy.isfinite(image))]
- else:
- data = image[numpy.isfinite(image)]
- cmap['vmin'], cmap['vmax'] = data.min(), data.max()
-
- self.setColormap(cmap)
+ self.setColormap(colormap=self._plot.getActiveImage().getColormap(),
+ data=image)
- def _defaultColormapChanged(self):
+ def _defaultColormapChanged(self, event):
"""Handle plot default colormap changed"""
- if self._plot.getActiveImage() is None:
+ if (event['event'] == 'defaultColormapChanged' and
+ self._plot.getActiveImage() is None):
# No active image, take default colormap update into account
self._syncWithDefaultColormap()
- def _syncWithDefaultColormap(self):
+ def _syncWithDefaultColormap(self, data=None):
"""Update colorbar according to plot default colormap"""
- self.setColormap(self._plot.getDefaultColormap())
+ self.setColormap(self._plot.getDefaultColormap(), data)
def getColorScaleBar(self):
"""
@@ -208,6 +230,21 @@ 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
@@ -251,12 +288,11 @@ class ColorScaleBar(qt.QWidget):
To run the following sample code, a QApplication must be initialized.
- >>> colormap={'name':'gray',
- ... 'normalization':'log',
- ... 'vmin':1,
- ... 'vmax':100000,
- ... 'autoscale':False
- ... }
+ >>> colormap = Colormap(name='gray',
+ ... norm='log',
+ ... vmin=1,
+ ... vmax=100000,
+ ... )
>>> colorscale = ColorScaleBar(parent=None,
... colormap=colormap )
>>> colorscale.show()
@@ -272,15 +308,8 @@ class ColorScaleBar(qt.QWidget):
"""The tick bar need a margin to display all labels at the correct place.
So the ColorScale should have the same margin in order for both to fit"""
- _MIN_LIM_SCI_FORM = -1000
- """Used for the min and max label to know when we should display it under
- the scientific form"""
-
- _MAX_LIM_SCI_FORM = 1000
- """Used for the min and max label to know when we should display it under
- the scientific form"""
-
- def __init__(self, parent=None, colormap=None, displayTicksValues=True):
+ def __init__(self, parent=None, colormap=None, data=None,
+ displayTicksValues=True):
super(ColorScaleBar, self).__init__(parent)
self.minVal = None
@@ -292,33 +321,41 @@ class ColorScaleBar(qt.QWidget):
# create the left side group (ColorScale)
self.colorScale = _ColorScale(colormap=colormap,
- parent=self,
- margin=ColorScaleBar._TEXT_MARGIN)
+ data=data,
+ parent=self,
+ margin=ColorScaleBar._TEXT_MARGIN)
+ if colormap:
+ vmin, vmax = colormap.getColormapRange(data)
+ else:
+ vmin, vmax = Colormap.DEFAULT_MIN_LIN, Colormap.DEFAULT_MAX_LIN
- self.tickbar = _TickBar(vmin=colormap['vmin'] if colormap else 0.0,
- vmax=colormap['vmax'] if colormap else 1.0,
- norm=colormap['normalization'] if colormap else 'linear',
- parent=self,
- displayValues=displayTicksValues,
- margin=ColorScaleBar._TEXT_MARGIN)
+ norm = colormap.getNormalization() if colormap else Colormap.Colormap.LINEAR
+ self.tickbar = _TickBar(vmin=vmin,
+ vmax=vmax,
+ norm=norm,
+ parent=self,
+ displayValues=displayTicksValues,
+ margin=ColorScaleBar._TEXT_MARGIN)
- self.layout().addWidget(self.tickbar, 1, 0)
- self.layout().addWidget(self.colorScale, 1, 1)
+ self.layout().addWidget(self.tickbar, 1, 0, 1, 1, qt.Qt.AlignRight)
+ self.layout().addWidget(self.colorScale, 1, 1, qt.Qt.AlignLeft)
self.layout().setContentsMargins(0, 0, 0, 0)
self.layout().setSpacing(0)
# max label
self._maxLabel = qt.QLabel(str(1.0), parent=self)
- self._maxLabel.setAlignment(qt.Qt.AlignHCenter)
- self._maxLabel.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Minimum)
- self.layout().addWidget(self._maxLabel, 0, 1)
+ self._maxLabel.setToolTip(str(0.0))
+ self.layout().addWidget(self._maxLabel, 0, 0, 1, 2, qt.Qt.AlignRight)
# min label
self._minLabel = qt.QLabel(str(0.0), parent=self)
- self._minLabel.setAlignment(qt.Qt.AlignHCenter)
- self._minLabel.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Minimum)
- self.layout().addWidget(self._minLabel, 2, 1)
+ self._minLabel.setToolTip(str(0.0))
+ self.layout().addWidget(self._minLabel, 2, 0, 1, 2, qt.Qt.AlignRight)
+
+ self.layout().setSizeConstraint(qt.QLayout.SetMinAndMaxSize)
+ self.layout().setColumnStretch(0, 1)
+ self.layout().setRowStretch(1, 1)
def getTickBar(self):
"""
@@ -334,19 +371,34 @@ class ColorScaleBar(qt.QWidget):
"""
return self.colorScale
- def setColormap(self, colormap):
+ def getColormap(self):
+ """
+
+ :returns: the colormap.
+ :rtype: :class:`.Colormap`
+ """
+ return self.colorScale.getColormap()
+
+ def setColormap(self, colormap, data=None):
"""Set the new colormap to be displayed
- :param dict colormap: the colormap to set
+ :param Colormap colormap: the colormap to set
+ :param numpy.ndarray data: the data to display, needed if the colormap
+ require an autoscale
"""
- if colormap is not None:
- self.colorScale.setColormap(colormap)
+ self.colorScale.setColormap(colormap, data)
- self.tickbar.update(vmin=colormap['vmin'],
- vmax=colormap['vmax'],
- norm=colormap['normalization'])
+ if colormap is not None:
+ vmin, vmax = colormap.getColormapRange(data)
+ norm = colormap.getNormalization()
+ else:
+ vmin, vmax = None, None
+ norm = None
- self._setMinMaxLabels(colormap['vmin'], colormap['vmax'])
+ self.tickbar.update(vmin=vmin,
+ vmax=vmax,
+ norm=norm)
+ self._setMinMaxLabels(vmin, vmax)
def setMinMaxVisible(self, val=True):
"""Change visibility of the min label and the max label
@@ -359,17 +411,29 @@ class ColorScaleBar(qt.QWidget):
def _updateMinMax(self):
"""Update the min and max label if we are in the case of the
configuration 'minMaxValueOnly'"""
- if self._minLabel is not None and self._maxLabel is not None:
- if self.minVal is not None:
- if ColorScaleBar._MIN_LIM_SCI_FORM <= self.minVal <= ColorScaleBar._MAX_LIM_SCI_FORM:
- self._minLabel.setText(str(self.minVal))
- else:
- self._minLabel.setText("{0:.0e}".format(self.minVal))
- if self.maxVal is not None:
- if ColorScaleBar._MIN_LIM_SCI_FORM <= self.maxVal <= ColorScaleBar._MAX_LIM_SCI_FORM:
- self._maxLabel.setText(str(self.maxVal))
- else:
- self._maxLabel.setText("{0:.0e}".format(self.maxVal))
+ if self.minVal is None:
+ text, tooltip = '', ''
+ else:
+ if self.minVal == 0 or 0 <= numpy.log10(abs(self.minVal)) < 7:
+ text = '%.7g' % self.minVal
+ else:
+ text = '%.2e' % self.minVal
+ tooltip = repr(self.minVal)
+
+ self._minLabel.setText(text)
+ self._minLabel.setToolTip(tooltip)
+
+ if self.maxVal is None:
+ text, tooltip = '', ''
+ else:
+ if self.maxVal == 0 or 0 <= numpy.log10(abs(self.maxVal)) < 7:
+ text = '%.7g' % self.maxVal
+ else:
+ text = '%.2e' % self.maxVal
+ tooltip = repr(self.maxVal)
+
+ self._maxLabel.setText(text)
+ self._maxLabel.setToolTip(tooltip)
def _setMinMaxLabels(self, minVal, maxVal):
"""Change the value of the min and max labels to be displayed.
@@ -400,12 +464,11 @@ class _ColorScale(qt.QWidget):
To run the following sample code, a QApplication must be initialized.
- >>> colormap={'name':'viridis',
- ... 'normalization':'log',
- ... 'vmin':1,
- ... 'vmax':100000,
- ... 'autoscale':False
- ... }
+ >>> colormap = Colormap(name='viridis',
+ ... norm='log',
+ ... vmin=1,
+ ... vmax=100000,
+ ... )
>>> colorscale = ColorScale(parent=None,
... colormap=colormap)
>>> colorscale.show()
@@ -423,83 +486,94 @@ class _ColorScale(qt.QWidget):
_NB_CONTROL_POINTS = 256
- def __init__(self, colormap, parent=None, margin=5):
+ def __init__(self, colormap, parent=None, margin=5, data=None):
qt.QWidget.__init__(self, parent)
- self.colormap = None
- self.setColormap(colormap)
+ self._colormap = None
+ self.margin = margin
+ self.setColormap(colormap, data)
self.setLayout(qt.QVBoxLayout())
- self.setSizePolicy(qt.QSizePolicy.Expanding, qt.QSizePolicy.Expanding)
+ self.setSizePolicy(qt.QSizePolicy.Fixed, qt.QSizePolicy.Expanding)
# needed to get the mouse event without waiting for button click
self.setMouseTracking(True)
self.setMargin(margin)
self.setContentsMargins(0, 0, 0, 0)
- def setColormap(self, colormap):
+ self.setMinimumHeight(self._NB_CONTROL_POINTS // 2 + 2 * self.margin)
+ self.setFixedWidth(25)
+
+ def setColormap(self, colormap, data=None):
"""Set the new colormap to be displayed
:param dict colormap: the colormap to set
+ :param data: Optional data for which to compute colormap range.
"""
- if colormap is None:
- return
+ self._colormap = colormap
+ self.setEnabled(colormap is not None)
- if colormap['normalization'] not in ('log', 'linear'):
- raise ValueError("Unrecognized normalization, should be 'linear' or 'log'")
+ if colormap is None:
+ self.vmin, self.vmax = None, None
+ else:
+ assert colormap.getNormalization() in Colormap.Colormap.NORMALIZATIONS
+ self.vmin, self.vmax = self._colormap.getColormapRange(data=data)
+ self._updateColorGradient()
+ self.update()
- if colormap['normalization'] is 'log':
- if not (colormap['vmin'] > 0 and colormap['vmax'] > 0):
- raise ValueError('vmin and vmax should be positives')
- self.colormap = colormap
- self._computeColorPoints()
+ def getColormap(self):
+ """Returns the colormap
- def _computeColorPoints(self):
- """Compute the color points for the gradient
+ :rtype: :class:`.Colormap`
"""
- if self.colormap is None:
+ return None if self._colormap is None else self._colormap
+
+ def _updateColorGradient(self):
+ """Compute the color gradient"""
+ colormap = self.getColormap()
+ if colormap is None:
return
- vmin = self.colormap['vmin']
- vmax = self.colormap['vmax']
- steps = (vmax - vmin)/float(_ColorScale._NB_CONTROL_POINTS)
- self.ctrPoints = numpy.arange(vmin, vmax, steps)
- self.colorsCtrPts = Colors.applyColormapToData(self.ctrPoints,
- name=self.colormap['name'],
- normalization='linear',
- autoscale=self.colormap['autoscale'],
- vmin=vmin,
- vmax=vmax)
+ 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)
+ self._gradient = qt.QLinearGradient(0, 1, 0, 0)
+ self._gradient.setCoordinateMode(qt.QGradient.StretchToDeviceMode)
+ self._gradient.setStops(
+ [(i, qt.QColor(*color)) for i, color in zip(indices, colors)]
+ )
def paintEvent(self, event):
""""""
- qt.QWidget.paintEvent(self, event)
- if self.colormap is None:
- return
-
- vmin = self.colormap['vmin']
- vmax = self.colormap['vmax']
-
painter = qt.QPainter(self)
- gradient = qt.QLinearGradient(0, 0, 0, self.rect().height() - 2*self.margin)
- for iPt, pt in enumerate(self.ctrPoints):
- colormapPosition = 1 - (pt-vmin) / (vmax-vmin)
- assert(colormapPosition >= 0.0)
- assert(colormapPosition <= 1.0)
- gradient.setColorAt(colormapPosition, qt.QColor(*(self.colorsCtrPts[iPt])))
+ if self.getColormap() is not None:
+ painter.setBrush(self._gradient)
+ penColor = self.palette().color(qt.QPalette.Active,
+ qt.QPalette.Foreground)
+ else:
+ penColor = self.palette().color(qt.QPalette.Disabled,
+ qt.QPalette.Foreground)
+ painter.setPen(penColor)
- painter.setBrush(gradient)
- painter.drawRect(
- qt.QRect(0, self.margin, self.width(), self.height() - 2.*self.margin))
+ painter.drawRect(qt.QRect(
+ 0,
+ self.margin,
+ self.width() - 1.,
+ self.height() - 2. * self.margin - 1.))
def mouseMoveEvent(self, event):
- """"""
- self.setToolTip(str(self.getValueFromRelativePosition(self._getRelativePosition(event.y()))))
+ tooltip = str(self.getValueFromRelativePosition(
+ self._getRelativePosition(event.y())))
+ qt.QToolTip.showText(event.globalPos(), tooltip, self)
super(_ColorScale, self).mouseMoveEvent(event)
def _getRelativePosition(self, yPixel):
"""yPixel : pixel position into _ColorScale widget reference
"""
# widgets are bottom-top referencial but we display in top-bottom referential
- return 1 - float(yPixel)/float(self.height() - 2*self.margin)
+ return 1. - (yPixel - self.margin) / float(self.height() - 2 * self.margin)
def getValueFromRelativePosition(self, value):
"""Return the value in the colorMap from a relative position in the
@@ -508,17 +582,22 @@ class _ColorScale(qt.QWidget):
:param value: float value in [0, 1]
:return: the value in [colormap['vmin'], colormap['vmax']]
"""
+ colormap = self.getColormap()
+ if colormap is None:
+ return
+
value = max(0.0, value)
value = min(value, 1.0)
- vmin = self.colormap['vmin']
- vmax = self.colormap['vmax']
- if self.colormap['normalization'] is 'linear':
+
+ vmin = self.vmin
+ vmax = self.vmax
+ if colormap.getNormalization() == Colormap.Colormap.LINEAR:
return vmin + (vmax - vmin) * value
- elif self.colormap['normalization'] is 'log':
+ elif colormap.getNormalization() == Colormap.Colormap.LOGARITHM:
rpos = (numpy.log10(vmax) - numpy.log10(vmin)) * value + numpy.log10(vmin)
return numpy.power(10., rpos)
else:
- err = "normalization type (%s) is not managed by the _ColorScale Widget" % self.colormap['normalization']
+ err = "normalization type (%s) is not managed by the _ColorScale Widget" % colormap['normalization']
raise ValueError(err)
def setMargin(self, margin):
@@ -529,6 +608,7 @@ class _ColorScale(qt.QWidget):
:param int margin: the margin to apply on the top and bottom.
"""
self.margin = margin
+ self.update()
class _TickBar(qt.QWidget):
@@ -536,7 +616,7 @@ class _TickBar(qt.QWidget):
To run the following sample code, a QApplication must be initialized.
- >>> bar = TickBar(1, 1000, norm='log', parent=None, displayValues=True)
+ >>> bar = _TickBar(1, 1000, norm='log', parent=None, displayValues=True)
>>> bar.show()
.. image:: img/tickbar.png
@@ -569,24 +649,19 @@ class _TickBar(qt.QWidget):
def __init__(self, vmin, vmax, norm, parent=None, displayValues=True,
nticks=None, margin=5):
super(_TickBar, self).__init__(parent)
+ self.margin = margin
+ self._nticks = None
+ self.ticks = ()
+ self.subTicks = ()
self._forcedDisplayType = None
self.ticksDensity = _TickBar.DEFAULT_TICK_DENSITY
self._vmin = vmin
self._vmax = vmax
- # TODO : should be grouped into a global function, called by all
- # logScale displayer to make sure we have the same behavior everywhere
- if self._vmin < 1. or self._vmax < 1.:
- _logger.warning(
- 'Log colormap with bound <= 1: changing bounds.')
- self._vmin, self._vmax = 1., 10.
-
self._norm = norm
self.displayValues = displayValues
self.setTicksNumber(nticks)
- self.setMargin(margin)
- self.setLayout(qt.QVBoxLayout())
self.setMargin(margin)
self.setContentsMargins(0, 0, 0, 0)
@@ -597,8 +672,8 @@ class _TickBar(qt.QWidget):
self._resetWidth()
def _resetWidth(self):
- self.width = _TickBar._WIDTH_DISP_VAL if self.displayValues else _TickBar._WIDTH_NO_DISP_VAL
- self.setFixedWidth(self.width)
+ width = self._WIDTH_DISP_VAL if self.displayValues else self._WIDTH_NO_DISP_VAL
+ self.setFixedWidth(width)
def update(self, vmin, vmax, norm):
self._vmin = vmin
@@ -623,7 +698,6 @@ class _TickBar(qt.QWidget):
optimal number of ticks from the tick density.
"""
self._nticks = nticks
- self.ticks = None
self.computeTicks()
qt.QWidget.update(self)
@@ -644,9 +718,13 @@ class _TickBar(qt.QWidget):
if nticks is None:
nticks = self._getOptimalNbTicks()
- if self._norm == 'log':
+ if self._vmin == self._vmax:
+ # No range: no ticks
+ self.ticks = ()
+ self.subTicks = ()
+ elif self._norm == Colormap.Colormap.LOGARITHM:
self._computeTicksLog(nticks)
- elif self._norm == 'linear':
+ elif self._norm == Colormap.Colormap.LINEAR:
self._computeTicksLin(nticks)
else:
err = 'TickBar - Wrong normalization %s' % self._norm
@@ -693,22 +771,19 @@ class _TickBar(qt.QWidget):
painter.setFont(font)
# paint ticks
- if self.ticks is not None:
- for val in self.ticks:
- self._paintTick(val, painter, majorTick=True)
-
- # paint subticks
- for val in self.subTicks:
- self._paintTick(val, painter, majorTick=False)
+ for val in self.ticks:
+ self._paintTick(val, painter, majorTick=True)
- qt.QWidget.paintEvent(self, event)
+ # paint subticks
+ for val in self.subTicks:
+ self._paintTick(val, painter, majorTick=False)
def _getRelativePosition(self, val):
"""Return the relative position of val according to min and max value
"""
- if self._norm == 'linear':
+ if self._norm == Colormap.Colormap.LINEAR:
return 1 - (val - self._vmin) / (self._vmax - self._vmin)
- elif self._norm == 'log':
+ elif self._norm == Colormap.Colormap.LOGARITHM:
return 1 - (numpy.log10(val) - numpy.log10(self._vmin))/(numpy.log10(self._vmax) - numpy.log(self._vmin))
else:
raise ValueError('Norm is not recognized')
@@ -720,7 +795,7 @@ class _TickBar(qt.QWidget):
with a smaller width
"""
fm = qt.QFontMetrics(painter.font())
- viewportHeight = self.rect().height() - self.margin * 2
+ viewportHeight = self.rect().height() - self.margin * 2 - 1
relativePos = self._getRelativePosition(val)
height = viewportHeight * relativePos
height += self.margin
@@ -728,9 +803,9 @@ class _TickBar(qt.QWidget):
if majorTick is False:
lineWidth /= 2
- painter.drawLine(qt.QLine(self.width - lineWidth,
+ painter.drawLine(qt.QLine(self.width() - lineWidth,
height,
- self.width,
+ self.width(),
height))
if self.displayValues and majorTick is True:
@@ -774,7 +849,6 @@ class _TickBar(qt.QWidget):
:param QFont font: the font we want want to use durint the painting
"""
- assert(type(self._vmin) == type(self._vmax))
form = self._getStandardFormat()
fm = qt.QFontMetrics(font)
diff --git a/silx/gui/plot/Colormap.py b/silx/gui/plot/Colormap.py
new file mode 100644
index 0000000..abe8546
--- /dev/null
+++ b/silx/gui/plot/Colormap.py
@@ -0,0 +1,410 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2015-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 the Colormap object
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent", "H.Payno"]
+__license__ = "MIT"
+__date__ = "05/12/2016"
+
+from silx.gui import qt
+import copy as copy_mdl
+import numpy
+from .matplotlib import Colormap as MPLColormap
+import logging
+from silx.math.combo import min_max
+
+_logger = logging.getLogger(__file__)
+
+DEFAULT_COLORMAPS = (
+ 'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue')
+"""Tuple of supported colormap names."""
+
+DEFAULT_MIN_LIN = 0
+"""Default min value if in linear normalization"""
+DEFAULT_MAX_LIN = 1
+"""Default max value if in linear normalization"""
+DEFAULT_MIN_LOG = 1
+"""Default min value if in log normalization"""
+DEFAULT_MAX_LOG = 10
+"""Default max value if in log normalization"""
+
+
+class Colormap(qt.QObject):
+ """Description of a colormap
+
+ :param str name: Name of the colormap
+ :param tuple colors: optional, custom colormap.
+ Nx3 or Nx4 numpy array of RGB(A) colors,
+ either uint8 or float in [0, 1].
+ If 'name' is None, then this array is used as the colormap.
+ :param str norm: Normalization: 'linear' (default) or 'log'
+ :param float vmin:
+ Lower bound of the colormap or None for autoscale (default)
+ :param float vmax:
+ Upper bounds of the colormap or None for autoscale (default)
+ """
+
+ LINEAR = 'linear'
+ """constant for linear normalization"""
+
+ LOGARITHM = 'log'
+ """constant for logarithmic normalization"""
+
+ NORMALIZATIONS = (LINEAR, LOGARITHM)
+ """Tuple of managed normalizations"""
+
+ sigChanged = qt.Signal()
+
+ def __init__(self, name='gray', colors=None, normalization=LINEAR, vmin=None, vmax=None):
+ qt.QObject.__init__(self)
+ assert normalization in Colormap.NORMALIZATIONS
+ assert not (name is None and colors is None)
+ if normalization is Colormap.LOGARITHM:
+ if (vmin is not None and vmin < 0) or (vmax is not None and vmax < 0):
+ m = "Unsuported vmin (%s) and/or vmax (%s) given for a log scale."
+ m += ' Autoscale will be performed.'
+ m = m % (vmin, vmax)
+ _logger.warning(m)
+ vmin = None
+ vmax = None
+
+ self._name = str(name) if name is not None else None
+ self._setColors(colors)
+ self._normalization = str(normalization)
+ self._vmin = float(vmin) if vmin is not None else None
+ self._vmax = float(vmax) if vmax is not None else None
+
+ def isAutoscale(self):
+ """Return True if both min and max are in autoscale mode"""
+ return self._vmin is None or self._vmax is None
+
+ def getName(self):
+ """Return the name of the colormap
+ :rtype: str
+ """
+ return self._name
+
+ def _setColors(self, colors):
+ if colors is None:
+ self._colors = None
+ else:
+ self._colors = numpy.array(colors, copy=True)
+
+ def setName(self, name):
+ """Set the name of the colormap and load the colors corresponding to
+ the name
+
+ :param str name: the name of the colormap (should be in ['gray',
+ 'reversed gray', 'temperature', 'red', 'green', 'blue', 'jet',
+ 'viridis', 'magma', 'inferno', 'plasma']
+ """
+ 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 self._colors
+
+ def setColormapLUT(self, colors):
+ """
+ Set the colors of the colormap.
+
+ :param numpy.ndarray colors: the colors of the LUT
+
+ .. warning: this will set the value of name to an empty string
+ """
+ self._setColors(colors)
+ if len(colors) is 0:
+ self._colors = None
+
+ self._name = None
+ self.sigChanged.emit()
+
+ def getNormalization(self):
+ """Return the normalization of the colormap ('log' or 'linear')
+
+ :return: the normalization of the colormap
+ :rtype: str
+ """
+ return self._normalization
+
+ def setNormalization(self, norm):
+ """Set the norm ('log', 'linear')
+
+ :param str norm: the norm to set
+ """
+ self._normalization = str(norm)
+ self.sigChanged.emit()
+
+ def getVMin(self):
+ """Return the lower bound of the colormap
+
+ :return: the lower bound of the colormap
+ :rtype: float or None
+ """
+ return self._vmin
+
+ def setVMin(self, vmin):
+ """Set the minimal value of the colormap
+
+ :param float vmin: Lower bound of the colormap or None for autoscale
+ (default)
+ value)
+ """
+ if 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)
+ raise ValueError(err)
+
+ self._vmin = vmin
+ self.sigChanged.emit()
+
+ def getVMax(self):
+ """Return the upper bounds of the colormap or None
+
+ :return: the upper bounds of the colormap or None
+ :rtype: float or None
+ """
+ return self._vmax
+
+ def setVMax(self, vmax):
+ """Set the maximal value of the colormap
+
+ :param float vmax: Upper bounds of the colormap or None for autoscale
+ (default)
+ """
+ if 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)
+ raise ValueError(err)
+
+ self._vmax = vmax
+ self.sigChanged.emit()
+
+ def getColormapRange(self, data=None):
+ """Return (vmin, vmax)
+
+ :return: the tuple vmin, vmax fitting vmin, vmax, normalization and
+ data if any given
+ :rtype: tuple
+ """
+ vmin = self._vmin
+ vmax = self._vmax
+ assert vmin is None or vmax is None or vmin <= vmax # TODO handle this in setters
+
+ if self.getNormalization() == self.LOGARITHM:
+ # Handle negative bounds as autoscale
+ if vmin is not None and (vmin is not None and vmin <= 0.):
+ mess = 'negative vmin, moving to autoscale for lower bound'
+ _logger.warning(mess)
+ vmin = None
+ if vmax is not None and (vmax is not None and vmax <= 0.):
+ mess = 'negative vmax, moving to autoscale for upper bound'
+ _logger.warning(mess)
+ vmax = None
+
+ if vmin is None or vmax is None: # Handle autoscale
+ # Get min/max from data
+ if data is not None:
+ data = numpy.array(data, copy=False)
+ if data.size == 0: # Fallback an array but no data
+ min_, max_ = self._getDefaultMin(), self._getDefaultMax()
+ else:
+ if self.getNormalization() == self.LOGARITHM:
+ result = min_max(data, min_positive=True, finite=True)
+ min_ = result.min_positive # >0 or None
+ max_ = result.maximum # can be <= 0
+ else:
+ min_, max_ = min_max(data, min_positive=False, finite=True)
+
+ # Handle fallback
+ if min_ is None or not numpy.isfinite(min_):
+ min_ = self._getDefaultMin()
+ if max_ is None or not numpy.isfinite(max_):
+ max_ = self._getDefaultMax()
+ else: # Fallback if no data is provided
+ min_, max_ = self._getDefaultMin(), self._getDefaultMax()
+
+ if vmin is None: # Set vmin respecting provided vmax
+ vmin = min_ if vmax is None else min(min_, vmax)
+
+ if vmax is None:
+ vmax = max(max_, vmin) # Handle max_ <= 0 for log scale
+
+ return vmin, vmax
+
+ def setVRange(self, vmin, vmax):
+ """
+ Set bounds to 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 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)
+ raise ValueError(err)
+
+ self._vmin = vmin
+ self._vmax = vmax
+ self.sigChanged.emit()
+
+ def __getitem__(self, item):
+ if item == 'autoscale':
+ return self.isAutoscale()
+ elif item == 'name':
+ return self.getName()
+ elif item == 'normalization':
+ return self.getNormalization()
+ elif item == 'vmin':
+ return self.getVMin()
+ elif item == 'vmax':
+ return self.getVMax()
+ elif item == 'colors':
+ return self.getColormapLUT()
+ else:
+ raise KeyError(item)
+
+ def _toDict(self):
+ """Return the equivalent colormap as a dictionary
+ (old colormap representation)
+
+ :return: the representation of the Colormap as a dictionary
+ :rtype: dict
+ """
+ return {
+ 'name': self._name,
+ 'colors': copy_mdl.copy(self._colors),
+ 'vmin': self._vmin,
+ 'vmax': self._vmax,
+ 'autoscale': self.isAutoscale(),
+ 'normalization': self._normalization
+ }
+
+ def _setFromDict(self, dic):
+ """Set values to the colormap from a dictionary
+
+ :param dict dic: the colormap as a dictionary
+ """
+ name = dic['name'] if 'name' in dic else None
+ colors = dic['colors'] if 'colors' in dic else None
+ vmin = dic['vmin'] if 'vmin' in dic else None
+ vmax = dic['vmax'] if 'vmax' in dic else None
+ if 'normalization' in dic:
+ normalization = dic['normalization']
+ else:
+ warn = 'Normalization not given in the dictionary, '
+ warn += 'set by default to ' + Colormap.LINEAR
+ _logger.warning(warn)
+ normalization = Colormap.LINEAR
+
+ if name is None and colors is None:
+ err = 'The colormap should have a name defined or a tuple of colors'
+ raise ValueError(err)
+ if normalization not in Colormap.NORMALIZATIONS:
+ err = 'Given normalization is not recoginized (%s)' % normalization
+ raise ValueError(err)
+
+ # If autoscale, then set boundaries to None
+ if dic.get('autoscale', False):
+ vmin, vmax = None, None
+
+ self._name = name
+ self._colors = colors
+ self._vmin = vmin
+ self._vmax = vmax
+ self._autoscale = True if (vmin is None and vmax is None) else False
+ self._normalization = normalization
+
+ self.sigChanged.emit()
+
+ @staticmethod
+ def _fromDict(dic):
+ colormap = Colormap(name="")
+ colormap._setFromDict(dic)
+ return colormap
+
+ def copy(self):
+ """
+
+ :return: a copy of the Colormap object
+ """
+ return Colormap(name=self._name,
+ colors=copy_mdl.copy(self._colors),
+ vmin=self._vmin,
+ vmax=self._vmax,
+ normalization=self._normalization)
+
+ def applyToData(self, data):
+ """Apply the colormap to the data
+
+ :param numpy.ndarray data: The data to convert.
+ """
+ rgbaImage = MPLColormap.applyColormapToData(colormap=self, data=data)
+ return rgbaImage
+
+ @staticmethod
+ def getSupportedColormaps():
+ """Get the supported colormap names as a tuple of str.
+
+ The list should at least contain and start by:
+ ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue')
+ :rtype: tuple
+ """
+ maps = MPLColormap.getSupportedColormaps()
+ return DEFAULT_COLORMAPS + maps
+
+ def __str__(self):
+ return str(self._toDict())
+
+ def _getDefaultMin(self):
+ return DEFAULT_MIN_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MIN_LOG
+
+ def _getDefaultMax(self):
+ return DEFAULT_MAX_LIN if self._normalization == Colormap.LINEAR else DEFAULT_MAX_LOG
+
+ def __eq__(self, other):
+ """Compare colormap values and not pointers"""
+ return (self.getName() == other.getName() and
+ self.getNormalization() == other.getNormalization() and
+ self.getVMin() == other.getVMin() and
+ self.getVMax() == other.getVMax() and
+ numpy.array_equal(self.getColormapLUT(), other.getColormapLUT())
+ )
+
diff --git a/silx/gui/plot/ColormapDialog.py b/silx/gui/plot/ColormapDialog.py
index ad1425c..748dd72 100644
--- a/silx/gui/plot/ColormapDialog.py
+++ b/silx/gui/plot/ColormapDialog.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2016 European Synchrotron Radiation Facility
+# Copyright (c) 2004-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
@@ -42,7 +42,7 @@ Create the colormap dialog and set the colormap description and data range:
Get the colormap description (compatible with :class:`Plot`) from the dialog:
>>> cmap = dialog.getColormap()
->>> cmap['name']
+>>> cmap.getName()
'red'
It is also possible to display an histogram of the image in the dialog.
@@ -61,7 +61,7 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "29/03/2016"
+__date__ = "02/10/2017"
import logging
@@ -69,37 +69,13 @@ import logging
import numpy
from .. import qt
+from .Colormap import Colormap
from . import PlotWidget
-
+from silx.gui.widgets.FloatEdit import FloatEdit
_logger = logging.getLogger(__name__)
-class _FloatEdit(qt.QLineEdit):
- """Field to edit a float value.
-
- :param parent: See :class:`QLineEdit`
- :param float value: The value to set the QLineEdit to.
- """
- def __init__(self, parent=None, value=None):
- qt.QLineEdit.__init__(self, parent)
- self.setValidator(qt.QDoubleValidator())
- self.setAlignment(qt.Qt.AlignRight)
- if value is not None:
- self.setValue(value)
-
- def value(self):
- """Return the QLineEdit current value as a float."""
- return float(self.text())
-
- def setValue(self, value):
- """Set the current value of the LineEdit
-
- :param float value: The value to set the QLineEdit to.
- """
- self.setText('%g' % value)
-
-
class ColormapDialog(qt.QDialog):
"""A QDialog widget to set the colormap.
@@ -107,7 +83,7 @@ class ColormapDialog(qt.QDialog):
:param str title: The QDialog title
"""
- sigColormapChanged = qt.Signal(dict)
+ sigColormapChanged = qt.Signal(Colormap)
"""Signal triggered when the colormap is changed.
It provides a dict describing the colormap to the slot.
@@ -122,10 +98,13 @@ class ColormapDialog(qt.QDialog):
self._dataRange = None
self._minMaxWasEdited = False
- self._colormapList = (
+ colormaps = [
'gray', 'reversed gray',
'temperature', 'red', 'green', 'blue', 'jet',
- 'viridis', 'magma', 'inferno', 'plasma')
+ 'viridis', 'magma', 'inferno', 'plasma']
+ if 'hsv' in Colormap.getSupportedColormaps():
+ colormaps.append('hsv')
+ self._colormapList = tuple(colormaps)
# Make the GUI
vLayout = qt.QVBoxLayout(self)
@@ -172,14 +151,14 @@ class ColormapDialog(qt.QDialog):
formLayout.addRow('Range:', self._rangeAutoscaleButton)
# Min row
- self._minValue = _FloatEdit(value=1.)
+ self._minValue = FloatEdit(parent=self, value=1.)
self._minValue.setEnabled(False)
self._minValue.textEdited.connect(self._minMaxTextEdited)
self._minValue.editingFinished.connect(self._minEditingFinished)
formLayout.addRow('\tMin:', self._minValue)
# Max row
- self._maxValue = _FloatEdit(value=10.)
+ self._maxValue = FloatEdit(parent=self, value=10.)
self._maxValue.setEnabled(False)
self._maxValue.textEdited.connect(self._minMaxTextEdited)
self._maxValue.editingFinished.connect(self._maxEditingFinished)
@@ -214,8 +193,8 @@ class ColormapDialog(qt.QDialog):
"""Init the plot to display the range and the values"""
self._plot = PlotWidget()
self._plot.setDataMargins(yMinMargin=0.125, yMaxMargin=0.125)
- self._plot.setGraphXLabel("Data Values")
- self._plot.setGraphYLabel("")
+ self._plot.getXAxis().setLabel("Data Values")
+ self._plot.getYAxis().setLabel("")
self._plot.setInteractiveMode('select', zoomOnWheel=False)
self._plot.setActiveCurveHandling(False)
self._plot.setMinimumSize(qt.QSize(250, 200))
@@ -392,17 +371,22 @@ class ColormapDialog(qt.QDialog):
self._plotUpdate()
def getColormap(self):
- """Return the colormap description as a dict.
+ """Return the colormap description as a :class:`.Colormap`.
- See :class:`Plot` for documentation on the colormap dict.
"""
isNormLinear = self._normButtonLinear.isChecked()
- colormap = {
- 'name': str(self._comboBoxColormap.currentText()).lower(),
- 'normalization': 'linear' if isNormLinear else 'log',
- 'autoscale': self._rangeAutoscaleButton.isChecked(),
- 'vmin': self._minValue.value(),
- 'vmax': self._maxValue.value()}
+ if self._rangeAutoscaleButton.isChecked():
+ vmin = None
+ vmax = None
+ 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,
@@ -423,9 +407,9 @@ class ColormapDialog(qt.QDialog):
self._comboBoxColormap.setCurrentIndex(index)
if normalization is not None:
- assert normalization in ('linear', 'log')
- self._normButtonLinear.setChecked(normalization == 'linear')
- self._normButtonLog.setChecked(normalization == 'log')
+ 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)
diff --git a/silx/gui/plot/Colors.py b/silx/gui/plot/Colors.py
index 7a3cd97..2d44d4d 100644
--- a/silx/gui/plot/Colors.py
+++ b/silx/gui/plot/Colors.py
@@ -24,20 +24,18 @@
# ###########################################################################*/
"""Color conversion function, color dictionary and colormap tools."""
-__authors__ = ["V.A. Sole", "T. VINCENT"]
+from __future__ import absolute_import
+
+__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "16/01/2017"
+__date__ = "15/05/2017"
+from silx.utils.deprecation import deprecated
import logging
-
import numpy
-import matplotlib
-import matplotlib.colors
-import matplotlib.cm
-
-from . import MPLColormap
+from .Colormap import Colormap
_logger = logging.getLogger(__name__)
@@ -143,159 +141,7 @@ def cursorColorForColormap(colormapName):
return _COLORMAP_CURSOR_COLORS.get(colormapName, 'black')
-_CMAPS = {} # Store additional colormaps
-
-
-def getMPLColormap(name):
- """Returns matplotlib colormap corresponding to given name
-
- :param str name: The name of the colormap
- :return: The corresponding colormap
- :rtype: matplolib.colors.Colormap
- """
- if not _CMAPS: # Lazy initialization of own colormaps
- cdict = {'red': ((0.0, 0.0, 0.0),
- (1.0, 1.0, 1.0)),
- 'green': ((0.0, 0.0, 0.0),
- (1.0, 0.0, 0.0)),
- 'blue': ((0.0, 0.0, 0.0),
- (1.0, 0.0, 0.0))}
- _CMAPS['red'] = matplotlib.colors.LinearSegmentedColormap(
- 'red', cdict, 256)
-
- cdict = {'red': ((0.0, 0.0, 0.0),
- (1.0, 0.0, 0.0)),
- 'green': ((0.0, 0.0, 0.0),
- (1.0, 1.0, 1.0)),
- 'blue': ((0.0, 0.0, 0.0),
- (1.0, 0.0, 0.0))}
- _CMAPS['green'] = matplotlib.colors.LinearSegmentedColormap(
- 'green', cdict, 256)
-
- cdict = {'red': ((0.0, 0.0, 0.0),
- (1.0, 0.0, 0.0)),
- 'green': ((0.0, 0.0, 0.0),
- (1.0, 0.0, 0.0)),
- 'blue': ((0.0, 0.0, 0.0),
- (1.0, 1.0, 1.0))}
- _CMAPS['blue'] = matplotlib.colors.LinearSegmentedColormap(
- 'blue', cdict, 256)
-
- # Temperature as defined in spslut
- cdict = {'red': ((0.0, 0.0, 0.0),
- (0.5, 0.0, 0.0),
- (0.75, 1.0, 1.0),
- (1.0, 1.0, 1.0)),
- 'green': ((0.0, 0.0, 0.0),
- (0.25, 1.0, 1.0),
- (0.75, 1.0, 1.0),
- (1.0, 0.0, 0.0)),
- 'blue': ((0.0, 1.0, 1.0),
- (0.25, 1.0, 1.0),
- (0.5, 0.0, 0.0),
- (1.0, 0.0, 0.0))}
- # but limited to 256 colors for a faster display (of the colorbar)
- _CMAPS['temperature'] = \
- matplotlib.colors.LinearSegmentedColormap(
- 'temperature', cdict, 256)
-
- # reversed gray
- cdict = {'red': ((0.0, 1.0, 1.0),
- (1.0, 0.0, 0.0)),
- 'green': ((0.0, 1.0, 1.0),
- (1.0, 0.0, 0.0)),
- 'blue': ((0.0, 1.0, 1.0),
- (1.0, 0.0, 0.0))}
-
- _CMAPS['reversed gray'] = \
- matplotlib.colors.LinearSegmentedColormap(
- 'yerg', cdict, 256)
-
- if name in _CMAPS:
- return _CMAPS[name]
- elif hasattr(MPLColormap, name): # viridis and sister colormaps
- return getattr(MPLColormap, name)
- else:
- # matplotlib built-in
- return matplotlib.cm.get_cmap(name)
-
-
-def getMPLScalarMappable(colormap, data=None):
- """Returns matplotlib ScalarMappable corresponding to colormap
-
- :param dict colormap: The colormap to convert
- :param numpy.ndarray data:
- The data on which the colormap is applied.
- If provided, it is used to compute autoscale.
- :return: matplotlib object corresponding to colormap
- :rtype: matplotlib.cm.ScalarMappable
- """
- assert colormap is not None
-
- if colormap['name'] is not None:
- cmap = getMPLColormap(colormap['name'])
-
- else: # No name, use custom colors
- if 'colors' not in colormap:
- raise ValueError(
- 'addImage: colormap no name nor list of colors.')
- colors = numpy.array(colormap['colors'], copy=True)
- assert len(colors.shape) == 2
- assert colors.shape[-1] in (3, 4)
- if colors.dtype == numpy.uint8:
- # Convert to float in [0., 1.]
- colors = colors.astype(numpy.float32) / 255.
- cmap = matplotlib.colors.ListedColormap(colors)
-
- if colormap['normalization'].startswith('log'):
- vmin, vmax = None, None
- if not colormap['autoscale']:
- if colormap['vmin'] > 0.:
- vmin = colormap['vmin']
- if colormap['vmax'] > 0.:
- vmax = colormap['vmax']
-
- 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) and data is not None:
- 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
-
- norm = matplotlib.colors.LogNorm(vmin, vmax)
-
- else: # Linear normalization
- if colormap['autoscale']:
- if data is None:
- vmin, vmax = None, None
- else:
- finiteData = data[numpy.isfinite(data)]
- vmin = finiteData.min()
- vmax = finiteData.max()
- else:
- vmin = colormap['vmin']
- vmax = colormap['vmax']
- 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)
-
-
+@deprecated(replacement='silx.gui.plot.Colormap.applyColormap')
def applyColormapToData(data,
name='gray',
normalization='linear',
@@ -324,36 +170,19 @@ def applyColormapToData(data,
:return: The computed RGBA image
:rtype: numpy.ndarray of uint8
"""
- # Debian 7 specific support
- # No transparent colormap with matplotlib < 1.2.0
- # Add support for transparent colormap for uint8 data with
- # colormap with 256 colors, linear norm, [0, 255] range
- if matplotlib.__version__ < '1.2.0':
- if name is None and colors is not None:
- colors = numpy.array(colors, copy=False)
- if (colors.shape[-1] == 4 and
- not numpy.all(numpy.equal(colors[3], 255))):
- # This is a transparent colormap
- if (colors.shape == (256, 4) and
- normalization == 'linear' and
- not autoscale and
- vmin == 0 and vmax == 255 and
- data.dtype == numpy.uint8):
- # Supported case, convert data to RGBA
- return colors[data.reshape(-1)].reshape(
- data.shape + (4,))
- else:
- _logger.warning(
- 'matplotlib %s does not support transparent '
- 'colormap.', matplotlib.__version__)
-
- colormap = dict(name=name,
- normalization=normalization,
- autoscale=autoscale,
- vmin=vmin,
- vmax=vmax,
- colors=colors)
- scalarMappable = getMPLScalarMappable(colormap, data)
- rgbaImage = scalarMappable.to_rgba(data, bytes=True)
-
- return rgbaImage
+ colormap = Colormap(name=name,
+ normalization=normalization,
+ vmin=vmin,
+ vmax=vmax,
+ colors=colors)
+ return colormap.applyToData(data)
+
+
+@deprecated(replacement='silx.gui.plot.Colormap.getSupportedColormaps')
+def getSupportedColormaps():
+ """Get the supported colormap names as a tuple of str.
+
+ The list should at least contain and start by:
+ ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue')
+ """
+ return Colormap.getSupportedColormaps()
diff --git a/silx/gui/plot/ComplexImageView.py b/silx/gui/plot/ComplexImageView.py
new file mode 100644
index 0000000..1463293
--- /dev/null
+++ b/silx/gui/plot/ComplexImageView.py
@@ -0,0 +1,670 @@
+# 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 widget to view 2D complex data.
+
+The :class:`ComplexImageView` widget is dedicated to visualize a single 2D dataset
+of complex data.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["Vincent Favre-Nicolin", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "02/10/2017"
+
+
+import logging
+import numpy
+
+from .. import qt, icons
+from .PlotWindow import Plot2D
+from .Colormap import Colormap
+from . import items
+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):
+ """QDialog asking for the amplitude range to display."""
+
+ sigRangeChanged = qt.Signal(tuple)
+ """Signal emitted when the range has changed.
+
+ It provides the new range as a 2-tuple: (max, delta)
+ """
+
+ def __init__(self,
+ parent=None,
+ amplitudeRange=None,
+ displayedRange=(None, 2)):
+ super(_AmplitudeRangeDialog, self).__init__(parent)
+ self.setWindowTitle('Set Displayed Amplitude Range')
+
+ if amplitudeRange is not None:
+ amplitudeRange = min(amplitudeRange), max(amplitudeRange)
+ self._amplitudeRange = amplitudeRange
+ self._defaultDisplayedRange = displayedRange
+
+ layout = qt.QFormLayout()
+ self.setLayout(layout)
+
+ if self._amplitudeRange is not None:
+ min_, max_ = self._amplitudeRange
+ layout.addRow(
+ qt.QLabel('Data Amplitude Range: [%g, %g]' % (min_, max_)))
+
+ self._maxLineEdit = FloatEdit(parent=self)
+ self._maxLineEdit.validator().setBottom(0.)
+ self._maxLineEdit.setAlignment(qt.Qt.AlignRight)
+
+ self._maxLineEdit.editingFinished.connect(self._rangeUpdated)
+ layout.addRow('Displayed Max.:', self._maxLineEdit)
+
+ self._autoscale = qt.QCheckBox('autoscale')
+ self._autoscale.toggled.connect(self._autoscaleCheckBoxToggled)
+ layout.addRow('', self._autoscale)
+
+ self._deltaLineEdit = FloatEdit(parent=self)
+ self._deltaLineEdit.validator().setBottom(1.)
+ self._deltaLineEdit.setAlignment(qt.Qt.AlignRight)
+ self._deltaLineEdit.editingFinished.connect(self._rangeUpdated)
+ layout.addRow('Displayed delta (log10 unit):', self._deltaLineEdit)
+
+ buttons = qt.QDialogButtonBox(self)
+ buttons.addButton(qt.QDialogButtonBox.Ok)
+ buttons.addButton(qt.QDialogButtonBox.Cancel)
+ buttons.accepted.connect(self.accept)
+ buttons.rejected.connect(self.reject)
+ layout.addRow(buttons)
+
+ # Set dialog from default values
+ self._resetDialogToDefault()
+
+ self.rejected.connect(self._handleRejected)
+
+ def _resetDialogToDefault(self):
+ """Set Widgets of the dialog from range information
+ """
+ max_, delta = self._defaultDisplayedRange
+
+ if max_ is not None: # Not in autoscale
+ displayedMax = max_
+ elif self._amplitudeRange is not None: # Autoscale with data
+ displayedMax = self._amplitudeRange[1]
+ else: # Autoscale without data
+ displayedMax = ''
+ if displayedMax == "":
+ self._maxLineEdit.setText("")
+ else:
+ self._maxLineEdit.setValue(displayedMax)
+ self._maxLineEdit.setEnabled(max_ is not None)
+
+ self._deltaLineEdit.setValue(delta)
+
+ self._autoscale.setChecked(self._defaultDisplayedRange[0] is None)
+
+ def getRangeInfo(self):
+ """Returns the current range as a 2-tuple (max, delta (in log10))"""
+ if self._autoscale.isChecked():
+ max_ = None
+ else:
+ maxStr = self._maxLineEdit.text()
+ max_ = self._maxLineEdit.value() if maxStr else None
+ return max_, self._deltaLineEdit.value() if self._deltaLineEdit.text() else 2
+
+ def _handleRejected(self):
+ """Reset range info to default when rejected"""
+ self._resetDialogToDefault()
+ self._rangeUpdated()
+
+ def _rangeUpdated(self):
+ """Handle QLineEdit editing finised"""
+ self.sigRangeChanged.emit(self.getRangeInfo())
+
+ def _autoscaleCheckBoxToggled(self, checked):
+ """Handle autoscale checkbox state changes"""
+ if checked: # Use default values
+ if self._amplitudeRange is None:
+ max_ = ''
+ else:
+ max_ = self._amplitudeRange[1]
+ if max_ == "":
+ self._maxLineEdit.setText("")
+ else:
+ self._maxLineEdit.setValue(max_)
+ self._maxLineEdit.setEnabled(not checked)
+ self._rangeUpdated()
+
+
+class _ComplexDataToolButton(qt.QToolButton):
+ """QToolButton providing choices of complex data visualization modes
+
+ :param parent: See :class:`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')]
+
+ _RANGE_DIALOG_TEXT = 'Set Amplitude Range...'
+
+ def __init__(self, parent=None, plot=None):
+ super(_ComplexDataToolButton, self).__init__(parent=parent)
+
+ assert plot is not None
+ self._plot2DComplex = plot
+
+ menu = qt.QMenu(self)
+ menu.triggered.connect(self._triggered)
+ self.setMenu(menu)
+
+ for _, icon, text in self._MODES:
+ action = qt.QAction(icons.getQIcon(icon), text, self)
+ action.setIconVisibleInMenu(True)
+ menu.addAction(action)
+
+ self._rangeDialogAction = qt.QAction(self)
+ self._rangeDialogAction.setText(self._RANGE_DIALOG_TEXT)
+ menu.addAction(self._rangeDialogAction)
+
+ self.setPopupMode(qt.QToolButton.InstantPopup)
+
+ self._modeChanged(self._plot2DComplex.getVisualizationMode())
+ self._plot2DComplex.sigVisualizationModeChanged.connect(
+ self._modeChanged)
+
+ 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')
+
+ def _triggered(self, action):
+ """Handle triggering of menu actions"""
+ actionText = action.text()
+
+ if actionText == self._RANGE_DIALOG_TEXT: # Show dialog
+ # Get amplitude range
+ data = self._plot2DComplex.getData(copy=False)
+
+ if data.size > 0:
+ absolute = numpy.absolute(data)
+ dataRange = (numpy.nanmin(absolute), numpy.nanmax(absolute))
+ else:
+ dataRange = None
+
+ # Show dialog
+ dialog = _AmplitudeRangeDialog(
+ parent=self,
+ amplitudeRange=dataRange,
+ displayedRange=self._plot2DComplex._getAmplitudeRangeInfo())
+ dialog.sigRangeChanged.connect(self._rangeChanged)
+ dialog.exec_()
+ dialog.sigRangeChanged.disconnect(self._rangeChanged)
+
+ else: # update mode
+ for mode, _, text in self._MODES:
+ if actionText == text:
+ self._plot2DComplex.setVisualizationMode(mode)
+
+ def _rangeChanged(self, range_):
+ """Handle updates of range in the dialog"""
+ self._plot2DComplex._setAmplitudeRangeInfo(*range_)
+
+
+class ComplexImageView(qt.QWidget):
+ """Display an image of complex data and allow to choose the visualization.
+
+ :param parent: See :class:`QMainWindow`
+ """
+
+ sigDataChanged = qt.Signal()
+ """Signal emitted when data has changed."""
+
+ sigVisualizationModeChanged = qt.Signal(str)
+ """Signal emitted when the visualization mode has changed.
+
+ It provides the new visualization mode.
+ """
+
+ def __init__(self, parent=None):
+ super(ComplexImageView, self).__init__(parent)
+ 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)
+ layout.setSpacing(0)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self._plot2D)
+ self.setLayout(layout)
+
+ # Create and add image to the plot
+ self._plotImage = _ImageComplexData()
+ self._plotImage._setLegend('__ComplexImageView__complex_image__')
+ self._plotImage.setData(self._displayedData)
+ self._plotImage.setVisualizationMode(self._mode)
+ self._plot2D._add(self._plotImage)
+ self._plot2D.setActiveImage(self._plotImage.getLegend())
+
+ toolBar = qt.QToolBar('Complex', self)
+ toolBar.addWidget(
+ _ComplexDataToolButton(parent=self, plot=self))
+
+ self._plot2D.insertToolBar(self._plot2D.getProfileToolbar(), toolBar)
+
+ 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.
+
+ :param numpy.ndarray data: 2D complex data
+ :param bool copy: True (default) to copy the data,
+ False to use provided data (do not modify!).
+ """
+ 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()
+
+ def getData(self, copy=True):
+ """Get the currently displayed complex data.
+
+ :param bool copy: True (default) to return a copy of the data,
+ False to return internal data (do not modify!).
+ :return: The complex data array.
+ :rtype: numpy.ndarray of complex with 2 dimensions
+ """
+ return numpy.array(self._data, copy=copy)
+
+ def getDisplayedData(self, copy=True):
+ """Returns the displayed data depending on the visualization mode
+
+ WARNING: The returned data can be a uint8 RGBA image
+
+ :param bool copy: True (default) to return a copy of the data,
+ 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)
+
+ @staticmethod
+ def getSupportedVisualizationModes():
+ """Returns the supported visualization modes.
+
+ Supported visualization modes are:
+
+ - amplitude: The absolute value provided by numpy.absolute
+ - phase: The phase (or argument) provided by numpy.angle
+ - real: Real part
+ - imaginary: Imaginary part
+ - amplitude_phase: Color-coded phase with amplitude as alpha.
+ - log10_amplitude_phase:
+ Color-coded phase with log10(amplitude) as alpha.
+
+ :rtype: tuple of str
+ """
+ return ('absolute',
+ 'phase',
+ 'real',
+ 'imaginary',
+ 'amplitude_phase',
+ 'log10_amplitude_phase')
+
+ def setVisualizationMode(self, mode):
+ """Set the mode of visualization of the complex data.
+
+ See :meth:`getSupportedVisualizationModes` for the list of
+ supported modes.
+
+ :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)
+
+ def getVisualizationMode(self):
+ """Get the current visualization mode of the complex data.
+
+ :rtype: str
+ """
+ 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)
+ mode = self.getVisualizationMode()
+ if mode == 'log10_amplitude_phase':
+ self._displayedData = self._convertData(
+ self.getData(copy=False), mode)
+ self._updatePlot()
+
+ 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
+
+ # Image item proxy
+
+ def setColormap(self, colormap):
+ """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
+ """
+ self._plotImage.setColormap(colormap)
+
+ def getColormap(self):
+ """Returns the colormap used to display the data.
+
+ :rtype: Colormap
+ """
+ # Returns internal colormap and bypass forcing colormap
+ return items.ImageData.getColormap(self._plotImage)
+
+ def getOrigin(self):
+ """Returns the offset from origin at which to display the image.
+
+ :rtype: 2-tuple of float
+ """
+ return self._plotImage.getOrigin()
+
+ def setOrigin(self, origin):
+ """Set the offset from origin at which to display the image.
+
+ :param origin: (ox, oy) Offset from origin
+ :type origin: float or 2-tuple of float
+ """
+ self._plotImage.setOrigin(origin)
+
+ def getScale(self):
+ """Returns the scale of the image in data coordinates.
+
+ :rtype: 2-tuple of float
+ """
+ return self._plotImage.getScale()
+
+ def setScale(self, scale):
+ """Set the scale of the image
+
+ :param scale: (sx, sy) Scale of the image
+ :type scale: float or 2-tuple of float
+ """
+ self._plotImage.setScale(scale)
+
+ # PlotWidget API proxy
+
+ def getXAxis(self):
+ """Returns the X axis
+
+ :rtype: :class:`.items.Axis`
+ """
+ return self.getPlot().getXAxis()
+
+ def getYAxis(self):
+ """Returns an Y axis
+
+ :rtype: :class:`.items.Axis`
+ """
+ return self.getPlot().getYAxis(axis='left')
+
+ def getGraphTitle(self):
+ """Return the plot main title as a str."""
+ return self.getPlot().getGraphTitle()
+
+ def setGraphTitle(self, title=""):
+ """Set the plot main title.
+
+ :param str title: Main title of the plot (default: '')
+ """
+ self.getPlot().setGraphTitle(title)
+
+ def setKeepDataAspectRatio(self, flag):
+ """Set whether the plot keeps data aspect ratio or not.
+
+ :param bool flag: True to respect data aspect ratio
+ """
+ self.getPlot().setKeepDataAspectRatio(flag)
+
+ def isKeepDataAspectRatio(self):
+ """Returns whether the plot is keeping data aspect ratio or not."""
+ return self.getPlot().isKeepDataAspectRatio()
diff --git a/silx/gui/plot/CurvesROIWidget.py b/silx/gui/plot/CurvesROIWidget.py
index 13c3de0..4b10cd6 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__ = "26/04/2017"
+__date__ = "27/06/2017"
from collections import OrderedDict
@@ -168,9 +168,9 @@ class CurvesROIWidget(qt.QWidget):
The dictionary keys are the ROI names.
Each value is a sub-dictionary of ROI info with the following fields:
- - ``"from"``: x coordinate of the left limit, as a float
- - ``"to"``: x coordinate of the right limit, as a float
- - ``"type"``: type of ROI, as a string (e.g "channels", "energy")
+ - ``"from"``: x coordinate of the left limit, as a float
+ - ``"to"``: x coordinate of the right limit, as a float
+ - ``"type"``: type of ROI, as a string (e.g "channels", "energy")
:param roidict: Dictionary of ROIs
@@ -194,9 +194,11 @@ class CurvesROIWidget(qt.QWidget):
The dictionary keys are the ROI names.
Each value is a sub-dictionary of ROI info with the following fields:
- - ``"from"``: x coordinate of the left limit, as a float
- - ``"to"``: x coordinate of the right limit, as a float
- - ``"type"``: type of ROI, as a string (e.g "channels", "energy")
+ - ``"from"``: x coordinate of the left limit, as a float
+ - ``"to"``: x coordinate of the right limit, as a float
+ - ``"type"``: type of ROI, as a string (e.g "channels", "energy")
+
+
:param order: Field used for ordering the ROIs.
One of "from", "to", "type", "netcounts", "rawcounts".
None (default) to get the same order as displayed in the widget.
@@ -742,7 +744,7 @@ class CurvesROIDockWidget(qt.QDockWidget):
"""Handle ROI widget signal"""
_logger.debug("PlotWindow._roiSignal %s", str(ddict))
if ddict['event'] == "AddROI":
- xmin, xmax = self.plot.getGraphXLimits()
+ 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')
@@ -786,7 +788,7 @@ class CurvesROIDockWidget(qt.QDockWidget):
if newroi == "ICR":
roiDict[newroi]['type'] = "Default"
else:
- roiDict[newroi]['type'] = self.plot.getGraphXLabel()
+ roiDict[newroi]['type'] = self.plot.getXAxis().getLabel()
roiDict[newroi]['from'] = fromdata
roiDict[newroi]['to'] = todata
self.roiWidget.fillFromROIDict(roilist=roiList,
diff --git a/silx/gui/plot/ImageView.py b/silx/gui/plot/ImageView.py
index 780215e..803a2fc 100644
--- a/silx/gui/plot/ImageView.py
+++ b/silx/gui/plot/ImageView.py
@@ -34,12 +34,7 @@ Basic usage of :class:`ImageView` is through the following methods:
default colormap to use and update the currently displayed image.
- :meth:`ImageView.setImage` to update the displayed image.
-The :class:`ImageView` uses :class:`PlotWindow` and also
-exposes :class:`silx.gui.plot.Plot` API for further control
-(plot title, axes labels, adding other images, ...).
-
-For an example of use, see the implementation of :class:`ImageViewMainWindow`,
-and `example/imageview.py`.
+For an example of use, see `imageview.py` in :ref:`sample-code`.
"""
from __future__ import division
@@ -47,7 +42,7 @@ from __future__ import division
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "13/10/2016"
+__date__ = "17/08/2017"
import logging
@@ -55,7 +50,8 @@ import numpy
from .. import qt
-from . import items, PlotWindow, PlotWidget, PlotActions
+from . import items, PlotWindow, PlotWidget, actions
+from .Colormap import Colormap
from .Colors import cursorColorForColormap
from .PlotTools import LimitsToolBar
from .Profile import ProfileToolBar
@@ -253,9 +249,13 @@ class ImageView(PlotWindow):
Use :meth:`setImage` to control the displayed image.
This class also provides the :class:`silx.gui.plot.Plot` API.
+ The :class:`ImageView` inherits from :class:`.PlotWindow` (which provides
+ the toolbars) and also exposes :class:`.PlotWidget` API for further
+ plot control (plot title, axes labels, aspect ratio, ...).
+
:param parent: The parent of this widget or None.
:param backend: The backend to use for the plot (default: matplotlib).
- See :class:`.Plot` for the list of supported backend.
+ See :class:`.PlotWidget` for the list of supported backend.
:type backend: str or :class:`BackendBase.BackendBase`
"""
@@ -318,7 +318,7 @@ class ImageView(PlotWindow):
self._histoHPlot = PlotWidget(backend=backend)
self._histoHPlot.setInteractiveMode('zoom')
- self._histoHPlot.setCallback(self._histoHPlotCB)
+ self._histoHPlot.sigPlotSignal.connect(self._histoHPlotCB)
self._histoHPlot.getWidgetHandle().sizeHint = sizeHint
self._histoHPlot.getWidgetHandle().minimumSizeHint = sizeHint
@@ -326,39 +326,39 @@ class ImageView(PlotWindow):
self.setInteractiveMode('zoom') # Color set in setColormap
self.sigPlotSignal.connect(self._imagePlotCB)
- self.sigSetYAxisInverted.connect(self._updateYAxisInverted)
+ self.getYAxis().sigInvertedChanged.connect(self._updateYAxisInverted)
self.sigActiveImageChanged.connect(self._activeImageChangedSlot)
self._histoVPlot = PlotWidget(backend=backend)
self._histoVPlot.setInteractiveMode('zoom')
- self._histoVPlot.setCallback(self._histoVPlotCB)
+ self._histoVPlot.sigPlotSignal.connect(self._histoVPlotCB)
self._histoVPlot.getWidgetHandle().sizeHint = sizeHint
self._histoVPlot.getWidgetHandle().minimumSizeHint = sizeHint
self._radarView = RadarView()
self._radarView.visibleRectDragged.connect(self._radarViewCB)
- self._layout = qt.QGridLayout()
- self._layout.addWidget(self.getWidgetHandle(), 0, 0)
- self._layout.addWidget(self._histoVPlot.getWidgetHandle(), 0, 1)
- self._layout.addWidget(self._histoHPlot.getWidgetHandle(), 1, 0)
- self._layout.addWidget(self._radarView, 1, 1)
+ layout = qt.QGridLayout()
+ layout.addWidget(self.getWidgetHandle(), 0, 0)
+ layout.addWidget(self._histoVPlot.getWidgetHandle(), 0, 1)
+ layout.addWidget(self._histoHPlot.getWidgetHandle(), 1, 0)
+ layout.addWidget(self._radarView, 1, 1)
- self._layout.setColumnMinimumWidth(0, self.IMAGE_MIN_SIZE)
- self._layout.setColumnStretch(0, 1)
- self._layout.setColumnMinimumWidth(1, self.HISTOGRAMS_HEIGHT)
- self._layout.setColumnStretch(1, 0)
+ layout.setColumnMinimumWidth(0, self.IMAGE_MIN_SIZE)
+ layout.setColumnStretch(0, 1)
+ layout.setColumnMinimumWidth(1, self.HISTOGRAMS_HEIGHT)
+ layout.setColumnStretch(1, 0)
- self._layout.setRowMinimumHeight(0, self.IMAGE_MIN_SIZE)
- self._layout.setRowStretch(0, 1)
- self._layout.setRowMinimumHeight(1, self.HISTOGRAMS_HEIGHT)
- self._layout.setRowStretch(1, 0)
+ layout.setRowMinimumHeight(0, self.IMAGE_MIN_SIZE)
+ layout.setRowStretch(0, 1)
+ layout.setRowMinimumHeight(1, self.HISTOGRAMS_HEIGHT)
+ layout.setRowStretch(1, 0)
- self._layout.setSpacing(0)
- self._layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ layout.setContentsMargins(0, 0, 0, 0)
centralWidget = qt.QWidget()
- centralWidget.setLayout(self._layout)
+ centralWidget.setLayout(layout)
self.setCentralWidget(centralWidget)
def _dirtyCache(self):
@@ -376,8 +376,8 @@ class ImageView(PlotWindow):
scale = activeImage.getScale()
height, width = data.shape
- xMin, xMax = self.getGraphXLimits()
- yMin, yMax = self.getGraphYLimits()
+ xMin, xMax = self.getXAxis().getLimits()
+ yMin, yMax = self.getYAxis().getLimits()
# Convert plot area limits to image coordinates
# and work in image coordinates (i.e., in pixels)
@@ -440,8 +440,8 @@ class ImageView(PlotWindow):
vOffset = 0.1 * (vMax - vMin)
if vOffset == 0.:
vOffset = 1.
- self._histoHPlot.setGraphYLimits(vMin - vOffset,
- vMax + vOffset)
+ self._histoHPlot.getYAxis().setLimits(vMin - vOffset,
+ vMax + vOffset)
coords = numpy.arange(2 * histoVVisibleData.size)
yCoords = (coords + 1) // 2 + subsetYMin
@@ -458,8 +458,8 @@ class ImageView(PlotWindow):
vOffset = 0.1 * (vMax - vMin)
if vOffset == 0.:
vOffset = 1.
- self._histoVPlot.setGraphXLimits(vMin - vOffset,
- vMax + vOffset)
+ self._histoVPlot.getXAxis().setLimits(vMin - vOffset,
+ vMax + vOffset)
else:
self._dirtyCache()
self._histoHPlot.remove(kind='curve')
@@ -472,8 +472,8 @@ class ImageView(PlotWindow):
Takes care of y coordinate conversion.
"""
- xMin, xMax = self.getGraphXLimits()
- yMin, yMax = self.getGraphYLimits()
+ xMin, xMax = self.getXAxis().getLimits()
+ yMin, yMax = self.getYAxis().getLimits()
self._radarView.setVisibleRect(xMin, yMin, xMax - xMin, yMax - yMin)
# Plots event listeners
@@ -499,6 +499,9 @@ class ImageView(PlotWindow):
data[y][x])
elif eventDict['event'] == 'limitsChanged':
+ self._updateHistogramsLimits()
+
+ def _updateHistogramsLimits(self):
# Do not handle histograms limitsChanged while
# updating their limits from here.
self._updatingLimits = True
@@ -506,15 +509,14 @@ class ImageView(PlotWindow):
# Refresh histograms
self._updateHistograms()
- # could use eventDict['xdata'], eventDict['ydata'] instead
- xMin, xMax = self.getGraphXLimits()
- yMin, yMax = self.getGraphYLimits()
+ xMin, xMax = self.getXAxis().getLimits()
+ yMin, yMax = self.getYAxis().getLimits()
# Set horizontal histo limits
- self._histoHPlot.setGraphXLimits(xMin, xMax)
+ self._histoHPlot.getXAxis().setLimits(xMin, xMax)
# Set vertical histo limits
- self._histoVPlot.setGraphYLimits(yMin, yMax)
+ self._histoVPlot.getYAxis().setLimits(yMin, yMax)
self._updateRadarView()
@@ -542,9 +544,9 @@ class ImageView(PlotWindow):
elif eventDict['event'] == 'limitsChanged':
if (not self._updatingLimits and
- eventDict['xdata'] != self.getGraphXLimits()):
+ eventDict['xdata'] != self.getXAxis().getLimits()):
xMin, xMax = eventDict['xdata']
- self.setGraphXLimits(xMin, xMax)
+ self.getXAxis().setLimits(xMin, xMax)
def _histoVPlotCB(self, eventDict):
"""Callback for vertical histogram plot events."""
@@ -568,9 +570,9 @@ class ImageView(PlotWindow):
elif eventDict['event'] == 'limitsChanged':
if (not self._updatingLimits and
- eventDict['ydata'] != self.getGraphYLimits()):
+ eventDict['ydata'] != self.getYAxis().getLimits()):
yMin, yMax = eventDict['ydata']
- self.setGraphYLimits(yMin, yMax)
+ self.getYAxis().setLimits(yMin, yMax)
def _radarViewCB(self, left, top, width, height):
"""Slot for radar view visible rectangle changes."""
@@ -582,9 +584,9 @@ class ImageView(PlotWindow):
"""Sync image, vertical histogram and radar view axis orientation."""
if inverted is None:
# Do not perform this when called from plot signal
- inverted = self.isYAxisInverted()
+ inverted = self.getYAxis().isInverted()
- self._histoVPlot.setYAxisInverted(inverted)
+ self._histoVPlot.getYAxis().setInverted(inverted)
# Use scale to invert radarView
# RadarView default Y direction is from top to bottom
@@ -643,7 +645,7 @@ class ImageView(PlotWindow):
self._radarView.visibleRectDragged.disconnect(self._radarViewCB)
self._radarView = radarView
self._radarView.visibleRectDragged.connect(self._radarViewCB)
- self._layout.addWidget(self._radarView, 1, 1)
+ self.centralWidget().layout().addWidget(self._radarView, 1, 1)
self._updateYAxisInverted()
@@ -693,42 +695,46 @@ class ImageView(PlotWindow):
:param numpy.ndarray colors: Only used if name is None.
Custom colormap colors as Nx3 or Nx4 RGB or RGBA arrays
"""
- cmapDict = self.getDefaultColormap()
+ cmap = self.getDefaultColormap()
+
+ if isinstance(colormap, Colormap):
+ # Replace colormap
+ cmap = colormap
+
+ self.setDefaultColormap(cmap)
+
+ # Update active image colormap
+ activeImage = self.getActiveImage()
+ if isinstance(activeImage, items.ColormapMixIn):
+ activeImage.setColormap(cmap)
- if isinstance(colormap, dict):
+ elif isinstance(colormap, dict):
# Support colormap parameter as a dict
assert normalization is None
assert autoscale is None
assert vmin is None
assert vmax is None
assert colors is None
- for key, value in colormap.items():
- cmapDict[key] = value
+ cmap._setFromDict(colormap)
else:
if colormap is not None:
- cmapDict['name'] = colormap
+ cmap.setName(colormap)
if normalization is not None:
- cmapDict['normalization'] = normalization
- if autoscale is not None:
- cmapDict['autoscale'] = autoscale
- if vmin is not None:
- cmapDict['vmin'] = vmin
- if vmax is not None:
- cmapDict['vmax'] = vmax
+ cmap.setNormalization(normalization)
+ if autoscale:
+ cmap.setVRange(None, None)
+ else:
+ if vmin is not None:
+ cmap.setVMin(vmin)
+ if vmax is not None:
+ cmap.setVMax(vmax)
if colors is not None:
- cmapDict['colors'] = colors
+ cmap.setColormapLUT(colors)
- cursorColor = cursorColorForColormap(cmapDict['name'])
+ cursorColor = cursorColorForColormap(cmap.getName())
self.setInteractiveMode('zoom', color=cursorColor)
- self.setDefaultColormap(cmapDict)
-
- # Update active image colormap
- activeImage = self.getActiveImage()
- if isinstance(activeImage, items.ColormapMixIn):
- activeImage.setColormap(self.getColormap())
-
def setImage(self, image, origin=(0, 0), scale=(1., 1.),
copy=True, reset=True):
"""Set the image to display.
@@ -768,7 +774,7 @@ class ImageView(PlotWindow):
legend=self._imageLegend,
origin=origin, scale=scale,
colormap=self.getColormap(),
- replace=False)
+ replace=False, resetzoom=False)
self.setActiveImage(self._imageLegend)
self._updateHistograms()
@@ -779,6 +785,8 @@ class ImageView(PlotWindow):
if reset:
self.resetZoom()
+ else:
+ self._updateHistogramsLimits()
# ImageViewMainWindow #########################################################
@@ -793,8 +801,8 @@ class ImageViewMainWindow(ImageView):
super(ImageViewMainWindow, self).__init__(parent, backend)
self.setWindowFlags(qt.Qt.Window)
- self.setGraphXLabel('X')
- self.setGraphYLabel('Y')
+ self.getXAxis().setLabel('X')
+ self.getYAxis().setLabel('Y')
self.setGraphTitle('Image')
# Add toolbars and status bar
@@ -814,11 +822,10 @@ class ImageViewMainWindow(ImageView):
menu.addSeparator()
menu.addAction(self.resetZoomAction)
menu.addAction(self.colormapAction)
- menu.addAction(PlotActions.KeepAspectRatioAction(self, self))
- menu.addAction(PlotActions.YAxisInvertedAction(self, self))
+ menu.addAction(actions.control.KeepAspectRatioAction(self, self))
+ menu.addAction(actions.control.YAxisInvertedAction(self, self))
menu = self.menuBar().addMenu('Profile')
- menu.addAction(self.profile.browseAction)
menu.addAction(self.profile.hLineAction)
menu.addAction(self.profile.vLineAction)
menu.addAction(self.profile.lineAction)
diff --git a/silx/gui/plot/ItemsSelectionDialog.py b/silx/gui/plot/ItemsSelectionDialog.py
new file mode 100644
index 0000000..acb287a
--- /dev/null
+++ b/silx/gui/plot/ItemsSelectionDialog.py
@@ -0,0 +1,282 @@
+# 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 dialog widget to select plot items.
+
+.. autoclass:: ItemsSelectionDialog
+
+"""
+
+__authors__ = ["P. Knobel"]
+__license__ = "MIT"
+__date__ = "28/06/2017"
+
+import logging
+
+from silx.gui import qt
+from silx.gui.plot.PlotWidget import PlotWidget
+
+_logger = logging.getLogger(__name__)
+
+
+class KindsSelector(qt.QListWidget):
+ """List widget allowing to select plot item kinds
+ ("curve", "scatter", "image"...)
+ """
+ sigSelectedKindsChanged = qt.Signal(list)
+
+ def __init__(self, parent=None, kinds=None):
+ """
+
+ :param parent: Parent QWidget or None
+ :param tuple(str) kinds: Sequence of kinds. If None, the default
+ behavior is to provide a checkbox for all possible item kinds.
+ """
+ qt.QListWidget.__init__(self, parent)
+
+ self.plot_item_kinds = []
+
+ self.setAvailableKinds(kinds if kinds is not None else PlotWidget.ITEM_KINDS)
+
+ self.setSelectionMode(qt.QAbstractItemView.ExtendedSelection)
+ self.selectAll()
+
+ self.itemSelectionChanged.connect(self.emitSigKindsSelectionChanged)
+
+ def emitSigKindsSelectionChanged(self):
+ self.sigSelectedKindsChanged.emit(self.selectedKinds)
+
+ @property
+ def selectedKinds(self):
+ """Tuple of all selected kinds (as strings)."""
+ # check for updates when self.itemSelectionChanged
+ return [item.text() for item in self.selectedItems()]
+
+ def setAvailableKinds(self, kinds):
+ """Set a list of kinds to be displayed.
+
+ :param list[str] kinds: Sequence of kinds
+ """
+ self.plot_item_kinds = kinds
+
+ self.clear()
+ for kind in self.plot_item_kinds:
+ item = qt.QListWidgetItem(self)
+ item.setText(kind)
+ self.addItem(item)
+
+ def selectAll(self):
+ """Select all available kinds."""
+ if self.selectionMode() in [qt.QAbstractItemView.SingleSelection,
+ qt.QAbstractItemView.NoSelection]:
+ raise RuntimeError("selectAll requires a multiple selection mode")
+ for i in range(self.count()):
+ self.item(i).setSelected(True)
+
+
+class PlotItemsSelector(qt.QTableWidget):
+ """Table widget displaying the legend and kind of all
+ plot items corresponding to a list of specified kinds.
+
+ Selected plot items are provided as property :attr:`selectedPlotItems`.
+ You can be warned of selection changes by listening to signal
+ :attr:`itemSelectionChanged`.
+ """
+ def __init__(self, parent=None, plot=None):
+ if plot is None or not isinstance(plot, PlotWidget):
+ raise AttributeError("parameter plot is required")
+ self.plot = plot
+ """:class:`PlotWidget` instance"""
+
+ self.plot_item_kinds = None
+ """List of plot item kinds (strings)"""
+
+ qt.QTableWidget.__init__(self, parent)
+
+ self.setColumnCount(2)
+
+ self.setSelectionBehavior(qt.QTableWidget.SelectRows)
+
+ def _clear(self):
+ self.clear()
+ self.setHorizontalHeaderLabels(["legend", "type"])
+
+ def setAllKindsFilter(self):
+ """Display all kinds of plot items."""
+ self.setKindsFilter(PlotWidget.ITEM_KINDS)
+
+ def setKindsFilter(self, kinds):
+ """Set list of all kinds of plot items to be displayed.
+
+ :param list[str] kinds: Sequence of kinds
+ """
+ if not set(kinds) <= set(PlotWidget.ITEM_KINDS):
+ raise KeyError("Illegal plot item kinds: %s" %
+ set(kinds) - set(PlotWidget.ITEM_KINDS))
+ self.plot_item_kinds = kinds
+
+ self.updatePlotItems()
+
+ def updatePlotItems(self):
+ self._clear()
+
+ nrows = len(self.plot._getItems(kind=self.plot_item_kinds,
+ just_legend=True))
+ self.setRowCount(nrows)
+
+ # respect order of kinds as set in method setKindsFilter
+ i = 0
+ for kind in self.plot_item_kinds:
+ for plot_item in self.plot._getItems(kind=kind):
+ legend_twitem = qt.QTableWidgetItem(plot_item.getLegend())
+ self.setItem(i, 0, legend_twitem)
+
+ kind_twitem = qt.QTableWidgetItem(kind)
+ self.setItem(i, 1, kind_twitem)
+ i += 1
+
+ @property
+ def selectedPlotItems(self):
+ """List of all selected items"""
+ selection_model = self.selectionModel()
+ selected_rows_idx = selection_model.selectedRows()
+ selected_rows = [idx.row() for idx in selected_rows_idx]
+
+ items = []
+ for row in selected_rows:
+ legend = self.item(row, 0).text()
+ kind = self.item(row, 1).text()
+ items.append(self.plot._getItem(kind, legend))
+
+ return items
+
+
+class ItemsSelectionDialog(qt.QDialog):
+ """This widget is a modal dialog allowing to select one or more plot
+ items, in a table displaying their legend and kind.
+
+ Public methods:
+
+ - :meth:`getSelectedItems`
+ - :meth:`setAvailableKinds`
+ - :meth:`setItemsSelectionMode`
+
+ This widget inherits QDialog and therefore implements the usual
+ dialog methods, e.g. :meth:`exec_`.
+
+ A trivial usage example would be::
+
+ isd = ItemsSelectionDialog(plot=my_plot_widget)
+ isd.setItemsSelectionMode(qt.QTableWidget.SingleSelection)
+ result = isd.exec_()
+ if result:
+ for item in isd.getSelectedItems():
+ print(item.getLegend(), type(item))
+ else:
+ print("Selection cancelled")
+ """
+ def __init__(self, parent=None, plot=None):
+ if plot is None or not isinstance(plot, PlotWidget):
+ raise AttributeError("parameter plot is required")
+ qt.QDialog.__init__(self, parent)
+
+ self.setWindowTitle("Plot items selector")
+
+ kind_selector_label = qt.QLabel("Filter item kinds:", self)
+ item_selector_label = qt.QLabel("Select items:", self)
+
+ self.kind_selector = KindsSelector(self)
+ self.kind_selector.setToolTip(
+ "select one or more item kinds to show them in the item list")
+
+ self.item_selector = PlotItemsSelector(self, plot)
+ self.item_selector.setToolTip("select items")
+
+ self.item_selector.setKindsFilter(self.kind_selector.selectedKinds)
+ self.kind_selector.sigSelectedKindsChanged.connect(
+ self.item_selector.setKindsFilter
+ )
+
+ okb = qt.QPushButton("OK", self)
+ okb.clicked.connect(self.accept)
+
+ cancelb = qt.QPushButton("Cancel", self)
+ cancelb.clicked.connect(self.reject)
+
+ layout = qt.QGridLayout(self)
+ layout.addWidget(kind_selector_label, 0, 0)
+ layout.addWidget(item_selector_label, 0, 1)
+ layout.addWidget(self.kind_selector, 1, 0)
+ layout.addWidget(self.item_selector, 1, 1)
+ layout.addWidget(okb, 2, 0)
+ layout.addWidget(cancelb, 2, 1)
+
+ self.setLayout(layout)
+
+ def getSelectedItems(self):
+ """Return a list of selected plot items
+
+ :return: List of selected plot items
+ :rtype: list[silx.gui.plot.items.Item]"""
+ return self.item_selector.selectedPlotItems
+
+ def setAvailableKinds(self, kinds):
+ """Set a list of kinds to be displayed.
+
+ :param list[str] kinds: Sequence of kinds
+ """
+ self.kind_selector.setAvailableKinds(kinds)
+
+ def selectAllKinds(self):
+ self.kind_selector.selectAll()
+
+ def setItemsSelectionMode(self, mode):
+ """Set selection mode for plot item (single item selection,
+ multiple...).
+
+ :param mode: One of :class:`QTableWidget` selection modes
+ """
+ if mode == self.item_selector.SingleSelection:
+ self.item_selector.setToolTip(
+ "Select one item by clicking on it.")
+ elif mode == self.item_selector.MultiSelection:
+ self.item_selector.setToolTip(
+ "Select one or more items by clicking with the left mouse"
+ " button.\nYou can unselect items by clicking them again.\n"
+ "Multiple items can be toggled by dragging the mouse over them.")
+ elif mode == self.item_selector.ExtendedSelection:
+ self.item_selector.setToolTip(
+ "Select one or more items. You can select multiple items "
+ "by keeping the Ctrl key pushed when clicking.\nYou can "
+ "select a range of items by clicking on the first and "
+ "last while keeping the Shift key pushed.")
+ elif mode == self.item_selector.ContiguousSelection:
+ self.item_selector.setToolTip(
+ "Select one item by clicking on it. If you press the Shift"
+ " key while clicking on a second item,\nall items between "
+ "the two will be selected.")
+ elif mode == self.item_selector.NoSelection:
+ raise ValueError("The NoSelection mode is not allowed "
+ "in this context.")
+ self.item_selector.setSelectionMode(mode)
diff --git a/silx/gui/plot/LegendSelector.py b/silx/gui/plot/LegendSelector.py
index 3af9050..31bc3db 100644
--- a/silx/gui/plot/LegendSelector.py
+++ b/silx/gui/plot/LegendSelector.py
@@ -29,7 +29,7 @@ This widget is meant to work with :class:`PlotWindow`.
__authors__ = ["V.A. Sole", "T. Rueter", "T. Vincent"]
__license__ = "MIT"
-__data__ = "28/04/2016"
+__data__ = "08/08/2016"
import logging
@@ -259,6 +259,7 @@ class LegendModel(qt.QAbstractListModel):
legendList = []
self.legendList = []
self.insertLegendList(0, legendList)
+ self._palette = qt.QPalette()
def __getitem__(self, idx):
if idx >= len(self.legendList):
@@ -282,6 +283,7 @@ class LegendModel(qt.QAbstractListModel):
raise IndexError('list index out of range')
item = self.legendList[idx]
+ isActive = item[1].get("active", False)
if role == qt.Qt.DisplayRole:
# Data to be rendered in the form of text
legend = str(item[0])
@@ -295,14 +297,19 @@ class LegendModel(qt.QAbstractListModel):
return alignment
elif role == qt.Qt.BackgroundRole:
# Background color, must be QBrush
- if idx % 2:
+ if isActive:
+ brush = self._palette.brush(qt.QPalette.Normal, qt.QPalette.Highlight)
+ elif idx % 2:
brush = qt.QBrush(qt.QColor(240, 240, 240))
else:
brush = qt.QBrush(qt.Qt.white)
return brush
elif role == qt.Qt.ForegroundRole:
# ForegroundRole color, must be QBrush
- brush = qt.QBrush(qt.Qt.blue)
+ if isActive:
+ brush = self._palette.brush(qt.QPalette.Normal, qt.QPalette.HighlightedText)
+ else:
+ brush = self._palette.brush(qt.QPalette.Normal, qt.QPalette.WindowText)
return brush
elif role == qt.Qt.CheckStateRole:
return bool(item[2]) # item[2] == True
@@ -513,6 +520,7 @@ class LegendListItemWidget(qt.QItemDelegate):
textAlign = modelIndex.data(qt.Qt.TextAlignmentRole)
painter.setBrush(textBrush)
painter.setFont(self.legend.font())
+ painter.setPen(textBrush.color())
painter.drawText(labelRect, textAlign, legendText)
# Draw icon
@@ -614,7 +622,7 @@ class LegendListView(qt.QListView):
self.setSelectionMode(qt.QAbstractItemView.NoSelection)
if model is None:
- model = LegendModel()
+ model = LegendModel(parent=self)
self.setModel(model)
self.setContextMenu(contextMenu)
@@ -1053,15 +1061,18 @@ class LegendsDockWidget(qt.QDockWidget):
# Use active color if curve is active
if legend == self.plot.getActiveCurve(just_legend=True):
color = qt.QColor(self.plot.getActiveCurveColor())
+ isActive = True
else:
color = qt.QColor.fromRgbF(*curve.getColor())
+ isActive = False
curveInfo = {
'color': color,
'linewidth': curve.getLineWidth(),
'linestyle': curve.getLineStyle(),
'symbol': curve.getSymbol(),
- 'selected': not self.plot.isCurveHidden(legend)}
+ 'selected': not self.plot.isCurveHidden(legend),
+ 'active': isActive}
legendList.append((legend, curveInfo))
self._legendWidget.setLegendList(legendList)
diff --git a/silx/gui/plot/LimitsHistory.py b/silx/gui/plot/LimitsHistory.py
new file mode 100644
index 0000000..a323548
--- /dev/null
+++ b/silx/gui/plot/LimitsHistory.py
@@ -0,0 +1,83 @@
+# 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 handling of :class:`PlotWidget` limits history.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "19/07/2017"
+
+
+from .. import qt
+
+
+class LimitsHistory(qt.QObject):
+ """Class handling history of limits of a :class:`PlotWidget`.
+
+ :param PlotWidget parent: The plot widget this object is bound to.
+ """
+
+ def __init__(self, parent):
+ self._history = []
+ super(LimitsHistory, self).__init__(parent)
+ self.setParent(parent)
+
+ def setParent(self, parent):
+ """See :meth:`QObject.setParent`.
+
+ :param PlotWidget parent: The PlotWidget this object is bound to.
+ """
+ self.clear() # Clear history when changing parent
+ super(LimitsHistory, self).setParent(parent)
+
+ def push(self):
+ """Append current limits to the history."""
+ plot = self.parent()
+ xmin, xmax = plot.getXAxis().getLimits()
+ ymin, ymax = plot.getYAxis(axis='left').getLimits()
+ y2min, y2max = plot.getYAxis(axis='right').getLimits()
+ self._history.append((xmin, xmax, ymin, ymax, y2min, y2max))
+
+ def pop(self):
+ """Restore previously limits stored in the history.
+
+ :return: True if limits were restored, False if history was empty.
+ :rtype: bool
+ """
+ plot = self.parent()
+ if self._history:
+ limits = self._history.pop(-1)
+ plot.setLimits(*limits)
+ return True
+ else:
+ plot.resetZoom()
+ return False
+
+ def clear(self):
+ """Clear stored limits states."""
+ self._history = []
+
+ def __len__(self):
+ return len(self._history)
diff --git a/silx/gui/plot/MPLColormap.py b/silx/gui/plot/MPLColormap.py
deleted file mode 100644
index 49b11d7..0000000
--- a/silx/gui/plot/MPLColormap.py
+++ /dev/null
@@ -1,1062 +0,0 @@
-# New matplotlib colormaps by Nathaniel J. Smith, Stefan van der Walt,
-# and (in the case of viridis) Eric Firing.
-#
-# This file and the colormaps in it are released under the CC0 license /
-# public domain dedication. We would appreciate credit if you use or
-# redistribute these colormaps, but do not impose any legal restrictions.
-#
-# To the extent possible under law, the persons who associated CC0 with
-# mpl-colormaps have waived all copyright and related or neighboring rights
-# to mpl-colormaps.
-#
-# You should have received a copy of the CC0 legalcode along with this
-# work. If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.
-"""Matplotlib's new colormaps"""
-
-
-from matplotlib.colors import ListedColormap
-
-
-__all__ = ['magma', 'inferno', 'plasma', 'viridis']
-
-_magma_data = [[0.001462, 0.000466, 0.013866],
- [0.002258, 0.001295, 0.018331],
- [0.003279, 0.002305, 0.023708],
- [0.004512, 0.003490, 0.029965],
- [0.005950, 0.004843, 0.037130],
- [0.007588, 0.006356, 0.044973],
- [0.009426, 0.008022, 0.052844],
- [0.011465, 0.009828, 0.060750],
- [0.013708, 0.011771, 0.068667],
- [0.016156, 0.013840, 0.076603],
- [0.018815, 0.016026, 0.084584],
- [0.021692, 0.018320, 0.092610],
- [0.024792, 0.020715, 0.100676],
- [0.028123, 0.023201, 0.108787],
- [0.031696, 0.025765, 0.116965],
- [0.035520, 0.028397, 0.125209],
- [0.039608, 0.031090, 0.133515],
- [0.043830, 0.033830, 0.141886],
- [0.048062, 0.036607, 0.150327],
- [0.052320, 0.039407, 0.158841],
- [0.056615, 0.042160, 0.167446],
- [0.060949, 0.044794, 0.176129],
- [0.065330, 0.047318, 0.184892],
- [0.069764, 0.049726, 0.193735],
- [0.074257, 0.052017, 0.202660],
- [0.078815, 0.054184, 0.211667],
- [0.083446, 0.056225, 0.220755],
- [0.088155, 0.058133, 0.229922],
- [0.092949, 0.059904, 0.239164],
- [0.097833, 0.061531, 0.248477],
- [0.102815, 0.063010, 0.257854],
- [0.107899, 0.064335, 0.267289],
- [0.113094, 0.065492, 0.276784],
- [0.118405, 0.066479, 0.286321],
- [0.123833, 0.067295, 0.295879],
- [0.129380, 0.067935, 0.305443],
- [0.135053, 0.068391, 0.315000],
- [0.140858, 0.068654, 0.324538],
- [0.146785, 0.068738, 0.334011],
- [0.152839, 0.068637, 0.343404],
- [0.159018, 0.068354, 0.352688],
- [0.165308, 0.067911, 0.361816],
- [0.171713, 0.067305, 0.370771],
- [0.178212, 0.066576, 0.379497],
- [0.184801, 0.065732, 0.387973],
- [0.191460, 0.064818, 0.396152],
- [0.198177, 0.063862, 0.404009],
- [0.204935, 0.062907, 0.411514],
- [0.211718, 0.061992, 0.418647],
- [0.218512, 0.061158, 0.425392],
- [0.225302, 0.060445, 0.431742],
- [0.232077, 0.059889, 0.437695],
- [0.238826, 0.059517, 0.443256],
- [0.245543, 0.059352, 0.448436],
- [0.252220, 0.059415, 0.453248],
- [0.258857, 0.059706, 0.457710],
- [0.265447, 0.060237, 0.461840],
- [0.271994, 0.060994, 0.465660],
- [0.278493, 0.061978, 0.469190],
- [0.284951, 0.063168, 0.472451],
- [0.291366, 0.064553, 0.475462],
- [0.297740, 0.066117, 0.478243],
- [0.304081, 0.067835, 0.480812],
- [0.310382, 0.069702, 0.483186],
- [0.316654, 0.071690, 0.485380],
- [0.322899, 0.073782, 0.487408],
- [0.329114, 0.075972, 0.489287],
- [0.335308, 0.078236, 0.491024],
- [0.341482, 0.080564, 0.492631],
- [0.347636, 0.082946, 0.494121],
- [0.353773, 0.085373, 0.495501],
- [0.359898, 0.087831, 0.496778],
- [0.366012, 0.090314, 0.497960],
- [0.372116, 0.092816, 0.499053],
- [0.378211, 0.095332, 0.500067],
- [0.384299, 0.097855, 0.501002],
- [0.390384, 0.100379, 0.501864],
- [0.396467, 0.102902, 0.502658],
- [0.402548, 0.105420, 0.503386],
- [0.408629, 0.107930, 0.504052],
- [0.414709, 0.110431, 0.504662],
- [0.420791, 0.112920, 0.505215],
- [0.426877, 0.115395, 0.505714],
- [0.432967, 0.117855, 0.506160],
- [0.439062, 0.120298, 0.506555],
- [0.445163, 0.122724, 0.506901],
- [0.451271, 0.125132, 0.507198],
- [0.457386, 0.127522, 0.507448],
- [0.463508, 0.129893, 0.507652],
- [0.469640, 0.132245, 0.507809],
- [0.475780, 0.134577, 0.507921],
- [0.481929, 0.136891, 0.507989],
- [0.488088, 0.139186, 0.508011],
- [0.494258, 0.141462, 0.507988],
- [0.500438, 0.143719, 0.507920],
- [0.506629, 0.145958, 0.507806],
- [0.512831, 0.148179, 0.507648],
- [0.519045, 0.150383, 0.507443],
- [0.525270, 0.152569, 0.507192],
- [0.531507, 0.154739, 0.506895],
- [0.537755, 0.156894, 0.506551],
- [0.544015, 0.159033, 0.506159],
- [0.550287, 0.161158, 0.505719],
- [0.556571, 0.163269, 0.505230],
- [0.562866, 0.165368, 0.504692],
- [0.569172, 0.167454, 0.504105],
- [0.575490, 0.169530, 0.503466],
- [0.581819, 0.171596, 0.502777],
- [0.588158, 0.173652, 0.502035],
- [0.594508, 0.175701, 0.501241],
- [0.600868, 0.177743, 0.500394],
- [0.607238, 0.179779, 0.499492],
- [0.613617, 0.181811, 0.498536],
- [0.620005, 0.183840, 0.497524],
- [0.626401, 0.185867, 0.496456],
- [0.632805, 0.187893, 0.495332],
- [0.639216, 0.189921, 0.494150],
- [0.645633, 0.191952, 0.492910],
- [0.652056, 0.193986, 0.491611],
- [0.658483, 0.196027, 0.490253],
- [0.664915, 0.198075, 0.488836],
- [0.671349, 0.200133, 0.487358],
- [0.677786, 0.202203, 0.485819],
- [0.684224, 0.204286, 0.484219],
- [0.690661, 0.206384, 0.482558],
- [0.697098, 0.208501, 0.480835],
- [0.703532, 0.210638, 0.479049],
- [0.709962, 0.212797, 0.477201],
- [0.716387, 0.214982, 0.475290],
- [0.722805, 0.217194, 0.473316],
- [0.729216, 0.219437, 0.471279],
- [0.735616, 0.221713, 0.469180],
- [0.742004, 0.224025, 0.467018],
- [0.748378, 0.226377, 0.464794],
- [0.754737, 0.228772, 0.462509],
- [0.761077, 0.231214, 0.460162],
- [0.767398, 0.233705, 0.457755],
- [0.773695, 0.236249, 0.455289],
- [0.779968, 0.238851, 0.452765],
- [0.786212, 0.241514, 0.450184],
- [0.792427, 0.244242, 0.447543],
- [0.798608, 0.247040, 0.444848],
- [0.804752, 0.249911, 0.442102],
- [0.810855, 0.252861, 0.439305],
- [0.816914, 0.255895, 0.436461],
- [0.822926, 0.259016, 0.433573],
- [0.828886, 0.262229, 0.430644],
- [0.834791, 0.265540, 0.427671],
- [0.840636, 0.268953, 0.424666],
- [0.846416, 0.272473, 0.421631],
- [0.852126, 0.276106, 0.418573],
- [0.857763, 0.279857, 0.415496],
- [0.863320, 0.283729, 0.412403],
- [0.868793, 0.287728, 0.409303],
- [0.874176, 0.291859, 0.406205],
- [0.879464, 0.296125, 0.403118],
- [0.884651, 0.300530, 0.400047],
- [0.889731, 0.305079, 0.397002],
- [0.894700, 0.309773, 0.393995],
- [0.899552, 0.314616, 0.391037],
- [0.904281, 0.319610, 0.388137],
- [0.908884, 0.324755, 0.385308],
- [0.913354, 0.330052, 0.382563],
- [0.917689, 0.335500, 0.379915],
- [0.921884, 0.341098, 0.377376],
- [0.925937, 0.346844, 0.374959],
- [0.929845, 0.352734, 0.372677],
- [0.933606, 0.358764, 0.370541],
- [0.937221, 0.364929, 0.368567],
- [0.940687, 0.371224, 0.366762],
- [0.944006, 0.377643, 0.365136],
- [0.947180, 0.384178, 0.363701],
- [0.950210, 0.390820, 0.362468],
- [0.953099, 0.397563, 0.361438],
- [0.955849, 0.404400, 0.360619],
- [0.958464, 0.411324, 0.360014],
- [0.960949, 0.418323, 0.359630],
- [0.963310, 0.425390, 0.359469],
- [0.965549, 0.432519, 0.359529],
- [0.967671, 0.439703, 0.359810],
- [0.969680, 0.446936, 0.360311],
- [0.971582, 0.454210, 0.361030],
- [0.973381, 0.461520, 0.361965],
- [0.975082, 0.468861, 0.363111],
- [0.976690, 0.476226, 0.364466],
- [0.978210, 0.483612, 0.366025],
- [0.979645, 0.491014, 0.367783],
- [0.981000, 0.498428, 0.369734],
- [0.982279, 0.505851, 0.371874],
- [0.983485, 0.513280, 0.374198],
- [0.984622, 0.520713, 0.376698],
- [0.985693, 0.528148, 0.379371],
- [0.986700, 0.535582, 0.382210],
- [0.987646, 0.543015, 0.385210],
- [0.988533, 0.550446, 0.388365],
- [0.989363, 0.557873, 0.391671],
- [0.990138, 0.565296, 0.395122],
- [0.990871, 0.572706, 0.398714],
- [0.991558, 0.580107, 0.402441],
- [0.992196, 0.587502, 0.406299],
- [0.992785, 0.594891, 0.410283],
- [0.993326, 0.602275, 0.414390],
- [0.993834, 0.609644, 0.418613],
- [0.994309, 0.616999, 0.422950],
- [0.994738, 0.624350, 0.427397],
- [0.995122, 0.631696, 0.431951],
- [0.995480, 0.639027, 0.436607],
- [0.995810, 0.646344, 0.441361],
- [0.996096, 0.653659, 0.446213],
- [0.996341, 0.660969, 0.451160],
- [0.996580, 0.668256, 0.456192],
- [0.996775, 0.675541, 0.461314],
- [0.996925, 0.682828, 0.466526],
- [0.997077, 0.690088, 0.471811],
- [0.997186, 0.697349, 0.477182],
- [0.997254, 0.704611, 0.482635],
- [0.997325, 0.711848, 0.488154],
- [0.997351, 0.719089, 0.493755],
- [0.997351, 0.726324, 0.499428],
- [0.997341, 0.733545, 0.505167],
- [0.997285, 0.740772, 0.510983],
- [0.997228, 0.747981, 0.516859],
- [0.997138, 0.755190, 0.522806],
- [0.997019, 0.762398, 0.528821],
- [0.996898, 0.769591, 0.534892],
- [0.996727, 0.776795, 0.541039],
- [0.996571, 0.783977, 0.547233],
- [0.996369, 0.791167, 0.553499],
- [0.996162, 0.798348, 0.559820],
- [0.995932, 0.805527, 0.566202],
- [0.995680, 0.812706, 0.572645],
- [0.995424, 0.819875, 0.579140],
- [0.995131, 0.827052, 0.585701],
- [0.994851, 0.834213, 0.592307],
- [0.994524, 0.841387, 0.598983],
- [0.994222, 0.848540, 0.605696],
- [0.993866, 0.855711, 0.612482],
- [0.993545, 0.862859, 0.619299],
- [0.993170, 0.870024, 0.626189],
- [0.992831, 0.877168, 0.633109],
- [0.992440, 0.884330, 0.640099],
- [0.992089, 0.891470, 0.647116],
- [0.991688, 0.898627, 0.654202],
- [0.991332, 0.905763, 0.661309],
- [0.990930, 0.912915, 0.668481],
- [0.990570, 0.920049, 0.675675],
- [0.990175, 0.927196, 0.682926],
- [0.989815, 0.934329, 0.690198],
- [0.989434, 0.941470, 0.697519],
- [0.989077, 0.948604, 0.704863],
- [0.988717, 0.955742, 0.712242],
- [0.988367, 0.962878, 0.719649],
- [0.988033, 0.970012, 0.727077],
- [0.987691, 0.977154, 0.734536],
- [0.987387, 0.984288, 0.742002],
- [0.987053, 0.991438, 0.749504]]
-
-_inferno_data = [[0.001462, 0.000466, 0.013866],
- [0.002267, 0.001270, 0.018570],
- [0.003299, 0.002249, 0.024239],
- [0.004547, 0.003392, 0.030909],
- [0.006006, 0.004692, 0.038558],
- [0.007676, 0.006136, 0.046836],
- [0.009561, 0.007713, 0.055143],
- [0.011663, 0.009417, 0.063460],
- [0.013995, 0.011225, 0.071862],
- [0.016561, 0.013136, 0.080282],
- [0.019373, 0.015133, 0.088767],
- [0.022447, 0.017199, 0.097327],
- [0.025793, 0.019331, 0.105930],
- [0.029432, 0.021503, 0.114621],
- [0.033385, 0.023702, 0.123397],
- [0.037668, 0.025921, 0.132232],
- [0.042253, 0.028139, 0.141141],
- [0.046915, 0.030324, 0.150164],
- [0.051644, 0.032474, 0.159254],
- [0.056449, 0.034569, 0.168414],
- [0.061340, 0.036590, 0.177642],
- [0.066331, 0.038504, 0.186962],
- [0.071429, 0.040294, 0.196354],
- [0.076637, 0.041905, 0.205799],
- [0.081962, 0.043328, 0.215289],
- [0.087411, 0.044556, 0.224813],
- [0.092990, 0.045583, 0.234358],
- [0.098702, 0.046402, 0.243904],
- [0.104551, 0.047008, 0.253430],
- [0.110536, 0.047399, 0.262912],
- [0.116656, 0.047574, 0.272321],
- [0.122908, 0.047536, 0.281624],
- [0.129285, 0.047293, 0.290788],
- [0.135778, 0.046856, 0.299776],
- [0.142378, 0.046242, 0.308553],
- [0.149073, 0.045468, 0.317085],
- [0.155850, 0.044559, 0.325338],
- [0.162689, 0.043554, 0.333277],
- [0.169575, 0.042489, 0.340874],
- [0.176493, 0.041402, 0.348111],
- [0.183429, 0.040329, 0.354971],
- [0.190367, 0.039309, 0.361447],
- [0.197297, 0.038400, 0.367535],
- [0.204209, 0.037632, 0.373238],
- [0.211095, 0.037030, 0.378563],
- [0.217949, 0.036615, 0.383522],
- [0.224763, 0.036405, 0.388129],
- [0.231538, 0.036405, 0.392400],
- [0.238273, 0.036621, 0.396353],
- [0.244967, 0.037055, 0.400007],
- [0.251620, 0.037705, 0.403378],
- [0.258234, 0.038571, 0.406485],
- [0.264810, 0.039647, 0.409345],
- [0.271347, 0.040922, 0.411976],
- [0.277850, 0.042353, 0.414392],
- [0.284321, 0.043933, 0.416608],
- [0.290763, 0.045644, 0.418637],
- [0.297178, 0.047470, 0.420491],
- [0.303568, 0.049396, 0.422182],
- [0.309935, 0.051407, 0.423721],
- [0.316282, 0.053490, 0.425116],
- [0.322610, 0.055634, 0.426377],
- [0.328921, 0.057827, 0.427511],
- [0.335217, 0.060060, 0.428524],
- [0.341500, 0.062325, 0.429425],
- [0.347771, 0.064616, 0.430217],
- [0.354032, 0.066925, 0.430906],
- [0.360284, 0.069247, 0.431497],
- [0.366529, 0.071579, 0.431994],
- [0.372768, 0.073915, 0.432400],
- [0.379001, 0.076253, 0.432719],
- [0.385228, 0.078591, 0.432955],
- [0.391453, 0.080927, 0.433109],
- [0.397674, 0.083257, 0.433183],
- [0.403894, 0.085580, 0.433179],
- [0.410113, 0.087896, 0.433098],
- [0.416331, 0.090203, 0.432943],
- [0.422549, 0.092501, 0.432714],
- [0.428768, 0.094790, 0.432412],
- [0.434987, 0.097069, 0.432039],
- [0.441207, 0.099338, 0.431594],
- [0.447428, 0.101597, 0.431080],
- [0.453651, 0.103848, 0.430498],
- [0.459875, 0.106089, 0.429846],
- [0.466100, 0.108322, 0.429125],
- [0.472328, 0.110547, 0.428334],
- [0.478558, 0.112764, 0.427475],
- [0.484789, 0.114974, 0.426548],
- [0.491022, 0.117179, 0.425552],
- [0.497257, 0.119379, 0.424488],
- [0.503493, 0.121575, 0.423356],
- [0.509730, 0.123769, 0.422156],
- [0.515967, 0.125960, 0.420887],
- [0.522206, 0.128150, 0.419549],
- [0.528444, 0.130341, 0.418142],
- [0.534683, 0.132534, 0.416667],
- [0.540920, 0.134729, 0.415123],
- [0.547157, 0.136929, 0.413511],
- [0.553392, 0.139134, 0.411829],
- [0.559624, 0.141346, 0.410078],
- [0.565854, 0.143567, 0.408258],
- [0.572081, 0.145797, 0.406369],
- [0.578304, 0.148039, 0.404411],
- [0.584521, 0.150294, 0.402385],
- [0.590734, 0.152563, 0.400290],
- [0.596940, 0.154848, 0.398125],
- [0.603139, 0.157151, 0.395891],
- [0.609330, 0.159474, 0.393589],
- [0.615513, 0.161817, 0.391219],
- [0.621685, 0.164184, 0.388781],
- [0.627847, 0.166575, 0.386276],
- [0.633998, 0.168992, 0.383704],
- [0.640135, 0.171438, 0.381065],
- [0.646260, 0.173914, 0.378359],
- [0.652369, 0.176421, 0.375586],
- [0.658463, 0.178962, 0.372748],
- [0.664540, 0.181539, 0.369846],
- [0.670599, 0.184153, 0.366879],
- [0.676638, 0.186807, 0.363849],
- [0.682656, 0.189501, 0.360757],
- [0.688653, 0.192239, 0.357603],
- [0.694627, 0.195021, 0.354388],
- [0.700576, 0.197851, 0.351113],
- [0.706500, 0.200728, 0.347777],
- [0.712396, 0.203656, 0.344383],
- [0.718264, 0.206636, 0.340931],
- [0.724103, 0.209670, 0.337424],
- [0.729909, 0.212759, 0.333861],
- [0.735683, 0.215906, 0.330245],
- [0.741423, 0.219112, 0.326576],
- [0.747127, 0.222378, 0.322856],
- [0.752794, 0.225706, 0.319085],
- [0.758422, 0.229097, 0.315266],
- [0.764010, 0.232554, 0.311399],
- [0.769556, 0.236077, 0.307485],
- [0.775059, 0.239667, 0.303526],
- [0.780517, 0.243327, 0.299523],
- [0.785929, 0.247056, 0.295477],
- [0.791293, 0.250856, 0.291390],
- [0.796607, 0.254728, 0.287264],
- [0.801871, 0.258674, 0.283099],
- [0.807082, 0.262692, 0.278898],
- [0.812239, 0.266786, 0.274661],
- [0.817341, 0.270954, 0.270390],
- [0.822386, 0.275197, 0.266085],
- [0.827372, 0.279517, 0.261750],
- [0.832299, 0.283913, 0.257383],
- [0.837165, 0.288385, 0.252988],
- [0.841969, 0.292933, 0.248564],
- [0.846709, 0.297559, 0.244113],
- [0.851384, 0.302260, 0.239636],
- [0.855992, 0.307038, 0.235133],
- [0.860533, 0.311892, 0.230606],
- [0.865006, 0.316822, 0.226055],
- [0.869409, 0.321827, 0.221482],
- [0.873741, 0.326906, 0.216886],
- [0.878001, 0.332060, 0.212268],
- [0.882188, 0.337287, 0.207628],
- [0.886302, 0.342586, 0.202968],
- [0.890341, 0.347957, 0.198286],
- [0.894305, 0.353399, 0.193584],
- [0.898192, 0.358911, 0.188860],
- [0.902003, 0.364492, 0.184116],
- [0.905735, 0.370140, 0.179350],
- [0.909390, 0.375856, 0.174563],
- [0.912966, 0.381636, 0.169755],
- [0.916462, 0.387481, 0.164924],
- [0.919879, 0.393389, 0.160070],
- [0.923215, 0.399359, 0.155193],
- [0.926470, 0.405389, 0.150292],
- [0.929644, 0.411479, 0.145367],
- [0.932737, 0.417627, 0.140417],
- [0.935747, 0.423831, 0.135440],
- [0.938675, 0.430091, 0.130438],
- [0.941521, 0.436405, 0.125409],
- [0.944285, 0.442772, 0.120354],
- [0.946965, 0.449191, 0.115272],
- [0.949562, 0.455660, 0.110164],
- [0.952075, 0.462178, 0.105031],
- [0.954506, 0.468744, 0.099874],
- [0.956852, 0.475356, 0.094695],
- [0.959114, 0.482014, 0.089499],
- [0.961293, 0.488716, 0.084289],
- [0.963387, 0.495462, 0.079073],
- [0.965397, 0.502249, 0.073859],
- [0.967322, 0.509078, 0.068659],
- [0.969163, 0.515946, 0.063488],
- [0.970919, 0.522853, 0.058367],
- [0.972590, 0.529798, 0.053324],
- [0.974176, 0.536780, 0.048392],
- [0.975677, 0.543798, 0.043618],
- [0.977092, 0.550850, 0.039050],
- [0.978422, 0.557937, 0.034931],
- [0.979666, 0.565057, 0.031409],
- [0.980824, 0.572209, 0.028508],
- [0.981895, 0.579392, 0.026250],
- [0.982881, 0.586606, 0.024661],
- [0.983779, 0.593849, 0.023770],
- [0.984591, 0.601122, 0.023606],
- [0.985315, 0.608422, 0.024202],
- [0.985952, 0.615750, 0.025592],
- [0.986502, 0.623105, 0.027814],
- [0.986964, 0.630485, 0.030908],
- [0.987337, 0.637890, 0.034916],
- [0.987622, 0.645320, 0.039886],
- [0.987819, 0.652773, 0.045581],
- [0.987926, 0.660250, 0.051750],
- [0.987945, 0.667748, 0.058329],
- [0.987874, 0.675267, 0.065257],
- [0.987714, 0.682807, 0.072489],
- [0.987464, 0.690366, 0.079990],
- [0.987124, 0.697944, 0.087731],
- [0.986694, 0.705540, 0.095694],
- [0.986175, 0.713153, 0.103863],
- [0.985566, 0.720782, 0.112229],
- [0.984865, 0.728427, 0.120785],
- [0.984075, 0.736087, 0.129527],
- [0.983196, 0.743758, 0.138453],
- [0.982228, 0.751442, 0.147565],
- [0.981173, 0.759135, 0.156863],
- [0.980032, 0.766837, 0.166353],
- [0.978806, 0.774545, 0.176037],
- [0.977497, 0.782258, 0.185923],
- [0.976108, 0.789974, 0.196018],
- [0.974638, 0.797692, 0.206332],
- [0.973088, 0.805409, 0.216877],
- [0.971468, 0.813122, 0.227658],
- [0.969783, 0.820825, 0.238686],
- [0.968041, 0.828515, 0.249972],
- [0.966243, 0.836191, 0.261534],
- [0.964394, 0.843848, 0.273391],
- [0.962517, 0.851476, 0.285546],
- [0.960626, 0.859069, 0.298010],
- [0.958720, 0.866624, 0.310820],
- [0.956834, 0.874129, 0.323974],
- [0.954997, 0.881569, 0.337475],
- [0.953215, 0.888942, 0.351369],
- [0.951546, 0.896226, 0.365627],
- [0.950018, 0.903409, 0.380271],
- [0.948683, 0.910473, 0.395289],
- [0.947594, 0.917399, 0.410665],
- [0.946809, 0.924168, 0.426373],
- [0.946392, 0.930761, 0.442367],
- [0.946403, 0.937159, 0.458592],
- [0.946903, 0.943348, 0.474970],
- [0.947937, 0.949318, 0.491426],
- [0.949545, 0.955063, 0.507860],
- [0.951740, 0.960587, 0.524203],
- [0.954529, 0.965896, 0.540361],
- [0.957896, 0.971003, 0.556275],
- [0.961812, 0.975924, 0.571925],
- [0.966249, 0.980678, 0.587206],
- [0.971162, 0.985282, 0.602154],
- [0.976511, 0.989753, 0.616760],
- [0.982257, 0.994109, 0.631017],
- [0.988362, 0.998364, 0.644924]]
-
-_plasma_data = [[0.050383, 0.029803, 0.527975],
- [0.063536, 0.028426, 0.533124],
- [0.075353, 0.027206, 0.538007],
- [0.086222, 0.026125, 0.542658],
- [0.096379, 0.025165, 0.547103],
- [0.105980, 0.024309, 0.551368],
- [0.115124, 0.023556, 0.555468],
- [0.123903, 0.022878, 0.559423],
- [0.132381, 0.022258, 0.563250],
- [0.140603, 0.021687, 0.566959],
- [0.148607, 0.021154, 0.570562],
- [0.156421, 0.020651, 0.574065],
- [0.164070, 0.020171, 0.577478],
- [0.171574, 0.019706, 0.580806],
- [0.178950, 0.019252, 0.584054],
- [0.186213, 0.018803, 0.587228],
- [0.193374, 0.018354, 0.590330],
- [0.200445, 0.017902, 0.593364],
- [0.207435, 0.017442, 0.596333],
- [0.214350, 0.016973, 0.599239],
- [0.221197, 0.016497, 0.602083],
- [0.227983, 0.016007, 0.604867],
- [0.234715, 0.015502, 0.607592],
- [0.241396, 0.014979, 0.610259],
- [0.248032, 0.014439, 0.612868],
- [0.254627, 0.013882, 0.615419],
- [0.261183, 0.013308, 0.617911],
- [0.267703, 0.012716, 0.620346],
- [0.274191, 0.012109, 0.622722],
- [0.280648, 0.011488, 0.625038],
- [0.287076, 0.010855, 0.627295],
- [0.293478, 0.010213, 0.629490],
- [0.299855, 0.009561, 0.631624],
- [0.306210, 0.008902, 0.633694],
- [0.312543, 0.008239, 0.635700],
- [0.318856, 0.007576, 0.637640],
- [0.325150, 0.006915, 0.639512],
- [0.331426, 0.006261, 0.641316],
- [0.337683, 0.005618, 0.643049],
- [0.343925, 0.004991, 0.644710],
- [0.350150, 0.004382, 0.646298],
- [0.356359, 0.003798, 0.647810],
- [0.362553, 0.003243, 0.649245],
- [0.368733, 0.002724, 0.650601],
- [0.374897, 0.002245, 0.651876],
- [0.381047, 0.001814, 0.653068],
- [0.387183, 0.001434, 0.654177],
- [0.393304, 0.001114, 0.655199],
- [0.399411, 0.000859, 0.656133],
- [0.405503, 0.000678, 0.656977],
- [0.411580, 0.000577, 0.657730],
- [0.417642, 0.000564, 0.658390],
- [0.423689, 0.000646, 0.658956],
- [0.429719, 0.000831, 0.659425],
- [0.435734, 0.001127, 0.659797],
- [0.441732, 0.001540, 0.660069],
- [0.447714, 0.002080, 0.660240],
- [0.453677, 0.002755, 0.660310],
- [0.459623, 0.003574, 0.660277],
- [0.465550, 0.004545, 0.660139],
- [0.471457, 0.005678, 0.659897],
- [0.477344, 0.006980, 0.659549],
- [0.483210, 0.008460, 0.659095],
- [0.489055, 0.010127, 0.658534],
- [0.494877, 0.011990, 0.657865],
- [0.500678, 0.014055, 0.657088],
- [0.506454, 0.016333, 0.656202],
- [0.512206, 0.018833, 0.655209],
- [0.517933, 0.021563, 0.654109],
- [0.523633, 0.024532, 0.652901],
- [0.529306, 0.027747, 0.651586],
- [0.534952, 0.031217, 0.650165],
- [0.540570, 0.034950, 0.648640],
- [0.546157, 0.038954, 0.647010],
- [0.551715, 0.043136, 0.645277],
- [0.557243, 0.047331, 0.643443],
- [0.562738, 0.051545, 0.641509],
- [0.568201, 0.055778, 0.639477],
- [0.573632, 0.060028, 0.637349],
- [0.579029, 0.064296, 0.635126],
- [0.584391, 0.068579, 0.632812],
- [0.589719, 0.072878, 0.630408],
- [0.595011, 0.077190, 0.627917],
- [0.600266, 0.081516, 0.625342],
- [0.605485, 0.085854, 0.622686],
- [0.610667, 0.090204, 0.619951],
- [0.615812, 0.094564, 0.617140],
- [0.620919, 0.098934, 0.614257],
- [0.625987, 0.103312, 0.611305],
- [0.631017, 0.107699, 0.608287],
- [0.636008, 0.112092, 0.605205],
- [0.640959, 0.116492, 0.602065],
- [0.645872, 0.120898, 0.598867],
- [0.650746, 0.125309, 0.595617],
- [0.655580, 0.129725, 0.592317],
- [0.660374, 0.134144, 0.588971],
- [0.665129, 0.138566, 0.585582],
- [0.669845, 0.142992, 0.582154],
- [0.674522, 0.147419, 0.578688],
- [0.679160, 0.151848, 0.575189],
- [0.683758, 0.156278, 0.571660],
- [0.688318, 0.160709, 0.568103],
- [0.692840, 0.165141, 0.564522],
- [0.697324, 0.169573, 0.560919],
- [0.701769, 0.174005, 0.557296],
- [0.706178, 0.178437, 0.553657],
- [0.710549, 0.182868, 0.550004],
- [0.714883, 0.187299, 0.546338],
- [0.719181, 0.191729, 0.542663],
- [0.723444, 0.196158, 0.538981],
- [0.727670, 0.200586, 0.535293],
- [0.731862, 0.205013, 0.531601],
- [0.736019, 0.209439, 0.527908],
- [0.740143, 0.213864, 0.524216],
- [0.744232, 0.218288, 0.520524],
- [0.748289, 0.222711, 0.516834],
- [0.752312, 0.227133, 0.513149],
- [0.756304, 0.231555, 0.509468],
- [0.760264, 0.235976, 0.505794],
- [0.764193, 0.240396, 0.502126],
- [0.768090, 0.244817, 0.498465],
- [0.771958, 0.249237, 0.494813],
- [0.775796, 0.253658, 0.491171],
- [0.779604, 0.258078, 0.487539],
- [0.783383, 0.262500, 0.483918],
- [0.787133, 0.266922, 0.480307],
- [0.790855, 0.271345, 0.476706],
- [0.794549, 0.275770, 0.473117],
- [0.798216, 0.280197, 0.469538],
- [0.801855, 0.284626, 0.465971],
- [0.805467, 0.289057, 0.462415],
- [0.809052, 0.293491, 0.458870],
- [0.812612, 0.297928, 0.455338],
- [0.816144, 0.302368, 0.451816],
- [0.819651, 0.306812, 0.448306],
- [0.823132, 0.311261, 0.444806],
- [0.826588, 0.315714, 0.441316],
- [0.830018, 0.320172, 0.437836],
- [0.833422, 0.324635, 0.434366],
- [0.836801, 0.329105, 0.430905],
- [0.840155, 0.333580, 0.427455],
- [0.843484, 0.338062, 0.424013],
- [0.846788, 0.342551, 0.420579],
- [0.850066, 0.347048, 0.417153],
- [0.853319, 0.351553, 0.413734],
- [0.856547, 0.356066, 0.410322],
- [0.859750, 0.360588, 0.406917],
- [0.862927, 0.365119, 0.403519],
- [0.866078, 0.369660, 0.400126],
- [0.869203, 0.374212, 0.396738],
- [0.872303, 0.378774, 0.393355],
- [0.875376, 0.383347, 0.389976],
- [0.878423, 0.387932, 0.386600],
- [0.881443, 0.392529, 0.383229],
- [0.884436, 0.397139, 0.379860],
- [0.887402, 0.401762, 0.376494],
- [0.890340, 0.406398, 0.373130],
- [0.893250, 0.411048, 0.369768],
- [0.896131, 0.415712, 0.366407],
- [0.898984, 0.420392, 0.363047],
- [0.901807, 0.425087, 0.359688],
- [0.904601, 0.429797, 0.356329],
- [0.907365, 0.434524, 0.352970],
- [0.910098, 0.439268, 0.349610],
- [0.912800, 0.444029, 0.346251],
- [0.915471, 0.448807, 0.342890],
- [0.918109, 0.453603, 0.339529],
- [0.920714, 0.458417, 0.336166],
- [0.923287, 0.463251, 0.332801],
- [0.925825, 0.468103, 0.329435],
- [0.928329, 0.472975, 0.326067],
- [0.930798, 0.477867, 0.322697],
- [0.933232, 0.482780, 0.319325],
- [0.935630, 0.487712, 0.315952],
- [0.937990, 0.492667, 0.312575],
- [0.940313, 0.497642, 0.309197],
- [0.942598, 0.502639, 0.305816],
- [0.944844, 0.507658, 0.302433],
- [0.947051, 0.512699, 0.299049],
- [0.949217, 0.517763, 0.295662],
- [0.951344, 0.522850, 0.292275],
- [0.953428, 0.527960, 0.288883],
- [0.955470, 0.533093, 0.285490],
- [0.957469, 0.538250, 0.282096],
- [0.959424, 0.543431, 0.278701],
- [0.961336, 0.548636, 0.275305],
- [0.963203, 0.553865, 0.271909],
- [0.965024, 0.559118, 0.268513],
- [0.966798, 0.564396, 0.265118],
- [0.968526, 0.569700, 0.261721],
- [0.970205, 0.575028, 0.258325],
- [0.971835, 0.580382, 0.254931],
- [0.973416, 0.585761, 0.251540],
- [0.974947, 0.591165, 0.248151],
- [0.976428, 0.596595, 0.244767],
- [0.977856, 0.602051, 0.241387],
- [0.979233, 0.607532, 0.238013],
- [0.980556, 0.613039, 0.234646],
- [0.981826, 0.618572, 0.231287],
- [0.983041, 0.624131, 0.227937],
- [0.984199, 0.629718, 0.224595],
- [0.985301, 0.635330, 0.221265],
- [0.986345, 0.640969, 0.217948],
- [0.987332, 0.646633, 0.214648],
- [0.988260, 0.652325, 0.211364],
- [0.989128, 0.658043, 0.208100],
- [0.989935, 0.663787, 0.204859],
- [0.990681, 0.669558, 0.201642],
- [0.991365, 0.675355, 0.198453],
- [0.991985, 0.681179, 0.195295],
- [0.992541, 0.687030, 0.192170],
- [0.993032, 0.692907, 0.189084],
- [0.993456, 0.698810, 0.186041],
- [0.993814, 0.704741, 0.183043],
- [0.994103, 0.710698, 0.180097],
- [0.994324, 0.716681, 0.177208],
- [0.994474, 0.722691, 0.174381],
- [0.994553, 0.728728, 0.171622],
- [0.994561, 0.734791, 0.168938],
- [0.994495, 0.740880, 0.166335],
- [0.994355, 0.746995, 0.163821],
- [0.994141, 0.753137, 0.161404],
- [0.993851, 0.759304, 0.159092],
- [0.993482, 0.765499, 0.156891],
- [0.993033, 0.771720, 0.154808],
- [0.992505, 0.777967, 0.152855],
- [0.991897, 0.784239, 0.151042],
- [0.991209, 0.790537, 0.149377],
- [0.990439, 0.796859, 0.147870],
- [0.989587, 0.803205, 0.146529],
- [0.988648, 0.809579, 0.145357],
- [0.987621, 0.815978, 0.144363],
- [0.986509, 0.822401, 0.143557],
- [0.985314, 0.828846, 0.142945],
- [0.984031, 0.835315, 0.142528],
- [0.982653, 0.841812, 0.142303],
- [0.981190, 0.848329, 0.142279],
- [0.979644, 0.854866, 0.142453],
- [0.977995, 0.861432, 0.142808],
- [0.976265, 0.868016, 0.143351],
- [0.974443, 0.874622, 0.144061],
- [0.972530, 0.881250, 0.144923],
- [0.970533, 0.887896, 0.145919],
- [0.968443, 0.894564, 0.147014],
- [0.966271, 0.901249, 0.148180],
- [0.964021, 0.907950, 0.149370],
- [0.961681, 0.914672, 0.150520],
- [0.959276, 0.921407, 0.151566],
- [0.956808, 0.928152, 0.152409],
- [0.954287, 0.934908, 0.152921],
- [0.951726, 0.941671, 0.152925],
- [0.949151, 0.948435, 0.152178],
- [0.946602, 0.955190, 0.150328],
- [0.944152, 0.961916, 0.146861],
- [0.941896, 0.968590, 0.140956],
- [0.940015, 0.975158, 0.131326]]
-
-_viridis_data = [[0.267004, 0.004874, 0.329415],
- [0.268510, 0.009605, 0.335427],
- [0.269944, 0.014625, 0.341379],
- [0.271305, 0.019942, 0.347269],
- [0.272594, 0.025563, 0.353093],
- [0.273809, 0.031497, 0.358853],
- [0.274952, 0.037752, 0.364543],
- [0.276022, 0.044167, 0.370164],
- [0.277018, 0.050344, 0.375715],
- [0.277941, 0.056324, 0.381191],
- [0.278791, 0.062145, 0.386592],
- [0.279566, 0.067836, 0.391917],
- [0.280267, 0.073417, 0.397163],
- [0.280894, 0.078907, 0.402329],
- [0.281446, 0.084320, 0.407414],
- [0.281924, 0.089666, 0.412415],
- [0.282327, 0.094955, 0.417331],
- [0.282656, 0.100196, 0.422160],
- [0.282910, 0.105393, 0.426902],
- [0.283091, 0.110553, 0.431554],
- [0.283197, 0.115680, 0.436115],
- [0.283229, 0.120777, 0.440584],
- [0.283187, 0.125848, 0.444960],
- [0.283072, 0.130895, 0.449241],
- [0.282884, 0.135920, 0.453427],
- [0.282623, 0.140926, 0.457517],
- [0.282290, 0.145912, 0.461510],
- [0.281887, 0.150881, 0.465405],
- [0.281412, 0.155834, 0.469201],
- [0.280868, 0.160771, 0.472899],
- [0.280255, 0.165693, 0.476498],
- [0.279574, 0.170599, 0.479997],
- [0.278826, 0.175490, 0.483397],
- [0.278012, 0.180367, 0.486697],
- [0.277134, 0.185228, 0.489898],
- [0.276194, 0.190074, 0.493001],
- [0.275191, 0.194905, 0.496005],
- [0.274128, 0.199721, 0.498911],
- [0.273006, 0.204520, 0.501721],
- [0.271828, 0.209303, 0.504434],
- [0.270595, 0.214069, 0.507052],
- [0.269308, 0.218818, 0.509577],
- [0.267968, 0.223549, 0.512008],
- [0.266580, 0.228262, 0.514349],
- [0.265145, 0.232956, 0.516599],
- [0.263663, 0.237631, 0.518762],
- [0.262138, 0.242286, 0.520837],
- [0.260571, 0.246922, 0.522828],
- [0.258965, 0.251537, 0.524736],
- [0.257322, 0.256130, 0.526563],
- [0.255645, 0.260703, 0.528312],
- [0.253935, 0.265254, 0.529983],
- [0.252194, 0.269783, 0.531579],
- [0.250425, 0.274290, 0.533103],
- [0.248629, 0.278775, 0.534556],
- [0.246811, 0.283237, 0.535941],
- [0.244972, 0.287675, 0.537260],
- [0.243113, 0.292092, 0.538516],
- [0.241237, 0.296485, 0.539709],
- [0.239346, 0.300855, 0.540844],
- [0.237441, 0.305202, 0.541921],
- [0.235526, 0.309527, 0.542944],
- [0.233603, 0.313828, 0.543914],
- [0.231674, 0.318106, 0.544834],
- [0.229739, 0.322361, 0.545706],
- [0.227802, 0.326594, 0.546532],
- [0.225863, 0.330805, 0.547314],
- [0.223925, 0.334994, 0.548053],
- [0.221989, 0.339161, 0.548752],
- [0.220057, 0.343307, 0.549413],
- [0.218130, 0.347432, 0.550038],
- [0.216210, 0.351535, 0.550627],
- [0.214298, 0.355619, 0.551184],
- [0.212395, 0.359683, 0.551710],
- [0.210503, 0.363727, 0.552206],
- [0.208623, 0.367752, 0.552675],
- [0.206756, 0.371758, 0.553117],
- [0.204903, 0.375746, 0.553533],
- [0.203063, 0.379716, 0.553925],
- [0.201239, 0.383670, 0.554294],
- [0.199430, 0.387607, 0.554642],
- [0.197636, 0.391528, 0.554969],
- [0.195860, 0.395433, 0.555276],
- [0.194100, 0.399323, 0.555565],
- [0.192357, 0.403199, 0.555836],
- [0.190631, 0.407061, 0.556089],
- [0.188923, 0.410910, 0.556326],
- [0.187231, 0.414746, 0.556547],
- [0.185556, 0.418570, 0.556753],
- [0.183898, 0.422383, 0.556944],
- [0.182256, 0.426184, 0.557120],
- [0.180629, 0.429975, 0.557282],
- [0.179019, 0.433756, 0.557430],
- [0.177423, 0.437527, 0.557565],
- [0.175841, 0.441290, 0.557685],
- [0.174274, 0.445044, 0.557792],
- [0.172719, 0.448791, 0.557885],
- [0.171176, 0.452530, 0.557965],
- [0.169646, 0.456262, 0.558030],
- [0.168126, 0.459988, 0.558082],
- [0.166617, 0.463708, 0.558119],
- [0.165117, 0.467423, 0.558141],
- [0.163625, 0.471133, 0.558148],
- [0.162142, 0.474838, 0.558140],
- [0.160665, 0.478540, 0.558115],
- [0.159194, 0.482237, 0.558073],
- [0.157729, 0.485932, 0.558013],
- [0.156270, 0.489624, 0.557936],
- [0.154815, 0.493313, 0.557840],
- [0.153364, 0.497000, 0.557724],
- [0.151918, 0.500685, 0.557587],
- [0.150476, 0.504369, 0.557430],
- [0.149039, 0.508051, 0.557250],
- [0.147607, 0.511733, 0.557049],
- [0.146180, 0.515413, 0.556823],
- [0.144759, 0.519093, 0.556572],
- [0.143343, 0.522773, 0.556295],
- [0.141935, 0.526453, 0.555991],
- [0.140536, 0.530132, 0.555659],
- [0.139147, 0.533812, 0.555298],
- [0.137770, 0.537492, 0.554906],
- [0.136408, 0.541173, 0.554483],
- [0.135066, 0.544853, 0.554029],
- [0.133743, 0.548535, 0.553541],
- [0.132444, 0.552216, 0.553018],
- [0.131172, 0.555899, 0.552459],
- [0.129933, 0.559582, 0.551864],
- [0.128729, 0.563265, 0.551229],
- [0.127568, 0.566949, 0.550556],
- [0.126453, 0.570633, 0.549841],
- [0.125394, 0.574318, 0.549086],
- [0.124395, 0.578002, 0.548287],
- [0.123463, 0.581687, 0.547445],
- [0.122606, 0.585371, 0.546557],
- [0.121831, 0.589055, 0.545623],
- [0.121148, 0.592739, 0.544641],
- [0.120565, 0.596422, 0.543611],
- [0.120092, 0.600104, 0.542530],
- [0.119738, 0.603785, 0.541400],
- [0.119512, 0.607464, 0.540218],
- [0.119423, 0.611141, 0.538982],
- [0.119483, 0.614817, 0.537692],
- [0.119699, 0.618490, 0.536347],
- [0.120081, 0.622161, 0.534946],
- [0.120638, 0.625828, 0.533488],
- [0.121380, 0.629492, 0.531973],
- [0.122312, 0.633153, 0.530398],
- [0.123444, 0.636809, 0.528763],
- [0.124780, 0.640461, 0.527068],
- [0.126326, 0.644107, 0.525311],
- [0.128087, 0.647749, 0.523491],
- [0.130067, 0.651384, 0.521608],
- [0.132268, 0.655014, 0.519661],
- [0.134692, 0.658636, 0.517649],
- [0.137339, 0.662252, 0.515571],
- [0.140210, 0.665859, 0.513427],
- [0.143303, 0.669459, 0.511215],
- [0.146616, 0.673050, 0.508936],
- [0.150148, 0.676631, 0.506589],
- [0.153894, 0.680203, 0.504172],
- [0.157851, 0.683765, 0.501686],
- [0.162016, 0.687316, 0.499129],
- [0.166383, 0.690856, 0.496502],
- [0.170948, 0.694384, 0.493803],
- [0.175707, 0.697900, 0.491033],
- [0.180653, 0.701402, 0.488189],
- [0.185783, 0.704891, 0.485273],
- [0.191090, 0.708366, 0.482284],
- [0.196571, 0.711827, 0.479221],
- [0.202219, 0.715272, 0.476084],
- [0.208030, 0.718701, 0.472873],
- [0.214000, 0.722114, 0.469588],
- [0.220124, 0.725509, 0.466226],
- [0.226397, 0.728888, 0.462789],
- [0.232815, 0.732247, 0.459277],
- [0.239374, 0.735588, 0.455688],
- [0.246070, 0.738910, 0.452024],
- [0.252899, 0.742211, 0.448284],
- [0.259857, 0.745492, 0.444467],
- [0.266941, 0.748751, 0.440573],
- [0.274149, 0.751988, 0.436601],
- [0.281477, 0.755203, 0.432552],
- [0.288921, 0.758394, 0.428426],
- [0.296479, 0.761561, 0.424223],
- [0.304148, 0.764704, 0.419943],
- [0.311925, 0.767822, 0.415586],
- [0.319809, 0.770914, 0.411152],
- [0.327796, 0.773980, 0.406640],
- [0.335885, 0.777018, 0.402049],
- [0.344074, 0.780029, 0.397381],
- [0.352360, 0.783011, 0.392636],
- [0.360741, 0.785964, 0.387814],
- [0.369214, 0.788888, 0.382914],
- [0.377779, 0.791781, 0.377939],
- [0.386433, 0.794644, 0.372886],
- [0.395174, 0.797475, 0.367757],
- [0.404001, 0.800275, 0.362552],
- [0.412913, 0.803041, 0.357269],
- [0.421908, 0.805774, 0.351910],
- [0.430983, 0.808473, 0.346476],
- [0.440137, 0.811138, 0.340967],
- [0.449368, 0.813768, 0.335384],
- [0.458674, 0.816363, 0.329727],
- [0.468053, 0.818921, 0.323998],
- [0.477504, 0.821444, 0.318195],
- [0.487026, 0.823929, 0.312321],
- [0.496615, 0.826376, 0.306377],
- [0.506271, 0.828786, 0.300362],
- [0.515992, 0.831158, 0.294279],
- [0.525776, 0.833491, 0.288127],
- [0.535621, 0.835785, 0.281908],
- [0.545524, 0.838039, 0.275626],
- [0.555484, 0.840254, 0.269281],
- [0.565498, 0.842430, 0.262877],
- [0.575563, 0.844566, 0.256415],
- [0.585678, 0.846661, 0.249897],
- [0.595839, 0.848717, 0.243329],
- [0.606045, 0.850733, 0.236712],
- [0.616293, 0.852709, 0.230052],
- [0.626579, 0.854645, 0.223353],
- [0.636902, 0.856542, 0.216620],
- [0.647257, 0.858400, 0.209861],
- [0.657642, 0.860219, 0.203082],
- [0.668054, 0.861999, 0.196293],
- [0.678489, 0.863742, 0.189503],
- [0.688944, 0.865448, 0.182725],
- [0.699415, 0.867117, 0.175971],
- [0.709898, 0.868751, 0.169257],
- [0.720391, 0.870350, 0.162603],
- [0.730889, 0.871916, 0.156029],
- [0.741388, 0.873449, 0.149561],
- [0.751884, 0.874951, 0.143228],
- [0.762373, 0.876424, 0.137064],
- [0.772852, 0.877868, 0.131109],
- [0.783315, 0.879285, 0.125405],
- [0.793760, 0.880678, 0.120005],
- [0.804182, 0.882046, 0.114965],
- [0.814576, 0.883393, 0.110347],
- [0.824940, 0.884720, 0.106217],
- [0.835270, 0.886029, 0.102646],
- [0.845561, 0.887322, 0.099702],
- [0.855810, 0.888601, 0.097452],
- [0.866013, 0.889868, 0.095953],
- [0.876168, 0.891125, 0.095250],
- [0.886271, 0.892374, 0.095374],
- [0.896320, 0.893616, 0.096335],
- [0.906311, 0.894855, 0.098125],
- [0.916242, 0.896091, 0.100717],
- [0.926106, 0.897330, 0.104071],
- [0.935904, 0.898570, 0.108131],
- [0.945636, 0.899815, 0.112838],
- [0.955300, 0.901065, 0.118128],
- [0.964894, 0.902323, 0.123941],
- [0.974417, 0.903590, 0.130215],
- [0.983868, 0.904867, 0.136897],
- [0.993248, 0.906157, 0.143936]]
-
-
-cmaps = {}
-for (name, data) in (('magma', _magma_data),
- ('inferno', _inferno_data),
- ('plasma', _plasma_data),
- ('viridis', _viridis_data)):
-
- cmaps[name] = ListedColormap(data, name=name)
-
-magma = cmaps['magma']
-inferno = cmaps['inferno']
-plasma = cmaps['plasma']
-viridis = cmaps['viridis']
diff --git a/silx/gui/plot/MaskToolsWidget.py b/silx/gui/plot/MaskToolsWidget.py
index 6407d44..09c5ca5 100644
--- a/silx/gui/plot/MaskToolsWidget.py
+++ b/silx/gui/plot/MaskToolsWidget.py
@@ -35,13 +35,14 @@ from __future__ import division
__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "20/04/2017"
+__date__ = "20/06/2017"
import os
import sys
import numpy
import logging
+import collections
from silx.image import shapes
@@ -211,17 +212,13 @@ class MaskToolsWidget(BaseMaskToolsWidget):
_maxLevelNumber = 255
def __init__(self, parent=None, plot=None):
+ super(MaskToolsWidget, self).__init__(parent, plot,
+ mask=ImageMask())
self._origin = (0., 0.) # Mask origin in plot
self._scale = (1., 1.) # Mask scale in plot
self._z = 1 # Mask layer in plot
self._data = numpy.zeros((0, 0), dtype=numpy.uint8) # Store image
- self._mask = ImageMask()
-
- super(MaskToolsWidget, self).__init__(parent, plot)
-
- self._initWidgets()
-
def setSelectionMask(self, mask, copy=True):
"""Set the mask to a new array.
@@ -239,6 +236,13 @@ class MaskToolsWidget(BaseMaskToolsWidget):
_logger.error('Not an image, shape: %d', len(mask.shape))
return None
+ # ensure all mask attributes are synchronized with the active image
+ # and connect listener
+ activeImage = self.plot.getActiveImage()
+ if activeImage is not None and activeImage.getLegend() != self._maskName:
+ self._activeImageChanged()
+ self.plot.sigActiveImageChanged.connect(self._activeImageChanged)
+
if self._data.shape[0:2] == (0, 0) or mask.shape == self._data.shape[0:2]:
self._mask.setMask(mask, copy=copy)
self._mask.commit()
@@ -262,12 +266,22 @@ class MaskToolsWidget(BaseMaskToolsWidget):
"""Update mask image in plot"""
mask = self.getSelectionMask(copy=False)
if len(mask):
- self.plot.addImage(mask, legend=self._maskName,
- colormap=self._colormap,
- origin=self._origin,
- scale=self._scale,
- z=self._z,
- replace=False, resetzoom=False)
+ # get the mask from the plot
+ maskItem = self.plot.getImage(self._maskName)
+ mustBeAdded = maskItem is None
+ if mustBeAdded:
+ maskItem = items.MaskImageData()
+ maskItem._setLegend(self._maskName)
+ # update the items
+ maskItem.setData(mask, copy=False)
+ maskItem.setColormap(self._colormap)
+ maskItem.setOrigin(self._origin)
+ maskItem.setScale(self._scale)
+ maskItem.setZValue(self._z)
+
+ if mustBeAdded:
+ self.plot._add(maskItem)
+
elif self.plot.getImage(self._maskName):
self.plot.remove(self._maskName, kind='image')
@@ -281,7 +295,11 @@ class MaskToolsWidget(BaseMaskToolsWidget):
self.plot.sigActiveImageChanged.connect(self._activeImageChanged)
def hideEvent(self, event):
- self.plot.sigActiveImageChanged.disconnect(self._activeImageChanged)
+ try:
+ self.plot.sigActiveImageChanged.disconnect(
+ self._activeImageChanged)
+ except (RuntimeError, TypeError):
+ pass
if not self.browseAction.isChecked():
self.browseAction.trigger() # Disable drawing tool
@@ -337,8 +355,9 @@ class MaskToolsWidget(BaseMaskToolsWidget):
def _activeImageChanged(self, *args):
"""Update widget and mask according to active image changes"""
activeImage = self.plot.getActiveImage()
- if activeImage is None or activeImage.getLegend() == self._maskName:
- # No active image or active image is the mask...
+ if (activeImage is None or activeImage.getLegend() == self._maskName or
+ activeImage.getData(copy=False).size == 0):
+ # No active image or active image is the mask or image has no data...
self.setEnabled(False)
self._data = numpy.zeros((0, 0), dtype=numpy.uint8)
@@ -390,6 +409,14 @@ class MaskToolsWidget(BaseMaskToolsWidget):
_logger.error("Can't load filename '%s'", filename)
_logger.debug("Backtrace", exc_info=True)
raise RuntimeError('File "%s" is not a numpy file.', filename)
+ elif extension in ["tif", "tiff"]:
+ try:
+ image = TiffIO(filename, mode="r")
+ mask = image.getImage(0)
+ except Exception as e:
+ _logger.error("Can't load filename %s", filename)
+ _logger.debug("Backtrace", exc_info=True)
+ raise e
elif extension == "edf":
try:
mask = EdfFile(filename, access='r').GetData(0)
@@ -423,14 +450,21 @@ class MaskToolsWidget(BaseMaskToolsWidget):
dialog = qt.QFileDialog(self)
dialog.setWindowTitle("Load Mask")
dialog.setModal(1)
- filters = [
- 'EDF (*.edf)',
- 'TIFF (*.tif)',
- 'NumPy binary file (*.npy)',
- # Fit2D mask is displayed anyway fabio is here or not
- # to show to the user that the option exists
- 'Fit2D mask (*.msk)',
- ]
+
+ extensions = collections.OrderedDict()
+ extensions["EDF files"] = "*.edf"
+ extensions["TIFF files"] = "*.tif *.tiff"
+ extensions["NumPy binary files"] = "*.npy"
+ # Fit2D mask is displayed anyway fabio is here or not
+ # to show to the user that the option exists
+ extensions["Fit2D mask files"] = "*.msk"
+
+ filters = []
+ filters.append("All supported files (%s)" % " ".join(extensions.values()))
+ for name, extension in extensions.items():
+ filters.append("%s (%s)" % (name, extension))
+ filters.append("All files (*)")
+
dialog.setNameFilters(filters)
dialog.setFileMode(qt.QFileDialog.ExistingFile)
dialog.setDirectory(self.maskFileDir)
@@ -610,6 +644,5 @@ class MaskToolsDockWidget(BaseMaskToolsDockWidget):
:paran str name: The title of this widget
"""
def __init__(self, parent=None, plot=None, name='Mask'):
- super(MaskToolsDockWidget, self).__init__(parent, name)
- self.setWidget(MaskToolsWidget(plot=plot))
- self.widget().sigMaskChanged.connect(self._emitSigMaskChanged)
+ widget = MaskToolsWidget(plot=plot)
+ super(MaskToolsDockWidget, self).__init__(parent, name, widget)
diff --git a/silx/gui/plot/Plot.py b/silx/gui/plot/Plot.py
deleted file mode 100644
index fe0a7b8..0000000
--- a/silx/gui/plot/Plot.py
+++ /dev/null
@@ -1,2925 +0,0 @@
-# coding: utf-8
-# /*##########################################################################
-#
-# Copyright (c) 2004-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.
-# ###########################################################################*/
-"""Plot API for 1D and 2D data.
-
-The :class:`Plot` implements the plot API initially provided in PyMca.
-
-
-Colormap
---------
-
-The :class:`Plot` uses a dictionary to describe a colormap.
-This dictionary has the following keys:
-
-- 'name': str, name of the colormap. Available colormap are returned by
- :meth:`Plot.getSupportedColormaps`.
- At least 'gray', 'reversed gray', 'temperature',
- 'red', 'green', 'blue' are supported.
-- 'normalization': Either 'linear' or 'log'
-- 'autoscale': bool, True to get bounds from the min and max of the
- data, False to use [vmin, vmax]
-- 'vmin': float, min value, ignored if autoscale is True
-- 'vmax': float, max value, ignored if autoscale is True
-- 'colors': optional, custom colormap.
- Nx3 or Nx4 numpy array of RGB(A) colors,
- either uint8 or float in [0, 1].
- If 'name' is None, then this array is used as the colormap.
-
-
-Plot Events
------------
-
-The Plot sends some event to the registered callback
-(See :meth:`Plot.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:`Plot.setInteractiveMode`).
-
-- 'event': 'drawingProgress' or 'drawingFinished'
-- 'parameters': dict of parameters used by the drawing mode.
- It has the following keys: 'shape', 'label', 'color'.
- See :meth:`Plot.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)
-- 'setXAxisAutoScale' event with a 'state' key (bool)
-- 'setXAxisLogarithmic' event with a 'state' key (bool)
-- 'setYAxisAutoScale' event with a 'state' key (bool)
-- 'setYAxisInverted' event with a 'state' key (bool)
-- 'setYAxisLogarithmic' 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.
-"""
-
-from __future__ import division
-
-
-__authors__ = ["V.A. Sole", "T. Vincent"]
-__license__ = "MIT"
-__date__ = "16/02/2017"
-
-
-from collections import OrderedDict, namedtuple
-import itertools
-import logging
-
-import numpy
-
-# Import matplotlib backend here to init matplotlib our way
-from .backends.BackendMatplotlib import BackendMatplotlibQt
-
-try:
- from matplotlib import cm as matplotlib_cm
-except ImportError:
- matplotlib_cm = None
-
-from . import Colors
-from . import PlotInteraction
-from . import PlotEvents
-from . import _utils
-
-from . import items
-
-
-_logger = logging.getLogger(__name__)
-
-
-_COLORDICT = Colors.COLORDICT
-_COLORLIST = [_COLORDICT['black'],
- _COLORDICT['blue'],
- _COLORDICT['red'],
- _COLORDICT['green'],
- _COLORDICT['pink'],
- _COLORDICT['yellow'],
- _COLORDICT['brown'],
- _COLORDICT['cyan'],
- _COLORDICT['magenta'],
- _COLORDICT['orange'],
- _COLORDICT['violet'],
- # _COLORDICT['bluegreen'],
- _COLORDICT['grey'],
- _COLORDICT['darkBlue'],
- _COLORDICT['darkRed'],
- _COLORDICT['darkGreen'],
- _COLORDICT['darkCyan'],
- _COLORDICT['darkMagenta'],
- _COLORDICT['darkYellow'],
- _COLORDICT['darkBrown']]
-
-
-"""
-Object returned when requesting the data range.
-"""
-_PlotDataRange = namedtuple('PlotDataRange',
- ['x', 'y', 'yright'])
-
-
-class Plot(object):
- """This class implements the plot API initially provided in PyMca.
-
- Supported backends:
-
- - 'matplotlib' and 'mpl': Matplotlib with Qt.
- - 'opengl' and 'gl': OpenGL backend (requires PyOpenGL and OpenGL >= 2.1)
- - 'none': No backend, to run headless for testing purpose.
-
- :param parent: The parent widget of the plot (Default: None)
- :param backend: The backend to use. A str in:
- 'matplotlib', 'mpl', 'opengl', 'gl', 'none'
- or a :class:`BackendBase.BackendBase` class
- """
-
- DEFAULT_BACKEND = 'matplotlib'
- """Class attribute setting the default backend for all instances."""
-
- colorList = _COLORLIST
- colorDict = _COLORDICT
-
- def __init__(self, parent=None, backend=None):
- self._autoreplot = False
- self._dirty = False
- self._cursorInPlot = False
-
- if backend is None:
- backend = self.DEFAULT_BACKEND
-
- if hasattr(backend, "__call__"):
- self._backend = backend(self, parent)
-
- elif hasattr(backend, "lower"):
- lowerCaseString = backend.lower()
- if lowerCaseString in ("matplotlib", "mpl"):
- backendClass = BackendMatplotlibQt
- elif lowerCaseString in ('gl', 'opengl'):
- from .backends.BackendOpenGL import BackendOpenGL
- backendClass = BackendOpenGL
- elif lowerCaseString == 'none':
- from .backends.BackendBase import BackendBase as backendClass
- else:
- raise ValueError("Backend not supported %s" % backend)
- self._backend = backendClass(self, parent)
-
- else:
- raise ValueError("Backend not supported %s" % str(backend))
-
- super(Plot, self).__init__()
-
- self.setCallback() # set _callback
-
- # Items handling
- self._content = OrderedDict()
- self._contentToUpdate = set()
-
- self._dataRange = None
-
- # line types
- self._styleList = ['-', '--', '-.', ':']
- self._colorIndex = 0
- self._styleIndex = 0
-
- self._activeCurveHandling = True
- self._activeCurveColor = "#000000"
- self._activeLegend = {'curve': None, 'image': None,
- 'scatter': None}
-
- # default properties
- self._cursorConfiguration = None
-
- self._logY = False
- self._logX = False
- self._xAutoScale = True
- self._yAutoScale = True
- self._grid = None
-
- # Store default labels provided to setGraph[X|Y]Label
- self._defaultLabels = {'x': '', 'y': '', 'yright': ''}
- # Store currently displayed labels
- # Current label can differ from input one with active curve handling
- self._currentLabels = {'x': '', 'y': '', 'yright': ''}
-
- self._graphTitle = ''
-
- self.setGraphTitle()
- self.setGraphXLabel()
- self.setGraphYLabel()
- self.setGraphYLabel('', axis='right')
-
- self.setDefaultColormap() # Init default colormap
-
- self.setDefaultPlotPoints(False)
- self.setDefaultPlotLines(True)
-
- self._eventHandler = PlotInteraction.PlotInteraction(self)
- self._eventHandler.setInteractiveMode('zoom', color=(0., 0., 0., 1.))
-
- self._pressedButtons = [] # Currently pressed mouse buttons
-
- self._defaultDataMargins = (0., 0., 0., 0.)
-
- # Only activate autoreplot at the end
- # This avoids errors when loaded in Qt designer
- self._dirty = False
- self._autoreplot = True
-
- def _getDirtyPlot(self):
- """Return the plot dirty flag.
-
- If False, the plot has not changed since last replot.
- If True, the full plot need to be redrawn.
- If 'overlay', only the overlay has changed since last replot.
-
- It can be accessed by backend to check the dirty state.
-
- :return: False, True, 'overlay'
- """
- return self._dirty
-
- def _setDirtyPlot(self, overlayOnly=False):
- """Mark the plot as needing redraw
-
- :param bool overlayOnly: True to redraw only the overlay,
- False to redraw everything
- """
- wasDirty = self._dirty
-
- if not self._dirty and overlayOnly:
- self._dirty = 'overlay'
- else:
- self._dirty = True
-
- if self._autoreplot and not wasDirty:
- self._backend.postRedisplay()
-
- def _invalidateDataRange(self):
- """
- Notifies this Plot instance that the range has changed and will have
- to be recomputed.
- """
- self._dataRange = None
-
- def _updateDataRange(self):
- """
- Recomputes the range of the data displayed on this Plot.
- """
- xMin = yMinLeft = yMinRight = float('nan')
- xMax = yMaxLeft = yMaxRight = float('nan')
-
- for item in self._content.values():
- if item.isVisible():
- bounds = item.getBounds()
- if bounds is not None:
- xMin = numpy.nanmin([xMin, bounds[0]])
- xMax = numpy.nanmax([xMax, bounds[1]])
- # Take care of right axis
- if (isinstance(item, items.YAxisMixIn) and
- item.getYAxis() == 'right'):
- yMinRight = numpy.nanmin([yMinRight, bounds[2]])
- yMaxRight = numpy.nanmax([yMaxRight, bounds[3]])
- else:
- yMinLeft = numpy.nanmin([yMinLeft, bounds[2]])
- yMaxLeft = numpy.nanmax([yMaxLeft, bounds[3]])
-
- def lGetRange(x, y):
- return None if numpy.isnan(x) and numpy.isnan(y) else (x, y)
- xRange = lGetRange(xMin, xMax)
- yLeftRange = lGetRange(yMinLeft, yMaxLeft)
- yRightRange = lGetRange(yMinRight, yMaxRight)
-
- self._dataRange = _PlotDataRange(x=xRange,
- y=yLeftRange,
- yright=yRightRange)
-
- def getDataRange(self):
- """
- Returns this Plot's data range.
-
- :return: a namedtuple with the following members :
- x, y (left y axis), yright. Each member is a tuple (min, max)
- or None if no data is associated with the axis.
- :rtype: namedtuple
- """
- if self._dataRange is None:
- self._updateDataRange()
- return self._dataRange
-
- # Content management
-
- @staticmethod
- def _itemKey(item):
- """Build the key of given :class:`Item` in the plot
-
- :param Item item: The item to make the key from
- :return: (legend, kind)
- :rtype: (str, str)
- """
- if isinstance(item, items.Curve):
- kind = 'curve'
- elif isinstance(item, items.ImageBase):
- kind = 'image'
- elif isinstance(item, items.Scatter):
- kind = 'scatter'
- elif isinstance(item, (items.Marker,
- items.XMarker, items.YMarker)):
- kind = 'marker'
- elif isinstance(item, items.Shape):
- kind = 'item'
- elif isinstance(item, items.Histogram):
- kind = 'histogram'
- else:
- raise ValueError('Unsupported item type %s' % type(item))
-
- return item.getLegend(), kind
-
- def _add(self, item):
- """Add the given :class:`Item` to the plot.
-
- :param Item item: The item to append to the plot content
- """
- key = self._itemKey(item)
- if key in self._content:
- raise RuntimeError('Item already in the plot')
-
- # Add item to plot
- self._content[key] = item
- item._setPlot(self)
- if item.isVisible():
- self._itemRequiresUpdate(item)
- if isinstance(item, (items.Curve, items.ImageBase)):
- self._invalidateDataRange() # TODO handle this automatically
-
- def _remove(self, item):
- """Remove the given :class:`Item` from the plot.
-
- :param Item item: The item to remove from the plot content
- """
- key = self._itemKey(item)
- if key not in self._content:
- raise RuntimeError('Item not in the plot')
-
- # Remove item from plot
- self._content.pop(key)
- self._contentToUpdate.discard(item)
- if item.isVisible():
- self._setDirtyPlot(overlayOnly=item.isOverlay())
- if item.getBounds() is not None:
- self._invalidateDataRange()
- item._removeBackendRenderer(self._backend)
- item._setPlot(None)
-
- def _itemRequiresUpdate(self, item):
- """Called by items in the plot for asynchronous update
-
- :param Item item: The item that required update
- """
- assert item.getPlot() == self
- self._contentToUpdate.add(item)
- self._setDirtyPlot(overlayOnly=item.isOverlay())
-
- # Add
-
- # add * input arguments management:
- # If an arg is set, then use it.
- # Else:
- # If a curve with the same legend exists, then use its arg value
- # Else, use a default value.
- # Store used value.
- # This value is used when curve is updated either internally or by user.
-
- def addCurve(self, x, y, legend=None, info=None,
- replace=False, replot=None,
- color=None, symbol=None,
- linewidth=None, linestyle=None,
- xlabel=None, ylabel=None, yaxis=None,
- xerror=None, yerror=None, z=None, selectable=None,
- fill=None, resetzoom=True,
- histogram=None, copy=True, **kw):
- """Add a 1D curve given by x an y to the graph.
-
- Curves are uniquely identified by their legend.
- To add multiple curves, call :meth:`addCurve` multiple times with
- different legend argument.
- To replace an existing curve, call :meth:`addCurve` with the
- existing curve legend.
- If you want to display the curve values as an histogram see the
- histogram parameter or :meth:`addHistogram`.
-
- When curve parameters are not provided, if a curve with the
- same legend is displayed in the plot, its parameters are used.
-
- :param numpy.ndarray x: The data corresponding to the x coordinates.
- If you attempt to plot an histogram you can set edges values in x.
- In this case len(x) = len(y) + 1
- :param numpy.ndarray y: The data corresponding to the y coordinates
- :param str legend: The legend to be associated to the curve (or None)
- :param info: User-defined information associated to the curve
- :param bool replace: True (the default) to delete already existing
- curves
- :param color: color(s) to be used
- :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or
- one of the predefined color names defined in Colors.py
- :param str symbol: Symbol to be drawn at each (x, y) position::
-
- - 'o' circle
- - '.' point
- - ',' pixel
- - '+' cross
- - 'x' x-cross
- - 'd' diamond
- - 's' square
- - None (the default) to use default symbol
-
- :param float linewidth: The width of the curve in pixels (Default: 1).
- :param str linestyle: Type of line::
-
- - ' ' no line
- - '-' solid line
- - '--' dashed line
- - '-.' dash-dot line
- - ':' dotted line
- - None (the default) to use default line style
-
- :param str xlabel: Label to show on the X axis when the curve is active
- or None to keep default axis label.
- :param str ylabel: Label to show on the Y axis when the curve is active
- or None to keep default axis label.
- :param str yaxis: The Y axis this curve is attached to.
- Either 'left' (the default) or 'right'
- :param xerror: Values with the uncertainties on the x values
- :type xerror: A float, or a numpy.ndarray of float32.
- If it is an array, it can either be a 1D array of
- same length as the data or a 2D array with 2 rows
- of same length as the data: row 0 for positive errors,
- row 1 for negative errors.
- :param yerror: Values with the uncertainties on the y values
- :type yerror: A float, or a numpy.ndarray of float32. See xerror.
- :param int z: Layer on which to draw the curve (default: 1)
- This allows to control the overlay.
- :param bool selectable: Indicate if the curve can be selected.
- (Default: True)
- :param bool fill: True to fill the curve, False otherwise (default).
- :param bool resetzoom: True (the default) to reset the zoom.
- :param str histogram: if not None then the curve will be draw as an
- histogram. The step for each values of the curve can be set to the
- left, center or right of the original x curve values.
- If histogram is not None and len(x) == len(y)+1 then x is directly
- take as edges of the histogram.
- Type of histogram::
-
- - None (default)
- - 'left'
- - 'right'
- - 'center'
- :param bool copy: True make a copy of the data (default),
- False to use provided arrays.
- :returns: The key string identify this curve
- """
- # Deprecation warnings
- if replot is not None:
- _logger.warning(
- 'addCurve deprecated replot argument, use resetzoom instead')
- resetzoom = replot and resetzoom
-
- if kw:
- _logger.warning('addCurve: deprecated extra arguments')
-
- # This is an histogram, use addHistogram
- if histogram is not None:
- histoLegend = self.addHistogram(histogram=y,
- edges=x,
- legend=legend,
- color=color,
- fill=fill,
- align=histogram,
- copy=copy)
- histo = self.getHistogram(histoLegend)
-
- histo.setInfo(info)
- if linewidth is not None:
- histo.setLineWidth(linewidth)
- if linestyle is not None:
- histo.setLineStyle(linestyle)
- if xlabel is not None:
- _logger.warning(
- 'addCurve: Histogram does not support xlabel argument')
- if ylabel is not None:
- _logger.warning(
- 'addCurve: Histogram does not support ylabel argument')
- if yaxis is not None:
- histo.setYAxis(yaxis)
- if z is not None:
- histo.setZValue(z)
- if selectable is not None:
- _logger.warning(
- 'addCurve: Histogram does not support selectable argument')
-
- return
-
- legend = 'Unnamed curve 1.1' if legend is None else str(legend)
-
- # Check if curve was previously active
- wasActive = self.getActiveCurve(just_legend=True) == legend
-
- # Create/Update curve object
- curve = self.getCurve(legend)
- if curve is None:
- # No previous curve, create a default one and add it to the plot
- curve = items.Curve() if histogram is None else items.Histogram()
- curve._setLegend(legend)
- # Set default color, linestyle and symbol
- default_color, default_linestyle = self._getColorAndStyle()
- curve.setColor(default_color)
- curve.setLineStyle(default_linestyle)
- curve.setSymbol(self._defaultPlotPoints)
- self._add(curve)
-
- # Override previous/default values with provided ones
- curve.setInfo(info)
- if color is not None:
- curve.setColor(color)
- if symbol is not None:
- curve.setSymbol(symbol)
- if linewidth is not None:
- curve.setLineWidth(linewidth)
- if linestyle is not None:
- curve.setLineStyle(linestyle)
- if xlabel is not None:
- curve._setXLabel(xlabel)
- if ylabel is not None:
- curve._setYLabel(ylabel)
- if yaxis is not None:
- curve.setYAxis(yaxis)
- if z is not None:
- curve.setZValue(z)
- if selectable is not None:
- curve._setSelectable(selectable)
- if fill is not None:
- curve.setFill(fill)
-
- # Set curve data
- # If errors not provided, reuse previous ones
- # TODO: Issue if size of data change but not that of errors
- if xerror is None:
- xerror = curve.getXErrorData(copy=False)
- if yerror is None:
- yerror = curve.getYErrorData(copy=False)
-
- curve.setData(x, y, xerror, yerror, copy=copy)
-
- if replace: # Then remove all other curves
- for c in self.getAllCurves(withhidden=True):
- if c is not curve:
- self._remove(c)
-
- self.notify(
- 'contentChanged', action='add', kind='curve', legend=legend)
-
- if wasActive:
- self.setActiveCurve(curve.getLegend())
-
- if resetzoom:
- # We ask for a zoom reset in order to handle the plot scaling
- # if the user does not want that, autoscale of the different
- # axes has to be set to off.
- self.resetZoom()
-
- return legend
-
- def addHistogram(self,
- histogram,
- edges,
- legend=None,
- color=None,
- fill=None,
- align='center',
- resetzoom=True,
- copy=True):
- """Add an histogram to the graph.
-
- This is NOT computing the histogram, this method takes as parameter
- already computed histogram values.
-
- Histogram are uniquely identified by their legend.
- To add multiple histograms, call :meth:`addHistogram` multiple times
- with different legend argument.
-
- When histogram parameters are not provided, if an histogram with the
- same legend is displayed in the plot, its parameters are used.
-
- :param numpy.ndarray histogram: The values of the histogram.
- :param numpy.ndarray edges:
- The bin edges of the histogram.
- If histogram and edges have the same length, the bin edges
- are computed according to the align parameter.
- :param str legend:
- The legend to be associated to the histogram (or None)
- :param color: color to be used
- :type color: str ("#RRGGBB") or RGB unsigned byte array or
- one of the predefined color names defined in Colors.py
- :param bool fill: True to fill the curve, False otherwise (default).
- :param str align:
- In case histogram values and edges have the same length N,
- the N+1 bin edges are computed according to the alignment in:
- 'center' (default), 'left', 'right'.
- :param bool resetzoom: True (the default) to reset the zoom.
- :param bool copy: True make a copy of the data (default),
- False to use provided arrays.
- :returns: The key string identify this histogram
- """
- legend = 'Unnamed histogram' if legend is None else str(legend)
-
- # Create/Update histogram object
- histo = self.getHistogram(legend)
- if histo is None:
- # No previous histogram, create a default one and
- # add it to the plot
- histo = items.Histogram()
- histo._setLegend(legend)
- histo.setColor(self._getColorAndStyle()[0])
- self._add(histo)
-
- # Override previous/default values with provided ones
- if color is not None:
- histo.setColor(color)
- if fill is not None:
- histo.setFill(fill)
-
- # Set histogram data
- histo.setData(histogram, edges, align=align, copy=copy)
-
- self.notify(
- 'contentChanged', action='add', kind='histogram', legend=legend)
-
- if resetzoom:
- # We ask for a zoom reset in order to handle the plot scaling
- # if the user does not want that, autoscale of the different
- # axes has to be set to off.
- self.resetZoom()
-
- return legend
-
- def addImage(self, data, legend=None, info=None,
- replace=True, replot=None,
- xScale=None, yScale=None, z=None,
- selectable=None, draggable=None,
- colormap=None, pixmap=None,
- xlabel=None, ylabel=None,
- origin=None, scale=None,
- resetzoom=True, copy=True, **kw):
- """Add a 2D dataset or an image to the plot.
-
- It displays either an array of data using a colormap or a RGB(A) image.
-
- Images are uniquely identified by their legend.
- To add multiple images, call :meth:`addImage` multiple times with
- different legend argument.
- To replace/update an existing image, call :meth:`addImage` with the
- existing image legend.
-
- When image parameters are not provided, if an image with the
- same legend is displayed in the plot, its parameters are used.
-
- :param numpy.ndarray data: (nrows, ncolumns) data or
- (nrows, ncolumns, RGBA) ubyte array
- :param str legend: The legend to be associated to the image (or None)
- :param info: User-defined information associated to the image
- :param bool replace: True (default) to delete already existing images
- :param int z: Layer on which to draw the image (default: 0)
- This allows to control the overlay.
- :param bool selectable: Indicate if the image can be selected.
- (default: False)
- :param bool draggable: Indicate if the image can be moved.
- (default: False)
- :param dict colormap: Description of the colormap to use (or None)
- This is ignored if data is a RGB(A) image.
- See :mod:`Plot` for the documentation
- of the 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,
- or None to keep default axis label.
- :param str ylabel: Y axis label to show when this curve is active,
- or None to keep default axis label.
- :param origin: (origin X, origin Y) of the data.
- It is possible to pass a single float if both
- coordinates are equal.
- Default: (0., 0.)
- :type origin: float or 2-tuple of float
- :param scale: (scale X, scale Y) of the data.
- It is possible to pass a single float if both
- coordinates are equal.
- Default: (1., 1.)
- :type scale: float or 2-tuple of float
- :param bool resetzoom: True (the default) to reset the zoom.
- :param bool copy: True make a copy of the data (default),
- False to use provided arrays.
- :returns: The key string identify this image
- """
- # Deprecation warnings
- if xScale is not None or yScale is not None:
- _logger.warning(
- 'addImage deprecated xScale and yScale arguments,'
- 'use origin, scale arguments instead.')
- if origin is None and scale is None:
- origin = xScale[0], yScale[0]
- scale = xScale[1], yScale[1]
- else:
- _logger.warning(
- 'addCurve: xScale, yScale and origin, scale arguments'
- ' are conflicting. xScale and yScale are ignored.'
- ' Use only origin, scale arguments.')
-
- if replot is not None:
- _logger.warning(
- 'addImage deprecated replot argument, use resetzoom instead')
- resetzoom = replot and resetzoom
-
- if kw:
- _logger.warning('addImage: deprecated extra arguments')
-
- legend = "Unnamed Image 1.1" if legend is None else str(legend)
-
- # Check if image was previously active
- wasActive = self.getActiveImage(just_legend=True) == legend
-
- data = numpy.array(data, copy=False)
- assert data.ndim in (2, 3)
-
- image = self.getImage(legend)
- if image is not None and image.getData(copy=False).ndim != data.ndim:
- # Update a data image with RGBA image or the other way around:
- # Remove previous image
- # In this case, we don't retrieve defaults from the previous image
- self._remove(image)
- image = None
-
- if image is None:
- # No previous image, create a default one and add it to the plot
- if data.ndim == 2:
- image = items.ImageData()
- image.setColormap(self.getDefaultColormap())
- else:
- image = items.ImageRgba()
- image._setLegend(legend)
- self._add(image)
-
- # Override previous/default values with provided ones
- image.setInfo(info)
- if origin is not None:
- image.setOrigin(origin)
- if scale is not None:
- image.setScale(scale)
- if z is not None:
- image.setZValue(z)
- if selectable is not None:
- image._setSelectable(selectable)
- if draggable is not None:
- image._setDraggable(draggable)
- if colormap is not None and isinstance(image, items.ColormapMixIn):
- image.setColormap(colormap)
- if xlabel is not None:
- image._setXLabel(xlabel)
- if ylabel is not None:
- image._setYLabel(ylabel)
-
- if data.ndim == 2:
- image.setData(data, alternative=pixmap, copy=copy)
- else: # RGB(A) image
- if pixmap is not None:
- _logger.warning(
- 'addImage: pixmap argument ignored when data is RGB(A)')
- image.setData(data, copy=copy)
-
- if replace:
- for img in self.getAllImages():
- if img is not image:
- self._remove(img)
-
- if len(self.getAllImages()) == 1 or wasActive:
- self.setActiveImage(legend)
-
- self.notify(
- 'contentChanged', action='add', kind='image', legend=legend)
-
- if resetzoom:
- # We ask for a zoom reset in order to handle the plot scaling
- # if the user does not want that, autoscale of the different
- # axes has to be set to off.
- self.resetZoom()
-
- return legend
-
- def addScatter(self, x, y, value, legend=None, colormap=None,
- info=None, symbol=None, xerror=None, yerror=None,
- z=None, copy=True):
- """Add a (x, y, value) scatter to the graph.
-
- Scatters are uniquely identified by their legend.
- To add multiple scatters, call :meth:`addScatter` multiple times with
- different legend argument.
- To replace/update an existing scatter, call :meth:`addScatter` with the
- existing scatter legend.
-
- When scatter parameters are not provided, if a scatter with the
- same legend is displayed in the plot, its parameters are used.
-
- :param numpy.ndarray x: The data corresponding to the x coordinates.
- :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 dict colormap: The colormap to be used for the scatter (or None)
- See :mod:`Plot` for the documentation
- of the colormap dict.
- :param info: User-defined information associated to the curve
- :param str symbol: Symbol to be drawn at each (x, y) position::
-
- - 'o' circle
- - '.' point
- - ',' pixel
- - '+' cross
- - 'x' x-cross
- - 'd' diamond
- - 's' square
- - None (the default) to use default symbol
-
- :param xerror: Values with the uncertainties on the x values
- :type xerror: A float, or a numpy.ndarray of float32.
- If it is an array, it can either be a 1D array of
- same length as the data or a 2D array with 2 rows
- of same length as the data: row 0 for positive errors,
- row 1 for negative errors.
- :param yerror: Values with the uncertainties on the y values
- :type yerror: A float, or a numpy.ndarray of float32. See xerror.
- :param int z: Layer on which to draw the scatter (default: 1)
- This allows to control the overlay.
-
- :param bool copy: True make a copy of the data (default),
- False to use provided arrays.
- :returns: The key string identify this scatter
- """
- legend = 'Unnamed scatter 1.1' if legend is None else str(legend)
-
- # Check if scatter was previously active
- wasActive = self._getActiveItem(kind='scatter',
- just_legend=True) == legend
-
- # Create/Update curve object
- scatter = self._getItem(kind='scatter', legend=legend)
- if scatter is None:
- # No previous scatter, create a default one and add it to the plot
- scatter = items.Scatter()
- scatter._setLegend(legend)
- scatter.setColormap(self.getDefaultColormap())
- self._add(scatter)
-
- # Override previous/default values with provided ones
- scatter.setInfo(info)
- if symbol is not None:
- scatter.setSymbol(symbol)
- if z is not None:
- scatter.setZValue(z)
- if colormap is not None:
- scatter.setColormap(colormap)
-
- # Set scatter data
- # If errors not provided, reuse previous ones
- if xerror is None:
- xerror = scatter.getXErrorData(copy=False)
- if xerror is not None and len(xerror) != len(x):
- xerror = None
- if yerror is None:
- yerror = scatter.getYErrorData(copy=False)
- if yerror is not None and len(yerror) != len(y):
- yerror = None
-
- scatter.setData(x, y, value, xerror, yerror, copy=copy)
-
- self.notify(
- 'contentChanged', action='add', kind='scatter', legend=legend)
-
- if len(self._getItems(kind="scatter")) == 1 or wasActive:
- self._setActiveItem('scatter', scatter.getLegend())
-
- return legend
-
- def addItem(self, xdata, ydata, legend=None, info=None,
- replace=False,
- shape="polygon", color='black', fill=True,
- overlay=False, z=None, **kw):
- """Add an item (i.e. a shape) to the plot.
-
- Items are uniquely identified by their legend.
- To add multiple items, call :meth:`addItem` multiple times with
- different legend argument.
- To replace/update an existing item, call :meth:`addItem` with the
- existing item legend.
-
- :param numpy.ndarray xdata: The X coords of the points of the shape
- :param numpy.ndarray ydata: The Y coords of the points of the shape
- :param str legend: The legend to be associated to the item
- :param info: User-defined information associated to the item
- :param bool replace: True (default) to delete already existing images
- :param str shape: Type of item to be drawn in
- hline, polygon (the default), rectangle, vline,
- polylines
- :param str color: Color of the item, e.g., 'blue', 'b', '#FF0000'
- (Default: 'black')
- :param bool fill: True (the default) to fill the shape
- :param bool overlay: True if item is an overlay (Default: False).
- This allows for rendering optimization if this
- item is changed often.
- :param int z: Layer on which to draw the item (default: 2)
- :returns: The key string identify this item
- """
- # expected to receive the same parameters as the signal
-
- if kw:
- _logger.warning('addItem deprecated parameters: %s', str(kw))
-
- legend = "Unnamed Item 1.1" if legend is None else str(legend)
-
- z = int(z) if z is not None else 2
-
- if replace:
- self.remove(kind='item')
- else:
- self.remove(legend, kind='item')
-
- item = items.Shape(shape)
- item._setLegend(legend)
- item.setInfo(info)
- item.setColor(color)
- item.setFill(fill)
- item.setOverlay(overlay)
- item.setZValue(z)
- item.setPoints(numpy.array((xdata, ydata)).T)
-
- self._add(item)
-
- self.notify('contentChanged', action='add', kind='item', legend=legend)
-
- return legend
-
- def addXMarker(self, x, legend=None,
- text=None,
- color=None,
- selectable=False,
- draggable=False,
- constraint=None,
- **kw):
- """Add a vertical line marker to the plot.
-
- Markers are uniquely identified by their legend.
- As opposed to curves, images and items, two calls to
- :meth:`addXMarker` without legend argument adds two markers with
- different identifying legends.
-
- :param float x: Position of the marker on the X axis in data
- coordinates
- :param str legend: Legend associated to the marker to identify it
- :param str text: Text to display on the marker.
- :param str color: Color of the marker, e.g., 'blue', 'b', '#FF0000'
- (Default: 'black')
- :param bool selectable: Indicate if the marker can be selected.
- (default: False)
- :param bool draggable: Indicate if the marker can be moved.
- (default: False)
- :param constraint: A function filtering marker displacement by
- dragging operations or None for no filter.
- This function is called each time a marker is
- moved.
- This parameter is only used if draggable is True.
- :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.
- :return: The key string identify this marker
- """
- if kw:
- _logger.warning(
- 'addXMarker deprecated extra parameters: %s', str(kw))
-
- return self._addMarker(x=x, y=None, legend=legend,
- text=text, color=color,
- selectable=selectable, draggable=draggable,
- symbol=None, constraint=constraint)
-
- def addYMarker(self, y,
- legend=None,
- text=None,
- color=None,
- selectable=False,
- draggable=False,
- constraint=None,
- **kw):
- """Add a horizontal line marker to the plot.
-
- Markers are uniquely identified by their legend.
- As opposed to curves, images and items, two calls to
- :meth:`addYMarker` without legend argument adds two markers with
- different identifying legends.
-
- :param float y: Position of the marker on the Y axis in data
- coordinates
- :param str legend: Legend associated to the marker to identify it
- :param str text: Text to display next to the marker.
- :param str color: Color of the marker, e.g., 'blue', 'b', '#FF0000'
- (Default: 'black')
- :param bool selectable: Indicate if the marker can be selected.
- (default: False)
- :param bool draggable: Indicate if the marker can be moved.
- (default: False)
- :param constraint: A function filtering marker displacement by
- dragging operations or None for no filter.
- This function is called each time a marker is
- moved.
- This parameter is only used if draggable is True.
- :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.
- :return: The key string identify this marker
- """
- if kw:
- _logger.warning(
- 'addYMarker deprecated extra parameters: %s', str(kw))
-
- return self._addMarker(x=None, y=y, legend=legend,
- text=text, color=color,
- selectable=selectable, draggable=draggable,
- symbol=None, constraint=constraint)
-
- def addMarker(self, x, y, legend=None,
- text=None,
- color=None,
- selectable=False,
- draggable=False,
- symbol='+',
- constraint=None,
- **kw):
- """Add a point marker to the plot.
-
- Markers are uniquely identified by their legend.
- As opposed to curves, images and items, two calls to
- :meth:`addMarker` without legend argument adds two markers with
- different identifying legends.
-
- :param float x: Position of the marker on the X axis in data
- coordinates
- :param float y: Position of the marker on the Y axis in data
- coordinates
- :param str legend: Legend associated to the marker to identify it
- :param str text: Text to display next to the marker
- :param str color: Color of the marker, e.g., 'blue', 'b', '#FF0000'
- (Default: 'black')
- :param bool selectable: Indicate if the marker can be selected.
- (default: False)
- :param bool draggable: Indicate if the marker can be moved.
- (default: False)
- :param str symbol: Symbol representing the marker in::
-
- - 'o' circle
- - '.' point
- - ',' pixel
- - '+' cross (the default)
- - 'x' x-cross
- - 'd' diamond
- - 's' square
-
- :param constraint: A function filtering marker displacement by
- dragging operations or None for no filter.
- This function is called each time a marker is
- moved.
- This parameter is only used if draggable is True.
- :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.
- :return: The key string identify this marker
- """
- if kw:
- _logger.warning(
- 'addMarker deprecated extra parameters: %s', str(kw))
-
- if x is None:
- xmin, xmax = self.getGraphXLimits()
- x = 0.5 * (xmax + xmin)
-
- if y is None:
- ymin, ymax = self.getGraphYLimits()
- y = 0.5 * (ymax + ymin)
-
- return self._addMarker(x=x, y=y, legend=legend,
- text=text, color=color,
- selectable=selectable, draggable=draggable,
- symbol=symbol, constraint=constraint)
-
- def _addMarker(self, x, y, legend,
- text, color,
- selectable, draggable,
- symbol, constraint):
- """Common method for adding point, vline and hline marker.
-
- See :meth:`addMarker` for argument documentation.
- """
- assert (x, y) != (None, None)
-
- if legend is None: # Find an unused legend
- markerLegends = self._getAllMarkers(just_legend=True)
- for index in itertools.count():
- legend = "Unnamed Marker %d" % index
- if legend not in markerLegends:
- break # Keep this legend
- legend = str(legend)
-
- if x is None:
- markerClass = items.YMarker
- elif y is None:
- markerClass = items.XMarker
- else:
- markerClass = items.Marker
-
- # Create/Update marker object
- marker = self._getMarker(legend)
- if marker is not None and not isinstance(marker, markerClass):
- _logger.warning('Adding marker with same legend'
- ' but different type replaces it')
- self._remove(marker)
- marker = None
-
- if marker is None:
- # No previous marker, create one
- marker = markerClass()
- marker._setLegend(legend)
- self._add(marker)
-
- if text is not None:
- marker.setText(text)
- if color is not None:
- marker.setColor(color)
- if selectable is not None:
- marker._setSelectable(selectable)
- if draggable is not None:
- marker._setDraggable(draggable)
- if symbol is not None:
- marker.setSymbol(symbol)
-
- # TODO to improve, but this ensure constraint is applied
- marker.setPosition(x, y)
- if constraint is not None:
- marker._setConstraint(constraint)
- marker.setPosition(x, y)
-
- self.notify(
- 'contentChanged', action='add', kind='marker', legend=legend)
-
- return legend
-
- # Hide
-
- def isCurveHidden(self, legend):
- """Returns True if the curve associated to legend is hidden, else False
-
- :param str legend: The legend key identifying the curve
- :return: True if the associated curve is hidden, False otherwise
- """
- curve = self._getItem('curve', legend)
- return curve is not None and not curve.isVisible()
-
- def hideCurve(self, legend, flag=True, replot=None):
- """Show/Hide the curve associated to legend.
-
- Even when hidden, the curve is kept in the list of curves.
-
- :param str legend: The legend associated to the curve to be hidden
- :param bool flag: True (default) to hide the curve, False to show it
- """
- if replot is not None:
- _logger.warning('hideCurve deprecated replot parameter')
-
- curve = self._getItem('curve', legend)
- if curve is None:
- _logger.warning('Curve not in plot: %s', legend)
- return
-
- isVisible = not flag
- if isVisible != curve.isVisible():
- curve.setVisible(isVisible)
-
- # Remove
-
- ITEM_KINDS = 'curve', 'image', 'scatter', 'item', 'marker', 'histogram'
-
- def remove(self, legend=None, kind=ITEM_KINDS):
- """Remove one or all element(s) of the given legend and kind.
-
- Examples:
-
- - ``remove()`` clears the plot
- - ``remove(kind='curve')`` removes all curves from the plot
- - ``remove('myCurve', kind='curve')`` removes the curve with
- legend 'myCurve' from the plot.
- - ``remove('myImage, kind='image')`` removes the image with
- legend 'myImage' from the plot.
- - ``remove('myImage')`` removes elements (for instance curve, image,
- item and marker) with legend 'myImage'.
-
- :param str legend: The legend associated to the element to remove,
- or None to remove
- :param kind: The kind of elements to remove from the plot.
- In: 'all', 'curve', 'image', 'item', 'marker'.
- By default, it removes all kind of elements.
- :type kind: str or tuple of str to specify multiple kinds.
- """
- if kind is 'all': # Replace all by tuple of all kinds
- kind = self.ITEM_KINDS
-
- if kind in self.ITEM_KINDS: # Kind is a str, make it a tuple
- kind = (kind,)
-
- for aKind in kind:
- assert aKind in self.ITEM_KINDS
-
- if legend is None: # This is a clear
- # Clear each given kind
- for aKind in kind:
- for legend in self._getItems(
- kind=aKind, just_legend=True, withhidden=True):
- self.remove(legend=legend, kind=aKind)
-
- else: # This is removing a single element
- # Remove each given kind
- for aKind in kind:
- item = self._getItem(aKind, legend)
- if item is not None:
- if aKind in ('curve', 'image'):
- if self._getActiveItem(aKind) == item:
- # Reset active item
- self._setActiveItem(aKind, None)
-
- self._remove(item)
-
- if (aKind == 'curve' and
- not self.getAllCurves(just_legend=True,
- withhidden=True)):
- self._colorIndex = 0
- self._styleIndex = 0
-
- self.notify('contentChanged', action='remove',
- kind=aKind, legend=legend)
-
- def removeCurve(self, legend):
- """Remove the curve associated to legend from the graph.
-
- :param str legend: The legend associated to the curve to be deleted
- """
- if legend is None:
- return
- self.remove(legend, kind='curve')
-
- def removeImage(self, legend):
- """Remove the image associated to legend from the graph.
-
- :param str legend: The legend associated to the image to be deleted
- """
- if legend is None:
- return
- self.remove(legend, kind='image')
-
- def removeItem(self, legend):
- """Remove the item associated to legend from the graph.
-
- :param str legend: The legend associated to the item to be deleted
- """
- if legend is None:
- return
- self.remove(legend, kind='item')
-
- def removeMarker(self, legend):
- """Remove the marker associated to legend from the graph.
-
- :param str legend: The legend associated to the marker to be deleted
- """
- if legend is None:
- return
- self.remove(legend, kind='marker')
-
- # Clear
-
- def clear(self):
- """Remove everything from the plot."""
- self.remove()
-
- def clearCurves(self):
- """Remove all the curves from the plot."""
- self.remove(kind='curve')
-
- def clearImages(self):
- """Remove all the images from the plot."""
- self.remove(kind='image')
-
- def clearItems(self):
- """Remove all the items from the plot. """
- self.remove(kind='item')
-
- def clearMarkers(self):
- """Remove all the markers from the plot."""
- self.remove(kind='marker')
-
- # Interaction
-
- def getGraphCursor(self):
- """Returns the state of the crosshair cursor.
-
- See :meth:`setGraphCursor`.
-
- :return: None if the crosshair cursor is not active,
- else a tuple (color, linewidth, linestyle).
- """
- return self._cursorConfiguration
-
- def setGraphCursor(self, flag=False, color='black',
- linewidth=1, linestyle='-'):
- """Toggle the display of a crosshair cursor and set its attributes.
-
- :param bool flag: Toggle the display of a crosshair cursor.
- The crosshair cursor is hidden by default.
- :param color: The color to use for the crosshair.
- :type color: A string (either a predefined color name in Colors.py
- or "#RRGGBB")) or a 4 columns unsigned byte array
- (Default: black).
- :param int linewidth: The width of the lines of the crosshair
- (Default: 1).
- :param str linestyle: Type of line::
-
- - ' ' no line
- - '-' solid line (the default)
- - '--' dashed line
- - '-.' dash-dot line
- - ':' dotted line
- """
- if flag:
- self._cursorConfiguration = color, linewidth, linestyle
- else:
- self._cursorConfiguration = None
-
- self._backend.setGraphCursor(flag=flag, color=color,
- linewidth=linewidth, linestyle=linestyle)
- self._setDirtyPlot()
- self.notify('setGraphCursor',
- state=self._cursorConfiguration is not None)
-
- def pan(self, direction, factor=0.1):
- """Pan the graph in the given direction by the given factor.
-
- Warning: Pan of right Y axis not implemented!
-
- :param str direction: One of 'up', 'down', 'left', 'right'.
- :param float factor: Proportion of the range used to pan the graph.
- Must be strictly positive.
- """
- assert direction in ('up', 'down', 'left', 'right')
- assert factor > 0.
-
- if direction in ('left', 'right'):
- xFactor = factor if direction == 'right' else - factor
- xMin, xMax = self.getGraphXLimits()
-
- xMin, xMax = _utils.applyPan(xMin, xMax, xFactor,
- self.isXAxisLogarithmic())
- self.setGraphXLimits(xMin, xMax)
-
- else: # direction in ('up', 'down')
- sign = -1. if self.isYAxisInverted() else 1.
- yFactor = sign * (factor if direction == 'up' else -factor)
- yMin, yMax = self.getGraphYLimits()
- yIsLog = self.isYAxisLogarithmic()
-
- yMin, yMax = _utils.applyPan(yMin, yMax, yFactor, yIsLog)
- self.setGraphYLimits(yMin, yMax, axis='left')
-
- y2Min, y2Max = self.getGraphYLimits(axis='right')
-
- y2Min, y2Max = _utils.applyPan(y2Min, y2Max, yFactor, yIsLog)
- self.setGraphYLimits(y2Min, y2Max, axis='right')
-
- # Active Curve/Image
-
- def isActiveCurveHandling(self):
- """Returns True if active curve selection is enabled."""
- return self._activeCurveHandling
-
- def setActiveCurveHandling(self, flag=True):
- """Enable/Disable active curve selection.
-
- :param bool flag: True (the default) to enable active curve selection.
- """
- if not flag:
- self.setActiveCurve(None) # Reset active curve
-
- self._activeCurveHandling = bool(flag)
-
- def getActiveCurveColor(self):
- """Get the color used to display the currently active curve.
-
- See :meth:`setActiveCurveColor`.
- """
- return self._activeCurveColor
-
- def setActiveCurveColor(self, color="#000000"):
- """Set the color to use to display the currently active curve.
-
- :param str color: Color of the active curve,
- e.g., 'blue', 'b', '#FF0000' (Default: 'black')
- """
- if color is None:
- color = "black"
- if color in self.colorDict:
- color = self.colorDict[color]
- self._activeCurveColor = color
-
- def getActiveCurve(self, just_legend=False):
- """Return the currently active curve.
-
- It returns None in case of not having an active curve.
-
- :param bool just_legend: True to get the legend of the curve,
- False (the default) to get the curve data
- and info.
- :return: Active curve's legend or corresponding
- :class:`.items.Curve`
- :rtype: str or :class:`.items.Curve` or None
- """
- if not self.isActiveCurveHandling():
- return None
-
- return self._getActiveItem(kind='curve', just_legend=just_legend)
-
- def setActiveCurve(self, legend, replot=None):
- """Make the curve associated to legend the active curve.
-
- :param legend: The legend associated to the curve
- or None to have no active curve.
- :type legend: str or None
- """
- if replot is not None:
- _logger.warning('setActiveCurve deprecated replot parameter')
-
- if not self.isActiveCurveHandling():
- return
-
- return self._setActiveItem(kind='curve', legend=legend)
-
- def getActiveImage(self, just_legend=False):
- """Returns the currently active image.
-
- It returns None in case of not having an active image.
-
- :param bool just_legend: True to get the legend of the image,
- False (the default) to get the image data
- and info.
- :return: Active image's legend or corresponding image object
- :rtype: str, :class:`.items.ImageData`, :class:`.items.ImageRgba`
- or None
- """
- return self._getActiveItem(kind='image', just_legend=just_legend)
-
- def setActiveImage(self, legend, replot=None):
- """Make the image associated to legend the active image.
-
- :param str legend: The legend associated to the image
- or None to have no active image.
- """
- if replot is not None:
- _logger.warning('setActiveImage deprecated replot parameter')
-
- return self._setActiveItem(kind='image', legend=legend)
-
- def _getActiveItem(self, kind, just_legend=False):
- """Return the currently active item of that kind if any
-
- :param str kind: Type of item: 'curve', 'scatter' or 'image'
- :param bool just_legend: True to get the legend,
- False (default) to get the item
- :return: legend or item or None if no active item
- """
- assert kind in ('curve', 'scatter', 'image')
-
- if self._activeLegend[kind] is None:
- return None
-
- if (self._activeLegend[kind], kind) not in self._content:
- self._activeLegend[kind] = None
- return None
-
- if just_legend:
- return self._activeLegend[kind]
- else:
- return self._getItem(kind, self._activeLegend[kind])
-
- def _setActiveItem(self, kind, legend):
- """Make the curve associated to legend the active curve.
-
- :param str kind: Type of item: 'curve' or 'image'
- :param legend: The legend associated to the curve
- or None to have no active curve.
- :type legend: str or None
- """
- assert kind in ('curve', 'image', 'scatter')
-
- xLabel = self._defaultLabels['x']
- yLabel = self._defaultLabels['y']
- yRightLabel = self._defaultLabels['yright']
-
- oldActiveItem = self._getActiveItem(kind=kind)
-
- # Curve specific: Reset highlight of previous active curve
- if kind == 'curve' and oldActiveItem is not None:
- oldActiveItem.setHighlighted(False)
-
- if legend is None:
- self._activeLegend[kind] = None
- else:
- legend = str(legend)
- item = self._getItem(kind, legend)
- if item is None:
- _logger.warning("This %s does not exist: %s", kind, legend)
- self._activeLegend[kind] = None
- else:
- self._activeLegend[kind] = legend
-
- # Curve specific: handle highlight
- if kind == 'curve':
- item.setHighlightedColor(self.getActiveCurveColor())
- item.setHighlighted(True)
-
- if isinstance(item, items.LabelsMixIn):
- if item.getXLabel() is not None:
- xLabel = item.getXLabel()
- if item.getYLabel() is not None:
- if (isinstance(item, items.YAxisMixIn) and
- item.getYAxis() == 'right'):
- yRightLabel = item.getYLabel()
- else:
- yLabel = item.getYLabel()
-
- # Store current labels and update plot
- self._currentLabels['x'] = xLabel
- self._currentLabels['y'] = yLabel
- self._currentLabels['yright'] = yRightLabel
-
- self._backend.setGraphXLabel(xLabel)
- self._backend.setGraphYLabel(yLabel, axis='left')
- self._backend.setGraphYLabel(yRightLabel, axis='right')
-
- self._setDirtyPlot()
-
- activeLegend = self._activeLegend[kind]
- if oldActiveItem is not None or activeLegend is not None:
- if oldActiveItem is None:
- oldActiveLegend = None
- else:
- oldActiveLegend = oldActiveItem.getLegend()
- self.notify(
- 'active' + kind[0].upper() + kind[1:] + 'Changed',
- updated=oldActiveLegend != activeLegend,
- previous=oldActiveLegend,
- legend=activeLegend)
-
- return activeLegend
-
- # Getters
-
- def getAllCurves(self, just_legend=False, withhidden=False):
- """Returns all curves legend or info and data.
-
- It returns an empty list in case of not having any curve.
-
- If just_legend is False, it returns a list of :class:`items.Curve`
- objects describing the curves.
- If just_legend is True, it returns a list of curves' legend.
-
- :param bool just_legend: True to get the legend of the curves,
- False (the default) to get the curves' data
- and info.
- :param bool withhidden: False (default) to skip hidden curves.
- :return: list of curves' legend or :class:`.items.Curve`
- :rtype: list of str or list of :class:`.items.Curve`
- """
- return self._getItems(kind='curve',
- just_legend=just_legend,
- withhidden=withhidden)
-
- def getCurve(self, legend=None):
- """Get the object describing a specific curve.
-
- It returns None in case no matching curve is found.
-
- :param str legend:
- The legend identifying the curve.
- If not provided or None (the default), the active curve is returned
- or if there is no active curve, the latest updated curve that is
- not hidden is returned if there are curves in the plot.
- :return: None or :class:`.items.Curve` object
- """
- return self._getItem(kind='curve', legend=legend)
-
- def getAllImages(self, just_legend=False):
- """Returns all images legend or objects.
-
- It returns an empty list in case of not having any image.
-
- If just_legend is False, it returns a list of :class:`items.ImageBase`
- objects describing the images.
- If just_legend is True, it returns a list of legends.
-
- :param bool just_legend: True to get the legend of the images,
- False (the default) to get the images'
- object.
- :return: list of images' legend or :class:`.items.ImageBase`
- :rtype: list of str or list of :class:`.items.ImageBase`
- """
- return self._getItems(kind='image',
- just_legend=just_legend,
- withhidden=True)
-
- def getImage(self, legend=None):
- """Get the object describing a specific image.
-
- It returns None in case no matching image is found.
-
- :param str legend:
- The legend identifying the image.
- If not provided or None (the default), the active image is returned
- or if there is no active image, the latest updated image
- is returned if there are images in the plot.
- :return: None or :class:`.items.ImageBase` object
- """
- return self._getItem(kind='image', legend=legend)
-
- def getScatter(self, legend=None):
- """Get the object describing a specific scatter.
-
- It returns None in case no matching scatter is found.
-
- :param str legend:
- The legend identifying the scatter.
- If not provided or None (the default), the active scatter is
- returned or if there is no active scatter, the latest updated
- scatter is returned if there are scatters in the plot.
- :return: None or :class:`.items.Scatter` object
- """
- return self._getItem(kind='scatter', legend=legend)
-
- def getHistogram(self, legend=None):
- """Get the object describing a specific histogram.
-
- It returns None in case no matching histogram is found.
-
- :param str legend:
- The legend identifying the histogram.
- If not provided or None (the default), the latest updated scatter
- is returned if there are histograms in the plot.
- :return: None or :class:`.items.Histogram` object
- """
- return self._getItem(kind='histogram', legend=legend)
-
- def _getItems(self, kind, just_legend=False, withhidden=False):
- """Retrieve all items of a kind in the plot
-
- :param str kind: Type of item: 'curve' or 'image'
- :param bool just_legend: True to get the legend of the curves,
- False (the default) to get the curves' data
- and info.
- :param bool withhidden: False (default) to skip hidden curves.
- :return: list of legends or item objects
- """
- assert kind in self.ITEM_KINDS
- output = []
- for (legend, type_), item in self._content.items():
- if type_ == kind and (withhidden or item.isVisible()):
- output.append(legend if just_legend else item)
- return output
-
- def _getItem(self, kind, legend=None):
- """Get an item from the plot: either an image or a curve.
-
- Returns None if no match found
-
- :param str kind: Type of item: 'curve' or 'image'
- :param str legend: Legend of the item or
- None to get active or last item
- :return: Object describing the item or None
- """
- assert kind in self.ITEM_KINDS
-
- if legend is not None:
- return self._content.get((legend, kind), None)
- else:
- if kind in ('curve', 'image', 'scatter'):
- item = self._getActiveItem(kind=kind)
- if item is not None: # Return active item if available
- return item
- # Return last visible item if any
- allItems = self._getItems(
- kind=kind, just_legend=False, withhidden=False)
- return allItems[-1] if allItems else None
-
- # Limits
-
- def _notifyLimitsChanged(self):
- """Send an event when plot area limits are changed."""
- xRange = self.getGraphXLimits()
- yRange = self.getGraphYLimits(axis='left')
- y2Range = self.getGraphYLimits(axis='right')
- event = PlotEvents.prepareLimitsChangedSignal(
- id(self.getWidgetHandle()), xRange, yRange, y2Range)
- self.notify(**event)
-
- def _checkLimits(self, min_, max_, axis):
- """Makes sure axis range is not empty
-
- :param float min_: Min axis value
- :param float max_: Max axis value
- :param str axis: 'x', 'y' or 'y2' the axis to deal with
- :return: (min, max) making sure min < max
- :rtype: 2-tuple of float
- """
- if max_ < min_:
- _logger.info('%s axis: max < min, inverting limits.', axis)
- min_, max_ = max_, min_
- elif max_ == min_:
- _logger.info('%s axis: max == min, expanding limits.', axis)
- if min_ == 0.:
- min_, max_ = -0.1, 0.1
- elif min_ < 0:
- min_, max_ = min_ * 1.1, min_ * 0.9
- else: # xmin > 0
- min_, max_ = min_ * 0.9, min_ * 1.1
-
- return min_, max_
-
- def getGraphXLimits(self):
- """Get the graph X (bottom) limits.
-
- :return: Minimum and maximum values of the X axis
- """
- return self._backend.getGraphXLimits()
-
- def setGraphXLimits(self, xmin, xmax, replot=None):
- """Set the graph X (bottom) limits.
-
- :param float xmin: minimum bottom axis value
- :param float xmax: maximum bottom axis value
- """
- if replot is not None:
- _logger.warning('setGraphXLimits deprecated replot parameter')
-
- xmin, xmax = self._checkLimits(xmin, xmax, axis='x')
-
- self._backend.setGraphXLimits(xmin, xmax)
- self._setDirtyPlot()
-
- self._notifyLimitsChanged()
-
- def getGraphYLimits(self, axis='left'):
- """Get the graph Y limits.
-
- :param str axis: The axis for which to get the limits:
- Either 'left' or 'right'
- :return: Minimum and maximum values of the X axis
- """
- assert axis in ('left', 'right')
- return self._backend.getGraphYLimits(axis)
-
- def setGraphYLimits(self, ymin, ymax, axis='left', replot=None):
- """Set the graph Y limits.
-
- :param float ymin: minimum bottom axis value
- :param float ymax: maximum bottom axis value
- :param str axis: The axis for which to get the limits:
- Either 'left' or 'right'
- """
- if replot is not None:
- _logger.warning('setGraphYLimits deprecated replot parameter')
-
- assert axis in ('left', 'right')
-
- ymin, ymax = self._checkLimits(ymin, ymax,
- axis='y' if axis == 'left' else 'y2')
-
- self._backend.setGraphYLimits(ymin, ymax, axis)
- self._setDirtyPlot()
-
- self._notifyLimitsChanged()
-
- def setLimits(self, xmin, xmax, ymin, ymax, y2min=None, y2max=None):
- """Set the limits of the X and Y axes at once.
-
- If y2min or y2max is None, the right Y axis limits are not updated.
-
- :param float xmin: minimum bottom axis value
- :param float xmax: maximum bottom axis value
- :param float ymin: minimum left axis value
- :param float ymax: maximum left axis value
- :param float y2min: minimum right axis value or None (the default)
- :param float y2max: maximum right axis value or None (the default)
- """
- # Deal with incorrect values
- xmin, xmax = self._checkLimits(xmin, xmax, axis='x')
- ymin, ymax = self._checkLimits(ymin, ymax, axis='y')
-
- if y2min is None or y2max is None:
- # if one limit is None, both are ignored
- y2min, y2max = None, None
- else:
- y2min, y2max = self._checkLimits(y2min, y2max, axis='y2')
-
- self._backend.setLimits(xmin, xmax, ymin, ymax, y2min, y2max)
- self._setDirtyPlot()
- self._notifyLimitsChanged()
-
- # Title and labels
-
- def getGraphTitle(self):
- """Return the plot main title as a str."""
- return self._graphTitle
-
- def setGraphTitle(self, title=""):
- """Set the plot main title.
-
- :param str title: Main title of the plot (default: '')
- """
- self._graphTitle = str(title)
- self._backend.setGraphTitle(title)
- self._setDirtyPlot()
-
- def getGraphXLabel(self):
- """Return the current X axis label as a str."""
- return self._currentLabels['x']
-
- def setGraphXLabel(self, label="X"):
- """Set the plot X axis label.
-
- The provided label can be temporarily replaced by the X label of the
- active curve if any.
-
- :param str label: The X axis label (default: 'X')
- """
- self._defaultLabels['x'] = label
- self._currentLabels['x'] = label
- self._backend.setGraphXLabel(label)
- self._setDirtyPlot()
-
- def getGraphYLabel(self, axis='left'):
- """Return the current Y axis label as a str.
-
- :param str axis: The Y axis for which to get the label (left or right)
- """
- assert axis in ('left', 'right')
-
- return self._currentLabels['y' if axis == 'left' else 'yright']
-
- def setGraphYLabel(self, label="Y", axis='left'):
- """Set the plot Y axis label.
-
- The provided label can be temporarily replaced by the Y label of the
- active curve if any.
-
- :param str label: The Y axis label (default: 'Y')
- :param str axis: The Y axis for which to set the label (left or right)
- """
- assert axis in ('left', 'right')
-
- if axis == 'left':
- self._defaultLabels['y'] = label
- self._currentLabels['y'] = label
- else:
- self._defaultLabels['yright'] = label
- self._currentLabels['yright'] = label
-
- self._backend.setGraphYLabel(label, axis=axis)
- self._setDirtyPlot()
-
- # Axes
-
- def setYAxisInverted(self, flag=True):
- """Set the Y axis orientation.
-
- :param bool flag: True for Y axis going from top to bottom,
- False for Y axis going from bottom to top
- """
- flag = bool(flag)
- self._backend.setYAxisInverted(flag)
- self._setDirtyPlot()
- self.notify('setYAxisInverted', state=flag)
-
- def isYAxisInverted(self):
- """Return True if Y axis goes from top to bottom, False otherwise."""
- return self._backend.isYAxisInverted()
-
- def isXAxisLogarithmic(self):
- """Return True if X axis scale is logarithmic, False if linear."""
- return self._logX
-
- def setXAxisLogarithmic(self, flag):
- """Set the bottom X axis scale (either linear or logarithmic).
-
- :param bool flag: True to use a logarithmic scale, False for linear.
- """
- if bool(flag) == self._logX:
- return
- self._logX = bool(flag)
-
- self._backend.setXAxisLogarithmic(self._logX)
-
- # TODO hackish way of forcing update of curves and images
- for curve in self.getAllCurves():
- curve._updated()
- for image in self.getAllImages():
- image._updated()
- self._invalidateDataRange()
-
- self.resetZoom()
- self.notify('setXAxisLogarithmic', state=self._logX)
-
- def isYAxisLogarithmic(self):
- """Return True if Y axis scale is logarithmic, False if linear."""
- return self._logY
-
- def setYAxisLogarithmic(self, flag):
- """Set the Y axes scale (either linear or logarithmic).
-
- :param bool flag: True to use a logarithmic scale, False for linear.
- """
- if bool(flag) == self._logY:
- return
- self._logY = bool(flag)
-
- self._backend.setYAxisLogarithmic(self._logY)
-
- # TODO hackish way of forcing update of curves and images
- for curve in self.getAllCurves():
- curve._updated()
- for image in self.getAllImages():
- image._updated()
- self._invalidateDataRange()
-
- self.resetZoom()
- self.notify('setYAxisLogarithmic', state=self._logY)
-
- def isXAxisAutoScale(self):
- """Return True if X axis is automatically adjusting its limits."""
- return self._xAutoScale
-
- def setXAxisAutoScale(self, flag=True):
- """Set the X axis limits adjusting behavior of :meth:`resetZoom`.
-
- :param bool flag: True to resize limits automatically,
- False to disable it.
- """
- self._xAutoScale = bool(flag)
- self.notify('setXAxisAutoScale', state=self._xAutoScale)
-
- def isYAxisAutoScale(self):
- """Return True if Y axes are automatically adjusting its limits."""
- return self._yAutoScale
-
- def setYAxisAutoScale(self, flag=True):
- """Set the Y axis limits adjusting behavior of :meth:`resetZoom`.
-
- :param bool flag: True to resize limits automatically,
- False to disable it.
- """
- self._yAutoScale = bool(flag)
- self.notify('setYAxisAutoScale', state=self._yAutoScale)
-
- def isKeepDataAspectRatio(self):
- """Returns whether the plot is keeping data aspect ratio or not."""
- return self._backend.isKeepDataAspectRatio()
-
- def setKeepDataAspectRatio(self, flag=True):
- """Set whether the plot keeps data aspect ratio or not.
-
- :param bool flag: True to respect data aspect ratio
- """
- flag = bool(flag)
- self._backend.setKeepDataAspectRatio(flag=flag)
- self._setDirtyPlot()
- self.resetZoom()
- self.notify('setKeepDataAspectRatio', state=flag)
-
- def getGraphGrid(self):
- """Return the current grid mode, either None, 'major' or 'both'.
-
- See :meth:`setGraphGrid`.
- """
- return self._grid
-
- def setGraphGrid(self, which=True):
- """Set the type of grid to display.
-
- :param which: None or False to disable the grid,
- 'major' or True for grid on major ticks (the default),
- 'both' for grid on both major and minor ticks.
- :type which: str of bool
- """
- assert which in (None, True, False, 'both', 'major')
- if not which:
- which = None
- elif which is True:
- which = 'major'
- self._grid = which
- self._backend.setGraphGrid(which)
- self._setDirtyPlot()
- self.notify('setGraphGrid', which=str(which))
-
- # Defaults
-
- def isDefaultPlotPoints(self):
- """Return True if default Curve symbol is 'o', False for no symbol."""
- return self._defaultPlotPoints == 'o'
-
- def setDefaultPlotPoints(self, flag):
- """Set the default symbol of all curves.
-
- When called, this reset the symbol of all existing curves.
-
- :param bool flag: True to use 'o' as the default curve symbol,
- False to use no symbol.
- """
- self._defaultPlotPoints = 'o' if flag else ''
-
- # Reset symbol of all curves
- curves = self.getAllCurves(just_legend=False, withhidden=True)
-
- if curves:
- for curve in curves:
- curve.setSymbol(self._defaultPlotPoints)
-
- def isDefaultPlotLines(self):
- """Return True for line as default line style, False for no line."""
- return self._plotLines
-
- def setDefaultPlotLines(self, flag):
- """Toggle the use of lines as the default curve line style.
-
- :param bool flag: True to use a line as the default line style,
- False to use no line as the default line style.
- """
- self._plotLines = bool(flag)
-
- linestyle = '-' if self._plotLines else ' '
-
- # Reset linestyle of all curves
- curves = self.getAllCurves(withhidden=True)
-
- if curves:
- for curve in curves:
- curve.setLineStyle(linestyle)
-
- def getDefaultColormap(self):
- """Return the default colormap used by :meth:`addImage` as a dict.
-
- See :mod:`Plot` for the documentation of the colormap dict.
- """
- return self._defaultColormap.copy()
-
- def setDefaultColormap(self, colormap=None):
- """Set the default colormap used by :meth:`addImage`.
-
- Setting the default colormap do not change any currently displayed
- image.
- It only affects future calls to :meth:`addImage` without the colormap
- parameter.
-
- :param dict colormap: The description of the default colormap, or
- None to set the colormap to a linear autoscale
- gray colormap.
- See :mod:`Plot` for the documentation
- of the colormap dict.
- """
- if colormap is None:
- colormap = {'name': 'gray', 'normalization': 'linear',
- 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0}
- self._defaultColormap = colormap.copy()
-
- def getSupportedColormaps(self):
- """Get the supported colormap names as a tuple of str.
-
- The list should at least contain and start by:
- ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue')
- """
- default = ('gray', 'reversed gray',
- 'temperature',
- 'red', 'green', 'blue')
- if matplotlib_cm is None:
- return default
- else:
- maps = [m for m in matplotlib_cm.datad]
- maps.sort()
- return default + tuple(maps)
-
- def _getColorAndStyle(self):
- color = self.colorList[self._colorIndex]
- style = self._styleList[self._styleIndex]
-
- # Loop over color and then styles
- self._colorIndex += 1
- if self._colorIndex >= len(self.colorList):
- self._colorIndex = 0
- self._styleIndex = (self._styleIndex + 1) % len(self._styleList)
-
- # If color is the one of active curve, take the next one
- if color == self.getActiveCurveColor():
- color, style = self._getColorAndStyle()
-
- if not self._plotLines:
- style = ' '
-
- return color, style
-
- # Misc.
-
- def getWidgetHandle(self):
- """Return the widget the plot is displayed in.
-
- This widget is owned by the backend.
- """
- return self._backend.getWidgetHandle()
-
- def notify(self, event, **kwargs):
- """Send an event to the listeners.
-
- Event are passed to the registered callback as a dict with an 'event'
- key for backward compatibility with PyMca.
-
- :param str event: The type of event
- :param kwargs: The information of the event.
- """
- eventDict = kwargs.copy()
- eventDict['event'] = event
- self._callback(eventDict)
-
- def setCallback(self, callbackFunction=None):
- """Attach a listener to the backend.
-
- Limitation: Only one listener at a time.
-
- :param callbackFunction: function accepting a dictionary as input
- to handle the graph events
- If None (default), use a default listener.
- """
- # TODO allow multiple listeners, keep a weakref on it
- # allow register listener by event type
- if callbackFunction is None:
- callbackFunction = self.graphCallback
- self._callback = callbackFunction
-
- def graphCallback(self, ddict=None):
- """This callback is going to receive all the events from the plot.
-
- Those events will consist on a dictionary and among the dictionary
- keys the key 'event' is mandatory to describe the type of event.
- This default implementation only handles setting the active curve.
- """
-
- if ddict is None:
- ddict = {}
- _logger.debug("Received dict keys = %s", str(ddict.keys()))
- _logger.debug(str(ddict))
- if ddict['event'] in ["legendClicked", "curveClicked"]:
- if ddict['button'] == "left":
- self.setActiveCurve(ddict['label'])
-
- def saveGraph(self, filename, fileFormat=None, dpi=None, **kw):
- """Save a snapshot of the plot.
-
- Supported file formats: "png", "svg", "pdf", "ps", "eps",
- "tif", "tiff", "jpeg", "jpg".
-
- :param filename: Destination
- :type filename: str, StringIO or BytesIO
- :param str fileFormat: String specifying the format
- :return: False if cannot save the plot, True otherwise
- """
- if kw:
- _logger.warning('Extra parameters ignored: %s', str(kw))
-
- if fileFormat is None:
- if not hasattr(filename, 'lower'):
- _logger.warning(
- 'saveGraph cancelled, cannot define file format.')
- return False
- else:
- fileFormat = (filename.split(".")[-1]).lower()
-
- supportedFormats = ("png", "svg", "pdf", "ps", "eps",
- "tif", "tiff", "jpeg", "jpg")
-
- if fileFormat not in supportedFormats:
- _logger.warning('Unsupported format %s', fileFormat)
- return False
- else:
- self._backend.saveGraph(filename,
- fileFormat=fileFormat,
- dpi=dpi)
- return True
-
- def getDataMargins(self):
- """Get the default data margin ratios, see :meth:`setDataMargins`.
-
- :return: The margin ratios for each side (xMin, xMax, yMin, yMax).
- :rtype: A 4-tuple of floats.
- """
- return self._defaultDataMargins
-
- def setDataMargins(self, xMinMargin=0., xMaxMargin=0.,
- yMinMargin=0., yMaxMargin=0.):
- """Set the default data margins to use in :meth:`resetZoom`.
-
- Set the default ratios of margins (as floats) to add around the data
- inside the plot area for each side.
- """
- self._defaultDataMargins = (xMinMargin, xMaxMargin,
- yMinMargin, yMaxMargin)
-
- def getAutoReplot(self):
- """Return True if replot is automatically handled, False otherwise.
-
- See :meth`setAutoReplot`.
- """
- return self._autoreplot
-
- def setAutoReplot(self, autoreplot=True):
- """Set automatic replot mode.
-
- When enabled, the plot is redrawn automatically when changed.
- When disabled, the plot is not redrawn when its content change.
- Instead, it :meth:`replot` must be called.
-
- :param bool autoreplot: True to enable it (default),
- False to disable it.
- """
- self._autoreplot = bool(autoreplot)
-
- # If the plot is dirty before enabling autoreplot,
- # then _backend.postRedisplay will never be called from _setDirtyPlot
- if self._autoreplot and self._getDirtyPlot():
- self._backend.postRedisplay()
-
- def replot(self):
- """Redraw the plot immediately."""
- for item in self._contentToUpdate:
- item._update(self._backend)
- self._contentToUpdate.clear()
- self._backend.replot()
- self._dirty = False # reset dirty flag
-
- def resetZoom(self, dataMargins=None):
- """Reset the plot limits to the bounds of the data and redraw the plot.
-
- It automatically scale limits of axes that are in autoscale mode
- (See :meth:`setXAxisAutoScale`, :meth:`setYAxisAutoScale`).
- It keeps current limits on axes that are not in autoscale mode.
-
- Extra margins can be added around the data inside the plot area.
- Margins are given as one ratio of the data range per limit of the
- data (xMin, xMax, yMin and yMax limits).
- For log scale, extra margins are applied in log10 of the data.
-
- :param dataMargins: Ratios of margins to add around the data inside
- the plot area for each side (Default: no margins).
- :type dataMargins: A 4-tuple of float as (xMin, xMax, yMin, yMax).
- """
- if dataMargins is None:
- dataMargins = self._defaultDataMargins
-
- xLimits = self.getGraphXLimits()
- yLimits = self.getGraphYLimits(axis='left')
- y2Limits = self.getGraphYLimits(axis='right')
-
- xAuto = self.isXAxisAutoScale()
- yAuto = self.isYAxisAutoScale()
-
- if not xAuto and not yAuto:
- _logger.debug("Nothing to autoscale")
- else: # Some axes to autoscale
-
- # Get data range
- ranges = self.getDataRange()
- xmin, xmax = (1., 100.) if ranges.x is None else ranges.x
- ymin, ymax = (1., 100.) if ranges.y is None else ranges.y
- if ranges.yright is None:
- ymin2, ymax2 = None, None
- else:
- ymin2, ymax2 = ranges.yright
-
- # Add margins around data inside the plot area
- newLimits = list(_utils.addMarginsToLimits(
- dataMargins,
- self.isXAxisLogarithmic(),
- self.isYAxisLogarithmic(),
- xmin, xmax, ymin, ymax, ymin2, ymax2))
-
- if self.isKeepDataAspectRatio():
- # Use limits with margins to keep ratio
- xmin, xmax, ymin, ymax = newLimits[:4]
-
- # Compute bbox wth figure aspect ratio
- plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:]
- plotRatio = plotHeight / plotWidth
-
- if plotRatio > 0.:
- dataRatio = (ymax - ymin) / (xmax - xmin)
- if dataRatio < plotRatio:
- # Increase y range
- ycenter = 0.5 * (ymax + ymin)
- yrange = (xmax - xmin) * plotRatio
- newLimits[2] = ycenter - 0.5 * yrange
- newLimits[3] = ycenter + 0.5 * yrange
-
- elif dataRatio > plotRatio:
- # Increase x range
- xcenter = 0.5 * (xmax + xmin)
- xrange_ = (ymax - ymin) / plotRatio
- newLimits[0] = xcenter - 0.5 * xrange_
- newLimits[1] = xcenter + 0.5 * xrange_
-
- self.setLimits(*newLimits)
-
- if not xAuto and yAuto:
- self.setGraphXLimits(*xLimits)
- elif xAuto and not yAuto:
- if y2Limits is not None:
- self.setGraphYLimits(
- y2Limits[0], y2Limits[1], axis='right')
- if yLimits is not None:
- self.setGraphYLimits(yLimits[0], yLimits[1], axis='left')
-
- self._setDirtyPlot()
-
- if (xLimits != self.getGraphXLimits() or
- yLimits != self.getGraphYLimits(axis='left') or
- y2Limits != self.getGraphYLimits(axis='right')):
- self._notifyLimitsChanged()
-
- # Coord conversion
-
- def dataToPixel(self, x=None, y=None, axis="left", check=True):
- """Convert a position in data coordinates to a position in pixels.
-
- :param float x: The X coordinate in data space. If None (default)
- the middle position of the displayed data is used.
- :param float y: The Y coordinate in data space. If None (default)
- the middle position of the displayed data is used.
- :param str axis: The Y axis to use for the conversion
- ('left' or 'right').
- :param bool check: True to return None if outside displayed area,
- False to convert to pixels anyway
- :returns: The corresponding position in pixels or
- None if the data position is not in the displayed area and
- check is True.
- :rtype: A tuple of 2 floats: (xPixel, yPixel) or None.
- """
- assert axis in ("left", "right")
-
- xmin, xmax = self.getGraphXLimits()
- ymin, ymax = self.getGraphYLimits(axis=axis)
-
- if x is None:
- x = 0.5 * (xmax + xmin)
- if y is None:
- y = 0.5 * (ymax + ymin)
-
- if check:
- if x > xmax or x < xmin:
- return None
-
- if y > ymax or y < ymin:
- return None
-
- return self._backend.dataToPixel(x, y, axis=axis)
-
- def pixelToData(self, x, y, axis="left", check=False):
- """Convert a position in pixels to a position in data coordinates.
-
- :param float x: The X coordinate in pixels. If None (default)
- the center of the widget is used.
- :param float y: The Y coordinate in pixels. If None (default)
- the center of the widget is used.
- :param str axis: The Y axis to use for the conversion
- ('left' or 'right').
- :param bool check: Toggle checking if pixel is in plot area.
- If False, this method never returns None.
- :returns: The corresponding position in data space or
- None if the pixel position is not in the plot area.
- :rtype: A tuple of 2 floats: (xData, yData) or None.
- """
- assert axis in ("left", "right")
- return self._backend.pixelToData(x, y, axis=axis, check=check)
-
- def getPlotBoundsInPixels(self):
- """Plot area bounds in widget coordinates in pixels.
-
- :return: bounds as a 4-tuple of int: (left, top, width, height)
- """
- return self._backend.getPlotBoundsInPixels()
-
- # Interaction support
-
- def setGraphCursorShape(self, cursor=None):
- """Set the cursor shape.
-
- :param str cursor: Name of the cursor shape
- """
- self._backend.setGraphCursorShape(cursor)
-
- def _pickMarker(self, x, y, test=None):
- """Pick a marker at the given position.
-
- To use for interaction implementation.
-
- :param float x: X position in pixels.
- :param float y: Y position in pixels.
- :param test: A callable to call for each picked marker to filter
- picked markers. If None (default), do not filter markers.
- """
- if test is None:
- def test(mark):
- return True
-
- markers = self._backend.pickItems(x, y)
- legends = [m['legend'] for m in markers if m['kind'] == 'marker']
-
- for legend in reversed(legends):
- marker = self._getMarker(legend)
- if marker is not None and test(marker):
- return marker
- return None
-
- def _getAllMarkers(self, just_legend=False):
- """Returns all markers' legend or objects
-
- :param bool just_legend: True to get the legend of the markers,
- False (the default) to get marker objects.
- :return: list of legend of list of marker objects
- :rtype: list of str or list of marker objects
- """
- return self._getItems(
- kind='marker', just_legend=just_legend, withhidden=True)
-
- def _getMarker(self, legend=None):
- """Get the object describing a specific marker.
-
- It returns None in case no matching marker is found
-
- :param str legend: The legend of the marker to retrieve
- :rtype: None of marker object
- """
- return self._getItem(kind='marker', legend=legend)
-
- def _pickImageOrCurve(self, x, y, test=None):
- """Pick an image or a curve at the given position.
-
- To use for interaction implementation.
-
- :param float x: X position in pixelsparam 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.
- """
- if test is None:
- def test(i):
- return True
-
- allItems = self._backend.pickItems(x, y)
- allItems = [item for item in allItems
- if item['kind'] in ['curve', 'image']]
-
- for item in reversed(allItems):
- kind, legend = item['kind'], item['legend']
- if kind == 'curve':
- curve = self.getCurve(legend)
- if curve is not None and test(curve):
- return kind, curve, item['xdata'], item['ydata']
-
- elif kind == 'image':
- image = self.getImage(legend)
- if image is not None and test(image):
- return kind, image, None
-
- else:
- _logger.warning('Unsupported kind: %s', kind)
-
- return None
-
- # User event handling #
-
- def _isPositionInPlotArea(self, x, y):
- """Project position in pixel to the closest point in the plot area
-
- :param float x: X coordinate in widget coordinate (in pixel)
- :param float y: Y coordinate in widget coordinate (in pixel)
- :return: (x, y) in widget coord (in pixel) in the plot area
- """
- left, top, width, height = self.getPlotBoundsInPixels()
- xPlot = numpy.clip(x, left, left + width)
- yPlot = numpy.clip(y, top, top + height)
- return xPlot, yPlot
-
- def onMousePress(self, xPixel, yPixel, btn):
- """Handle mouse press event.
-
- :param float xPixel: X mouse position in pixels
- :param float yPixel: Y mouse position in pixels
- :param str btn: Mouse button in 'left', 'middle', 'right'
- """
- if self._isPositionInPlotArea(xPixel, yPixel) == (xPixel, yPixel):
- self._pressedButtons.append(btn)
- self._eventHandler.handleEvent('press', xPixel, yPixel, btn)
-
- def onMouseMove(self, xPixel, yPixel):
- """Handle mouse move event.
-
- :param float xPixel: X mouse position in pixels
- :param float yPixel: Y mouse position in pixels
- """
- inXPixel, inYPixel = self._isPositionInPlotArea(xPixel, yPixel)
- isCursorInPlot = inXPixel == xPixel and inYPixel == yPixel
-
- if self._cursorInPlot != isCursorInPlot:
- self._cursorInPlot = isCursorInPlot
- self._eventHandler.handleEvent(
- 'enter' if self._cursorInPlot else 'leave')
-
- if isCursorInPlot:
- # Signal mouse move event
- dataPos = self.pixelToData(inXPixel, inYPixel)
- assert dataPos is not None
-
- btn = self._pressedButtons[-1] if self._pressedButtons else None
- event = PlotEvents.prepareMouseSignal(
- 'mouseMoved', btn, dataPos[0], dataPos[1], xPixel, yPixel)
- self.notify(**event)
-
- # Either button was pressed in the plot or cursor is in the plot
- if isCursorInPlot or self._pressedButtons:
- self._eventHandler.handleEvent('move', inXPixel, inYPixel)
-
- def onMouseRelease(self, xPixel, yPixel, btn):
- """Handle mouse release event.
-
- :param float xPixel: X mouse position in pixels
- :param float yPixel: Y mouse position in pixels
- :param str btn: Mouse button in 'left', 'middle', 'right'
- """
- try:
- self._pressedButtons.remove(btn)
- except ValueError:
- pass
- else:
- xPixel, yPixel = self._isPositionInPlotArea(xPixel, yPixel)
- self._eventHandler.handleEvent('release', xPixel, yPixel, btn)
-
- def onMouseWheel(self, xPixel, yPixel, angleInDegrees):
- """Handle mouse wheel event.
-
- :param float xPixel: X mouse position in pixels
- :param float yPixel: Y mouse position in pixels
- :param float angleInDegrees: Angle corresponding to wheel motion.
- Positive for movement away from the user,
- negative for movement toward the user.
- """
- if self._isPositionInPlotArea(xPixel, yPixel) == (xPixel, yPixel):
- self._eventHandler.handleEvent(
- 'wheel', xPixel, yPixel, angleInDegrees)
-
- def onMouseLeaveWidget(self):
- """Handle mouse leave widget event."""
- if self._cursorInPlot:
- self._cursorInPlot = False
- self._eventHandler.handleEvent('leave')
-
- # Interaction modes #
-
- def getInteractiveMode(self):
- """Returns the current interactive mode as a dict.
-
- The returned dict contains at least the key 'mode'.
- Mode can be: 'draw', 'pan', 'select', 'zoom'.
- It can also contains extra keys (e.g., 'color') specific to a mode
- as provided to :meth:`setInteractiveMode`.
- """
- return self._eventHandler.getInteractiveMode()
-
- def setInteractiveMode(self, mode, color='black',
- shape='polygon', label=None,
- zoomOnWheel=True, source=None, width=None):
- """Switch the interactive mode.
-
- :param str mode: The name of the interactive mode.
- In 'draw', 'pan', 'select', 'zoom'.
- :param color: Only for 'draw' and 'zoom' modes.
- Color to use for drawing selection area. Default black.
- :type color: Color description: The name as a str or
- a tuple of 4 floats.
- :param str shape: Only for 'draw' mode. The kind of shape to draw.
- In 'polygon', 'rectangle', 'line', 'vline', 'hline',
- 'freeline'.
- Default is 'polygon'.
- :param str label: Only for 'draw' mode, sent in drawing events.
- :param bool zoomOnWheel: Toggle zoom on wheel support
- :param source: A user-defined object (typically the caller object)
- that will be send in the interactiveModeChanged event,
- to identify which object required a mode change.
- Default: None
- :param float width: Width of the pencil. Only for draw pencil mode.
- """
- self._eventHandler.setInteractiveMode(mode, color, shape, label, width)
- self._eventHandler.zoomOnWheel = zoomOnWheel
-
- self.notify(
- 'interactiveModeChanged', source=source)
-
- # Deprecated #
-
- def isDrawModeEnabled(self):
- """Deprecated, use :meth:`getInteractiveMode` instead.
-
- Return True if the current interactive state is drawing."""
- _logger.warning(
- 'isDrawModeEnabled deprecated, use getInteractiveMode instead')
- return self.getInteractiveMode()['mode'] == 'draw'
-
- def setDrawModeEnabled(self, flag=True, shape='polygon', label=None,
- color=None, **kwargs):
- """Deprecated, use :meth:`setInteractiveMode` instead.
-
- Set the drawing mode if flag is True and its parameters.
-
- If flag is False, only item selection is enabled.
-
- Warning: Zoom and drawing are not compatible and cannot be enabled
- simultaneously.
-
- :param bool flag: True to enable drawing and disable zoom and select.
- :param str shape: Type of item to be drawn in:
- hline, vline, rectangle, polygon (default)
- :param str label: Associated text for identifying draw signals
- :param color: The color to use to draw the selection area
- :type color: string ("#RRGGBB") or 4 column unsigned byte array or
- one of the predefined color names defined in Colors.py
- """
- _logger.warning(
- 'setDrawModeEnabled deprecated, use setInteractiveMode instead')
-
- if kwargs:
- _logger.warning('setDrawModeEnabled ignores additional parameters')
-
- if color is None:
- color = 'black'
-
- if flag:
- self.setInteractiveMode('draw', shape=shape,
- label=label, color=color)
- elif self.getInteractiveMode()['mode'] == 'draw':
- self.setInteractiveMode('select')
-
- def getDrawMode(self):
- """Deprecated, use :meth:`getInteractiveMode` instead.
-
- Return the draw mode parameters as a dict of None.
-
- It returns None if the interactive mode is not a drawing mode,
- otherwise, it returns a dict containing the drawing mode parameters
- as provided to :meth:`setDrawModeEnabled`.
- """
- _logger.warning(
- 'getDrawMode deprecated, use getInteractiveMode instead')
- mode = self.getInteractiveMode()
- return mode if mode['mode'] == 'draw' else None
-
- def isZoomModeEnabled(self):
- """Deprecated, use :meth:`getInteractiveMode` instead.
-
- Return True if the current interactive state is zooming."""
- _logger.warning(
- 'isZoomModeEnabled deprecated, use getInteractiveMode instead')
- return self.getInteractiveMode()['mode'] == 'zoom'
-
- def setZoomModeEnabled(self, flag=True, color=None):
- """Deprecated, use :meth:`setInteractiveMode` instead.
-
- Set the zoom mode if flag is True, else item selection is enabled.
-
- Warning: Zoom and drawing are not compatible and cannot be enabled
- simultaneously
-
- :param bool flag: If True, enable zoom and select mode.
- :param color: The color to use to draw the selection area.
- (Default: 'black')
- :param color: The color to use to draw the selection area
- :type color: string ("#RRGGBB") or 4 column unsigned byte array or
- one of the predefined color names defined in Colors.py
- """
- _logger.warning(
- 'setZoomModeEnabled deprecated, use setInteractiveMode instead')
- if color is None:
- color = 'black'
-
- if flag:
- self.setInteractiveMode('zoom', color=color)
- elif self.getInteractiveMode()['mode'] == 'zoom':
- self.setInteractiveMode('select')
-
- def insertMarker(self, *args, **kwargs):
- """Deprecated, use :meth:`addMarker` instead."""
- _logger.warning(
- 'insertMarker deprecated, use addMarker instead.')
- return self.addMarker(*args, **kwargs)
-
- def insertXMarker(self, *args, **kwargs):
- """Deprecated, use :meth:`addXMarker` instead."""
- _logger.warning(
- 'insertXMarker deprecated, use addXMarker instead.')
- return self.addXMarker(*args, **kwargs)
-
- def insertYMarker(self, *args, **kwargs):
- """Deprecated, use :meth:`addYMarker` instead."""
- _logger.warning(
- 'insertYMarker deprecated, use addYMarker instead.')
- return self.addYMarker(*args, **kwargs)
-
- def isActiveCurveHandlingEnabled(self):
- """Deprecated, use :meth:`isActiveCurveHandling` instead."""
- _logger.warning(
- 'isActiveCurveHandlingEnabled deprecated, '
- 'use isActiveCurveHandling instead.')
- return self.isActiveCurveHandling()
-
- def enableActiveCurveHandling(self, *args, **kwargs):
- """Deprecated, use :meth:`setActiveCurveHandling` instead."""
- _logger.warning(
- 'enableActiveCurveHandling deprecated, '
- 'use setActiveCurveHandling instead.')
- return self.setActiveCurveHandling(*args, **kwargs)
-
- def invertYAxis(self, *args, **kwargs):
- """Deprecated, use :meth:`setYAxisInverted` instead."""
- _logger.warning('invertYAxis deprecated, '
- 'use setYAxisInverted instead.')
- return self.setYAxisInverted(*args, **kwargs)
-
- def showGrid(self, flag=True):
- """Deprecated, use :meth:`setGraphGrid` instead."""
- _logger.warning("showGrid deprecated, use setGraphGrid instead")
- if flag in (0, False):
- flag = None
- elif flag in (1, True):
- flag = 'major'
- else:
- flag = 'both'
- return self.setGraphGrid(flag)
-
- def keepDataAspectRatio(self, *args, **kwargs):
- """Deprecated, use :meth:`setKeepDataAspectRatio`."""
- _logger.warning('keepDataAspectRatio deprecated,'
- 'use setKeepDataAspectRatio instead')
- return self.setKeepDataAspectRatio(*args, **kwargs)
diff --git a/silx/gui/plot/PlotActions.py b/silx/gui/plot/PlotActions.py
index aad27d2..dd16221 100644
--- a/silx/gui/plot/PlotActions.py
+++ b/silx/gui/plot/PlotActions.py
@@ -22,1365 +22,46 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""This module provides a set of QAction to use with :class:`.PlotWidget`.
+"""Depracted module linking old PlotAction with the actions.xxx"""
-The following QAction are available:
-- :class:`ColormapAction`
-- :class:`CopyAction`
-- :class:`CrosshairAction`
-- :class:`CurveStyleAction`
-- :class:`FitAction`
-- :class:`GridAction`
-- :class:`KeepAspectRatioAction`
-- :class:`PanWithArrowKeysAction`
-- :class:`PrintAction`
-- :class:`ResetZoomAction`
-- :class:`SaveAction`
-- :class:`XAxisLogarithmicAction`
-- :class:`XAxisAutoScaleAction`
-- :class:`YAxisInvertedAction`
-- :class:`YAxisLogarithmicAction`
-- :class:`YAxisAutoScaleAction`
-- :class:`ZoomInAction`
-- :class:`ZoomOutAction`
-"""
-
-from __future__ import division
-
-
-__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
+__author__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "20/04/2017"
-
-
-from collections import OrderedDict
-import logging
-import sys
-import traceback
-import weakref
-
-if sys.version_info[0] == 3:
- from io import BytesIO
-else:
- import cStringIO as _StringIO
- BytesIO = _StringIO.StringIO
-
-import numpy
-
-from .. import icons
-from .. import qt
-from .._utils import convertArrayToQImage
-from . import Colors, items
-from .ColormapDialog import ColormapDialog
-from ._utils import applyZoomToPlot as _applyZoomToPlot
-from silx.third_party.EdfFile import EdfFile
-from silx.third_party.TiffIO import TiffIO
-from silx.math.histogram import Histogramnd
-from silx.math.medianfilter import medfilt2d
-from silx.gui.widgets.MedianFilterDialog import MedianFilterDialog
-
-from silx.io.utils import save1D, savespec
-
-
-_logger = logging.getLogger(__name__)
-
-
-class PlotAction(qt.QAction):
- """Base class for QAction that operates on a PlotWidget.
-
- :param plot: :class:`.PlotWidget` instance on which to operate.
- :param icon: QIcon or str name of icon to use
- :param str text: The name of this action to be used for menu label
- :param str tooltip: The text of the tooltip
- :param triggered: The callback to connect to the action's triggered
- signal or None for no callback.
- :param bool checkable: True for checkable action, False otherwise (default)
- :param parent: See :class:`QAction`.
- """
-
- def __init__(self, plot, icon, text, tooltip=None,
- triggered=None, checkable=False, parent=None):
- assert plot is not None
- self._plotRef = weakref.ref(plot)
-
- if not isinstance(icon, qt.QIcon):
- # Try with icon as a string and load corresponding icon
- icon = icons.getQIcon(icon)
-
- super(PlotAction, self).__init__(icon, text, parent)
-
- if tooltip is not None:
- self.setToolTip(tooltip)
-
- self.setCheckable(checkable)
-
- if triggered is not None:
- self.triggered[bool].connect(triggered)
-
- @property
- def plot(self):
- """The :class:`.PlotWidget` this action group is controlling."""
- return self._plotRef()
-
-
-class ResetZoomAction(PlotAction):
- """QAction controlling reset zoom on a :class:`.PlotWidget`.
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
-
- def __init__(self, plot, parent=None):
- super(ResetZoomAction, self).__init__(
- plot, icon='zoom-original', text='Reset Zoom',
- tooltip='Auto-scale the graph',
- triggered=self._actionTriggered,
- checkable=False, parent=parent)
- self._autoscaleChanged(True)
- plot.sigSetXAxisAutoScale.connect(self._autoscaleChanged)
- plot.sigSetYAxisAutoScale.connect(self._autoscaleChanged)
-
- def _autoscaleChanged(self, enabled):
- self.setEnabled(
- self.plot.isXAxisAutoScale() or self.plot.isYAxisAutoScale())
-
- if self.plot.isXAxisAutoScale() and self.plot.isYAxisAutoScale():
- tooltip = 'Auto-scale the graph'
- elif self.plot.isXAxisAutoScale(): # And not Y axis
- tooltip = 'Auto-scale the x-axis of the graph only'
- elif self.plot.isYAxisAutoScale(): # And not X axis
- tooltip = 'Auto-scale the y-axis of the graph only'
- else: # no axis in autoscale
- tooltip = 'Auto-scale the graph'
- self.setToolTip(tooltip)
-
- def _actionTriggered(self, checked=False):
- self.plot.resetZoom()
-
-
-class ZoomInAction(PlotAction):
- """QAction performing a zoom-in on a :class:`.PlotWidget`.
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
-
- def __init__(self, plot, parent=None):
- super(ZoomInAction, self).__init__(
- plot, icon='zoom-in', text='Zoom In',
- tooltip='Zoom in the plot',
- triggered=self._actionTriggered,
- checkable=False, parent=parent)
- self.setShortcut(qt.QKeySequence.ZoomIn)
- self.setShortcutContext(qt.Qt.WidgetShortcut)
-
- def _actionTriggered(self, checked=False):
- _applyZoomToPlot(self.plot, 1.1)
-
-
-class ZoomOutAction(PlotAction):
- """QAction performing a zoom-out on a :class:`.PlotWidget`.
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
-
- def __init__(self, plot, parent=None):
- super(ZoomOutAction, self).__init__(
- plot, icon='zoom-out', text='Zoom Out',
- tooltip='Zoom out the plot',
- triggered=self._actionTriggered,
- checkable=False, parent=parent)
- self.setShortcut(qt.QKeySequence.ZoomOut)
- self.setShortcutContext(qt.Qt.WidgetShortcut)
-
- def _actionTriggered(self, checked=False):
- _applyZoomToPlot(self.plot, 1. / 1.1)
-
-
-class XAxisAutoScaleAction(PlotAction):
- """QAction controlling X axis autoscale on a :class:`.PlotWidget`.
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
-
- def __init__(self, plot, parent=None):
- super(XAxisAutoScaleAction, self).__init__(
- plot, icon='plot-xauto', text='X Autoscale',
- tooltip='Enable x-axis auto-scale when checked.\n'
- 'If unchecked, x-axis does not change when reseting zoom.',
- triggered=self._actionTriggered,
- checkable=True, parent=parent)
- self.setChecked(plot.isXAxisAutoScale())
- plot.sigSetXAxisAutoScale.connect(self.setChecked)
-
- def _actionTriggered(self, checked=False):
- self.plot.setXAxisAutoScale(checked)
- if checked:
- self.plot.resetZoom()
-
-
-class YAxisAutoScaleAction(PlotAction):
- """QAction controlling Y axis autoscale on a :class:`.PlotWidget`.
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
-
- def __init__(self, plot, parent=None):
- super(YAxisAutoScaleAction, self).__init__(
- plot, icon='plot-yauto', text='Y Autoscale',
- tooltip='Enable y-axis auto-scale when checked.\n'
- 'If unchecked, y-axis does not change when reseting zoom.',
- triggered=self._actionTriggered,
- checkable=True, parent=parent)
- self.setChecked(plot.isXAxisAutoScale())
- plot.sigSetYAxisAutoScale.connect(self.setChecked)
-
- def _actionTriggered(self, checked=False):
- self.plot.setYAxisAutoScale(checked)
- if checked:
- self.plot.resetZoom()
-
-
-class XAxisLogarithmicAction(PlotAction):
- """QAction controlling X axis log scale on a :class:`.PlotWidget`.
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
-
- def __init__(self, plot, parent=None):
- super(XAxisLogarithmicAction, self).__init__(
- plot, icon='plot-xlog', text='X Log. scale',
- tooltip='Logarithmic x-axis when checked',
- triggered=self._actionTriggered,
- checkable=True, parent=parent)
- self.setChecked(plot.isXAxisLogarithmic())
- plot.sigSetXAxisLogarithmic.connect(self.setChecked)
-
- def _actionTriggered(self, checked=False):
- self.plot.setXAxisLogarithmic(checked)
-
-
-class YAxisLogarithmicAction(PlotAction):
- """QAction controlling Y axis log scale on a :class:`.PlotWidget`.
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
-
- def __init__(self, plot, parent=None):
- super(YAxisLogarithmicAction, self).__init__(
- plot, icon='plot-ylog', text='Y Log. scale',
- tooltip='Logarithmic y-axis when checked',
- triggered=self._actionTriggered,
- checkable=True, parent=parent)
- self.setChecked(plot.isYAxisLogarithmic())
- plot.sigSetYAxisLogarithmic.connect(self.setChecked)
-
- def _actionTriggered(self, checked=False):
- self.plot.setYAxisLogarithmic(checked)
-
-
-class GridAction(PlotAction):
- """QAction controlling grid mode on a :class:`.PlotWidget`.
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param str gridMode: The grid mode to use in 'both', 'major'.
- See :meth:`.PlotWidget.setGraphGrid`
- :param parent: See :class:`QAction`
- """
-
- def __init__(self, plot, gridMode='both', parent=None):
- assert gridMode in ('both', 'major')
- self._gridMode = gridMode
-
- super(GridAction, self).__init__(
- plot, icon='plot-grid', text='Grid',
- tooltip='Toggle grid (on/off)',
- triggered=self._actionTriggered,
- checkable=True, parent=parent)
- self.setChecked(plot.getGraphGrid() is not None)
- plot.sigSetGraphGrid.connect(self._gridChanged)
-
- def _gridChanged(self, which):
- """Slot listening for PlotWidget grid mode change."""
- self.setChecked(which != 'None')
-
- def _actionTriggered(self, checked=False):
- self.plot.setGraphGrid(self._gridMode if checked else None)
-
-
-class CurveStyleAction(PlotAction):
- """QAction controlling curve style on a :class:`.PlotWidget`.
-
- It changes the default line and markers style which updates all
- curves on the plot.
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
-
- def __init__(self, plot, parent=None):
- super(CurveStyleAction, self).__init__(
- plot, icon='plot-toggle-points', text='Curve style',
- tooltip='Change curve line and markers style',
- triggered=self._actionTriggered,
- checkable=False, parent=parent)
-
- def _actionTriggered(self, checked=False):
- currentState = (self.plot.isDefaultPlotLines(),
- self.plot.isDefaultPlotPoints())
-
- # line only, line and symbol, symbol only
- states = (True, False), (True, True), (False, True)
- newState = states[(states.index(currentState) + 1) % 3]
-
- self.plot.setDefaultPlotLines(newState[0])
- self.plot.setDefaultPlotPoints(newState[1])
-
-
-class ColormapAction(PlotAction):
- """QAction opening a ColormapDialog to update the colormap.
-
- Both the active image colormap and the default colormap are updated.
-
- :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(ColormapAction, self).__init__(
- plot, icon='colormap', text='Colormap',
- tooltip="Change colormap",
- triggered=self._actionTriggered,
- checkable=False, parent=parent)
-
- 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()
-
- 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
-
- else:
- # Set dialog from active image
- colormap = image.getColormap()
-
- data = image.getData(copy=False)
-
- goodData = data[numpy.isfinite(data)]
- if goodData.size > 0:
- dataMin = goodData.min()
- dataMax = goodData.max()
- else:
- qt.QMessageBox.warning(
- self, "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(**colormap)
-
- # Run the dialog listening to colormap change
- self._dialog.sigColormapChanged.connect(self._colormapChanged)
- result = self._dialog.exec_()
- self._dialog.sigColormapChanged.disconnect(self._colormapChanged)
-
- if not result: # Restore the previous colormap
- self._colormapChanged(colormap)
-
- def _colormapChanged(self, colormap):
- # Update default colormap
- self.plot.setDefaultColormap(colormap)
-
- # Update active image colormap
- activeImage = self.plot.getActiveImage()
- if isinstance(activeImage, items.ColormapMixIn):
- activeImage.setColormap(colormap)
-
-
-class KeepAspectRatioAction(PlotAction):
- """QAction controlling aspect ratio on a :class:`.PlotWidget`.
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
-
- def __init__(self, plot, parent=None):
- # Uses two images for checked/unchecked states
- self._states = {
- False: (icons.getQIcon('shape-circle-solid'),
- "Keep data aspect ratio"),
- True: (icons.getQIcon('shape-ellipse-solid'),
- "Do no keep data aspect ratio")
- }
-
- icon, tooltip = self._states[plot.isKeepDataAspectRatio()]
- super(KeepAspectRatioAction, self).__init__(
- plot,
- icon=icon,
- text='Toggle keep aspect ratio',
- tooltip=tooltip,
- triggered=self._actionTriggered,
- checkable=False,
- parent=parent)
- plot.sigSetKeepDataAspectRatio.connect(
- self._keepDataAspectRatioChanged)
-
- def _keepDataAspectRatioChanged(self, aspectRatio):
- """Handle Plot set keep aspect ratio signal"""
- icon, tooltip = self._states[aspectRatio]
- self.setIcon(icon)
- self.setToolTip(tooltip)
-
- def _actionTriggered(self, checked=False):
- # This will trigger _keepDataAspectRatioChanged
- self.plot.setKeepDataAspectRatio(not self.plot.isKeepDataAspectRatio())
-
-
-class YAxisInvertedAction(PlotAction):
- """QAction controlling Y orientation on a :class:`.PlotWidget`.
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
-
- def __init__(self, plot, parent=None):
- # Uses two images for checked/unchecked states
- self._states = {
- False: (icons.getQIcon('plot-ydown'),
- "Orient Y axis downward"),
- True: (icons.getQIcon('plot-yup'),
- "Orient Y axis upward"),
- }
-
- icon, tooltip = self._states[plot.isYAxisInverted()]
- super(YAxisInvertedAction, self).__init__(
- plot,
- icon=icon,
- text='Invert Y Axis',
- tooltip=tooltip,
- triggered=self._actionTriggered,
- checkable=False,
- parent=parent)
- plot.sigSetYAxisInverted.connect(self._yAxisInvertedChanged)
-
- def _yAxisInvertedChanged(self, inverted):
- """Handle Plot set y axis inverted signal"""
- icon, tooltip = self._states[inverted]
- self.setIcon(icon)
- self.setToolTip(tooltip)
-
- def _actionTriggered(self, checked=False):
- # This will trigger _yAxisInvertedChanged
- self.plot.setYAxisInverted(not self.plot.isYAxisInverted())
-
-
-class SaveAction(PlotAction):
- """QAction for saving Plot content.
-
- It opens a Save as... dialog.
-
- :param plot: :class:`.PlotWidget` instance on which to operate.
- :param parent: See :class:`QAction`.
- """
- # TODO find a way to make the filter list selectable and extensible
-
- SNAPSHOT_FILTER_SVG = 'Plot Snapshot as SVG (*.svg)'
-
- SNAPSHOT_FILTERS = ('Plot Snapshot as PNG (*.png)',
- 'Plot Snapshot as JPEG (*.jpg)',
- SNAPSHOT_FILTER_SVG)
-
- # Dict of curve filters with CSV-like format
- # Using ordered dict to guarantee filters order
- # Note: '%.18e' is numpy.savetxt default format
- CURVE_FILTERS_TXT = OrderedDict((
- ('Curve as Raw ASCII (*.txt)',
- {'fmt': '%.18e', 'delimiter': ' ', 'header': False}),
- ('Curve as ";"-separated CSV (*.csv)',
- {'fmt': '%.18e', 'delimiter': ';', 'header': True}),
- ('Curve as ","-separated CSV (*.csv)',
- {'fmt': '%.18e', 'delimiter': ',', 'header': True}),
- ('Curve as tab-separated CSV (*.csv)',
- {'fmt': '%.18e', 'delimiter': '\t', 'header': True}),
- ('Curve as OMNIC CSV (*.csv)',
- {'fmt': '%.7E', 'delimiter': ',', 'header': False}),
- ('Curve as SpecFile (*.dat)',
- {'fmt': '%.7g', 'delimiter': '', 'header': False})
- ))
-
- CURVE_FILTER_NPY = 'Curve as NumPy binary file (*.npy)'
-
- CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [CURVE_FILTER_NPY]
-
- ALL_CURVES_FILTERS = ("All curves as SpecFile (*.dat)", )
-
- IMAGE_FILTER_EDF = 'Image data as EDF (*.edf)'
- IMAGE_FILTER_TIFF = 'Image data as TIFF (*.tif)'
- IMAGE_FILTER_NUMPY = 'Image data as NumPy binary file (*.npy)'
- IMAGE_FILTER_ASCII = 'Image data as ASCII (*.dat)'
- IMAGE_FILTER_CSV_COMMA = 'Image data as ,-separated CSV (*.csv)'
- IMAGE_FILTER_CSV_SEMICOLON = 'Image data as ;-separated CSV (*.csv)'
- 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_FILTERS = (IMAGE_FILTER_EDF,
- IMAGE_FILTER_TIFF,
- IMAGE_FILTER_NUMPY,
- IMAGE_FILTER_ASCII,
- IMAGE_FILTER_CSV_COMMA,
- IMAGE_FILTER_CSV_SEMICOLON,
- IMAGE_FILTER_CSV_TAB,
- IMAGE_FILTER_RGB_PNG,
- IMAGE_FILTER_RGB_TIFF)
-
- def __init__(self, plot, parent=None):
- super(SaveAction, self).__init__(
- plot, icon='document-save', text='Save as...',
- tooltip='Save curve/image/plot snapshot dialog',
- triggered=self._actionTriggered,
- checkable=False, parent=parent)
- self.setShortcut(qt.QKeySequence.Save)
- self.setShortcutContext(qt.Qt.WidgetShortcut)
-
- def _errorMessage(self, informativeText=''):
- """Display an error message."""
- # TODO issue with QMessageBox size fixed and too small
- msg = qt.QMessageBox(self.plot)
- msg.setIcon(qt.QMessageBox.Critical)
- msg.setInformativeText(informativeText + ' ' + str(sys.exc_info()[1]))
- msg.setDetailedText(traceback.format_exc())
- msg.exec_()
-
- def _saveSnapshot(self, filename, nameFilter):
- """Save a snapshot of the :class:`PlotWindow` widget.
-
- :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 == self.SNAPSHOT_FILTER_SVG:
- self.plot.saveGraph(filename, fileFormat='svg')
-
- else:
- if hasattr(qt.QPixmap, "grabWidget"):
- # Qt 4
- pixmap = qt.QPixmap.grabWidget(self.plot.getWidgetHandle())
- else:
- # Qt 5
- pixmap = self.plot.getWidgetHandle().grab()
- if not pixmap.save(filename):
- self._errorMessage()
- return False
- return True
-
- def _saveCurve(self, filename, nameFilter):
- """Save a curve 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.CURVE_FILTERS:
- return False
-
- # Check if a curve is to be saved
- curve = self.plot.getActiveCurve()
- # before calling _saveCurve, if there is no selected curve, we
- # make sure there is only one curve on the graph
- if curve is None:
- curves = self.plot.getAllCurves()
- if not curves:
- self._errorMessage("No curve to be saved")
- return False
- curve = curves[0]
-
- if nameFilter in self.CURVE_FILTERS_TXT:
- filter_ = self.CURVE_FILTERS_TXT[nameFilter]
- fmt = filter_['fmt']
- csvdelim = filter_['delimiter']
- autoheader = filter_['header']
- else:
- # .npy
- fmt, csvdelim, autoheader = ("", "", False)
-
- # If curve has no associated label, get the default from the plot
- xlabel = curve.getXLabel()
- if xlabel is None:
- xlabel = self.plot.getGraphXLabel()
- ylabel = curve.getYLabel()
- if ylabel is None:
- ylabel = self.plot.getGraphYLabel()
-
- try:
- save1D(filename,
- curve.getXData(copy=False),
- curve.getYData(copy=False),
- xlabel, [ylabel],
- fmt=fmt, csvdelim=csvdelim,
- autoheader=autoheader)
- except IOError:
- self._errorMessage('Save failed\n')
- return False
-
- return True
-
- def _saveCurves(self, filename, nameFilter):
- """Save all curves 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.ALL_CURVES_FILTERS:
- return False
-
- curves = self.plot.getAllCurves()
- if not curves:
- self._errorMessage("No curves to be saved")
- return False
-
- curve = curves[0]
- scanno = 1
- try:
- specfile = savespec(filename,
- curve.getXData(copy=False),
- curve.getYData(copy=False),
- curve.getXLabel(),
- curve.getYLabel(),
- fmt="%.7g", scan_number=1, mode="w",
- write_file_header=True,
- close_file=False)
- except IOError:
- self._errorMessage('Save failed\n')
- return False
-
- for curve in curves[1:]:
- try:
- scanno += 1
- specfile = savespec(specfile,
- curve.getXData(copy=False),
- curve.getYData(copy=False),
- curve.getXLabel(),
- curve.getYLabel(),
- fmt="%.7g", scan_number=scanno, mode="w",
- write_file_header=False,
- close_file=False)
- except IOError:
- self._errorMessage('Save failed\n')
- return False
- specfile.close()
-
- return True
-
- def _saveImage(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.IMAGE_FILTERS:
- return False
-
- image = self.plot.getActiveImage()
- if image is None:
- qt.QMessageBox.warning(
- self.plot, "No Data", "No image to be saved")
- return False
-
- data = image.getData(copy=False)
-
- # TODO Use silx.io for writing files
- if nameFilter == self.IMAGE_FILTER_EDF:
- edfFile = EdfFile(filename, access="w+")
- edfFile.WriteImage({}, data, Append=0)
- return True
-
- elif nameFilter == self.IMAGE_FILTER_TIFF:
- tiffFile = TiffIO(filename, mode='w')
- tiffFile.writeImage(data, software='silx')
- return True
-
- elif nameFilter == self.IMAGE_FILTER_NUMPY:
- try:
- numpy.save(filename, data)
- except IOError:
- self._errorMessage('Save failed\n')
- return False
- return True
-
- elif nameFilter in (self.IMAGE_FILTER_ASCII,
- self.IMAGE_FILTER_CSV_COMMA,
- self.IMAGE_FILTER_CSV_SEMICOLON,
- self.IMAGE_FILTER_CSV_TAB):
- csvdelim, filetype = {
- self.IMAGE_FILTER_ASCII: (' ', 'txt'),
- self.IMAGE_FILTER_CSV_COMMA: (',', 'csv'),
- self.IMAGE_FILTER_CSV_SEMICOLON: (';', 'csv'),
- self.IMAGE_FILTER_CSV_TAB: ('\t', 'csv'),
- }[nameFilter]
-
- height, width = data.shape
- rows, cols = numpy.mgrid[0:height, 0:width]
- try:
- save1D(filename, rows.ravel(), (cols.ravel(), data.ravel()),
- filetype=filetype,
- xlabel='row',
- ylabels=['column', 'value'],
- csvdelim=csvdelim,
- autoheader=True)
-
- except IOError:
- self._errorMessage('Save failed\n')
- return False
- return True
-
- elif nameFilter in (self.IMAGE_FILTER_RGB_PNG,
- self.IMAGE_FILTER_RGB_TIFF):
- # Get displayed image
- rgbaImage = image.getRbgaImageData(copy=False)
- # Convert RGB QImage
- qimage = convertArrayToQImage(rgbaImage[:, :, :3])
-
- if nameFilter == self.IMAGE_FILTER_RGB_PNG:
- fileFormat = 'PNG'
- else:
- fileFormat = 'TIFF'
-
- if qimage.save(filename, fileFormat):
- return True
- else:
- _logger.error('Failed to save image as %s', filename)
- qt.QMessageBox.critical(
- self.parent(),
- 'Save image as',
- 'Failed to save image')
-
- return False
-
- def _actionTriggered(self, checked=False):
- """Handle save action."""
- # Set-up filters
- filters = []
-
- # Add image filters if there is an active image
- if self.plot.getActiveImage() is not None:
- filters.extend(self.IMAGE_FILTERS)
-
- # Add curve filters if there is a curve to save
- if (self.plot.getActiveCurve() is not None or
- len(self.plot.getAllCurves()) == 1):
- filters.extend(self.CURVE_FILTERS)
- if len(self.plot.getAllCurves()) > 1:
- filters.extend(self.ALL_CURVES_FILTERS)
-
- filters.extend(self.SNAPSHOT_FILTERS)
-
- # Create and run File dialog
- dialog = qt.QFileDialog(self.plot)
- dialog.setWindowTitle("Output File Selection")
- dialog.setModal(1)
- dialog.setNameFilters(filters)
-
- dialog.setFileMode(dialog.AnyFile)
- dialog.setAcceptMode(dialog.AcceptSave)
-
- if not dialog.exec_():
- return False
-
- nameFilter = dialog.selectedNameFilter()
- filename = dialog.selectedFiles()[0]
- 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
-
- # Handle save
- if nameFilter in self.SNAPSHOT_FILTERS:
- return self._saveSnapshot(filename, nameFilter)
- elif nameFilter in self.CURVE_FILTERS:
- return self._saveCurve(filename, nameFilter)
- elif nameFilter in self.ALL_CURVES_FILTERS:
- return self._saveCurves(filename, nameFilter)
- elif nameFilter in self.IMAGE_FILTERS:
- return self._saveImage(filename, nameFilter)
- else:
- _logger.warning('Unsupported file filter: %s', nameFilter)
- return False
-
-
-def _plotAsPNG(plot):
- """Save a :class:`Plot` as PNG and return the payload.
-
- :param plot: The :class:`Plot` to save
- """
- pngFile = BytesIO()
- plot.saveGraph(pngFile, fileFormat='png')
- pngFile.flush()
- pngFile.seek(0)
- data = pngFile.read()
- pngFile.close()
- return data
-
-
-class PrintAction(PlotAction):
- """QAction for printing the plot.
-
- It opens a Print dialog.
-
- Current implementation print a bitmap of the plot area and not vector
- graphics, so printing quality is not great.
-
- :param plot: :class:`.PlotWidget` instance on which to operate.
- :param parent: See :class:`QAction`.
- """
-
- # Share QPrinter instance to propose latest used as default
- _printer = None
-
- def __init__(self, plot, parent=None):
- super(PrintAction, self).__init__(
- plot, icon='document-print', text='Print...',
- tooltip='Open print dialog',
- triggered=self.printPlot,
- checkable=False, parent=parent)
- self.setShortcut(qt.QKeySequence.Print)
- self.setShortcutContext(qt.Qt.WidgetShortcut)
-
- @property
- def printer(self):
- """The QPrinter instance used by the actions.
-
- This is shared accross all instances of PrintAct
- """
- if self._printer is None:
- PrintAction._printer = qt.QPrinter()
- return self._printer
-
- def printPlotAsWidget(self):
- """Open the print dialog and print the plot.
-
- Use :meth:`QWidget.render` to print the plot
-
- :return: True if successful
- """
- dialog = qt.QPrintDialog(self.printer, self.plot)
- dialog.setWindowTitle('Print Plot')
- if not dialog.exec_():
- return False
-
- # Print a snapshot of the plot widget at the top of the page
- widget = self.plot.centralWidget()
-
- painter = qt.QPainter()
- if not painter.begin(self.printer):
- return False
-
- pageRect = self.printer.pageRect()
- xScale = pageRect.width() / widget.width()
- yScale = pageRect.height() / widget.height()
- scale = min(xScale, yScale)
-
- painter.translate(pageRect.width() / 2., 0.)
- painter.scale(scale, scale)
- painter.translate(-widget.width() / 2., 0.)
- widget.render(painter)
- painter.end()
-
- return True
-
- def printPlot(self):
- """Open the print dialog and print the plot.
-
- Use :meth:`Plot.saveGraph` to print the plot.
-
- :return: True if successful
- """
- # Init printer and start printer dialog
- dialog = qt.QPrintDialog(self.printer, self.plot)
- dialog.setWindowTitle('Print Plot')
- if not dialog.exec_():
- return False
-
- # Save Plot as PNG and make a pixmap from it with default dpi
- pngData = _plotAsPNG(self.plot)
-
- pixmap = qt.QPixmap()
- pixmap.loadFromData(pngData, 'png')
-
- xScale = self.printer.pageRect().width() / pixmap.width()
- yScale = self.printer.pageRect().height() / pixmap.height()
- scale = min(xScale, yScale)
-
- # Draw pixmap with painter
- painter = qt.QPainter()
- if not painter.begin(self.printer):
- return False
-
- painter.drawPixmap(0, 0,
- pixmap.width() * scale,
- pixmap.height() * scale,
- pixmap)
- painter.end()
-
- return True
-
-
-class CopyAction(PlotAction):
- """QAction to copy :class:`.PlotWidget` content to clipboard.
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
-
- def __init__(self, plot, parent=None):
- super(CopyAction, self).__init__(
- plot, icon='edit-copy', text='Copy plot',
- tooltip='Copy a snapshot of the plot into the clipboard',
- triggered=self.copyPlot,
- checkable=False, parent=parent)
- self.setShortcut(qt.QKeySequence.Copy)
- self.setShortcutContext(qt.Qt.WidgetShortcut)
-
- def copyPlot(self):
- """Copy plot content to the clipboard as a bitmap."""
- # Save Plot as PNG and make a QImage from it with default dpi
- pngData = _plotAsPNG(self.plot)
- image = qt.QImage.fromData(pngData, 'png')
- qt.QApplication.clipboard().setImage(image)
-
-
-class CrosshairAction(PlotAction):
- """QAction toggling crosshair cursor on a :class:`.PlotWidget`.
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param str color: Color to use to draw the crosshair
- :param int linewidth: Width of the crosshair cursor
- :param str linestyle: Style of line. See :meth:`.Plot.setGraphCursor`
- :param parent: See :class:`QAction`
- """
-
- def __init__(self, plot, color='black', linewidth=1, linestyle='-',
- parent=None):
- self.color = color
- """Color used to draw the crosshair (str)."""
-
- self.linewidth = linewidth
- """Width of the crosshair cursor (int)."""
-
- self.linestyle = linestyle
- """Style of line of the cursor (str)."""
-
- super(CrosshairAction, self).__init__(
- plot, icon='crosshair', text='Crosshair Cursor',
- tooltip='Enable crosshair cursor when checked',
- triggered=self._actionTriggered,
- checkable=True, parent=parent)
- self.setChecked(plot.getGraphCursor() is not None)
- plot.sigSetGraphCursor.connect(self.setChecked)
-
- def _actionTriggered(self, checked=False):
- self.plot.setGraphCursor(checked,
- color=self.color,
- linestyle=self.linestyle,
- linewidth=self.linewidth)
-
-
-class PanWithArrowKeysAction(PlotAction):
- """QAction toggling pan with arrow keys on a :class:`.PlotWidget`.
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
-
- def __init__(self, plot, parent=None):
-
- super(PanWithArrowKeysAction, self).__init__(
- plot, icon='arrow-keys', text='Pan with arrow keys',
- tooltip='Enable pan with arrow keys when checked',
- triggered=self._actionTriggered,
- checkable=True, parent=parent)
- self.setChecked(plot.isPanWithArrowKeys())
- plot.sigSetPanWithArrowKeys.connect(self.setChecked)
-
- def _actionTriggered(self, checked=False):
- self.plot.setPanWithArrowKeys(checked)
-
-
-def _warningMessage(informativeText='', detailedText='', parent=None):
- """Display a popup warning message."""
- msg = qt.QMessageBox(parent)
- msg.setIcon(qt.QMessageBox.Warning)
- msg.setInformativeText(informativeText)
- msg.setDetailedText(detailedText)
- msg.exec_()
-
-
-def _getOneCurve(plt, mode="unique"):
- """Get a single curve from the plot.
- By default, get the active curve if any, else if a single curve is plotted
- get it, else return None and display a warning popup.
-
- This behavior can be adjusted by modifying the *mode* parameter: always
- return the active curve if any, but adjust the behavior in case no curve
- is active.
-
- :param plt: :class:`.PlotWidget` instance on which to operate
- :param mode: Parameter defining the behavior when no curve is active.
- Possible modes:
- - "none": return None (enforce curve activation)
- - "unique": return the unique curve or None if multiple curves
- - "first": return first curve
- - "last": return last curve (most recently added one)
- :return: return value of plt.getActiveCurve(), or plt.getAllCurves()[0],
- or plt.getAllCurves()[-1], or None
- """
- curve = plt.getActiveCurve()
- if curve is not None:
- return curve
-
- if mode is None or mode.lower() == "none":
- _warningMessage("You must activate a curve!",
- parent=plt)
- return None
-
- curves = plt.getAllCurves()
- if len(curves) == 0:
- _warningMessage("No curve on this plot.",
- parent=plt)
- return None
-
- if len(curves) == 1:
- return curves[0]
-
- if len(curves) > 1:
- if mode == "unique":
- _warningMessage("Multiple curves are plotted. " +
- "Please activate the one you want to use.",
- parent=plt)
- return None
- if mode.lower() == "first":
- return curves[0]
- if mode.lower() == "last":
- return curves[-1]
-
- raise ValueError("Illegal value for parameter 'mode'." +
- " Allowed values: 'none', 'unique', 'first', 'last'.")
-
-
-class FitAction(PlotAction):
- """QAction to open a :class:`FitWidget` and set its data to the
- active curve if any, or to the first curve.
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
- def __init__(self, plot, parent=None):
- super(FitAction, self).__init__(
- plot, icon='math-fit', text='Fit curve',
- tooltip='Open a fit dialog',
- triggered=self._getFitWindow,
- checkable=False, parent=parent)
- self.fit_window = None
-
- def _getFitWindow(self):
- curve = _getOneCurve(self.plot)
- if curve is None:
- return
- self.xlabel = self.plot.getGraphXLabel()
- self.ylabel = self.plot.getGraphYLabel()
- self.x = curve.getXData(copy=False)
- self.y = curve.getYData(copy=False)
- self.legend = curve.getLegend()
- self.xmin, self.xmax = self.plot.getGraphXLimits()
-
- # open a window with a FitWidget
- if self.fit_window is None:
- self.fit_window = qt.QMainWindow()
- # import done here rather than at module level to avoid circular import
- # FitWidget -> BackgroundWidget -> PlotWindow -> PlotActions -> FitWidget
- from ..fit.FitWidget import FitWidget
- self.fit_widget = FitWidget(parent=self.fit_window)
- self.fit_window.setCentralWidget(
- self.fit_widget)
- self.fit_widget.guibuttons.DismissButton.clicked.connect(
- self.fit_window.close)
- self.fit_widget.sigFitWidgetSignal.connect(
- self.handle_signal)
- self.fit_window.show()
- else:
- if self.fit_window.isHidden():
- self.fit_window.show()
- self.fit_widget.show()
- self.fit_window.raise_()
-
- self.fit_widget.setData(self.x, self.y,
- xmin=self.xmin, xmax=self.xmax)
- self.fit_window.setWindowTitle(
- "Fitting " + self.legend +
- " on x range %f-%f" % (self.xmin, self.xmax))
-
- def handle_signal(self, ddict):
- x_fit = self.x[self.xmin <= self.x]
- x_fit = x_fit[x_fit <= self.xmax]
- fit_legend = "Fit <%s>" % self.legend
- fit_curve = self.plot.getCurve(fit_legend)
-
- if ddict["event"] == "FitFinished":
- y_fit = self.fit_widget.fitmanager.gendata()
- if fit_curve is None:
- self.plot.addCurve(x_fit, y_fit,
- fit_legend,
- xlabel=self.xlabel, ylabel=self.ylabel,
- resetzoom=False)
- else:
- fit_curve.setData(x_fit, y_fit)
- fit_curve.setVisible(True)
-
- if ddict["event"] in ["FitStarted", "FitFailed"]:
- if fit_curve is not None:
- fit_curve.setVisible(False)
-
-
-class PixelIntensitiesHistoAction(PlotAction):
- """QAction to plot the pixels intensities diagram
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
-
- def __init__(self, plot, parent=None):
- PlotAction.__init__(self,
- plot,
- icon='pixel-intensities',
- text='pixels intensity',
- tooltip='Compute image intensity distribution',
- triggered=self._triggered,
- parent=parent,
- checkable=True)
- self._plotHistogram = None
- self._connectedToActiveImage = False
- self._histo = None
-
- def _triggered(self, checked):
- """Update the plot of the histogram visibility status
-
- :param bool checked: status of the action button
- """
- if checked:
- if not self._connectedToActiveImage:
- self.plot.sigActiveImageChanged.connect(
- self._activeImageChanged)
- self._connectedToActiveImage = True
- self.computeIntensityDistribution()
-
- self.getHistogramPlotWidget().show()
-
- else:
- if self._connectedToActiveImage:
- self.plot.sigActiveImageChanged.disconnect(
- self._activeImageChanged)
- self._connectedToActiveImage = False
-
- self.getHistogramPlotWidget().hide()
-
- def _activeImageChanged(self, previous, legend):
- """Handle active image change: toggle enabled toolbar, update curve"""
- if self.isChecked():
- self.computeIntensityDistribution()
-
- def computeIntensityDistribution(self):
- """Get the active image and compute the image intensity distribution
- """
- activeImage = self.plot.getActiveImage()
-
- if activeImage is not None:
- image = activeImage.getData(copy=False)
- if image.ndim == 3: # RGB(A) images
- _logger.info('Converting current image from RGB(A) to grayscale\
- in order to compute the intensity distribution')
- image = (image[:, :, 0] * 0.299 +
- image[:, :, 1] * 0.587 +
- image[:, :, 2] * 0.114)
-
- xmin = numpy.nanmin(image)
- xmax = numpy.nanmax(image)
- nbins = min(1024, int(numpy.sqrt(image.size)))
- data_range = xmin, xmax
-
- # bad hack: get 256 bins in the case we have a B&W
- if numpy.issubdtype(image.dtype, numpy.integer):
- if nbins > xmax - xmin:
- nbins = xmax - xmin
-
- nbins = max(2, nbins)
-
- data = image.ravel().astype(numpy.float32)
- histogram = Histogramnd(data, n_bins=nbins, histo_range=data_range)
- assert len(histogram.edges) == 1
- self._histo = histogram.histo
- edges = histogram.edges[0]
- plot = self.getHistogramPlotWidget()
- plot.addHistogram(histogram=self._histo,
- edges=edges,
- legend='pixel intensity',
- fill=True,
- color='red')
- plot.resetZoom()
-
- def eventFilter(self, qobject, event):
- """Observe when the close event is emitted then
- simply uncheck the action button
-
- :param qobject: the object observe
- :param event: the event received by qobject
- """
- if event.type() == qt.QEvent.Close:
- if self._plotHistogram is not None:
- self._plotHistogram.hide()
- self.setChecked(False)
-
- return PlotAction.eventFilter(self, qobject, event)
-
- def getHistogramPlotWidget(self):
- """Create the plot histogram if needed, otherwise create it
-
- :return: the PlotWidget showing the histogram of the pixel intensities
- """
- from silx.gui.plot.PlotWindow import Plot1D
- if self._plotHistogram is None:
- self._plotHistogram = Plot1D(parent=self.plot)
- self._plotHistogram.setWindowFlags(qt.Qt.Window)
- self._plotHistogram.setWindowTitle('Image Intensity Histogram')
- self._plotHistogram.installEventFilter(self)
- self._plotHistogram.setGraphXLabel("Value")
- self._plotHistogram.setGraphYLabel("Count")
-
- return self._plotHistogram
-
- def getHistogram(self):
- """Return the last computed histogram
-
- :return: the histogram displayed in the HistogramPlotWiget
- """
- return self._histo
-
-
-class MedianFilterAction(PlotAction):
- """QAction to plot the pixels intensities diagram
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
-
- def __init__(self, plot, parent=None):
- PlotAction.__init__(self,
- plot,
- icon='median-filter',
- text='median filter',
- tooltip='Apply a median filter on the image',
- triggered=self._triggered,
- parent=parent)
- self._originalImage = None
- self._legend = None
- self._filteredImage = None
- self._popup = MedianFilterDialog(parent=None)
- self._popup.sigFilterOptChanged.connect(self._updateFilter)
- self.plot.sigActiveImageChanged.connect( self._updateActiveImage)
- self._updateActiveImage()
-
- def _triggered(self, checked):
- """Update the plot of the histogram visibility status
-
- :param bool checked: status of the action button
- """
- self._popup.show()
-
- def _updateActiveImage(self):
- """Set _activeImageLegend and _originalImage from the active image"""
- self._activeImageLegend = self.plot.getActiveImage(just_legend=True)
- if self._activeImageLegend is None:
- self._originalImage = None
- self._legend = None
- else:
- self._originalImage = self.plot.getImage(self._activeImageLegend).getData(copy=False)
- self._legend = self.plot.getImage(self._activeImageLegend).getLegend()
-
- def _updateFilter(self, kernelWidth, conditional=False):
- if self._originalImage is None:
- return
-
- self.plot.sigActiveImageChanged.disconnect(self._updateActiveImage)
- filteredImage = self._computeFilteredImage(kernelWidth, conditional)
- self.plot.addImage(data=filteredImage,
- legend=self._legend,
- replace=True)
- self.plot.sigActiveImageChanged.connect(self._updateActiveImage)
-
- def _computeFilteredImage(self, kernelWidth, conditional):
- raise NotImplemented('MedianFilterAction is a an abstract class')
-
- def getFilteredImage(self):
- """
- :return: the image with the median filter apply on"""
- return self._filteredImage
-
-
-class MedianFilter1DAction(MedianFilterAction):
- """Define the MedianFilterAction for 1D
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
- def __init__(self, plot, parent=None):
- MedianFilterAction.__init__(self,
- plot,
- parent=parent)
-
- def _computeFilteredImage(self, kernelWidth, conditional):
- assert(self.plot is not None)
- return medfilt2d(self._originalImage,
- (kernelWidth, 1),
- conditional)
-
-
-class MedianFilter2DAction(MedianFilterAction):
- """Define the MedianFilterAction for 2D
-
- :param plot: :class:`.PlotWidget` instance on which to operate
- :param parent: See :class:`QAction`
- """
- def __init__(self, plot, parent=None):
- MedianFilterAction.__init__(self,
- plot,
- parent=parent)
-
- def _computeFilteredImage(self, kernelWidth, conditional):
- assert(self.plot is not None)
- return medfilt2d(self._originalImage,
- (kernelWidth, kernelWidth),
- conditional)
+__date__ = "01/06/2017"
+
+from silx.utils.deprecation import deprecated_warning
+
+deprecated_warning(type_='module',
+ name=__file__,
+ reason='PlotActions refactoring',
+ replacement='plot.actions',
+ since_version='0.6')
+
+from .actions import PlotAction
+
+from .actions.io import CopyAction
+from .actions.io import PrintAction
+from .actions.io import SaveAction
+
+from .actions.control import ColormapAction
+from .actions.control import CrosshairAction
+from .actions.control import CurveStyleAction
+from .actions.control import GridAction
+from .actions.control import KeepAspectRatioAction
+from .actions.control import PanWithArrowKeysAction
+from .actions.control import ResetZoomAction
+from .actions.control import XAxisAutoScaleAction
+from .actions.control import XAxisLogarithmicAction
+from .actions.control import YAxisAutoScaleAction
+from .actions.control import YAxisLogarithmicAction
+from .actions.control import YAxisInvertedAction
+from .actions.control import ZoomInAction
+from .actions.control import ZoomOutAction
+
+from .actions.medfilt import MedianFilter1DAction
+from .actions.medfilt import MedianFilter2DAction
+from .actions.medfilt import MedianFilterAction
+
+from .actions.histogram import PixelIntensitiesHistoAction
+
+from .actions.fit import FitAction
diff --git a/silx/gui/plot/PlotInteraction.py b/silx/gui/plot/PlotInteraction.py
index fbc9c1f..865073b 100644
--- a/silx/gui/plot/PlotInteraction.py
+++ b/silx/gui/plot/PlotInteraction.py
@@ -26,7 +26,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "24/01/2017"
+__date__ = "27/06/2017"
import math
@@ -153,11 +153,11 @@ class Pan(_ZoomOnWheel):
xData, yData, y2Data = self._pixelToData(x, y)
lastX, lastY, lastY2 = self._previousDataPos
- xMin, xMax = self.plot.getGraphXLimits()
- yMin, yMax = self.plot.getGraphYLimits(axis='left')
- y2Min, y2Max = self.plot.getGraphYLimits(axis='right')
+ xMin, xMax = self.plot.getXAxis().getLimits()
+ yMin, yMax = self.plot.getYAxis().getLimits()
+ y2Min, y2Max = self.plot.getYAxis(axis='right').getLimits()
- if self.plot.isXAxisLogarithmic():
+ if self.plot.getXAxis()._isLogarithmic():
try:
dx = math.log10(xData) - math.log10(lastX)
newXMin = pow(10., (math.log10(xMin) - dx))
@@ -176,7 +176,7 @@ class Pan(_ZoomOnWheel):
if newXMin < FLOAT32_SAFE_MIN or newXMax > FLOAT32_SAFE_MAX:
newXMin, newXMax = xMin, xMax
- if self.plot.isYAxisLogarithmic():
+ if self.plot.getYAxis()._isLogarithmic():
try:
dy = math.log10(yData) - math.log10(lastY)
newYMin = pow(10., math.log10(yMin) - dy)
@@ -233,10 +233,10 @@ class Zoom(_ZoomOnWheel):
def __init__(self, plot, color):
self.color = color
- self.zoomStack = []
self._lastClick = 0., None
super(Zoom, self).__init__(plot)
+ self.plot.getLimitsHistory().clear()
def _areaWithAspectRatio(self, x0, y0, x1, y1):
_plotLeft, _plotTop, plotW, plotH = self.plot.getPlotBoundsInPixels()
@@ -286,25 +286,14 @@ class Zoom(_ZoomOnWheel):
self._lastClick = time.time(), (dataPos[0], dataPos[1], x, y)
- # Zoom-in centered on mouse cursor
- # xMin, xMax = self.plot.getGraphXLimits()
- # yMin, yMax = self.plot.getGraphYLimits()
- # y2Min, y2Max = self.plot.getGraphYLimits(axis="right")
- # self.zoomStack.append((xMin, xMax, yMin, yMax, y2Min, y2Max))
- # self._zoom(x, y, 2)
elif btn == RIGHT_BTN:
- try:
- xMin, xMax, yMin, yMax, y2Min, y2Max = self.zoomStack.pop()
- except IndexError:
- # Signal mouse clicked event
- dataPos = self.plot.pixelToData(x, y)
- assert dataPos is not None
- eventDict = prepareMouseSignal('mouseClicked', 'right',
- dataPos[0], dataPos[1],
- x, y)
- self.plot.notify(**eventDict)
- else:
- self.plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max)
+ # Signal mouse clicked event
+ dataPos = self.plot.pixelToData(x, y)
+ assert dataPos is not None
+ eventDict = prepareMouseSignal('mouseClicked', 'right',
+ dataPos[0], dataPos[1],
+ x, y)
+ self.plot.notify(**eventDict)
def beginDrag(self, x, y):
dataPos = self.plot.pixelToData(x, y)
@@ -354,10 +343,7 @@ class Zoom(_ZoomOnWheel):
if x0 != x1 or y0 != y1: # Avoid empty zoom area
# Store current zoom state in stack
- xMin, xMax = self.plot.getGraphXLimits()
- yMin, yMax = self.plot.getGraphYLimits()
- y2Min, y2Max = self.plot.getGraphYLimits(axis="right")
- self.zoomStack.append((xMin, xMax, yMin, yMax, y2Min, y2Max))
+ self.plot.getLimitsHistory().push()
if self.plot.isKeepDataAspectRatio():
x0, y0, x1, y1 = self._areaWithAspectRatio(x0, y0, x1, y1)
@@ -510,33 +496,6 @@ class SelectPolygon(Select):
self.points[-1] = dataPos
return True
-
- elif btn == RIGHT_BTN:
- self.machine.resetSelectionArea()
-
- firstPos = self.machine.plot.dataToPixel(*self._firstPos,
- check=False)
- dx, dy = abs(firstPos[0] - x), abs(firstPos[1] - y)
-
- if (dx < self.machine.DRAG_THRESHOLD_DIST and
- dy < self.machine.DRAG_THRESHOLD_DIST):
- self.points[-1] = self.points[0]
- else:
- dataPos = self.machine.plot.pixelToData(x, y)
- assert dataPos is not None
- self.points[-1] = dataPos
- if self.points[-2] == self.points[-1]:
- self.points.pop()
- self.points.append(self.points[0])
-
- eventDict = prepareDrawingSignal('drawingFinished',
- 'polygon',
- self.points,
- self.machine.parameters)
- self.machine.plot.notify(**eventDict)
- self.goto('idle')
- return False
-
return False
def onMove(self, x, y):
@@ -1100,14 +1059,19 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
elif picked[0] == 'curve':
curve = picked[1]
+ indices = picked[2]
dataPos = self.plot.pixelToData(x, y)
assert dataPos is not None
+ xData = curve.getXData(copy=False)
+ yData = curve.getYData(copy=False)
+
eventDict = prepareCurveSignal('left',
curve.getLegend(),
'curve',
- picked[2], picked[3],
+ xData[indices],
+ yData[indices],
dataPos[0], dataPos[1],
x, y)
return eventDict
@@ -1123,7 +1087,6 @@ class ItemsInteraction(ClickOrDrag, _PlotInteraction):
scale = image.getScale()
column = int((dataPos[0] - origin[0]) / float(scale[0]))
row = int((dataPos[1] - origin[1]) / float(scale[1]))
-
eventDict = prepareImageSignal('left',
image.getLegend(),
'image',
diff --git a/silx/gui/plot/PlotToolButtons.py b/silx/gui/plot/PlotToolButtons.py
index 8042391..430489d 100644
--- a/silx/gui/plot/PlotToolButtons.py
+++ b/silx/gui/plot/PlotToolButtons.py
@@ -34,7 +34,7 @@ The following QToolButton are available:
__authors__ = ["V. Valls", "H. Payno"]
__license__ = "MIT"
-__date__ = "26/01/2017"
+__date__ = "27/06/2017"
import logging
@@ -197,25 +197,26 @@ class YAxisOriginToolButton(PlotToolButton):
return qt.QAction(icon, text, self)
def _connectPlot(self, plot):
- plot.sigSetYAxisInverted.connect(self._yAxisInvertedChanged)
- self._yAxisInvertedChanged(plot.isYAxisInverted())
+ yAxis = plot.getYAxis()
+ yAxis.sigInvertedChanged.connect(self._yAxisInvertedChanged)
+ self._yAxisInvertedChanged(yAxis.isInverted())
def _disconnectPlot(self, plot):
- plot.sigSetYAxisInverted.disconnect(self._yAxisInvertedChanged)
+ plot.getYAxis().sigInvertedChanged.disconnect(self._yAxisInvertedChanged)
def setYAxisUpward(self):
"""Configure the plot to use y-axis upward"""
plot = self.plot()
if plot is not None:
# This will trigger _yAxisInvertedChanged
- plot.setYAxisInverted(False)
+ plot.getYAxis().setInverted(False)
def setYAxisDownward(self):
"""Configure the plot to use y-axis downward"""
plot = self.plot()
if plot is not None:
# This will trigger _yAxisInvertedChanged
- plot.setYAxisInverted(True)
+ plot.getYAxis().setInverted(True)
def _yAxisInvertedChanged(self, inverted):
"""Handle Plot set y axis inverted signal"""
diff --git a/silx/gui/plot/PlotTools.py b/silx/gui/plot/PlotTools.py
index 7158d0e..85dcc31 100644
--- a/silx/gui/plot/PlotTools.py
+++ b/silx/gui/plot/PlotTools.py
@@ -29,7 +29,7 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "03/03/2017"
+__date__ = "02/10/2017"
import logging
@@ -40,9 +40,9 @@ import weakref
import numpy
from .. import qt
+from silx.gui.widgets.FloatEdit import FloatEdit
_logger = logging.getLogger(__name__)
-_logger.setLevel(logging.DEBUG)
# PositionInfo ################################################################
@@ -150,56 +150,70 @@ class PositionInfo(qt.QWidget):
:param dict event: Plot event
"""
if event['event'] == 'mouseMoved':
- x, y = event['x'], event['y'] # Position in data
- styleSheet = "color: rgb(0, 0, 0);" # Default style
-
- if self.autoSnapToActiveCurve and self.plot.getGraphCursor():
- # Check if near active curve with symbols.
-
- styleSheet = "color: rgb(255, 0, 0);" # Style far from curve
-
- activeCurve = self.plot.getActiveCurve()
- if activeCurve:
- xData = activeCurve.getXData(copy=False)
- yData = activeCurve.getYData(copy=False)
- if activeCurve.getSymbol(): # Only handled if symbols on curve
- closestIndex = numpy.argmin(
- pow(xData - x, 2) + pow(yData - y, 2))
-
- xClosest = xData[closestIndex]
- yClosest = yData[closestIndex]
-
- closestInPixels = self.plot.dataToPixel(
- xClosest, yClosest, axis=activeCurve.getYAxis())
- if closestInPixels is not None:
- xPixel, yPixel = event['xpixel'], event['ypixel']
-
- if (abs(closestInPixels[0] - xPixel) < 5 and
- abs(closestInPixels[1] - yPixel) < 5):
- # Update label style sheet
- styleSheet = "color: rgb(0, 0, 0);"
-
- # if close enough, wrap to data point coords
- x, y = xClosest, yClosest
-
- for label, name, func in self._fields:
- label.setStyleSheet(styleSheet)
-
- try:
- value = func(x, y)
- except:
- label.setText('Error')
- _logger.error(
- "Error while converting coordinates (%f, %f)"
- "with converter '%s'" % (x, y, name))
- _logger.error(traceback.format_exc())
- else:
- if isinstance(value, numbers.Real):
- value = '%.7g' % value # Use this for floats and int
- else:
- value = str(value) # Fallback for other types
- label.setText(value)
+ x, y = event['x'], event['y']
+ self._updateStatusBar(x, y)
+ def _updateStatusBar(self, x, y):
+ """Update information from the status bar using the definitions.
+
+ :param float x: Position-x in data
+ :param float y: Position-y in data
+ """
+ styleSheet = "color: rgb(0, 0, 0);" # Default style
+
+ if self.autoSnapToActiveCurve and self.plot.getGraphCursor():
+ # Check if near active curve with symbols.
+
+ styleSheet = "color: rgb(255, 0, 0);" # Style far from curve
+
+ activeCurve = self.plot.getActiveCurve()
+ if activeCurve:
+ xData = activeCurve.getXData(copy=False)
+ yData = activeCurve.getYData(copy=False)
+ if activeCurve.getSymbol(): # Only handled if symbols on curve
+ closestIndex = numpy.argmin(
+ pow(xData - x, 2) + pow(yData - y, 2))
+
+ xClosest = xData[closestIndex]
+ yClosest = yData[closestIndex]
+
+ closestInPixels = self.plot.dataToPixel(
+ xClosest, yClosest, axis=activeCurve.getYAxis())
+ if closestInPixels is not None:
+ xPixel, yPixel = event['xpixel'], event['ypixel']
+
+ if (abs(closestInPixels[0] - xPixel) < 5 and
+ abs(closestInPixels[1] - yPixel) < 5):
+ # Update label style sheet
+ styleSheet = "color: rgb(0, 0, 0);"
+
+ # if close enough, wrap to data point coords
+ x, y = xClosest, yClosest
+
+ for label, name, func in self._fields:
+ label.setStyleSheet(styleSheet)
+
+ try:
+ value = func(x, y)
+ text = self.valueToString(value)
+ label.setText(text)
+ except:
+ label.setText('Error')
+ _logger.error(
+ "Error while converting coordinates (%f, %f)"
+ "with converter '%s'" % (x, y, name))
+ _logger.error(traceback.format_exc())
+
+ def valueToString(self, value):
+ if isinstance(value, (tuple, list)):
+ value = [self.valueToString(v) for v in value]
+ return ", ".join(value)
+ elif isinstance(value, numbers.Real):
+ # Use this for floats and int
+ return '%.7g' % value
+ else:
+ # Fallback for other types
+ return str(value)
# LimitsToolBar ##############################################################
@@ -226,22 +240,6 @@ class LimitsToolBar(qt.QToolBar):
:param str title: See :class:`QToolBar`.
"""
- class _FloatEdit(qt.QLineEdit):
- """Field to edit a float value."""
- def __init__(self, value=None, *args, **kwargs):
- qt.QLineEdit.__init__(self, *args, **kwargs)
- self.setValidator(qt.QDoubleValidator())
- self.setFixedWidth(100)
- self.setAlignment(qt.Qt.AlignLeft)
- if value is not None:
- self.setValue(value)
-
- def value(self):
- return float(self.text())
-
- def setValue(self, value):
- self.setText('%g' % value)
-
def __init__(self, parent=None, plot=None, title='Limits'):
super(LimitsToolBar, self).__init__(title, parent)
assert plot is not None
@@ -257,28 +255,28 @@ class LimitsToolBar(qt.QToolBar):
def _initWidgets(self):
"""Create and init Toolbar widgets."""
- xMin, xMax = self.plot.getGraphXLimits()
- yMin, yMax = self.plot.getGraphYLimits()
+ xMin, xMax = self.plot.getXAxis().getLimits()
+ yMin, yMax = self.plot.getYAxis().getLimits()
self.addWidget(qt.QLabel('Limits: '))
self.addWidget(qt.QLabel(' X: '))
- self._xMinFloatEdit = self._FloatEdit(xMin)
+ self._xMinFloatEdit = FloatEdit(self, xMin)
self._xMinFloatEdit.editingFinished[()].connect(
self._xFloatEditChanged)
self.addWidget(self._xMinFloatEdit)
- self._xMaxFloatEdit = self._FloatEdit(xMax)
+ self._xMaxFloatEdit = FloatEdit(self, xMax)
self._xMaxFloatEdit.editingFinished[()].connect(
self._xFloatEditChanged)
self.addWidget(self._xMaxFloatEdit)
self.addWidget(qt.QLabel(' Y: '))
- self._yMinFloatEdit = self._FloatEdit(yMin)
+ self._yMinFloatEdit = FloatEdit(self, yMin)
self._yMinFloatEdit.editingFinished[()].connect(
self._yFloatEditChanged)
self.addWidget(self._yMinFloatEdit)
- self._yMaxFloatEdit = self._FloatEdit(yMax)
+ self._yMaxFloatEdit = FloatEdit(self, yMax)
self._yMaxFloatEdit.editingFinished[()].connect(
self._yFloatEditChanged)
self.addWidget(self._yMaxFloatEdit)
@@ -288,8 +286,8 @@ class LimitsToolBar(qt.QToolBar):
if event['event'] not in ('limitsChanged',):
return
- xMin, xMax = self.plot.getGraphXLimits()
- yMin, yMax = self.plot.getGraphYLimits()
+ xMin, xMax = self.plot.getXAxis().getLimits()
+ yMin, yMax = self.plot.getYAxis().getLimits()
self._xMinFloatEdit.setValue(xMin)
self._xMaxFloatEdit.setValue(xMax)
@@ -302,7 +300,7 @@ class LimitsToolBar(qt.QToolBar):
if xMax < xMin:
xMin, xMax = xMax, xMin
- self.plot.setGraphXLimits(xMin, xMax)
+ self.plot.getXAxis().setLimits(xMin, xMax)
def _yFloatEditChanged(self):
"""Handle Y limits changed from the GUI."""
@@ -310,4 +308,4 @@ class LimitsToolBar(qt.QToolBar):
if yMax < yMin:
yMin, yMax = yMax, yMin
- self.plot.setGraphYLimits(yMin, yMax)
+ self.plot.getYAxis().setLimits(yMin, yMax)
diff --git a/silx/gui/plot/PlotWidget.py b/silx/gui/plot/PlotWidget.py
index 5666d56..8fd5a5e 100644
--- a/silx/gui/plot/PlotWidget.py
+++ b/silx/gui/plot/PlotWidget.py
@@ -20,64 +20,253 @@
# 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 widget providing Plot API for 1D and 2D data.
+"""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.
+
-This provides the plot API of :class:`silx.gui.plot.Plot.Plot` as a
-Qt widget.
+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
+
+
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "22/02/2016"
+__date__ = "30/08/2017"
+from collections import OrderedDict, namedtuple
+from contextlib import contextmanager
+import itertools
import logging
-from . import Plot
+import numpy
+
+from silx.utils.deprecation import deprecated
+# Import matplotlib backend here to init matplotlib our way
+from .backends.BackendMatplotlib import BackendMatplotlibQt
+
+from .Colormap import Colormap
+from . import Colors
+from . import PlotInteraction
+from . import PlotEvents
+from .LimitsHistory import LimitsHistory
+from . import _utils
+
+from . import items
from .. import qt
+from ._utils.panzoom import ViewConstraints
_logger = logging.getLogger(__name__)
-class PlotWidget(qt.QMainWindow, Plot.Plot):
+_COLORDICT = Colors.COLORDICT
+_COLORLIST = [_COLORDICT['black'],
+ _COLORDICT['blue'],
+ _COLORDICT['red'],
+ _COLORDICT['green'],
+ _COLORDICT['pink'],
+ _COLORDICT['yellow'],
+ _COLORDICT['brown'],
+ _COLORDICT['cyan'],
+ _COLORDICT['magenta'],
+ _COLORDICT['orange'],
+ _COLORDICT['violet'],
+ # _COLORDICT['bluegreen'],
+ _COLORDICT['grey'],
+ _COLORDICT['darkBlue'],
+ _COLORDICT['darkRed'],
+ _COLORDICT['darkGreen'],
+ _COLORDICT['darkCyan'],
+ _COLORDICT['darkMagenta'],
+ _COLORDICT['darkYellow'],
+ _COLORDICT['darkBrown']]
+
+
+"""
+Object returned when requesting the data range.
+"""
+_PlotDataRange = namedtuple('PlotDataRange',
+ ['x', 'y', 'yright'])
+
+
+class PlotWidget(qt.QMainWindow):
"""Qt Widget providing a 1D/2D plot.
This widget is a QMainWindow.
- It provides Qt signals for the Plot and add supports for panning
- with arrow keys.
+ This class implements the plot API initially provided in PyMca.
+
+ Supported backends:
- :param parent: The parent of this widget or None.
- :param backend: The backend to use for the plot (default: matplotlib).
- See :class:`.Plot` for the list of supported backend.
+ - 'matplotlib' and 'mpl': Matplotlib with Qt.
+ - 'opengl' and 'gl': OpenGL backend (requires PyOpenGL and OpenGL >= 2.1)
+ - 'none': No backend, to run headless for testing purpose.
+
+ :param parent: The parent of this widget or None (default).
+ :param backend: The backend to use, in:
+ 'matplotlib' (default), 'mpl', 'opengl', 'gl', 'none'
+ or a :class:`BackendBase.BackendBase` class
:type backend: str or :class:`BackendBase.BackendBase`
"""
+ DEFAULT_BACKEND = 'matplotlib'
+ """Class attribute setting the default backend for all instances."""
+
+ colorList = _COLORLIST
+ colorDict = _COLORDICT
+
sigPlotSignal = qt.Signal(object)
"""Signal for all events of the plot.
The signal information is provided as a dict.
- See :class:`.Plot` for documentation of the content of the dict.
+ See :class:`PlotWidget` for documentation of the content of the dict.
"""
- sigSetYAxisInverted = qt.Signal(bool)
- """Signal emitted when Y axis orientation has changed"""
-
- sigSetXAxisLogarithmic = qt.Signal(bool)
- """Signal emitted when X axis scale has changed"""
-
- sigSetYAxisLogarithmic = qt.Signal(bool)
- """Signal emitted when Y axis scale has changed"""
-
- sigSetXAxisAutoScale = qt.Signal(bool)
- """Signal emitted when X axis autoscale has changed"""
-
- sigSetYAxisAutoScale = qt.Signal(bool)
- """Signal emitted when Y axis autoscale has changed"""
-
sigSetKeepDataAspectRatio = qt.Signal(bool)
"""Signal emitted when plot keep aspect ratio has changed"""
@@ -90,10 +279,13 @@ class PlotWidget(qt.QMainWindow, Plot.Plot):
sigSetPanWithArrowKeys = qt.Signal(bool)
"""Signal emitted when pan with arrow keys has changed"""
+ _sigAxesVisibilityChanged = qt.Signal(bool)
+ """Signal emitted when the axes visibility changed"""
+
sigContentChanged = qt.Signal(str, str, str)
"""Signal emitted when the content of the plot is changed.
- It provides 3 informations:
+ It provides the following information:
- action: The change of the plot: 'add' or 'remove'
- kind: The kind of primitive changed:
@@ -104,7 +296,7 @@ class PlotWidget(qt.QMainWindow, Plot.Plot):
sigActiveCurveChanged = qt.Signal(object, object)
"""Signal emitted when the active curve has changed.
- It provides 2 informations:
+ It provides the following information:
- previous: The legend of the previous active curve or None
- legend: The legend of the new active curve or None if no curve is active
@@ -113,7 +305,7 @@ class PlotWidget(qt.QMainWindow, Plot.Plot):
sigActiveImageChanged = qt.Signal(object, object)
"""Signal emitted when the active image has changed.
- It provides 2 informations:
+ It provides the following information:
- previous: The legend of the previous active image or None
- legend: The legend of the new active image or None if no image is active
@@ -122,7 +314,7 @@ class PlotWidget(qt.QMainWindow, Plot.Plot):
sigActiveScatterChanged = qt.Signal(object, object)
"""Signal emitted when the active Scatter has changed.
- It provides following information:
+ It provides the following information:
- previous: The legend of the previous active scatter or None
- legend: The legend of the new active image or None if no image is active
@@ -136,6 +328,10 @@ class PlotWidget(qt.QMainWindow, Plot.Plot):
def __init__(self, parent=None, backend=None,
legends=False, callback=None, **kw):
+ self._autoreplot = False
+ self._dirty = False
+ self._cursorInPlot = False
+ self.__muteActiveItemChanged = False
if kw:
_logger.warning(
@@ -146,42 +342,2137 @@ class PlotWidget(qt.QMainWindow, Plot.Plot):
_logger.warning('deprecated: __init__ callback argument')
self._panWithArrowKeys = True
+ self._viewConstrains = None
- qt.QMainWindow.__init__(self, parent)
+ super(PlotWidget, self).__init__(parent)
if parent is not None:
# behave as a widget
self.setWindowFlags(qt.Qt.Widget)
else:
self.setWindowTitle('PlotWidget')
- Plot.Plot.__init__(self, parent, backend=backend)
+ if backend is None:
+ backend = self.DEFAULT_BACKEND
+
+ if hasattr(backend, "__call__"):
+ self._backend = backend(self, parent)
+
+ elif hasattr(backend, "lower"):
+ lowerCaseString = backend.lower()
+ if lowerCaseString in ("matplotlib", "mpl"):
+ backendClass = BackendMatplotlibQt
+ elif lowerCaseString in ('gl', 'opengl'):
+ from .backends.BackendOpenGL import BackendOpenGL
+ backendClass = BackendOpenGL
+ elif lowerCaseString == 'none':
+ from .backends.BackendBase import BackendBase as backendClass
+ else:
+ raise ValueError("Backend not supported %s" % backend)
+ self._backend = backendClass(self, parent)
+
+ else:
+ raise ValueError("Backend not supported %s" % str(backend))
+
+ self.setCallback() # set _callback
+
+ # Items handling
+ self._content = OrderedDict()
+ self._contentToUpdate = [] # Used as an OrderedSet
+
+ self._dataRange = None
+
+ # line types
+ self._styleList = ['-', '--', '-.', ':']
+ self._colorIndex = 0
+ self._styleIndex = 0
+
+ self._activeCurveHandling = True
+ self._activeCurveColor = "#000000"
+ self._activeLegend = {'curve': None, 'image': None,
+ 'scatter': None}
+
+ # default properties
+ self._cursorConfiguration = None
+
+ self._xAxis = items.XAxis(self)
+ self._yAxis = items.YAxis(self)
+ self._yRightAxis = items.YRightAxis(self, self._yAxis)
+
+ self._grid = None
+ self._graphTitle = ''
+
+ self.setGraphTitle()
+ self.setGraphXLabel()
+ self.setGraphYLabel()
+ self.setGraphYLabel('', axis='right')
+
+ self.setDefaultColormap() # Init default colormap
+
+ self.setDefaultPlotPoints(False)
+ self.setDefaultPlotLines(True)
+
+ self._limitsHistory = LimitsHistory(self)
+
+ self._eventHandler = PlotInteraction.PlotInteraction(self)
+ self._eventHandler.setInteractiveMode('zoom', color=(0., 0., 0., 1.))
+
+ self._pressedButtons = [] # Currently pressed mouse buttons
+
+ self._defaultDataMargins = (0., 0., 0., 0.)
+
+ # Only activate autoreplot at the end
+ # This avoids errors when loaded in Qt designer
+ self._dirty = False
+ self._autoreplot = True
widget = self.getWidgetHandle()
if widget is not None:
self.setCentralWidget(widget)
else:
- _logger.warning("Plot backend does not support widget")
+ _logger.info("PlotWidget backend does not support widget")
self.setFocusPolicy(qt.Qt.StrongFocus)
self.setFocus(qt.Qt.OtherFocusReason)
+ # Set default limits
+ self.setGraphXLimits(0., 100.)
+ self.setGraphYLimits(0., 100., axis='right')
+ self.setGraphYLimits(0., 100., axis='left')
+
+ @staticmethod
+ def setDefaultBackend(backend):
+ """Set system wide default plot backend.
+
+ .. versionadded:: 0.6
+
+ :param backend: The backend to use, in:
+ 'matplotlib' (default), 'mpl', 'opengl', 'gl', 'none'
+ or a :class:`BackendBase.BackendBase` class
+ """
+ PlotWidget.DEFAULT_BACKEND = backend
+
+ def _getDirtyPlot(self):
+ """Return the plot dirty flag.
+
+ If False, the plot has not changed since last replot.
+ If True, the full plot need to be redrawn.
+ If 'overlay', only the overlay has changed since last replot.
+
+ It can be accessed by backend to check the dirty state.
+
+ :return: False, True, 'overlay'
+ """
+ return self._dirty
+
+ def _setDirtyPlot(self, overlayOnly=False):
+ """Mark the plot as needing redraw
+
+ :param bool overlayOnly: True to redraw only the overlay,
+ False to redraw everything
+ """
+ wasDirty = self._dirty
+
+ if not self._dirty and overlayOnly:
+ self._dirty = 'overlay'
+ else:
+ self._dirty = True
+
+ if self._autoreplot and not wasDirty:
+ self._backend.postRedisplay()
+
+ def _invalidateDataRange(self):
+ """
+ Notifies this PlotWidget instance that the range has changed
+ and will have to be recomputed.
+ """
+ self._dataRange = None
+
+ def _updateDataRange(self):
+ """
+ Recomputes the range of the data displayed on this PlotWidget.
+ """
+ xMin = yMinLeft = yMinRight = float('nan')
+ xMax = yMaxLeft = yMaxRight = float('nan')
+
+ for item in self._content.values():
+ if item.isVisible():
+ bounds = item.getBounds()
+ if bounds is not None:
+ xMin = numpy.nanmin([xMin, bounds[0]])
+ xMax = numpy.nanmax([xMax, bounds[1]])
+ # Take care of right axis
+ if (isinstance(item, items.YAxisMixIn) and
+ item.getYAxis() == 'right'):
+ yMinRight = numpy.nanmin([yMinRight, bounds[2]])
+ yMaxRight = numpy.nanmax([yMaxRight, bounds[3]])
+ else:
+ yMinLeft = numpy.nanmin([yMinLeft, bounds[2]])
+ yMaxLeft = numpy.nanmax([yMaxLeft, bounds[3]])
+
+ def lGetRange(x, y):
+ return None if numpy.isnan(x) and numpy.isnan(y) else (x, y)
+ xRange = lGetRange(xMin, xMax)
+ yLeftRange = lGetRange(yMinLeft, yMaxLeft)
+ yRightRange = lGetRange(yMinRight, yMaxRight)
+
+ self._dataRange = _PlotDataRange(x=xRange,
+ y=yLeftRange,
+ yright=yRightRange)
+
+ def getDataRange(self):
+ """
+ Returns this PlotWidget's data range.
+
+ :return: a namedtuple with the following members :
+ x, y (left y axis), yright. Each member is a tuple (min, max)
+ or None if no data is associated with the axis.
+ :rtype: namedtuple
+ """
+ if self._dataRange is None:
+ self._updateDataRange()
+ return self._dataRange
+
+ # Content management
+
+ @staticmethod
+ def _itemKey(item):
+ """Build the key of given :class:`Item` in the plot
+
+ :param Item item: The item to make the key from
+ :return: (legend, kind)
+ :rtype: (str, str)
+ """
+ if isinstance(item, items.Curve):
+ kind = 'curve'
+ elif isinstance(item, items.ImageBase):
+ kind = 'image'
+ elif isinstance(item, items.Scatter):
+ kind = 'scatter'
+ elif isinstance(item, (items.Marker,
+ items.XMarker, items.YMarker)):
+ kind = 'marker'
+ elif isinstance(item, items.Shape):
+ kind = 'item'
+ elif isinstance(item, items.Histogram):
+ kind = 'histogram'
+ else:
+ raise ValueError('Unsupported item type %s' % type(item))
+
+ return item.getLegend(), kind
+
+ def _add(self, item):
+ """Add the given :class:`Item` to the plot.
+
+ :param Item item: The item to append to the plot content
+ """
+ key = self._itemKey(item)
+ if key in self._content:
+ raise RuntimeError('Item already in the plot')
+
+ # Add item to plot
+ self._content[key] = item
+ item._setPlot(self)
+ if item.isVisible():
+ self._itemRequiresUpdate(item)
+ if isinstance(item, (items.Curve, items.ImageBase)):
+ self._invalidateDataRange() # TODO handle this automatically
+
+ self._notifyContentChanged(item)
+
+ def _notifyContentChanged(self, item):
+ legend, kind = self._itemKey(item)
+ self.notify('contentChanged', action='add', kind=kind, legend=legend)
+
+ def _remove(self, item):
+ """Remove the given :class:`Item` from the plot.
+
+ :param Item item: The item to remove from the plot content
+ """
+ key = self._itemKey(item)
+ if key not in self._content:
+ raise RuntimeError('Item not in the plot')
+
+ legend, kind = key
+
+ if kind in self._ACTIVE_ITEM_KINDS:
+ if self._getActiveItem(kind) == item:
+ # Reset active item
+ self._setActiveItem(kind, None)
+
+ # Remove item from plot
+ self._content.pop(key)
+ if item in self._contentToUpdate:
+ self._contentToUpdate.remove(item)
+ if item.isVisible():
+ self._setDirtyPlot(overlayOnly=item.isOverlay())
+ if item.getBounds() is not None:
+ self._invalidateDataRange()
+ item._removeBackendRenderer(self._backend)
+ item._setPlot(None)
+
+ if (kind == 'curve' and not self.getAllCurves(just_legend=True,
+ withhidden=True)):
+ self._colorIndex = 0
+ self._styleIndex = 0
+
+ self.notify('contentChanged', action='remove',
+ kind=kind, legend=legend)
+
+ def _itemRequiresUpdate(self, item):
+ """Called by items in the plot for asynchronous update
+
+ :param Item item: The item that required update
+ """
+ assert item.getPlot() == self
+ # Pu item at the end of the list
+ if item in self._contentToUpdate:
+ self._contentToUpdate.remove(item)
+ self._contentToUpdate.append(item)
+ self._setDirtyPlot(overlayOnly=item.isOverlay())
+
+ @contextmanager
+ def _muteActiveItemChangedSignal(self):
+ self.__muteActiveItemChanged = True
+ yield
+ self.__muteActiveItemChanged = False
+
+ # Add
+
+ # add * input arguments management:
+ # If an arg is set, then use it.
+ # Else:
+ # If a curve with the same legend exists, then use its arg value
+ # Else, use a default value.
+ # Store used value.
+ # This value is used when curve is updated either internally or by user.
+
+ def addCurve(self, x, y, legend=None, info=None,
+ replace=False, replot=None,
+ color=None, symbol=None,
+ linewidth=None, linestyle=None,
+ xlabel=None, ylabel=None, yaxis=None,
+ xerror=None, yerror=None, z=None, selectable=None,
+ fill=None, resetzoom=True,
+ histogram=None, copy=True, **kw):
+ """Add a 1D curve given by x an y to the graph.
+
+ Curves are uniquely identified by their legend.
+ To add multiple curves, call :meth:`addCurve` multiple times with
+ different legend argument.
+ To replace an existing curve, call :meth:`addCurve` with the
+ existing curve legend.
+ If you want to display the curve values as an histogram see the
+ histogram parameter or :meth:`addHistogram`.
+
+ When curve parameters are not provided, if a curve with the
+ same legend is displayed in the plot, its parameters are used.
+
+ :param numpy.ndarray x: The data corresponding to the x coordinates.
+ If you attempt to plot an histogram you can set edges values in x.
+ In this case len(x) = len(y) + 1
+ :param numpy.ndarray y: The data corresponding to the y coordinates
+ :param str legend: The legend to be associated to the curve (or None)
+ :param info: User-defined information associated to the curve
+ :param bool replace: True (the default) to delete already existing
+ curves
+ :param color: color(s) to be used
+ :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or
+ one of the predefined color names defined in Colors.py
+ :param str symbol: Symbol to be drawn at each (x, y) position::
+
+ - 'o' circle
+ - '.' point
+ - ',' pixel
+ - '+' cross
+ - 'x' x-cross
+ - 'd' diamond
+ - 's' square
+ - None (the default) to use default symbol
+
+ :param float linewidth: The width of the curve in pixels (Default: 1).
+ :param str linestyle: Type of line::
+
+ - ' ' no line
+ - '-' solid line
+ - '--' dashed line
+ - '-.' dash-dot line
+ - ':' dotted line
+ - None (the default) to use default line style
+
+ :param str xlabel: Label to show on the X axis when the curve is active
+ or None to keep default axis label.
+ :param str ylabel: Label to show on the Y axis when the curve is active
+ or None to keep default axis label.
+ :param str yaxis: The Y axis this curve is attached to.
+ Either 'left' (the default) or 'right'
+ :param xerror: Values with the uncertainties on the x values
+ :type xerror: A float, or a numpy.ndarray of float32.
+ If it is an array, it can either be a 1D array of
+ same length as the data or a 2D array with 2 rows
+ of same length as the data: row 0 for positive errors,
+ row 1 for negative errors.
+ :param yerror: Values with the uncertainties on the y values
+ :type yerror: A float, or a numpy.ndarray of float32. See xerror.
+ :param int z: Layer on which to draw the curve (default: 1)
+ This allows to control the overlay.
+ :param bool selectable: Indicate if the curve can be selected.
+ (Default: True)
+ :param bool fill: True to fill the curve, False otherwise (default).
+ :param bool resetzoom: True (the default) to reset the zoom.
+ :param str histogram: if not None then the curve will be draw as an
+ histogram. The step for each values of the curve can be set to the
+ left, center or right of the original x curve values.
+ If histogram is not None and len(x) == len(y)+1 then x is directly
+ take as edges of the histogram.
+ Type of histogram::
+
+ - None (default)
+ - 'left'
+ - 'right'
+ - 'center'
+ :param bool copy: True make a copy of the data (default),
+ False to use provided arrays.
+ :returns: The key string identify this curve
+ """
+ # Deprecation warnings
+ if replot is not None:
+ _logger.warning(
+ 'addCurve deprecated replot argument, use resetzoom instead')
+ resetzoom = replot and resetzoom
+
+ if kw:
+ _logger.warning('addCurve: deprecated extra arguments')
+
+ # This is an histogram, use addHistogram
+ if histogram is not None:
+ histoLegend = self.addHistogram(histogram=y,
+ edges=x,
+ legend=legend,
+ color=color,
+ fill=fill,
+ align=histogram,
+ copy=copy)
+ histo = self.getHistogram(histoLegend)
+
+ histo.setInfo(info)
+ if linewidth is not None:
+ histo.setLineWidth(linewidth)
+ if linestyle is not None:
+ histo.setLineStyle(linestyle)
+ if xlabel is not None:
+ _logger.warning(
+ 'addCurve: Histogram does not support xlabel argument')
+ if ylabel is not None:
+ _logger.warning(
+ 'addCurve: Histogram does not support ylabel argument')
+ if yaxis is not None:
+ histo.setYAxis(yaxis)
+ if z is not None:
+ histo.setZValue(z)
+ if selectable is not None:
+ _logger.warning(
+ 'addCurve: Histogram does not support selectable argument')
+
+ return
+
+ legend = 'Unnamed curve 1.1' if legend is None else str(legend)
+
+ # Check if curve was previously active
+ wasActive = self.getActiveCurve(just_legend=True) == legend
+
+ # Create/Update curve object
+ curve = self.getCurve(legend)
+ mustBeAdded = curve is None
+ if curve is None:
+ # No previous curve, create a default one and add it to the plot
+ curve = items.Curve() if histogram is None else items.Histogram()
+ curve._setLegend(legend)
+ # Set default color, linestyle and symbol
+ default_color, default_linestyle = self._getColorAndStyle()
+ curve.setColor(default_color)
+ curve.setLineStyle(default_linestyle)
+ curve.setSymbol(self._defaultPlotPoints)
+
+ # Do not emit sigActiveCurveChanged,
+ # it will be sent once with _setActiveItem
+ with self._muteActiveItemChangedSignal():
+ # Override previous/default values with provided ones
+ curve.setInfo(info)
+ if color is not None:
+ curve.setColor(color)
+ if symbol is not None:
+ curve.setSymbol(symbol)
+ if linewidth is not None:
+ curve.setLineWidth(linewidth)
+ if linestyle is not None:
+ curve.setLineStyle(linestyle)
+ if xlabel is not None:
+ curve._setXLabel(xlabel)
+ if ylabel is not None:
+ curve._setYLabel(ylabel)
+ if yaxis is not None:
+ curve.setYAxis(yaxis)
+ if z is not None:
+ curve.setZValue(z)
+ if selectable is not None:
+ curve._setSelectable(selectable)
+ if fill is not None:
+ curve.setFill(fill)
+
+ # Set curve data
+ # If errors not provided, reuse previous ones
+ # TODO: Issue if size of data change but not that of errors
+ if xerror is None:
+ xerror = curve.getXErrorData(copy=False)
+ if yerror is None:
+ yerror = curve.getYErrorData(copy=False)
+
+ curve.setData(x, y, xerror, yerror, copy=copy)
+
+ if replace: # Then remove all other curves
+ for c in self.getAllCurves(withhidden=True):
+ if c is not curve:
+ self._remove(c)
+
+ if mustBeAdded:
+ self._add(curve)
+ else:
+ self._notifyContentChanged(curve)
+
+ if wasActive:
+ self.setActiveCurve(curve.getLegend())
+
+ if resetzoom:
+ # We ask for a zoom reset in order to handle the plot scaling
+ # if the user does not want that, autoscale of the different
+ # axes has to be set to off.
+ self.resetZoom()
+
+ return legend
+
+ def addHistogram(self,
+ histogram,
+ edges,
+ legend=None,
+ color=None,
+ fill=None,
+ align='center',
+ resetzoom=True,
+ copy=True):
+ """Add an histogram to the graph.
+
+ This is NOT computing the histogram, this method takes as parameter
+ already computed histogram values.
+
+ Histogram are uniquely identified by their legend.
+ To add multiple histograms, call :meth:`addHistogram` multiple times
+ with different legend argument.
+
+ When histogram parameters are not provided, if an histogram with the
+ same legend is displayed in the plot, its parameters are used.
+
+ :param numpy.ndarray histogram: The values of the histogram.
+ :param numpy.ndarray edges:
+ The bin edges of the histogram.
+ If histogram and edges have the same length, the bin edges
+ are computed according to the align parameter.
+ :param str legend:
+ The legend to be associated to the histogram (or None)
+ :param color: color to be used
+ :type color: str ("#RRGGBB") or RGB unsigned byte array or
+ one of the predefined color names defined in Colors.py
+ :param bool fill: True to fill the curve, False otherwise (default).
+ :param str align:
+ In case histogram values and edges have the same length N,
+ the N+1 bin edges are computed according to the alignment in:
+ 'center' (default), 'left', 'right'.
+ :param bool resetzoom: True (the default) to reset the zoom.
+ :param bool copy: True make a copy of the data (default),
+ False to use provided arrays.
+ :returns: The key string identify this histogram
+ """
+ legend = 'Unnamed histogram' if legend is None else str(legend)
+
+ # Create/Update histogram object
+ histo = self.getHistogram(legend)
+ mustBeAdded = histo is None
+ if histo is None:
+ # No previous histogram, create a default one and
+ # add it to the plot
+ histo = items.Histogram()
+ histo._setLegend(legend)
+ histo.setColor(self._getColorAndStyle()[0])
+
+ # Override previous/default values with provided ones
+ if color is not None:
+ histo.setColor(color)
+ if fill is not None:
+ histo.setFill(fill)
+
+ # Set histogram data
+ histo.setData(histogram, edges, align=align, copy=copy)
+
+ if mustBeAdded:
+ self._add(histo)
+ else:
+ self._notifyContentChanged(histo)
+
+ if resetzoom:
+ # We ask for a zoom reset in order to handle the plot scaling
+ # if the user does not want that, autoscale of the different
+ # axes has to be set to off.
+ self.resetZoom()
+
+ return legend
+
+ def addImage(self, data, legend=None, info=None,
+ replace=True, replot=None,
+ xScale=None, yScale=None, z=None,
+ selectable=None, draggable=None,
+ colormap=None, pixmap=None,
+ xlabel=None, ylabel=None,
+ origin=None, scale=None,
+ resetzoom=True, copy=True, **kw):
+ """Add a 2D dataset or an image to the plot.
+
+ It displays either an array of data using a colormap or a RGB(A) image.
+
+ Images are uniquely identified by their legend.
+ To add multiple images, call :meth:`addImage` multiple times with
+ different legend argument.
+ To replace/update an existing image, call :meth:`addImage` with the
+ existing image legend.
+
+ When image parameters are not provided, if an image with the
+ same legend is displayed in the plot, its parameters are used.
+
+ :param numpy.ndarray data:
+ (nrows, ncolumns) data or
+ (nrows, ncolumns, RGBA) ubyte array
+ Note: boolean values are converted to int8.
+ :param str legend: The legend to be associated to the image (or None)
+ :param info: User-defined information associated to the image
+ :param bool replace: True (default) to delete already existing images
+ :param int z: Layer on which to draw the image (default: 0)
+ This allows to control the overlay.
+ :param bool selectable: Indicate if the image can be selected.
+ (default: False)
+ :param bool draggable: Indicate if the image can be moved.
+ (default: False)
+ :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 )
+ :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,
+ or None to keep default axis label.
+ :param str ylabel: Y axis label to show when this curve is active,
+ or None to keep default axis label.
+ :param origin: (origin X, origin Y) of the data.
+ It is possible to pass a single float if both
+ coordinates are equal.
+ Default: (0., 0.)
+ :type origin: float or 2-tuple of float
+ :param scale: (scale X, scale Y) of the data.
+ It is possible to pass a single float if both
+ coordinates are equal.
+ Default: (1., 1.)
+ :type scale: float or 2-tuple of float
+ :param bool resetzoom: True (the default) to reset the zoom.
+ :param bool copy: True make a copy of the data (default),
+ False to use provided arrays.
+ :returns: The key string identify this image
+ """
+ # Deprecation warnings
+ if xScale is not None or yScale is not None:
+ _logger.warning(
+ 'addImage deprecated xScale and yScale arguments,'
+ 'use origin, scale arguments instead.')
+ if origin is None and scale is None:
+ origin = xScale[0], yScale[0]
+ scale = xScale[1], yScale[1]
+ else:
+ _logger.warning(
+ 'addCurve: xScale, yScale and origin, scale arguments'
+ ' are conflicting. xScale and yScale are ignored.'
+ ' Use only origin, scale arguments.')
+
+ if replot is not None:
+ _logger.warning(
+ 'addImage deprecated replot argument, use resetzoom instead')
+ resetzoom = replot and resetzoom
+
+ if kw:
+ _logger.warning('addImage: deprecated extra arguments')
+
+ legend = "Unnamed Image 1.1" if legend is None else str(legend)
+
+ # Check if image was previously active
+ wasActive = self.getActiveImage(just_legend=True) == legend
+
+ data = numpy.array(data, copy=False)
+ assert data.ndim in (2, 3)
+
+ image = self.getImage(legend)
+ if image is not None and image.getData(copy=False).ndim != data.ndim:
+ # Update a data image with RGBA image or the other way around:
+ # Remove previous image
+ # In this case, we don't retrieve defaults from the previous image
+ self._remove(image)
+ image = None
+
+ mustBeAdded = image is None
+ if image is None:
+ # No previous image, create a default one and add it to the plot
+ if data.ndim == 2:
+ image = items.ImageData()
+ image.setColormap(self.getDefaultColormap())
+ else:
+ image = items.ImageRgba()
+ image._setLegend(legend)
+
+ # Do not emit sigActiveImageChanged,
+ # it will be sent once with _setActiveItem
+ with self._muteActiveItemChangedSignal():
+ # Override previous/default values with provided ones
+ image.setInfo(info)
+ if origin is not None:
+ image.setOrigin(origin)
+ if scale is not None:
+ image.setScale(scale)
+ if z is not None:
+ image.setZValue(z)
+ if selectable is not None:
+ image._setSelectable(selectable)
+ if draggable is not None:
+ image._setDraggable(draggable)
+ if colormap is not None and isinstance(image, items.ColormapMixIn):
+ if isinstance(colormap, dict):
+ image.setColormap(Colormap._fromDict(colormap))
+ else:
+ assert isinstance(colormap, Colormap)
+ image.setColormap(colormap)
+ if xlabel is not None:
+ image._setXLabel(xlabel)
+ if ylabel is not None:
+ image._setYLabel(ylabel)
+
+ if data.ndim == 2:
+ image.setData(data, alternative=pixmap, copy=copy)
+ else: # RGB(A) image
+ if pixmap is not None:
+ _logger.warning(
+ 'addImage: pixmap argument ignored when data is RGB(A)')
+ image.setData(data, copy=copy)
+
+ if replace:
+ for img in self.getAllImages():
+ if img is not image:
+ self._remove(img)
+
+ if mustBeAdded:
+ self._add(image)
+ else:
+ self._notifyContentChanged(image)
+
+ if len(self.getAllImages()) == 1 or wasActive:
+ self.setActiveImage(legend)
+
+ if resetzoom:
+ # We ask for a zoom reset in order to handle the plot scaling
+ # if the user does not want that, autoscale of the different
+ # axes has to be set to off.
+ self.resetZoom()
+
+ return legend
+
+ def addScatter(self, x, y, value, legend=None, colormap=None,
+ info=None, symbol=None, xerror=None, yerror=None,
+ z=None, copy=True):
+ """Add a (x, y, value) scatter to the graph.
+
+ Scatters are uniquely identified by their legend.
+ To add multiple scatters, call :meth:`addScatter` multiple times with
+ different legend argument.
+ To replace/update an existing scatter, call :meth:`addScatter` with the
+ existing scatter legend.
+
+ When scatter parameters are not provided, if a scatter with the
+ same legend is displayed in the plot, its parameters are used.
+
+ :param numpy.ndarray x: The data corresponding to the x coordinates.
+ :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 info: User-defined information associated to the curve
+ :param str symbol: Symbol to be drawn at each (x, y) position::
+
+ - 'o' circle
+ - '.' point
+ - ',' pixel
+ - '+' cross
+ - 'x' x-cross
+ - 'd' diamond
+ - 's' square
+ - None (the default) to use default symbol
+
+ :param xerror: Values with the uncertainties on the x values
+ :type xerror: A float, or a numpy.ndarray of float32.
+ If it is an array, it can either be a 1D array of
+ same length as the data or a 2D array with 2 rows
+ of same length as the data: row 0 for positive errors,
+ row 1 for negative errors.
+ :param yerror: Values with the uncertainties on the y values
+ :type yerror: A float, or a numpy.ndarray of float32. See xerror.
+ :param int z: Layer on which to draw the scatter (default: 1)
+ This allows to control the overlay.
+
+ :param bool copy: True make a copy of the data (default),
+ False to use provided arrays.
+ :returns: The key string identify this scatter
+ """
+ legend = 'Unnamed scatter 1.1' if legend is None else str(legend)
+
+ # Check if scatter was previously active
+ wasActive = self._getActiveItem(kind='scatter',
+ just_legend=True) == legend
+
+ # Create/Update curve object
+ scatter = self._getItem(kind='scatter', legend=legend)
+ mustBeAdded = scatter is None
+ if scatter is None:
+ # No previous scatter, create a default one and add it to the plot
+ scatter = items.Scatter()
+ scatter._setLegend(legend)
+ scatter.setColormap(self.getDefaultColormap())
+
+ # Do not emit sigActiveScatterChanged,
+ # it will be sent once with _setActiveItem
+ with self._muteActiveItemChangedSignal():
+ # Override previous/default values with provided ones
+ scatter.setInfo(info)
+ if symbol is not None:
+ scatter.setSymbol(symbol)
+ if z is not None:
+ scatter.setZValue(z)
+ if colormap is not None:
+ if isinstance(colormap, dict):
+ scatter.setColormap(Colormap._fromDict(colormap))
+ else:
+ assert isinstance(colormap, Colormap)
+ scatter.setColormap(colormap)
+
+ # Set scatter data
+ # If errors not provided, reuse previous ones
+ if xerror is None:
+ xerror = scatter.getXErrorData(copy=False)
+ if xerror is not None and len(xerror) != len(x):
+ xerror = None
+ if yerror is None:
+ yerror = scatter.getYErrorData(copy=False)
+ if yerror is not None and len(yerror) != len(y):
+ yerror = None
+
+ scatter.setData(x, y, value, xerror, yerror, copy=copy)
+
+ if mustBeAdded:
+ self._add(scatter)
+ else:
+ self._notifyContentChanged(scatter)
+
+ if len(self._getItems(kind="scatter")) == 1 or wasActive:
+ self._setActiveItem('scatter', scatter.getLegend())
+
+ return legend
+
+ def addItem(self, xdata, ydata, legend=None, info=None,
+ replace=False,
+ shape="polygon", color='black', fill=True,
+ overlay=False, z=None, **kw):
+ """Add an item (i.e. a shape) to the plot.
+
+ Items are uniquely identified by their legend.
+ To add multiple items, call :meth:`addItem` multiple times with
+ different legend argument.
+ To replace/update an existing item, call :meth:`addItem` with the
+ existing item legend.
+
+ :param numpy.ndarray xdata: The X coords of the points of the shape
+ :param numpy.ndarray ydata: The Y coords of the points of the shape
+ :param str legend: The legend to be associated to the item
+ :param info: User-defined information associated to the item
+ :param bool replace: True (default) to delete already existing images
+ :param str shape: Type of item to be drawn in
+ hline, polygon (the default), rectangle, vline,
+ polylines
+ :param str color: Color of the item, e.g., 'blue', 'b', '#FF0000'
+ (Default: 'black')
+ :param bool fill: True (the default) to fill the shape
+ :param bool overlay: True if item is an overlay (Default: False).
+ This allows for rendering optimization if this
+ item is changed often.
+ :param int z: Layer on which to draw the item (default: 2)
+ :returns: The key string identify this item
+ """
+ # expected to receive the same parameters as the signal
+
+ if kw:
+ _logger.warning('addItem deprecated parameters: %s', str(kw))
+
+ legend = "Unnamed Item 1.1" if legend is None else str(legend)
+
+ z = int(z) if z is not None else 2
+
+ if replace:
+ self.remove(kind='item')
+ else:
+ self.remove(legend, kind='item')
+
+ item = items.Shape(shape)
+ item._setLegend(legend)
+ item.setInfo(info)
+ item.setColor(color)
+ item.setFill(fill)
+ item.setOverlay(overlay)
+ item.setZValue(z)
+ item.setPoints(numpy.array((xdata, ydata)).T)
+
+ self._add(item)
+
+ return legend
+
+ def addXMarker(self, x, legend=None,
+ text=None,
+ color=None,
+ selectable=False,
+ draggable=False,
+ constraint=None,
+ **kw):
+ """Add a vertical line marker to the plot.
+
+ Markers are uniquely identified by their legend.
+ As opposed to curves, images and items, two calls to
+ :meth:`addXMarker` without legend argument adds two markers with
+ different identifying legends.
+
+ :param float x: Position of the marker on the X axis in data
+ coordinates
+ :param str legend: Legend associated to the marker to identify it
+ :param str text: Text to display on the marker.
+ :param str color: Color of the marker, e.g., 'blue', 'b', '#FF0000'
+ (Default: 'black')
+ :param bool selectable: Indicate if the marker can be selected.
+ (default: False)
+ :param bool draggable: Indicate if the marker can be moved.
+ (default: False)
+ :param constraint: A function filtering marker displacement by
+ dragging operations or None for no filter.
+ This function is called each time a marker is
+ moved.
+ This parameter is only used if draggable is True.
+ :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.
+ :return: The key string identify this marker
+ """
+ if kw:
+ _logger.warning(
+ 'addXMarker deprecated extra parameters: %s', str(kw))
+
+ return self._addMarker(x=x, y=None, legend=legend,
+ text=text, color=color,
+ selectable=selectable, draggable=draggable,
+ symbol=None, constraint=constraint)
+
+ def addYMarker(self, y,
+ legend=None,
+ text=None,
+ color=None,
+ selectable=False,
+ draggable=False,
+ constraint=None,
+ **kw):
+ """Add a horizontal line marker to the plot.
+
+ Markers are uniquely identified by their legend.
+ As opposed to curves, images and items, two calls to
+ :meth:`addYMarker` without legend argument adds two markers with
+ different identifying legends.
+
+ :param float y: Position of the marker on the Y axis in data
+ coordinates
+ :param str legend: Legend associated to the marker to identify it
+ :param str text: Text to display next to the marker.
+ :param str color: Color of the marker, e.g., 'blue', 'b', '#FF0000'
+ (Default: 'black')
+ :param bool selectable: Indicate if the marker can be selected.
+ (default: False)
+ :param bool draggable: Indicate if the marker can be moved.
+ (default: False)
+ :param constraint: A function filtering marker displacement by
+ dragging operations or None for no filter.
+ This function is called each time a marker is
+ moved.
+ This parameter is only used if draggable is True.
+ :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.
+ :return: The key string identify this marker
+ """
+ if kw:
+ _logger.warning(
+ 'addYMarker deprecated extra parameters: %s', str(kw))
+
+ return self._addMarker(x=None, y=y, legend=legend,
+ text=text, color=color,
+ selectable=selectable, draggable=draggable,
+ symbol=None, constraint=constraint)
+
+ def addMarker(self, x, y, legend=None,
+ text=None,
+ color=None,
+ selectable=False,
+ draggable=False,
+ symbol='+',
+ constraint=None,
+ **kw):
+ """Add a point marker to the plot.
+
+ Markers are uniquely identified by their legend.
+ As opposed to curves, images and items, two calls to
+ :meth:`addMarker` without legend argument adds two markers with
+ different identifying legends.
+
+ :param float x: Position of the marker on the X axis in data
+ coordinates
+ :param float y: Position of the marker on the Y axis in data
+ coordinates
+ :param str legend: Legend associated to the marker to identify it
+ :param str text: Text to display next to the marker
+ :param str color: Color of the marker, e.g., 'blue', 'b', '#FF0000'
+ (Default: 'black')
+ :param bool selectable: Indicate if the marker can be selected.
+ (default: False)
+ :param bool draggable: Indicate if the marker can be moved.
+ (default: False)
+ :param str symbol: Symbol representing the marker in::
+
+ - 'o' circle
+ - '.' point
+ - ',' pixel
+ - '+' cross (the default)
+ - 'x' x-cross
+ - 'd' diamond
+ - 's' square
+
+ :param constraint: A function filtering marker displacement by
+ dragging operations or None for no filter.
+ This function is called each time a marker is
+ moved.
+ This parameter is only used if draggable is True.
+ :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.
+ :return: The key string identify this marker
+ """
+ if kw:
+ _logger.warning(
+ 'addMarker deprecated extra parameters: %s', str(kw))
+
+ if x is None:
+ xmin, xmax = self._xAxis.getLimits()
+ x = 0.5 * (xmax + xmin)
+
+ if y is None:
+ ymin, ymax = self._yAxis.getLimits()
+ y = 0.5 * (ymax + ymin)
+
+ return self._addMarker(x=x, y=y, legend=legend,
+ text=text, color=color,
+ selectable=selectable, draggable=draggable,
+ symbol=symbol, constraint=constraint)
+
+ def _addMarker(self, x, y, legend,
+ text, color,
+ selectable, draggable,
+ symbol, constraint):
+ """Common method for adding point, vline and hline marker.
+
+ See :meth:`addMarker` for argument documentation.
+ """
+ assert (x, y) != (None, None)
+
+ if legend is None: # Find an unused legend
+ markerLegends = self._getAllMarkers(just_legend=True)
+ for index in itertools.count():
+ legend = "Unnamed Marker %d" % index
+ if legend not in markerLegends:
+ break # Keep this legend
+ legend = str(legend)
+
+ if x is None:
+ markerClass = items.YMarker
+ elif y is None:
+ markerClass = items.XMarker
+ else:
+ markerClass = items.Marker
+
+ # Create/Update marker object
+ marker = self._getMarker(legend)
+ if marker is not None and not isinstance(marker, markerClass):
+ _logger.warning('Adding marker with same legend'
+ ' but different type replaces it')
+ self._remove(marker)
+ marker = None
+
+ mustBeAdded = marker is None
+ if marker is None:
+ # No previous marker, create one
+ marker = markerClass()
+ marker._setLegend(legend)
+
+ if text is not None:
+ marker.setText(text)
+ if color is not None:
+ marker.setColor(color)
+ if selectable is not None:
+ marker._setSelectable(selectable)
+ if draggable is not None:
+ marker._setDraggable(draggable)
+ if symbol is not None:
+ marker.setSymbol(symbol)
+
+ # TODO to improve, but this ensure constraint is applied
+ marker.setPosition(x, y)
+ if constraint is not None:
+ marker._setConstraint(constraint)
+ marker.setPosition(x, y)
+
+ if mustBeAdded:
+ self._add(marker)
+ else:
+ self._notifyContentChanged(marker)
+
+ return legend
+
+ # Hide
+
+ def isCurveHidden(self, legend):
+ """Returns True if the curve associated to legend is hidden, else False
+
+ :param str legend: The legend key identifying the curve
+ :return: True if the associated curve is hidden, False otherwise
+ """
+ curve = self._getItem('curve', legend)
+ return curve is not None and not curve.isVisible()
+
+ def hideCurve(self, legend, flag=True, replot=None):
+ """Show/Hide the curve associated to legend.
+
+ Even when hidden, the curve is kept in the list of curves.
+
+ :param str legend: The legend associated to the curve to be hidden
+ :param bool flag: True (default) to hide the curve, False to show it
+ """
+ if replot is not None:
+ _logger.warning('hideCurve deprecated replot parameter')
+
+ curve = self._getItem('curve', legend)
+ if curve is None:
+ _logger.warning('Curve not in plot: %s', legend)
+ return
+
+ isVisible = not flag
+ if isVisible != curve.isVisible():
+ curve.setVisible(isVisible)
+
+ # Remove
+
+ ITEM_KINDS = 'curve', 'image', 'scatter', 'item', 'marker', 'histogram'
+ """List of supported kind of items in the plot."""
+
+ _ACTIVE_ITEM_KINDS = 'curve', 'scatter', 'image'
+ """List of item's kind which have a active item."""
+
+ def remove(self, legend=None, kind=ITEM_KINDS):
+ """Remove one or all element(s) of the given legend and kind.
+
+ Examples:
+
+ - ``remove()`` clears the plot
+ - ``remove(kind='curve')`` removes all curves from the plot
+ - ``remove('myCurve', kind='curve')`` removes the curve with
+ legend 'myCurve' from the plot.
+ - ``remove('myImage, kind='image')`` removes the image with
+ legend 'myImage' from the plot.
+ - ``remove('myImage')`` removes elements (for instance curve, image,
+ item and marker) with legend 'myImage'.
+
+ :param str legend: The legend associated to the element to remove,
+ or None to remove
+ :param kind: The kind of elements to remove from the plot.
+ See :attr:`ITEM_KINDS`.
+ By default, it removes all kind of elements.
+ :type kind: str or tuple of str to specify multiple kinds.
+ """
+ if kind is 'all': # Replace all by tuple of all kinds
+ kind = self.ITEM_KINDS
+
+ if kind in self.ITEM_KINDS: # Kind is a str, make it a tuple
+ kind = (kind,)
+
+ for aKind in kind:
+ assert aKind in self.ITEM_KINDS
+
+ if legend is None: # This is a clear
+ # Clear each given kind
+ for aKind in kind:
+ for legend in self._getItems(
+ kind=aKind, just_legend=True, withhidden=True):
+ self.remove(legend=legend, kind=aKind)
+
+ else: # This is removing a single element
+ # Remove each given kind
+ for aKind in kind:
+ item = self._getItem(aKind, legend)
+ if item is not None:
+ self._remove(item)
+
+ def removeCurve(self, legend):
+ """Remove the curve associated to legend from the graph.
+
+ :param str legend: The legend associated to the curve to be deleted
+ """
+ if legend is None:
+ return
+ self.remove(legend, kind='curve')
+
+ def removeImage(self, legend):
+ """Remove the image associated to legend from the graph.
+
+ :param str legend: The legend associated to the image to be deleted
+ """
+ if legend is None:
+ return
+ self.remove(legend, kind='image')
+
+ def removeItem(self, legend):
+ """Remove the item associated to legend from the graph.
+
+ :param str legend: The legend associated to the item to be deleted
+ """
+ if legend is None:
+ return
+ self.remove(legend, kind='item')
+
+ def removeMarker(self, legend):
+ """Remove the marker associated to legend from the graph.
+
+ :param str legend: The legend associated to the marker to be deleted
+ """
+ if legend is None:
+ return
+ self.remove(legend, kind='marker')
+
+ # Clear
+
+ def clear(self):
+ """Remove everything from the plot."""
+ self.remove()
+
+ def clearCurves(self):
+ """Remove all the curves from the plot."""
+ self.remove(kind='curve')
+
+ def clearImages(self):
+ """Remove all the images from the plot."""
+ self.remove(kind='image')
+
+ def clearItems(self):
+ """Remove all the items from the plot. """
+ self.remove(kind='item')
+
+ def clearMarkers(self):
+ """Remove all the markers from the plot."""
+ self.remove(kind='marker')
+
+ # Interaction
+
+ def getGraphCursor(self):
+ """Returns the state of the crosshair cursor.
+
+ See :meth:`setGraphCursor`.
+
+ :return: None if the crosshair cursor is not active,
+ else a tuple (color, linewidth, linestyle).
+ """
+ return self._cursorConfiguration
+
+ def setGraphCursor(self, flag=False, color='black',
+ linewidth=1, linestyle='-'):
+ """Toggle the display of a crosshair cursor and set its attributes.
+
+ :param bool flag: Toggle the display of a crosshair cursor.
+ The crosshair cursor is hidden by default.
+ :param color: The color to use for the crosshair.
+ :type color: A string (either a predefined color name in Colors.py
+ or "#RRGGBB")) or a 4 columns unsigned byte array
+ (Default: black).
+ :param int linewidth: The width of the lines of the crosshair
+ (Default: 1).
+ :param str linestyle: Type of line::
+
+ - ' ' no line
+ - '-' solid line (the default)
+ - '--' dashed line
+ - '-.' dash-dot line
+ - ':' dotted line
+ """
+ if flag:
+ self._cursorConfiguration = color, linewidth, linestyle
+ else:
+ self._cursorConfiguration = None
+
+ self._backend.setGraphCursor(flag=flag, color=color,
+ linewidth=linewidth, linestyle=linestyle)
+ self._setDirtyPlot()
+ self.notify('setGraphCursor',
+ state=self._cursorConfiguration is not None)
+
+ def pan(self, direction, factor=0.1):
+ """Pan the graph in the given direction by the given factor.
+
+ Warning: Pan of right Y axis not implemented!
+
+ :param str direction: One of 'up', 'down', 'left', 'right'.
+ :param float factor: Proportion of the range used to pan the graph.
+ Must be strictly positive.
+ """
+ assert direction in ('up', 'down', 'left', 'right')
+ assert factor > 0.
+
+ if direction in ('left', 'right'):
+ xFactor = factor if direction == 'right' else - factor
+ xMin, xMax = self._xAxis.getLimits()
+
+ xMin, xMax = _utils.applyPan(xMin, xMax, xFactor,
+ self._xAxis.getScale() == self._xAxis.LOGARITHMIC)
+ self._xAxis.setLimits(xMin, xMax)
+
+ else: # direction in ('up', 'down')
+ sign = -1. if self._yAxis.isInverted() else 1.
+ yFactor = sign * (factor if direction == 'up' else -factor)
+ yMin, yMax = self._yAxis.getLimits()
+ yIsLog = self._yAxis.getScale() == self._yAxis.LOGARITHMIC
+
+ yMin, yMax = _utils.applyPan(yMin, yMax, yFactor, yIsLog)
+ self._yAxis.setLimits(yMin, yMax)
+
+ y2Min, y2Max = self._yRightAxis.getLimits()
+
+ y2Min, y2Max = _utils.applyPan(y2Min, y2Max, yFactor, yIsLog)
+ self._yRightAxis.setLimits(y2Min, y2Max)
+
+ # Active Curve/Image
+
+ def isActiveCurveHandling(self):
+ """Returns True if active curve selection is enabled."""
+ return self._activeCurveHandling
+
+ def setActiveCurveHandling(self, flag=True):
+ """Enable/Disable active curve selection.
+
+ :param bool flag: True (the default) to enable active curve selection.
+ """
+ if not flag:
+ self.setActiveCurve(None) # Reset active curve
+
+ self._activeCurveHandling = bool(flag)
+
+ def getActiveCurveColor(self):
+ """Get the color used to display the currently active curve.
+
+ See :meth:`setActiveCurveColor`.
+ """
+ return self._activeCurveColor
+
+ def setActiveCurveColor(self, color="#000000"):
+ """Set the color to use to display the currently active curve.
+
+ :param str color: Color of the active curve,
+ e.g., 'blue', 'b', '#FF0000' (Default: 'black')
+ """
+ if color is None:
+ color = "black"
+ if color in self.colorDict:
+ color = self.colorDict[color]
+ self._activeCurveColor = color
+
+ def getActiveCurve(self, just_legend=False):
+ """Return the currently active curve.
+
+ It returns None in case of not having an active curve.
+
+ :param bool just_legend: True to get the legend of the curve,
+ False (the default) to get the curve data
+ and info.
+ :return: Active curve's legend or corresponding
+ :class:`.items.Curve`
+ :rtype: str or :class:`.items.Curve` or None
+ """
+ if not self.isActiveCurveHandling():
+ return None
+
+ return self._getActiveItem(kind='curve', just_legend=just_legend)
+
+ def setActiveCurve(self, legend, replot=None):
+ """Make the curve associated to legend the active curve.
+
+ :param legend: The legend associated to the curve
+ or None to have no active curve.
+ :type legend: str or None
+ """
+ if replot is not None:
+ _logger.warning('setActiveCurve deprecated replot parameter')
+
+ if not self.isActiveCurveHandling():
+ return
+
+ return self._setActiveItem(kind='curve', legend=legend)
+
+ def getActiveImage(self, just_legend=False):
+ """Returns the currently active image.
+
+ It returns None in case of not having an active image.
+
+ :param bool just_legend: True to get the legend of the image,
+ False (the default) to get the image data
+ and info.
+ :return: Active image's legend or corresponding image object
+ :rtype: str, :class:`.items.ImageData`, :class:`.items.ImageRgba`
+ or None
+ """
+ return self._getActiveItem(kind='image', just_legend=just_legend)
+
+ def setActiveImage(self, legend, replot=None):
+ """Make the image associated to legend the active image.
+
+ :param str legend: The legend associated to the image
+ or None to have no active image.
+ """
+ if replot is not None:
+ _logger.warning('setActiveImage deprecated replot parameter')
+
+ return self._setActiveItem(kind='image', legend=legend)
+
+ def _getActiveItem(self, kind, just_legend=False):
+ """Return the currently active item of that kind if any
+
+ :param str kind: Type of item: 'curve', 'scatter' or 'image'
+ :param bool just_legend: True to get the legend,
+ False (default) to get the item
+ :return: legend or item or None if no active item
+ """
+ assert kind in self._ACTIVE_ITEM_KINDS
+
+ if self._activeLegend[kind] is None:
+ return None
+
+ if (self._activeLegend[kind], kind) not in self._content:
+ self._activeLegend[kind] = None
+ return None
+
+ if just_legend:
+ return self._activeLegend[kind]
+ else:
+ return self._getItem(kind, self._activeLegend[kind])
+
+ def _setActiveItem(self, kind, legend):
+ """Make the curve associated to legend the active curve.
+
+ :param str kind: Type of item: 'curve' or 'image'
+ :param legend: The legend associated to the curve
+ or None to have no active curve.
+ :type legend: str or None
+ """
+ assert kind in self._ACTIVE_ITEM_KINDS
+
+ xLabel = None
+ yLabel = None
+ yRightLabel = None
+
+ oldActiveItem = self._getActiveItem(kind=kind)
+
+ if oldActiveItem is not None: # Stop listening previous active image
+ oldActiveItem.sigItemChanged.disconnect(self._activeItemChanged)
+
+ # Curve specific: Reset highlight of previous active curve
+ if kind == 'curve' and oldActiveItem is not None:
+ oldActiveItem.setHighlighted(False)
+
+ if legend is None:
+ self._activeLegend[kind] = None
+ else:
+ legend = str(legend)
+ item = self._getItem(kind, legend)
+ if item is None:
+ _logger.warning("This %s does not exist: %s", kind, legend)
+ self._activeLegend[kind] = None
+ else:
+ self._activeLegend[kind] = legend
+
+ # Curve specific: handle highlight
+ if kind == 'curve':
+ item.setHighlightedColor(self.getActiveCurveColor())
+ item.setHighlighted(True)
+
+ if isinstance(item, items.LabelsMixIn):
+ if item.getXLabel() is not None:
+ xLabel = item.getXLabel()
+ if item.getYLabel() is not None:
+ if (isinstance(item, items.YAxisMixIn) and
+ item.getYAxis() == 'right'):
+ yRightLabel = item.getYLabel()
+ else:
+ yLabel = item.getYLabel()
+
+ # Start listening new active item
+ item.sigItemChanged.connect(self._activeItemChanged)
+
+ # Store current labels and update plot
+ self._xAxis._setCurrentLabel(xLabel)
+ self._yAxis._setCurrentLabel(yLabel)
+ self._yRightAxis._setCurrentLabel(yRightLabel)
+
+ self._setDirtyPlot()
+
+ activeLegend = self._activeLegend[kind]
+ if oldActiveItem is not None or activeLegend is not None:
+ if oldActiveItem is None:
+ oldActiveLegend = None
+ else:
+ oldActiveLegend = oldActiveItem.getLegend()
+ self.notify(
+ 'active' + kind[0].upper() + kind[1:] + 'Changed',
+ updated=oldActiveLegend != activeLegend,
+ previous=oldActiveLegend,
+ legend=activeLegend)
+
+ return activeLegend
+
+ def _activeItemChanged(self, type_):
+ """Listen for active item changed signal and broadcast signal
+
+ :param item.ItemChangedType type_: The type of item change
+ """
+ if not self.__muteActiveItemChanged:
+ item = self.sender()
+ if item is not None:
+ legend, kind = self._itemKey(item)
+ self.notify(
+ 'active' + kind[0].upper() + kind[1:] + 'Changed',
+ updated=False,
+ previous=legend,
+ legend=legend)
+
+ # Getters
+
+ def getAllCurves(self, just_legend=False, withhidden=False):
+ """Returns all curves legend or info and data.
+
+ It returns an empty list in case of not having any curve.
+
+ If just_legend is False, it returns a list of :class:`items.Curve`
+ objects describing the curves.
+ If just_legend is True, it returns a list of curves' legend.
+
+ :param bool just_legend: True to get the legend of the curves,
+ False (the default) to get the curves' data
+ and info.
+ :param bool withhidden: False (default) to skip hidden curves.
+ :return: list of curves' legend or :class:`.items.Curve`
+ :rtype: list of str or list of :class:`.items.Curve`
+ """
+ return self._getItems(kind='curve',
+ just_legend=just_legend,
+ withhidden=withhidden)
+
+ def getCurve(self, legend=None):
+ """Get the object describing a specific curve.
+
+ It returns None in case no matching curve is found.
+
+ :param str legend:
+ The legend identifying the curve.
+ If not provided or None (the default), the active curve is returned
+ or if there is no active curve, the latest updated curve that is
+ not hidden is returned if there are curves in the plot.
+ :return: None or :class:`.items.Curve` object
+ """
+ return self._getItem(kind='curve', legend=legend)
+
+ def getAllImages(self, just_legend=False):
+ """Returns all images legend or objects.
+
+ It returns an empty list in case of not having any image.
+
+ If just_legend is False, it returns a list of :class:`items.ImageBase`
+ objects describing the images.
+ If just_legend is True, it returns a list of legends.
+
+ :param bool just_legend: True to get the legend of the images,
+ False (the default) to get the images'
+ object.
+ :return: list of images' legend or :class:`.items.ImageBase`
+ :rtype: list of str or list of :class:`.items.ImageBase`
+ """
+ return self._getItems(kind='image',
+ just_legend=just_legend,
+ withhidden=True)
+
+ def getImage(self, legend=None):
+ """Get the object describing a specific image.
+
+ It returns None in case no matching image is found.
+
+ :param str legend:
+ The legend identifying the image.
+ If not provided or None (the default), the active image is returned
+ or if there is no active image, the latest updated image
+ is returned if there are images in the plot.
+ :return: None or :class:`.items.ImageBase` object
+ """
+ return self._getItem(kind='image', legend=legend)
+
+ def getScatter(self, legend=None):
+ """Get the object describing a specific scatter.
+
+ It returns None in case no matching scatter is found.
+
+ :param str legend:
+ The legend identifying the scatter.
+ If not provided or None (the default), the active scatter is
+ returned or if there is no active scatter, the latest updated
+ scatter is returned if there are scatters in the plot.
+ :return: None or :class:`.items.Scatter` object
+ """
+ return self._getItem(kind='scatter', legend=legend)
+
+ def getHistogram(self, legend=None):
+ """Get the object describing a specific histogram.
+
+ It returns None in case no matching histogram is found.
+
+ :param str legend:
+ The legend identifying the histogram.
+ If not provided or None (the default), the latest updated scatter
+ is returned if there are histograms in the plot.
+ :return: None or :class:`.items.Histogram` object
+ """
+ return self._getItem(kind='histogram', legend=legend)
+
+ def _getItems(self, kind=ITEM_KINDS, just_legend=False, withhidden=False):
+ """Retrieve all items of a kind in the plot
+
+ :param kind: The kind of elements to retrieve from the plot.
+ See :attr:`ITEM_KINDS`.
+ By default, it removes all kind of elements.
+ :type kind: str or tuple of str to specify multiple kinds.
+ :param str kind: Type of item: 'curve' or 'image'
+ :param bool just_legend: True to get the legend of the curves,
+ False (the default) to get the curves' data
+ and info.
+ :param bool withhidden: False (default) to skip hidden curves.
+ :return: list of legends or item objects
+ """
+ if kind is 'all': # Replace all by tuple of all kinds
+ kind = self.ITEM_KINDS
+
+ if kind in self.ITEM_KINDS: # Kind is a str, make it a tuple
+ kind = (kind,)
+
+ for aKind in kind:
+ assert aKind in self.ITEM_KINDS
+
+ output = []
+ for (legend, type_), item in self._content.items():
+ if type_ in kind and (withhidden or item.isVisible()):
+ output.append(legend if just_legend else item)
+ return output
+
+ def _getItem(self, kind, legend=None):
+ """Get an item from the plot: either an image or a curve.
+
+ Returns None if no match found.
+
+ :param str kind: Type of item to retrieve,
+ see :attr:`ITEM_KINDS`.
+ :param str legend: Legend of the item or
+ None to get active or last item
+ :return: Object describing the item or None
+ """
+ assert kind in self.ITEM_KINDS
+
+ if legend is not None:
+ return self._content.get((legend, kind), None)
+ else:
+ if kind in self._ACTIVE_ITEM_KINDS:
+ item = self._getActiveItem(kind=kind)
+ if item is not None: # Return active item if available
+ return item
+ # Return last visible item if any
+ allItems = self._getItems(
+ kind=kind, just_legend=False, withhidden=False)
+ return allItems[-1] if allItems else None
+
+ # Limits
+
+ def _notifyLimitsChanged(self, emitSignal=True):
+ """Send an event when plot area limits are changed."""
+ xRange = self._xAxis.getLimits()
+ yRange = self._yAxis.getLimits()
+ y2Range = self._yRightAxis.getLimits()
+ if emitSignal:
+ axes = self.getXAxis(), self.getYAxis(), self.getYAxis(axis="right")
+ ranges = xRange, yRange, y2Range
+ for axis, limits in zip(axes, ranges):
+ axis.sigLimitsChanged.emit(*limits)
+ event = PlotEvents.prepareLimitsChangedSignal(
+ id(self.getWidgetHandle()), xRange, yRange, y2Range)
+ self.notify(**event)
+
+ def getLimitsHistory(self):
+ """Returns the object handling the history of limits of the plot"""
+ return self._limitsHistory
+
+ def getGraphXLimits(self):
+ """Get the graph X (bottom) limits.
+
+ :return: Minimum and maximum values of the X axis
+ """
+ return self._backend.getGraphXLimits()
+
+ def setGraphXLimits(self, xmin, xmax, replot=None):
+ """Set the graph X (bottom) limits.
+
+ :param float xmin: minimum bottom axis value
+ :param float xmax: maximum bottom axis value
+ """
+ if replot is not None:
+ _logger.warning('setGraphXLimits deprecated replot parameter')
+ self._xAxis.setLimits(xmin, xmax)
+
+ def getGraphYLimits(self, axis='left'):
+ """Get the graph Y limits.
+
+ :param str axis: The axis for which to get the limits:
+ Either 'left' or 'right'
+ :return: Minimum and maximum values of the X axis
+ """
+ assert axis in ('left', 'right')
+ yAxis = self._yAxis if axis == 'left' else self._yRightAxis
+ return yAxis.getLimits()
+
+ def setGraphYLimits(self, ymin, ymax, axis='left', replot=None):
+ """Set the graph Y limits.
+
+ :param float ymin: minimum bottom axis value
+ :param float ymax: maximum bottom axis value
+ :param str axis: The axis for which to get the limits:
+ Either 'left' or 'right'
+ """
+ if replot is not None:
+ _logger.warning('setGraphYLimits deprecated replot parameter')
+ assert axis in ('left', 'right')
+ yAxis = self._yAxis if axis == 'left' else self._yRightAxis
+ return yAxis.setLimits(ymin, ymax)
+
+ def setLimits(self, xmin, xmax, ymin, ymax, y2min=None, y2max=None):
+ """Set the limits of the X and Y axes at once.
+
+ If y2min or y2max is None, the right Y axis limits are not updated.
+
+ :param float xmin: minimum bottom axis value
+ :param float xmax: maximum bottom axis value
+ :param float ymin: minimum left axis value
+ :param float ymax: maximum left axis value
+ :param float y2min: minimum right axis value or None (the default)
+ :param float y2max: maximum right axis value or None (the default)
+ """
+ # Deal with incorrect values
+ axis = self.getXAxis()
+ xmin, xmax = axis._checkLimits(xmin, xmax)
+ axis = self.getYAxis()
+ ymin, ymax = axis._checkLimits(ymin, ymax)
+
+ if y2min is None or y2max is None:
+ # if one limit is None, both are ignored
+ y2min, y2max = None, None
+ else:
+ axis = self.getYAxis(axis="right")
+ y2min, y2max = axis._checkLimits(y2min, y2max)
+
+ if self._viewConstrains:
+ view = self._viewConstrains.normalize(xmin, xmax, ymin, ymax)
+ xmin, xmax, ymin, ymax = view
+
+ self._backend.setLimits(xmin, xmax, ymin, ymax, y2min, y2max)
+ self._setDirtyPlot()
+ self._notifyLimitsChanged()
+
+ def _getViewConstraints(self):
+ """Return the plot object managing constaints on the plot view.
+
+ :rtype: ViewConstraints
+ """
+ if self._viewConstrains is None:
+ self._viewConstrains = ViewConstraints()
+ return self._viewConstrains
+
+ # Title and labels
+
+ def getGraphTitle(self):
+ """Return the plot main title as a str."""
+ return self._graphTitle
+
+ def setGraphTitle(self, title=""):
+ """Set the plot main title.
+
+ :param str title: Main title of the plot (default: '')
+ """
+ self._graphTitle = str(title)
+ self._backend.setGraphTitle(title)
+ self._setDirtyPlot()
+
+ def getGraphXLabel(self):
+ """Return the current X axis label as a str."""
+ return self._xAxis.getLabel()
+
+ def setGraphXLabel(self, label="X"):
+ """Set the plot X axis label.
+
+ The provided label can be temporarily replaced by the X label of the
+ active curve if any.
+
+ :param str label: The X axis label (default: 'X')
+ """
+ self._xAxis.setLabel(label)
+
+ def getGraphYLabel(self, axis='left'):
+ """Return the current Y axis label as a str.
+
+ :param str axis: The Y axis for which to get the label (left or right)
+ """
+ assert axis in ('left', 'right')
+ yAxis = self._yAxis if axis == 'left' else self._yRightAxis
+ return yAxis.getLabel()
+
+ def setGraphYLabel(self, label="Y", axis='left'):
+ """Set the plot Y axis label.
+
+ The provided label can be temporarily replaced by the Y label of the
+ active curve if any.
+
+ :param str label: The Y axis label (default: 'Y')
+ :param str axis: The Y axis for which to set the label (left or right)
+ """
+ assert axis in ('left', 'right')
+ yAxis = self._yAxis if axis == 'left' else self._yRightAxis
+ return yAxis.setLabel(label)
+
+ # Axes
+
+ def getXAxis(self):
+ """Returns the X axis
+
+ .. versionadded:: 0.6
+
+ :rtype: :class:`.items.Axis`
+ """
+ return self._xAxis
+
+ def getYAxis(self, axis="left"):
+ """Returns an Y axis
+
+ .. versionadded:: 0.6
+
+ :param str axis: The Y axis to return
+ ('left' or 'right').
+ :rtype: :class:`.items.Axis`
+ """
+ assert(axis in ["left", "right"])
+ return self._yAxis if axis == "left" else self._yRightAxis
+
+ def setAxesDisplayed(self, displayed):
+ """Display or not the axes.
+
+ :param bool displayed: If `True` axes are displayed. If `False` axes
+ are not anymore visible and the margin used for them is removed.
+ """
+ self._backend.setAxesDisplayed(displayed)
+ self._setDirtyPlot()
+ self._sigAxesVisibilityChanged.emit(displayed)
+
+ def _isAxesDisplayed(self):
+ return self._backend.isAxesDisplayed()
+
+ @property
+ @deprecated(since_version='0.6')
+ def sigSetYAxisInverted(self):
+ """Signal emitted when Y axis orientation has changed"""
+ return self._yAxis.sigInvertedChanged
+
+ @property
+ @deprecated(since_version='0.6')
+ def sigSetXAxisLogarithmic(self):
+ """Signal emitted when X axis scale has changed"""
+ return self._xAxis._sigLogarithmicChanged
+
+ @property
+ @deprecated(since_version='0.6')
+ def sigSetYAxisLogarithmic(self):
+ """Signal emitted when Y axis scale has changed"""
+ return self._yAxis._sigLogarithmicChanged
+
+ @property
+ @deprecated(since_version='0.6')
+ def sigSetXAxisAutoScale(self):
+ """Signal emitted when X axis autoscale has changed"""
+ return self._xAxis.sigAutoScaleChanged
+
+ @property
+ @deprecated(since_version='0.6')
+ def sigSetYAxisAutoScale(self):
+ """Signal emitted when Y axis autoscale has changed"""
+ return self._yAxis.sigAutoScaleChanged
+
+ def setYAxisInverted(self, flag=True):
+ """Set the Y axis orientation.
+
+ :param bool flag: True for Y axis going from top to bottom,
+ False for Y axis going from bottom to top
+ """
+ self._yAxis.setInverted(flag)
+
+ def isYAxisInverted(self):
+ """Return True if Y axis goes from top to bottom, False otherwise."""
+ return self._yAxis.isInverted()
+
+ def isXAxisLogarithmic(self):
+ """Return True if X axis scale is logarithmic, False if linear."""
+ return self._xAxis._isLogarithmic()
+
+ def setXAxisLogarithmic(self, flag):
+ """Set the bottom X axis scale (either linear or logarithmic).
+
+ :param bool flag: True to use a logarithmic scale, False for linear.
+ """
+ self._xAxis._setLogarithmic(flag)
+
+ def isYAxisLogarithmic(self):
+ """Return True if Y axis scale is logarithmic, False if linear."""
+ return self._yAxis._isLogarithmic()
+
+ def setYAxisLogarithmic(self, flag):
+ """Set the Y axes scale (either linear or logarithmic).
+
+ :param bool flag: True to use a logarithmic scale, False for linear.
+ """
+ self._yAxis._setLogarithmic(flag)
+
+ def isXAxisAutoScale(self):
+ """Return True if X axis is automatically adjusting its limits."""
+ return self._xAxis.isAutoScale()
+
+ def setXAxisAutoScale(self, flag=True):
+ """Set the X axis limits adjusting behavior of :meth:`resetZoom`.
+
+ :param bool flag: True to resize limits automatically,
+ False to disable it.
+ """
+ self._xAxis.setAutoScale(flag)
+
+ def isYAxisAutoScale(self):
+ """Return True if Y axes are automatically adjusting its limits."""
+ return self._yAxis.isAutoScale()
+
+ def setYAxisAutoScale(self, flag=True):
+ """Set the Y axis limits adjusting behavior of :meth:`resetZoom`.
+
+ :param bool flag: True to resize limits automatically,
+ False to disable it.
+ """
+ self._yAxis.setAutoScale(flag)
+
+ def isKeepDataAspectRatio(self):
+ """Returns whether the plot is keeping data aspect ratio or not."""
+ return self._backend.isKeepDataAspectRatio()
+
+ def setKeepDataAspectRatio(self, flag=True):
+ """Set whether the plot keeps data aspect ratio or not.
+
+ :param bool flag: True to respect data aspect ratio
+ """
+ flag = bool(flag)
+ self._backend.setKeepDataAspectRatio(flag=flag)
+ self._setDirtyPlot()
+ self.resetZoom()
+ self.notify('setKeepDataAspectRatio', state=flag)
+
+ def getGraphGrid(self):
+ """Return the current grid mode, either None, 'major' or 'both'.
+
+ See :meth:`setGraphGrid`.
+ """
+ return self._grid
+
+ def setGraphGrid(self, which=True):
+ """Set the type of grid to display.
+
+ :param which: None or False to disable the grid,
+ 'major' or True for grid on major ticks (the default),
+ 'both' for grid on both major and minor ticks.
+ :type which: str of bool
+ """
+ assert which in (None, True, False, 'both', 'major')
+ if not which:
+ which = None
+ elif which is True:
+ which = 'major'
+ self._grid = which
+ self._backend.setGraphGrid(which)
+ self._setDirtyPlot()
+ self.notify('setGraphGrid', which=str(which))
+
+ # Defaults
+
+ def isDefaultPlotPoints(self):
+ """Return True if default Curve symbol is 'o', False for no symbol."""
+ return self._defaultPlotPoints == 'o'
+
+ def setDefaultPlotPoints(self, flag):
+ """Set the default symbol of all curves.
+
+ When called, this reset the symbol of all existing curves.
+
+ :param bool flag: True to use 'o' as the default curve symbol,
+ False to use no symbol.
+ """
+ self._defaultPlotPoints = 'o' if flag else ''
+
+ # Reset symbol of all curves
+ curves = self.getAllCurves(just_legend=False, withhidden=True)
+
+ if curves:
+ for curve in curves:
+ curve.setSymbol(self._defaultPlotPoints)
+
+ def isDefaultPlotLines(self):
+ """Return True for line as default line style, False for no line."""
+ return self._plotLines
+
+ def setDefaultPlotLines(self, flag):
+ """Toggle the use of lines as the default curve line style.
+
+ :param bool flag: True to use a line as the default line style,
+ False to use no line as the default line style.
+ """
+ self._plotLines = bool(flag)
+
+ linestyle = '-' if self._plotLines else ' '
+
+ # Reset linestyle of all curves
+ curves = self.getAllCurves(withhidden=True)
+
+ if curves:
+ for curve in curves:
+ curve.setLineStyle(linestyle)
+
+ def getDefaultColormap(self):
+ """Return the default :class:`.Colormap` used by :meth:`addImage`.
+
+ """
+ return self._defaultColormap
+
+ def setDefaultColormap(self, colormap=None):
+ """Set the default colormap used by :meth:`addImage`.
+
+ Setting the default colormap do not change any currently displayed
+ image.
+ 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.
+ """
+ if colormap is None:
+ colormap = Colormap(name='gray',
+ normalization='linear',
+ vmin=None,
+ vmax=None)
+ if isinstance(colormap, dict):
+ self._defaultColormap = Colormap._fromDict(colormap)
+ else:
+ assert isinstance(colormap, Colormap)
+ self._defaultColormap = colormap
+ self.notify('defaultColormapChanged')
+
+ @staticmethod
+ def getSupportedColormaps():
+ """Get the supported colormap names as a tuple of str.
+
+ The list contains at least:
+ ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue',
+ 'magma', 'inferno', 'plasma', 'viridis')
+ """
+ return Colormap.getSupportedColormaps()
+
+ def _getColorAndStyle(self):
+ color = self.colorList[self._colorIndex]
+ style = self._styleList[self._styleIndex]
+
+ # Loop over color and then styles
+ self._colorIndex += 1
+ if self._colorIndex >= len(self.colorList):
+ self._colorIndex = 0
+ self._styleIndex = (self._styleIndex + 1) % len(self._styleList)
+
+ # If color is the one of active curve, take the next one
+ if color == self.getActiveCurveColor():
+ color, style = self._getColorAndStyle()
+
+ if not self._plotLines:
+ style = ' '
+
+ return color, style
+
+ # Misc.
+
+ def getWidgetHandle(self):
+ """Return the widget the plot is displayed in.
+
+ This widget is owned by the backend.
+ """
+ return self._backend.getWidgetHandle()
+
def notify(self, event, **kwargs):
- """Override :meth:`Plot.notify` to send Qt signals."""
+ """Send an event to the listeners and send signals.
+
+ Event are passed to the registered callback as a dict with an 'event'
+ key for backward compatibility with PyMca.
+
+ :param str event: The type of event
+ :param kwargs: The information of the event.
+ """
eventDict = kwargs.copy()
eventDict['event'] = event
self.sigPlotSignal.emit(eventDict)
- if event == 'setYAxisInverted':
- self.sigSetYAxisInverted.emit(kwargs['state'])
- elif event == 'setXAxisLogarithmic':
- self.sigSetXAxisLogarithmic.emit(kwargs['state'])
- elif event == 'setYAxisLogarithmic':
- self.sigSetYAxisLogarithmic.emit(kwargs['state'])
- elif event == 'setXAxisAutoScale':
- self.sigSetXAxisAutoScale.emit(kwargs['state'])
- elif event == 'setYAxisAutoScale':
- self.sigSetYAxisAutoScale.emit(kwargs['state'])
- elif event == 'setKeepDataAspectRatio':
+ if event == 'setKeepDataAspectRatio':
self.sigSetKeepDataAspectRatio.emit(kwargs['state'])
elif event == 'setGraphGrid':
self.sigSetGraphGrid.emit(kwargs['which'])
@@ -201,7 +2492,494 @@ class PlotWidget(qt.QMainWindow, Plot.Plot):
kwargs['previous'], kwargs['legend'])
elif event == 'interactiveModeChanged':
self.sigInteractiveModeChanged.emit(kwargs['source'])
- Plot.Plot.notify(self, event, **kwargs)
+
+ eventDict = kwargs.copy()
+ eventDict['event'] = event
+ self._callback(eventDict)
+
+ def setCallback(self, callbackFunction=None):
+ """Attach a listener to the backend.
+
+ Limitation: Only one listener at a time.
+
+ :param callbackFunction: function accepting a dictionary as input
+ to handle the graph events
+ If None (default), use a default listener.
+ """
+ # TODO allow multiple listeners, keep a weakref on it
+ # allow register listener by event type
+ if callbackFunction is None:
+ callbackFunction = self.graphCallback
+ self._callback = callbackFunction
+
+ def graphCallback(self, ddict=None):
+ """This callback is going to receive all the events from the plot.
+
+ Those events will consist on a dictionary and among the dictionary
+ keys the key 'event' is mandatory to describe the type of event.
+ This default implementation only handles setting the active curve.
+ """
+
+ if ddict is None:
+ ddict = {}
+ _logger.debug("Received dict keys = %s", str(ddict.keys()))
+ _logger.debug(str(ddict))
+ if ddict['event'] in ["legendClicked", "curveClicked"]:
+ if ddict['button'] == "left":
+ self.setActiveCurve(ddict['label'])
+
+ def saveGraph(self, filename, fileFormat=None, dpi=None, **kw):
+ """Save a snapshot of the plot.
+
+ Supported file formats depends on the backend in use.
+ The following file formats are always supported: "png", "svg".
+ The matplotlib backend supports more formats:
+ "pdf", "ps", "eps", "tiff", "jpeg", "jpg".
+
+ :param filename: Destination
+ :type filename: str, StringIO or BytesIO
+ :param str fileFormat: String specifying the format
+ :return: False if cannot save the plot, True otherwise
+ """
+ if kw:
+ _logger.warning('Extra parameters ignored: %s', str(kw))
+
+ if fileFormat is None:
+ if not hasattr(filename, 'lower'):
+ _logger.warning(
+ 'saveGraph cancelled, cannot define file format.')
+ return False
+ else:
+ fileFormat = (filename.split(".")[-1]).lower()
+
+ supportedFormats = ("png", "svg", "pdf", "ps", "eps",
+ "tif", "tiff", "jpeg", "jpg")
+
+ if fileFormat not in supportedFormats:
+ _logger.warning('Unsupported format %s', fileFormat)
+ return False
+ else:
+ self._backend.saveGraph(filename,
+ fileFormat=fileFormat,
+ dpi=dpi)
+ return True
+
+ def getDataMargins(self):
+ """Get the default data margin ratios, see :meth:`setDataMargins`.
+
+ :return: The margin ratios for each side (xMin, xMax, yMin, yMax).
+ :rtype: A 4-tuple of floats.
+ """
+ return self._defaultDataMargins
+
+ def setDataMargins(self, xMinMargin=0., xMaxMargin=0.,
+ yMinMargin=0., yMaxMargin=0.):
+ """Set the default data margins to use in :meth:`resetZoom`.
+
+ Set the default ratios of margins (as floats) to add around the data
+ inside the plot area for each side.
+ """
+ self._defaultDataMargins = (xMinMargin, xMaxMargin,
+ yMinMargin, yMaxMargin)
+
+ def getAutoReplot(self):
+ """Return True if replot is automatically handled, False otherwise.
+
+ See :meth`setAutoReplot`.
+ """
+ return self._autoreplot
+
+ def setAutoReplot(self, autoreplot=True):
+ """Set automatic replot mode.
+
+ When enabled, the plot is redrawn automatically when changed.
+ When disabled, the plot is not redrawn when its content change.
+ Instead, it :meth:`replot` must be called.
+
+ :param bool autoreplot: True to enable it (default),
+ False to disable it.
+ """
+ self._autoreplot = bool(autoreplot)
+
+ # If the plot is dirty before enabling autoreplot,
+ # then _backend.postRedisplay will never be called from _setDirtyPlot
+ if self._autoreplot and self._getDirtyPlot():
+ self._backend.postRedisplay()
+
+ def replot(self):
+ """Redraw the plot immediately."""
+ for item in self._contentToUpdate:
+ item._update(self._backend)
+ self._contentToUpdate = []
+ self._backend.replot()
+ self._dirty = False # reset dirty flag
+
+ def resetZoom(self, dataMargins=None):
+ """Reset the plot limits to the bounds of the data and redraw the plot.
+
+ It automatically scale limits of axes that are in autoscale mode
+ (see :meth:`getXAxis`, :meth:`getYAxis` and :meth:`Axis.setAutoScale`).
+ It keeps current limits on axes that are not in autoscale mode.
+
+ Extra margins can be added around the data inside the plot area
+ (see :meth:`setDataMargins`).
+ Margins are given as one ratio of the data range per limit of the
+ data (xMin, xMax, yMin and yMax limits).
+ For log scale, extra margins are applied in log10 of the data.
+
+ :param dataMargins: Ratios of margins to add around the data inside
+ the plot area for each side (default: no margins).
+ :type dataMargins: A 4-tuple of float as (xMin, xMax, yMin, yMax).
+ """
+ if dataMargins is None:
+ dataMargins = self._defaultDataMargins
+
+ xLimits = self._xAxis.getLimits()
+ yLimits = self._yAxis.getLimits()
+ y2Limits = self._yRightAxis.getLimits()
+
+ xAuto = self._xAxis.isAutoScale()
+ yAuto = self._yAxis.isAutoScale()
+
+ if not xAuto and not yAuto:
+ _logger.debug("Nothing to autoscale")
+ else: # Some axes to autoscale
+
+ # Get data range
+ ranges = self.getDataRange()
+ xmin, xmax = (1., 100.) if ranges.x is None else ranges.x
+ ymin, ymax = (1., 100.) if ranges.y is None else ranges.y
+ if ranges.yright is None:
+ ymin2, ymax2 = None, None
+ else:
+ ymin2, ymax2 = ranges.yright
+
+ # Add margins around data inside the plot area
+ newLimits = list(_utils.addMarginsToLimits(
+ dataMargins,
+ self._xAxis._isLogarithmic(),
+ self._yAxis._isLogarithmic(),
+ xmin, xmax, ymin, ymax, ymin2, ymax2))
+
+ if self.isKeepDataAspectRatio():
+ # Use limits with margins to keep ratio
+ xmin, xmax, ymin, ymax = newLimits[:4]
+
+ # Compute bbox wth figure aspect ratio
+ plotWidth, plotHeight = self.getPlotBoundsInPixels()[2:]
+ plotRatio = plotHeight / plotWidth
+
+ if plotRatio > 0.:
+ dataRatio = (ymax - ymin) / (xmax - xmin)
+ if dataRatio < plotRatio:
+ # Increase y range
+ ycenter = 0.5 * (ymax + ymin)
+ yrange = (xmax - xmin) * plotRatio
+ newLimits[2] = ycenter - 0.5 * yrange
+ newLimits[3] = ycenter + 0.5 * yrange
+
+ elif dataRatio > plotRatio:
+ # Increase x range
+ xcenter = 0.5 * (xmax + xmin)
+ xrange_ = (ymax - ymin) / plotRatio
+ newLimits[0] = xcenter - 0.5 * xrange_
+ newLimits[1] = xcenter + 0.5 * xrange_
+
+ self.setLimits(*newLimits)
+
+ if not xAuto and yAuto:
+ self.setGraphXLimits(*xLimits)
+ elif xAuto and not yAuto:
+ if y2Limits is not None:
+ self.setGraphYLimits(
+ y2Limits[0], y2Limits[1], axis='right')
+ if yLimits is not None:
+ self.setGraphYLimits(yLimits[0], yLimits[1], axis='left')
+
+ self._setDirtyPlot()
+
+ if (xLimits != self._xAxis.getLimits() or
+ yLimits != self._yAxis.getLimits() or
+ y2Limits != self._yRightAxis.getLimits()):
+ self._notifyLimitsChanged()
+
+ # Coord conversion
+
+ def dataToPixel(self, x=None, y=None, axis="left", check=True):
+ """Convert a position in data coordinates to a position in pixels.
+
+ :param float x: The X coordinate in data space. If None (default)
+ the middle position of the displayed data is used.
+ :param float y: The Y coordinate in data space. If None (default)
+ the middle position of the displayed data is used.
+ :param str axis: The Y axis to use for the conversion
+ ('left' or 'right').
+ :param bool check: True to return None if outside displayed area,
+ False to convert to pixels anyway
+ :returns: The corresponding position in pixels or
+ None if the data position is not in the displayed area and
+ check is True.
+ :rtype: A tuple of 2 floats: (xPixel, yPixel) or None.
+ """
+ assert axis in ("left", "right")
+
+ xmin, xmax = self._xAxis.getLimits()
+ yAxis = self.getYAxis(axis=axis)
+ ymin, ymax = yAxis.getLimits()
+
+ if x is None:
+ x = 0.5 * (xmax + xmin)
+ if y is None:
+ y = 0.5 * (ymax + ymin)
+
+ if check:
+ if x > xmax or x < xmin:
+ return None
+
+ if y > ymax or y < ymin:
+ return None
+
+ return self._backend.dataToPixel(x, y, axis=axis)
+
+ def pixelToData(self, x, y, axis="left", check=False):
+ """Convert a position in pixels to a position in data coordinates.
+
+ :param float x: The X coordinate in pixels. If None (default)
+ the center of the widget is used.
+ :param float y: The Y coordinate in pixels. If None (default)
+ the center of the widget is used.
+ :param str axis: The Y axis to use for the conversion
+ ('left' or 'right').
+ :param bool check: Toggle checking if pixel is in plot area.
+ If False, this method never returns None.
+ :returns: The corresponding position in data space or
+ None if the pixel position is not in the plot area.
+ :rtype: A tuple of 2 floats: (xData, yData) or None.
+ """
+ assert axis in ("left", "right")
+ return self._backend.pixelToData(x, y, axis=axis, check=check)
+
+ def getPlotBoundsInPixels(self):
+ """Plot area bounds in widget coordinates in pixels.
+
+ :return: bounds as a 4-tuple of int: (left, top, width, height)
+ """
+ return self._backend.getPlotBoundsInPixels()
+
+ # Interaction support
+
+ def setGraphCursorShape(self, cursor=None):
+ """Set the cursor shape.
+
+ :param str cursor: Name of the cursor shape
+ """
+ self._backend.setGraphCursorShape(cursor)
+
+ def _pickMarker(self, x, y, test=None):
+ """Pick a marker at the given position.
+
+ To use for interaction implementation.
+
+ :param float x: X position in pixels.
+ :param float y: Y position in pixels.
+ :param test: A callable to call for each picked marker to filter
+ picked markers. If None (default), do not filter markers.
+ """
+ if test is None:
+ def test(mark):
+ return True
+
+ markers = self._backend.pickItems(x, y)
+ legends = [m['legend'] for m in markers if m['kind'] == 'marker']
+
+ for legend in reversed(legends):
+ marker = self._getMarker(legend)
+ if marker is not None and test(marker):
+ return marker
+ return None
+
+ def _getAllMarkers(self, just_legend=False):
+ """Returns all markers' legend or objects
+
+ :param bool just_legend: True to get the legend of the markers,
+ False (the default) to get marker objects.
+ :return: list of legend of list of marker objects
+ :rtype: list of str or list of marker objects
+ """
+ return self._getItems(
+ kind='marker', just_legend=just_legend, withhidden=True)
+
+ def _getMarker(self, legend=None):
+ """Get the object describing a specific marker.
+
+ It returns None in case no matching marker is found
+
+ :param str legend: The legend of the marker to retrieve
+ :rtype: None of marker object
+ """
+ return self._getItem(kind='marker', legend=legend)
+
+ def _pickImageOrCurve(self, x, y, test=None):
+ """Pick an image or a curve at the given position.
+
+ To use for interaction implementation.
+
+ :param float x: X position in pixelsparam 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.
+ """
+ if test is None:
+ def test(i):
+ return True
+
+ allItems = self._backend.pickItems(x, y)
+ allItems = [item for item in allItems
+ if item['kind'] in ['curve', 'image']]
+
+ for item in reversed(allItems):
+ kind, legend = item['kind'], item['legend']
+ if kind == 'curve':
+ curve = self.getCurve(legend)
+ if curve is not None and test(curve):
+ return kind, curve, item['indices']
+
+ elif kind == 'image':
+ image = self.getImage(legend)
+ if image is not None and test(image):
+ return kind, image, None
+
+ else:
+ _logger.warning('Unsupported kind: %s', kind)
+
+ return None
+
+ # User event handling #
+
+ def _isPositionInPlotArea(self, x, y):
+ """Project position in pixel to the closest point in the plot area
+
+ :param float x: X coordinate in widget coordinate (in pixel)
+ :param float y: Y coordinate in widget coordinate (in pixel)
+ :return: (x, y) in widget coord (in pixel) in the plot area
+ """
+ left, top, width, height = self.getPlotBoundsInPixels()
+ xPlot = numpy.clip(x, left, left + width)
+ yPlot = numpy.clip(y, top, top + height)
+ return xPlot, yPlot
+
+ def onMousePress(self, xPixel, yPixel, btn):
+ """Handle mouse press event.
+
+ :param float xPixel: X mouse position in pixels
+ :param float yPixel: Y mouse position in pixels
+ :param str btn: Mouse button in 'left', 'middle', 'right'
+ """
+ if self._isPositionInPlotArea(xPixel, yPixel) == (xPixel, yPixel):
+ self._pressedButtons.append(btn)
+ self._eventHandler.handleEvent('press', xPixel, yPixel, btn)
+
+ def onMouseMove(self, xPixel, yPixel):
+ """Handle mouse move event.
+
+ :param float xPixel: X mouse position in pixels
+ :param float yPixel: Y mouse position in pixels
+ """
+ inXPixel, inYPixel = self._isPositionInPlotArea(xPixel, yPixel)
+ isCursorInPlot = inXPixel == xPixel and inYPixel == yPixel
+
+ if self._cursorInPlot != isCursorInPlot:
+ self._cursorInPlot = isCursorInPlot
+ self._eventHandler.handleEvent(
+ 'enter' if self._cursorInPlot else 'leave')
+
+ if isCursorInPlot:
+ # Signal mouse move event
+ dataPos = self.pixelToData(inXPixel, inYPixel)
+ assert dataPos is not None
+
+ btn = self._pressedButtons[-1] if self._pressedButtons else None
+ event = PlotEvents.prepareMouseSignal(
+ 'mouseMoved', btn, dataPos[0], dataPos[1], xPixel, yPixel)
+ self.notify(**event)
+
+ # Either button was pressed in the plot or cursor is in the plot
+ if isCursorInPlot or self._pressedButtons:
+ self._eventHandler.handleEvent('move', inXPixel, inYPixel)
+
+ def onMouseRelease(self, xPixel, yPixel, btn):
+ """Handle mouse release event.
+
+ :param float xPixel: X mouse position in pixels
+ :param float yPixel: Y mouse position in pixels
+ :param str btn: Mouse button in 'left', 'middle', 'right'
+ """
+ try:
+ self._pressedButtons.remove(btn)
+ except ValueError:
+ pass
+ else:
+ xPixel, yPixel = self._isPositionInPlotArea(xPixel, yPixel)
+ self._eventHandler.handleEvent('release', xPixel, yPixel, btn)
+
+ def onMouseWheel(self, xPixel, yPixel, angleInDegrees):
+ """Handle mouse wheel event.
+
+ :param float xPixel: X mouse position in pixels
+ :param float yPixel: Y mouse position in pixels
+ :param float angleInDegrees: Angle corresponding to wheel motion.
+ Positive for movement away from the user,
+ negative for movement toward the user.
+ """
+ if self._isPositionInPlotArea(xPixel, yPixel) == (xPixel, yPixel):
+ self._eventHandler.handleEvent(
+ 'wheel', xPixel, yPixel, angleInDegrees)
+
+ def onMouseLeaveWidget(self):
+ """Handle mouse leave widget event."""
+ if self._cursorInPlot:
+ self._cursorInPlot = False
+ self._eventHandler.handleEvent('leave')
+
+ # Interaction modes #
+
+ def getInteractiveMode(self):
+ """Returns the current interactive mode as a dict.
+
+ The returned dict contains at least the key 'mode'.
+ Mode can be: 'draw', 'pan', 'select', 'zoom'.
+ It can also contains extra keys (e.g., 'color') specific to a mode
+ as provided to :meth:`setInteractiveMode`.
+ """
+ return self._eventHandler.getInteractiveMode()
+
+ def setInteractiveMode(self, mode, color='black',
+ shape='polygon', label=None,
+ zoomOnWheel=True, source=None, width=None):
+ """Switch the interactive mode.
+
+ :param str mode: The name of the interactive mode.
+ In 'draw', 'pan', 'select', 'zoom'.
+ :param color: Only for 'draw' and 'zoom' modes.
+ Color to use for drawing selection area. Default black.
+ :type color: Color description: The name as a str or
+ a tuple of 4 floats.
+ :param str shape: Only for 'draw' mode. The kind of shape to draw.
+ In 'polygon', 'rectangle', 'line', 'vline', 'hline',
+ 'freeline'.
+ Default is 'polygon'.
+ :param str label: Only for 'draw' mode, sent in drawing events.
+ :param bool zoomOnWheel: Toggle zoom on wheel support
+ :param source: A user-defined object (typically the caller object)
+ that will be send in the interactiveModeChanged event,
+ to identify which object required a mode change.
+ Default: None
+ :param float width: Width of the pencil. Only for draw pencil mode.
+ """
+ self._eventHandler.setInteractiveMode(mode, color, shape, label, width)
+ self._eventHandler.zoomOnWheel = zoomOnWheel
+
+ self.notify(
+ 'interactiveModeChanged', source=source)
# Panning with arrow keys
@@ -265,3 +3043,149 @@ class PlotWidget(qt.QMainWindow, Plot.Plot):
# Only call base class implementation when key is not handled.
# See QWidget.keyPressEvent for details.
super(PlotWidget, self).keyPressEvent(event)
+
+ # Deprecated #
+
+ def isDrawModeEnabled(self):
+ """Deprecated, use :meth:`getInteractiveMode` instead.
+
+ Return True if the current interactive state is drawing."""
+ _logger.warning(
+ 'isDrawModeEnabled deprecated, use getInteractiveMode instead')
+ return self.getInteractiveMode()['mode'] == 'draw'
+
+ def setDrawModeEnabled(self, flag=True, shape='polygon', label=None,
+ color=None, **kwargs):
+ """Deprecated, use :meth:`setInteractiveMode` instead.
+
+ Set the drawing mode if flag is True and its parameters.
+
+ If flag is False, only item selection is enabled.
+
+ Warning: Zoom and drawing are not compatible and cannot be enabled
+ simultaneously.
+
+ :param bool flag: True to enable drawing and disable zoom and select.
+ :param str shape: Type of item to be drawn in:
+ hline, vline, rectangle, polygon (default)
+ :param str label: Associated text for identifying draw signals
+ :param color: The color to use to draw the selection area
+ :type color: string ("#RRGGBB") or 4 column unsigned byte array or
+ one of the predefined color names defined in Colors.py
+ """
+ _logger.warning(
+ 'setDrawModeEnabled deprecated, use setInteractiveMode instead')
+
+ if kwargs:
+ _logger.warning('setDrawModeEnabled ignores additional parameters')
+
+ if color is None:
+ color = 'black'
+
+ if flag:
+ self.setInteractiveMode('draw', shape=shape,
+ label=label, color=color)
+ elif self.getInteractiveMode()['mode'] == 'draw':
+ self.setInteractiveMode('select')
+
+ def getDrawMode(self):
+ """Deprecated, use :meth:`getInteractiveMode` instead.
+
+ Return the draw mode parameters as a dict of None.
+
+ It returns None if the interactive mode is not a drawing mode,
+ otherwise, it returns a dict containing the drawing mode parameters
+ as provided to :meth:`setDrawModeEnabled`.
+ """
+ _logger.warning(
+ 'getDrawMode deprecated, use getInteractiveMode instead')
+ mode = self.getInteractiveMode()
+ return mode if mode['mode'] == 'draw' else None
+
+ def isZoomModeEnabled(self):
+ """Deprecated, use :meth:`getInteractiveMode` instead.
+
+ Return True if the current interactive state is zooming."""
+ _logger.warning(
+ 'isZoomModeEnabled deprecated, use getInteractiveMode instead')
+ return self.getInteractiveMode()['mode'] == 'zoom'
+
+ def setZoomModeEnabled(self, flag=True, color=None):
+ """Deprecated, use :meth:`setInteractiveMode` instead.
+
+ Set the zoom mode if flag is True, else item selection is enabled.
+
+ Warning: Zoom and drawing are not compatible and cannot be enabled
+ simultaneously
+
+ :param bool flag: If True, enable zoom and select mode.
+ :param color: The color to use to draw the selection area.
+ (Default: 'black')
+ :param color: The color to use to draw the selection area
+ :type color: string ("#RRGGBB") or 4 column unsigned byte array or
+ one of the predefined color names defined in Colors.py
+ """
+ _logger.warning(
+ 'setZoomModeEnabled deprecated, use setInteractiveMode instead')
+ if color is None:
+ color = 'black'
+
+ if flag:
+ self.setInteractiveMode('zoom', color=color)
+ elif self.getInteractiveMode()['mode'] == 'zoom':
+ self.setInteractiveMode('select')
+
+ def insertMarker(self, *args, **kwargs):
+ """Deprecated, use :meth:`addMarker` instead."""
+ _logger.warning(
+ 'insertMarker deprecated, use addMarker instead.')
+ return self.addMarker(*args, **kwargs)
+
+ def insertXMarker(self, *args, **kwargs):
+ """Deprecated, use :meth:`addXMarker` instead."""
+ _logger.warning(
+ 'insertXMarker deprecated, use addXMarker instead.')
+ return self.addXMarker(*args, **kwargs)
+
+ def insertYMarker(self, *args, **kwargs):
+ """Deprecated, use :meth:`addYMarker` instead."""
+ _logger.warning(
+ 'insertYMarker deprecated, use addYMarker instead.')
+ return self.addYMarker(*args, **kwargs)
+
+ def isActiveCurveHandlingEnabled(self):
+ """Deprecated, use :meth:`isActiveCurveHandling` instead."""
+ _logger.warning(
+ 'isActiveCurveHandlingEnabled deprecated, '
+ 'use isActiveCurveHandling instead.')
+ return self.isActiveCurveHandling()
+
+ def enableActiveCurveHandling(self, *args, **kwargs):
+ """Deprecated, use :meth:`setActiveCurveHandling` instead."""
+ _logger.warning(
+ 'enableActiveCurveHandling deprecated, '
+ 'use setActiveCurveHandling instead.')
+ return self.setActiveCurveHandling(*args, **kwargs)
+
+ def invertYAxis(self, *args, **kwargs):
+ """Deprecated, use :meth:`Axis.setInverted` instead."""
+ _logger.warning('invertYAxis deprecated, '
+ 'use getYAxis().setInverted instead.')
+ return self.getYAxis().setInverted(*args, **kwargs)
+
+ def showGrid(self, flag=True):
+ """Deprecated, use :meth:`setGraphGrid` instead."""
+ _logger.warning("showGrid deprecated, use setGraphGrid instead")
+ if flag in (0, False):
+ flag = None
+ elif flag in (1, True):
+ flag = 'major'
+ else:
+ flag = 'both'
+ return self.setGraphGrid(flag)
+
+ def keepDataAspectRatio(self, *args, **kwargs):
+ """Deprecated, use :meth:`setKeepDataAspectRatio`."""
+ _logger.warning('keepDataAspectRatio deprecated,'
+ 'use setKeepDataAspectRatio instead')
+ return self.setKeepDataAspectRatio(*args, **kwargs)
diff --git a/silx/gui/plot/PlotWindow.py b/silx/gui/plot/PlotWindow.py
index ae25cfd..a23db04 100644
--- a/silx/gui/plot/PlotWindow.py
+++ b/silx/gui/plot/PlotWindow.py
@@ -25,26 +25,30 @@
"""A :class:`.PlotWidget` with additional toolbars.
The :class:`PlotWindow` is a subclass of :class:`.PlotWidget`.
-It provides the plot API fully defined in :class:`.Plot`.
"""
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "27/04/2017"
+__date__ = "17/08/2017"
import collections
import logging
-from silx.utils.decorators import deprecated
+from silx.utils.deprecation import deprecated
from . import PlotWidget
-from . import PlotActions
+from . import actions
+from . import items
+from .actions import medfilt as actions_medfilt
+from .actions import fit as actions_fit
+from .actions import histogram as actions_histogram
from . import PlotToolButtons
from .PlotTools import PositionInfo
from .Profile import ProfileToolBar
from .LegendSelector import LegendsDockWidget
from .CurvesROIWidget import CurvesROIDockWidget
from .MaskToolsWidget import MaskToolsDockWidget
+from .ColorBar import ColorBarWidget
try:
from ..console import IPythonDockWidget
except ImportError:
@@ -65,7 +69,7 @@ class PlotWindow(PlotWidget):
:param parent: The parent of this widget or None.
:param backend: The backend to use for the plot (default: matplotlib).
- See :class:`.Plot` for the list of supported backend.
+ See :class:`.PlotWidget` for the list of supported backend.
:type backend: str or :class:`BackendBase.BackendBase`
:param bool resetzoom: Toggle visibility of reset zoom action.
:param bool autoScale: Toggle visibility of axes autoscale actions.
@@ -113,46 +117,54 @@ class PlotWindow(PlotWidget):
self.group = qt.QActionGroup(self)
self.group.setExclusive(False)
- self.resetZoomAction = self.group.addAction(PlotActions.ResetZoomAction(self))
+ self.zoomModeAction = self.group.addAction(
+ actions.mode.ZoomModeAction(self))
+ self.panModeAction = self.group.addAction(
+ actions.mode.PanModeAction(self))
+
+ self.resetZoomAction = self.group.addAction(
+ actions.control.ResetZoomAction(self))
self.resetZoomAction.setVisible(resetzoom)
self.addAction(self.resetZoomAction)
- self.zoomInAction = PlotActions.ZoomInAction(self)
+ self.zoomInAction = actions.control.ZoomInAction(self)
self.addAction(self.zoomInAction)
- self.zoomOutAction = PlotActions.ZoomOutAction(self)
+ self.zoomOutAction = actions.control.ZoomOutAction(self)
self.addAction(self.zoomOutAction)
self.xAxisAutoScaleAction = self.group.addAction(
- PlotActions.XAxisAutoScaleAction(self))
+ actions.control.XAxisAutoScaleAction(self))
self.xAxisAutoScaleAction.setVisible(autoScale)
self.addAction(self.xAxisAutoScaleAction)
self.yAxisAutoScaleAction = self.group.addAction(
- PlotActions.YAxisAutoScaleAction(self))
+ actions.control.YAxisAutoScaleAction(self))
self.yAxisAutoScaleAction.setVisible(autoScale)
self.addAction(self.yAxisAutoScaleAction)
self.xAxisLogarithmicAction = self.group.addAction(
- PlotActions.XAxisLogarithmicAction(self))
+ actions.control.XAxisLogarithmicAction(self))
self.xAxisLogarithmicAction.setVisible(logScale)
self.addAction(self.xAxisLogarithmicAction)
self.yAxisLogarithmicAction = self.group.addAction(
- PlotActions.YAxisLogarithmicAction(self))
+ actions.control.YAxisLogarithmicAction(self))
self.yAxisLogarithmicAction.setVisible(logScale)
self.addAction(self.yAxisLogarithmicAction)
self.gridAction = self.group.addAction(
- PlotActions.GridAction(self, gridMode='both'))
+ actions.control.GridAction(self, gridMode='both'))
self.gridAction.setVisible(grid)
self.addAction(self.gridAction)
- self.curveStyleAction = self.group.addAction(PlotActions.CurveStyleAction(self))
+ self.curveStyleAction = self.group.addAction(
+ actions.control.CurveStyleAction(self))
self.curveStyleAction.setVisible(curveStyle)
self.addAction(self.curveStyleAction)
- self.colormapAction = self.group.addAction(PlotActions.ColormapAction(self))
+ self.colormapAction = self.group.addAction(
+ actions.control.ColormapAction(self))
self.colormapAction.setVisible(colormap)
self.addAction(self.colormapAction)
@@ -171,34 +183,34 @@ class PlotWindow(PlotWidget):
self.getMaskAction().setVisible(mask)
self._intensityHistoAction = self.group.addAction(
- PlotActions.PixelIntensitiesHistoAction(self))
+ actions_histogram.PixelIntensitiesHistoAction(self))
self._intensityHistoAction.setVisible(False)
self._medianFilter2DAction = self.group.addAction(
- PlotActions.MedianFilter2DAction(self))
+ actions_medfilt.MedianFilter2DAction(self))
self._medianFilter2DAction.setVisible(False)
self._medianFilter1DAction = self.group.addAction(
- PlotActions.MedianFilter1DAction(self))
+ actions_medfilt.MedianFilter1DAction(self))
self._medianFilter1DAction.setVisible(False)
self._separator = qt.QAction('separator', self)
self._separator.setSeparator(True)
self.group.addAction(self._separator)
- self.copyAction = self.group.addAction(PlotActions.CopyAction(self))
+ self.copyAction = self.group.addAction(actions.io.CopyAction(self))
self.copyAction.setVisible(copy)
self.addAction(self.copyAction)
- self.saveAction = self.group.addAction(PlotActions.SaveAction(self))
+ self.saveAction = self.group.addAction(actions.io.SaveAction(self))
self.saveAction.setVisible(save)
self.addAction(self.saveAction)
- self.printAction = self.group.addAction(PlotActions.PrintAction(self))
+ self.printAction = self.group.addAction(actions.io.PrintAction(self))
self.printAction.setVisible(print_)
self.addAction(self.printAction)
- self.fitAction = self.group.addAction(PlotActions.FitAction(self))
+ self.fitAction = self.group.addAction(actions_fit.FitAction(self))
self.fitAction.setVisible(fit)
self.addAction(self.fitAction)
@@ -207,6 +219,28 @@ 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()
+ palette.setColor(qt.QPalette.Background, qt.Qt.white)
+ palette.setColor(qt.QPalette.Window, qt.Qt.white)
+ self._colorbar.setPalette(palette)
+
+ gridLayout = qt.QGridLayout()
+ gridLayout.setSpacing(0)
+ gridLayout.setContentsMargins(0, 0, 0, 0)
+ gridLayout.addWidget(self.getWidgetHandle(), 0, 0)
+ gridLayout.addWidget(self._colorbar, 0, 1)
+ gridLayout.setRowStretch(0, 1)
+ gridLayout.setColumnStretch(0, 1)
+ centralWidget = qt.QWidget()
+ centralWidget.setLayout(gridLayout)
+ self.setCentralWidget(centralWidget)
+
if control or position:
hbox = qt.QHBoxLayout()
hbox.setContentsMargins(0, 0, 0, 0)
@@ -239,16 +273,7 @@ class PlotWindow(PlotWidget):
bottomBar = qt.QWidget()
bottomBar.setLayout(hbox)
- layout = qt.QVBoxLayout()
- layout.setSpacing(0)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.addWidget(self.getWidgetHandle())
- layout.addWidget(bottomBar)
- layout.setStretch(0, 1)
-
- centralWidget = qt.QWidget()
- centralWidget.setLayout(layout)
- self.setCentralWidget(centralWidget)
+ gridLayout.addWidget(bottomBar, 1, 0, 1, -1)
# Creating the toolbar also create actions for toolbuttons
self._toolbar = self._createToolBar(title='Plot', parent=None)
@@ -322,6 +347,8 @@ class PlotWindow(PlotWidget):
self.yAxisInvertedAction = toolbar.addWidget(obj)
else:
raise RuntimeError()
+ if obj is self.panModeAction:
+ toolbar.addSeparator()
return toolbar
def toolBar(self):
@@ -378,6 +405,13 @@ class PlotWindow(PlotWidget):
self.tabifyDockWidget(self._dockWidgets[0],
dock_widget)
+ def getColorBarWidget(self):
+ """Returns the embedded :class:`ColorBarWidget` widget.
+
+ :rtype: ColorBarWidget
+ """
+ return self._colorbar
+
# getters for dock widgets
@property
@deprecated(replacement="getLegendsDockWidget()", since_version="0.4.0")
@@ -464,10 +498,10 @@ class PlotWindow(PlotWidget):
def getCrosshairAction(self):
"""Action toggling crosshair cursor mode.
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
if self._crosshairAction is None:
- self._crosshairAction = PlotActions.CrosshairAction(self, color='red')
+ self._crosshairAction = actions.control.CrosshairAction(self, color='red')
return self._crosshairAction
@property
@@ -491,10 +525,10 @@ class PlotWindow(PlotWidget):
def getPanWithArrowKeysAction(self):
"""Action toggling pan with arrow keys.
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
if self._panWithArrowKeysAction is None:
- self._panWithArrowKeysAction = PlotActions.PanWithArrowKeysAction(self)
+ self._panWithArrowKeysAction = actions.control.PanWithArrowKeysAction(self)
return self._panWithArrowKeysAction
@property
@@ -512,63 +546,63 @@ class PlotWindow(PlotWidget):
def getResetZoomAction(self):
"""Action resetting the zoom
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self.resetZoomAction
def getZoomInAction(self):
"""Action to zoom in
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self.zoomInAction
def getZoomOutAction(self):
"""Action to zoom out
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self.zoomOutAction
def getXAxisAutoScaleAction(self):
"""Action to toggle the X axis autoscale on zoom reset
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self.xAxisAutoScaleAction
def getYAxisAutoScaleAction(self):
"""Action to toggle the Y axis autoscale on zoom reset
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self.yAxisAutoScaleAction
def getXAxisLogarithmicAction(self):
"""Action to toggle logarithmic X axis
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self.xAxisLogarithmicAction
def getYAxisLogarithmicAction(self):
"""Action to toggle logarithmic Y axis
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self.yAxisLogarithmicAction
def getGridAction(self):
"""Action to toggle the grid visibility in the plot
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self.gridAction
def getCurveStyleAction(self):
"""Action to change curve line and markers styles
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self.curveStyleAction
@@ -576,7 +610,7 @@ class PlotWindow(PlotWidget):
"""Action open a colormap dialog to change active image
and default colormap.
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self.colormapAction
@@ -592,7 +626,7 @@ class PlotWindow(PlotWidget):
Use this to change the visibility of keepDataAspectRatioButton in the
toolbar (See :meth:`QToolBar.addWidget` documentation).
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self.keepDataAspectRatioButton
@@ -608,56 +642,56 @@ class PlotWindow(PlotWidget):
Use this to change the visibility yAxisInvertedButton in the toolbar.
(See :meth:`QToolBar.addWidget` documentation).
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self.yAxisInvertedAction
def getIntensityHistogramAction(self):
"""Action toggling the histogram intensity Plot widget
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self._intensityHistoAction
def getCopyAction(self):
"""Action to copy plot snapshot to clipboard
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self.copyAction
def getSaveAction(self):
"""Action to save plot
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self.saveAction
def getPrintAction(self):
"""Action to print plot
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self.printAction
def getFitAction(self):
"""Action to fit selected curve
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self.fitAction
def getMedianFilter1DAction(self):
"""Action toggling the 1D median filter
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self._medianFilter1DAction
def getMedianFilter2DAction(self):
"""Action toggling the 2D median filter
- :rtype: PlotActions.PlotAction
+ :rtype: actions.PlotAction
"""
return self._medianFilter2DAction
@@ -669,7 +703,7 @@ class Plot1D(PlotWindow):
:param parent: The parent of this widget
:param backend: The backend to use for the plot (default: matplotlib).
- See :class:`.Plot` for the list of supported backend.
+ See :class:`.PlotWidget` for the list of supported backend.
:type backend: str or :class:`BackendBase.BackendBase`
"""
@@ -684,8 +718,8 @@ class Plot1D(PlotWindow):
roi=True, mask=False, fit=True)
if parent is None:
self.setWindowTitle('Plot1D')
- self.setGraphXLabel('X')
- self.setGraphYLabel('Y')
+ self.getXAxis().setLabel('X')
+ self.getYAxis().setLabel('Y')
class Plot2D(PlotWindow):
@@ -695,7 +729,7 @@ class Plot2D(PlotWindow):
:param parent: The parent of this widget
:param backend: The backend to use for the plot (default: matplotlib).
- See :class:`.Plot` for the list of supported backend.
+ See :class:`.PlotWidget` for the list of supported backend.
:type backend: str or :class:`BackendBase.BackendBase`
"""
@@ -716,26 +750,44 @@ class Plot2D(PlotWindow):
roi=False, mask=True)
if parent is None:
self.setWindowTitle('Plot2D')
- self.setGraphXLabel('Columns')
- self.setGraphYLabel('Rows')
+ self.getXAxis().setLabel('Columns')
+ self.getYAxis().setLabel('Rows')
self.profile = ProfileToolBar(plot=self)
-
self.addToolBar(self.profile)
+ self.getColorBarWidget().setVisible(True)
+
+ # Put colorbar action after colormap action
+ actions = self.toolBar().actions()
+ 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 value of top most image at position (x, y)
+ """Get status bar value of top most image at position (x, y)
:param float x: X position in plot coordinates
:param float y: Y position in plot coordinates
:return: The value at that point or '-'
"""
value = '-'
- valueZ = - float('inf')
+ valueZ = -float('inf')
+ mask = 0
+ maskZ = -float('inf')
for image in self.getAllImages():
data = image.getData(copy=False)
- if image.getZValue() >= valueZ: # This image is over the previous one
+ isMask = isinstance(image, items.MaskImageData)
+ if isMask:
+ zIndex = maskZ
+ else:
+ zIndex = valueZ
+ if image.getZValue() >= zIndex:
+ # This image is over the previous one
ox, oy = image.getOrigin()
sx, sy = image.getScale()
row, col = (y - oy) / sy, (x - ox) / sx
@@ -743,8 +795,15 @@ class Plot2D(PlotWindow):
# Test positive before cast otherwise issue with int(-0.5) = 0
row, col = int(row), int(col)
if (row < data.shape[0] and col < data.shape[1]):
- value = data[row, col]
- valueZ = image.getZValue()
+ v, z = data[row, col], image.getZValue()
+ if not isMask:
+ value = v
+ valueZ = z
+ else:
+ mask = v
+ maskZ = z
+ if maskZ > valueZ and mask > 0:
+ return value, "Masked"
return value
def getProfileToolbar(self):
diff --git a/silx/gui/plot/PrintPreviewToolButton.py b/silx/gui/plot/PrintPreviewToolButton.py
new file mode 100644
index 0000000..c5479b8
--- /dev/null
+++ b/silx/gui/plot/PrintPreviewToolButton.py
@@ -0,0 +1,350 @@
+# 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 modules provides tool buttons to send the content of a plot to a
+print preview page.
+The plot content can then be moved on the page and resized prior to printing.
+
+Classes
+-------
+
+- :class:`PrintPreviewToolButton`
+- :class:`SingletonPrintPreviewToolButton`
+
+Examples
+--------
+
+Simple example
+++++++++++++++
+
+.. code-block:: python
+
+ from silx.gui import qt
+ from silx.gui.plot import PlotWidget
+ from silx.gui.plot.PrintPreviewToolButton import PrintPreviewToolButton
+ import numpy
+
+ app = qt.QApplication([])
+
+ pw = PlotWidget()
+ toolbar = qt.QToolBar(pw)
+ toolbutton = PrintPreviewToolButton(parent=toolbar, plot=pw)
+ pw.addToolBar(toolbar)
+ toolbar.addWidget(toolbutton)
+ pw.show()
+
+ x = numpy.arange(1000)
+ y = x / numpy.sin(x)
+ pw.addCurve(x, y)
+
+ app.exec_()
+
+Singleton example
++++++++++++++++++
+
+This example illustrates how to print the content of several different
+plots on the same page. The plots all instantiate a
+:class:`SingletonPrintPreviewToolButton`, which relies on a singleton widget
+(:class:`silx.gui.widgets.PrintPreview.SingletonPrintPreviewDialog`).
+
+.. image:: img/printPreviewMultiPlot.png
+
+.. code-block:: python
+
+ from silx.gui import qt
+ from silx.gui.plot import PlotWidget
+ from silx.gui.plot.PrintPreviewToolButton import SingletonPrintPreviewToolButton
+ import numpy
+
+ app = qt.QApplication([])
+
+ plot_widgets = []
+
+ for i in range(3):
+ pw = PlotWidget()
+ toolbar = qt.QToolBar(pw)
+ toolbutton = SingletonPrintPreviewToolButton(parent=toolbar,
+ plot=pw)
+ pw.addToolBar(toolbar)
+ toolbar.addWidget(toolbutton)
+ pw.show()
+ plot_widgets.append(pw)
+
+ x = numpy.arange(1000)
+
+ plot_widgets[0].addCurve(x, numpy.sin(x * 2 * numpy.pi / 1000))
+ plot_widgets[1].addCurve(x, numpy.cos(x * 2 * numpy.pi / 1000))
+ plot_widgets[2].addCurve(x, numpy.tan(x * 2 * numpy.pi / 1000))
+
+ app.exec_()
+
+"""
+from __future__ import absolute_import
+
+import logging
+from io import StringIO
+
+from .. import qt
+from .. import icons
+from . import PlotWidget
+from ..widgets.PrintPreview import PrintPreviewDialog, SingletonPrintPreviewDialog
+from ..widgets.PrintGeometryDialog import PrintGeometryDialog
+
+__authors__ = ["P. Knobel"]
+__license__ = "MIT"
+__date__ = "18/07/2017"
+
+_logger = logging.getLogger(__name__)
+# _logger.setLevel(logging.DEBUG)
+
+
+class PrintPreviewToolButton(qt.QToolButton):
+ """QToolButton to open a :class:`PrintPreviewDialog` (if not already open)
+ and add the current plot to its page to be printed.
+
+ :param parent: See :class:`QAction`
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ """
+ def __init__(self, parent=None, plot=None):
+ super(PrintPreviewToolButton, self).__init__(parent)
+
+ if not isinstance(plot, PlotWidget):
+ raise TypeError("plot parameter must be a PlotWidget")
+ self.plot = plot
+
+ self.setIcon(icons.getQIcon('document-print'))
+
+ printGeomAction = qt.QAction("Print geometry", self)
+ printGeomAction.setToolTip("Define a print geometry prior to sending "
+ "the plot to the print preview dialog")
+ printGeomAction.setIcon(icons.getQIcon('shape-rectangle')) # fixme: icon not displayed in menu
+ printGeomAction.triggered.connect(self._setPrintConfiguration)
+
+ printPreviewAction = qt.QAction("Print preview", self)
+ printPreviewAction.setToolTip("Send plot to the print preview dialog")
+ printPreviewAction.setIcon(icons.getQIcon('document-print')) # fixme: icon not displayed
+ printPreviewAction.triggered.connect(self._plotToPrintPreview)
+
+ menu = qt.QMenu(self)
+ menu.addAction(printGeomAction)
+ menu.addAction(printPreviewAction)
+ self.setMenu(menu)
+ self.setPopupMode(qt.QToolButton.InstantPopup)
+
+ self._printPreviewDialog = None
+ self._printConfigurationDialog = None
+
+ self._printGeometry = {"xOffset": 0.1,
+ "yOffset": 0.1,
+ "width": 0.9,
+ "height": 0.9,
+ "units": "page",
+ "keepAspectRatio": True}
+
+ @property
+ def printPreviewDialog(self):
+ """Lazy loaded :class:`PrintPreviewDialog`"""
+ # if changes are made here, don't forget making them in
+ # SingletonPrintPreviewToolButton.printPreviewDialog as well
+ if self._printPreviewDialog is None:
+ self._printPreviewDialog = PrintPreviewDialog(self.parent())
+ return self._printPreviewDialog
+
+ def _plotToPrintPreview(self):
+ """Grab the plot widget and send it to the print preview dialog.
+ Make sure the print preview dialog is shown and raised."""
+ self.printPreviewDialog.ensurePrinterIsSet()
+
+ if qt.HAS_SVG:
+ svgRenderer, viewBox = self._getSvgRendererAndViewbox()
+ self.printPreviewDialog.addSvgItem(svgRenderer,
+ viewBox=viewBox)
+ else:
+ _logger.warning("Missing QtSvg library, using a raster image")
+ if qt.BINDING in ["PyQt4", "PySide"]:
+ pixmap = qt.QPixmap.grabWidget(self.plot.centralWidget())
+ else:
+ # PyQt5 and hopefully PyQt6+
+ pixmap = self.plot.centralWidget().grab()
+ self.printPreviewDialog.addPixmap(pixmap)
+ self.printPreviewDialog.show()
+ self.printPreviewDialog.raise_()
+
+ def _getSvgRendererAndViewbox(self):
+ """Return a SVG renderer displaying the plot and its viewbox
+ (interactively specified by the user the first time this is called).
+
+ The size of the renderer is adjusted to the printer configuration
+ and to the geometry configuration (width, height, ratio) specified
+ by the user."""
+ imgData = StringIO()
+ assert self.plot.saveGraph(imgData, fileFormat="svg"), \
+ "Unable to save graph"
+ imgData.flush()
+ imgData.seek(0)
+ svgData = imgData.read()
+
+ svgRenderer = qt.QSvgRenderer()
+
+ viewbox = self._getViewBox()
+
+ svgRenderer.setViewBox(viewbox)
+
+ xml_stream = qt.QXmlStreamReader(svgData.encode(errors="replace"))
+
+ # This is for PyMca compatibility, to share a print preview with PyMca plots
+ svgRenderer._viewBox = viewbox
+ svgRenderer._svgRawData = svgData.encode(errors="replace")
+ svgRenderer._svgRendererData = xml_stream
+
+ if not svgRenderer.load(xml_stream):
+ raise RuntimeError("Cannot interpret svg data")
+
+ return svgRenderer, viewbox
+
+ def _getViewBox(self):
+ """
+ """
+ printer = self.printPreviewDialog.printer
+ dpix = printer.logicalDpiX()
+ dpiy = printer.logicalDpiY()
+ availableWidth = printer.width()
+ availableHeight = printer.height()
+
+ config = self._printGeometry
+ width = config['width']
+ height = config['height']
+ xOffset = config['xOffset']
+ yOffset = config['yOffset']
+ units = config['units']
+ keepAspectRatio = config['keepAspectRatio']
+ aspectRatio = self._getPlotAspectRatio()
+
+ # convert the offsets to dots
+ if units.lower() in ['inch', 'inches']:
+ xOffset = xOffset * dpix
+ yOffset = yOffset * dpiy
+ if width is not None:
+ width = width * dpix
+ if height is not None:
+ height = height * dpiy
+ elif units.lower() in ['cm', 'centimeters']:
+ xOffset = (xOffset / 2.54) * dpix
+ yOffset = (yOffset / 2.54) * dpiy
+ if width is not None:
+ width = (width / 2.54) * dpix
+ if height is not None:
+ height = (height / 2.54) * dpiy
+ else:
+ # page units
+ xOffset = availableWidth * xOffset
+ yOffset = availableHeight * yOffset
+ if width is not None:
+ width = availableWidth * width
+ if height is not None:
+ height = availableHeight * height
+
+ availableWidth -= xOffset
+ availableHeight -= yOffset
+
+ if width is not None:
+ if (availableWidth + 0.1) < width:
+ txt = "Available width %f is less than requested width %f" % \
+ (availableWidth, width)
+ raise ValueError(txt)
+ if height is not None:
+ if (availableHeight + 0.1) < height:
+ txt = "Available height %f is less than requested height %f" % \
+ (availableHeight, height)
+ raise ValueError(txt)
+
+ if keepAspectRatio:
+ bodyWidth = width or availableWidth
+ bodyHeight = bodyWidth * aspectRatio
+
+ if bodyHeight > availableHeight:
+ bodyHeight = availableHeight
+ bodyWidth = bodyHeight / aspectRatio
+
+ else:
+ bodyWidth = width or availableWidth
+ bodyHeight = height or availableHeight
+
+ return qt.QRectF(xOffset,
+ yOffset,
+ bodyWidth,
+ bodyHeight)
+
+ def _setPrintConfiguration(self):
+ """Open a dialog to prompt the user to adjust print
+ geometry parameters."""
+ self.printPreviewDialog.ensurePrinterIsSet()
+ if self._printConfigurationDialog is None:
+ self._printConfigurationDialog = PrintGeometryDialog(self.parent())
+
+ self._printConfigurationDialog.setPrintGeometry(self._printGeometry)
+ if self._printConfigurationDialog.exec_():
+ self._printGeometry = self._printConfigurationDialog.getPrintGeometry()
+
+ def _getPlotAspectRatio(self):
+ widget = self.plot.centralWidget()
+ graphWidth = float(widget.width())
+ graphHeight = float(widget.height())
+ return graphHeight / graphWidth
+
+
+class SingletonPrintPreviewToolButton(PrintPreviewToolButton):
+ """This class is similar to its parent class :class:`PrintPreviewToolButton`
+ but it uses a singleton print preview widget.
+
+ This allows for several plots to send their content to the
+ same print page, and for users to arrange them."""
+ def __init__(self, parent=None, plot=None):
+ PrintPreviewToolButton.__init__(self, parent, plot)
+
+ @property
+ def printPreviewDialog(self):
+ if self._printPreviewDialog is None:
+ self._printPreviewDialog = SingletonPrintPreviewDialog(self.parent())
+ return self._printPreviewDialog
+
+
+if __name__ == '__main__':
+ import numpy
+ app = qt.QApplication([])
+
+ pw = PlotWidget()
+ toolbar = qt.QToolBar(pw)
+ toolbutton = PrintPreviewToolButton(parent=toolbar,
+ plot=pw)
+ pw.addToolBar(toolbar)
+ toolbar.addWidget(toolbutton)
+ pw.show()
+
+ x = numpy.arange(1000)
+ y = x / numpy.sin(x)
+ pw.addCurve(x, y)
+
+ app.exec_()
diff --git a/silx/gui/plot/Profile.py b/silx/gui/plot/Profile.py
index a11b3f0..ff85695 100644
--- a/silx/gui/plot/Profile.py
+++ b/silx/gui/plot/Profile.py
@@ -28,7 +28,7 @@ and stacks of images"""
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel", "H. Payno"]
__license__ = "MIT"
-__date__ = "24/04/2017"
+__date__ = "17/08/2017"
import numpy
@@ -39,11 +39,11 @@ from .. import icons
from .. import qt
from . import items
from .Colors import cursorColorForColormap
-from .PlotActions import PlotAction
+from . import actions
from .PlotToolButtons import ProfileToolButton
from .ProfileMainWindow import ProfileMainWindow
-from silx.utils.decorators import deprecated
+from silx.utils.deprecation import deprecated
def _alignedFullProfile(data, origin, scale, position, roiWidth, axis):
@@ -372,13 +372,8 @@ class ProfileToolBar(qt.QToolBar):
self._profileMainWindow = ProfileMainWindow(self)
# Actions
- self.browseAction = qt.QAction(
- icons.getQIcon('normal'),
- 'Browsing Mode', None)
- self.browseAction.setToolTip(
- 'Enables zooming interaction mode')
- self.browseAction.setCheckable(True)
- self.browseAction.triggered[bool].connect(self._browseActionTriggered)
+ self._browseAction = actions.mode.ZoomModeAction(self.plot, parent=self)
+ self._browseAction.setVisible(False)
self.hLineAction = qt.QAction(
icons.getQIcon('shape-horizontal'),
@@ -414,15 +409,13 @@ class ProfileToolBar(qt.QToolBar):
# ActionGroup
self.actionGroup = qt.QActionGroup(self)
- self.actionGroup.addAction(self.browseAction)
+ self.actionGroup.addAction(self._browseAction)
self.actionGroup.addAction(self.hLineAction)
self.actionGroup.addAction(self.vLineAction)
self.actionGroup.addAction(self.lineAction)
- self.browseAction.setChecked(True)
-
# Add actions to ToolBar
- self.addAction(self.browseAction)
+ self.addAction(self._browseAction)
self.addAction(self.hLineAction)
self.addAction(self.vLineAction)
self.addAction(self.lineAction)
@@ -450,6 +443,11 @@ class ProfileToolBar(qt.QToolBar):
self.getProfileMainWindow().sigClose.connect(self.clearProfile)
@property
+ @deprecated(since_version="0.6.0")
+ def browseAction(self):
+ return self._browseAction
+
+ @property
@deprecated(replacement="getProfilePlot", since_version="0.5.0")
def profileWindow(self):
return self.getProfilePlot()
@@ -473,10 +471,15 @@ class ProfileToolBar(qt.QToolBar):
def _activeImageChanged(self, previous, legend):
"""Handle active image change: toggle enabled toolbar, update curve"""
- self.setEnabled(legend is not None)
- if legend is not None:
- # Update default profile color
+ if legend is None:
+ self.setEnabled(False)
+ else:
activeImage = self.plot.getActiveImage()
+
+ # Disable for empty image
+ self.setEnabled(activeImage.getData(copy=False).size > 0)
+
+ # Update default profile color
if isinstance(activeImage, items.ColormapMixIn):
self._defaultOverlayColor = cursorColorForColormap(
activeImage.getColormap()['name'])
@@ -495,7 +498,15 @@ class ProfileToolBar(qt.QToolBar):
If changed from elsewhere, disable drawing tool
"""
if source is not self:
- self.browseAction.setChecked(True)
+ self.clearProfile()
+
+ # Uncheck all drawing profile modes
+ self.hLineAction.setChecked(False)
+ self.vLineAction.setChecked(False)
+ self.lineAction.setChecked(False)
+
+ if self.getProfileMainWindow() is not None:
+ self.getProfileMainWindow().hide()
def _hLineActionToggled(self, checked):
"""Handle horizontal line profile action toggle"""
@@ -524,14 +535,6 @@ class ProfileToolBar(qt.QToolBar):
else:
self.plot.sigPlotSignal.disconnect(self._plotWindowSlot)
- def _browseActionTriggered(self, checked):
- """Handle browse action mode triggered by user."""
- if checked:
- self.clearProfile()
- self.plot.setInteractiveMode('zoom', source=self)
- if self.getProfileMainWindow() is not None:
- self.getProfileMainWindow().hide()
-
def _plotWindowSlot(self, event):
"""Listen to Plot to handle drawing events to refresh ROI and profile.
"""
@@ -585,8 +588,8 @@ class ProfileToolBar(qt.QToolBar):
self.plot.remove(self._POLYGON_LEGEND, kind='item')
self.getProfilePlot().clear()
self.getProfilePlot().setGraphTitle('')
- self.getProfilePlot().setGraphXLabel('X')
- self.getProfilePlot().setGraphYLabel('Y')
+ self.getProfilePlot().getXAxis().setLabel('X')
+ self.getProfilePlot().getYAxis().setLabel('Y')
self._createProfile(currentData=image.getData(copy=False),
origin=image.getOrigin(),
@@ -678,17 +681,21 @@ class ProfileToolBar(qt.QToolBar):
class Profile3DToolBar(ProfileToolBar):
- def __init__(self, parent=None, plot=None, title='Profile Selection'):
+ def __init__(self, parent=None, stackview=None,
+ title='Profile Selection'):
"""QToolBar providing profile tools for an image or a stack of images.
:param parent: the parent QWidget
- :param plot: :class:`PlotWindow` instance on which to operate.
+ :param stackview: :class:`StackView` instance on which to operate.
:param str title: See :class:`QToolBar`.
:param parent: See :class:`QToolBar`.
"""
# TODO: add param profileWindow (specify the plot used for profiles)
- super(Profile3DToolBar, self).__init__(parent=parent, plot=plot,
+ super(Profile3DToolBar, self).__init__(parent=parent,
+ plot=stackview.getPlot(),
title=title)
+ self.stackView = stackview
+ """:class:`StackView` instance"""
self.profile3dAction = ProfileToolButton(
parent=self, plot=self.plot)
@@ -721,15 +728,15 @@ class Profile3DToolBar(ProfileToolBar):
if self._profileType == "1D":
super(Profile3DToolBar, self).updateProfile()
elif self._profileType == "2D":
- stackData = self.plot.getCurrentView(copy=False,
- returnNumpyArray=True)
+ stackData = self.stackView.getCurrentView(copy=False,
+ returnNumpyArray=True)
if stackData is None:
return
self.plot.remove(self._POLYGON_LEGEND, kind='item')
self.getProfilePlot().clear()
self.getProfilePlot().setGraphTitle('')
- self.getProfilePlot().setGraphXLabel('X')
- self.getProfilePlot().setGraphYLabel('Y')
+ self.getProfilePlot().getXAxis().setLabel('X')
+ self.getProfilePlot().getYAxis().setLabel('Y')
self._createProfile(currentData=stackData[0],
origin=stackData[1]['origin'],
diff --git a/silx/gui/plot/ScatterMaskToolsWidget.py b/silx/gui/plot/ScatterMaskToolsWidget.py
index 793719d..a9c1073 100644
--- a/silx/gui/plot/ScatterMaskToolsWidget.py
+++ b/silx/gui/plot/ScatterMaskToolsWidget.py
@@ -181,18 +181,14 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
:class:`PlotWidget`."""
def __init__(self, parent=None, plot=None):
+ super(ScatterMaskToolsWidget, self).__init__(parent, plot,
+ mask=ScatterMask())
self._z = 2 # Mask layer in plot
self._data_scatter = None
"""plot Scatter item for data"""
self._mask_scatter = None
"""plot Scatter item for representing the mask"""
- self._mask = ScatterMask()
-
- super(ScatterMaskToolsWidget, self).__init__(parent, plot)
-
- self._initWidgets()
-
def setSelectionMask(self, mask, copy=True):
"""Set the mask to a new array.
@@ -524,6 +520,5 @@ class ScatterMaskToolsDockWidget(BaseMaskToolsDockWidget):
:paran str name: The title of this widget
"""
def __init__(self, parent=None, plot=None, name='Mask'):
- super(ScatterMaskToolsDockWidget, self).__init__(parent, name)
- self.setWidget(ScatterMaskToolsWidget(plot=plot))
- self.widget().sigMaskChanged.connect(self._emitSigMaskChanged)
+ widget = ScatterMaskToolsWidget(plot=plot)
+ super(ScatterMaskToolsDockWidget, self).__init__(parent, name, widget)
diff --git a/silx/gui/plot/StackView.py b/silx/gui/plot/StackView.py
index 9bb0cf0..938447b 100644
--- a/silx/gui/plot/StackView.py
+++ b/silx/gui/plot/StackView.py
@@ -69,18 +69,14 @@ Example::
__authors__ = ["P. Knobel", "H. Payno"]
__license__ = "MIT"
-__date__ = "20/01/2017"
+__date__ = "11/09/2017"
import numpy
-try:
- import h5py
-except ImportError:
- h5py = None
-
from silx.gui import qt
from .. import icons
-from . import items, PlotWindow, PlotActions
+from . import items, PlotWindow, actions
+from .Colormap import Colormap
from .Colors import cursorColorForColormap
from .PlotTools import LimitsToolBar
from .Profile import Profile3DToolBar
@@ -88,6 +84,16 @@ from ..widgets.FrameBrowser import HorizontalSliderWithBrowser
from silx.utils.array_like import DatasetView, ListOfImages
from silx.math import calibration
+from silx.utils.deprecation import deprecated_warning
+
+try:
+ import h5py
+except ImportError:
+ def is_dataset(obj):
+ return False
+ h5py = None
+else:
+ from silx.io.utils import is_dataset
class StackView(qt.QMainWindow):
@@ -100,7 +106,7 @@ class StackView(qt.QMainWindow):
:param QWidget parent: the Qt parent, or None
:param backend: The backend to use for the plot (default: matplotlib).
- See :class:`.Plot` for the list of supported backend.
+ See :class:`.PlotWidget` for the list of supported backend.
:type backend: str or :class:`BackendBase.BackendBase`
:param bool resetzoom: Toggle visibility of reset zoom action.
:param bool autoScale: Toggle visibility of axes autoscale actions.
@@ -181,6 +187,10 @@ class StackView(qt.QMainWindow):
self._first_stack_dimension = 0
"""Used for dimension labels and combobox"""
+ self._titleCallback = self._defaultTitleCallback
+ """Function returning the plot title based on the frame index.
+ It can be set to a custom function using :meth:`setTitleCallback`"""
+
central_widget = qt.QWidget(self)
self._plot = PlotWindow(parent=central_widget, backend=backend,
@@ -195,11 +205,13 @@ class StackView(qt.QMainWindow):
self.sigActiveImageChanged = self._plot.sigActiveImageChanged
self.sigPlotSignal = self._plot.sigPlotSignal
+ self._addColorBarAction()
+
self._plot.profile = Profile3DToolBar(parent=self._plot,
- plot=self)
+ stackview=self)
self._plot.addToolBar(self._plot.profile)
- self._plot.setGraphXLabel('Columns')
- self._plot.setGraphYLabel('Rows')
+ self._plot.getXAxis().setLabel('Columns')
+ self._plot.getYAxis().setLabel('Rows')
self._plot.sigPlotSignal.connect(self._plotCallback)
self.__planeSelection = PlanesWidget(self._plot)
@@ -227,14 +239,15 @@ class StackView(qt.QMainWindow):
self.__planeSelection.sigPlaneSelectionChanged.connect(
self._plot.profile.clearProfile)
- def setOptionVisible(self, isVisible):
- """
- Set the visibility of the browsing options.
-
- :param bool isVisible: True to have the options visible, else False
- """
- self._browser.setVisible(isVisible)
- self.__planeSelection.setVisible(isVisible)
+ def _addColorBarAction(self):
+ self._plot.getColorBarWidget().setVisible(True)
+ actions = self._plot.toolBar().actions()
+ for index, action in enumerate(actions):
+ if action is self._plot.getColormapAction():
+ break
+ self._plot.toolBar().insertAction(
+ actions[index + 1],
+ self._plot.getColorBarWidget().getToggleViewAction())
def _plotCallback(self, eventDict):
"""Callback for plot events.
@@ -242,7 +255,7 @@ class StackView(qt.QMainWindow):
Emit :attr:`valueChanged` signal, with (x, y, value) tuple of the
cursor location in the plot."""
if eventDict['event'] == 'mouseMoved':
- activeImage = self.getActiveImage()
+ activeImage = self._plot.getActiveImage()
if activeImage is not None:
data = activeImage.getData()
height, width = data.shape
@@ -305,8 +318,7 @@ class StackView(qt.QMainWindow):
if isinstance(self._stack, numpy.ndarray):
self.__transposed_view = self._stack
- elif h5py is not None and isinstance(self._stack, h5py.Dataset) or \
- isinstance(self._stack, DatasetView):
+ elif is_dataset(self._stack) or isinstance(self._stack, DatasetView):
self.__transposed_view = DatasetView(self._stack)
elif isinstance(self._stack, ListOfImages):
@@ -321,13 +333,6 @@ class StackView(qt.QMainWindow):
self._browser.setRange(0, self.__transposed_view.shape[0] - 1)
self._browser.setValue(0)
- def setFrameNumber(self, number):
- """Set the frame selection to a specific value\
-
- :param int number: Number of the frame
- """
- self._browser.setValue(number)
-
def __updateFrameNumber(self, index):
"""Update the current image.
@@ -339,7 +344,7 @@ class StackView(qt.QMainWindow):
scale=self._getImageScale(),
legend=self.__imageLegend,
resetzoom=False, replace=False)
- self._plot.setGraphTitle("Image z=%g" % self._getImageZ(index))
+ self._updateTitle()
def _set3DScaleAndOrigin(self, calibrations):
"""Set scale and origin for all 3 axes, to be used when plotting
@@ -396,7 +401,14 @@ class StackView(qt.QMainWindow):
_xcalib, _ycalib, zcalib = self._getXYZCalibs()
return zcalib(index)
- # public API
+ def _updateTitle(self):
+ frame_idx = self._browser.value()
+ self._plot.setGraphTitle(self._titleCallback(frame_idx))
+
+ def _defaultTitleCallback(self, index):
+ return "Image z=%g" % self._getImageZ(index)
+
+ # public API, stack specific methods
def setStack(self, stack, perspective=0, reset=True, calibrations=None):
"""Set the 3D stack.
@@ -426,7 +438,7 @@ class StackView(qt.QMainWindow):
# stack as list of 2D arrays: must be converted into an array_like
if not isinstance(stack, numpy.ndarray):
- if h5py is None or not isinstance(stack, h5py.Dataset):
+ if not is_dataset(stack):
try:
assert hasattr(stack, "__len__")
for img in stack:
@@ -492,7 +504,7 @@ class StackView(qt.QMainWindow):
:return: 3D stack and parameters.
:rtype: (numpy.ndarray, dict)
"""
- image = self.getActiveImage()
+ image = self._plot.getActiveImage()
if image is None:
return None
@@ -543,7 +555,7 @@ class StackView(qt.QMainWindow):
:return: 3D stack and parameters.
:rtype: (numpy.ndarray, dict)
"""
- image = self.getActiveImage()
+ image = self._plot.getActiveImage()
if image is None:
return None
@@ -567,18 +579,57 @@ class StackView(qt.QMainWindow):
return numpy.array(self.__transposed_view, copy=copy), params
return self.__transposed_view, params
- def getActiveImage(self, just_legend=False):
- """Returns the currently active image object.
+ def setFrameNumber(self, number):
+ """Set the frame selection to a specific value
- It returns None in case of not having an active image.
+ :param int number: Number of the frame
+ """
+ self._browser.setValue(number)
- :param bool just_legend: True to get the legend of the image,
- False (the default) to get the image data and info.
- Note: :class:`StackView` uses the same legend for all frames.
- :return: legend or image object
- :rtype: str or list or None
+ def setFirstStackDimension(self, first_stack_dimension):
+ """When viewing the last 3 dimensions of an n-D array (n>3), you can
+ use this method to change the text in the combobox.
+
+ For instance, for a 7-D array, first stack dim is 4, so the default
+ "Dim1-Dim2" text should be replaced with "Dim5-Dim6" (dimensions
+ numbers are 0-based).
+
+ :param int first_stack_dim: First stack dimension (n-3) when viewing the
+ last 3 dimensions of an n-D array.
"""
- return self._plot.getActiveImage(just_legend=just_legend)
+ old_state = self.__planeSelection.blockSignals(True)
+ self.__planeSelection.setFirstStackDimension(first_stack_dimension)
+ self.__planeSelection.blockSignals(old_state)
+ self._first_stack_dimension = first_stack_dimension
+ self._browser_label.setText("Image index (Dim%d):" % first_stack_dimension)
+
+ def setTitleCallback(self, callback):
+ """Set a user defined function to generate the plot title based on the
+ image/frame index.
+
+ The callback function must accept an integer as a its first positional
+ parameter and must not require any other mandatory parameter.
+ It must return a string.
+
+ To switch back the default behavior, you can pass ``None``::
+
+ mystackview.setTitleCallback(None)
+
+ To have no title, pass a function that returns an empty string::
+
+ mystackview.setTitleCallback(lambda idx: "")
+
+ :param callback: Callback function generating the stack title based
+ on the frame number.
+ """
+
+ if callback is None:
+ self._titleCallback = self._defaultTitleCallback
+ elif callable(callback):
+ self._titleCallback = callback
+ else:
+ raise TypeError("Provided callback is not callable")
+ self._updateTitle()
def clear(self):
"""Clear the widget:
@@ -592,22 +643,6 @@ class StackView(qt.QMainWindow):
self._browser.setEnabled(False)
self._plot.clear()
- def resetZoom(self):
- """Reset the plot limits to the bounds of the data and redraw the plot.
- """
- self._plot.resetZoom()
-
- def getGraphTitle(self):
- """Return the plot main title as a str."""
- return self._plot.getGraphTitle()
-
- def setGraphTitle(self, title=""):
- """Set the plot main title.
-
- :param str title: Main title of the plot (default: '')
- """
- return self._plot.setGraphTitle(title)
-
def setLabels(self, labels=None):
"""Set the labels to be displayed on the plot axes.
@@ -635,55 +670,13 @@ class StackView(qt.QMainWindow):
self.__dimensionsLabels = new_labels
self.__updatePlotLabels()
- def getGraphXLabel(self):
- """Return the current horizontal axis label as a str."""
- return self._plot.getGraphXLabel()
-
- def setGraphXLabel(self, label=None):
- """Set the plot horizontal axis label.
-
- :param str label: The horizontal axis label
- """
- if label is None:
- label = self.__dimensionsLabels[1 if self._perspective == 2 else 2]
- self._plot.setGraphXLabel(label)
-
- def getGraphYLabel(self, axis='left'):
- """Return the current vertical axis label as a str.
-
- :param str axis: The Y axis for which to get the label (left or right)
- """
- return self._plot.getGraphYLabel(axis)
-
- def setGraphYLabel(self, label=None, axis='left'):
- """Set the vertical axis label on the plot.
-
- :param str label: The Y axis label
- :param str axis: The Y axis for which to set the label (left or right)
- """
- if label is None:
- label = self.__dimensionsLabels[1 if self._perspective == 0 else 0]
- self._plot.setGraphYLabel(label, axis)
-
- def setYAxisInverted(self, flag=True):
- """Set the Y axis orientation.
+ def getLabels(self):
+ """Return dimension labels displayed on the plot axes
- :param bool flag: True for Y axis going from top to bottom,
- False for Y axis going from bottom to top
- """
- self._plot.setYAxisInverted(flag)
-
- def isYAxisInverted(self):
- """Return True if Y axis goes from top to bottom, False otherwise."""
- return self._backend.isYAxisInverted()
-
- def getSupportedColormaps(self):
- """Get the supported colormap names as a tuple of str.
-
- The list should at least contain and start by:
- ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue')
+ :return: List of three strings corresponding to the 3 dimensions
+ of the stack: (name_dim0, name_dim1, name_dim2)
"""
- return self._plot.getSupportedColormaps()
+ return self.__dimensionsLabels
def getColormap(self):
"""Get the current colormap description.
@@ -720,12 +713,12 @@ class StackView(qt.QMainWindow):
:param colormap: Name of the colormap in
'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue'.
- Or the description of the colormap as a dict.
+ Or a :class`.Colormap` object.
:type colormap: dict or str.
:param str normalization: Colormap mapping: 'linear' or 'log'.
:param bool autoscale: Whether to use autoscale or [vmin, vmax] range.
- Default value of autoscale is True if data is a numpy array,
- False if data is a h5py dataset.
+ Default value of autoscale is False. This option is not compatible
+ with h5py datasets.
:param float vmin: The minimum value of the range to use if
'autoscale' is False.
:param float vmax: The maximum value of the range to use if
@@ -733,84 +726,85 @@ class StackView(qt.QMainWindow):
:param numpy.ndarray colors: Only used if name is None.
Custom colormap colors as Nx3 or Nx4 RGB or RGBA arrays
"""
- cmapDict = self.getColormap()
-
- if isinstance(colormap, dict):
+ # if is a colormap object or a dictionary
+ if isinstance(colormap, Colormap) or isinstance(colormap, dict):
# Support colormap parameter as a dict
- errmsg = "If colormap is provided as a dict, all other parameters"
+ errmsg = "If colormap is provided as a Colormap object, all other parameters"
errmsg += " must not be specified when calling setColormap"
assert normalization is None, errmsg
assert autoscale is None, errmsg
assert vmin is None, errmsg
assert vmax is None, errmsg
assert colors is None, errmsg
- cmapDict.update(colormap)
+ if isinstance(colormap, dict):
+ reason = 'colormap parameter should now be an object'
+ replacement = 'Colormap()'
+ since_version = '0.6'
+ deprecated_warning(type_='function',
+ name='setColormap',
+ reason=reason,
+ replacement=replacement,
+ since_version=since_version)
+ _colormap = Colormap._fromDict(colormap)
+ else:
+ _colormap = colormap
else:
- if colormap is not None:
- cmapDict['name'] = colormap
- if normalization is not None:
- cmapDict['normalization'] = normalization
- if colors is not None:
- cmapDict['colors'] = colors
-
- # Default meaning of autoscale is to reset min and max
- # each time a new image is added to the plot.
- # We want to use min and max of global volume,
- # and not change them when browsing slides
- cmapDict['autoscale'] = False
-
+ norm = normalization if normalization is not None else 'linear'
+ name = colormap if colormap is not None else 'gray'
+ _colormap = Colormap(name=name,
+ normalization=norm,
+ vmin=vmin,
+ vmax=vmax,
+ colors=colors)
+
+ # Patch: since we don't apply this colormap to a single 2D data but
+ # a 2D stack we have to deal manually with vmin, vmax
if autoscale is None:
# set default
autoscale = False
- # TODO: assess cost of computing min/max for large 3D array
- # if isinstance(self._stack, numpy.ndarray):
- # autoscale = True
- # else: # h5py.Dataset
- # autoscale = False
- elif autoscale and isinstance(self._stack, h5py.Dataset):
+ elif autoscale and is_dataset(self._stack):
# h5py dataset has no min()/max() methods
raise RuntimeError(
"Cannot auto-scale colormap for a h5py dataset")
else:
autoscale = autoscale
self.__autoscaleCmap = autoscale
+
if autoscale and (self._stack is not None):
- cmapDict['vmin'] = self._stack.min()
- cmapDict['vmax'] = self._stack.max()
+ _vmin, _vmax = _colormap.getColormapRange(data=self._stack)
+ _colormap.setVRange(vmin=_vmin, vmax=_vmax)
else:
- if vmin is not None:
- cmapDict['vmin'] = vmin
- if vmax is not None:
- cmapDict['vmax'] = vmax
+ if vmin is None and self._stack is not None:
+ _colormap.setVMin(self._stack.min())
+ else:
+ _colormap.setVMin(vmin)
+ if vmax is None and self._stack is not None:
+ _colormap.setVMax(self._stack.max())
+ else:
+ _colormap.setVMax(vmax)
- cursorColor = cursorColorForColormap(cmapDict['name'])
+ cursorColor = cursorColorForColormap(_colormap.getName())
self._plot.setInteractiveMode('zoom', color=cursorColor)
- self._plot.setDefaultColormap(cmapDict)
+ self._plot.setDefaultColormap(_colormap)
# Update active image colormap
activeImage = self._plot.getActiveImage()
if isinstance(activeImage, items.ColormapMixIn):
activeImage.setColormap(self.getColormap())
- def isKeepDataAspectRatio(self):
- """Returns whether the plot is keeping data aspect ratio or not."""
- return self._plot.isKeepDataAspectRatio()
+ def getPlot(self):
+ """Return the :class:`PlotWidget`.
- def setKeepDataAspectRatio(self, flag=True):
- """Set whether the plot keeps data aspect ratio or not.
+ This gives access to advanced plot configuration options.
+ Be warned that modifying the plot can cause issues, and some changes
+ you make to the plot could be overwritten by the :class:`StackView`
+ widget's internal methods and callbacks.
- :param bool flag: True to respect data aspect ratio
+ :return: instance of :class:`PlotWidget` used in widget
"""
- self._plot.setKeepDataAspectRatio(flag)
-
- def getProfileToolbar(self):
- """Profile tools attached to this plot
-
- See :class:`silx.gui.plot.Profile.Profile3DToolBar`
- """
- return self._plot.profile
+ return self._plot
def getProfileWindow1D(self):
"""Plot window used to display 1D profile curve.
@@ -826,7 +820,158 @@ class StackView(qt.QMainWindow):
"""
return self._plot.profile.getProfileWindow2D()
+ def setOptionVisible(self, isVisible):
+ """
+ Set the visibility of the browsing options.
+
+ :param bool isVisible: True to have the options visible, else False
+ """
+ self._browser.setVisible(isVisible)
+ self.__planeSelection.setVisible(isVisible)
+
+ # proxies to PlotWidget or PlotWindow methods
+ def getProfileToolbar(self):
+ """Profile tools attached to this plot
+
+ See :class:`silx.gui.plot.Profile.Profile3DToolBar`
+ """
+ return self._plot.profile
+
+ def getGraphTitle(self):
+ """Return the plot main title as a str.
+ """
+ return self._plot.getGraphTitle()
+
+ def setGraphTitle(self, title=""):
+ """Set the plot main title.
+
+ :param str title: Main title of the plot (default: '')
+ """
+ return self._plot.setGraphTitle(title)
+
+ def getGraphXLabel(self):
+ """Return the current horizontal axis label as a str.
+ """
+ return self._plot.getXAxis().getLabel()
+
+ def setGraphXLabel(self, label=None):
+ """Set the plot horizontal axis label.
+
+ :param str label: The horizontal axis label
+ """
+ if label is None:
+ label = self.__dimensionsLabels[1 if self._perspective == 2 else 2]
+ self._plot.getXAxis().setLabel(label)
+
+ def getGraphYLabel(self, axis='left'):
+ """Return the current vertical axis label as a str.
+
+ :param str axis: The Y axis for which to get the label (left or right)
+ """
+ return self._plot.getYAxis().getLabel(axis)
+
+ def setGraphYLabel(self, label=None, axis='left'):
+ """Set the vertical axis label on the plot.
+
+ :param str label: The Y axis label
+ :param str axis: The Y axis for which to set the label (left or right)
+ """
+ if label is None:
+ label = self.__dimensionsLabels[1 if self._perspective == 0 else 0]
+ self._plot.getYAxis(axis=axis).setLabel(label)
+
+ def resetZoom(self):
+ """Reset the plot limits to the bounds of the data and redraw the plot.
+
+ This method is a simple proxy to the legacy :class:`PlotWidget` method
+ of the same name. Using the object oriented approach is now
+ preferred::
+
+ stackview.getPlot().resetZoom()
+ """
+ self._plot.resetZoom()
+
+ def setYAxisInverted(self, flag=True):
+ """Set the Y axis orientation.
+
+ This method is a simple proxy to the legacy :class:`PlotWidget` method
+ of the same name. Using the object oriented approach is now
+ preferred::
+
+ stackview.getPlot().setYAxisInverted(flag)
+
+ :param bool flag: True for Y axis going from top to bottom,
+ False for Y axis going from bottom to top
+ """
+ self._plot.setYAxisInverted(flag)
+
+ def isYAxisInverted(self):
+ """Return True if Y axis goes from top to bottom, False otherwise.
+
+ This method is a simple proxy to the legacy :class:`PlotWidget` method
+ of the same name. Using the object oriented approach is now
+ preferred::
+
+ stackview.getPlot().isYAxisInverted()"""
+ return self._plot.isYAxisInverted()
+
+ def getSupportedColormaps(self):
+ """Get the supported colormap names as a tuple of str.
+
+ The list should at least contain and start by:
+ ('gray', 'reversed gray', 'temperature', 'red', 'green', 'blue')
+
+ This method is a simple proxy to the legacy :class:`PlotWidget` method
+ of the same name. Using the object oriented approach is now
+ preferred::
+
+ stackview.getPlot().getSupportedColormaps()
+ """
+ return self._plot.getSupportedColormaps()
+
+ def isKeepDataAspectRatio(self):
+ """Returns whether the plot is keeping data aspect ratio or not.
+
+ This method is a simple proxy to the legacy :class:`PlotWidget` method
+ of the same name. Using the object oriented approach is now
+ preferred::
+
+ stackview.getPlot().isKeepDataAspectRatio()"""
+ return self._plot.isKeepDataAspectRatio()
+
+ def setKeepDataAspectRatio(self, flag=True):
+ """Set whether the plot keeps data aspect ratio or not.
+
+ This method is a simple proxy to the legacy :class:`PlotWidget` method
+ of the same name. Using the object oriented approach is now
+ preferred::
+
+ stackview.getPlot().setKeepDataAspectRatio(flag)
+
+ :param bool flag: True to respect data aspect ratio
+ """
+ self._plot.setKeepDataAspectRatio(flag)
+
# kind of private methods, but needed by Profile
+ def getActiveImage(self, just_legend=False):
+ """Returns the currently active image object.
+
+ It returns None in case of not having an active image.
+
+ This method is a simple proxy to the legacy :class:`PlotWidget` method
+ of the same name. Using the object oriented approach is now
+ preferred::
+
+ stackview.getPlot().getActiveImage()
+
+ :param bool just_legend: True to get the legend of the image,
+ False (the default) to get the image data and info.
+ Note: :class:`StackView` uses the same legend for all frames.
+ :return: legend or image object
+ :rtype: str or list or None
+ """
+ return self._plot.getActiveImage(just_legend=just_legend)
+
def remove(self, legend=None,
kind=('curve', 'image', 'item', 'marker')):
"""See :meth:`Plot.Plot.remove`"""
@@ -844,23 +989,6 @@ class StackView(qt.QMainWindow):
"""
self._plot.addItem(*args, **kwargs)
- def setFirstStackDimension(self, first_stack_dimension):
- """When viewing the last 3 dimensions of an n-D array (n>3), you can
- use this method to change the text in the combobox.
-
- For instance, for a 7-D array, first stack dim is 4, so the default
- "Dim1-Dim2" text should be replaced with "Dim5-Dim6" (dimensions
- numbers are 0-based).
-
- :param int first_stack_dim: First stack dimension (n-3) when viewing the
- last 3 dimensions of an n-D array.
- """
- old_state = self.__planeSelection.blockSignals(True)
- self.__planeSelection.setFirstStackDimension(first_stack_dimension)
- self.__planeSelection.blockSignals(old_state)
- self._first_stack_dimension = first_stack_dimension
- self._browser_label.setText("Image index (Dim%d):" % first_stack_dimension)
-
class PlanesWidget(qt.QWidget):
"""Widget for the plane/perspective selection
@@ -974,11 +1102,12 @@ class StackViewMainWindow(StackView):
menu.addSeparator()
menu.addAction(self._plot.resetZoomAction)
menu.addAction(self._plot.colormapAction)
- menu.addAction(PlotActions.KeepAspectRatioAction(self._plot, self))
- menu.addAction(PlotActions.YAxisInvertedAction(self._plot, self))
+ menu.addAction(self._plot.getColorBarWidget().getToggleViewAction())
+
+ menu.addAction(actions.control.KeepAspectRatioAction(self._plot, self))
+ menu.addAction(actions.control.YAxisInvertedAction(self._plot, self))
menu = self.menuBar().addMenu('Profile')
- menu.addAction(self._plot.profile.browseAction)
menu.addAction(self._plot.profile.hLineAction)
menu.addAction(self._plot.profile.vLineAction)
menu.addAction(self._plot.profile.lineAction)
diff --git a/silx/gui/plot/_BaseMaskToolsWidget.py b/silx/gui/plot/_BaseMaskToolsWidget.py
index 91bbe1c..35a48ae 100644
--- a/silx/gui/plot/_BaseMaskToolsWidget.py
+++ b/silx/gui/plot/_BaseMaskToolsWidget.py
@@ -27,17 +27,19 @@
"""
from __future__ import division
-
__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "20/04/2017"
+__date__ = "02/10/2017"
import os
import numpy
from silx.gui import qt, icons
+from silx.gui.widgets.FloatEdit import FloatEdit
+from silx.gui.plot.Colormap import Colormap
from silx.gui.plot.Colors import rgba
+from .actions.mode import PanModeAction
class BaseMask(qt.QObject):
@@ -353,24 +355,38 @@ class BaseMaskToolsWidget(qt.QWidget):
sigMaskChanged = qt.Signal()
_maxLevelNumber = 255
- def __init__(self, parent=None, plot=None):
+ def __init__(self, parent=None, plot=None, mask=None):
+ """
+
+ :param parent: Parent QWidget
+ :param plot: Plot widget on which to operate
+ :param mask: Instance of subclass of :class:`BaseMask`
+ (e.g. :class:`ImageMask`)
+ """
+ super(BaseMaskToolsWidget, self).__init__(parent)
# register if the user as force a color for the corresponding mask level
self._defaultColors = numpy.ones((self._maxLevelNumber + 1), dtype=numpy.bool)
# overlays colors set by the user
self._overlayColors = numpy.zeros((self._maxLevelNumber + 1, 3), dtype=numpy.float32)
+ # as parent have to be the first argument of the widget to fit
+ # QtDesigner need but here plot can't be None by default.
+ assert plot is not None
self._plot = plot
self._maskName = '__MASK_TOOLS_%d' % id(self) # Legend of the mask
- self._colormap = {
- 'name': None,
- 'normalization': 'linear',
- 'autoscale': False,
- 'vmin': 0, 'vmax': self._maxLevelNumber,
- 'colors': None}
+ self._colormap = Colormap(name="",
+ normalization='linear',
+ vmin=0,
+ vmax=self._maxLevelNumber,
+ colors=None)
self._defaultOverlayColor = rgba('gray') # Color of the mask
self._setMaskColors(1, 0.5)
+ if not isinstance(mask, BaseMask):
+ raise TypeError("mask is not an instance of BaseMask")
+ self._mask = mask
+
self._mask.sigChanged.connect(self._updatePlotMask)
self._mask.sigChanged.connect(self._emitSigMaskChanged)
@@ -378,12 +394,12 @@ class BaseMaskToolsWidget(qt.QWidget):
self._lastPencilPos = None
self._multipleMasks = 'exclusive'
- super(BaseMaskToolsWidget, self).__init__(parent)
-
self._maskFileDir = qt.QDir.home().absolutePath()
self.plot.sigInteractiveModeChanged.connect(
self._interactiveModeChanged)
+ self._initWidgets()
+
def _emitSigMaskChanged(self):
"""Notify mask changes"""
self.sigMaskChanged.emit()
@@ -570,17 +586,10 @@ class BaseMaskToolsWidget(qt.QWidget):
"""Init drawing tools widgets"""
layout = qt.QVBoxLayout()
- # Draw tools
- self.browseAction = qt.QAction(
- icons.getQIcon('normal'), 'Browse', None)
- self.browseAction.setShortcut(qt.QKeySequence(qt.Qt.Key_B))
- self.browseAction.setToolTip(
- 'Disables drawing tools, enables zooming interaction mode'
- ' <b>B</b>')
- self.browseAction.setCheckable(True)
- self.browseAction.triggered.connect(self._activeBrowseMode)
+ self.browseAction = PanModeAction(self.plot, self.plot)
self.addAction(self.browseAction)
+ # Draw tools
self.rectAction = qt.QAction(
icons.getQIcon('shape-rectangle'), 'Rectangle selection', None)
self.rectAction.setToolTip(
@@ -608,23 +617,22 @@ class BaseMaskToolsWidget(qt.QWidget):
'Pencil tool: (Un)Mask using a pencil <b>P</b>')
self.pencilAction.setCheckable(True)
self.pencilAction.triggered.connect(self._activePencilMode)
- self.addAction(self.polygonAction)
+ self.addAction(self.pencilAction)
self.drawActionGroup = qt.QActionGroup(self)
self.drawActionGroup.setExclusive(True)
- self.drawActionGroup.addAction(self.browseAction)
self.drawActionGroup.addAction(self.rectAction)
self.drawActionGroup.addAction(self.polygonAction)
self.drawActionGroup.addAction(self.pencilAction)
- self.browseAction.setChecked(True)
-
- self.drawButtons = {}
- for action in self.drawActionGroup.actions():
+ actions = (self.browseAction, self.rectAction,
+ self.polygonAction, self.pencilAction)
+ drawButtons = []
+ for action in actions:
btn = qt.QToolButton()
btn.setDefaultAction(action)
- self.drawButtons[action.text()] = btn
- container = self._hboxWidget(*self.drawButtons.values())
+ drawButtons.append(btn)
+ container = self._hboxWidget(*drawButtons)
layout.addWidget(container)
# Mask/Unmask radio buttons
@@ -644,10 +652,7 @@ class BaseMaskToolsWidget(qt.QWidget):
self.maskStateWidget = self._hboxWidget(maskRadioBtn, unmaskRadioBtn)
layout.addWidget(self.maskStateWidget)
- # Connect mask state widget visibility with browse action
- self.maskStateWidget.setHidden(self.browseAction.isChecked())
- self.browseAction.toggled[bool].connect(
- self.maskStateWidget.setHidden)
+ self.maskStateWidget.setHidden(True)
# Pencil settings
self.pencilSetting = self._createPencilSettings(None)
@@ -752,15 +757,11 @@ class BaseMaskToolsWidget(qt.QWidget):
form = qt.QFormLayout()
- self.minLineEdit = qt.QLineEdit()
- self.minLineEdit.setText('0')
- self.minLineEdit.setValidator(qt.QDoubleValidator())
+ self.minLineEdit = FloatEdit(self, value=0)
self.minLineEdit.setEnabled(False)
form.addRow('Min:', self.minLineEdit)
- self.maxLineEdit = qt.QLineEdit()
- self.maxLineEdit.setText('0')
- self.maxLineEdit.setValidator(qt.QDoubleValidator())
+ self.maxLineEdit = FloatEdit(self, value=0)
self.maxLineEdit.setEnabled(False)
form.addRow('Max:', self.maxLineEdit)
@@ -790,8 +791,9 @@ class BaseMaskToolsWidget(qt.QWidget):
"""Reset drawing action when disabling widget"""
if (event.type() == qt.QEvent.EnabledChange and
not self.isEnabled() and
- not self.browseAction.isChecked()):
- self.browseAction.trigger() # Disable drawing tool
+ self.drawActionGroup.checkedAction()):
+ # Disable drawing tool by setting interaction to zoom
+ self.browseAction.trigger()
def save(self, filename, kind):
"""Save current mask in a file
@@ -839,7 +841,7 @@ class BaseMaskToolsWidget(qt.QWidget):
# Set no mask level
colors[0] = (0., 0., 0., 0.)
- self._colormap['colors'] = colors
+ self._colormap.setColormapLUT(colors)
def resetMaskColors(self, level=None):
"""Reset the mask color at the given level to be defaultColors
@@ -928,10 +930,11 @@ class BaseMaskToolsWidget(qt.QWidget):
If changed from elsewhere, disable drawing tool
"""
if source is not self:
- # Do not trigger browseAction to avoid to call
- # self.plot.setInteractiveMode
- self.browseAction.setChecked(True)
+ self.pencilAction.setChecked(False)
+ self.rectAction.setChecked(False)
+ self.polygonAction.setChecked(False)
self._releaseDrawingMode()
+ self._updateDrawingModeWidgets()
def _releaseDrawingMode(self):
"""Release the drawing mode if is was used"""
@@ -940,16 +943,6 @@ class BaseMaskToolsWidget(qt.QWidget):
self.plot.sigPlotSignal.disconnect(self._plotDrawEvent)
self._drawingMode = None
- def _activeBrowseMode(self):
- """Handle browse action mode triggered by user.
-
- Set plot interactive mode only when
- the user is triggering the browse action.
- """
- self._releaseDrawingMode()
- self.plot.setInteractiveMode('zoom', source=self)
- self._updateDrawingModeWidgets()
-
def _activeRectMode(self):
"""Handle rect action mode triggering"""
self._releaseDrawingMode()
@@ -981,6 +974,7 @@ class BaseMaskToolsWidget(qt.QWidget):
self._updateDrawingModeWidgets()
def _updateDrawingModeWidgets(self):
+ self.maskStateWidget.setVisible(self._drawingMode is not None)
self.pencilSetting.setVisible(self._drawingMode == 'pencil')
# Handle plot drawing events
@@ -1032,20 +1026,20 @@ class BaseMaskToolsWidget(qt.QWidget):
if self.belowThresholdAction.isChecked():
if self.minLineEdit.text():
self._mask.updateBelowThreshold(self.levelSpinBox.value(),
- float(self.minLineEdit.text()))
+ self.minLineEdit.value())
self._mask.commit()
elif self.betweenThresholdAction.isChecked():
if self.minLineEdit.text() and self.maxLineEdit.text():
- min_ = float(self.minLineEdit.text())
- max_ = float(self.maxLineEdit.text())
+ min_ = self.minLineEdit.value()
+ max_ = self.maxLineEdit.value()
self._mask.updateBetweenThresholds(self.levelSpinBox.value(),
min_, max_)
self._mask.commit()
elif self.aboveThresholdAction.isChecked():
if self.maxLineEdit.text():
- max_ = float(self.maxLineEdit.text())
+ max_ = float(self.maxLineEdit.value())
self._mask.updateAboveThreshold(self.levelSpinBox.value(),
max_)
self._mask.commit()
@@ -1059,7 +1053,7 @@ class BaseMaskToolsWidget(qt.QWidget):
class BaseMaskToolsDockWidget(qt.QDockWidget):
"""Base class for :class:`MaskToolsWidget` and
- :class:`ScatterMaskToolsWidget`
+ :class:`ScatterMaskToolsWidget`.
For integration in a :class:`PlotWindow`.
@@ -1069,10 +1063,15 @@ class BaseMaskToolsDockWidget(qt.QDockWidget):
sigMaskChanged = qt.Signal()
- def __init__(self, parent=None, name='Mask'):
+ def __init__(self, parent=None, name='Mask', widget=None):
super(BaseMaskToolsDockWidget, self).__init__(parent)
self.setWindowTitle(name)
+ if not isinstance(widget, BaseMaskToolsWidget):
+ raise TypeError("BaseMaskToolsDockWidget requires a MaskToolsWidget")
+ self.setWidget(widget)
+ self.widget().sigMaskChanged.connect(self._emitSigMaskChanged)
+
self.layout().setContentsMargins(0, 0, 0, 0)
self.dockLocationChanged.connect(self._dockLocationChanged)
self.topLevelChanged.connect(self._topLevelChanged)
@@ -1107,6 +1106,11 @@ class BaseMaskToolsDockWidget(qt.QDockWidget):
"""
return self.widget().setSelectionMask(mask, copy=copy)
+ def resetSelectionMask(self):
+ """Reset the mask to an array of zeros with the shape of the
+ current data."""
+ self.widget().resetSelectionMask()
+
def toggleViewAction(self):
"""Returns a checkable action that shows or closes this widget.
diff --git a/silx/gui/plot/__init__.py b/silx/gui/plot/__init__.py
index 06a24a7..b03392d 100644
--- a/silx/gui/plot/__init__.py
+++ b/silx/gui/plot/__init__.py
@@ -43,7 +43,7 @@ List of Qt widgets:
By default, those widget are using matplotlib_.
They can optionally use a faster OpenGL-based rendering (beta feature),
which is enabled by setting the ``backend`` argument to ``'gl'``
-when creating the widgets (See :class:`.Plot`).
+when creating the widgets (See :class:`.PlotWidget`).
.. note::
@@ -56,12 +56,9 @@ when creating the widgets (See :class:`.Plot`).
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "22/02/2016"
+__date__ = "03/05/2017"
-# First of all init matplotlib and set its backend
-from .backends import _matplotlib # noqa
-
from .PlotWidget import PlotWidget # noqa
from .PlotWindow import PlotWindow, Plot1D, Plot2D # noqa
from .ImageView import ImageView # noqa
diff --git a/silx/gui/plot/_utils/__init__.py b/silx/gui/plot/_utils/__init__.py
index 355bc02..3c2dfa4 100644
--- a/silx/gui/plot/_utils/__init__.py
+++ b/silx/gui/plot/_utils/__init__.py
@@ -35,17 +35,6 @@ from .panzoom import FLOAT32_SAFE_MIN, FLOAT32_MINPOS, FLOAT32_SAFE_MAX
from .panzoom import applyZoomToPlot, applyPan
-def clipColormapLogRange(colormap):
- """Clip colormap vmin and vmax to 1, 10 if normalization is 'log' and vmin
- or vmax <1
-
- :param dict colormap: the colormap for which we want to clip vmin and vmax
- """
- if colormap['normalization'] is 'log':
- if colormap['vmin'] < 1. or colormap['vmax'] < 1.:
- colormap['vmin'], colormap['vmax'] = 1., 10.
-
-
def addMarginsToLimits(margins, isXLog, isYLog,
xMin, xMax, yMin, yMax, y2Min=None, y2Max=None):
"""Returns updated limits by extending them with margins.
diff --git a/silx/gui/plot/_utils/panzoom.py b/silx/gui/plot/_utils/panzoom.py
index bec31df..3946a04 100644
--- a/silx/gui/plot/_utils/panzoom.py
+++ b/silx/gui/plot/_utils/panzoom.py
@@ -24,13 +24,12 @@
# ###########################################################################*/
"""Functions to apply pan and zoom on a Plot"""
-__authors__ = ["T. Vincent"]
+__authors__ = ["T. Vincent", "V. Valls"]
__license__ = "MIT"
-__date__ = "21/03/2017"
+__date__ = "08/08/2017"
import math
-
import numpy
@@ -93,8 +92,8 @@ def applyZoomToPlot(plot, scaleF, center=None):
:param center: (x, y) coords in pixel coordinates of the zoom center.
:type center: 2-tuple of float
"""
- xMin, xMax = plot.getGraphXLimits()
- yMin, yMax = plot.getGraphYLimits()
+ xMin, xMax = plot.getXAxis().getLimits()
+ yMin, yMax = plot.getYAxis().getLimits()
if center is None:
left, top, width, height = plot.getPlotBoundsInPixels()
@@ -106,17 +105,17 @@ def applyZoomToPlot(plot, scaleF, center=None):
assert dataCenterPos is not None
xMin, xMax = scale1DRange(xMin, xMax, dataCenterPos[0], scaleF,
- plot.isXAxisLogarithmic())
+ plot.getXAxis()._isLogarithmic())
yMin, yMax = scale1DRange(yMin, yMax, dataCenterPos[1], scaleF,
- plot.isYAxisLogarithmic())
+ plot.getYAxis()._isLogarithmic())
dataPos = plot.pixelToData(cx, cy, axis="right")
assert dataPos is not None
y2Center = dataPos[1]
- y2Min, y2Max = plot.getGraphYLimits(axis="right")
+ y2Min, y2Max = plot.getYAxis(axis="right").getLimits()
y2Min, y2Max = scale1DRange(y2Min, y2Max, y2Center, scaleF,
- plot.isYAxisLogarithmic())
+ plot.getYAxis()._isLogarithmic())
plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max)
@@ -154,3 +153,140 @@ def applyPan(min_, max_, panFactor, isLog10):
if newMin > - float('inf') and newMax < float('inf'):
min_, max_ = newMin, newMax
return min_, max_
+
+
+class _Unset(object):
+ """To be able to have distinction between None and unset"""
+ pass
+
+
+class ViewConstraints(object):
+ """
+ Store constraints applied on the view box and compute the resulting view box.
+ """
+
+ def __init__(self):
+ self._min = [None, None]
+ self._max = [None, None]
+ self._minRange = [None, None]
+ self._maxRange = [None, None]
+
+ def update(self, xMin=_Unset, xMax=_Unset,
+ yMin=_Unset, yMax=_Unset,
+ minXRange=_Unset, maxXRange=_Unset,
+ minYRange=_Unset, maxYRange=_Unset):
+ """
+ Update the constraints managed by the object
+
+ The constraints are the same as the ones provided by PyQtGraph.
+
+ :param float xMin: Minimum allowed x-axis value.
+ (default do not change the stat, None remove the constraint)
+ :param float xMax: Maximum allowed x-axis value.
+ (default do not change the stat, None remove the constraint)
+ :param float yMin: Minimum allowed y-axis value.
+ (default do not change the stat, None remove the constraint)
+ :param float yMax: Maximum allowed y-axis value.
+ (default do not change the stat, None remove the constraint)
+ :param float minXRange: Minimum allowed left-to-right span across the
+ view (default do not change the stat, None remove the constraint)
+ :param float maxXRange: Maximum allowed left-to-right span across the
+ view (default do not change the stat, None remove the constraint)
+ :param float minYRange: Minimum allowed top-to-bottom span across the
+ view (default do not change the stat, None remove the constraint)
+ :param float maxYRange: Maximum allowed top-to-bottom span across the
+ view (default do not change the stat, None remove the constraint)
+ :return: True if the constraints was changed
+ """
+ updated = False
+
+ minRange = [minXRange, minYRange]
+ maxRange = [maxXRange, maxYRange]
+ minPos = [xMin, yMin]
+ maxPos = [xMax, yMax]
+
+ for axis in range(2):
+
+ value = minPos[axis]
+ if value is not _Unset and value != self._min[axis]:
+ self._min[axis] = value
+ updated = True
+
+ value = maxPos[axis]
+ if value is not _Unset and value != self._max[axis]:
+ self._max[axis] = value
+ updated = True
+
+ value = minRange[axis]
+ if value is not _Unset and value != self._minRange[axis]:
+ self._minRange[axis] = value
+ updated = True
+
+ value = maxRange[axis]
+ if value is not _Unset and value != self._maxRange[axis]:
+ self._maxRange[axis] = value
+ updated = True
+
+ # Sanity checks
+
+ for axis in range(2):
+ if self._maxRange[axis] is not None and self._min[axis] is not None and self._max[axis] is not None:
+ # max range cannot be larger than bounds
+ diff = self._max[axis] - self._min[axis]
+ self._maxRange[axis] = min(self._maxRange[axis], diff)
+ updated = True
+
+ return updated
+
+ def normalize(self, xMin, xMax, yMin, yMax, allow_scaling=True):
+ """Normalize a view range defined by x and y corners using predefined
+ containts.
+
+ :param float xMin: Min position of the x-axis
+ :param float xMax: Max position of the x-axis
+ :param float yMin: Min position of the y-axis
+ :param float yMax: Max position of the y-axis
+ :param bool allow_scaling: Allow or not to apply scaling for the
+ normalization. Used according to the interaction mode.
+ :return: A normalized tuple of (xMin, xMax, yMin, yMax)
+ """
+ viewRange = [[xMin, xMax], [yMin, yMax]]
+
+ for axis in range(2):
+ # clamp xRange and yRange
+ if allow_scaling:
+ diff = viewRange[axis][1] - viewRange[axis][0]
+ delta = None
+ if self._maxRange[axis] is not None and diff > self._maxRange[axis]:
+ delta = self._maxRange[axis] - diff
+ elif self._minRange[axis] is not None and diff < self._minRange[axis]:
+ delta = self._minRange[axis] - diff
+ if delta is not None:
+ viewRange[axis][0] -= delta * 0.5
+ viewRange[axis][1] += delta * 0.5
+
+ # clamp min and max positions
+ outMin = self._min[axis] is not None and viewRange[axis][0] < self._min[axis]
+ outMax = self._max[axis] is not None and viewRange[axis][1] > self._max[axis]
+
+ if outMin and outMax:
+ if allow_scaling:
+ # we can clamp both sides
+ viewRange[axis][0] = self._min[axis]
+ viewRange[axis][1] = self._max[axis]
+ else:
+ # center the result
+ delta = viewRange[axis][1] - viewRange[axis][0]
+ mid = self._min[axis] + self._max[axis] - self._min[axis]
+ viewRange[axis][0] = mid - delta
+ viewRange[axis][1] = mid + delta
+ elif outMin:
+ delta = self._min[axis] - viewRange[axis][0]
+ viewRange[axis][0] += delta
+ viewRange[axis][1] += delta
+ elif outMax:
+ delta = self._max[axis] - viewRange[axis][1]
+ viewRange[axis][0] += delta
+ viewRange[axis][1] += delta
+
+ return viewRange[0][0], viewRange[0][1], viewRange[1][0], viewRange[1][1]
diff --git a/silx/gui/plot/actions/PlotAction.py b/silx/gui/plot/actions/PlotAction.py
new file mode 100644
index 0000000..6eb9ba3
--- /dev/null
+++ b/silx/gui/plot/actions/PlotAction.py
@@ -0,0 +1,79 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-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.
+#
+# ###########################################################################*/
+"""
+The class :class:`.PlotAction` help the creation of a qt.QAction associated
+with a :class:`.PlotWidget`.
+"""
+
+from __future__ import division
+
+
+__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
+__license__ = "MIT"
+__date__ = "20/04/2017"
+
+
+from collections import OrderedDict
+import weakref
+from silx.gui import icons
+from silx.gui import qt
+
+
+class PlotAction(qt.QAction):
+ """Base class for QAction that operates on a PlotWidget.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate.
+ :param icon: QIcon or str name of icon to use
+ :param str text: The name of this action to be used for menu label
+ :param str tooltip: The text of the tooltip
+ :param triggered: The callback to connect to the action's triggered
+ signal or None for no callback.
+ :param bool checkable: True for checkable action, False otherwise (default)
+ :param parent: See :class:`QAction`.
+ """
+
+ def __init__(self, plot, icon, text, tooltip=None,
+ triggered=None, checkable=False, parent=None):
+ assert plot is not None
+ self._plotRef = weakref.ref(plot)
+
+ if not isinstance(icon, qt.QIcon):
+ # Try with icon as a string and load corresponding icon
+ icon = icons.getQIcon(icon)
+
+ super(PlotAction, self).__init__(icon, text, parent)
+
+ if tooltip is not None:
+ self.setToolTip(tooltip)
+
+ self.setCheckable(checkable)
+
+ if triggered is not None:
+ self.triggered[bool].connect(triggered)
+
+ @property
+ def plot(self):
+ """The :class:`.PlotWidget` this action group is controlling."""
+ return self._plotRef()
diff --git a/silx/gui/plot/actions/__init__.py b/silx/gui/plot/actions/__init__.py
new file mode 100644
index 0000000..73829cd
--- /dev/null
+++ b/silx/gui/plot/actions/__init__.py
@@ -0,0 +1,38 @@
+# 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 package provides a set of QActions to use with :class:`PlotWidget`
+
+It also contains the :class:'.PlotAction' (Base class for QAction that operates
+on a PlotWidget)
+"""
+
+__authors__ = ["H. Payno"]
+__license__ = "MIT"
+__date__ = "16/08/2017"
+
+from .PlotAction import PlotAction
+from . import control
+from . import mode
+from . import io
diff --git a/silx/gui/plot/actions/control.py b/silx/gui/plot/actions/control.py
new file mode 100644
index 0000000..23e710e
--- /dev/null
+++ b/silx/gui/plot/actions/control.py
@@ -0,0 +1,549 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-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.
+#
+# ###########################################################################*/
+"""
+:mod:`silx.gui.plot.actions.control` provides a set of QAction relative to control
+of a :class:`.PlotWidget`.
+
+The following QAction are available:
+
+- :class:`ColormapAction`
+- :class:`CrosshairAction`
+- :class:`CurveStyleAction`
+- :class:`GridAction`
+- :class:`KeepAspectRatioAction`
+- :class:`PanWithArrowKeysAction`
+- :class:`ResetZoomAction`
+- :class:`XAxisLogarithmicAction`
+- :class:`XAxisAutoScaleAction`
+- :class:`YAxisInvertedAction`
+- :class:`YAxisLogarithmicAction`
+- :class:`YAxisAutoScaleAction`
+- :class:`ZoomBackAction`
+- :class:`ZoomInAction`
+- :class:`ZoomOutAction`
+- :class:'ShowAxisAction'
+"""
+
+from __future__ import division
+
+__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
+__license__ = "MIT"
+__date__ = "27/06/2017"
+
+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
+from silx.gui import qt
+from silx.gui import icons
+
+_logger = logging.getLogger(__name__)
+
+
+class ResetZoomAction(PlotAction):
+ """QAction controlling reset zoom on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(ResetZoomAction, self).__init__(
+ plot, icon='zoom-original', text='Reset Zoom',
+ tooltip='Auto-scale the graph',
+ triggered=self._actionTriggered,
+ checkable=False, parent=parent)
+ self._autoscaleChanged(True)
+ plot.getXAxis().sigAutoScaleChanged.connect(self._autoscaleChanged)
+ plot.getYAxis().sigAutoScaleChanged.connect(self._autoscaleChanged)
+
+ def _autoscaleChanged(self, enabled):
+ xAxis = self.plot.getXAxis()
+ yAxis = self.plot.getYAxis()
+ self.setEnabled(xAxis.isAutoScale() or yAxis.isAutoScale())
+
+ if xAxis.isAutoScale() and yAxis.isAutoScale():
+ tooltip = 'Auto-scale the graph'
+ elif xAxis.isAutoScale(): # And not Y axis
+ tooltip = 'Auto-scale the x-axis of the graph only'
+ elif yAxis.isAutoScale(): # And not X axis
+ tooltip = 'Auto-scale the y-axis of the graph only'
+ else: # no axis in autoscale
+ tooltip = 'Auto-scale the graph'
+ self.setToolTip(tooltip)
+
+ def _actionTriggered(self, checked=False):
+ self.plot.resetZoom()
+
+
+class ZoomBackAction(PlotAction):
+ """QAction performing a zoom-back in :class:`.PlotWidget` limits history.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(ZoomBackAction, self).__init__(
+ plot, icon='zoom-back', text='Zoom Back',
+ tooltip='Zoom back the plot',
+ triggered=self._actionTriggered,
+ checkable=False, parent=parent)
+ self.setShortcutContext(qt.Qt.WidgetShortcut)
+
+ def _actionTriggered(self, checked=False):
+ self.plot.getLimitsHistory().pop()
+
+
+class ZoomInAction(PlotAction):
+ """QAction performing a zoom-in on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(ZoomInAction, self).__init__(
+ plot, icon='zoom-in', text='Zoom In',
+ tooltip='Zoom in the plot',
+ triggered=self._actionTriggered,
+ checkable=False, parent=parent)
+ self.setShortcut(qt.QKeySequence.ZoomIn)
+ self.setShortcutContext(qt.Qt.WidgetShortcut)
+
+ def _actionTriggered(self, checked=False):
+ _applyZoomToPlot(self.plot, 1.1)
+
+
+class ZoomOutAction(PlotAction):
+ """QAction performing a zoom-out on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(ZoomOutAction, self).__init__(
+ plot, icon='zoom-out', text='Zoom Out',
+ tooltip='Zoom out the plot',
+ triggered=self._actionTriggered,
+ checkable=False, parent=parent)
+ self.setShortcut(qt.QKeySequence.ZoomOut)
+ self.setShortcutContext(qt.Qt.WidgetShortcut)
+
+ def _actionTriggered(self, checked=False):
+ _applyZoomToPlot(self.plot, 1. / 1.1)
+
+
+class XAxisAutoScaleAction(PlotAction):
+ """QAction controlling X axis autoscale on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(XAxisAutoScaleAction, self).__init__(
+ plot, icon='plot-xauto', text='X Autoscale',
+ tooltip='Enable x-axis auto-scale when checked.\n'
+ 'If unchecked, x-axis does not change when reseting zoom.',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ self.setChecked(plot.getXAxis().isAutoScale())
+ plot.getXAxis().sigAutoScaleChanged.connect(self.setChecked)
+
+ def _actionTriggered(self, checked=False):
+ self.plot.getXAxis().setAutoScale(checked)
+ if checked:
+ self.plot.resetZoom()
+
+
+class YAxisAutoScaleAction(PlotAction):
+ """QAction controlling Y axis autoscale on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(YAxisAutoScaleAction, self).__init__(
+ plot, icon='plot-yauto', text='Y Autoscale',
+ tooltip='Enable y-axis auto-scale when checked.\n'
+ 'If unchecked, y-axis does not change when reseting zoom.',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ self.setChecked(plot.getYAxis().isAutoScale())
+ plot.getYAxis().sigAutoScaleChanged.connect(self.setChecked)
+
+ def _actionTriggered(self, checked=False):
+ self.plot.getYAxis().setAutoScale(checked)
+ if checked:
+ self.plot.resetZoom()
+
+
+class XAxisLogarithmicAction(PlotAction):
+ """QAction controlling X axis log scale on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(XAxisLogarithmicAction, self).__init__(
+ plot, icon='plot-xlog', text='X Log. scale',
+ tooltip='Logarithmic x-axis when checked',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ self.axis = plot.getXAxis()
+ self.setChecked(self.axis.getScale() == self.axis.LOGARITHMIC)
+ self.axis.sigScaleChanged.connect(self._setCheckedIfLogScale)
+
+ def _setCheckedIfLogScale(self, scale):
+ self.setChecked(scale == self.axis.LOGARITHMIC)
+
+ def _actionTriggered(self, checked=False):
+ scale = self.axis.LOGARITHMIC if checked else self.axis.LINEAR
+ self.axis.setScale(scale)
+
+
+class YAxisLogarithmicAction(PlotAction):
+ """QAction controlling Y axis log scale on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(YAxisLogarithmicAction, self).__init__(
+ plot, icon='plot-ylog', text='Y Log. scale',
+ tooltip='Logarithmic y-axis when checked',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ self.axis = plot.getYAxis()
+ self.setChecked(self.axis.getScale() == self.axis.LOGARITHMIC)
+ self.axis.sigScaleChanged.connect(self._setCheckedIfLogScale)
+
+ def _setCheckedIfLogScale(self, scale):
+ self.setChecked(scale == self.axis.LOGARITHMIC)
+
+ def _actionTriggered(self, checked=False):
+ scale = self.axis.LOGARITHMIC if checked else self.axis.LINEAR
+ self.axis.setScale(scale)
+
+
+class GridAction(PlotAction):
+ """QAction controlling grid mode on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param str gridMode: The grid mode to use in 'both', 'major'.
+ See :meth:`.PlotWidget.setGraphGrid`
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, gridMode='both', parent=None):
+ assert gridMode in ('both', 'major')
+ self._gridMode = gridMode
+
+ super(GridAction, self).__init__(
+ plot, icon='plot-grid', text='Grid',
+ tooltip='Toggle grid (on/off)',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ self.setChecked(plot.getGraphGrid() is not None)
+ plot.sigSetGraphGrid.connect(self._gridChanged)
+
+ def _gridChanged(self, which):
+ """Slot listening for PlotWidget grid mode change."""
+ self.setChecked(which != 'None')
+
+ def _actionTriggered(self, checked=False):
+ self.plot.setGraphGrid(self._gridMode if checked else None)
+
+
+class CurveStyleAction(PlotAction):
+ """QAction controlling curve style on a :class:`.PlotWidget`.
+
+ It changes the default line and markers style which updates all
+ curves on the plot.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(CurveStyleAction, self).__init__(
+ plot, icon='plot-toggle-points', text='Curve style',
+ tooltip='Change curve line and markers style',
+ triggered=self._actionTriggered,
+ checkable=False, parent=parent)
+
+ def _actionTriggered(self, checked=False):
+ currentState = (self.plot.isDefaultPlotLines(),
+ self.plot.isDefaultPlotPoints())
+
+ # line only, line and symbol, symbol only
+ states = (True, False), (True, True), (False, True)
+ newState = states[(states.index(currentState) + 1) % 3]
+
+ self.plot.setDefaultPlotLines(newState[0])
+ self.plot.setDefaultPlotPoints(newState[1])
+
+
+class ColormapAction(PlotAction):
+ """QAction opening a ColormapDialog to update the colormap.
+
+ Both the active image colormap and the default colormap are updated.
+
+ :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(ColormapAction, self).__init__(
+ plot, icon='colormap', text='Colormap',
+ tooltip="Change colormap",
+ triggered=self._actionTriggered,
+ checkable=False, parent=parent)
+
+ 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()
+
+ 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
+
+ else:
+ # Set dialog from active image
+ colormap = image.getColormap()
+
+ data = image.getData(copy=False)
+
+ 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())
+
+ # Run the dialog listening to colormap change
+ self._dialog.sigColormapChanged.connect(self._colormapChanged)
+ result = self._dialog.exec_()
+ self._dialog.sigColormapChanged.disconnect(self._colormapChanged)
+
+ if not result: # Restore the previous colormap
+ self._colormapChanged(colormap)
+
+ def _colormapChanged(self, colormap):
+ # Update default colormap
+ self.plot.setDefaultColormap(colormap)
+
+ # Update active image colormap
+ activeImage = self.plot.getActiveImage()
+ if isinstance(activeImage, items.ColormapMixIn):
+ activeImage.setColormap(colormap)
+
+
+class KeepAspectRatioAction(PlotAction):
+ """QAction controlling aspect ratio on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ # Uses two images for checked/unchecked states
+ self._states = {
+ False: (icons.getQIcon('shape-circle-solid'),
+ "Keep data aspect ratio"),
+ True: (icons.getQIcon('shape-ellipse-solid'),
+ "Do no keep data aspect ratio")
+ }
+
+ icon, tooltip = self._states[plot.isKeepDataAspectRatio()]
+ super(KeepAspectRatioAction, self).__init__(
+ plot,
+ icon=icon,
+ text='Toggle keep aspect ratio',
+ tooltip=tooltip,
+ triggered=self._actionTriggered,
+ checkable=False,
+ parent=parent)
+ plot.sigSetKeepDataAspectRatio.connect(
+ self._keepDataAspectRatioChanged)
+
+ def _keepDataAspectRatioChanged(self, aspectRatio):
+ """Handle Plot set keep aspect ratio signal"""
+ icon, tooltip = self._states[aspectRatio]
+ self.setIcon(icon)
+ self.setToolTip(tooltip)
+
+ def _actionTriggered(self, checked=False):
+ # This will trigger _keepDataAspectRatioChanged
+ self.plot.setKeepDataAspectRatio(not self.plot.isKeepDataAspectRatio())
+
+
+class YAxisInvertedAction(PlotAction):
+ """QAction controlling Y orientation on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ # Uses two images for checked/unchecked states
+ self._states = {
+ False: (icons.getQIcon('plot-ydown'),
+ "Orient Y axis downward"),
+ True: (icons.getQIcon('plot-yup'),
+ "Orient Y axis upward"),
+ }
+
+ icon, tooltip = self._states[plot.getYAxis().isInverted()]
+ super(YAxisInvertedAction, self).__init__(
+ plot,
+ icon=icon,
+ text='Invert Y Axis',
+ tooltip=tooltip,
+ triggered=self._actionTriggered,
+ checkable=False,
+ parent=parent)
+ plot.getYAxis().sigInvertedChanged.connect(self._yAxisInvertedChanged)
+
+ def _yAxisInvertedChanged(self, inverted):
+ """Handle Plot set y axis inverted signal"""
+ icon, tooltip = self._states[inverted]
+ self.setIcon(icon)
+ self.setToolTip(tooltip)
+
+ def _actionTriggered(self, checked=False):
+ # This will trigger _yAxisInvertedChanged
+ yAxis = self.plot.getYAxis()
+ yAxis.setInverted(not yAxis.isInverted())
+
+
+class CrosshairAction(PlotAction):
+ """QAction toggling crosshair cursor on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param str color: Color to use to draw the crosshair
+ :param int linewidth: Width of the crosshair cursor
+ :param str linestyle: Style of line. See :meth:`.Plot.setGraphCursor`
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, color='black', linewidth=1, linestyle='-',
+ parent=None):
+ self.color = color
+ """Color used to draw the crosshair (str)."""
+
+ self.linewidth = linewidth
+ """Width of the crosshair cursor (int)."""
+
+ self.linestyle = linestyle
+ """Style of line of the cursor (str)."""
+
+ super(CrosshairAction, self).__init__(
+ plot, icon='crosshair', text='Crosshair Cursor',
+ tooltip='Enable crosshair cursor when checked',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ self.setChecked(plot.getGraphCursor() is not None)
+ plot.sigSetGraphCursor.connect(self.setChecked)
+
+ def _actionTriggered(self, checked=False):
+ self.plot.setGraphCursor(checked,
+ color=self.color,
+ linestyle=self.linestyle,
+ linewidth=self.linewidth)
+
+
+class PanWithArrowKeysAction(PlotAction):
+ """QAction toggling pan with arrow keys on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+
+ super(PanWithArrowKeysAction, self).__init__(
+ plot, icon='arrow-keys', text='Pan with arrow keys',
+ tooltip='Enable pan with arrow keys when checked',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ self.setChecked(plot.isPanWithArrowKeys())
+ plot.sigSetPanWithArrowKeys.connect(self.setChecked)
+
+ def _actionTriggered(self, checked=False):
+ self.plot.setPanWithArrowKeys(checked)
+
+
+class ShowAxisAction(PlotAction):
+ """QAction controlling axis visibility on a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ tooltip = 'Show plot axis when checked, otherwise hide them'
+ PlotAction.__init__(self,
+ plot,
+ icon='axis',
+ text='show axis',
+ tooltip=tooltip,
+ triggered=self._actionTriggered,
+ checkable=True,
+ parent=parent)
+ self.setChecked(self.plot._backend.isAxesDisplayed())
+ plot._sigAxesVisibilityChanged.connect(self.setChecked)
+
+ def _actionTriggered(self, checked=False):
+ self.plot.setAxesDisplayed(checked)
diff --git a/silx/gui/plot/actions/fit.py b/silx/gui/plot/actions/fit.py
new file mode 100644
index 0000000..d7256ab
--- /dev/null
+++ b/silx/gui/plot/actions/fit.py
@@ -0,0 +1,189 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-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.
+#
+# ###########################################################################*/
+"""
+:mod:`silx.gui.plot.actions.fit` module provides actions relative to fit.
+
+The following QAction are available:
+
+- :class:`.FitAction`
+
+.. autoclass:`.FitAction`
+"""
+
+from __future__ import division
+
+__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
+__license__ = "MIT"
+__date__ = "28/06/2017"
+
+from . import PlotAction
+import logging
+from silx.gui import qt
+from silx.gui.plot.ItemsSelectionDialog import ItemsSelectionDialog
+from silx.gui.plot.items import Curve, Histogram
+
+_logger = logging.getLogger(__name__)
+
+
+def _getUniqueCurve(plt):
+ """Get a single curve from the plot.
+ Get the active curve if any, else if a single curve is plotted
+ get it, else return None.
+
+ :param plt: :class:`.PlotWidget` instance on which to operate
+
+ :return: return value of plt.getActiveCurve(), or plt.getAllCurves()[0],
+ or None
+ """
+ curve = plt.getActiveCurve()
+ if curve is not None:
+ return curve
+
+ curves = plt.getAllCurves()
+ if len(curves) == 0:
+ return None
+
+ if len(curves) == 1 and len(plt._getItems(kind='histogram')) == 0:
+ return curves[0]
+
+ return None
+
+
+def _getUniqueHistogram(plt):
+ """Return the histogram if there is a single histogram and no curve in
+ the plot. In all other cases, return None.
+
+ :param plt: :class:`.PlotWidget` instance on which to operate
+ :return: histogram or None
+ """
+ histograms = plt._getItems(kind='histogram')
+ if len(histograms) != 1:
+ return None
+ if plt.getAllCurves(just_legend=True):
+ return None
+ return histograms[0]
+
+
+class FitAction(PlotAction):
+ """QAction to open a :class:`FitWidget` and set its data to the
+ active curve if any, or to the first curve.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+ def __init__(self, plot, parent=None):
+ super(FitAction, self).__init__(
+ plot, icon='math-fit', text='Fit curve',
+ tooltip='Open a fit dialog',
+ triggered=self._getFitWindow,
+ checkable=False, parent=parent)
+ self.fit_window = None
+
+ def _getFitWindow(self):
+ self.xlabel = self.plot.getXAxis().getLabel()
+ self.ylabel = self.plot.getYAxis().getLabel()
+ self.xmin, self.xmax = self.plot.getXAxis().getLimits()
+
+ histo = _getUniqueHistogram(self.plot)
+ curve = _getUniqueCurve(self.plot)
+
+ 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.setWindowTitle("Select item to be fitted")
+ isd.setItemsSelectionMode(qt.QTableWidget.SingleSelection)
+ isd.setAvailableKinds(["curve", "histogram"])
+ isd.selectAllKinds()
+
+ result = isd.exec_()
+ if result and len(isd.getSelectedItems()) == 1:
+ item = isd.getSelectedItems()[0]
+ else:
+ return
+ elif histo is not None:
+ # presence of a unique histo and no curve
+ item = histo
+ elif curve is not None:
+ # presence of a unique or active curve
+ item = curve
+
+ self.legend = item.getLegend()
+
+ if isinstance(item, Histogram):
+ bin_edges = item.getBinEdgesData(copy=False)
+ # take the middle coordinate between adjacent bin edges
+ self.x = (bin_edges[1:] + bin_edges[:-1]) / 2
+ self.y = item.getValueData(copy=False)
+ # else take the active curve, or else the unique curve
+ elif isinstance(item, Curve):
+ self.x = item.getXData(copy=False)
+ self.y = item.getYData(copy=False)
+
+ # open a window with a FitWidget
+ if self.fit_window is None:
+ self.fit_window = qt.QMainWindow()
+ # import done here rather than at module level to avoid circular import
+ # FitWidget -> BackgroundWidget -> PlotWindow -> actions -> fit -> FitWidget
+ from ...fit.FitWidget import FitWidget
+ self.fit_widget = FitWidget(parent=self.fit_window)
+ self.fit_window.setCentralWidget(
+ self.fit_widget)
+ self.fit_widget.guibuttons.DismissButton.clicked.connect(
+ self.fit_window.close)
+ self.fit_widget.sigFitWidgetSignal.connect(
+ self.handle_signal)
+ self.fit_window.show()
+ else:
+ if self.fit_window.isHidden():
+ self.fit_window.show()
+ self.fit_widget.show()
+ self.fit_window.raise_()
+
+ self.fit_widget.setData(self.x, self.y,
+ xmin=self.xmin, xmax=self.xmax)
+ self.fit_window.setWindowTitle(
+ "Fitting " + self.legend +
+ " on x range %f-%f" % (self.xmin, self.xmax))
+
+ def handle_signal(self, ddict):
+ x_fit = self.x[self.xmin <= self.x]
+ x_fit = x_fit[x_fit <= self.xmax]
+ fit_legend = "Fit <%s>" % self.legend
+ fit_curve = self.plot.getCurve(fit_legend)
+
+ if ddict["event"] == "FitFinished":
+ y_fit = self.fit_widget.fitmanager.gendata()
+ if fit_curve is None:
+ self.plot.addCurve(x_fit, y_fit,
+ fit_legend,
+ xlabel=self.xlabel, ylabel=self.ylabel,
+ resetzoom=False)
+ else:
+ fit_curve.setData(x_fit, y_fit)
+ fit_curve.setVisible(True)
+
+ if ddict["event"] in ["FitStarted", "FitFailed"]:
+ if fit_curve is not None:
+ fit_curve.setVisible(False)
diff --git a/silx/gui/plot/actions/histogram.py b/silx/gui/plot/actions/histogram.py
new file mode 100644
index 0000000..a4a91e9
--- /dev/null
+++ b/silx/gui/plot/actions/histogram.py
@@ -0,0 +1,170 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-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.
+#
+# ###########################################################################*/
+"""
+:mod:`silx.gui.plot.actions.histogram` provides actions relative to histograms
+for :class:`.PlotWidget`.
+
+The following QAction are available:
+
+- :class:`PixelIntensitiesHistoAction`
+"""
+
+from __future__ import division
+
+__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
+__date__ = "27/06/2017"
+__license__ = "MIT"
+
+from . import PlotAction
+from silx.math.histogram import Histogramnd
+import numpy
+import logging
+from silx.gui import qt
+
+_logger = logging.getLogger(__name__)
+
+
+class PixelIntensitiesHistoAction(PlotAction):
+ """QAction to plot the pixels intensities diagram
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ PlotAction.__init__(self,
+ plot,
+ icon='pixel-intensities',
+ text='pixels intensity',
+ tooltip='Compute image intensity distribution',
+ triggered=self._triggered,
+ parent=parent,
+ checkable=True)
+ self._plotHistogram = None
+ self._connectedToActiveImage = False
+ self._histo = None
+
+ def _triggered(self, checked):
+ """Update the plot of the histogram visibility status
+
+ :param bool checked: status of the action button
+ """
+ if checked:
+ if not self._connectedToActiveImage:
+ self.plot.sigActiveImageChanged.connect(
+ self._activeImageChanged)
+ self._connectedToActiveImage = True
+ self.computeIntensityDistribution()
+
+ self.getHistogramPlotWidget().show()
+
+ else:
+ if self._connectedToActiveImage:
+ self.plot.sigActiveImageChanged.disconnect(
+ self._activeImageChanged)
+ self._connectedToActiveImage = False
+
+ self.getHistogramPlotWidget().hide()
+
+ def _activeImageChanged(self, previous, legend):
+ """Handle active image change: toggle enabled toolbar, update curve"""
+ if self.isChecked():
+ self.computeIntensityDistribution()
+
+ def computeIntensityDistribution(self):
+ """Get the active image and compute the image intensity distribution
+ """
+ activeImage = self.plot.getActiveImage()
+
+ if activeImage is not None:
+ image = activeImage.getData(copy=False)
+ if image.ndim == 3: # RGB(A) images
+ _logger.info('Converting current image from RGB(A) to grayscale\
+ in order to compute the intensity distribution')
+ image = (image[:, :, 0] * 0.299 +
+ image[:, :, 1] * 0.587 +
+ image[:, :, 2] * 0.114)
+
+ xmin = numpy.nanmin(image)
+ xmax = numpy.nanmax(image)
+ nbins = min(1024, int(numpy.sqrt(image.size)))
+ data_range = xmin, xmax
+
+ # bad hack: get 256 bins in the case we have a B&W
+ if numpy.issubdtype(image.dtype, numpy.integer):
+ if nbins > xmax - xmin:
+ nbins = xmax - xmin
+
+ nbins = max(2, nbins)
+
+ data = image.ravel().astype(numpy.float32)
+ histogram = Histogramnd(data, n_bins=nbins, histo_range=data_range)
+ assert len(histogram.edges) == 1
+ self._histo = histogram.histo
+ edges = histogram.edges[0]
+ plot = self.getHistogramPlotWidget()
+ plot.addHistogram(histogram=self._histo,
+ edges=edges,
+ legend='pixel intensity',
+ fill=True,
+ color='red')
+ plot.resetZoom()
+
+ def eventFilter(self, qobject, event):
+ """Observe when the close event is emitted then
+ simply uncheck the action button
+
+ :param qobject: the object observe
+ :param event: the event received by qobject
+ """
+ if event.type() == qt.QEvent.Close:
+ if self._plotHistogram is not None:
+ self._plotHistogram.hide()
+ self.setChecked(False)
+
+ return PlotAction.eventFilter(self, qobject, event)
+
+ def getHistogramPlotWidget(self):
+ """Create the plot histogram if needed, otherwise create it
+
+ :return: the PlotWidget showing the histogram of the pixel intensities
+ """
+ from silx.gui.plot.PlotWindow import Plot1D
+ if self._plotHistogram is None:
+ self._plotHistogram = Plot1D(parent=self.plot)
+ self._plotHistogram.setWindowFlags(qt.Qt.Window)
+ self._plotHistogram.setWindowTitle('Image Intensity Histogram')
+ self._plotHistogram.installEventFilter(self)
+ self._plotHistogram.getXAxis().setLabel("Value")
+ self._plotHistogram.getYAxis().setLabel("Count")
+
+ return self._plotHistogram
+
+ def getHistogram(self):
+ """Return the last computed histogram
+
+ :return: the histogram displayed in the HistogramPlotWiget
+ """
+ return self._histo
diff --git a/silx/gui/plot/actions/io.py b/silx/gui/plot/actions/io.py
new file mode 100644
index 0000000..50410e3
--- /dev/null
+++ b/silx/gui/plot/actions/io.py
@@ -0,0 +1,538 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-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.
+#
+# ###########################################################################*/
+"""
+:mod:`silx.gui.plot.actions.io` provides a set of QAction relative of inputs
+and outputs for a :class:`.PlotWidget`.
+
+The following QAction are available:
+
+- :class:`CopyAction`
+- :class:`PrintAction`
+- :class:`SaveAction`
+"""
+
+from __future__ import division
+
+__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
+__license__ = "MIT"
+__date__ = "27/06/2017"
+
+from . import PlotAction
+from silx.io.utils import save1D, savespec
+import logging
+import sys
+from collections import OrderedDict
+import traceback
+import numpy
+from silx.gui import qt
+from silx.third_party.EdfFile import EdfFile
+from silx.third_party.TiffIO import TiffIO
+from silx.gui._utils import convertArrayToQImage
+if sys.version_info[0] == 3:
+ from io import BytesIO
+else:
+ import cStringIO as _StringIO
+ BytesIO = _StringIO.StringIO
+
+_logger = logging.getLogger(__name__)
+
+
+class SaveAction(PlotAction):
+ """QAction for saving Plot content.
+
+ It opens a Save as... dialog.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate.
+ :param parent: See :class:`QAction`.
+ """
+ # TODO find a way to make the filter list selectable and extensible
+
+ SNAPSHOT_FILTER_SVG = 'Plot Snapshot as SVG (*.svg)'
+ SNAPSHOT_FILTER_PNG = 'Plot Snapshot as PNG (*.png)'
+
+ SNAPSHOT_FILTERS = (SNAPSHOT_FILTER_PNG, SNAPSHOT_FILTER_SVG)
+
+ # Dict of curve filters with CSV-like format
+ # Using ordered dict to guarantee filters order
+ # Note: '%.18e' is numpy.savetxt default format
+ CURVE_FILTERS_TXT = OrderedDict((
+ ('Curve as Raw ASCII (*.txt)',
+ {'fmt': '%.18e', 'delimiter': ' ', 'header': False}),
+ ('Curve as ";"-separated CSV (*.csv)',
+ {'fmt': '%.18e', 'delimiter': ';', 'header': True}),
+ ('Curve as ","-separated CSV (*.csv)',
+ {'fmt': '%.18e', 'delimiter': ',', 'header': True}),
+ ('Curve as tab-separated CSV (*.csv)',
+ {'fmt': '%.18e', 'delimiter': '\t', 'header': True}),
+ ('Curve as OMNIC CSV (*.csv)',
+ {'fmt': '%.7E', 'delimiter': ',', 'header': False}),
+ ('Curve as SpecFile (*.dat)',
+ {'fmt': '%.7g', 'delimiter': '', 'header': False})
+ ))
+
+ CURVE_FILTER_NPY = 'Curve as NumPy binary file (*.npy)'
+
+ CURVE_FILTERS = list(CURVE_FILTERS_TXT.keys()) + [CURVE_FILTER_NPY]
+
+ ALL_CURVES_FILTERS = ("All curves as SpecFile (*.dat)", )
+
+ IMAGE_FILTER_EDF = 'Image data as EDF (*.edf)'
+ IMAGE_FILTER_TIFF = 'Image data as TIFF (*.tif)'
+ IMAGE_FILTER_NUMPY = 'Image data as NumPy binary file (*.npy)'
+ IMAGE_FILTER_ASCII = 'Image data as ASCII (*.dat)'
+ IMAGE_FILTER_CSV_COMMA = 'Image data as ,-separated CSV (*.csv)'
+ IMAGE_FILTER_CSV_SEMICOLON = 'Image data as ;-separated CSV (*.csv)'
+ 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_FILTERS = (IMAGE_FILTER_EDF,
+ IMAGE_FILTER_TIFF,
+ IMAGE_FILTER_NUMPY,
+ IMAGE_FILTER_ASCII,
+ IMAGE_FILTER_CSV_COMMA,
+ IMAGE_FILTER_CSV_SEMICOLON,
+ IMAGE_FILTER_CSV_TAB,
+ IMAGE_FILTER_RGB_PNG,
+ IMAGE_FILTER_RGB_TIFF)
+
+ def __init__(self, plot, parent=None):
+ super(SaveAction, self).__init__(
+ plot, icon='document-save', text='Save as...',
+ tooltip='Save curve/image/plot snapshot dialog',
+ triggered=self._actionTriggered,
+ checkable=False, parent=parent)
+ self.setShortcut(qt.QKeySequence.Save)
+ self.setShortcutContext(qt.Qt.WidgetShortcut)
+
+ def _errorMessage(self, informativeText=''):
+ """Display an error message."""
+ # TODO issue with QMessageBox size fixed and too small
+ msg = qt.QMessageBox(self.plot)
+ msg.setIcon(qt.QMessageBox.Critical)
+ msg.setInformativeText(informativeText + ' ' + str(sys.exc_info()[1]))
+ msg.setDetailedText(traceback.format_exc())
+ msg.exec_()
+
+ def _saveSnapshot(self, filename, nameFilter):
+ """Save a snapshot of the :class:`PlotWindow` widget.
+
+ :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 == self.SNAPSHOT_FILTER_PNG:
+ fileFormat = 'png'
+ elif nameFilter == self.SNAPSHOT_FILTER_SVG:
+ fileFormat = 'svg'
+ else: # Format not supported
+ _logger.error(
+ 'Saving plot snapshot failed: format not supported')
+ return False
+
+ self.plot.saveGraph(filename, fileFormat=fileFormat)
+ return True
+
+ def _saveCurve(self, filename, nameFilter):
+ """Save a curve 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.CURVE_FILTERS:
+ return False
+
+ # Check if a curve is to be saved
+ curve = self.plot.getActiveCurve()
+ # before calling _saveCurve, if there is no selected curve, we
+ # make sure there is only one curve on the graph
+ if curve is None:
+ curves = self.plot.getAllCurves()
+ if not curves:
+ self._errorMessage("No curve to be saved")
+ return False
+ curve = curves[0]
+
+ if nameFilter in self.CURVE_FILTERS_TXT:
+ filter_ = self.CURVE_FILTERS_TXT[nameFilter]
+ fmt = filter_['fmt']
+ csvdelim = filter_['delimiter']
+ autoheader = filter_['header']
+ else:
+ # .npy
+ fmt, csvdelim, autoheader = ("", "", False)
+
+ # If curve has no associated label, get the default from the plot
+ xlabel = curve.getXLabel()
+ if xlabel is None:
+ xlabel = self.plot.getXAxis().getLabel()
+ ylabel = curve.getYLabel()
+ if ylabel is None:
+ ylabel = self.plot.getYAxis().getLabel()
+
+ try:
+ save1D(filename,
+ curve.getXData(copy=False),
+ curve.getYData(copy=False),
+ xlabel, [ylabel],
+ fmt=fmt, csvdelim=csvdelim,
+ autoheader=autoheader)
+ except IOError:
+ self._errorMessage('Save failed\n')
+ return False
+
+ return True
+
+ def _saveCurves(self, filename, nameFilter):
+ """Save all curves 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.ALL_CURVES_FILTERS:
+ return False
+
+ curves = self.plot.getAllCurves()
+ if not curves:
+ self._errorMessage("No curves to be saved")
+ return False
+
+ curve = curves[0]
+ scanno = 1
+ try:
+ specfile = savespec(filename,
+ curve.getXData(copy=False),
+ curve.getYData(copy=False),
+ curve.getXLabel(),
+ curve.getYLabel(),
+ fmt="%.7g", scan_number=1, mode="w",
+ write_file_header=True,
+ close_file=False)
+ except IOError:
+ self._errorMessage('Save failed\n')
+ return False
+
+ for curve in curves[1:]:
+ try:
+ scanno += 1
+ specfile = savespec(specfile,
+ curve.getXData(copy=False),
+ curve.getYData(copy=False),
+ curve.getXLabel(),
+ curve.getYLabel(),
+ fmt="%.7g", scan_number=scanno, mode="w",
+ write_file_header=False,
+ close_file=False)
+ except IOError:
+ self._errorMessage('Save failed\n')
+ return False
+ specfile.close()
+
+ return True
+
+ def _saveImage(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.IMAGE_FILTERS:
+ return False
+
+ image = self.plot.getActiveImage()
+ if image is None:
+ qt.QMessageBox.warning(
+ self.plot, "No Data", "No image to be saved")
+ return False
+
+ data = image.getData(copy=False)
+
+ # TODO Use silx.io for writing files
+ if nameFilter == self.IMAGE_FILTER_EDF:
+ edfFile = EdfFile(filename, access="w+")
+ edfFile.WriteImage({}, data, Append=0)
+ return True
+
+ elif nameFilter == self.IMAGE_FILTER_TIFF:
+ tiffFile = TiffIO(filename, mode='w')
+ tiffFile.writeImage(data, software='silx')
+ return True
+
+ elif nameFilter == self.IMAGE_FILTER_NUMPY:
+ try:
+ numpy.save(filename, data)
+ except IOError:
+ self._errorMessage('Save failed\n')
+ return False
+ return True
+
+ elif nameFilter in (self.IMAGE_FILTER_ASCII,
+ self.IMAGE_FILTER_CSV_COMMA,
+ self.IMAGE_FILTER_CSV_SEMICOLON,
+ self.IMAGE_FILTER_CSV_TAB):
+ csvdelim, filetype = {
+ self.IMAGE_FILTER_ASCII: (' ', 'txt'),
+ self.IMAGE_FILTER_CSV_COMMA: (',', 'csv'),
+ self.IMAGE_FILTER_CSV_SEMICOLON: (';', 'csv'),
+ self.IMAGE_FILTER_CSV_TAB: ('\t', 'csv'),
+ }[nameFilter]
+
+ height, width = data.shape
+ rows, cols = numpy.mgrid[0:height, 0:width]
+ try:
+ save1D(filename, rows.ravel(), (cols.ravel(), data.ravel()),
+ filetype=filetype,
+ xlabel='row',
+ ylabels=['column', 'value'],
+ csvdelim=csvdelim,
+ autoheader=True)
+
+ except IOError:
+ self._errorMessage('Save failed\n')
+ return False
+ return True
+
+ elif nameFilter in (self.IMAGE_FILTER_RGB_PNG,
+ self.IMAGE_FILTER_RGB_TIFF):
+ # Get displayed image
+ rgbaImage = image.getRgbaImageData(copy=False)
+ # Convert RGB QImage
+ qimage = convertArrayToQImage(rgbaImage[:, :, :3])
+
+ if nameFilter == self.IMAGE_FILTER_RGB_PNG:
+ fileFormat = 'PNG'
+ else:
+ fileFormat = 'TIFF'
+
+ if qimage.save(filename, fileFormat):
+ return True
+ else:
+ _logger.error('Failed to save image as %s', filename)
+ qt.QMessageBox.critical(
+ self.parent(),
+ 'Save image as',
+ 'Failed to save image')
+
+ return False
+
+ def _actionTriggered(self, checked=False):
+ """Handle save action."""
+ # Set-up filters
+ filters = []
+
+ # Add image filters if there is an active image
+ if self.plot.getActiveImage() is not None:
+ filters.extend(self.IMAGE_FILTERS)
+
+ # Add curve filters if there is a curve to save
+ if (self.plot.getActiveCurve() is not None or
+ len(self.plot.getAllCurves()) == 1):
+ filters.extend(self.CURVE_FILTERS)
+ if len(self.plot.getAllCurves()) > 1:
+ filters.extend(self.ALL_CURVES_FILTERS)
+
+ filters.extend(self.SNAPSHOT_FILTERS)
+
+ # Create and run File dialog
+ dialog = qt.QFileDialog(self.plot)
+ dialog.setWindowTitle("Output File Selection")
+ dialog.setModal(1)
+ dialog.setNameFilters(filters)
+
+ dialog.setFileMode(dialog.AnyFile)
+ dialog.setAcceptMode(dialog.AcceptSave)
+
+ if not dialog.exec_():
+ return False
+
+ nameFilter = dialog.selectedNameFilter()
+ filename = dialog.selectedFiles()[0]
+ 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
+
+ # Handle save
+ if nameFilter in self.SNAPSHOT_FILTERS:
+ return self._saveSnapshot(filename, nameFilter)
+ elif nameFilter in self.CURVE_FILTERS:
+ return self._saveCurve(filename, nameFilter)
+ elif nameFilter in self.ALL_CURVES_FILTERS:
+ return self._saveCurves(filename, nameFilter)
+ elif nameFilter in self.IMAGE_FILTERS:
+ return self._saveImage(filename, nameFilter)
+ else:
+ _logger.warning('Unsupported file filter: %s', nameFilter)
+ return False
+
+
+def _plotAsPNG(plot):
+ """Save a :class:`Plot` as PNG and return the payload.
+
+ :param plot: The :class:`Plot` to save
+ """
+ pngFile = BytesIO()
+ plot.saveGraph(pngFile, fileFormat='png')
+ pngFile.flush()
+ pngFile.seek(0)
+ data = pngFile.read()
+ pngFile.close()
+ return data
+
+
+class PrintAction(PlotAction):
+ """QAction for printing the plot.
+
+ It opens a Print dialog.
+
+ Current implementation print a bitmap of the plot area and not vector
+ graphics, so printing quality is not great.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate.
+ :param parent: See :class:`QAction`.
+ """
+
+ # Share QPrinter instance to propose latest used as default
+ _printer = None
+
+ def __init__(self, plot, parent=None):
+ super(PrintAction, self).__init__(
+ plot, icon='document-print', text='Print...',
+ tooltip='Open print dialog',
+ triggered=self.printPlot,
+ checkable=False, parent=parent)
+ self.setShortcut(qt.QKeySequence.Print)
+ self.setShortcutContext(qt.Qt.WidgetShortcut)
+
+ @property
+ def printer(self):
+ """The QPrinter instance used by the actions.
+
+ This is shared accross all instances of PrintAct
+ """
+ if self._printer is None:
+ PrintAction._printer = qt.QPrinter()
+ return self._printer
+
+ def printPlotAsWidget(self):
+ """Open the print dialog and print the plot.
+
+ Use :meth:`QWidget.render` to print the plot
+
+ :return: True if successful
+ """
+ dialog = qt.QPrintDialog(self.printer, self.plot)
+ dialog.setWindowTitle('Print Plot')
+ if not dialog.exec_():
+ return False
+
+ # Print a snapshot of the plot widget at the top of the page
+ widget = self.plot.centralWidget()
+
+ painter = qt.QPainter()
+ if not painter.begin(self.printer):
+ return False
+
+ pageRect = self.printer.pageRect()
+ xScale = pageRect.width() / widget.width()
+ yScale = pageRect.height() / widget.height()
+ scale = min(xScale, yScale)
+
+ painter.translate(pageRect.width() / 2., 0.)
+ painter.scale(scale, scale)
+ painter.translate(-widget.width() / 2., 0.)
+ widget.render(painter)
+ painter.end()
+
+ return True
+
+ def printPlot(self):
+ """Open the print dialog and print the plot.
+
+ Use :meth:`Plot.saveGraph` to print the plot.
+
+ :return: True if successful
+ """
+ # Init printer and start printer dialog
+ dialog = qt.QPrintDialog(self.printer, self.plot)
+ dialog.setWindowTitle('Print Plot')
+ if not dialog.exec_():
+ return False
+
+ # Save Plot as PNG and make a pixmap from it with default dpi
+ pngData = _plotAsPNG(self.plot)
+
+ pixmap = qt.QPixmap()
+ pixmap.loadFromData(pngData, 'png')
+
+ xScale = self.printer.pageRect().width() / pixmap.width()
+ yScale = self.printer.pageRect().height() / pixmap.height()
+ scale = min(xScale, yScale)
+
+ # Draw pixmap with painter
+ painter = qt.QPainter()
+ if not painter.begin(self.printer):
+ return False
+
+ painter.drawPixmap(0, 0,
+ pixmap.width() * scale,
+ pixmap.height() * scale,
+ pixmap)
+ painter.end()
+
+ return True
+
+
+class CopyAction(PlotAction):
+ """QAction to copy :class:`.PlotWidget` content to clipboard.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(CopyAction, self).__init__(
+ plot, icon='edit-copy', text='Copy plot',
+ tooltip='Copy a snapshot of the plot into the clipboard',
+ triggered=self.copyPlot,
+ checkable=False, parent=parent)
+ self.setShortcut(qt.QKeySequence.Copy)
+ self.setShortcutContext(qt.Qt.WidgetShortcut)
+
+ def copyPlot(self):
+ """Copy plot content to the clipboard as a bitmap."""
+ # Save Plot as PNG and make a QImage from it with default dpi
+ pngData = _plotAsPNG(self.plot)
+ image = qt.QImage.fromData(pngData, 'png')
+ qt.QApplication.clipboard().setImage(image)
diff --git a/silx/gui/plot/actions/medfilt.py b/silx/gui/plot/actions/medfilt.py
new file mode 100644
index 0000000..3305d1b
--- /dev/null
+++ b/silx/gui/plot/actions/medfilt.py
@@ -0,0 +1,145 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-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.
+#
+# ###########################################################################*/
+"""
+:mod:`silx.gui.plot.actions.medfilt` provides a set of QAction to apply filter
+on data contained in a :class:`.PlotWidget`.
+
+The following QAction are available:
+
+- :class:`MedianFilterAction`
+- :class:`MedianFilter1DAction`
+- :class:`MedianFilter2DAction`
+
+"""
+
+from __future__ import division
+
+__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
+__license__ = "MIT"
+
+__date__ = "24/05/2017"
+
+from . import PlotAction
+from silx.gui.widgets.MedianFilterDialog import MedianFilterDialog
+from silx.math.medianfilter import medfilt2d
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+class MedianFilterAction(PlotAction):
+ """QAction to plot the pixels intensities diagram
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ PlotAction.__init__(self,
+ plot,
+ icon='median-filter',
+ text='median filter',
+ tooltip='Apply a median filter on the image',
+ triggered=self._triggered,
+ parent=parent)
+ self._originalImage = None
+ self._legend = None
+ self._filteredImage = None
+ self._popup = MedianFilterDialog(parent=None)
+ self._popup.sigFilterOptChanged.connect(self._updateFilter)
+ self.plot.sigActiveImageChanged.connect(self._updateActiveImage)
+ self._updateActiveImage()
+
+ def _triggered(self, checked):
+ """Update the plot of the histogram visibility status
+
+ :param bool checked: status of the action button
+ """
+ self._popup.show()
+
+ def _updateActiveImage(self):
+ """Set _activeImageLegend and _originalImage from the active image"""
+ self._activeImageLegend = self.plot.getActiveImage(just_legend=True)
+ if self._activeImageLegend is None:
+ self._originalImage = None
+ self._legend = None
+ else:
+ self._originalImage = self.plot.getImage(self._activeImageLegend).getData(copy=False)
+ self._legend = self.plot.getImage(self._activeImageLegend).getLegend()
+
+ def _updateFilter(self, kernelWidth, conditional=False):
+ if self._originalImage is None:
+ return
+
+ self.plot.sigActiveImageChanged.disconnect(self._updateActiveImage)
+ filteredImage = self._computeFilteredImage(kernelWidth, conditional)
+ self.plot.addImage(data=filteredImage,
+ legend=self._legend,
+ replace=True)
+ self.plot.sigActiveImageChanged.connect(self._updateActiveImage)
+
+ def _computeFilteredImage(self, kernelWidth, conditional):
+ raise NotImplemented('MedianFilterAction is a an abstract class')
+
+ def getFilteredImage(self):
+ """
+ :return: the image with the median filter apply on"""
+ return self._filteredImage
+
+
+class MedianFilter1DAction(MedianFilterAction):
+ """Define the MedianFilterAction for 1D
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+ def __init__(self, plot, parent=None):
+ MedianFilterAction.__init__(self,
+ plot,
+ parent=parent)
+
+ def _computeFilteredImage(self, kernelWidth, conditional):
+ assert(self.plot is not None)
+ return medfilt2d(self._originalImage,
+ (kernelWidth, 1),
+ conditional)
+
+
+class MedianFilter2DAction(MedianFilterAction):
+ """Define the MedianFilterAction for 2D
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+ def __init__(self, plot, parent=None):
+ MedianFilterAction.__init__(self,
+ plot,
+ parent=parent)
+
+ def _computeFilteredImage(self, kernelWidth, conditional):
+ assert(self.plot is not None)
+ return medfilt2d(self._originalImage,
+ (kernelWidth, kernelWidth),
+ conditional)
diff --git a/silx/gui/plot/actions/mode.py b/silx/gui/plot/actions/mode.py
new file mode 100644
index 0000000..026a94d
--- /dev/null
+++ b/silx/gui/plot/actions/mode.py
@@ -0,0 +1,100 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-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.
+#
+# ###########################################################################*/
+"""
+:mod:`silx.gui.plot.actions.mode` provides a set of QAction relative to mouse
+mode of a :class:`.PlotWidget`.
+
+The following QAction are available:
+
+- :class:`ZoomModeAction`
+- :class:`PanModeAction`
+"""
+
+from __future__ import division
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "16/08/2017"
+
+from . import PlotAction
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+class ZoomModeAction(PlotAction):
+ """QAction controlling the zoom mode of a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(ZoomModeAction, self).__init__(
+ plot, icon='zoom', text='Zoom mode',
+ tooltip='Zoom in or out',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ # Listen to mode change
+ self.plot.sigInteractiveModeChanged.connect(self._modeChanged)
+ # Init the state
+ self._modeChanged(None)
+
+ def _modeChanged(self, source):
+ modeDict = self.plot.getInteractiveMode()
+ old = self.blockSignals(True)
+ self.setChecked(modeDict["mode"] == "zoom")
+ self.blockSignals(old)
+
+ def _actionTriggered(self, checked=False):
+ self.plot.setInteractiveMode('zoom', source=self)
+
+
+class PanModeAction(PlotAction):
+ """QAction controlling the pan mode of a :class:`.PlotWidget`.
+
+ :param plot: :class:`.PlotWidget` instance on which to operate
+ :param parent: See :class:`QAction`
+ """
+
+ def __init__(self, plot, parent=None):
+ super(PanModeAction, self).__init__(
+ plot, icon='pan', text='Pan mode',
+ tooltip='Pan the view',
+ triggered=self._actionTriggered,
+ checkable=True, parent=parent)
+ # Listen to mode change
+ self.plot.sigInteractiveModeChanged.connect(self._modeChanged)
+ # Init the state
+ self._modeChanged(None)
+
+ def _modeChanged(self, source):
+ modeDict = self.plot.getInteractiveMode()
+ old = self.blockSignals(True)
+ self.setChecked(modeDict["mode"] == "pan")
+ self.blockSignals(old)
+
+ def _actionTriggered(self, checked=False):
+ self.plot.setInteractiveMode('pan', source=self)
diff --git a/silx/gui/plot/backends/BackendBase.py b/silx/gui/plot/backends/BackendBase.py
index 74f96af..12561b2 100644
--- a/silx/gui/plot/backends/BackendBase.py
+++ b/silx/gui/plot/backends/BackendBase.py
@@ -31,10 +31,11 @@ This API is a simplified version of PyMca PlotBackend API.
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "18/02/2016"
+__date__ = "16/08/2017"
import weakref
+from ... import qt
# Names for setCursor
@@ -58,9 +59,12 @@ class BackendBase(object):
self.__yLimits = {'left': (1., 100.), 'right': (1., 100.)}
self.__yAxisInverted = False
self.__keepDataAspectRatio = False
+ self._axesDisplayed = True
# Store a weakref to get access to the plot state.
self._setPlot(plot)
+ self.__zoomBackAction = None
+
@property
def _plot(self):
"""The plot this backend is attached to."""
@@ -79,6 +83,18 @@ class BackendBase(object):
"""
self._plotRef = weakref.ref(plot)
+ # Default Qt context menu
+
+ def contextMenuEvent(self, event):
+ """Override QWidget.contextMenuEvent to implement the context menu"""
+ if self.__zoomBackAction is None:
+ from ..actions.control import ZoomBackAction # Avoid cyclic import
+ self.__zoomBackAction = ZoomBackAction(plot=self._plot,
+ parent=self._plot)
+ menu = qt.QMenu(self)
+ menu.addAction(self.__zoomBackAction)
+ menu.exec_(event.globalPos())
+
# Add methods
def addCurve(self, x, y, legend,
@@ -147,9 +163,9 @@ class BackendBase(object):
:param int z: Layer on which to draw the image
:param bool selectable: indicate if the image can be selected
:param bool draggable: indicate if the image can be moved
- :param colormap: Dictionary describing the colormap to use.
+ :param colormap: :class:`.Colormap` describing the colormap to use.
Ignored if data is RGB(A).
- :type colormap: dict or None
+ :type colormap: :class:`.Colormap`
:param float alpha: Opacity of the image, as a float in range [0, 1].
:returns: The handle used by the backend to univocally access the image
"""
@@ -307,6 +323,8 @@ class BackendBase(object):
def saveGraph(self, fileName, fileFormat, dpi):
"""Save the graph to a file (or a StringIO)
+ At least "png", "svg" are supported.
+
:param fileName: Destination
:type fileName: String or StringIO or BytesIO
:param str fileFormat: String specifying the format
@@ -472,3 +490,18 @@ class BackendBase(object):
:return: bounds as a 4-tuple of int: (left, top, width, height)
"""
raise NotImplementedError()
+
+ def setAxesDisplayed(self, displayed):
+ """Display or not the axes.
+
+ :param bool displayed: If `True` axes are displayed. If `False` axes
+ are not anymore visible and the margin used for them is removed.
+ """
+ self._axesDisplayed = displayed
+
+ def isAxesDisplayed(self):
+ """private because in some case it is possible that one of the two axes
+ are displayed and not the other.
+ This only check status set to axes from the public API
+ """
+ return self._axesDisplayed \ No newline at end of file
diff --git a/silx/gui/plot/backends/BackendMatplotlib.py b/silx/gui/plot/backends/BackendMatplotlib.py
index f9e60d5..59e753e 100644
--- a/silx/gui/plot/backends/BackendMatplotlib.py
+++ b/silx/gui/plot/backends/BackendMatplotlib.py
@@ -28,7 +28,7 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent, H. Payno"]
__license__ = "MIT"
-__date__ = "18/01/2017"
+__date__ = "16/08/2017"
import logging
@@ -41,7 +41,9 @@ _logger = logging.getLogger(__name__)
from ... import qt
-from ._matplotlib import FigureCanvasQTAgg
+# First of all init matplotlib and set its backend
+from ..matplotlib import Colormap as MPLColormap
+from ..matplotlib import FigureCanvasQTAgg
import matplotlib
from matplotlib.container import Container
from matplotlib.figure import Figure
@@ -51,9 +53,8 @@ from matplotlib.backend_bases import MouseEvent
from matplotlib.lines import Line2D
from matplotlib.collections import PathCollection, LineCollection
-from .ModestImage import ModestImage
+from ..matplotlib.ModestImage import ModestImage
from . import BackendBase
-from .. import Colors
from .._utils import FLOAT32_MINPOS
@@ -75,6 +76,7 @@ class BackendMatplotlib(BackendBase.BackendBase):
# This attribute is used to ensure consistent values returned
# when getting the limits at the expense of a replot
self._dirtyLimits = True
+ self._axesDisplayed = True
self.fig = Figure()
self.fig.set_facecolor("w")
@@ -83,6 +85,16 @@ class BackendMatplotlib(BackendBase.BackendBase):
self.ax2 = self.ax.twinx()
self.ax2.set_label("right")
+ # disable the use of offsets
+ try:
+ self.ax.get_yaxis().get_major_formatter().set_useOffset(False)
+ self.ax.get_xaxis().get_major_formatter().set_useOffset(False)
+ self.ax2.get_yaxis().get_major_formatter().set_useOffset(False)
+ self.ax2.get_xaxis().get_major_formatter().set_useOffset(False)
+ except:
+ _logger.warning('Cannot disabled axes offsets in %s ' \
+ % matplotlib.__version__)
+
# critical for picking!!!!
self.ax2.set_zorder(0)
self.ax2.set_autoscaley_on(True)
@@ -102,10 +114,6 @@ class BackendMatplotlib(BackendBase.BackendBase):
self._graphCursor = tuple()
self.matplotlibVersion = matplotlib.__version__
- self.setGraphXLimits(0., 100.)
- self.setGraphYLimits(0., 100., axis='right')
- self.setGraphYLimits(0., 100., axis='left')
-
self._enableAxis('right', False)
# Add methods
@@ -142,7 +150,7 @@ class BackendMatplotlib(BackendBase.BackendBase):
errorbarColor = color
# On Debian 7 at least, Nx1 array yerr does not seems supported
- if (yerror is not None and yerror.ndim == 2 and
+ if (isinstance(yerror, numpy.ndarray) and yerror.ndim == 2 and
yerror.shape[1] == 1 and len(x) != 1):
yerror = numpy.ravel(yerror)
@@ -226,17 +234,17 @@ class BackendMatplotlib(BackendBase.BackendBase):
# Add support for transparent colormap for uint8 data with
# colormap with 256 colors, linear norm, [0, 255] range
if matplotlib.__version__ < '1.2.0':
- if (len(data.shape) == 2 and colormap['name'] is None and
- 'colors' in colormap):
- colors = numpy.array(colormap['colors'], copy=False)
+ if (len(data.shape) == 2 and colormap.getName() is None and
+ colormap.getColormapLUT() is not None):
+ colors = colormap.getColormapLUT()
if (colors.shape[-1] == 4 and
not numpy.all(numpy.equal(colors[3], 255))):
# This is a transparent colormap
if (colors.shape == (256, 4) and
- colormap['normalization'] == 'linear' and
- not colormap['autoscale'] and
- colormap['vmin'] == 0 and
- colormap['vmax'] == 255 and
+ colormap.getNormalization() == 'linear' and
+ not colormap.isAutoscale() and
+ colormap.getVMin() == 0 and
+ colormap.getVMax() == 255 and
data.dtype == numpy.uint8):
# Supported case, convert data to RGBA
data = colors[data.reshape(-1)].reshape(
@@ -264,7 +272,7 @@ class BackendMatplotlib(BackendBase.BackendBase):
else:
# Convert colormap argument to matplotlib colormap
- scalarMappable = Colors.getMPLScalarMappable(colormap, data)
+ scalarMappable = MPLColormap.getScalarMappable(colormap, data)
# try as data
image = imageClass(self.ax,
@@ -440,7 +448,10 @@ class BackendMatplotlib(BackendBase.BackendBase):
item._infoText.remove()
item._infoText = None
self._overlays.discard(item)
- item.remove()
+ try:
+ item.remove()
+ except ValueError:
+ pass # Already removed e.g., in set[X|Y]AxisLogarithmic
# Interaction methods
@@ -591,10 +602,16 @@ class BackendMatplotlib(BackendBase.BackendBase):
# Graph axes
def setXAxisLogarithmic(self, flag):
+ if matplotlib.__version__ >= "2.0.0":
+ self.ax.cla()
+ self.ax2.cla()
self.ax2.set_xscale('log' if flag else 'linear')
self.ax.set_xscale('log' if flag else 'linear')
def setYAxisLogarithmic(self, flag):
+ if matplotlib.__version__ >= "2.0.0":
+ self.ax.cla()
+ self.ax2.cla()
self.ax2.set_yscale('log' if flag else 'linear')
self.ax.set_yscale('log' if flag else 'linear')
@@ -649,6 +666,29 @@ class BackendMatplotlib(BackendBase.BackendBase):
return (bbox.bounds[0] * dpi, bbox.bounds[1] * dpi,
bbox.bounds[2] * dpi, bbox.bounds[3] * dpi)
+ def setAxesDisplayed(self, displayed):
+ """Display or not the axes.
+
+ :param bool displayed: If `True` axes are displayed. If `False` axes
+ are not anymore visible and the margin used for them is removed.
+ """
+ BackendBase.BackendBase.setAxesDisplayed(self, displayed)
+ if displayed:
+ # show axes and viewbox rect
+ self.ax.set_axis_on()
+ self.ax2.set_axis_on()
+ # set the default margins
+ self.ax.set_position([.15, .15, .75, .75])
+ self.ax2.set_position([.15, .15, .75, .75])
+ else:
+ # hide axes and viewbox rect
+ self.ax.set_axis_off()
+ self.ax2.set_axis_off()
+ # remove external margins
+ self.ax.set_position([0, 0, 1, 1])
+ self.ax2.set_position([0, 0, 1, 1])
+ self._plot._setDirtyPlot()
+
class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
"""QWidget matplotlib backend using a QtAgg canvas.
@@ -682,6 +722,11 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
self.mpl_connect('motion_notify_event', self._onMouseMove)
self.mpl_connect('scroll_event', self._onMouseWheel)
+ def contextMenuEvent(self, event):
+ """Override QWidget.contextMenuEvent to implement the context menu"""
+ # Makes sure it is overridden (issue with PySide)
+ BackendBase.BackendBase.contextMenuEvent(self, event)
+
def postRedisplay(self):
self._sigPostRedisplay.emit()
@@ -738,18 +783,12 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
self._picked.append({'kind': 'image', 'legend': label[9:]})
else: # it's a curve, item have no picker for now
- if isinstance(event.artist, PathCollection):
- data = event.artist.get_offsets()[event.ind, :]
- xdata, ydata = data[:, 0], data[:, 1]
- elif isinstance(event.artist, Line2D):
- xdata = event.artist.get_xdata()[event.ind]
- ydata = event.artist.get_ydata()[event.ind]
- else:
+ if not isinstance(event.artist, (PathCollection, Line2D)):
_logger.info('Unsupported artist, ignored')
return
self._picked.append({'kind': 'curve', 'legend': label,
- 'xdata': xdata, 'ydata': ydata})
+ 'indices': event.ind})
def pickItems(self, x, y):
self._picked = []
@@ -776,9 +815,22 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
def draw(self):
"""Override canvas draw method to support faster draw of overlays."""
if self._plot._getDirtyPlot(): # Need a full redraw
+ # Store previous limits
+ xLimits = self.ax.get_xbound()
+ yLimits = self.ax.get_ybound()
+ yRightLimits = self.ax2.get_ybound()
+
FigureCanvasQTAgg.draw(self)
self._background = None # Any saved background is dirty
+ # Check if limits changed due to a resize of the widget
+ if xLimits != self.ax.get_xbound():
+ self._plot.getXAxis()._emitLimitsChanged()
+ if yLimits != self.ax.get_ybound():
+ self._plot.getYAxis(axis='left')._emitLimitsChanged()
+ if yRightLimits != self.ax2.get_ybound():
+ self._plot.getYAxis(axis='left')._emitLimitsChanged()
+
if (self._overlays or self._graphCursor or
self._plot._getDirtyPlot() == 'overlay'):
# There are overlays or crosshair, or they is just no more overlays
diff --git a/silx/gui/plot/backends/BackendOpenGL.py b/silx/gui/plot/backends/BackendOpenGL.py
index bc10eca..c70b03a 100644
--- a/silx/gui/plot/backends/BackendOpenGL.py
+++ b/silx/gui/plot/backends/BackendOpenGL.py
@@ -28,7 +28,7 @@ from __future__ import division
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "21/03/2017"
+__date__ = "16/08/2017"
from collections import OrderedDict, namedtuple
from ctypes import c_void_p
@@ -39,6 +39,7 @@ import numpy
from .._utils import FLOAT32_MINPOS
from . import BackendBase
from .. import Colors
+from ..Colormap import Colormap
from ... import qt
from ..._glutils import gl
@@ -299,6 +300,7 @@ _texFragShd = """
void main(void) {
gl_FragColor = texture2D(tex, coords);
+ gl_FragColor.a = 1.0;
}
"""
@@ -313,7 +315,7 @@ def _getContext():
return _current_context
-class BackendOpenGL(BackendBase.BackendBase, qt.QGLWidget):
+class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
"""OpenGL-based Plot backend.
WARNINGS:
@@ -328,8 +330,13 @@ class BackendOpenGL(BackendBase.BackendBase, qt.QGLWidget):
_sigPostRedisplay = qt.Signal()
"""Signal handling automatic asynchronous replot"""
- def __init__(self, plot, parent=None):
- qt.QGLWidget.__init__(self, parent)
+ def __init__(self, plot, parent=None, f=qt.Qt.WindowFlags()):
+ glu.OpenGLWidget.__init__(self, parent,
+ alphaBufferSize=8,
+ depthBufferSize=0,
+ stencilBufferSize=0,
+ version=(2, 1),
+ f=f)
BackendBase.BackendBase.__init__(self, plot, parent)
self.matScreenProj = mat4Identity()
@@ -342,8 +349,6 @@ class BackendOpenGL(BackendBase.BackendBase, qt.QGLWidget):
self._keepDataAspectRatio = False
- self._devicePixelRatio = 1.0
-
self._crosshairCursor = None
self._mousePosInPixels = None
@@ -361,11 +366,6 @@ class BackendOpenGL(BackendBase.BackendBase, qt.QGLWidget):
super(BackendOpenGL, self).postRedisplay,
qt.Qt.QueuedConnection)
- # TODO is this needed? move it Plot?
- self.setGraphXLimits(0., 100.)
- self.setGraphYLimits(0., 100., axis='right')
- self.setGraphYLimits(0., 100., axis='left')
-
self.setAutoFillBackground(False)
self.setMouseTracking(True)
@@ -373,19 +373,24 @@ class BackendOpenGL(BackendBase.BackendBase, qt.QGLWidget):
_MOUSE_BTNS = {1: 'left', 2: 'right', 4: 'middle'}
+ def contextMenuEvent(self, event):
+ """Override QWidget.contextMenuEvent to implement the context menu"""
+ # Makes sure it is overridden (issue with PySide)
+ BackendBase.BackendBase.contextMenuEvent(self, event)
+
def sizeHint(self):
return qt.QSize(8 * 80, 6 * 80) # Mimic MatplotlibBackend
def mousePressEvent(self, event):
- xPixel = event.x() * self._devicePixelRatio
- yPixel = event.y() * self._devicePixelRatio
+ xPixel = event.x() * self.getDevicePixelRatio()
+ yPixel = event.y() * self.getDevicePixelRatio()
btn = self._MOUSE_BTNS[event.button()]
self._plot.onMousePress(xPixel, yPixel, btn)
event.accept()
def mouseMoveEvent(self, event):
- xPixel = event.x() * self._devicePixelRatio
- yPixel = event.y() * self._devicePixelRatio
+ xPixel = event.x() * self.getDevicePixelRatio()
+ yPixel = event.y() * self.getDevicePixelRatio()
# Handle crosshair
inXPixel, inYPixel = self._mouseInPlotArea(xPixel, yPixel)
@@ -402,16 +407,16 @@ class BackendOpenGL(BackendBase.BackendBase, qt.QGLWidget):
event.accept()
def mouseReleaseEvent(self, event):
- xPixel = event.x() * self._devicePixelRatio
- yPixel = event.y() * self._devicePixelRatio
+ xPixel = event.x() * self.getDevicePixelRatio()
+ yPixel = event.y() * self.getDevicePixelRatio()
btn = self._MOUSE_BTNS[event.button()]
self._plot.onMouseRelease(xPixel, yPixel, btn)
event.accept()
def wheelEvent(self, event):
- xPixel = event.x() * self._devicePixelRatio
- yPixel = event.y() * self._devicePixelRatio
+ xPixel = event.x() * self.getDevicePixelRatio()
+ yPixel = event.y() * self.getDevicePixelRatio()
if hasattr(event, 'angleDelta'): # Qt 5
delta = event.angleDelta().y()
@@ -424,11 +429,11 @@ class BackendOpenGL(BackendBase.BackendBase, qt.QGLWidget):
def leaveEvent(self, _):
self._plot.onMouseLeaveWidget()
- # QGLWidget API
+ # OpenGLWidget API
@staticmethod
def _setBlendFuncGL():
- # glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
+ # gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA)
gl.glBlendFuncSeparate(gl.GL_SRC_ALPHA,
gl.GL_ONE_MINUS_SRC_ALPHA,
gl.GL_ONE,
@@ -526,13 +531,6 @@ class BackendOpenGL(BackendBase.BackendBase, qt.QGLWidget):
glu.setGLContextGetter(_getContext)
- if hasattr(self, 'windowHandle'): # Qt 5
- devicePixelRatio = self.windowHandle().devicePixelRatio()
- if devicePixelRatio != self._devicePixelRatio:
- self._devicePixelRatio = devicePixelRatio
- self.resizeGL(int(self.width() * devicePixelRatio),
- int(self.height() * devicePixelRatio))
-
# Release OpenGL resources
for item in self._glGarbageCollector:
item.discard()
@@ -916,16 +914,32 @@ class BackendOpenGL(BackendBase.BackendBase, qt.QGLWidget):
def resizeGL(self, width, height):
if width == 0 or height == 0: # Do not resize
return
- self._plotFrame.size = width, height
+
+ self._plotFrame.size = (
+ int(self.getDevicePixelRatio() * width),
+ int(self.getDevicePixelRatio() * height))
self.matScreenProj = mat4Ortho(0, self._plotFrame.size[0],
self._plotFrame.size[1], 0,
1, -1)
+ # Store current ranges
+ previousXRange = self.getGraphXLimits()
+ previousYRange = self.getGraphYLimits(axis='left')
+ previousYRightRange = self.getGraphYLimits(axis='right')
+
(xMin, xMax), (yMin, yMax), (y2Min, y2Max) = \
self._plotFrame.dataRanges
self.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max)
+ # If plot range has changed, then emit signal
+ if previousXRange != self.getGraphXLimits():
+ self._plot.getXAxis()._emitLimitsChanged()
+ if previousYRange != self.getGraphYLimits(axis='left'):
+ self._plot.getYAxis(axis='left')._emitLimitsChanged()
+ if previousYRightRange != self.getGraphYLimits(axis='right'):
+ self._plot.getYAxis(axis='right')._emitLimitsChanged()
+
# Add methods
def addCurve(self, x, y, legend,
@@ -1017,23 +1031,18 @@ class BackendOpenGL(BackendBase.BackendBase, qt.QGLWidget):
'addImage: Convert %s data to float32', str(data.dtype))
data = numpy.array(data, dtype=numpy.float32, order='C')
- colormapIsLog = colormap['normalization'].startswith('log')
+ colormapIsLog = colormap.getNormalization() == 'log'
- if colormap['autoscale']:
- cmapRange = None
- else:
- cmapRange = colormap['vmin'], colormap['vmax']
- assert cmapRange[0] <= cmapRange[1]
+ cmapRange = colormap.getColormapRange(data=data)
# Retrieve colormap LUT from name and color array
- colormapLut = Colors.applyColormapToData(
- numpy.arange(256, dtype=numpy.uint8),
- name=colormap['name'],
- normalization='linear',
- autoscale=False,
- vmin=0,
- vmax=255,
- colors=colormap.get('colors'))
+ colormapDisp = Colormap(name=colormap.getName(),
+ normalization=Colormap.LINEAR,
+ vmin=0,
+ vmax=255,
+ colors=colormap.getColormapLUT())
+ colormapLut = colormapDisp.applyToData(
+ numpy.arange(256, dtype=numpy.uint8))
image = GLPlotColormap(data,
origin,
@@ -1301,8 +1310,7 @@ class BackendOpenGL(BackendBase.BackendBase, qt.QGLWidget):
if pickedIndices:
picked.append(dict(kind='curve',
legend=item.info['legend'],
- xdata=item.xData[pickedIndices],
- ydata=item.yData[pickedIndices]))
+ indices=pickedIndices))
return picked
@@ -1330,20 +1338,38 @@ class BackendOpenGL(BackendBase.BackendBase, qt.QGLWidget):
if fileFormat not in ['png', 'ppm', 'svg', 'tiff']:
raise NotImplementedError('Unsupported format: %s' % fileFormat)
- self.makeCurrent()
-
- data = numpy.empty(
- (self._plotFrame.size[1], self._plotFrame.size[0], 3),
- dtype=numpy.uint8, order='C')
+ if not self.isValid():
+ _logger.error('OpenGL 2.1 not available, cannot save OpenGL image')
+ width, height = self._plotFrame.size
+ data = numpy.zeros((height, width, 3), dtype=numpy.uint8)
+ else:
+ self.makeCurrent()
+
+ data = numpy.empty(
+ (self._plotFrame.size[1], self._plotFrame.size[0], 3),
+ dtype=numpy.uint8, order='C')
+
+ context = self.context()
+ framebufferTexture = self._plotFBOs.get(context)
+ if framebufferTexture is None:
+ # Fallback, supports direct rendering mode: _paintDirectGL
+ # might have issues as it can read on-screen framebuffer
+ fboName = self.defaultFramebufferObject()
+ width, height = self._plotFrame.size
+ else:
+ fboName = framebufferTexture.name
+ height, width = framebufferTexture.shape
- gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0)
- gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1)
- gl.glReadPixels(0, 0, self._plotFrame.size[0], self._plotFrame.size[1],
- gl.GL_RGB, gl.GL_UNSIGNED_BYTE, data)
+ previousFramebuffer = gl.glGetInteger(gl.GL_FRAMEBUFFER_BINDING)
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, fboName)
+ gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1)
+ gl.glReadPixels(0, 0, width, height,
+ gl.GL_RGB, gl.GL_UNSIGNED_BYTE, data)
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, previousFramebuffer)
- # glReadPixels gives bottom to top,
- # while images are stored as top to bottom
- data = numpy.flipud(data)
+ # glReadPixels gives bottom to top,
+ # while images are stored as top to bottom
+ data = numpy.flipud(data)
# fileName is either a file-like object or a str
saveImageToFile(data, fileName, fileFormat)
@@ -1629,3 +1655,7 @@ class BackendOpenGL(BackendBase.BackendBase, qt.QGLWidget):
def getPlotBoundsInPixels(self):
return self._plotFrame.plotOrigin + self._plotFrame.plotSize
+
+ def setAxesDisplayed(self, displayed):
+ BackendBase.BackendBase.setAxesDisplayed(self, displayed)
+ self._plotFrame.displayed = displayed \ No newline at end of file
diff --git a/silx/gui/plot/backends/glutils/GLPlotCurve.py b/silx/gui/plot/backends/glutils/GLPlotCurve.py
index 4f08054..4433613 100644
--- a/silx/gui/plot/backends/glutils/GLPlotCurve.py
+++ b/silx/gui/plot/backends/glutils/GLPlotCurve.py
@@ -1185,7 +1185,7 @@ class GLPlotCurve2D(object):
self.yVboData.offset += yAttrib.itemsize
if cAttrib is not None and colorData.dtype.kind == 'u':
- cAttrib.normalisation = True # Normalise uint to [0, 1]
+ cAttrib.normalization = True # Normalize uint to [0, 1]
self.colorVboData = cAttrib
self.useColorVboData = cAttrib is not None
self.distVboData = dAttrib
diff --git a/silx/gui/plot/backends/glutils/GLPlotFrame.py b/silx/gui/plot/backends/glutils/GLPlotFrame.py
index 367419c..eb101c4 100644
--- a/silx/gui/plot/backends/glutils/GLPlotFrame.py
+++ b/silx/gui/plot/backends/glutils/GLPlotFrame.py
@@ -317,6 +317,9 @@ class GLPlotFrame(object):
_Margins = namedtuple('Margins', ('left', 'right', 'top', 'bottom'))
+ # Margins used when plot frame is not displayed
+ _NoDisplayMargins = _Margins(0, 0, 0, 0)
+
def __init__(self, margins):
"""
:param margins: The margins around plot area for axis and labels.
@@ -332,6 +335,7 @@ class GLPlotFrame(object):
self._grid = False
self._size = 0., 0.
self._title = ''
+ self._displayed = True
@property
def isDirty(self):
@@ -344,9 +348,24 @@ class GLPlotFrame(object):
GRID_ALL_TICKS = (GRID_MAIN_TICKS + GRID_SUB_TICKS)
@property
+ def displayed(self):
+ """Whether axes and their labels are displayed or not (bool)"""
+ return self._displayed
+
+ @displayed.setter
+ def displayed(self, displayed):
+ displayed = bool(displayed)
+ if displayed != self._displayed:
+ self._displayed = displayed
+ self._dirty()
+
+ @property
def margins(self):
"""Margins in pixels around the plot."""
- return self._margins
+ if not self.displayed:
+ return self._NoDisplayMargins
+ else:
+ return self._margins
@property
def grid(self):
@@ -465,6 +484,9 @@ class GLPlotFrame(object):
_SHADERS['vertex'], _SHADERS['fragment'], attrib0='position')
def render(self):
+ if not self.displayed:
+ return
+
if self._renderResources is None:
self._buildVerticesAndLabels()
vertices, gridVertices, labels = self._renderResources
diff --git a/silx/gui/plot/backends/glutils/GLPlotImage.py b/silx/gui/plot/backends/glutils/GLPlotImage.py
index 8fff82b..df5b289 100644
--- a/silx/gui/plot/backends/glutils/GLPlotImage.py
+++ b/silx/gui/plot/backends/glutils/GLPlotImage.py
@@ -243,8 +243,7 @@ class GLPlotColormap(_GLPlotData2D):
super(GLPlotColormap, self).__init__(data, origin, scale)
self.colormap = numpy.array(colormap, copy=False)
self.cmapIsLog = cmapIsLog
- self._cmapRange = None # User-provided range info
- self._cmapRangeCache = None # Store extra data for range
+ self._cmapRange = (1., 10.) # Colormap range
self.cmapRange = cmapRange # Update _cmapRange
self._alpha = numpy.clip(alpha, 0., 1.)
@@ -264,54 +263,15 @@ class GLPlotColormap(_GLPlotData2D):
@property
def cmapRange(self):
- if self._cmapRange is None: # Auto-scale mode
- if self._cmapRangeCache is None:
- # Build data , positive ranges
- result = min_max(self.data, min_positive=True)
- min_ = result.minimum
- minPos = result.min_positive
- max_ = result.maximum
- maxPos = max_ if max_ > 0. else 1.
- if minPos is None:
- minPos = maxPos
- self._cmapRangeCache = {'range': (min_, max_),
- 'pos': (minPos, maxPos)}
-
- return self._cmapRangeCache['pos' if self.cmapIsLog else 'range']
-
- else:
- if not self.cmapIsLog:
- return self._cmapRange # Return range as is
- else:
- if self._cmapRangeCache is None:
- # Build a strictly positive range from cmapRange
- min_, max_ = self._cmapRange
- if min_ > 0. and max_ > 0.:
- minPos, maxPos = min_, max_
- else:
- result = min_max(self.data, min_positive=True)
- minPos = result.min_positive
- dataMax = result.maximum
- if max_ > 0.:
- maxPos = max_
- elif dataMax > 0.:
- maxPos = dataMax
- else:
- maxPos = 1. # Arbitrary fallback
- if minPos is None:
- minPos = maxPos
- self._cmapRangeCache = minPos, maxPos
- return self._cmapRangeCache # Strictly positive range
+ if self.cmapIsLog:
+ assert self._cmapRange[0] > 0. and self._cmapRange[1] > 0.
+ return self._cmapRange
@cmapRange.setter
def cmapRange(self, cmapRange):
- self._cmapRangeCache = None
- if cmapRange is None:
- self._cmapRange = None
- else:
- assert len(cmapRange) == 2
- assert cmapRange[0] <= cmapRange[1]
- self._cmapRange = tuple(cmapRange)
+ assert len(cmapRange) == 2
+ assert cmapRange[0] <= cmapRange[1]
+ self._cmapRange = float(cmapRange[0]), float(cmapRange[1])
@property
def alpha(self):
@@ -322,8 +282,6 @@ class GLPlotColormap(_GLPlotData2D):
oldData = self.data
self.data = data
- self._cmapRangeCache = None
-
if self._texture is not None:
if (self.data.shape != oldData.shape or
self.data.dtype != oldData.dtype):
diff --git a/silx/gui/plot/backends/glutils/GLText.py b/silx/gui/plot/backends/glutils/GLText.py
index 495882c..cef0c5a 100644
--- a/silx/gui/plot/backends/glutils/GLText.py
+++ b/silx/gui/plot/backends/glutils/GLText.py
@@ -88,11 +88,12 @@ class Text2D(object):
attrib0='position')
_textures = {}
-
- _rasterTextCache = {}
- """Internal cache storing already rasterized text"""
+ """Cache already created textures"""
# TODO limit cache size and discard least recent used
+ _sizes = {}
+ """Cache already computed sizes"""
+
def __init__(self, text, x=0, y=0,
color=(0., 0., 0., 1.),
bgColor=None,
@@ -117,31 +118,37 @@ class Text2D(object):
self._rotate = numpy.radians(rotate)
- @classmethod
- def _getTexture(cls, text):
+ def _getTexture(self, text):
key = getGLContext(), text
- if key not in cls._textures:
+
+ if key not in self._textures:
image, offset = font.rasterText(text,
font.getDefaultFontFamily())
- cls._textures[key] = (Texture(gl.GL_RED,
- data=image,
- minFilter=gl.GL_NEAREST,
- magFilter=gl.GL_NEAREST,
- wrap=(gl.GL_CLAMP_TO_EDGE,
- gl.GL_CLAMP_TO_EDGE)),
- offset)
+ if text not in self._sizes:
+ self._sizes[text] = image.shape[1], image.shape[0]
- return cls._textures[key]
+ self._textures[key] = (
+ Texture(gl.GL_RED,
+ data=image,
+ minFilter=gl.GL_NEAREST,
+ magFilter=gl.GL_NEAREST,
+ wrap=(gl.GL_CLAMP_TO_EDGE,
+ gl.GL_CLAMP_TO_EDGE)),
+ offset)
+
+ return self._textures[key]
@property
def text(self):
return self._text
@property
- def size(self): # TODO very poor implementation
- image, offset = font.rasterText(self.text,
- font.getDefaultFontFamily())
- return image.shape[1], image.shape[0]
+ def size(self):
+ if self.text not in self._sizes:
+ image, offset = font.rasterText(self.text,
+ font.getDefaultFontFamily())
+ self._sizes[self.text] = image.shape[1], image.shape[0]
+ return self._sizes[self.text]
def getVertices(self, offset, shape):
height, width = shape
diff --git a/silx/gui/plot/backends/glutils/PlotImageFile.py b/silx/gui/plot/backends/glutils/PlotImageFile.py
index e4ebe24..f028ee8 100644
--- a/silx/gui/plot/backends/glutils/PlotImageFile.py
+++ b/silx/gui/plot/backends/glutils/PlotImageFile.py
@@ -96,7 +96,11 @@ def saveImageToFile(data, fileNameOrObj, fileFormat):
if sys.version < "3.0":
fileObj = open(fileNameOrObj, "wb")
else:
- fileObj = open(fileNameOrObj, "w", newline='')
+ if fileFormat in ('png', 'ppm', 'tiff'):
+ # Open in binary mode
+ fileObj = open(fileNameOrObj, 'wb')
+ else:
+ fileObj = open(fileNameOrObj, 'w', newline='')
else: # Use as a file-like object
fileObj = fileNameOrObj
@@ -127,9 +131,9 @@ def saveImageToFile(data, fileNameOrObj, fileFormat):
elif fileFormat == 'ppm':
height, width = data.shape[:2]
- fileObj.write('P6\n')
- fileObj.write('%d %d\n' % (width, height))
- fileObj.write('255\n')
+ fileObj.write(b'P6\n')
+ fileObj.write(b'%d %d\n' % (width, height))
+ fileObj.write(b'255\n')
fileObj.write(data.tostring())
elif fileFormat == 'png':
@@ -143,7 +147,7 @@ def saveImageToFile(data, fileNameOrObj, fileFormat):
from silx.third_party.TiffIO import TiffIO
tif = TiffIO(fileNameOrObj, mode='wb+')
- tif.writeImage(data, info={'Title': 'PyMCA GL Snapshot'})
+ tif.writeImage(data, info={'Title': 'OpenGL Plot Snapshot'})
if fileObj != fileNameOrObj:
fileObj.close()
diff --git a/silx/gui/plot/items/__init__.py b/silx/gui/plot/items/__init__.py
index b16fe40..bf39c87 100644
--- a/silx/gui/plot/items/__init__.py
+++ b/silx/gui/plot/items/__init__.py
@@ -22,22 +22,23 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""This package provides classes that describes :class:`.Plot` content.
+"""This package provides classes that describes :class:`.PlotWidget` content.
-Instances of those classes are returned by :class:`.Plot` methods that give
-access to its content such as :meth:`.Plot.getCurve`, :meth:`.Plot.getImage`.
+Instances of those classes are returned by :class:`.PlotWidget` methods that give
+access to its content such as :meth:`.PlotWidget.getCurve`, :meth:`.PlotWidget.getImage`.
"""
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "06/03/2017"
+__date__ = "22/06/2017"
from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, # noqa
SymbolMixIn, ColorMixIn, YAxisMixIn, FillMixIn, # noqa
- AlphaMixIn, LineMixIn) # noqa
+ AlphaMixIn, LineMixIn, ItemChangedType) # noqa
from .curve import Curve # noqa
from .histogram import Histogram # noqa
-from .image import ImageBase, ImageData, ImageRgba # noqa
+from .image import ImageBase, ImageData, ImageRgba, MaskImageData # noqa
from .shape import Shape # noqa
from .scatter import Scatter # noqa
from .marker import Marker, XMarker, YMarker # noqa
+from .axis import Axis, XAxis, YAxis, YRightAxis
diff --git a/silx/gui/plot/items/axis.py b/silx/gui/plot/items/axis.py
new file mode 100644
index 0000000..56fd762
--- /dev/null
+++ b/silx/gui/plot/items/axis.py
@@ -0,0 +1,477 @@
+# 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 the class for axes of the :class:`PlotWidget`.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "30/08/2017"
+
+import logging
+from ... import qt
+
+_logger = logging.getLogger(__name__)
+
+
+class Axis(qt.QObject):
+ """This class describes and controls a plot axis.
+
+ Note: This is an abstract class.
+ """
+ # States are half-stored on the backend of the plot, and half-stored on this
+ # object.
+ # TODO It would be good to store all the states of an axis in this object.
+ # i.e. vmin and vmax
+
+ LINEAR = "linear"
+ """Constant defining a linear scale"""
+
+ LOGARITHMIC = "log"
+ """Constant defining a logarithmic scale"""
+
+ _SCALES = set([LINEAR, LOGARITHMIC])
+
+ sigInvertedChanged = qt.Signal(bool)
+ """Signal emitted when axis orientation has changed"""
+
+ sigScaleChanged = qt.Signal(str)
+ """Signal emitted when axis scale has changed"""
+
+ _sigLogarithmicChanged = qt.Signal(bool)
+ """Signal emitted when axis scale has changed to or from logarithmic"""
+
+ sigAutoScaleChanged = qt.Signal(bool)
+ """Signal emitted when axis autoscale has changed"""
+
+ sigLimitsChanged = qt.Signal(float, float)
+ """Signal emitted when axis autoscale has changed"""
+
+ def __init__(self, plot):
+ """Constructor
+
+ :param silx.gui.plot.PlotWidget.PlotWidget plot: Parent plot of this
+ axis
+ """
+ qt.QObject.__init__(self, parent=plot)
+ self._scale = self.LINEAR
+ self._isAutoScale = True
+ # Store default labels provided to setGraph[X|Y]Label
+ self._defaultLabel = ''
+ # Store currently displayed labels
+ # Current label can differ from input one with active curve handling
+ self._currentLabel = ''
+ self._plot = plot
+
+ def getLimits(self):
+ """Get the limits of this axis.
+
+ :return: Minimum and maximum values of this axis as tuple
+ """
+ return self._internalGetLimits()
+
+ def setLimits(self, vmin, vmax):
+ """Set this axis limits.
+
+ :param float vmin: minimum axis value
+ :param float vmax: maximum axis value
+ """
+ vmin, vmax = self._checkLimits(vmin, vmax)
+ if self.getLimits() == (vmin, vmax):
+ return
+
+ self._internalSetLimits(vmin, vmax)
+ self._plot._setDirtyPlot()
+
+ self._emitLimitsChanged()
+
+ def _emitLimitsChanged(self):
+ """Emit axis sigLimitsChanged and PlotWidget limitsChanged event"""
+ vmin, vmax = self.getLimits()
+ self.sigLimitsChanged.emit(vmin, vmax)
+ self._plot._notifyLimitsChanged(emitSignal=False)
+
+ def _checkLimits(self, vmin, vmax):
+ """Makes sure axis range is not empty
+
+ :param float vmin: Min axis value
+ :param float vmax: Max axis value
+ :return: (min, max) making sure min < max
+ :rtype: 2-tuple of float
+ """
+ if vmax < vmin:
+ _logger.debug('%s axis: max < min, inverting limits.', self._defaultLabel)
+ vmin, vmax = vmax, vmin
+ elif vmax == vmin:
+ _logger.debug('%s axis: max == min, expanding limits.', self._defaultLabel)
+ if vmin == 0.:
+ vmin, vmax = -0.1, 0.1
+ elif vmin < 0:
+ vmin, vmax = vmin * 1.1, vmin * 0.9
+ else: # xmin > 0
+ vmin, vmax = vmin * 0.9, vmin * 1.1
+
+ return vmin, vmax
+
+ def isInverted(self):
+ """Return True if the axis is inverted (top to bottom for the y-axis),
+ False otherwise. It is always False for the X axis.
+
+ :rtype: bool
+ """
+ return False
+
+ def setInverted(self, isInverted):
+ """Set the axis orientation.
+
+ This is only available for the Y axis.
+
+ :param bool flag: True for Y axis going from top to bottom,
+ False for Y axis going from bottom to top
+ """
+ if isInverted == self.isInverted():
+ return
+ raise NotImplementedError()
+
+ def getLabel(self):
+ """Return the current displayed label of this axis.
+
+ :param str axis: The Y axis for which to get the label (left or right)
+ :rtype: str
+ """
+ return self._currentLabel
+
+ def setLabel(self, label):
+ """Set the label displayed on the plot for this axis.
+
+ The provided label can be temporarily replaced by the label of the
+ active curve if any.
+
+ :param str label: The axis label
+ """
+ self._defaultLabel = label
+ self._setCurrentLabel(label)
+ self._plot._setDirtyPlot()
+
+ def _setCurrentLabel(self, label):
+ """Define the label currently displayed.
+
+ If the label is None or empty the default label is used.
+
+ :param str label: Currently displayed label
+ """
+ if label is None or label == '':
+ label = self._defaultLabel
+ if label is None:
+ label = ''
+ self._currentLabel = label
+ self._internalSetCurrentLabel(label)
+
+ def getScale(self):
+ """Return the name of the scale used by this axis.
+
+ :rtype: str
+ """
+ return self._scale
+
+ def setScale(self, scale):
+ """Set the scale to be used by this axis.
+
+ :param str scale: Name of the scale ("log", or "linear")
+ """
+ assert(scale in self._SCALES)
+ if self._scale == scale:
+ return
+
+ # For the backward compatibility signal
+ emitLog = self._scale == self.LOGARITHMIC or scale == self.LOGARITHMIC
+
+ if scale == self.LOGARITHMIC:
+ self._internalSetLogarithmic(True)
+ elif scale == self.LINEAR:
+ self._internalSetLogarithmic(False)
+ else:
+ raise ValueError("Scale %s unsupported" % scale)
+
+ self._scale = scale
+
+ # TODO hackish way of forcing update of curves and images
+ for item in self._plot._getItems(withhidden=True):
+ item._updated()
+ self._plot._invalidateDataRange()
+ self._plot.resetZoom()
+
+ self.sigScaleChanged.emit(self._scale)
+ if emitLog:
+ self._sigLogarithmicChanged.emit(self._scale == self.LOGARITHMIC)
+
+ def _isLogarithmic(self):
+ """Return True if this axis scale is logarithmic, False if linear.
+
+ :rtype: bool
+ """
+ return self._scale == self.LOGARITHMIC
+
+ def _setLogarithmic(self, flag):
+ """Set the scale of this axes (either linear or logarithmic).
+
+ :param bool flag: True to use a logarithmic scale, False for linear.
+ """
+ flag = bool(flag)
+ self.setScale(self.LOGARITHMIC if flag else self.LINEAR)
+
+ def isAutoScale(self):
+ """Return True if axis is automatically adjusting its limits.
+
+ :rtype: bool
+ """
+ return self._isAutoScale
+
+ def setAutoScale(self, flag=True):
+ """Set the axis limits adjusting behavior of :meth:`resetZoom`.
+
+ :param bool flag: True to resize limits automatically,
+ False to disable it.
+ """
+ self._isAutoScale = bool(flag)
+ self.sigAutoScaleChanged.emit(self._isAutoScale)
+
+ def _setLimitsConstraints(self, minPos=None, maxPos=None):
+ raise NotImplementedError()
+
+ def setLimitsConstraints(self, minPos=None, maxPos=None):
+ """
+ Set a constaints on the position of the axes.
+
+ :param float minPos: Minimum allowed axis value.
+ :param float maxPos: Maximum allowed axis value.
+ :return: True if the constaints was updated
+ :rtype: bool
+ """
+ updated = self._setLimitsConstraints(minPos, maxPos)
+ if updated:
+ plot = self._plot
+ xMin, xMax = plot.getXAxis().getLimits()
+ yMin, yMax = plot.getYAxis().getLimits()
+ y2Min, y2Max = plot.getYAxis('right').getLimits()
+ plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max)
+ return updated
+
+ def _setRangeConstraints(self, minRange=None, maxRange=None):
+ raise NotImplementedError()
+
+ def setRangeConstraints(self, minRange=None, maxRange=None):
+ """
+ Set a constaints on the position of the axes.
+
+ :param float minRange: Minimum allowed left-to-right span across the
+ view
+ :param float maxRange: Maximum allowed left-to-right span across the
+ view
+ :return: True if the constaints was updated
+ :rtype: bool
+ """
+ updated = self._setRangeConstraints(minRange, maxRange)
+ if updated:
+ plot = self._plot
+ xMin, xMax = plot.getXAxis().getLimits()
+ yMin, yMax = plot.getYAxis().getLimits()
+ y2Min, y2Max = plot.getYAxis('right').getLimits()
+ plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max)
+ return updated
+
+
+class XAxis(Axis):
+ """Axis class defining primitives for the X axis"""
+
+ # TODO With some changes on the backend, it will be able to remove all this
+ # specialised implementations (prefixel by '_internal')
+
+ def _internalSetCurrentLabel(self, label):
+ self._plot._backend.setGraphXLabel(label)
+
+ def _internalGetLimits(self):
+ return self._plot._backend.getGraphXLimits()
+
+ def _internalSetLimits(self, xmin, xmax):
+ self._plot._backend.setGraphXLimits(xmin, xmax)
+
+ def _internalSetLogarithmic(self, flag):
+ self._plot._backend.setXAxisLogarithmic(flag)
+
+ def _setLimitsConstraints(self, minPos=None, maxPos=None):
+ constrains = self._plot._getViewConstraints()
+ updated = constrains.update(xMin=minPos, xMax=maxPos)
+ return updated
+
+ def _setRangeConstraints(self, minRange=None, maxRange=None):
+ constrains = self._plot._getViewConstraints()
+ updated = constrains.update(minXRange=minRange, maxXRange=maxRange)
+ return updated
+
+
+class YAxis(Axis):
+ """Axis class defining primitives for the Y axis"""
+
+ # TODO With some changes on the backend, it will be able to remove all this
+ # specialised implementations (prefixel by '_internal')
+
+ def _internalSetCurrentLabel(self, label):
+ self._plot._backend.setGraphYLabel(label, axis='left')
+
+ def _internalGetLimits(self):
+ return self._plot._backend.getGraphYLimits(axis='left')
+
+ def _internalSetLimits(self, ymin, ymax):
+ self._plot._backend.setGraphYLimits(ymin, ymax, axis='left')
+
+ def _internalSetLogarithmic(self, flag):
+ self._plot._backend.setYAxisLogarithmic(flag)
+
+ def setInverted(self, flag=True):
+ """Set the axis orientation.
+
+ This is only available for the Y axis.
+
+ :param bool flag: True for Y axis going from top to bottom,
+ False for Y axis going from bottom to top
+ """
+ flag = bool(flag)
+ self._plot._backend.setYAxisInverted(flag)
+ self._plot._setDirtyPlot()
+ self.sigInvertedChanged.emit(flag)
+
+ def isInverted(self):
+ """Return True if the axis is inverted (top to bottom for the y-axis),
+ False otherwise. It is always False for the X axis.
+
+ :rtype: bool
+ """
+ return self._plot._backend.isYAxisInverted()
+
+ def _setLimitsConstraints(self, minPos=None, maxPos=None):
+ constrains = self._plot._getViewConstraints()
+ updated = constrains.update(yMin=minPos, yMax=maxPos)
+ return updated
+
+ def _setRangeConstraints(self, minRange=None, maxRange=None):
+ constrains = self._plot._getViewConstraints()
+ updated = constrains.update(minYRange=minRange, maxYRange=maxRange)
+ return updated
+
+
+class YRightAxis(Axis):
+ """Proxy axis for the secondary Y axes. It manages it own label and limit
+ but share the some state like scale and direction with the main axis."""
+
+ # TODO With some changes on the backend, it will be able to remove all this
+ # specialised implementations (prefixel by '_internal')
+
+ def __init__(self, plot, mainAxis):
+ """Constructor
+
+ :param silx.gui.plot.PlotWidget.PlotWidget plot: Parent plot of this
+ axis
+ :param Axis mainAxis: Axis which sharing state with this axis
+ """
+ Axis.__init__(self, plot)
+ self.__mainAxis = mainAxis
+
+ @property
+ def sigInvertedChanged(self):
+ """Signal emitted when axis orientation has changed"""
+ return self.__mainAxis.sigInvertedChanged
+
+ @property
+ def sigScaleChanged(self):
+ """Signal emitted when axis scale has changed"""
+ return self.__mainAxis.sigScaleChanged
+
+ @property
+ def _sigLogarithmicChanged(self):
+ """Signal emitted when axis scale has changed to or from logarithmic"""
+ return self.__mainAxis._sigLogarithmicChanged
+
+ @property
+ def sigAutoScaleChanged(self):
+ """Signal emitted when axis autoscale has changed"""
+ return self.__mainAxis.sigAutoScaleChanged
+
+ def _internalSetCurrentLabel(self, label):
+ self._plot._backend.setGraphYLabel(label, axis='right')
+
+ def _internalGetLimits(self):
+ return self._plot._backend.getGraphYLimits(axis='right')
+
+ def _internalSetLimits(self, ymin, ymax):
+ self._plot._backend.setGraphYLimits(ymin, ymax, axis='right')
+
+ def setInverted(self, flag=True):
+ """Set the Y axis orientation.
+
+ :param bool flag: True for Y axis going from top to bottom,
+ False for Y axis going from bottom to top
+ """
+ return self.__mainAxis.setInverted(flag)
+
+ def isInverted(self):
+ """Return True if Y axis goes from top to bottom, False otherwise."""
+ return self.__mainAxis.isInverted()
+
+ def getScale(self):
+ """Return the name of the scale used by this axis.
+
+ :rtype: str
+ """
+ return self.__mainAxis.getScale()
+
+ def setScale(self, scale):
+ """Set the scale to be used by this axis.
+
+ :param str scale: Name of the scale ("log", or "linear")
+ """
+ self.__mainAxis.setScale(scale)
+
+ def _isLogarithmic(self):
+ """Return True if Y axis scale is logarithmic, False if linear."""
+ return self.__mainAxis._isLogarithmic()
+
+ def _setLogarithmic(self, flag):
+ """Set the Y axes scale (either linear or logarithmic).
+
+ :param bool flag: True to use a logarithmic scale, False for linear.
+ """
+ return self.__mainAxis._setLogarithmic(flag)
+
+ def isAutoScale(self):
+ """Return True if Y axes are automatically adjusting its limits."""
+ return self.__mainAxis.isAutoScale()
+
+ def setAutoScale(self, flag=True):
+ """Set the Y axis limits adjusting behavior of :meth:`PlotWidget.resetZoom`.
+
+ :param bool flag: True to resize limits automatically,
+ False to disable it.
+ """
+ return self.__mainAxis.setAutoScale(flag)
diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py
index 72bfd9a..0f4ffb9 100644
--- a/silx/gui/plot/items/core.py
+++ b/silx/gui/plot/items/core.py
@@ -27,22 +27,96 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "26/04/2017"
+__date__ = "27/06/2017"
+import collections
from copy import deepcopy
import logging
import weakref
import numpy
-from silx.third_party import six
+from silx.third_party import six, enum
+from ... import qt
from .. import Colors
-
+from ..Colormap import Colormap
_logger = logging.getLogger(__name__)
-class Item(object):
+@enum.unique
+class ItemChangedType(enum.Enum):
+ """Type of modification provided by :attr:`Item.sigItemChanged` signal."""
+ # Private setters and setInfo are not emitting sigItemChanged signal.
+ # Signals to consider:
+ # COLORMAP_SET emitted when setColormap is called but not forward colormap object signal
+ # CURRENT_COLOR_CHANGED emitted current color changed because highlight changed,
+ # highlighted color changed or color changed depending on hightlight state.
+
+ VISIBLE = 'visibleChanged'
+ """Item's visibility changed flag."""
+
+ ZVALUE = 'zValueChanged'
+ """Item's Z value changed flag."""
+
+ COLORMAP = 'colormapChanged' # Emitted when set + forward events from the colormap object
+ """Item's colormap changed flag.
+
+ This is emitted both when setting a new colormap and
+ when the current colormap object is updated.
+ """
+
+ SYMBOL = 'symbolChanged'
+ """Item's symbol changed flag."""
+
+ SYMBOL_SIZE = 'symbolSizeChanged'
+ """Item's symbol size changed flag."""
+
+ LINE_WIDTH = 'lineWidthChanged'
+ """Item's line width changed flag."""
+
+ LINE_STYLE = 'lineStyleChanged'
+ """Item's line style changed flag."""
+
+ COLOR = 'colorChanged'
+ """Item's color changed flag."""
+
+ YAXIS = 'yAxisChanged'
+ """Item's Y axis binding changed flag."""
+
+ FILL = 'fillChanged'
+ """Item's fill changed flag."""
+
+ ALPHA = 'alphaChanged'
+ """Item's transparency alpha changed flag."""
+
+ DATA = 'dataChanged'
+ """Item's data changed flag"""
+
+ HIGHLIGHTED = 'highlightedChanged'
+ """Item's highlight state changed flag."""
+
+ HIGHLIGHTED_COLOR = 'highlightedColorChanged'
+ """Item's highlighted color changed flag."""
+
+ SCALE = 'scaleChanged'
+ """Item's scale changed flag."""
+
+ TEXT = 'textChanged'
+ """Item's text changed flag."""
+
+ POSITION = 'positionChanged'
+ """Item's position changed flag.
+
+ This is emitted when a marker position changed and
+ when an image origin changed.
+ """
+
+ OVERLAY = 'overlayChanged'
+ """Item's overlay state changed flag."""
+
+
+class Item(qt.QObject):
"""Description of an item of the plot"""
_DEFAULT_Z_LAYER = 0
@@ -54,7 +128,15 @@ class Item(object):
_DEFAULT_SELECTABLE = False
"""Default selectable state of items"""
+ sigItemChanged = qt.Signal(object)
+ """Signal emitted when the item has changed.
+
+ It provides a flag describing which property of the item has changed.
+ See :class:`ItemChangedType` for flags description.
+ """
+
def __init__(self):
+ super(Item, self).__init__()
self._dirty = True
self._plotRef = None
self._visible = True
@@ -114,7 +196,8 @@ class Item(object):
if visible != self._visible:
self._visible = visible
# When visibility has changed, always mark as dirty
- self._updated(checkVisibility=False)
+ self._updated(ItemChangedType.VISIBLE,
+ checkVisibility=False)
def isOverlay(self):
"""Return true if item is drawn as an overlay.
@@ -158,7 +241,7 @@ class Item(object):
z = int(z) if z is not None else self._DEFAULT_Z_LAYER
if z != self._z:
self._z = z
- self._updated()
+ self._updated(ItemChangedType.ZVALUE)
def getInfo(self, copy=True):
"""Returns the info associated to this item
@@ -172,11 +255,12 @@ class Item(object):
info = deepcopy(info)
self._info = info
- def _updated(self, checkVisibility=True):
+ def _updated(self, event=None, checkVisibility=True):
"""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.
"""
@@ -187,6 +271,8 @@ class Item(object):
plot = self.getPlot()
if plot is not None:
plot._itemRequiresUpdate(self)
+ if event is not None:
+ self.sigItemChanged.emit(event)
def _update(self, backend):
"""Called by Plot to update the backend for this item.
@@ -292,25 +378,32 @@ class DraggableMixIn(object):
class ColormapMixIn(object):
"""Mix-in class for items with colormap"""
- _DEFAULT_COLORMAP = {'name': 'gray', 'normalization': 'linear',
- 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0}
- """Default colormap of the item"""
-
def __init__(self):
- self._colormap = self._DEFAULT_COLORMAP
+ self._colormap = Colormap()
+ self._colormap.sigChanged.connect(self._colormapChanged)
def getColormap(self):
"""Return the used colormap"""
- return self._colormap.copy()
+ return self._colormap
def setColormap(self, colormap):
"""Set the colormap of this image
- :param dict colormap: colormap description
+ :param Colormap colormap: colormap description
"""
- self._colormap = colormap.copy()
- # TODO colormap comparison + colormap object and events on modification
- self._updated()
+ if isinstance(colormap, dict):
+ colormap = Colormap._fromDict(colormap)
+
+ if self._colormap is not None:
+ self._colormap.sigChanged.disconnect(self._colormapChanged)
+ self._colormap = colormap
+ if self._colormap is not None:
+ self._colormap.sigChanged.connect(self._colormapChanged)
+ self._colormapChanged()
+
+ def _colormapChanged(self):
+ """Handle updates of the colormap"""
+ self._updated(ItemChangedType.COLORMAP)
class SymbolMixIn(object):
@@ -355,7 +448,7 @@ class SymbolMixIn(object):
symbol = self._DEFAULT_SYMBOL
if symbol != self._symbol:
self._symbol = symbol
- self._updated()
+ self._updated(ItemChangedType.SYMBOL)
def getSymbolSize(self):
"""Return the point marker size in points.
@@ -375,7 +468,7 @@ class SymbolMixIn(object):
size = self._DEFAULT_SYMBOL_SIZE
if size != self._symbol_size:
self._symbol_size = size
- self._updated()
+ self._updated(ItemChangedType.SYMBOL_SIZE)
class LineMixIn(object):
@@ -405,7 +498,7 @@ class LineMixIn(object):
width = float(width)
if width != self._linewidth:
self._linewidth = width
- self._updated()
+ self._updated(ItemChangedType.LINE_WIDTH)
def getLineStyle(self):
"""Return the type of the line
@@ -435,7 +528,7 @@ class LineMixIn(object):
style = self._DEFAULT_LINESTYLE
if style != self._linestyle:
self._linestyle = style
- self._updated()
+ self._updated(ItemChangedType.LINE_STYLE)
class ColorMixIn(object):
@@ -473,8 +566,9 @@ class ColorMixIn(object):
else: # Array of colors
assert color.ndim == 2
- self._color = color
- self._updated()
+ if self._color != color:
+ self._color = color
+ self._updated(ItemChangedType.COLOR)
class YAxisMixIn(object):
@@ -504,7 +598,7 @@ class YAxisMixIn(object):
assert yaxis in ('left', 'right')
if yaxis != self._yaxis:
self._yaxis = yaxis
- self._updated()
+ self._updated(ItemChangedType.YAXIS)
class FillMixIn(object):
@@ -528,7 +622,7 @@ class FillMixIn(object):
fill = bool(fill)
if fill != self._fill:
self._fill = fill
- self._updated()
+ self._updated(ItemChangedType.FILL)
class AlphaMixIn(object):
@@ -561,7 +655,7 @@ class AlphaMixIn(object):
alpha = max(0., min(alpha, 1.)) # Clip alpha to [0., 1.] range
if alpha != self._alpha:
self._alpha = alpha
- self._updated()
+ self._updated(ItemChangedType.ALPHA)
class Points(Item, SymbolMixIn, AlphaMixIn):
@@ -690,8 +784,8 @@ class Points(Item, SymbolMixIn, AlphaMixIn):
plot = self.getPlot()
if plot is not None:
- xPositive = plot.isXAxisLogarithmic()
- yPositive = plot.isYAxisLogarithmic()
+ xPositive = plot.getXAxis()._isLogarithmic()
+ yPositive = plot.getYAxis()._isLogarithmic()
else:
xPositive = False
yPositive = False
@@ -724,8 +818,8 @@ class Points(Item, SymbolMixIn, AlphaMixIn):
Return None if caching is not applicable."""
plot = self.getPlot()
if plot is not None:
- xPositive = plot.isXAxisLogarithmic()
- yPositive = plot.isYAxisLogarithmic()
+ xPositive = plot.getXAxis()._isLogarithmic()
+ yPositive = plot.getYAxis()._isLogarithmic()
if xPositive or yPositive:
# At least one axis has log scale, filter data
if (xPositive, yPositive) not in self._filteredCache:
@@ -779,24 +873,24 @@ class Points(Item, SymbolMixIn, AlphaMixIn):
:param copy: True (Default) to get a copy,
False to use internal representation (do not modify!)
- :rtype: numpy.ndarray or None
+ :rtype: numpy.ndarray, float or None
"""
- if self._xerror is None:
- return None
- else:
+ if isinstance(self._xerror, numpy.ndarray):
return numpy.array(self._xerror, copy=copy)
+ else:
+ return self._xerror # float or None
def getYErrorData(self, copy=True):
"""Returns the y error of the points
:param copy: True (Default) to get a copy,
False to use internal representation (do not modify!)
- :rtype: numpy.ndarray or None
+ :rtype: numpy.ndarray, float or None
"""
- if self._yerror is None:
- return None
- else:
+ if isinstance(self._yerror, numpy.ndarray):
return numpy.array(self._yerror, copy=copy)
+ else:
+ return self._yerror # float or None
def setData(self, x, y, xerror=None, yerror=None, copy=True):
"""Set the data of the curve.
@@ -820,9 +914,15 @@ class Points(Item, SymbolMixIn, AlphaMixIn):
assert x.ndim == y.ndim == 1
if xerror is not None:
- xerror = numpy.array(xerror, copy=copy)
+ if isinstance(xerror, collections.Iterable):
+ xerror = numpy.array(xerror, copy=copy)
+ else:
+ xerror = float(xerror)
if yerror is not None:
- yerror = numpy.array(yerror, copy=copy)
+ if isinstance(yerror, collections.Iterable):
+ yerror = numpy.array(yerror, copy=copy)
+ else:
+ yerror = float(yerror)
# TODO checks on xerror, yerror
self._x, self._y = x, y
self._xerror, self._yerror = xerror, yerror
@@ -831,9 +931,9 @@ class Points(Item, SymbolMixIn, AlphaMixIn):
self._filteredCache = {} # Reset cached filtered data
self._clippedCache = {} # Reset cached clipped bool array
- self._updated()
# TODO hackish data range implementation
if self.isVisible():
plot = self.getPlot()
if plot is not None:
plot._invalidateDataRange()
+ self._updated(ItemChangedType.DATA)
diff --git a/silx/gui/plot/items/curve.py b/silx/gui/plot/items/curve.py
index d25ae00..ce7f03e 100644
--- a/silx/gui/plot/items/curve.py
+++ b/silx/gui/plot/items/curve.py
@@ -32,11 +32,9 @@ __date__ = "06/03/2017"
import logging
-import numpy
-
from .. import Colors
-from .core import (Points, LabelsMixIn, SymbolMixIn,
- ColorMixIn, YAxisMixIn, FillMixIn, LineMixIn)
+from .core import (Points, LabelsMixIn, ColorMixIn, YAxisMixIn,
+ FillMixIn, LineMixIn, ItemChangedType)
_logger = logging.getLogger(__name__)
@@ -132,15 +130,15 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
:param bool visible: True to display it, False otherwise
"""
- visibleChanged = self.isVisible() != bool(visible)
- super(Curve, self).setVisible(visible)
-
+ visible = bool(visible)
# TODO hackish data range implementation
- if visibleChanged:
+ if self.isVisible() != visible:
plot = self.getPlot()
if plot is not None:
plot._invalidateDataRange()
+ super(Curve, self).setVisible(visible)
+
def isHighlighted(self):
"""Returns True if curve is highlighted.
@@ -157,7 +155,7 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
if highlighted != self._highlighted:
self._highlighted = highlighted
# TODO inefficient: better to use backend's setCurveColor
- self._updated()
+ self._updated(ItemChangedType.HIGHLIGHTED)
def getHighlightedColor(self):
"""Returns the RGBA highlight color of the item
@@ -176,7 +174,7 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
color = Colors.rgba(color)
if color != self._highlightColor:
self._highlightColor = color
- self._updated()
+ self._updated(ItemChangedType.HIGHLIGHTED_COLOR)
def getCurrentColor(self):
"""Returns the current color of the curve.
diff --git a/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py
index c3821bc..ad89677 100644
--- a/silx/gui/plot/items/histogram.py
+++ b/silx/gui/plot/items/histogram.py
@@ -27,7 +27,7 @@
__authors__ = ["H. Payno", "T. Vincent"]
__license__ = "MIT"
-__date__ = "02/05/2017"
+__date__ = "27/06/2017"
import logging
@@ -35,7 +35,7 @@ import logging
import numpy
from .core import (Item, AlphaMixIn, ColorMixIn, FillMixIn,
- LineMixIn, YAxisMixIn)
+ LineMixIn, YAxisMixIn, ItemChangedType)
_logger = logging.getLogger(__name__)
@@ -139,8 +139,8 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
# Filter-out values <= 0
plot = self.getPlot()
if plot is not None:
- xPositive = plot.isXAxisLogarithmic()
- yPositive = plot.isYAxisLogarithmic()
+ xPositive = plot.getXAxis()._isLogarithmic()
+ yPositive = plot.getYAxis()._isLogarithmic()
else:
xPositive = False
yPositive = False
@@ -174,8 +174,8 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
plot = self.getPlot()
if plot is not None:
- xPositive = plot.isXAxisLogarithmic()
- yPositive = plot.isYAxisLogarithmic()
+ xPositive = plot.getXAxis()._isLogarithmic()
+ yPositive = plot.getYAxis()._isLogarithmic()
else:
xPositive = False
yPositive = False
@@ -185,14 +185,19 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
if xPositive:
# Replace edges <= 0 by NaN and corresponding values by NaN
- clipped = (edges <= 0)
+ clipped_edges = (edges <= 0)
edges = numpy.array(edges, copy=True, dtype=numpy.float)
- edges[clipped] = numpy.nan
- values[numpy.logical_or(clipped[:-1], clipped[1:])] = numpy.nan
+ edges[clipped_edges] = numpy.nan
+ clipped_values = numpy.logical_or(clipped_edges[:-1],
+ clipped_edges[1:])
+ else:
+ clipped_values = numpy.zeros_like(values, dtype=numpy.bool)
if yPositive:
# Replace values <= 0 by NaN, do not modify edges
- values[values <= 0] = numpy.nan
+ clipped_values = numpy.logical_or(clipped_values, values <= 0)
+
+ values[clipped_values] = numpy.nan
if xPositive or yPositive:
return (numpy.nanmin(edges),
@@ -211,14 +216,13 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
:param bool visible: True to display it, False otherwise
"""
- visibleChanged = self.isVisible() != bool(visible)
- super(Histogram, self).setVisible(visible)
-
+ visible = bool(visible)
# TODO hackish data range implementation
- if visibleChanged:
+ if self.isVisible() != visible:
plot = self.getPlot()
if plot is not None:
plot._invalidateDataRange()
+ super(Histogram, self).setVisible(visible)
def getValueData(self, copy=True):
"""The values of the histogram
@@ -248,7 +252,7 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
:returns: (N histogram value, N+1 bin edges)
:rtype: 2-tuple of numpy.nadarray
"""
- return (self.getValueData(copy), self.getBinEdgesData(copy))
+ return self.getValueData(copy), self.getBinEdgesData(copy)
def setData(self, histogram, edges, align='center', copy=True):
"""Set the histogram values and bin edges.
@@ -286,3 +290,5 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
self._histogram = histogram
self._edges = edges
+
+ self._updated(ItemChangedType.DATA)
diff --git a/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py
index 7e1dd8b..acf7bf6 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__ = "06/03/2017"
+__date__ = "27/06/2017"
from collections import Sequence
@@ -36,7 +36,8 @@ import logging
import numpy
-from .core import Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, AlphaMixIn
+from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn,
+ AlphaMixIn, ItemChangedType)
from ..Colors import applyColormapToData
@@ -130,14 +131,23 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn):
:param bool visible: True to display it, False otherwise
"""
- visibleChanged = self.isVisible() != bool(visible)
- super(ImageBase, self).setVisible(visible)
-
+ visible = bool(visible)
# TODO hackish data range implementation
- if visibleChanged:
+ if self.isVisible() != visible:
plot = self.getPlot()
if plot is not None:
plot._invalidateDataRange()
+ super(ImageBase, self).setVisible(visible)
+
+ def _isPlotLinear(self, plot):
+ """Return True if plot only uses linear scale for both of x and y
+ axes."""
+ linear = plot.getXAxis().LINEAR
+ if plot.getXAxis().getScale() != linear:
+ return False
+ if plot.getYAxis().getScale() != linear:
+ return False
+ return True
def _getBounds(self):
if self.getData(copy=False).size == 0: # Empty data
@@ -156,8 +166,7 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn):
ymin, ymax = ymax, ymin
plot = self.getPlot()
- if (plot is not None and
- plot.isXAxisLogarithmic() or plot.isYAxisLogarithmic()):
+ if plot is not None and not self._isPlotLinear(plot):
return None
else:
return xmin, xmax, ymin, ymax
@@ -197,7 +206,6 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn):
origin = float(origin), float(origin)
if origin != self._origin:
self._origin = origin
- self._updated()
# TODO hackish data range implementation
if self.isVisible():
@@ -205,6 +213,8 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn):
if plot is not None:
plot._invalidateDataRange()
+ self._updated(ItemChangedType.POSITION)
+
def getScale(self):
"""Returns the scale of the image in data coordinates.
@@ -222,9 +232,17 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn):
scale = float(scale[0]), float(scale[1])
else: # single value scale
scale = float(scale), float(scale)
+
if scale != self._scale:
self._scale = scale
- self._updated()
+
+ # TODO hackish data range implementation
+ if self.isVisible():
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
+
+ self._updated(ItemChangedType.SCALE)
class ImageData(ImageBase, ColormapMixIn):
@@ -240,8 +258,9 @@ class ImageData(ImageBase, ColormapMixIn):
"""Update backend renderer"""
plot = self.getPlot()
assert plot is not None
- if plot.isXAxisLogarithmic() or plot.isYAxisLogarithmic():
- return None # Do not render with log scales
+ if not self._isPlotLinear(plot):
+ # Do not render with non linear scales
+ return None
if self.getAlternativeImageData(copy=False) is not None:
dataToUse = self.getAlternativeImageData(copy=False)
@@ -283,8 +302,7 @@ class ImageData(ImageBase, ColormapMixIn):
else:
# Apply colormap, in this case an new array is always returned
colormap = self.getColormap()
- image = applyColormapToData(self.getData(copy=False),
- **colormap)
+ image = colormap.applyToData(self.getData(copy=False))
return image
def getAlternativeImageData(self, copy=True):
@@ -312,6 +330,14 @@ class ImageData(ImageBase, ColormapMixIn):
"""
data = numpy.array(data, copy=copy)
assert data.ndim == 2
+ if data.dtype.kind == 'b':
+ _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):
+ _logger.warning(
+ 'Converting complex image to absolute value to plot it.')
+ data = numpy.absolute(data)
self._data = data
if alternative is not None:
@@ -320,7 +346,6 @@ class ImageData(ImageBase, ColormapMixIn):
assert alternative.shape[2] in (3, 4)
assert alternative.shape[:2] == data.shape[:2]
self._alternativeImage = alternative
- self._updated()
# TODO hackish data range implementation
if self.isVisible():
@@ -328,6 +353,8 @@ class ImageData(ImageBase, ColormapMixIn):
if plot is not None:
plot._invalidateDataRange()
+ self._updated(ItemChangedType.DATA)
+
class ImageRgba(ImageBase):
"""Description of an RGB(A) image"""
@@ -339,8 +366,9 @@ class ImageRgba(ImageBase):
"""Update backend renderer"""
plot = self.getPlot()
assert plot is not None
- if plot.isXAxisLogarithmic() or plot.isYAxisLogarithmic():
- return None # Do not render with log scales
+ if not self._isPlotLinear(plot):
+ # Do not render with non linear scales
+ return None
data = self.getData(copy=False)
@@ -376,10 +404,19 @@ class ImageRgba(ImageBase):
assert data.shape[-1] in (3, 4)
self._data = data
- self._updated()
-
# TODO hackish data range implementation
if self.isVisible():
plot = self.getPlot()
if plot is not None:
plot._invalidateDataRange()
+
+ self._updated(ItemChangedType.DATA)
+
+
+class MaskImageData(ImageData):
+ """Description of an image used as a mask.
+
+ This class is used to flag mask items. This information is used to improve
+ internal silx widgets.
+ """
+ pass
diff --git a/silx/gui/plot/items/marker.py b/silx/gui/plot/items/marker.py
index c05558b..5f930b7 100644
--- a/silx/gui/plot/items/marker.py
+++ b/silx/gui/plot/items/marker.py
@@ -32,7 +32,8 @@ __date__ = "06/03/2017"
import logging
-from .core import Item, DraggableMixIn, ColorMixIn, SymbolMixIn
+from .core import (Item, DraggableMixIn, ColorMixIn, SymbolMixIn,
+ ItemChangedType)
_logger = logging.getLogger(__name__)
@@ -95,7 +96,7 @@ class _BaseMarker(Item, DraggableMixIn, ColorMixIn):
text = str(text)
if text != self._text:
self._text = text
- self._updated()
+ self._updated(ItemChangedType.TEXT)
def getXPosition(self):
"""Returns the X position of the marker line in data coordinates
@@ -130,7 +131,7 @@ class _BaseMarker(Item, DraggableMixIn, ColorMixIn):
x, y = float(x), float(y)
if x != self._x or y != self._y:
self._x, self._y = x, y
- self._updated()
+ self._updated(ItemChangedType.POSITION)
def getConstraint(self):
"""Returns the dragging constraint of this item"""
@@ -216,7 +217,7 @@ class XMarker(_BaseMarker):
x = float(x)
if x != self._x:
self._x = x
- self._updated()
+ self._updated(ItemChangedType.POSITION)
class YMarker(_BaseMarker):
@@ -238,4 +239,4 @@ class YMarker(_BaseMarker):
y = float(y)
if y != self._y:
self._y = y
- self._updated()
+ self._updated(ItemChangedType.POSITION)
diff --git a/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py
index 3897dc1..98ed473 100644
--- a/silx/gui/plot/items/scatter.py
+++ b/silx/gui/plot/items/scatter.py
@@ -35,7 +35,7 @@ import logging
import numpy
from .core import Points, ColormapMixIn
-from silx.gui.plot.Colors import applyColormapToData # TODO: cherry-pick commit or wait for PR merge
+
_logger = logging.getLogger(__name__)
@@ -60,13 +60,7 @@ class Scatter(Points, ColormapMixIn):
return None # No data to display, do not add renderer to backend
cmap = self.getColormap()
- rgbacolors = applyColormapToData(self._value,
- cmap["name"],
- cmap["normalization"],
- cmap["autoscale"],
- cmap["vmin"],
- cmap["vmax"],
- cmap.get("colors"))
+ rgbacolors = cmap.applyToData(self._value)
return backend.addCurve(xFiltered, yFiltered, self.getLegend(),
color=rgbacolors,
diff --git a/silx/gui/plot/items/shape.py b/silx/gui/plot/items/shape.py
index b663989..65b26a1 100644
--- a/silx/gui/plot/items/shape.py
+++ b/silx/gui/plot/items/shape.py
@@ -27,14 +27,14 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "06/03/2017"
+__date__ = "17/05/2017"
import logging
import numpy
-from .core import Item, ColorMixIn, FillMixIn
+from .core import (Item, ColorMixIn, FillMixIn, ItemChangedType)
_logger = logging.getLogger(__name__)
@@ -46,7 +46,7 @@ class Shape(Item, ColorMixIn, FillMixIn):
"""Description of a shape item
:param str type_: The type of shape in:
- 'hline', 'polygon', 'rectangle', 'vline', 'polyline'
+ 'hline', 'polygon', 'rectangle', 'vline', 'polylines'
"""
def __init__(self, type_):
@@ -54,7 +54,7 @@ class Shape(Item, ColorMixIn, FillMixIn):
ColorMixIn.__init__(self)
FillMixIn.__init__(self)
self._overlay = False
- assert type_ in ('hline', 'polygon', 'rectangle', 'vline', 'polyline')
+ assert type_ in ('hline', 'polygon', 'rectangle', 'vline', 'polylines')
self._type = type_
self._points = ()
@@ -88,12 +88,12 @@ class Shape(Item, ColorMixIn, FillMixIn):
overlay = bool(overlay)
if overlay != self._overlay:
self._overlay = overlay
- self._updated()
+ self._updated(ItemChangedType.OVERLAY)
def getType(self):
"""Returns the type of shape to draw.
- One of: 'hline', 'polygon', 'rectangle', 'vline', 'polyline'
+ One of: 'hline', 'polygon', 'rectangle', 'vline', 'polylines'
:rtype: str
"""
@@ -118,4 +118,4 @@ class Shape(Item, ColorMixIn, FillMixIn):
:return:
"""
self._points = numpy.array(points, copy=copy)
- self._updated()
+ self._updated(ItemChangedType.DATA)
diff --git a/silx/gui/plot/matplotlib/Colormap.py b/silx/gui/plot/matplotlib/Colormap.py
new file mode 100644
index 0000000..a86d76e
--- /dev/null
+++ b/silx/gui/plot/matplotlib/Colormap.py
@@ -0,0 +1,282 @@
+# 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.
+#
+# ############################################################################*/
+"""Matplotlib's new colormaps"""
+
+import numpy
+import logging
+from matplotlib.colors import ListedColormap
+import matplotlib.colors
+import matplotlib.cm
+import silx.resources
+
+_logger = logging.getLogger(__name__)
+
+_AVAILABLE_AS_RESOURCE = ('magma', 'inferno', 'plasma', 'viridis')
+"""List available colormap name as resources"""
+
+_AVAILABLE_AS_BUILTINS = ('gray', 'reversed gray',
+ 'temperature', 'red', 'green', 'blue')
+"""List of colormaps available through built-in declarations"""
+
+_CMAPS = {}
+"""Cache colormaps"""
+
+
+@property
+def magma():
+ return getColormap('magma')
+
+
+@property
+def inferno():
+ return getColormap('inferno')
+
+
+@property
+def plasma():
+ return getColormap('plasma')
+
+
+@property
+def viridis():
+ return getColormap('viridis')
+
+
+def getColormap(name):
+ """Returns matplotlib colormap corresponding to given name
+
+ :param str name: The name of the colormap
+ :return: The corresponding colormap
+ :rtype: matplolib.colors.Colormap
+ """
+ if not _CMAPS: # Lazy initialization of own colormaps
+ cdict = {'red': ((0.0, 0.0, 0.0),
+ (1.0, 1.0, 1.0)),
+ 'green': ((0.0, 0.0, 0.0),
+ (1.0, 0.0, 0.0)),
+ 'blue': ((0.0, 0.0, 0.0),
+ (1.0, 0.0, 0.0))}
+ _CMAPS['red'] = matplotlib.colors.LinearSegmentedColormap(
+ 'red', cdict, 256)
+
+ cdict = {'red': ((0.0, 0.0, 0.0),
+ (1.0, 0.0, 0.0)),
+ 'green': ((0.0, 0.0, 0.0),
+ (1.0, 1.0, 1.0)),
+ 'blue': ((0.0, 0.0, 0.0),
+ (1.0, 0.0, 0.0))}
+ _CMAPS['green'] = matplotlib.colors.LinearSegmentedColormap(
+ 'green', cdict, 256)
+
+ cdict = {'red': ((0.0, 0.0, 0.0),
+ (1.0, 0.0, 0.0)),
+ 'green': ((0.0, 0.0, 0.0),
+ (1.0, 0.0, 0.0)),
+ 'blue': ((0.0, 0.0, 0.0),
+ (1.0, 1.0, 1.0))}
+ _CMAPS['blue'] = matplotlib.colors.LinearSegmentedColormap(
+ 'blue', cdict, 256)
+
+ # Temperature as defined in spslut
+ cdict = {'red': ((0.0, 0.0, 0.0),
+ (0.5, 0.0, 0.0),
+ (0.75, 1.0, 1.0),
+ (1.0, 1.0, 1.0)),
+ 'green': ((0.0, 0.0, 0.0),
+ (0.25, 1.0, 1.0),
+ (0.75, 1.0, 1.0),
+ (1.0, 0.0, 0.0)),
+ 'blue': ((0.0, 1.0, 1.0),
+ (0.25, 1.0, 1.0),
+ (0.5, 0.0, 0.0),
+ (1.0, 0.0, 0.0))}
+ # but limited to 256 colors for a faster display (of the colorbar)
+ _CMAPS['temperature'] = \
+ matplotlib.colors.LinearSegmentedColormap(
+ 'temperature', cdict, 256)
+
+ # reversed gray
+ cdict = {'red': ((0.0, 1.0, 1.0),
+ (1.0, 0.0, 0.0)),
+ 'green': ((0.0, 1.0, 1.0),
+ (1.0, 0.0, 0.0)),
+ 'blue': ((0.0, 1.0, 1.0),
+ (1.0, 0.0, 0.0))}
+
+ _CMAPS['reversed gray'] = \
+ matplotlib.colors.LinearSegmentedColormap(
+ 'yerg', cdict, 256)
+
+ if name in _CMAPS:
+ return _CMAPS[name]
+ elif name in _AVAILABLE_AS_RESOURCE:
+ filename = silx.resources.resource_filename("gui/colormaps/%s.npy" % name)
+ data = numpy.load(filename)
+ lut = ListedColormap(data, name=name)
+ _CMAPS[name] = lut
+ return lut
+ else:
+ # matplotlib built-in
+ return matplotlib.cm.get_cmap(name)
+
+
+def getScalarMappable(colormap, data=None):
+ """Returns matplotlib ScalarMappable corresponding to colormap
+
+ :param :class:`.Colormap` colormap: The colormap to convert
+ :param numpy.ndarray data:
+ The data on which the colormap is applied.
+ If provided, it is used to compute autoscale.
+ :return: matplotlib object corresponding to colormap
+ :rtype: matplotlib.cm.ScalarMappable
+ """
+ assert colormap is not None
+
+ if colormap.getName() is not None:
+ cmap = getColormap(colormap.getName())
+
+ else: # No name, use custom colors
+ if colormap.getColormapLUT() is None:
+ raise ValueError(
+ 'addImage: colormap no name nor list of colors.')
+ colors = colormap.getColormapLUT()
+ assert len(colors.shape) == 2
+ assert colors.shape[-1] in (3, 4)
+ if colors.dtype == numpy.uint8:
+ # Convert to float in [0., 1.]
+ colors = colors.astype(numpy.float32) / 255.
+ cmap = matplotlib.colors.ListedColormap(colors)
+
+ 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):
+ """Apply a colormap to the data and returns the RGBA image
+
+ This supports data of any dimensions (not only of dimension 2).
+ The returned array will have one more dimension (with 4 entries)
+ than the input data to store the RGBA channels
+ corresponding to each bin in the array.
+
+ :param numpy.ndarray data: The data to convert.
+ :param :class:`.Colormap`: The colormap to apply
+ """
+ # Debian 7 specific support
+ # No transparent colormap with matplotlib < 1.2.0
+ # Add support for transparent colormap for uint8 data with
+ # colormap with 256 colors, linear norm, [0, 255] range
+ if matplotlib.__version__ < '1.2.0':
+ if (colormap.getName() is None and
+ colormap.getColormapLUT() is not None):
+ colors = colormap.getColormapLUT()
+ if (colors.shape[-1] == 4 and
+ not numpy.all(numpy.equal(colors[3], 255))):
+ # This is a transparent colormap
+ if (colors.shape == (256, 4) and
+ colormap.getNormalization() == 'linear' and
+ not colormap.isAutoscale() and
+ colormap.getVMin() == 0 and
+ colormap.getVMax() == 255 and
+ data.dtype == numpy.uint8):
+ # Supported case, convert data to RGBA
+ return colors[data.reshape(-1)].reshape(
+ data.shape + (4,))
+ else:
+ _logger.warning(
+ 'matplotlib %s does not support transparent '
+ 'colormap.', matplotlib.__version__)
+
+ scalarMappable = getScalarMappable(colormap, data)
+ rgbaImage = scalarMappable.to_rgba(data, bytes=True)
+
+ return rgbaImage
+
+
+def getSupportedColormaps():
+ """Get the supported colormap names as a tuple of str.
+ """
+ colormaps = set(matplotlib.cm.datad.keys())
+ colormaps.update(_AVAILABLE_AS_BUILTINS)
+ colormaps.update(_AVAILABLE_AS_RESOURCE)
+ return tuple(sorted(colormaps))
diff --git a/silx/gui/plot/backends/ModestImage.py b/silx/gui/plot/matplotlib/ModestImage.py
index 93fba5a..e4a72d5 100644
--- a/silx/gui/plot/backends/ModestImage.py
+++ b/silx/gui/plot/matplotlib/ModestImage.py
@@ -26,7 +26,7 @@
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "16/02/2016"
+__date__ = "03/05/2017"
import numpy
diff --git a/silx/gui/plot/backends/_matplotlib.py b/silx/gui/plot/matplotlib/__init__.py
index 26732a0..be9cb9a 100644
--- a/silx/gui/plot/backends/_matplotlib.py
+++ b/silx/gui/plot/matplotlib/__init__.py
@@ -32,7 +32,7 @@ to the used backend.
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "26/10/2016"
+__date__ = "04/05/2017"
import sys
@@ -53,12 +53,18 @@ import matplotlib
if qt.BINDING == 'PySide':
matplotlib.rcParams['backend'] = 'Qt4Agg'
matplotlib.rcParams['backend.qt4'] = 'PySide'
- from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg # noqa
+ import matplotlib.backends.backend_qt4agg as backend
elif qt.BINDING == 'PyQt4':
matplotlib.rcParams['backend'] = 'Qt4Agg'
- from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg # noqa
+ import matplotlib.backends.backend_qt4agg as backend
elif qt.BINDING == 'PyQt5':
matplotlib.rcParams['backend'] = 'Qt5Agg'
- from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg # noqa
+ import matplotlib.backends.backend_qt5agg as backend
+
+else:
+ backend = None
+
+if backend is not None:
+ FigureCanvasQTAgg = backend.FigureCanvasQTAgg # noqa
diff --git a/silx/gui/plot/setup.py b/silx/gui/plot/setup.py
index 6408113..205c5fa 100644
--- a/silx/gui/plot/setup.py
+++ b/silx/gui/plot/setup.py
@@ -24,7 +24,7 @@
# ###########################################################################*/
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "16/02/2016"
+__date__ = "29/06/2017"
from numpy.distutils.misc_util import Configuration
@@ -33,10 +33,13 @@ from numpy.distutils.misc_util import Configuration
def configuration(parent_package='', top_path=None):
config = Configuration('plot', parent_package, top_path)
config.add_subpackage('_utils')
+ config.add_subpackage('utils')
+ config.add_subpackage('matplotlib')
config.add_subpackage('backends')
config.add_subpackage('backends.glutils')
config.add_subpackage('items')
config.add_subpackage('test')
+ config.add_subpackage('actions')
return config
diff --git a/silx/gui/plot/test/__init__.py b/silx/gui/plot/test/__init__.py
index b4378c7..2c2943e 100644
--- a/silx/gui/plot/test/__init__.py
+++ b/silx/gui/plot/test/__init__.py
@@ -24,48 +24,58 @@
# ###########################################################################*/
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "18/02/2016"
+__date__ = "04/08/2017"
import unittest
-from .._utils.test import suite as testUtilsSuite
-from .testColorBar import suite as testColorBarSuite
-from .testColormapDialog import suite as testColormapDialogSuite
-from .testColors import suite as testColorsSuite
-from .testCurvesROIWidget import suite as testCurvesROIWidgetSuite
-from .testAlphaSlider import suite as testAlphaSliderSuite
-from .testInteraction import suite as testInteractionSuite
-from .testLegendSelector import suite as testLegendSelectorSuite
-from .testMaskToolsWidget import suite as testMaskToolsWidgetSuite
-from .testScatterMaskToolsWidget import suite as testScatterMaskToolsWidgetSuite
-from .testPlotInteraction import suite as testPlotInteractionSuite
-from .testPlotTools import suite as testPlotToolsSuite
-from .testPlotWidget import suite as testPlotWidgetSuite
-from .testPlotWindow import suite as testPlotWindowSuite
-from .testPlot import suite as testPlotSuite
-from .testProfile import suite as testProfileSuite
-from .testStackView import suite as testStackViewSuite
+from .._utils import test
+from . import testColorBar
+from . import testColormap
+from . import testColormapDialog
+from . import testColors
+from . import testCurvesROIWidget
+from . import testAlphaSlider
+from . import testInteraction
+from . import testLegendSelector
+from . import testMaskToolsWidget
+from . import testScatterMaskToolsWidget
+from . import testPlotInteraction
+from . import testPlotTools
+from . import testPlotWidgetNoBackend
+from . import testPlotWidget
+from . import testPlotWindow
+from . import testProfile
+from . import testStackView
+from . import testItem
+from . import testUtilsAxis
+from . import testLimitConstraints
+from . import testComplexImageView
def suite():
test_suite = unittest.TestSuite()
test_suite.addTests(
- [testUtilsSuite(),
- testColorBarSuite(),
- testColorsSuite(),
- testColormapDialogSuite(),
- testCurvesROIWidgetSuite(),
- testAlphaSliderSuite(),
- testInteractionSuite(),
- testLegendSelectorSuite(),
- testMaskToolsWidgetSuite(),
- testScatterMaskToolsWidgetSuite(),
- testPlotInteractionSuite(),
- testPlotSuite(),
- testPlotToolsSuite(),
- testPlotWidgetSuite(),
- testPlotWindowSuite(),
- testProfileSuite(),
- testStackViewSuite()])
+ [test.suite(),
+ testColorBar.suite(),
+ testColors.suite(),
+ testColormapDialog.suite(),
+ testCurvesROIWidget.suite(),
+ testAlphaSlider.suite(),
+ testInteraction.suite(),
+ testLegendSelector.suite(),
+ testMaskToolsWidget.suite(),
+ testScatterMaskToolsWidget.suite(),
+ testPlotInteraction.suite(),
+ testPlotWidgetNoBackend.suite(),
+ testPlotTools.suite(),
+ testPlotWidget.suite(),
+ testPlotWindow.suite(),
+ testProfile.suite(),
+ testStackView.suite(),
+ testColormap.suite(),
+ testItem.suite(),
+ testUtilsAxis.suite(),
+ testLimitConstraints.suite(),
+ testComplexImageView.suite()])
return test_suite
diff --git a/silx/gui/plot/test/testColorBar.py b/silx/gui/plot/test/testColorBar.py
index 797ff03..80ae6a8 100644
--- a/silx/gui/plot/test/testColorBar.py
+++ b/silx/gui/plot/test/testColorBar.py
@@ -32,22 +32,37 @@ import unittest
from silx.gui.test.utils import TestCaseQt
from silx.gui.plot.ColorBar import _ColorScale
from silx.gui.plot.ColorBar import ColorBarWidget
+from silx.gui.plot.Colormap import Colormap
from silx.gui.plot import Plot2D
+from silx.gui import qt
import numpy
-class TestColorScale(unittest.TestCase):
+class TestColorScale(TestCaseQt):
"""Test that interaction with the colorScale is correct"""
def setUp(self):
+ super(TestColorScale, self).setUp()
self.colorScaleWidget = _ColorScale(colormap=None, parent=None)
+ self.colorScaleWidget.show()
+ self.qWaitForWindowExposed(self.colorScaleWidget)
def tearDown(self):
- self.colorScaleWidget.deleteLater()
- self.colorScaleWidget = None
+ self.qapp.processEvents()
+ self.colorScaleWidget.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.colorScaleWidget.close()
+ del self.colorScaleWidget
+ super(TestColorScale, self).tearDown()
+
+ def testNoColormap(self):
+ """Test _ColorScale without a colormap"""
+ colormap = self.colorScaleWidget.getColormap()
+ self.assertIsNone(colormap)
def testRelativePositionLinear(self):
- self.colorMapLin1 = { 'name': 'gray', 'normalization': 'linear',
- 'autoscale': False, 'vmin': 0.0, 'vmax': 1.0 }
+ self.colorMapLin1 = Colormap(name='gray',
+ normalization=Colormap.LINEAR,
+ vmin=0.0,
+ vmax=1.0)
self.colorScaleWidget.setColormap(self.colorMapLin1)
self.assertTrue(
@@ -57,8 +72,10 @@ class TestColorScale(unittest.TestCase):
self.assertTrue(
self.colorScaleWidget.getValueFromRelativePosition(1.0) == 1.0)
- self.colorMapLin2 = { 'name': 'viridis', 'normalization': 'linear',
- 'autoscale': False, 'vmin': -10, 'vmax': 0 }
+ self.colorMapLin2 = Colormap(name='viridis',
+ normalization=Colormap.LINEAR,
+ vmin=-10,
+ vmax=0)
self.colorScaleWidget.setColormap(self.colorMapLin2)
self.assertTrue(
@@ -69,8 +86,10 @@ class TestColorScale(unittest.TestCase):
self.colorScaleWidget.getValueFromRelativePosition(1.0) == 0.0)
def testRelativePositionLog(self):
- self.colorMapLog1 = { 'name': 'temperature', 'normalization': 'log',
- 'autoscale': False, 'vmin': 1.0, 'vmax': 100.0 }
+ self.colorMapLog1 = Colormap(name='temperature',
+ normalization=Colormap.LOGARITHM,
+ vmin=1.0,
+ vmax=100.0)
self.colorScaleWidget.setColormap(self.colorMapLog1)
@@ -83,41 +102,38 @@ class TestColorScale(unittest.TestCase):
val = self.colorScaleWidget.getValueFromRelativePosition(0.0)
self.assertTrue(val == 1.0)
- def testNegativeLogMin(self):
- colormap = { 'name': 'gray', 'normalization': 'log',
- 'autoscale': False, 'vmin': -1.0, 'vmax': 1.0 }
-
- with self.assertRaises(ValueError):
- self.colorScaleWidget.setColormap(colormap)
-
- def testNegativeLogMax(self):
- colormap = { 'name': 'gray', 'normalization': 'log',
- 'autoscale': False, 'vmin': 1.0, 'vmax': -1.0 }
- with self.assertRaises(ValueError):
- self.colorScaleWidget.setColormap(colormap)
-
-class TestNoAutoscale(unittest.TestCase):
+class TestNoAutoscale(TestCaseQt):
"""Test that ticks and color displayed are correct in the case of a colormap
with no autoscale
"""
def setUp(self):
+ super(TestNoAutoscale, self).setUp()
self.plot = Plot2D()
- self.colorBar = ColorBarWidget(parent=None, plot=self.plot)
+ self.colorBar = self.plot.getColorBarWidget()
+ self.colorBar.setVisible(True) # Makes sure the colormap is visible
self.tickBar = self.colorBar.getColorScaleBar().getTickBar()
self.colorScale = self.colorBar.getColorScaleBar().getColorScale()
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
def tearDown(self):
+ self.qapp.processEvents()
self.tickBar = None
self.colorScale = None
del self.colorBar
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
self.plot.close()
del self.plot
+ super(TestNoAutoscale, self).tearDown()
def testLogNormNoAutoscale(self):
- colormapLog = { 'name': 'gray', 'normalization': 'log',
- 'autoscale': False, 'vmin': 1.0, 'vmax': 100.0 }
+ colormapLog = Colormap(name='gray',
+ normalization=Colormap.LOGARITHM,
+ vmin=1.0,
+ vmax=100.0)
data = numpy.linspace(10, 1e10, 9).reshape(3, 3)
self.plot.addImage(data=data, colormap=colormapLog, legend='toto')
@@ -139,8 +155,10 @@ class TestNoAutoscale(unittest.TestCase):
self.assertTrue(val == 1.0)
def testLinearNormNoAutoscale(self):
- colormapLog = { 'name': 'gray', 'normalization': 'linear',
- 'autoscale': False, 'vmin': -4, 'vmax': 5 }
+ colormapLog = Colormap(name='gray',
+ normalization=Colormap.LINEAR,
+ vmin=-4,
+ vmax=5)
data = numpy.linspace(1, 9, 9).reshape(3, 3)
self.plot.addImage(data=data, colormap=colormapLog, legend='toto')
@@ -159,20 +177,26 @@ class TestNoAutoscale(unittest.TestCase):
val = self.colorScale.getValueFromRelativePosition(0.0)
self.assertTrue(val == -4.0)
-class TestColorbarWidget(TestCaseQt):
- """Test interaction with the ColorScaleBar"""
+
+class TestColorBarWidget(TestCaseQt):
+ """Test interaction with the ColorBarWidget"""
def setUp(self):
- super(TestColorbarWidget, self).setUp()
+ super(TestColorBarWidget, self).setUp()
self.plot = Plot2D()
- self.colorBar = ColorBarWidget(parent=None, plot=self.plot)
+ self.colorBar = self.plot.getColorBarWidget()
+ self.colorBar.setVisible(True) # Makes sure the colormap is visible
+
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
def tearDown(self):
+ self.qapp.processEvents()
del self.colorBar
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
self.plot.close()
del self.plot
-
- super(TestColorbarWidget, self).tearDown()
+ super(TestColorBarWidget, self).tearDown()
def testEmptyColorBar(self):
colorBar = ColorBarWidget(parent=None)
@@ -185,38 +209,43 @@ class TestColorbarWidget(TestCaseQt):
Note : colorbar is modified by the Plot directly not ColorBarWidget
"""
- colormapLog = { 'name': 'gray', 'normalization': 'log',
- 'autoscale': True, 'vmin': -1.0, 'vmax': 1.0 }
-
- colormapLog2 = { 'name': 'gray', 'normalization': 'log',
- 'autoscale': False, 'vmin': -1.0, 'vmax': 1.0 }
+ colormapLog = Colormap(name='gray',
+ normalization=Colormap.LOGARITHM,
+ vmin=None,
+ vmax=None)
data = numpy.array([-5, -4, 0, 2, 3, 5, 10, 20, 30])
data = data.reshape(3, 3)
self.plot.addImage(data=data, colormap=colormapLog, legend='toto')
self.plot.setActiveImage('toto')
- # default behavior when autoscale : set to minmal positive value
- data[data<1] = data.max()
- self.assertTrue(self.colorBar._colormap['vmin'] == data.min())
- self.assertTrue(self.colorBar._colormap['vmax'] == data.max())
-
- data = numpy.linspace(-9, -2, 100).reshape(10, 10)
+ # default behavior when with log and negative values: should set vmin
+ # to 1 and vmax to 10
+ self.assertTrue(self.colorBar.getColorScaleBar().minVal == 2)
+ self.assertTrue(self.colorBar.getColorScaleBar().maxVal == 30)
- self.plot.addImage(data=data, colormap=colormapLog2, legend='toto')
+ # if data is positive
+ data[data<1] = data.max()
+ self.plot.addImage(data=data,
+ colormap=colormapLog,
+ legend='toto',
+ replace=True)
self.plot.setActiveImage('toto')
- # if negative values, changing bounds for default : 1, 10
- self.assertTrue(self.colorBar._colormap['vmin'] == 1)
- self.assertTrue(self.colorBar._colormap['vmax'] == 10)
- def testPlotAssocation(self):
- """Make sure the ColorBarWidget is proparly connected with the plot"""
- colormap = { 'name': 'gray', 'normalization': 'linear',
- 'autoscale': True, 'vmin': -1.0, 'vmax': 1.0 }
+ self.assertTrue(self.colorBar.getColorScaleBar().minVal == data.min())
+ self.assertTrue(self.colorBar.getColorScaleBar().maxVal == data.max())
- # make sure that default settings are the same
+ def testPlotAssocation(self):
+ """Make sure the ColorBarWidget is properly connected with the plot"""
+ colormap = Colormap(name='gray',
+ normalization=Colormap.LINEAR,
+ vmin=None,
+ vmax=None)
+
+ # make sure that default settings are the same (but a copy of the
+ self.colorBar.setPlot(self.plot)
self.assertTrue(
- self.colorBar.getColormap() == self.plot.getDefaultColormap())
+ self.colorBar.getColormap() is self.plot.getDefaultColormap())
data = numpy.linspace(0, 10, 100).reshape(10, 10)
self.plot.addImage(data=data, colormap=colormap, legend='toto')
@@ -224,12 +253,94 @@ class TestColorbarWidget(TestCaseQt):
# make sure the modification of the colormap has been done
self.assertFalse(
- self.colorBar.getColormap() == self.plot.getDefaultColormap())
+ self.colorBar.getColormap() is self.plot.getDefaultColormap())
+ self.assertTrue(
+ self.colorBar.getColormap() is colormap)
+
+ # test that colorbar is updated when default plot colormap changes
+ self.plot.clear()
+ plotColormap = Colormap(name='gray',
+ normalization=Colormap.LOGARITHM,
+ vmin=None,
+ vmax=None)
+ self.plot.setDefaultColormap(plotColormap)
+ self.assertTrue(self.colorBar.getColormap() is plotColormap)
+
+ def testColormapWithoutRange(self):
+ """Test with a colormap with vmin==vmax"""
+ colormap = Colormap(name='gray',
+ normalization=Colormap.LINEAR,
+ vmin=1.0,
+ vmax=1.0)
+ self.colorBar.setColormap(colormap)
+
+
+class TestColorBarUpdate(TestCaseQt):
+ """Test that the ColorBar is correctly updated when the signal 'sigChanged'
+ of the colormap is emitted
+ """
+
+ def setUp(self):
+ super(TestColorBarUpdate, self).setUp()
+ self.plot = Plot2D()
+ self.colorBar = self.plot.getColorBarWidget()
+ self.colorBar.setVisible(True) # Makes sure the colormap is visible
+ self.colorBar.setPlot(self.plot)
+
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+ self.data = numpy.random.rand(9).reshape(3, 3)
+
+ def tearDown(self):
+ self.qapp.processEvents()
+ del self.colorBar
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+ super(TestColorBarUpdate, self).tearDown()
+
+ def testUpdateColorMap(self):
+ colormap = Colormap(name='gray',
+ normalization='linear',
+ vmin=0,
+ vmax=1)
+
+ # check inital state
+ self.plot.addImage(data=self.data, colormap=colormap, legend='toto')
+ self.plot.setActiveImage('toto')
+
+ self.assertTrue(self.colorBar.getColorScaleBar().minVal == 0)
+ self.assertTrue(self.colorBar.getColorScaleBar().maxVal == 1)
+ self.assertTrue(
+ self.colorBar.getColorScaleBar().getTickBar()._vmin == 0)
+ self.assertTrue(
+ self.colorBar.getColorScaleBar().getTickBar()._vmax == 1)
+ self.assertTrue(
+ self.colorBar.getColorScaleBar().getTickBar()._norm == "linear")
+
+ # update colormap
+ colormap.setVMin(0.5)
+ self.assertTrue(self.colorBar.getColorScaleBar().minVal == 0.5)
+ self.assertTrue(
+ self.colorBar.getColorScaleBar().getTickBar()._vmin == 0.5)
+
+ colormap.setVMax(0.8)
+ self.assertTrue(self.colorBar.getColorScaleBar().maxVal == 0.8)
+ self.assertTrue(
+ self.colorBar.getColorScaleBar().getTickBar()._vmax == 0.8)
+
+ colormap.setNormalization('log')
+ self.assertTrue(
+ self.colorBar.getColorScaleBar().getTickBar()._norm == 'log')
+
+ # TODO : should also check that if the colormap is changing then values (especially in log scale)
+ # should be coherent if in autoscale
def suite():
test_suite = unittest.TestSuite()
- for ui in (TestColorScale, TestNoAutoscale, TestColorbarWidget):
+ for ui in (TestColorScale, TestNoAutoscale, TestColorBarWidget,
+ TestColorBarUpdate):
test_suite.addTest(
unittest.defaultTestLoader.loadTestsFromTestCase(ui))
@@ -237,4 +348,4 @@ def suite():
if __name__ == '__main__':
- unittest.main(defaultTest='suite') \ No newline at end of file
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testColormap.py b/silx/gui/plot/test/testColormap.py
new file mode 100644
index 0000000..aa285d3
--- /dev/null
+++ b/silx/gui/plot/test/testColormap.py
@@ -0,0 +1,291 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2015-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 the Colormap object
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["H.Payno"]
+__license__ = "MIT"
+__date__ = "05/12/2016"
+
+import unittest
+import numpy
+from silx.test.utils import ParametricTestCase
+from silx.gui.plot.Colormap import Colormap
+
+
+class TestDictAPI(unittest.TestCase):
+ """Make sure the old dictionary API is working
+ """
+
+ def setUp(self):
+ self.vmin = -1.0
+ self.vmax = 12
+
+ def testGetItem(self):
+ """test the item getter API ([xxx])"""
+ colormap = Colormap(name='viridis',
+ normalization=Colormap.LINEAR,
+ vmin=self.vmin,
+ vmax=self.vmax)
+ self.assertTrue(colormap['name'] == 'viridis')
+ self.assertTrue(colormap['normalization'] == Colormap.LINEAR)
+ self.assertTrue(colormap['vmin'] == self.vmin)
+ self.assertTrue(colormap['vmax'] == self.vmax)
+ with self.assertRaises(KeyError):
+ colormap['toto']
+
+ def testGetDict(self):
+ """Test the getDict function API"""
+ clmObject = Colormap(name='viridis',
+ normalization=Colormap.LINEAR,
+ vmin=self.vmin,
+ vmax=self.vmax)
+ clmDict = clmObject._toDict()
+ self.assertTrue(clmDict['name'] == 'viridis')
+ self.assertTrue(clmDict['autoscale'] is False)
+ self.assertTrue(clmDict['vmin'] == self.vmin)
+ self.assertTrue(clmDict['vmax'] == self.vmax)
+ self.assertTrue(clmDict['normalization'] == Colormap.LINEAR)
+
+ clmObject.setVRange(None, None)
+ self.assertTrue(clmObject._toDict()['autoscale'] is True)
+
+ def testSetValidDict(self):
+ """Test that if a colormap is created from a dict then it is correctly
+ created and the values are copied (so if some values from the dict
+ is changing, this won't affect the Colormap object"""
+ clm_dict = {
+ 'name': 'temperature',
+ 'vmin': 1.0,
+ 'vmax': 2.0,
+ 'normalization': 'linear',
+ 'colors': None,
+ 'autoscale': False
+ }
+
+ # Test that the colormap is correctly created
+ colormapObject = Colormap._fromDict(clm_dict)
+ self.assertTrue(colormapObject.getName() == clm_dict['name'])
+ self.assertTrue(colormapObject.getColormapLUT() == clm_dict['colors'])
+ self.assertTrue(colormapObject.getVMin() == clm_dict['vmin'])
+ self.assertTrue(colormapObject.getVMax() == clm_dict['vmax'])
+ self.assertTrue(colormapObject.isAutoscale() == clm_dict['autoscale'])
+
+ # Check that the colormap has copied the values
+ clm_dict['vmin'] = None
+ clm_dict['vmax'] = None
+ clm_dict['colors'] = [1.0, 2.0]
+ clm_dict['autoscale'] = True
+ clm_dict['normalization'] = Colormap.LOGARITHM
+ clm_dict['name'] = 'viridis'
+
+ self.assertFalse(colormapObject.getName() == clm_dict['name'])
+ self.assertFalse(colormapObject.getColormapLUT() == clm_dict['colors'])
+ self.assertFalse(colormapObject.getVMin() == clm_dict['vmin'])
+ self.assertFalse(colormapObject.getVMax() == clm_dict['vmax'])
+ self.assertFalse(colormapObject.isAutoscale() == clm_dict['autoscale'])
+
+ def testMissingKeysFromDict(self):
+ """Make sure we can create a Colormap object from a dictionnary even if
+ there is missing keys excepts if those keys are 'colors' or 'name'
+ """
+ colormap = Colormap._fromDict({'name': 'toto'})
+ self.assertTrue(colormap.getVMin() is None)
+ colormap = Colormap._fromDict({'colors': numpy.zeros(10)})
+ self.assertTrue(colormap.getName() is None)
+
+ with self.assertRaises(ValueError):
+ Colormap._fromDict({})
+
+ def testUnknowNorm(self):
+ """Make sure an error is raised if the given normalization is not
+ knowed
+ """
+ clm_dict = {
+ 'name': 'temperature',
+ 'vmin': 1.0,
+ 'vmax': 2.0,
+ 'normalization': 'toto',
+ 'colors': None,
+ 'autoscale': False
+ }
+ with self.assertRaises(ValueError):
+ colormapObject = 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
+ vmax = 2.0
+
+ colormapObject = Colormap(name='viridis',
+ vmin=vmin,
+ vmax=vmax,
+ normalization=Colormap.LINEAR)
+
+ with self.assertRaises(ValueError):
+ colormapObject.setVMin(3)
+
+ with self.assertRaises(ValueError):
+ colormapObject.setVMax(-2)
+
+ with self.assertRaises(ValueError):
+ colormapObject.setVRange(3, -2)
+
+ self.assertTrue(colormapObject.getColormapRange() == (1.0, 2.0))
+ self.assertTrue(colormapObject.isAutoscale() is False)
+ colormapObject.setVRange(None, None)
+ self.assertTrue(colormapObject.getVMin() is None)
+ self.assertTrue(colormapObject.getVMax() is None)
+ self.assertTrue(colormapObject.isAutoscale() is True)
+
+ def testCopy(self):
+ """Make sure the copy function is correctly processing
+ """
+ colormapObject = Colormap(name='toto',
+ colors=numpy.array([12, 13, 14]),
+ vmin=None,
+ vmax=None,
+ normalization=Colormap.LOGARITHM)
+
+ colormapObject2 = colormapObject.copy()
+ self.assertTrue(colormapObject == colormapObject2)
+ colormapObject.setColormapLUT(numpy.array([0, 1]))
+ self.assertFalse(colormapObject == colormapObject2)
+
+ colormapObject2 = colormapObject.copy()
+ self.assertTrue(colormapObject == colormapObject2)
+ colormapObject.setNormalization(Colormap.LINEAR)
+ self.assertFalse(colormapObject == colormapObject2)
+
+ def testGetColorMapRange(self):
+ """Make sure the getColormapRange function of colormap is correctly
+ applying
+ """
+ # test linear scale
+ data = numpy.array([-1, 1, 2, 3, float('nan')])
+ cl1 = Colormap(name='gray',
+ normalization=Colormap.LINEAR,
+ vmin=0,
+ vmax=2)
+ cl2 = Colormap(name='gray',
+ normalization=Colormap.LINEAR,
+ vmin=None,
+ vmax=2)
+ cl3 = Colormap(name='gray',
+ normalization=Colormap.LINEAR,
+ vmin=0,
+ vmax=None)
+ cl4 = Colormap(name='gray',
+ normalization=Colormap.LINEAR,
+ vmin=None,
+ vmax=None)
+
+ self.assertTrue(cl1.getColormapRange(data) == (0, 2))
+ self.assertTrue(cl2.getColormapRange(data) == (-1, 2))
+ self.assertTrue(cl3.getColormapRange(data) == (0, 3))
+ self.assertTrue(cl4.getColormapRange(data) == (-1, 3))
+
+ # test linear with annoying cases
+ self.assertEqual(cl3.getColormapRange((-1, -2)), (0, 0))
+ self.assertEqual(cl4.getColormapRange(()), (0., 1.))
+ self.assertEqual(cl4.getColormapRange(
+ (float('nan'), float('inf'), 1., -float('inf'), 2)), (1., 2.))
+ self.assertEqual(cl4.getColormapRange(
+ (float('nan'), float('inf'))), (0., 1.))
+
+ # test log scale
+ data = numpy.array([float('nan'), -1, 1, 10, 100, 1000])
+ cl1 = Colormap(name='gray',
+ normalization=Colormap.LOGARITHM,
+ vmin=1,
+ vmax=100)
+ cl2 = Colormap(name='gray',
+ normalization=Colormap.LOGARITHM,
+ vmin=None,
+ vmax=100)
+ cl3 = Colormap(name='gray',
+ normalization=Colormap.LOGARITHM,
+ vmin=1,
+ vmax=None)
+ cl4 = Colormap(name='gray',
+ normalization=Colormap.LOGARITHM,
+ vmin=None,
+ vmax=None)
+
+ self.assertTrue(cl1.getColormapRange(data) == (1, 100))
+ self.assertTrue(cl2.getColormapRange(data) == (1, 100))
+ self.assertTrue(cl3.getColormapRange(data) == (1, 1000))
+ self.assertTrue(cl4.getColormapRange(data) == (1, 1000))
+
+ # test log with annoying cases
+ self.assertEqual(cl3.getColormapRange((0.1, 0.2)), (1, 1))
+ self.assertEqual(cl4.getColormapRange((-2., -1.)), (1., 1.))
+ self.assertEqual(cl4.getColormapRange(()), (1., 10.))
+ self.assertEqual(cl4.getColormapRange(
+ (float('nan'), float('inf'), 1., -float('inf'), 2)), (1., 2.))
+ self.assertEqual(cl4.getColormapRange(
+ (float('nan'), float('inf'))), (1., 10.))
+
+ def testApplyToData(self):
+ """Test applyToData on different datasets"""
+ datasets = [
+ numpy.zeros((0, 0)), # Empty array
+ numpy.array((numpy.nan, numpy.inf)), # All non-finite
+ numpy.array((-numpy.inf, numpy.inf, 1.0, 2.0)), # Some infinite
+ ]
+
+ for normalization in ('linear', 'log'):
+ colormap = Colormap(name='gray',
+ normalization=normalization,
+ vmin=None,
+ vmax=None)
+
+ for data in datasets:
+ with self.subTest(data=data):
+ image = colormap.applyToData(data)
+ self.assertEqual(image.dtype, numpy.uint8)
+ self.assertEqual(image.shape[-1], 4)
+ self.assertEqual(image.shape[:-1], data.shape)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ for ui in (TestDictAPI, TestObjectAPI):
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(ui))
+
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testColors.py b/silx/gui/plot/test/testColors.py
index 94c22f3..18f0902 100644
--- a/silx/gui/plot/test/testColors.py
+++ b/silx/gui/plot/test/testColors.py
@@ -35,7 +35,7 @@ import unittest
from silx.test.utils import ParametricTestCase
from silx.gui.plot import Colors
-
+from silx.gui.plot.Colormap import Colormap
class TestRGBA(ParametricTestCase):
"""Basic tests of rgba function"""
@@ -65,8 +65,8 @@ class TestApplyColormapToData(ParametricTestCase):
def testApplyColormapToData(self):
"""Simple test of applyColormapToData function"""
- colormap = dict(name='gray', normalization='linear',
- autoscale=False, vmin=0, vmax=255)
+ colormap = Colormap(name='gray', normalization='linear',
+ vmin=0, vmax=255)
size = 10
expected = numpy.empty((size, 4), dtype='uint8')
@@ -78,7 +78,7 @@ class TestApplyColormapToData(ParametricTestCase):
for dtype in ('uint8', 'int32', 'float32', 'float64'):
with self.subTest(dtype=dtype):
array = numpy.arange(size, dtype=dtype)
- result = Colors.applyColormapToData(array, **colormap)
+ result = colormap.applyToData(data=array)
self.assertTrue(numpy.all(numpy.equal(result, expected)))
diff --git a/silx/gui/plot/test/testComplexImageView.py b/silx/gui/plot/test/testComplexImageView.py
new file mode 100644
index 0000000..f8ec370
--- /dev/null
+++ b/silx/gui/plot/test/testComplexImageView.py
@@ -0,0 +1,95 @@
+# 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 suite for :class:`ComplexImageView`"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "14/09/2017"
+
+
+import unittest
+import logging
+import numpy
+
+from silx.test.utils import ParametricTestCase
+from silx.gui.plot import ComplexImageView
+
+from .utils import PlotWidgetTestCase
+
+
+logger = logging.getLogger(__name__)
+
+
+class TestComplexImageView(PlotWidgetTestCase, ParametricTestCase):
+ """Test suite of ComplexImageView widget"""
+
+ def _createPlot(self):
+ return ComplexImageView.ComplexImageView()
+
+ def testPlot2DComplex(self):
+ """Test API of ComplexImageView widget"""
+ data = numpy.array(((0, 1j), (1, 1 + 1j)), dtype=numpy.complex)
+ self.plot.setData(data)
+ self.plot.setKeepDataAspectRatio(True)
+ self.plot.getPlot().resetZoom()
+ self.qWait(100)
+
+ # Test colormap API
+ colormap = self.plot.getColormap().copy()
+ colormap.setName('magma')
+ self.plot.setColormap(colormap)
+ self.qWait(100)
+
+ # Test all modes
+ modes = self.plot.getSupportedVisualizationModes()
+ for mode in modes:
+ with self.subTest(mode=mode):
+ self.plot.setVisualizationMode(mode)
+ self.qWait(100)
+
+ # Test origin and scale API
+ self.plot.setScale((2, 1))
+ self.qWait(100)
+ self.plot.setOrigin((1, 1))
+ self.qWait(100)
+
+ # Test no data
+ self.plot.setData(numpy.zeros((0, 0), dtype=numpy.complex))
+ self.qWait(100)
+
+ # Test float data
+ self.plot.setData(numpy.arange(100, dtype=numpy.float).reshape(10, 10))
+ self.qWait(100)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(
+ TestComplexImageView))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testCurvesROIWidget.py b/silx/gui/plot/test/testCurvesROIWidget.py
index 3c6f2ba..716960a 100644
--- a/silx/gui/plot/test/testCurvesROIWidget.py
+++ b/silx/gui/plot/test/testCurvesROIWidget.py
@@ -26,7 +26,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "05/12/2016"
+__date__ = "15/05/2017"
import logging
@@ -41,7 +41,6 @@ from silx.gui.test.utils import TestCaseQt
from silx.gui.plot import PlotWindow, CurvesROIWidget
-logging.basicConfig()
_logger = logging.getLogger(__name__)
diff --git a/silx/gui/plot/test/testItem.py b/silx/gui/plot/test/testItem.py
new file mode 100644
index 0000000..8c15bb7
--- /dev/null
+++ b/silx/gui/plot/test/testItem.py
@@ -0,0 +1,231 @@
+# 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.
+#
+# ###########################################################################*/
+"""Tests for PlotWidget items."""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "01/09/2017"
+
+
+import unittest
+
+import numpy
+
+from silx.gui.test.utils import SignalListener
+from silx.gui.plot.items import ItemChangedType
+from .utils import PlotWidgetTestCase
+
+
+class TestSigItemChangedSignal(PlotWidgetTestCase):
+ """Test item's sigItemChanged signal"""
+
+ def testCurveChanged(self):
+ """Test sigItemChanged for curve"""
+ self.plot.addCurve(numpy.arange(10), numpy.arange(10), legend='test')
+ curve = self.plot.getCurve('test')
+
+ listener = SignalListener()
+ curve.sigItemChanged.connect(listener)
+
+ # Test for signal in Item class
+ curve.setVisible(False)
+ curve.setVisible(True)
+ curve.setZValue(100)
+
+ # Test for signals in Points class
+ curve.setData(numpy.arange(100), numpy.arange(100))
+
+ # SymbolMixIn
+ curve.setSymbol('o')
+ curve.setSymbol('d')
+ curve.setSymbolSize(20)
+
+ # AlphaMixIn
+ curve.setAlpha(0.5)
+
+ # Test for signals in Curve class
+ # ColorMixIn
+ curve.setColor('yellow')
+ # YAxisMixIn
+ curve.setYAxis('right')
+ # FillMixIn
+ curve.setFill(True)
+ # LineMixIn
+ curve.setLineStyle(':')
+ curve.setLineStyle(':') # Not sending event
+ curve.setLineWidth(2)
+
+ self.assertEqual(listener.arguments(argumentIndex=0),
+ [ItemChangedType.VISIBLE,
+ ItemChangedType.VISIBLE,
+ ItemChangedType.ZVALUE,
+ ItemChangedType.DATA,
+ ItemChangedType.SYMBOL,
+ ItemChangedType.SYMBOL,
+ ItemChangedType.SYMBOL_SIZE,
+ ItemChangedType.ALPHA,
+ ItemChangedType.COLOR,
+ ItemChangedType.YAXIS,
+ ItemChangedType.FILL,
+ ItemChangedType.LINE_STYLE,
+ ItemChangedType.LINE_WIDTH])
+
+ def testHistogramChanged(self):
+ """Test sigItemChanged for Histogram"""
+ self.plot.addHistogram(
+ numpy.arange(10), edges=numpy.arange(11), legend='test')
+ histogram = self.plot.getHistogram('test')
+ listener = SignalListener()
+ histogram.sigItemChanged.connect(listener)
+
+ # Test signals in Histogram class
+ histogram.setData(numpy.zeros(10), numpy.arange(11))
+
+ self.assertEqual(listener.arguments(argumentIndex=0),
+ [ItemChangedType.DATA])
+
+ def testImageDataChanged(self):
+ """Test sigItemChanged for ImageData"""
+ self.plot.addImage(numpy.arange(100).reshape(10, 10), legend='test')
+ image = self.plot.getImage('test')
+
+ listener = SignalListener()
+ image.sigItemChanged.connect(listener)
+
+ # ColormapMixIn
+ colormap = self.plot.getDefaultColormap().copy()
+ image.setColormap(colormap)
+ image.getColormap().setName('viridis')
+
+ # Test of signals in ImageBase class
+ image.setOrigin(10)
+ image.setScale(2)
+
+ # Test of signals in ImageData class
+ image.setData(numpy.ones((10, 10)))
+
+ self.assertEqual(listener.arguments(argumentIndex=0),
+ [ItemChangedType.COLORMAP,
+ ItemChangedType.COLORMAP,
+ ItemChangedType.POSITION,
+ ItemChangedType.SCALE,
+ ItemChangedType.DATA])
+
+ def testImageRgbaChanged(self):
+ """Test sigItemChanged for ImageRgba"""
+ self.plot.addImage(numpy.ones((10, 10, 3)), legend='rgb')
+ image = self.plot.getImage('rgb')
+
+ listener = SignalListener()
+ image.sigItemChanged.connect(listener)
+
+ # Test of signals in ImageRgba class
+ image.setData(numpy.zeros((10, 10, 3)))
+
+ self.assertEqual(listener.arguments(argumentIndex=0),
+ [ItemChangedType.DATA])
+
+ def testMarkerChanged(self):
+ """Test sigItemChanged for markers"""
+ self.plot.addMarker(10, 20, legend='test')
+ marker = self.plot._getMarker('test')
+
+ listener = SignalListener()
+ marker.sigItemChanged.connect(listener)
+
+ # Test signals in _BaseMarker
+ marker.setPosition(10, 10)
+ marker.setPosition(10, 10) # Not sending event
+ marker.setText('toto')
+ self.assertEqual(listener.arguments(argumentIndex=0),
+ [ItemChangedType.POSITION,
+ ItemChangedType.TEXT])
+
+ # XMarker
+ self.plot.addXMarker(10, legend='x')
+ marker = self.plot._getMarker('x')
+
+ listener = SignalListener()
+ marker.sigItemChanged.connect(listener)
+ marker.setPosition(20, 20)
+ self.assertEqual(listener.arguments(argumentIndex=0),
+ [ItemChangedType.POSITION])
+
+ # YMarker
+ self.plot.addYMarker(10, legend='x')
+ marker = self.plot._getMarker('x')
+
+ listener = SignalListener()
+ marker.sigItemChanged.connect(listener)
+ marker.setPosition(20, 20)
+ self.assertEqual(listener.arguments(argumentIndex=0),
+ [ItemChangedType.POSITION])
+
+ def testScatterChanged(self):
+ """Test sigItemChanged for scatter"""
+ data = numpy.arange(10)
+ self.plot.addScatter(data, data, data, legend='test')
+ scatter = self.plot.getScatter('test')
+
+ listener = SignalListener()
+ scatter.sigItemChanged.connect(listener)
+
+ # ColormapMixIn
+ scatter.getColormap().setName('viridis')
+ data2 = data + 10
+
+ # Test of signals in Scatter class
+ scatter.setData(data2, data2, data2)
+
+ self.assertEqual(listener.arguments(),
+ [(ItemChangedType.COLORMAP,),
+ (ItemChangedType.DATA,)])
+
+ def testShapeChanged(self):
+ """Test sigItemChanged for shape"""
+ data = numpy.array((1., 10.))
+ self.plot.addItem(data, data, legend='test', shape='rectangle')
+ shape = self.plot._getItem(kind='item', legend='test')
+
+ listener = SignalListener()
+ shape.sigItemChanged.connect(listener)
+
+ shape.setOverlay(True)
+ shape.setPoints(((2., 2.), (3., 3.)))
+
+ self.assertEqual(listener.arguments(),
+ [(ItemChangedType.OVERLAY,),
+ (ItemChangedType.DATA,)])
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loadTests(TestSigItemChangedSignal))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testLegendSelector.py b/silx/gui/plot/test/testLegendSelector.py
index 371197f..9d4ada7 100644
--- a/silx/gui/plot/test/testLegendSelector.py
+++ b/silx/gui/plot/test/testLegendSelector.py
@@ -26,7 +26,7 @@
__authors__ = ["T. Rueter", "T. Vincent"]
__license__ = "MIT"
-__date__ = "05/12/2016"
+__date__ = "15/05/2017"
import logging
@@ -37,7 +37,6 @@ from silx.gui.test.utils import TestCaseQt
from silx.gui.plot import LegendSelector
-logging.basicConfig()
_logger = logging.getLogger(__name__)
diff --git a/silx/gui/plot/test/testLimitConstraints.py b/silx/gui/plot/test/testLimitConstraints.py
new file mode 100644
index 0000000..94aae76
--- /dev/null
+++ b/silx/gui/plot/test/testLimitConstraints.py
@@ -0,0 +1,125 @@
+# 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 setLimitConstaints on the PlotWidget"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "30/08/2017"
+
+
+import unittest
+from silx.gui.plot import PlotWidget
+
+
+class TestLimitConstaints(unittest.TestCase):
+ """Tests setLimitConstaints class"""
+
+ def setUp(self):
+ self.plot = PlotWidget()
+
+ def tearDown(self):
+ self.plot = None
+
+ def testApi(self):
+ """Test availability of the API"""
+ self.plot.getXAxis().setLimitsConstraints(minPos=1, maxPos=1)
+ self.plot.getXAxis().setRangeConstraints(minRange=1, maxRange=1)
+ self.plot.getYAxis().setLimitsConstraints(minPos=1, maxPos=1)
+ self.plot.getYAxis().setRangeConstraints(minRange=1, maxRange=1)
+
+ def testXMinMax(self):
+ """Test limit constains on x-axis"""
+ self.plot.getXAxis().setLimitsConstraints(minPos=0, maxPos=100)
+ self.plot.setLimits(xmin=-1, xmax=101, ymin=-1, ymax=101)
+ self.assertEqual(self.plot.getXAxis().getLimits(), (0, 100))
+ self.assertEqual(self.plot.getYAxis().getLimits(), (-1, 101))
+
+ def testYMinMax(self):
+ """Test limit constains on y-axis"""
+ self.plot.getYAxis().setLimitsConstraints(minPos=0, maxPos=100)
+ self.plot.setLimits(xmin=-1, xmax=101, ymin=-1, ymax=101)
+ self.assertEqual(self.plot.getXAxis().getLimits(), (-1, 101))
+ self.assertEqual(self.plot.getYAxis().getLimits(), (0, 100))
+
+ def testMinXRange(self):
+ """Test min range constains on x-axis"""
+ self.plot.getXAxis().setRangeConstraints(minRange=100)
+ self.plot.setLimits(xmin=1, xmax=99, ymin=1, ymax=99)
+ limits = self.plot.getXAxis().getLimits()
+ self.assertEqual(limits[1] - limits[0], 100)
+ limits = self.plot.getYAxis().getLimits()
+ self.assertNotEqual(limits[1] - limits[0], 100)
+
+ def testMaxXRange(self):
+ """Test max range constains on x-axis"""
+ self.plot.getXAxis().setRangeConstraints(maxRange=100)
+ self.plot.setLimits(xmin=-1, xmax=101, ymin=-1, ymax=101)
+ limits = self.plot.getXAxis().getLimits()
+ self.assertEqual(limits[1] - limits[0], 100)
+ limits = self.plot.getYAxis().getLimits()
+ self.assertNotEqual(limits[1] - limits[0], 100)
+
+ def testMinYRange(self):
+ """Test min range constains on y-axis"""
+ self.plot.getYAxis().setRangeConstraints(minRange=100)
+ self.plot.setLimits(xmin=1, xmax=99, ymin=1, ymax=99)
+ limits = self.plot.getXAxis().getLimits()
+ self.assertNotEqual(limits[1] - limits[0], 100)
+ limits = self.plot.getYAxis().getLimits()
+ self.assertEqual(limits[1] - limits[0], 100)
+
+ def testMaxYRange(self):
+ """Test max range constains on y-axis"""
+ self.plot.getYAxis().setRangeConstraints(maxRange=100)
+ self.plot.setLimits(xmin=-1, xmax=101, ymin=-1, ymax=101)
+ limits = self.plot.getXAxis().getLimits()
+ self.assertNotEqual(limits[1] - limits[0], 100)
+ limits = self.plot.getYAxis().getLimits()
+ self.assertEqual(limits[1] - limits[0], 100)
+
+ def testChangeOfConstraints(self):
+ """Test changing of the constraints"""
+ self.plot.getXAxis().setRangeConstraints(minRange=10, maxRange=10)
+ # There is no more constraints on the range
+ self.plot.getXAxis().setRangeConstraints(minRange=None, maxRange=None)
+ self.plot.setLimits(xmin=-1, xmax=101, ymin=-1, ymax=101)
+ self.assertEqual(self.plot.getXAxis().getLimits(), (-1, 101))
+
+ def testSettingConstraints(self):
+ """Test setting a constaint (setLimits first then the constaint)"""
+ self.plot.setLimits(xmin=-1, xmax=101, ymin=-1, ymax=101)
+ self.plot.getXAxis().setLimitsConstraints(minPos=0, maxPos=100)
+ self.assertEqual(self.plot.getXAxis().getLimits(), (0, 100))
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loadTests(TestLimitConstaints))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testMaskToolsWidget.py b/silx/gui/plot/test/testMaskToolsWidget.py
index 0c11928..191bbe0 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__ = "24/01/2017"
+__date__ = "01/09/2017"
import logging
@@ -37,8 +37,9 @@ import numpy
from silx.gui import qt
from silx.test.utils import temp_dir, ParametricTestCase
-from silx.gui.test.utils import TestCaseQt, getQToolButtonFromAction
+from silx.gui.test.utils import getQToolButtonFromAction
from silx.gui.plot import PlotWindow, MaskToolsWidget
+from .utils import PlotWidgetTestCase
try:
import fabio
@@ -46,33 +47,24 @@ except ImportError:
fabio = None
-logging.basicConfig()
_logger = logging.getLogger(__name__)
-class TestMaskToolsWidget(TestCaseQt, ParametricTestCase):
+class TestMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
"""Basic test for MaskToolsWidget"""
+ def _createPlot(self):
+ return PlotWindow()
+
def setUp(self):
super(TestMaskToolsWidget, self).setUp()
- self.plot = PlotWindow()
-
self.widget = MaskToolsWidget.MaskToolsDockWidget(plot=self.plot, name='TEST')
self.plot.addDockWidget(qt.Qt.BottomDockWidgetArea, self.widget)
-
- self.plot.show()
- self.qWaitForWindowExposed(self.plot)
-
self.maskWidget = self.widget.widget()
def tearDown(self):
del self.maskWidget
del self.widget
-
- self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
- self.plot.close()
- del self.plot
-
super(TestMaskToolsWidget, self).tearDown()
def testEmptyPlot(self):
@@ -85,7 +77,7 @@ class TestMaskToolsWidget(TestCaseQt, ParametricTestCase):
def _drag(self):
"""Drag from plot center to offset position"""
- plot = self.plot.centralWidget()
+ plot = self.plot.getWidgetHandle()
xCenter, yCenter = plot.width() // 2, plot.height() // 2
offset = min(plot.width(), plot.height()) // 10
@@ -99,7 +91,7 @@ class TestMaskToolsWidget(TestCaseQt, ParametricTestCase):
def _drawPolygon(self):
"""Draw a star polygon in the plot"""
- plot = self.plot.centralWidget()
+ plot = self.plot.getWidgetHandle()
x, y = plot.width() // 2, plot.height() // 2
offset = min(plot.width(), plot.height()) // 10
@@ -107,16 +99,17 @@ class TestMaskToolsWidget(TestCaseQt, ParametricTestCase):
(x - offset, y - offset),
(x + offset, y),
(x - offset, y),
- (x + offset, y - offset)]
+ (x + offset, y - offset),
+ (x, y + offset)] # Close polygon
+ self.mouseMove(plot, pos=(0, 0))
for pos in star:
self.mouseMove(plot, pos=pos)
- btn = qt.Qt.LeftButton if pos != star[-1] else qt.Qt.RightButton
- self.mouseClick(plot, btn, pos=pos)
+ self.mouseClick(plot, qt.Qt.LeftButton, pos=pos)
def _drawPencil(self):
"""Draw a star polygon in the plot"""
- plot = self.plot.centralWidget()
+ plot = self.plot.getWidgetHandle()
x, y = plot.width() // 2, plot.height() // 2
offset = min(plot.width(), plot.height()) // 10
@@ -126,9 +119,10 @@ class TestMaskToolsWidget(TestCaseQt, ParametricTestCase):
(x - offset, y),
(x + offset, y - offset)]
+ self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=star[0])
self.mousePress(plot, qt.Qt.LeftButton, pos=star[0])
- for pos in star:
+ for pos in star[1:]:
self.mouseMove(plot, pos=pos)
self.mouseRelease(
plot, qt.Qt.LeftButton, pos=star[-1])
diff --git a/silx/gui/plot/test/testPlotInteraction.py b/silx/gui/plot/test/testPlotInteraction.py
index 25f57a9..335b1e4 100644
--- a/silx/gui/plot/test/testPlotInteraction.py
+++ b/silx/gui/plot/test/testPlotInteraction.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
@@ -26,12 +26,12 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "13/10/2016"
+__date__ = "01/09/2017"
import unittest
from silx.gui import qt
-from silx.gui.plot.test.testPlotWidget import _PlotWidgetTest
+from .utils import PlotWidgetTestCase
class _SignalDump(object):
@@ -49,7 +49,7 @@ class _SignalDump(object):
return list(self._received)
-class TestSelectPolygon(_PlotWidgetTest):
+class TestSelectPolygon(PlotWidgetTestCase):
"""Test polygon selection interaction"""
def _interactionModeChanged(self, source):
@@ -59,17 +59,16 @@ class TestSelectPolygon(_PlotWidgetTest):
def _draw(self, polygon):
"""Draw a polygon in the plot
- :param polygon: List of points (x, y) of the polygon (not closed)
+ :param polygon: List of points (x, y) of the polygon (closed)
"""
- plot = self.plot.centralWidget()
+ plot = self.plot.getWidgetHandle()
dump = _SignalDump()
self.plot.sigPlotSignal.connect(dump)
for pos in polygon:
self.mouseMove(plot, pos=pos)
- btn = qt.Qt.LeftButton if pos != polygon[-1] else qt.Qt.RightButton
- self.mouseClick(plot, btn, pos=pos)
+ self.mouseClick(plot, qt.Qt.LeftButton, pos=pos)
self.plot.sigPlotSignal.disconnect(dump)
return [args[0] for args in dump.received]
@@ -89,7 +88,7 @@ class TestSelectPolygon(_PlotWidgetTest):
self.plot.sigInteractiveModeChanged.disconnect(
self._interactionModeChanged)
- plot = self.plot.centralWidget()
+ plot = self.plot.getWidgetHandle()
xCenter, yCenter = plot.width() // 2, plot.height() // 2
offset = min(plot.width(), plot.height()) // 10
@@ -98,7 +97,8 @@ class TestSelectPolygon(_PlotWidgetTest):
(xCenter - offset, yCenter - offset),
(xCenter + offset, yCenter),
(xCenter - offset, yCenter),
- (xCenter + offset, yCenter - offset)]
+ (xCenter + offset, yCenter - offset),
+ (xCenter, yCenter + offset)] # Close polygon
# Draw while dumping signals
events = self._draw(star)
@@ -113,7 +113,8 @@ class TestSelectPolygon(_PlotWidgetTest):
largeSquare = [(xCenter - offset, yCenter - offset),
(xCenter + offset, yCenter - offset),
(xCenter + offset, yCenter + offset),
- (xCenter - offset, yCenter + offset)]
+ (xCenter - offset, yCenter + offset),
+ (xCenter - offset, yCenter - offset)] # Close polygon
# Draw while dumping signals
events = self._draw(largeSquare)
@@ -128,7 +129,7 @@ class TestSelectPolygon(_PlotWidgetTest):
thinRectX = [(xCenter, yCenter - offset),
(xCenter, yCenter + offset),
(xCenter + 1, yCenter + offset),
- (xCenter + 1, yCenter - offset)]
+ (xCenter + 1, yCenter - offset)] # Close polygon
# Draw while dumping signals
events = self._draw(thinRectX)
@@ -143,7 +144,7 @@ class TestSelectPolygon(_PlotWidgetTest):
thinRectY = [(xCenter - offset, yCenter),
(xCenter + offset, yCenter),
(xCenter + offset, yCenter + 1),
- (xCenter - offset, yCenter + 1)]
+ (xCenter - offset, yCenter + 1)] # Close polygon
# Draw while dumping signals
events = self._draw(thinRectY)
diff --git a/silx/gui/plot/test/testPlotTools.py b/silx/gui/plot/test/testPlotTools.py
index 1d5e148..a08a18a 100644
--- a/silx/gui/plot/test/testPlotTools.py
+++ b/silx/gui/plot/test/testPlotTools.py
@@ -26,7 +26,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "05/12/2016"
+__date__ = "01/09/2017"
import numpy
@@ -37,6 +37,7 @@ from silx.gui.test.utils import (
qWaitForWindowExposedAndActivate, TestCaseQt, getQToolButtonFromAction)
from silx.gui import qt
from silx.gui.plot import Plot2D, PlotWindow, PlotTools
+from .utils import PlotWidgetTestCase
# Makes sure a QApplication exists
@@ -67,23 +68,19 @@ def _tearDownDocTest(docTest):
# """
-class TestPositionInfo(TestCaseQt):
+class TestPositionInfo(PlotWidgetTestCase):
"""Tests for PositionInfo widget."""
+ def _createPlot(self):
+ return PlotWindow()
+
def setUp(self):
super(TestPositionInfo, self).setUp()
- self.plot = PlotWindow()
- self.plot.show()
- self.qWaitForWindowExposed(self.plot)
- self.mouseMove(self.plot, pos=(1, 1))
+ self.mouseMove(self.plot, pos=(0, 0))
self.qapp.processEvents()
self.qWait(100)
def tearDown(self):
- self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
- self.plot.close()
- del self.plot
-
super(TestPositionInfo, self).tearDown()
def _test(self, positionWidget, converterNames, **kwargs):
@@ -104,10 +101,10 @@ class TestPositionInfo(TestCaseQt):
with TestLogging(PlotTools.__name__, **kwargs):
# Move mouse to center
- self.mouseMove(self.plot)
+ center = self.plot.size() / 2
+ self.mouseMove(self.plot, pos=(center.width(), center.height()))
+ # Move out
self.mouseMove(self.plot, pos=(1, 1))
- self.qapp.processEvents()
- self.qWait(100)
def testDefaultConverters(self):
"""Test PositionInfo with default converters"""
diff --git a/silx/gui/plot/test/testPlotWidget.py b/silx/gui/plot/test/testPlotWidget.py
index 2de18a8..deeb198 100644
--- a/silx/gui/plot/test/testPlotWidget.py
+++ b/silx/gui/plot/test/testPlotWidget.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
@@ -26,18 +26,24 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "05/12/2016"
+__date__ = "01/09/2017"
import unittest
-
+import logging
import numpy
from silx.test.utils 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 deprecation
from silx.gui import qt
from silx.gui.plot import PlotWidget
+from silx.gui.plot.Colormap import Colormap
+
+from .utils import PlotWidgetTestCase
SIZE = 1024
@@ -47,27 +53,10 @@ DATA_2D = numpy.arange(SIZE ** 2).reshape(SIZE, SIZE)
"""Image data set"""
-class _PlotWidgetTest(TestCaseQt):
- """Base class for tests of PlotWidget, not a TestCase in itself.
-
- plot attribute is the PlotWidget created for the test.
- """
-
- def setUp(self):
- super(_PlotWidgetTest, self).setUp()
- self.plot = PlotWidget()
- self.plot.show()
- self.qWaitForWindowExposed(self.plot)
-
- def tearDown(self):
- self.qapp.processEvents()
- self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
- self.plot.close()
- del self.plot
- super(_PlotWidgetTest, self).tearDown()
+logger = logging.getLogger(__name__)
-class TestPlotWidget(_PlotWidgetTest, ParametricTestCase):
+class TestPlotWidget(PlotWidgetTestCase, ParametricTestCase):
"""Basic tests for PlotWidget"""
def testShow(self):
@@ -79,77 +68,115 @@ class TestPlotWidget(_PlotWidgetTest, ParametricTestCase):
title, xlabel, ylabel = 'the title', 'x label', 'y label'
self.plot.setGraphTitle(title)
- self.plot.setGraphXLabel(xlabel)
- self.plot.setGraphYLabel(ylabel)
+ self.plot.getXAxis().setLabel(xlabel)
+ self.plot.getYAxis().setLabel(ylabel)
self.qapp.processEvents()
self.assertEqual(self.plot.getGraphTitle(), title)
- self.assertEqual(self.plot.getGraphXLabel(), xlabel)
- self.assertEqual(self.plot.getGraphYLabel(), ylabel)
+ self.assertEqual(self.plot.getXAxis().getLabel(), xlabel)
+ self.assertEqual(self.plot.getYAxis().getLabel(), ylabel)
- def testChangeLimitsWithAspectRatio(self):
- def checkLimits(expectedXLim=None, expectedYLim=None,
- expectedRatio=None):
- xlim = self.plot.getGraphXLimits()
- ylim = self.plot.getGraphYLimits()
- ratio = abs(xlim[1] - xlim[0]) / abs(ylim[1] - ylim[0])
+ def _checkLimits(self,
+ expectedXLim=None,
+ expectedYLim=None,
+ expectedRatio=None):
+ """Assert that limits are as expected"""
+ xlim = self.plot.getXAxis().getLimits()
+ ylim = self.plot.getYAxis().getLimits()
+ ratio = abs(xlim[1] - xlim[0]) / abs(ylim[1] - ylim[0])
- if expectedXLim is not None:
- self.assertEqual(expectedXLim, xlim)
+ if expectedXLim is not None:
+ self.assertEqual(expectedXLim, xlim)
- if expectedYLim is not None:
- self.assertEqual(expectedYLim, ylim)
+ if expectedYLim is not None:
+ self.assertEqual(expectedYLim, ylim)
- if expectedRatio is not None:
- self.assertTrue(
- numpy.allclose(expectedRatio, ratio, atol=0.01))
+ if expectedRatio is not None:
+ self.assertTrue(
+ numpy.allclose(expectedRatio, ratio, atol=0.01))
+ def testChangeLimitsWithAspectRatio(self):
self.plot.setKeepDataAspectRatio()
self.qapp.processEvents()
- xlim = self.plot.getGraphXLimits()
- ylim = self.plot.getGraphYLimits()
+ xlim = self.plot.getXAxis().getLimits()
+ ylim = self.plot.getYAxis().getLimits()
defaultRatio = abs(xlim[1] - xlim[0]) / abs(ylim[1] - ylim[0])
- self.plot.setGraphXLimits(1., 10.)
- checkLimits(expectedXLim=(1., 10.), expectedRatio=defaultRatio)
+ self.plot.getXAxis().setLimits(1., 10.)
+ self._checkLimits(expectedXLim=(1., 10.), expectedRatio=defaultRatio)
+ self.qapp.processEvents()
+ self._checkLimits(expectedXLim=(1., 10.), expectedRatio=defaultRatio)
+
+ self.plot.getYAxis().setLimits(1., 10.)
+ self._checkLimits(expectedYLim=(1., 10.), expectedRatio=defaultRatio)
self.qapp.processEvents()
- checkLimits(expectedXLim=(1., 10.), expectedRatio=defaultRatio)
+ self._checkLimits(expectedYLim=(1., 10.), expectedRatio=defaultRatio)
- self.plot.setGraphYLimits(1., 10.)
- checkLimits(expectedYLim=(1., 10.), expectedRatio=defaultRatio)
+ def testResizeWidget(self):
+ """Test resizing the widget and receiving limitsChanged events"""
+ self.plot.resize(200, 200)
self.qapp.processEvents()
- checkLimits(expectedYLim=(1., 10.), expectedRatio=defaultRatio)
+ xlim = self.plot.getXAxis().getLimits()
+ ylim = self.plot.getYAxis().getLimits()
-class TestPlotImage(_PlotWidgetTest, ParametricTestCase):
+ listener = SignalListener()
+ self.plot.getXAxis().sigLimitsChanged.connect(listener.partial('x'))
+ self.plot.getYAxis().sigLimitsChanged.connect(listener.partial('y'))
+
+ # Resize without aspect ratio
+ self.plot.resize(200, 300)
+ self.qapp.processEvents()
+ self._checkLimits(expectedXLim=xlim, expectedYLim=ylim)
+ self.assertEqual(listener.callCount(), 0)
+
+ # 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
+
+ self.plot.resize(200, 200)
+ self.qapp.processEvents()
+
+ self.assertNotEqual(listener.callCount(), 0)
+
+
+class TestPlotImage(PlotWidgetTestCase, ParametricTestCase):
"""Basic tests for addImage"""
def setUp(self):
super(TestPlotImage, self).setUp()
- self.plot.setGraphYLabel('Rows')
- self.plot.setGraphXLabel('Columns')
+ self.plot.getYAxis().setLabel('Rows')
+ self.plot.getXAxis().setLabel('Columns')
def testPlotColormapTemperature(self):
self.plot.setGraphTitle('Temp. Linear')
- colormap = {'name': 'temperature', 'normalization': 'linear',
- 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0}
+ colormap = Colormap(name='temperature',
+ normalization='linear',
+ vmin=None,
+ vmax=None)
self.plot.addImage(DATA_2D, legend="image 1", colormap=colormap)
def testPlotColormapGray(self):
self.plot.setKeepDataAspectRatio(False)
self.plot.setGraphTitle('Gray Linear')
- colormap = {'name': 'gray', 'normalization': 'linear',
- 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0}
+ colormap = Colormap(name='gray',
+ normalization='linear',
+ vmin=None,
+ vmax=None)
self.plot.addImage(DATA_2D, legend="image 1", colormap=colormap)
def testPlotColormapTemperatureLog(self):
self.plot.setGraphTitle('Temp. Log')
- colormap = {'name': 'temperature', 'normalization': 'log',
- 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0}
+ colormap = Colormap(name='temperature',
+ normalization=Colormap.LOGARITHM,
+ vmin=None,
+ vmax=None)
self.plot.addImage(DATA_2D, legend="image 1", colormap=colormap)
def testPlotRgbRgba(self):
@@ -180,19 +207,23 @@ class TestPlotImage(_PlotWidgetTest, ParametricTestCase):
self.plot.setKeepDataAspectRatio(False)
self.plot.setGraphTitle('Custom colormap')
- colormap = {'name': None, 'normalization': 'linear',
- 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0,
- 'colors': ((0., 0., 0.), (1., 0., 0.),
- (0., 1., 0.), (0., 0., 1.))}
+ colormap = Colormap(name=None,
+ normalization=Colormap.LINEAR,
+ vmin=None,
+ vmax=None,
+ colors=((0., 0., 0.), (1., 0., 0.),
+ (0., 1., 0.), (0., 0., 1.)))
self.plot.addImage(DATA_2D, legend="image 1", colormap=colormap,
replace=False, resetzoom=False)
- colormap = {'name': None, 'normalization': 'linear',
- 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0,
- 'colors': numpy.array(
- ((0, 0, 0, 0), (0, 0, 0, 128),
- (128, 128, 128, 128), (255, 255, 255, 255)),
- dtype=numpy.uint8)}
+ colormap = Colormap(name=None,
+ normalization=Colormap.LINEAR,
+ vmin=None,
+ vmax=None,
+ colors=numpy.array(
+ ((0, 0, 0, 0), (0, 0, 0, 128),
+ (128, 128, 128, 128), (255, 255, 255, 255)),
+ dtype=numpy.uint8))
self.plot.addImage(DATA_2D, legend="image 2", colormap=colormap,
origin=(DATA_2D.shape[0], 0),
replace=False, resetzoom=False)
@@ -228,8 +259,8 @@ class TestPlotImage(_PlotWidgetTest, ParametricTestCase):
ybounds = oy, oy + DATA_2D.shape[0] * sy
# Check limits without aspect ratio
- xmin, xmax = self.plot.getGraphXLimits()
- ymin, ymax = self.plot.getGraphYLimits()
+ xmin, xmax = self.plot.getXAxis().getLimits()
+ ymin, ymax = self.plot.getYAxis().getLimits()
self.assertEqual(xmin, min(xbounds))
self.assertEqual(xmax, max(xbounds))
self.assertEqual(ymin, min(ybounds))
@@ -237,8 +268,8 @@ class TestPlotImage(_PlotWidgetTest, ParametricTestCase):
# Check limits with aspect ratio
self.plot.setKeepDataAspectRatio(True)
- xmin, xmax = self.plot.getGraphXLimits()
- ymin, ymax = self.plot.getGraphYLimits()
+ xmin, xmax = self.plot.getXAxis().getLimits()
+ ymin, ymax = self.plot.getYAxis().getLimits()
self.assertTrue(xmin <= min(xbounds))
self.assertTrue(xmax >= max(xbounds))
self.assertTrue(ymin <= min(ybounds))
@@ -248,8 +279,42 @@ class TestPlotImage(_PlotWidgetTest, ParametricTestCase):
self.plot.clear()
self.plot.resetZoom()
+ def testPlotColormapDictAPI(self):
+ """Test that the addImage API using a colormap dictionary is still
+ working"""
+ self.plot.setGraphTitle('Temp. Log')
+
+ colormap = {
+ 'name': 'temperature',
+ 'normalization': 'log',
+ 'vmin': None,
+ 'vmax': None
+ }
+ self.plot.addImage(DATA_2D, legend="image 1", colormap=colormap)
+
+ def testPlotComplexImage(self):
+ """Test that a complex image is displayed as its absolute value."""
+ data = numpy.linspace(1, 1j, 100).reshape(10, 10)
+ self.plot.addImage(data, legend='complex')
+
+ image = self.plot.getActiveImage()
+ retrievedData = image.getData(copy=False)
+ self.assertTrue(
+ numpy.all(numpy.equal(retrievedData, numpy.absolute(data))))
+
+ def testPlotBooleanImage(self):
+ """Test that a boolean image is displayed and converted to int8."""
+ data = numpy.zeros((10, 10), dtype=numpy.bool)
+ data[::2, ::2] = True
+ self.plot.addImage(data, legend='boolean')
-class TestPlotCurve(_PlotWidgetTest):
+ image = self.plot.getActiveImage()
+ retrievedData = image.getData(copy=False)
+ self.assertTrue(numpy.all(numpy.equal(retrievedData, data)))
+ self.assertIs(retrievedData.dtype.type, numpy.int8)
+
+
+class TestPlotCurve(PlotWidgetTestCase):
"""Basic tests for addCurve."""
# Test data sets
@@ -261,8 +326,8 @@ class TestPlotCurve(_PlotWidgetTest):
def setUp(self):
super(TestPlotCurve, self).setUp()
self.plot.setGraphTitle('Curve')
- self.plot.setGraphYLabel('Rows')
- self.plot.setGraphXLabel('Columns')
+ self.plot.getYAxis().setLabel('Rows')
+ self.plot.getXAxis().setLabel('Columns')
self.plot.setActiveCurveHandling(False)
@@ -307,16 +372,16 @@ class TestPlotCurve(_PlotWidgetTest):
self.plot.resetZoom()
-class TestPlotMarker(_PlotWidgetTest):
+class TestPlotMarker(PlotWidgetTestCase):
"""Basic tests for add*Marker"""
def setUp(self):
super(TestPlotMarker, self).setUp()
- self.plot.setGraphYLabel('Rows')
- self.plot.setGraphXLabel('Columns')
+ self.plot.getYAxis().setLabel('Rows')
+ self.plot.getXAxis().setLabel('Columns')
- self.plot.setXAxisAutoScale(False)
- self.plot.setYAxisAutoScale(False)
+ self.plot.getXAxis().setAutoScale(False)
+ self.plot.getYAxis().setAutoScale(False)
self.plot.setKeepDataAspectRatio(False)
self.plot.setLimits(0., 100., -100., 100.)
@@ -382,7 +447,7 @@ class TestPlotMarker(_PlotWidgetTest):
def testPlotMarkerWithoutLegend(self):
self.plot.setGraphTitle('Markers without legend')
- self.plot.setYAxisInverted(True)
+ self.plot.getYAxis().setInverted(True)
# Markers without legend
self.plot.addMarker(10, 10)
@@ -401,7 +466,7 @@ class TestPlotMarker(_PlotWidgetTest):
# TestPlotItem ################################################################
-class TestPlotItem(_PlotWidgetTest):
+class TestPlotItem(PlotWidgetTestCase):
"""Basic tests for addItem."""
# Polygon coordinates and color
@@ -431,10 +496,10 @@ class TestPlotItem(_PlotWidgetTest):
def setUp(self):
super(TestPlotItem, self).setUp()
- self.plot.setGraphYLabel('Rows')
- self.plot.setGraphXLabel('Columns')
- self.plot.setXAxisAutoScale(False)
- self.plot.setYAxisAutoScale(False)
+ self.plot.getYAxis().setLabel('Rows')
+ self.plot.getXAxis().setLabel('Columns')
+ self.plot.getXAxis().setAutoScale(False)
+ self.plot.getYAxis().setAutoScale(False)
self.plot.setKeepDataAspectRatio(False)
self.plot.setLimits(0., 100., -100., 100.)
@@ -475,102 +540,428 @@ class TestPlotItem(_PlotWidgetTest):
self.plot.resetZoom()
-class TestPlotActiveCurveImage(_PlotWidgetTest):
+class TestPlotActiveCurveImage(PlotWidgetTestCase):
"""Basic tests for active image handling"""
def testActiveCurveAndLabels(self):
# Active curve handling off, no label change
self.plot.setActiveCurveHandling(False)
- self.plot.setGraphXLabel('XLabel')
- self.plot.setGraphYLabel('YLabel')
+ self.plot.getXAxis().setLabel('XLabel')
+ self.plot.getYAxis().setLabel('YLabel')
self.plot.addCurve((1, 2), (1, 2))
- self.assertEqual(self.plot.getGraphXLabel(), 'XLabel')
- self.assertEqual(self.plot.getGraphYLabel(), 'YLabel')
+ self.assertEqual(self.plot.getXAxis().getLabel(), 'XLabel')
+ self.assertEqual(self.plot.getYAxis().getLabel(), 'YLabel')
self.plot.addCurve((1, 2), (2, 3), xlabel='x1', ylabel='y1')
- self.assertEqual(self.plot.getGraphXLabel(), 'XLabel')
- self.assertEqual(self.plot.getGraphYLabel(), 'YLabel')
+ self.assertEqual(self.plot.getXAxis().getLabel(), 'XLabel')
+ self.assertEqual(self.plot.getYAxis().getLabel(), 'YLabel')
self.plot.clear()
- self.assertEqual(self.plot.getGraphXLabel(), 'XLabel')
- self.assertEqual(self.plot.getGraphYLabel(), 'YLabel')
+ self.assertEqual(self.plot.getXAxis().getLabel(), 'XLabel')
+ self.assertEqual(self.plot.getYAxis().getLabel(), 'YLabel')
# Active curve handling on, label changes
self.plot.setActiveCurveHandling(True)
- self.plot.setGraphXLabel('XLabel')
- self.plot.setGraphYLabel('YLabel')
+ self.plot.getXAxis().setLabel('XLabel')
+ self.plot.getYAxis().setLabel('YLabel')
# labels changed as active curve
self.plot.addCurve((1, 2), (1, 2), legend='1',
xlabel='x1', ylabel='y1')
- self.assertEqual(self.plot.getGraphXLabel(), 'x1')
- self.assertEqual(self.plot.getGraphYLabel(), 'y1')
+ self.assertEqual(self.plot.getXAxis().getLabel(), 'x1')
+ self.assertEqual(self.plot.getYAxis().getLabel(), 'y1')
# labels not changed as not active curve
self.plot.addCurve((1, 2), (2, 3), legend='2')
- self.assertEqual(self.plot.getGraphXLabel(), 'x1')
- self.assertEqual(self.plot.getGraphYLabel(), 'y1')
+ self.assertEqual(self.plot.getXAxis().getLabel(), 'x1')
+ self.assertEqual(self.plot.getYAxis().getLabel(), 'y1')
# labels changed
self.plot.setActiveCurve('2')
- self.assertEqual(self.plot.getGraphXLabel(), 'XLabel')
- self.assertEqual(self.plot.getGraphYLabel(), 'YLabel')
+ self.assertEqual(self.plot.getXAxis().getLabel(), 'XLabel')
+ self.assertEqual(self.plot.getYAxis().getLabel(), 'YLabel')
self.plot.setActiveCurve('1')
- self.assertEqual(self.plot.getGraphXLabel(), 'x1')
- self.assertEqual(self.plot.getGraphYLabel(), 'y1')
+ self.assertEqual(self.plot.getXAxis().getLabel(), 'x1')
+ self.assertEqual(self.plot.getYAxis().getLabel(), 'y1')
self.plot.clear()
- self.assertEqual(self.plot.getGraphXLabel(), 'XLabel')
- self.assertEqual(self.plot.getGraphYLabel(), 'YLabel')
+ self.assertEqual(self.plot.getXAxis().getLabel(), 'XLabel')
+ self.assertEqual(self.plot.getYAxis().getLabel(), 'YLabel')
def testActiveImageAndLabels(self):
# Active image handling always on, no API for toggling it
- self.plot.setGraphXLabel('XLabel')
- self.plot.setGraphYLabel('YLabel')
+ self.plot.getXAxis().setLabel('XLabel')
+ self.plot.getYAxis().setLabel('YLabel')
# labels changed as active curve
self.plot.addImage(numpy.arange(100).reshape(10, 10), replace=False,
legend='1', xlabel='x1', ylabel='y1')
- self.assertEqual(self.plot.getGraphXLabel(), 'x1')
- self.assertEqual(self.plot.getGraphYLabel(), 'y1')
+ self.assertEqual(self.plot.getXAxis().getLabel(), 'x1')
+ self.assertEqual(self.plot.getYAxis().getLabel(), 'y1')
# labels not changed as not active curve
self.plot.addImage(numpy.arange(100).reshape(10, 10), replace=False,
legend='2')
- self.assertEqual(self.plot.getGraphXLabel(), 'x1')
- self.assertEqual(self.plot.getGraphYLabel(), 'y1')
+ self.assertEqual(self.plot.getXAxis().getLabel(), 'x1')
+ self.assertEqual(self.plot.getYAxis().getLabel(), 'y1')
# labels changed
self.plot.setActiveImage('2')
- self.assertEqual(self.plot.getGraphXLabel(), 'XLabel')
- self.assertEqual(self.plot.getGraphYLabel(), 'YLabel')
+ self.assertEqual(self.plot.getXAxis().getLabel(), 'XLabel')
+ self.assertEqual(self.plot.getYAxis().getLabel(), 'YLabel')
self.plot.setActiveImage('1')
- self.assertEqual(self.plot.getGraphXLabel(), 'x1')
- self.assertEqual(self.plot.getGraphYLabel(), 'y1')
+ self.assertEqual(self.plot.getXAxis().getLabel(), 'x1')
+ self.assertEqual(self.plot.getYAxis().getLabel(), 'y1')
self.plot.clear()
- self.assertEqual(self.plot.getGraphXLabel(), 'XLabel')
- self.assertEqual(self.plot.getGraphYLabel(), 'YLabel')
+ self.assertEqual(self.plot.getXAxis().getLabel(), 'XLabel')
+ self.assertEqual(self.plot.getYAxis().getLabel(), 'YLabel')
##############################################################################
# Log
##############################################################################
-class TestPlotEmptyLog(_PlotWidgetTest):
+class TestPlotEmptyLog(PlotWidgetTestCase):
"""Basic tests for log plot"""
def testEmptyPlotTitleLabelsLog(self):
self.plot.setGraphTitle('Empty Log Log')
- self.plot.setGraphXLabel('X')
- self.plot.setGraphYLabel('Y')
+ self.plot.getXAxis().setLabel('X')
+ self.plot.getYAxis().setLabel('Y')
+ self.plot.getXAxis()._setLogarithmic(True)
+ self.plot.getYAxis()._setLogarithmic(True)
+ self.plot.resetZoom()
+
+
+class TestPlotAxes(TestCaseQt, ParametricTestCase):
+
+ # Test data
+ xData = numpy.arange(1, 10)
+ yData = xData ** 2
+
+ def setUp(self):
+ super(TestPlotAxes, self).setUp()
+ self.plot = PlotWidget()
+ # It is not needed to display the plot
+ # It saves a lot of time
+ # self.plot.show()
+ # self.qWaitForWindowExposed(self.plot)
+
+ def tearDown(self):
+ self.qapp.processEvents()
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+ super(TestPlotAxes, self).tearDown()
+
+ def testDefaultAxes(self):
+ axis = self.plot.getXAxis()
+ self.assertEqual(axis.getScale(), axis.LINEAR)
+ axis = self.plot.getYAxis()
+ self.assertEqual(axis.getScale(), axis.LINEAR)
+ axis = self.plot.getYAxis(axis="right")
+ self.assertEqual(axis.getScale(), axis.LINEAR)
+
+ def testOldPlotAxis_getterSetter(self):
+ """Test silx API prior to silx 0.6"""
+ x = self.plot.getXAxis()
+ y = self.plot.getYAxis()
+ p = self.plot
+
+ tests = [
+ # setters
+ (p.setGraphXLimits, (10, 20), x.getLimits, (10, 20)),
+ (p.setGraphYLimits, (10, 20), y.getLimits, (10, 20)),
+ (p.setGraphXLabel, "foox", x.getLabel, "foox"),
+ (p.setGraphYLabel, "fooy", y.getLabel, "fooy"),
+ (p.setYAxisInverted, True, y.isInverted, True),
+ (p.setXAxisLogarithmic, True, x.getScale, x.LOGARITHMIC),
+ (p.setYAxisLogarithmic, True, y.getScale, y.LOGARITHMIC),
+ (p.setXAxisAutoScale, False, x.isAutoScale, False),
+ (p.setYAxisAutoScale, False, y.isAutoScale, False),
+ # getters
+ (x.setLimits, (11, 20), p.getGraphXLimits, (11, 20)),
+ (y.setLimits, (11, 20), p.getGraphYLimits, (11, 20)),
+ (x.setLabel, "fooxx", p.getGraphXLabel, "fooxx"),
+ (y.setLabel, "fooyy", p.getGraphYLabel, "fooyy"),
+ (y.setInverted, False, p.isYAxisInverted, False),
+ (x.setScale, x.LINEAR, p.isXAxisLogarithmic, False),
+ (y.setScale, y.LINEAR, p.isYAxisLogarithmic, False),
+ (x.setAutoScale, True, p.isXAxisAutoScale, True),
+ (y.setAutoScale, True, p.isYAxisAutoScale, True),
+ ]
+ for testCase in tests:
+ setter, value, getter, expected = testCase
+ with self.subTest():
+ if setter is not None:
+ if not isinstance(value, tuple):
+ value = (value, )
+ setter(*value)
+ if getter is not None:
+ self.assertEqual(getter(), expected)
+
+ @utils.test_logging(deprecation.depreclog.name, warning=2)
+ def testOldPlotAxis_Logarithmic(self):
+ """Test silx API prior to silx 0.6"""
+ x = self.plot.getXAxis()
+ y = self.plot.getYAxis()
+ yright = self.plot.getYAxis(axis="right")
+
+ listener = SignalListener()
+ self.plot.sigSetXAxisLogarithmic.connect(listener.partial("x"))
+ self.plot.sigSetYAxisLogarithmic.connect(listener.partial("y"))
+
+ self.assertEqual(x.getScale(), x.LINEAR)
+ self.assertEqual(y.getScale(), x.LINEAR)
+ self.assertEqual(yright.getScale(), x.LINEAR)
+
self.plot.setXAxisLogarithmic(True)
+ self.assertEqual(x.getScale(), x.LOGARITHMIC)
+ self.assertEqual(y.getScale(), x.LINEAR)
+ self.assertEqual(yright.getScale(), x.LINEAR)
+ self.assertEqual(self.plot.isXAxisLogarithmic(), True)
+ self.assertEqual(self.plot.isYAxisLogarithmic(), False)
+ self.assertEqual(listener.arguments(callIndex=-1), ("x", True))
+
self.plot.setYAxisLogarithmic(True)
- self.plot.resetZoom()
+ self.assertEqual(x.getScale(), x.LOGARITHMIC)
+ self.assertEqual(y.getScale(), x.LOGARITHMIC)
+ self.assertEqual(yright.getScale(), x.LOGARITHMIC)
+ self.assertEqual(self.plot.isXAxisLogarithmic(), True)
+ self.assertEqual(self.plot.isYAxisLogarithmic(), True)
+ self.assertEqual(listener.arguments(callIndex=-1), ("y", True))
+
+ yright.setScale(yright.LINEAR)
+ self.assertEqual(x.getScale(), x.LOGARITHMIC)
+ self.assertEqual(y.getScale(), x.LINEAR)
+ self.assertEqual(yright.getScale(), x.LINEAR)
+ self.assertEqual(self.plot.isXAxisLogarithmic(), True)
+ self.assertEqual(self.plot.isYAxisLogarithmic(), False)
+ self.assertEqual(listener.arguments(callIndex=-1), ("y", False))
+
+ @utils.test_logging(deprecation.depreclog.name, warning=2)
+ def testOldPlotAxis_AutoScale(self):
+ """Test silx API prior to silx 0.6"""
+ x = self.plot.getXAxis()
+ y = self.plot.getYAxis()
+ yright = self.plot.getYAxis(axis="right")
+
+ listener = SignalListener()
+ self.plot.sigSetXAxisAutoScale.connect(listener.partial("x"))
+ self.plot.sigSetYAxisAutoScale.connect(listener.partial("y"))
+
+ self.assertEqual(x.isAutoScale(), True)
+ self.assertEqual(y.isAutoScale(), True)
+ self.assertEqual(yright.isAutoScale(), True)
+
+ self.plot.setXAxisAutoScale(False)
+ self.assertEqual(x.isAutoScale(), False)
+ self.assertEqual(y.isAutoScale(), True)
+ self.assertEqual(yright.isAutoScale(), True)
+ self.assertEqual(self.plot.isXAxisAutoScale(), False)
+ self.assertEqual(self.plot.isYAxisAutoScale(), True)
+ self.assertEqual(listener.arguments(callIndex=-1), ("x", False))
+
+ self.plot.setYAxisAutoScale(False)
+ self.assertEqual(x.isAutoScale(), False)
+ self.assertEqual(y.isAutoScale(), False)
+ self.assertEqual(yright.isAutoScale(), False)
+ self.assertEqual(self.plot.isXAxisAutoScale(), False)
+ self.assertEqual(self.plot.isYAxisAutoScale(), False)
+ self.assertEqual(listener.arguments(callIndex=-1), ("y", False))
+
+ yright.setAutoScale(True)
+ self.assertEqual(x.isAutoScale(), False)
+ self.assertEqual(y.isAutoScale(), True)
+ self.assertEqual(yright.isAutoScale(), True)
+ self.assertEqual(self.plot.isXAxisAutoScale(), False)
+ self.assertEqual(self.plot.isYAxisAutoScale(), True)
+ self.assertEqual(listener.arguments(callIndex=-1), ("y", True))
+
+ @utils.test_logging(deprecation.depreclog.name, warning=1)
+ def testOldPlotAxis_Inverted(self):
+ """Test silx API prior to silx 0.6"""
+ x = self.plot.getXAxis()
+ y = self.plot.getYAxis()
+ yright = self.plot.getYAxis(axis="right")
+
+ listener = SignalListener()
+ self.plot.sigSetYAxisInverted.connect(listener.partial("y"))
+
+ self.assertEqual(x.isInverted(), False)
+ self.assertEqual(y.isInverted(), False)
+ self.assertEqual(yright.isInverted(), False)
+
+ self.plot.setYAxisInverted(True)
+ self.assertEqual(x.isInverted(), False)
+ self.assertEqual(y.isInverted(), True)
+ self.assertEqual(yright.isInverted(), True)
+ self.assertEqual(self.plot.isYAxisInverted(), True)
+ self.assertEqual(listener.arguments(callIndex=-1), ("y", True))
+
+ yright.setInverted(False)
+ self.assertEqual(x.isInverted(), False)
+ self.assertEqual(y.isInverted(), False)
+ self.assertEqual(yright.isInverted(), False)
+ self.assertEqual(self.plot.isYAxisInverted(), False)
+ self.assertEqual(listener.arguments(callIndex=-1), ("y", False))
+
+ def testLogXWithData(self):
+ self.plot.setGraphTitle('Curve X: Log Y: Linear')
+ self.plot.addCurve(self.xData, self.yData,
+ legend="curve",
+ replace=False, resetzoom=True,
+ color='green', linestyle="-", symbol='o')
+ axis = self.plot.getXAxis()
+ axis.setScale(axis.LOGARITHMIC)
+
+ self.assertEqual(axis.getScale(), axis.LOGARITHMIC)
+
+ def testLogYWithData(self):
+ self.plot.setGraphTitle('Curve X: Linear Y: Log')
+ self.plot.addCurve(self.xData, self.yData,
+ legend="curve",
+ replace=False, resetzoom=True,
+ color='green', linestyle="-", symbol='o')
+ axis = self.plot.getYAxis()
+ axis.setScale(axis.LOGARITHMIC)
+ self.assertEqual(axis.getScale(), axis.LOGARITHMIC)
+ axis = self.plot.getYAxis(axis="right")
+ self.assertEqual(axis.getScale(), axis.LOGARITHMIC)
-class TestPlotCurveLog(_PlotWidgetTest, ParametricTestCase):
+ def testLogYRightWithData(self):
+ self.plot.setGraphTitle('Curve X: Linear Y: Log')
+ self.plot.addCurve(self.xData, self.yData,
+ legend="curve",
+ replace=False, resetzoom=True,
+ color='green', linestyle="-", symbol='o')
+ axis = self.plot.getYAxis(axis="right")
+ axis.setScale(axis.LOGARITHMIC)
+
+ self.assertEqual(axis.getScale(), axis.LOGARITHMIC)
+ axis = self.plot.getYAxis()
+ self.assertEqual(axis.getScale(), axis.LOGARITHMIC)
+
+ def testLimitsChanged_setLimits(self):
+ self.plot.addCurve(self.xData, self.yData,
+ legend="curve",
+ replace=False, resetzoom=False,
+ color='green', linestyle="-", symbol='o')
+ listener = SignalListener()
+ self.plot.getXAxis().sigLimitsChanged.connect(listener.partial(axis="x"))
+ self.plot.getYAxis().sigLimitsChanged.connect(listener.partial(axis="y"))
+ self.plot.getYAxis(axis="right").sigLimitsChanged.connect(listener.partial(axis="y2"))
+ self.plot.setLimits(0, 1, 0, 1, 0, 1)
+ # at least one event per axis
+ self.assertEquals(len(set(listener.karguments(argumentName="axis"))), 3)
+
+ def testLimitsChanged_resetZoom(self):
+ self.plot.addCurve(self.xData, self.yData,
+ legend="curve",
+ replace=False, resetzoom=False,
+ color='green', linestyle="-", symbol='o')
+ listener = SignalListener()
+ self.plot.getXAxis().sigLimitsChanged.connect(listener.partial(axis="x"))
+ self.plot.getYAxis().sigLimitsChanged.connect(listener.partial(axis="y"))
+ self.plot.getYAxis(axis="right").sigLimitsChanged.connect(listener.partial(axis="y2"))
+ self.plot.resetZoom()
+ # at least one event per axis
+ self.assertEquals(len(set(listener.karguments(argumentName="axis"))), 3)
+
+ def testLimitsChanged_setXLimit(self):
+ self.plot.addCurve(self.xData, self.yData,
+ legend="curve",
+ replace=False, resetzoom=False,
+ color='green', linestyle="-", symbol='o')
+ listener = SignalListener()
+ axis = self.plot.getXAxis()
+ axis.sigLimitsChanged.connect(listener)
+ axis.setLimits(20, 30)
+ # at least one event per axis
+ self.assertEquals(listener.arguments(callIndex=-1), (20.0, 30.0))
+ self.assertEquals(axis.getLimits(), (20.0, 30.0))
+
+ def testLimitsChanged_setYLimit(self):
+ self.plot.addCurve(self.xData, self.yData,
+ legend="curve",
+ replace=False, resetzoom=False,
+ color='green', linestyle="-", symbol='o')
+ listener = SignalListener()
+ axis = self.plot.getYAxis()
+ axis.sigLimitsChanged.connect(listener)
+ axis.setLimits(20, 30)
+ # at least one event per axis
+ self.assertEquals(listener.arguments(callIndex=-1), (20.0, 30.0))
+ self.assertEquals(axis.getLimits(), (20.0, 30.0))
+
+ def testLimitsChanged_setYRightLimit(self):
+ self.plot.addCurve(self.xData, self.yData,
+ legend="curve",
+ replace=False, resetzoom=False,
+ color='green', linestyle="-", symbol='o')
+ listener = SignalListener()
+ axis = self.plot.getYAxis(axis="right")
+ axis.sigLimitsChanged.connect(listener)
+ axis.setLimits(20, 30)
+ # at least one event per axis
+ self.assertEquals(listener.arguments(callIndex=-1), (20.0, 30.0))
+ self.assertEquals(axis.getLimits(), (20.0, 30.0))
+
+ def testScaleProxy(self):
+ listener = SignalListener()
+ y = self.plot.getYAxis()
+ yright = self.plot.getYAxis(axis="right")
+ y.sigScaleChanged.connect(listener.partial("left"))
+ yright.sigScaleChanged.connect(listener.partial("right"))
+ yright.setScale(yright.LOGARITHMIC)
+
+ self.assertEquals(y.getScale(), y.LOGARITHMIC)
+ events = listener.arguments()
+ self.assertEquals(len(events), 2)
+ self.assertIn(("left", y.LOGARITHMIC), events)
+ self.assertIn(("right", y.LOGARITHMIC), events)
+
+ def testAutoScaleProxy(self):
+ listener = SignalListener()
+ y = self.plot.getYAxis()
+ yright = self.plot.getYAxis(axis="right")
+ y.sigAutoScaleChanged.connect(listener.partial("left"))
+ yright.sigAutoScaleChanged.connect(listener.partial("right"))
+ yright.setAutoScale(False)
+
+ self.assertEquals(y.isAutoScale(), False)
+ events = listener.arguments()
+ self.assertEquals(len(events), 2)
+ self.assertIn(("left", False), events)
+ self.assertIn(("right", False), events)
+
+ def testInvertedProxy(self):
+ listener = SignalListener()
+ y = self.plot.getYAxis()
+ yright = self.plot.getYAxis(axis="right")
+ y.sigInvertedChanged.connect(listener.partial("left"))
+ yright.sigInvertedChanged.connect(listener.partial("right"))
+ yright.setInverted(True)
+
+ self.assertEquals(y.isInverted(), True)
+ events = listener.arguments()
+ self.assertEquals(len(events), 2)
+ self.assertIn(("left", True), events)
+ self.assertIn(("right", True), events)
+
+ def testAxesDisplayedFalse(self):
+ """Test coverage on setAxesDisplayed(False)"""
+ self.plot.setAxesDisplayed(False)
+
+ def testAxesDisplayedTrue(self):
+ """Test coverage on setAxesDisplayed(True)"""
+ self.plot.setAxesDisplayed(True)
+
+
+class TestPlotCurveLog(PlotWidgetTestCase, ParametricTestCase):
"""Basic tests for addCurve with log scale axes"""
# Test data
@@ -578,12 +969,12 @@ class TestPlotCurveLog(_PlotWidgetTest, ParametricTestCase):
yData = xData ** 2
def _setLabels(self):
- self.plot.setGraphXLabel('X')
- self.plot.setGraphYLabel('X * X')
+ self.plot.getXAxis().setLabel('X')
+ self.plot.getYAxis().setLabel('X * X')
def testPlotCurveLogX(self):
self._setLabels()
- self.plot.setXAxisLogarithmic(True)
+ self.plot.getXAxis()._setLogarithmic(True)
self.plot.setGraphTitle('Curve X: Log Y: Linear')
self.plot.addCurve(self.xData, self.yData,
@@ -593,7 +984,7 @@ class TestPlotCurveLog(_PlotWidgetTest, ParametricTestCase):
def testPlotCurveLogY(self):
self._setLabels()
- self.plot.setYAxisLogarithmic(True)
+ self.plot.getYAxis()._setLogarithmic(True)
self.plot.setGraphTitle('Curve X: Linear Y: Log')
@@ -604,8 +995,8 @@ class TestPlotCurveLog(_PlotWidgetTest, ParametricTestCase):
def testPlotCurveLogXY(self):
self._setLabels()
- self.plot.setXAxisLogarithmic(True)
- self.plot.setYAxisLogarithmic(True)
+ self.plot.getXAxis()._setLogarithmic(True)
+ self.plot.getYAxis()._setLogarithmic(True)
self.plot.setGraphTitle('Curve X: Log Y: Log')
@@ -615,8 +1006,8 @@ class TestPlotCurveLog(_PlotWidgetTest, ParametricTestCase):
color='green', linestyle="-", symbol='o')
def testPlotCurveErrorLogXY(self):
- self.plot.setXAxisLogarithmic(True)
- self.plot.setYAxisLogarithmic(True)
+ self.plot.getXAxis()._setLogarithmic(True)
+ self.plot.getYAxis()._setLogarithmic(True)
# Every second error leads to negative number
errors = numpy.ones_like(self.xData)
@@ -666,17 +1057,17 @@ class TestPlotCurveLog(_PlotWidgetTest, ParametricTestCase):
self.qapp.processEvents()
# no log axis
- xLim = self.plot.getGraphXLimits()
+ xLim = self.plot.getXAxis().getLimits()
self.assertEqual(xLim, (min(xData), max(xData)))
- yLim = self.plot.getGraphYLimits()
+ yLim = self.plot.getYAxis().getLimits()
self.assertEqual(yLim, (min(yData), max(yData)))
# x axis log
- self.plot.setXAxisLogarithmic(True)
+ self.plot.getXAxis()._setLogarithmic(True)
self.qapp.processEvents()
- xLim = self.plot.getGraphXLimits()
- yLim = self.plot.getGraphYLimits()
+ xLim = self.plot.getXAxis().getLimits()
+ yLim = self.plot.getYAxis().getLimits()
positives = xData > 0
if numpy.any(positives):
self.assertTrue(numpy.allclose(
@@ -688,11 +1079,11 @@ class TestPlotCurveLog(_PlotWidgetTest, ParametricTestCase):
self.assertEqual(yLim, (1., 100.))
# x axis and y axis log
- self.plot.setYAxisLogarithmic(True)
+ self.plot.getYAxis()._setLogarithmic(True)
self.qapp.processEvents()
- xLim = self.plot.getGraphXLimits()
- yLim = self.plot.getGraphYLimits()
+ xLim = self.plot.getXAxis().getLimits()
+ yLim = self.plot.getYAxis().getLimits()
positives = numpy.logical_and(xData > 0, yData > 0)
if numpy.any(positives):
self.assertTrue(numpy.allclose(
@@ -704,11 +1095,11 @@ class TestPlotCurveLog(_PlotWidgetTest, ParametricTestCase):
self.assertEqual(yLim, (1., 100.))
# y axis log
- self.plot.setXAxisLogarithmic(False)
+ self.plot.getXAxis()._setLogarithmic(False)
self.qapp.processEvents()
- xLim = self.plot.getGraphXLimits()
- yLim = self.plot.getGraphYLimits()
+ xLim = self.plot.getXAxis().getLimits()
+ yLim = self.plot.getYAxis().getLimits()
positives = yData > 0
if numpy.any(positives):
self.assertEqual(
@@ -720,12 +1111,12 @@ class TestPlotCurveLog(_PlotWidgetTest, ParametricTestCase):
self.assertEqual(yLim, (1., 100.))
# no log axis
- self.plot.setYAxisLogarithmic(False)
+ self.plot.getYAxis()._setLogarithmic(False)
self.qapp.processEvents()
- xLim = self.plot.getGraphXLimits()
+ xLim = self.plot.getXAxis().getLimits()
self.assertEqual(xLim, (min(xData), max(xData)))
- yLim = self.plot.getGraphYLimits()
+ yLim = self.plot.getYAxis().getLimits()
self.assertEqual(yLim, (min(yData), max(yData)))
self.plot.clear()
@@ -733,52 +1124,58 @@ class TestPlotCurveLog(_PlotWidgetTest, ParametricTestCase):
self.qapp.processEvents()
-class TestPlotImageLog(_PlotWidgetTest):
+class TestPlotImageLog(PlotWidgetTestCase):
"""Basic tests for addImage with log scale axes."""
def setUp(self):
super(TestPlotImageLog, self).setUp()
- self.plot.setGraphXLabel('Columns')
- self.plot.setGraphYLabel('Rows')
+ self.plot.getXAxis().setLabel('Columns')
+ self.plot.getYAxis().setLabel('Rows')
def testPlotColormapGrayLogX(self):
- self.plot.setXAxisLogarithmic(True)
+ self.plot.getXAxis()._setLogarithmic(True)
self.plot.setGraphTitle('CMap X: Log Y: Linear')
- colormap = {'name': 'gray', 'normalization': 'linear',
- 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0}
+ colormap = Colormap(name='gray',
+ normalization='linear',
+ vmin=None,
+ vmax=None)
self.plot.addImage(DATA_2D, legend="image 1",
origin=(1., 1.), scale=(1., 1.),
replace=False, resetzoom=False, colormap=colormap)
self.plot.resetZoom()
def testPlotColormapGrayLogY(self):
- self.plot.setYAxisLogarithmic(True)
+ self.plot.getYAxis()._setLogarithmic(True)
self.plot.setGraphTitle('CMap X: Linear Y: Log')
- colormap = {'name': 'gray', 'normalization': 'linear',
- 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0}
+ colormap = Colormap(name='gray',
+ normalization='linear',
+ vmin=None,
+ vmax=None)
self.plot.addImage(DATA_2D, legend="image 1",
origin=(1., 1.), scale=(1., 1.),
replace=False, resetzoom=False, colormap=colormap)
self.plot.resetZoom()
def testPlotColormapGrayLogXY(self):
- self.plot.setXAxisLogarithmic(True)
- self.plot.setYAxisLogarithmic(True)
+ self.plot.getXAxis()._setLogarithmic(True)
+ self.plot.getYAxis()._setLogarithmic(True)
self.plot.setGraphTitle('CMap X: Log Y: Log')
- colormap = {'name': 'gray', 'normalization': 'linear',
- 'autoscale': True, 'vmin': 0.0, 'vmax': 1.0}
+ colormap = Colormap(name='gray',
+ normalization='linear',
+ vmin=None,
+ vmax=None)
self.plot.addImage(DATA_2D, legend="image 1",
origin=(1., 1.), scale=(1., 1.),
replace=False, resetzoom=False, colormap=colormap)
self.plot.resetZoom()
def testPlotRgbRgbaLogXY(self):
- self.plot.setXAxisLogarithmic(True)
- self.plot.setYAxisLogarithmic(True)
+ self.plot.getXAxis()._setLogarithmic(True)
+ self.plot.getYAxis()._setLogarithmic(True)
self.plot.setGraphTitle('RGB + RGBA X: Log Y: Log')
rgb = numpy.array(
@@ -801,7 +1198,7 @@ class TestPlotImageLog(_PlotWidgetTest):
self.plot.resetZoom()
-class TestPlotMarkerLog(_PlotWidgetTest):
+class TestPlotMarkerLog(PlotWidgetTestCase):
"""Basic tests for markers on log scales"""
# Test marker parameters
@@ -816,14 +1213,14 @@ class TestPlotMarkerLog(_PlotWidgetTest):
def setUp(self):
super(TestPlotMarkerLog, self).setUp()
- self.plot.setGraphYLabel('Rows')
- self.plot.setGraphXLabel('Columns')
- self.plot.setXAxisAutoScale(False)
- self.plot.setYAxisAutoScale(False)
+ self.plot.getYAxis().setLabel('Rows')
+ self.plot.getXAxis().setLabel('Columns')
+ self.plot.getXAxis().setAutoScale(False)
+ self.plot.getYAxis().setAutoScale(False)
self.plot.setKeepDataAspectRatio(False)
self.plot.setLimits(1., 100., 1., 1000.)
- self.plot.setXAxisLogarithmic(True)
- self.plot.setYAxisLogarithmic(True)
+ self.plot.getXAxis()._setLogarithmic(True)
+ self.plot.getYAxis()._setLogarithmic(True)
def testPlotMarkerXLog(self):
self.plot.setGraphTitle('Markers X, Log axes')
@@ -862,7 +1259,7 @@ class TestPlotMarkerLog(_PlotWidgetTest):
self.plot.resetZoom()
-class TestPlotItemLog(_PlotWidgetTest):
+class TestPlotItemLog(PlotWidgetTestCase):
"""Basic tests for items with log scale axes"""
# Polygon coordinates and color
@@ -892,14 +1289,14 @@ class TestPlotItemLog(_PlotWidgetTest):
def setUp(self):
super(TestPlotItemLog, self).setUp()
- self.plot.setGraphYLabel('Rows')
- self.plot.setGraphXLabel('Columns')
- self.plot.setXAxisAutoScale(False)
- self.plot.setYAxisAutoScale(False)
+ self.plot.getYAxis().setLabel('Rows')
+ self.plot.getXAxis().setLabel('Columns')
+ self.plot.getXAxis().setAutoScale(False)
+ self.plot.getYAxis().setAutoScale(False)
self.plot.setKeepDataAspectRatio(False)
self.plot.setLimits(1., 100., 1., 100.)
- self.plot.setXAxisLogarithmic(True)
- self.plot.setYAxisLogarithmic(True)
+ self.plot.getXAxis()._setLogarithmic(True)
+ self.plot.getYAxis()._setLogarithmic(True)
def testPlotItemPolygonLogFill(self):
self.plot.setGraphTitle('Item Fill Log')
@@ -940,26 +1337,18 @@ class TestPlotItemLog(_PlotWidgetTest):
def suite():
test_suite = unittest.TestSuite()
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotWidget))
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotImage))
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotCurve))
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotMarker))
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotItem))
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotEmptyLog))
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotCurveLog))
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotImageLog))
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotMarkerLog))
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestPlotItemLog))
+ loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loadTests(TestPlotWidget))
+ test_suite.addTest(loadTests(TestPlotImage))
+ test_suite.addTest(loadTests(TestPlotCurve))
+ test_suite.addTest(loadTests(TestPlotMarker))
+ test_suite.addTest(loadTests(TestPlotItem))
+ test_suite.addTest(loadTests(TestPlotAxes))
+ test_suite.addTest(loadTests(TestPlotEmptyLog))
+ test_suite.addTest(loadTests(TestPlotCurveLog))
+ test_suite.addTest(loadTests(TestPlotImageLog))
+ test_suite.addTest(loadTests(TestPlotMarkerLog))
+ test_suite.addTest(loadTests(TestPlotItemLog))
return test_suite
diff --git a/silx/gui/plot/test/testPlot.py b/silx/gui/plot/test/testPlotWidgetNoBackend.py
index 25e7511..3094a20 100644
--- a/silx/gui/plot/test/testPlot.py
+++ b/silx/gui/plot/test/testPlotWidgetNoBackend.py
@@ -22,11 +22,11 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""Basic tests for Plot"""
+"""Basic tests for PlotWidget with 'none' backend"""
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "05/12/2016"
+__date__ = "27/06/2017"
import unittest
@@ -35,7 +35,7 @@ from silx.test.utils import ParametricTestCase
import numpy
-from silx.gui.plot.Plot import Plot
+from silx.gui.plot.PlotWidget import PlotWidget
from silx.gui.plot.items.histogram import _getHistogramCurve, _computeEdges
@@ -45,21 +45,21 @@ class TestPlot(unittest.TestCase):
def testPlotTitleLabels(self):
"""Create a Plot and set the labels"""
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
title, xlabel, ylabel = 'the title', 'x label', 'y label'
plot.setGraphTitle(title)
- plot.setGraphXLabel(xlabel)
- plot.setGraphYLabel(ylabel)
+ plot.getXAxis().setLabel(xlabel)
+ plot.getYAxis().setLabel(ylabel)
self.assertEqual(plot.getGraphTitle(), title)
- self.assertEqual(plot.getGraphXLabel(), xlabel)
- self.assertEqual(plot.getGraphYLabel(), ylabel)
+ self.assertEqual(plot.getXAxis().getLabel(), xlabel)
+ self.assertEqual(plot.getYAxis().getLabel(), ylabel)
def testAddNoRemove(self):
"""add objects to the Plot"""
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
plot.addCurve(x=(1, 2, 3), y=(3, 2, 1))
plot.addImage(numpy.arange(100.).reshape(10, -1))
plot.addItem(
@@ -96,7 +96,7 @@ class TestPlotRanges(ParametricTestCase):
def testDataRangeNoPlot(self):
"""empty plot data range"""
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
for logX, logY in ((False, False),
(True, False),
@@ -104,8 +104,8 @@ class TestPlotRanges(ParametricTestCase):
(False, True),
(False, False)):
with self.subTest(logX=logX, logY=logY):
- plot.setXAxisLogarithmic(logX)
- plot.setYAxisLogarithmic(logY)
+ plot.getXAxis()._setLogarithmic(logX)
+ plot.getYAxis()._setLogarithmic(logY)
dataRange = plot.getDataRange()
self.assertIsNone(dataRange.x)
self.assertIsNone(dataRange.y)
@@ -114,7 +114,7 @@ class TestPlotRanges(ParametricTestCase):
def testDataRangeLeft(self):
"""left axis range"""
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
xData = numpy.arange(10) - 4.9 # range : -4.9 , 4.1
yData = numpy.arange(10) - 6.9 # range : -6.9 , 2.1
@@ -130,8 +130,8 @@ class TestPlotRanges(ParametricTestCase):
(False, True),
(False, False)):
with self.subTest(logX=logX, logY=logY):
- plot.setXAxisLogarithmic(logX)
- plot.setYAxisLogarithmic(logY)
+ plot.getXAxis()._setLogarithmic(logX)
+ plot.getYAxis()._setLogarithmic(logY)
dataRange = plot.getDataRange()
xRange, yRange = self._getRanges([xData, yData],
[logX, logY])
@@ -142,7 +142,7 @@ class TestPlotRanges(ParametricTestCase):
def testDataRangeRight(self):
"""right axis range"""
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
xData = numpy.arange(10) - 4.9 # range : -4.9 , 4.1
yData = numpy.arange(10) - 6.9 # range : -6.9 , 2.1
plot.addCurve(x=xData,
@@ -156,8 +156,8 @@ class TestPlotRanges(ParametricTestCase):
(False, True),
(False, False)):
with self.subTest(logX=logX, logY=logY):
- plot.setXAxisLogarithmic(logX)
- plot.setYAxisLogarithmic(logY)
+ plot.getXAxis()._setLogarithmic(logX)
+ plot.getYAxis()._setLogarithmic(logY)
dataRange = plot.getDataRange()
xRange, yRange = self._getRanges([xData, yData],
[logX, logY])
@@ -172,7 +172,7 @@ class TestPlotRanges(ParametricTestCase):
scale = (3., 8.)
image = numpy.arange(100.).reshape(20, 5)
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
plot.addImage(image,
origin=origin, scale=scale)
@@ -190,8 +190,8 @@ class TestPlotRanges(ParametricTestCase):
(False, True),
(False, False)):
with self.subTest(logX=logX, logY=logY):
- plot.setXAxisLogarithmic(logX)
- plot.setYAxisLogarithmic(logY)
+ plot.getXAxis()._setLogarithmic(logX)
+ plot.getYAxis()._setLogarithmic(logY)
dataRange = plot.getDataRange()
xRange, yRange = ranges[logX, logY]
self.assertTrue(numpy.array_equal(dataRange.x, xRange),
@@ -203,7 +203,7 @@ class TestPlotRanges(ParametricTestCase):
def testDataRangeLeftRight(self):
"""right+left axis range"""
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
xData_l = numpy.arange(10) - 0.9 # range : -0.9 , 8.1
yData_l = numpy.arange(10) - 1.9 # range : -1.9 , 7.1
@@ -225,8 +225,8 @@ class TestPlotRanges(ParametricTestCase):
(False, True),
(False, False)):
with self.subTest(logX=logX, logY=logY):
- plot.setXAxisLogarithmic(logX)
- plot.setYAxisLogarithmic(logY)
+ plot.getXAxis()._setLogarithmic(logX)
+ plot.getYAxis()._setLogarithmic(logY)
dataRange = plot.getDataRange()
xRangeL, yRangeL = self._getRanges([xData_l, yData_l],
[logX, logY])
@@ -244,7 +244,7 @@ class TestPlotRanges(ParametricTestCase):
# image sets x min and y max
# plot_left sets y min
# plot_right sets x max (and yright)
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
origin = (-10, 5)
scale = (3., 8.)
@@ -276,8 +276,8 @@ class TestPlotRanges(ParametricTestCase):
(False, True),
(False, False)):
with self.subTest(logX=logX, logY=logY):
- plot.setXAxisLogarithmic(logX)
- plot.setYAxisLogarithmic(logY)
+ plot.getXAxis()._setLogarithmic(logX)
+ plot.getYAxis()._setLogarithmic(logY)
dataRange = plot.getDataRange()
xRangeL, yRangeL = self._getRanges([xData_l, yData_l],
[logX, logY])
@@ -301,7 +301,7 @@ class TestPlotRanges(ParametricTestCase):
scale = (-3., 8.)
image = numpy.arange(100.).reshape(20, 5)
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
plot.addImage(image,
origin=origin, scale=scale)
@@ -320,8 +320,8 @@ class TestPlotRanges(ParametricTestCase):
(False, True),
(False, False)):
with self.subTest(logX=logX, logY=logY):
- plot.setXAxisLogarithmic(logX)
- plot.setYAxisLogarithmic(logY)
+ plot.getXAxis()._setLogarithmic(logX)
+ plot.getYAxis()._setLogarithmic(logY)
dataRange = plot.getDataRange()
xRange, yRange = ranges[logX, logY]
self.assertTrue(numpy.array_equal(dataRange.x, xRange),
@@ -337,7 +337,7 @@ class TestPlotRanges(ParametricTestCase):
scale = (3., -8.)
image = numpy.arange(100.).reshape(20, 5)
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
plot.addImage(image,
origin=origin, scale=scale)
@@ -356,8 +356,8 @@ class TestPlotRanges(ParametricTestCase):
(False, True),
(False, False)):
with self.subTest(logX=logX, logY=logY):
- plot.setXAxisLogarithmic(logX)
- plot.setYAxisLogarithmic(logY)
+ plot.getXAxis()._setLogarithmic(logX)
+ plot.getYAxis()._setLogarithmic(logY)
dataRange = plot.getDataRange()
xRange, yRange = ranges[logX, logY]
self.assertTrue(numpy.array_equal(dataRange.x, xRange),
@@ -368,7 +368,7 @@ class TestPlotRanges(ParametricTestCase):
def testDataRangeHiddenCurve(self):
"""curves with a hidden curve"""
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
plot.addCurve((0, 1), (0, 1), legend='shown')
plot.addCurve((0, 1, 2), (5, 5, 5), legend='hidden')
range1 = plot.getDataRange()
@@ -384,9 +384,9 @@ class TestPlotGetCurveImage(unittest.TestCase):
"""Test of plot getCurve and getImage methods"""
def testGetCurve(self):
- """Plot.getCurve and Plot.getActiveCurve tests"""
+ """PlotWidget.getCurve and Plot.getActiveCurve tests"""
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
# No curve
curve = plot.getCurve()
@@ -423,9 +423,9 @@ class TestPlotGetCurveImage(unittest.TestCase):
self.assertIsNone(curve)
def testGetCurveOldApi(self):
- """old API Plot.getCurve and Plot.getActiveCurve tests"""
+ """old API PlotWidget.getCurve and Plot.getActiveCurve tests"""
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
# No curve
curve = plot.getCurve()
@@ -433,7 +433,7 @@ class TestPlotGetCurveImage(unittest.TestCase):
plot.setActiveCurveHandling(True)
x = numpy.arange(10.).astype(numpy.float32)
- y = x * x;
+ y = x * x
plot.addCurve(x=x, y=y, legend='curve 0', info=["whatever"])
plot.addCurve(x=x, y=2*x, legend='curve 1', info="anything")
plot.setActiveCurve('curve 0')
@@ -449,12 +449,12 @@ class TestPlotGetCurveImage(unittest.TestCase):
self.assertEqual(legend, 'curve 1')
self.assertEqual(info, 'anything')
self.assertTrue(numpy.allclose(xOut, x), 'curve 1 wrong x data')
- self.assertTrue(numpy.allclose(yOut, 2*x), 'curve 1 wrong y data')
+ self.assertTrue(numpy.allclose(yOut, 2 * x), 'curve 1 wrong y data')
def testGetImage(self):
- """Plot.getImage and Plot.getActiveImage tests"""
+ """PlotWidget.getImage and PlotWidget.getActiveImage tests"""
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
# No image
image = plot.getImage()
@@ -485,9 +485,9 @@ class TestPlotGetCurveImage(unittest.TestCase):
self.assertEqual(image.getLegend(), 'image 1')
def testGetImageOldApi(self):
- """Plot.getImage and Plot.getActiveImage old API tests"""
+ """PlotWidget.getImage and PlotWidget.getActiveImage old API tests"""
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
# No image
image = plot.getImage()
@@ -505,9 +505,9 @@ class TestPlotGetCurveImage(unittest.TestCase):
self.assertTrue(numpy.allclose(data, image), "image 0 data not correct")
def testGetAllImages(self):
- """Plot.getAllImages test"""
+ """PlotWidget.getAllImages test"""
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
# No image
images = plot.getAllImages()
@@ -530,7 +530,7 @@ class TestPlotAddScatter(unittest.TestCase):
def testAddGetScatter(self):
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
# No curve
scatter = plot._getItem(kind="scatter")
@@ -565,9 +565,9 @@ class TestPlotAddScatter(unittest.TestCase):
self.assertEqual(scatter1.getLegend(), 'scatter 1')
def testGetAllScatters(self):
- """Plot.getAllImages test"""
+ """PlotWidget.getAllImages test"""
- plot = Plot(backend='none')
+ plot = PlotWidget(backend='none')
scatters = plot._getItems(kind='scatter')
self.assertEqual(len(scatters), 0)
diff --git a/silx/gui/plot/test/testPlotWindow.py b/silx/gui/plot/test/testPlotWindow.py
index 5afd53a..24d840b 100644
--- a/silx/gui/plot/test/testPlotWindow.py
+++ b/silx/gui/plot/test/testPlotWindow.py
@@ -26,7 +26,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "05/12/2016"
+__date__ = "27/06/2017"
import doctest
@@ -84,10 +84,10 @@ class TestPlotWindow(TestCaseQt):
self.plot.setLimits(1, 100, 1, 100)
checkList = [ # QAction, Plot state getter
- (self.plot.xAxisAutoScaleAction, self.plot.isXAxisAutoScale),
- (self.plot.yAxisAutoScaleAction, self.plot.isYAxisAutoScale),
- (self.plot.xAxisLogarithmicAction, self.plot.isXAxisLogarithmic),
- (self.plot.yAxisLogarithmicAction, self.plot.isYAxisLogarithmic),
+ (self.plot.xAxisAutoScaleAction, self.plot.getXAxis().isAutoScale),
+ (self.plot.yAxisAutoScaleAction, self.plot.getYAxis().isAutoScale),
+ (self.plot.xAxisLogarithmicAction, self.plot.getXAxis()._isLogarithmic),
+ (self.plot.yAxisLogarithmicAction, self.plot.getYAxis()._isLogarithmic),
(self.plot.gridAction, self.plot.getGraphGrid),
]
@@ -121,9 +121,9 @@ class TestPlotWindow(TestCaseQt):
def testToolYAxisOrigin(self):
self.plot.toolBar()
self.plot.yAxisInvertedButton.setYAxisUpward()
- self.assertFalse(self.plot.isYAxisInverted())
+ self.assertFalse(self.plot.getYAxis().isInverted())
self.plot.yAxisInvertedButton.setYAxisDownward()
- self.assertTrue(self.plot.isYAxisInverted())
+ self.assertTrue(self.plot.getYAxis().isInverted())
def suite():
diff --git a/silx/gui/plot/test/testScatterMaskToolsWidget.py b/silx/gui/plot/test/testScatterMaskToolsWidget.py
index 8b5f2ad..178274a 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__ = "10/07/2017"
+__date__ = "01/09/2017"
import logging
@@ -37,8 +37,9 @@ import numpy
from silx.gui import qt
from silx.test.utils import temp_dir, ParametricTestCase
-from silx.gui.test.utils import TestCaseQt, getQToolButtonFromAction
+from silx.gui.test.utils import getQToolButtonFromAction
from silx.gui.plot import PlotWindow, ScatterMaskToolsWidget
+from .utils import PlotWidgetTestCase
try:
import fabio
@@ -46,34 +47,26 @@ except ImportError:
fabio = None
-logging.basicConfig()
_logger = logging.getLogger(__name__)
-class TestScatterMaskToolsWidget(TestCaseQt, ParametricTestCase):
+class TestScatterMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
"""Basic test for MaskToolsWidget"""
+ def _createPlot(self):
+ return PlotWindow()
+
def setUp(self):
super(TestScatterMaskToolsWidget, self).setUp()
- self.plot = PlotWindow()
-
self.widget = ScatterMaskToolsWidget.ScatterMaskToolsDockWidget(
plot=self.plot, name='TEST')
self.plot.addDockWidget(qt.Qt.BottomDockWidgetArea, self.widget)
- self.plot.show()
- self.qWaitForWindowExposed(self.plot)
-
self.maskWidget = self.widget.widget()
def tearDown(self):
del self.maskWidget
del self.widget
-
- self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
- self.plot.close()
- del self.plot
-
super(TestScatterMaskToolsWidget, self).tearDown()
def testEmptyPlot(self):
@@ -86,7 +79,7 @@ class TestScatterMaskToolsWidget(TestCaseQt, ParametricTestCase):
def _drag(self):
"""Drag from plot center to offset position"""
- plot = self.plot.centralWidget()
+ plot = self.plot.getWidgetHandle()
xCenter, yCenter = plot.width() // 2, plot.height() // 2
offset = min(plot.width(), plot.height()) // 10
@@ -100,7 +93,7 @@ class TestScatterMaskToolsWidget(TestCaseQt, ParametricTestCase):
def _drawPolygon(self):
"""Draw a star polygon in the plot"""
- plot = self.plot.centralWidget()
+ plot = self.plot.getWidgetHandle()
x, y = plot.width() // 2, plot.height() // 2
offset = min(plot.width(), plot.height()) // 10
@@ -108,16 +101,17 @@ class TestScatterMaskToolsWidget(TestCaseQt, ParametricTestCase):
(x - offset, y - offset),
(x + offset, y),
(x - offset, y),
- (x + offset, y - offset)]
+ (x + offset, y - offset),
+ (x, y + offset)] # Close polygon
+ self.mouseMove(plot, pos=[0, 0])
for pos in star:
self.mouseMove(plot, pos=pos)
- btn = qt.Qt.LeftButton if pos != star[-1] else qt.Qt.RightButton
- self.mouseClick(plot, btn, pos=pos)
+ self.mouseClick(plot, qt.Qt.LeftButton, pos=pos)
def _drawPencil(self):
"""Draw a star polygon in the plot"""
- plot = self.plot.centralWidget()
+ plot = self.plot.getWidgetHandle()
x, y = plot.width() // 2, plot.height() // 2
offset = min(plot.width(), plot.height()) // 10
@@ -127,9 +121,10 @@ class TestScatterMaskToolsWidget(TestCaseQt, ParametricTestCase):
(x - offset, y),
(x + offset, y - offset)]
+ self.mouseMove(plot, pos=[0, 0])
self.mouseMove(plot, pos=star[0])
self.mousePress(plot, qt.Qt.LeftButton, pos=star[0])
- for pos in star:
+ for pos in star[1:]:
self.mouseMove(plot, pos=pos)
self.mouseRelease(
plot, qt.Qt.LeftButton, pos=star[-1])
diff --git a/silx/gui/plot/test/testStackView.py b/silx/gui/plot/test/testStackView.py
index 69584cd..8d2a0ee 100644
--- a/silx/gui/plot/test/testStackView.py
+++ b/silx/gui/plot/test/testStackView.py
@@ -130,7 +130,7 @@ class TestStackView(TestCaseQt):
self.assertEqual(self.stackview._perspective, 2,
"Perspective not set in setStack(..., perspective=2).")
- def testTitle(self):
+ def testDefaultTitle(self):
"""Test that the plot title contains the proper Z information"""
self.stackview.setStack(numpy.arange(24).reshape((4, 3, 2)),
calibrations=[(0, 1), (-10, 10), (3.14, 3.14)])
@@ -156,6 +156,37 @@ class TestStackView(TestCaseQt):
self.assertEqual(self.stackview._plot.getGraphTitle(),
"Image z=6.28")
+ def testCustomTitle(self):
+ """Test setting the plot title with a user defined callback"""
+ self.stackview.setStack(numpy.arange(24).reshape((4, 3, 2)),
+ calibrations=[(0, 1), (-10, 10), (3.14, 3.14)])
+
+ def title_callback(frame_idx):
+ return "Cubed index title %d" % (frame_idx**3)
+
+ self.stackview.setTitleCallback(title_callback)
+ self.assertEqual(self.stackview._plot.getGraphTitle(),
+ "Cubed index title 0")
+ self.stackview.setFrameNumber(2)
+ self.assertEqual(self.stackview._plot.getGraphTitle(),
+ "Cubed index title 8")
+
+ # perspective should not matter, only frame index
+ self.stackview._StackView__planeSelection.setPerspective(1)
+ self.stackview.setFrameNumber(0)
+ self.assertEqual(self.stackview._plot.getGraphTitle(),
+ "Cubed index title 0")
+ self.stackview.setFrameNumber(2)
+ self.assertEqual(self.stackview._plot.getGraphTitle(),
+ "Cubed index title 8")
+
+ with self.assertRaises(TypeError):
+ # setTitleCallback should not accept non-callable objects like strings
+ self.stackview.setTitleCallback(
+ "Là, vous faites sirop de vingt-et-un et vous dites : "
+ "beau sirop, mi-sirop, siroté, gagne-sirop, sirop-grelot,"
+ " passe-montagne, sirop au bon goût.")
+
class TestStackViewMainWindow(TestCaseQt):
"""Base class for tests of StackView."""
diff --git a/silx/gui/plot/test/testUtilsAxis.py b/silx/gui/plot/test/testUtilsAxis.py
new file mode 100644
index 0000000..6702b00
--- /dev/null
+++ b/silx/gui/plot/test/testUtilsAxis.py
@@ -0,0 +1,148 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Basic tests for PlotWidget"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "04/08/2017"
+
+
+import unittest
+from silx.gui.plot import PlotWidget
+from silx.gui.plot.utils.axis import SyncAxes
+
+
+class TestAxisSync(unittest.TestCase):
+ """Tests AxisSync class"""
+
+ def setUp(self):
+ self.plot1 = PlotWidget()
+ self.plot2 = PlotWidget()
+ self.plot3 = PlotWidget()
+
+ def tearDown(self):
+ self.plot1 = None
+ self.plot2 = None
+ self.plot3 = None
+
+ def testMoveFirstAxis(self):
+ """Test synchronization after construction"""
+ _sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()])
+
+ self.plot1.getXAxis().setLimits(10, 500)
+ self.assertEqual(self.plot1.getXAxis().getLimits(), (10, 500))
+ self.assertEqual(self.plot2.getXAxis().getLimits(), (10, 500))
+ self.assertEqual(self.plot3.getXAxis().getLimits(), (10, 500))
+
+ def testMoveSecondAxis(self):
+ """Test synchronization after construction"""
+ _sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()])
+
+ self.plot2.getXAxis().setLimits(10, 500)
+ self.assertEqual(self.plot1.getXAxis().getLimits(), (10, 500))
+ self.assertEqual(self.plot2.getXAxis().getLimits(), (10, 500))
+ self.assertEqual(self.plot3.getXAxis().getLimits(), (10, 500))
+
+ def testMoveTwoAxes(self):
+ """Test synchronization after construction"""
+ _sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()])
+
+ self.plot1.getXAxis().setLimits(1, 50)
+ self.plot2.getXAxis().setLimits(10, 500)
+ self.assertEqual(self.plot1.getXAxis().getLimits(), (10, 500))
+ self.assertEqual(self.plot2.getXAxis().getLimits(), (10, 500))
+ self.assertEqual(self.plot3.getXAxis().getLimits(), (10, 500))
+
+ def testDestruction(self):
+ """Test synchronization when sync object is destroyed"""
+ sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()])
+ del sync
+
+ self.plot1.getXAxis().setLimits(10, 500)
+ self.assertEqual(self.plot1.getXAxis().getLimits(), (10, 500))
+ self.assertNotEqual(self.plot2.getXAxis().getLimits(), (10, 500))
+ self.assertNotEqual(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()])
+ sync.stop()
+
+ self.plot1.getXAxis().setLimits(10, 500)
+ self.assertEqual(self.plot1.getXAxis().getLimits(), (10, 500))
+ self.assertNotEqual(self.plot2.getXAxis().getLimits(), (10, 500))
+ self.assertNotEqual(self.plot3.getXAxis().getLimits(), (10, 500))
+
+ def testStopMovingStart(self):
+ """Test synchronization after calling stop, moving an axis, then start again"""
+ sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()])
+ sync.stop()
+ self.plot1.getXAxis().setLimits(10, 500)
+ self.plot2.getXAxis().setLimits(1, 50)
+ self.assertEqual(self.plot1.getXAxis().getLimits(), (10, 500))
+ sync.start()
+
+ # The first axis is the reference
+ self.assertEqual(self.plot1.getXAxis().getLimits(), (10, 500))
+ self.assertEqual(self.plot2.getXAxis().getLimits(), (10, 500))
+ self.assertEqual(self.plot3.getXAxis().getLimits(), (10, 500))
+
+ def testDoubleStop(self):
+ """Test double stop"""
+ sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()])
+ sync.stop()
+ self.assertRaises(RuntimeError, sync.stop)
+
+ def testDoubleStart(self):
+ """Test double stop"""
+ sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()])
+ self.assertRaises(RuntimeError, sync.start)
+
+ def testScale(self):
+ """Test scale change"""
+ _sync = SyncAxes([self.plot1.getXAxis(), self.plot2.getXAxis(), self.plot3.getXAxis()])
+ self.plot1.getXAxis().setScale(self.plot1.getXAxis().LOGARITHMIC)
+ self.assertEqual(self.plot1.getXAxis().getScale(), self.plot1.getXAxis().LOGARITHMIC)
+ self.assertEqual(self.plot2.getXAxis().getScale(), self.plot1.getXAxis().LOGARITHMIC)
+ self.assertEqual(self.plot3.getXAxis().getScale(), self.plot1.getXAxis().LOGARITHMIC)
+
+ def testDirection(self):
+ """Test direction change"""
+ _sync = SyncAxes([self.plot1.getYAxis(), self.plot2.getYAxis(), self.plot3.getYAxis()])
+ self.plot1.getYAxis().setInverted(True)
+ self.assertEqual(self.plot1.getYAxis().isInverted(), True)
+ self.assertEqual(self.plot2.getYAxis().isInverted(), True)
+ self.assertEqual(self.plot3.getYAxis().isInverted(), True)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loadTests(TestAxisSync))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/utils.py b/silx/gui/plot/test/utils.py
new file mode 100644
index 0000000..ef547c6
--- /dev/null
+++ b/silx/gui/plot/test/utils.py
@@ -0,0 +1,194 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Basic tests for PlotWidget"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "01/09/2017"
+
+
+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__)
+
+
+class PlotWidgetTestCase(TestCaseQt):
+ """Base class for tests of PlotWidget, not a TestCase in itself.
+
+ plot attribute is the PlotWidget created for the test.
+ """
+
+ def __init__(self, methodName='runTest'):
+ TestCaseQt.__init__(self, methodName=methodName)
+ self.__mousePos = None
+
+ def _createPlot(self):
+ return PlotWidget()
+
+ def setUp(self):
+ super(PlotWidgetTestCase, self).setUp()
+ self.plot = self._createPlot()
+ self.plot.show()
+ self.plotAlive = True
+ self.qWaitForWindowExposed(self.plot)
+ TestCaseQt.mouseClick(self, self.plot, button=qt.Qt.LeftButton, pos=(0, 0))
+
+ def __onPlotDestroyed(self):
+ self.plotAlive = False
+
+ def _waitForPlotClosed(self):
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.destroyed.connect(self.__onPlotDestroyed)
+ self.plot.close()
+ del self.plot
+ for _ in range(100):
+ if not self.plotAlive:
+ break
+ self.qWait(10)
+ else:
+ logger.error("Plot is still alive")
+
+ def tearDown(self):
+ 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/__init__.py b/silx/gui/plot/utils/__init__.py
new file mode 100644
index 0000000..3187f6b
--- /dev/null
+++ b/silx/gui/plot/utils/__init__.py
@@ -0,0 +1,30 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Utils module for plot.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "29/06/2017"
diff --git a/silx/gui/plot/utils/axis.py b/silx/gui/plot/utils/axis.py
new file mode 100644
index 0000000..f7ec711
--- /dev/null
+++ b/silx/gui/plot/utils/axis.py
@@ -0,0 +1,164 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module contains utils class for axes management.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "04/08/2017"
+
+import functools
+import logging
+from contextlib import contextmanager
+from silx.utils import weakref
+
+_logger = logging.getLogger(__name__)
+
+
+class SyncAxes(object):
+ """Synchronize a set of plot axes together.
+
+ It is created with the expected axes and starts to synchronize them.
+
+ It can be customized to synchronize limits, scale, and direction of axes
+ together. By default everything is synchronized.
+
+ The API :meth:`start` and :meth:`stop` can be used to enable/disable the
+ synchronization while this object is still alive.
+
+ If this object is destroyed the synchronization stop.
+
+ .. versionadded:: 0.6
+ """
+
+ def __init__(self, axes, syncLimits=True, syncScale=True, syncDirection=True):
+ """
+ Constructor
+
+ :param list(Axis) axes: A list of axes to synchronize together
+ :param bool syncLimits: Synchronize axes limits
+ :param bool syncScale: Synchronize axes scale
+ :param bool syncDirection: Synchronize axes direction
+ """
+ object.__init__(self)
+ self.__axes = []
+ self.__locked = False
+ self.__syncLimits = syncLimits
+ self.__syncScale = syncScale
+ self.__syncDirection = syncDirection
+ self.__callbacks = []
+
+ self.__axes.extend(axes)
+ self.start()
+
+ def start(self):
+ """Start synchronizing axes together.
+
+ The first axis is used as the reference for the first synchronization.
+ After that, any changes to any axes will be used to synchronize other
+ axes.
+ """
+ if len(self.__callbacks) != 0:
+ raise RuntimeError("Axes already synchronized")
+
+ # register callback for further sync
+ for axis in self.__axes:
+ if self.__syncLimits:
+ # the weakref is needed to be able ignore self references
+ callback = weakref.WeakMethodProxy(self.__axisLimitsChanged)
+ callback = functools.partial(callback, axis)
+ sig = axis.sigLimitsChanged
+ sig.connect(callback)
+ self.__callbacks.append((sig, callback))
+ if self.__syncScale:
+ # the weakref is needed to be able ignore self references
+ callback = weakref.WeakMethodProxy(self.__axisScaleChanged)
+ callback = functools.partial(callback, axis)
+ sig = axis.sigScaleChanged
+ sig.connect(callback)
+ self.__callbacks.append((sig, callback))
+ if self.__syncDirection:
+ # the weakref is needed to be able ignore self references
+ callback = weakref.WeakMethodProxy(self.__axisInvertedChanged)
+ callback = functools.partial(callback, axis)
+ sig = axis.sigInvertedChanged
+ sig.connect(callback)
+ self.__callbacks.append((sig, callback))
+
+ # sync the current state
+ mainAxis = self.__axes[0]
+ if self.__syncLimits:
+ self.__axisLimitsChanged(mainAxis, *mainAxis.getLimits())
+ if self.__syncScale:
+ self.__axisScaleChanged(mainAxis, mainAxis.getScale())
+ if self.__syncDirection:
+ self.__axisInvertedChanged(mainAxis, mainAxis.isInverted())
+
+ def stop(self):
+ """Stop the synchronization of the axes"""
+ if len(self.__callbacks) == 0:
+ raise RuntimeError("Axes not synchronized")
+ for sig, callback in self.__callbacks:
+ sig.disconnect(callback)
+ self.__callbacks = []
+
+ def __del__(self):
+ """Destructor"""
+ # clean up references
+ if len(self.__callbacks) != 0:
+ self.stop()
+
+ @contextmanager
+ def __inhibitSignals(self):
+ self.__locked = True
+ yield
+ self.__locked = False
+
+ def __otherAxes(self, changedAxis):
+ for axis in self.__axes:
+ if axis is changedAxis:
+ continue
+ yield axis
+
+ def __axisLimitsChanged(self, changedAxis, vmin, vmax):
+ if self.__locked:
+ return
+ with self.__inhibitSignals():
+ for axis in self.__otherAxes(changedAxis):
+ axis.setLimits(vmin, vmax)
+
+ def __axisScaleChanged(self, changedAxis, scale):
+ if self.__locked:
+ return
+ with self.__inhibitSignals():
+ for axis in self.__otherAxes(changedAxis):
+ axis.setScale(scale)
+
+ def __axisInvertedChanged(self, changedAxis, isInverted):
+ if self.__locked:
+ return
+ with self.__inhibitSignals():
+ for axis in self.__otherAxes(changedAxis):
+ axis.setInverted(isInverted)
diff --git a/silx/gui/plot3d/Plot3DWidget.py b/silx/gui/plot3d/Plot3DWidget.py
index 9c9da0c..aae3955 100644
--- a/silx/gui/plot3d/Plot3DWidget.py
+++ b/silx/gui/plot3d/Plot3DWidget.py
@@ -35,10 +35,10 @@ import logging
from silx.gui import qt
from silx.gui.plot.Colors import rgba
-from silx.gui.plot3d import Plot3DActions
+from . import actions
from .._utils import convertArrayToQImage
-from .._glutils import gl
+from .. import _glutils as glu
from .scene import interaction, primitives, transform
from . import scene
@@ -79,31 +79,29 @@ class _OverviewViewport(scene.Viewport):
source.extrinsic.direction, source.extrinsic.up)
-class Plot3DWidget(qt.QGLWidget):
- """QGLWidget with a 3D viewport and an overview."""
+class Plot3DWidget(glu.OpenGLWidget):
+ """OpenGL widget with a 3D viewport and an overview."""
- def __init__(self, parent=None):
- if not qt.QGLFormat.hasOpenGL(): # Check if any OpenGL is available
- raise RuntimeError(
- 'OpenGL is not available on this platform: 3D disabled')
+ sigInteractiveModeChanged = qt.Signal()
+ """Signal emitted when the interactive mode has changed
+ """
- self._devicePixelRatio = 1.0 # Store GL canvas/QWidget ratio
- self._isOpenGL21 = False
+ def __init__(self, parent=None, f=qt.Qt.WindowFlags()):
self._firstRender = True
- format_ = qt.QGLFormat()
- format_.setRgba(True)
- format_.setDepth(False)
- format_.setStencil(False)
- format_.setVersion(2, 1)
- format_.setDoubleBuffer(True)
+ super(Plot3DWidget, self).__init__(
+ parent,
+ alphaBufferSize=8,
+ depthBufferSize=0,
+ stencilBufferSize=0,
+ version=(2, 1),
+ f=f)
- super(Plot3DWidget, self).__init__(format_, parent)
self.setAutoFillBackground(False)
self.setMouseTracking(True)
self.setFocusPolicy(qt.Qt.StrongFocus)
- self._copyAction = Plot3DActions.CopyAction(parent=self, plot3d=self)
+ self._copyAction = actions.io.CopyAction(parent=self, plot3d=self)
self.addAction(self._copyAction)
self._updating = False # True if an update is requested
@@ -112,8 +110,8 @@ class Plot3DWidget(qt.QGLWidget):
self.viewport = scene.Viewport()
self.viewport.background = 0.2, 0.2, 0.2, 1.
- sceneScale = transform.Scale(1., 1., 1.)
- self.viewport.scene.transforms = [sceneScale,
+ self._sceneScale = transform.Scale(1., 1., 1.)
+ self.viewport.scene.transforms = [self._sceneScale,
transform.Translate(0., 0., 0.)]
# Overview area
@@ -122,15 +120,56 @@ class Plot3DWidget(qt.QGLWidget):
self.setBackgroundColor((0.2, 0.2, 0.2, 1.))
# Window describing on screen area to render
- self.window = scene.Window(mode='framebuffer')
- self.window.viewports = [self.viewport, self.overview]
+ self._window = scene.Window(mode='framebuffer')
+ self._window.viewports = [self.viewport, self.overview]
+ self._window.addListener(self._redraw)
+
+ self.eventHandler = None
+ self.setInteractiveMode('rotate')
+
+ def setInteractiveMode(self, mode):
+ """Set the interactive mode.
+
+ :param str mode: The interactive mode: 'rotate', 'pan' or None
+ """
+ if mode == self.getInteractiveMode():
+ return
+
+ if mode is None:
+ self.eventHandler = None
+
+ elif mode == 'rotate':
+ self.eventHandler = interaction.RotateCameraControl(
+ self.viewport,
+ orbitAroundCenter=False,
+ mode='position',
+ scaleTransform=self._sceneScale)
+
+ elif mode == 'pan':
+ self.eventHandler = interaction.PanCameraControl(
+ self.viewport,
+ mode='position',
+ scaleTransform=self._sceneScale,
+ selectCB=None)
+
+ else:
+ raise ValueError('Unsupported interactive mode %s', str(mode))
+
+ self.sigInteractiveModeChanged.emit()
- self.eventHandler = interaction.CameraControl(
- self.viewport, orbitAroundCenter=False,
- mode='position', scaleTransform=sceneScale,
- selectCB=None)
+ def getInteractiveMode(self):
+ """Returns the interactive mode in use.
- self.viewport.addListener(self._redraw)
+ :rtype: str
+ """
+ if self.eventHandler is None:
+ return None
+ if isinstance(self.eventHandler, interaction.RotateCameraControl):
+ return 'rotate'
+ elif isinstance(self.eventHandler, interaction.PanCameraControl):
+ return 'pan'
+ else:
+ return None
def setProjection(self, projection):
"""Change the projection in use.
@@ -176,6 +215,25 @@ class Plot3DWidget(qt.QGLWidget):
"""Returns the RGBA background color (QColor)."""
return qt.QColor.fromRgbF(*self.viewport.background)
+ def isOrientationIndicatorVisible(self):
+ """Returns True if the orientation indicator is displayed.
+
+ :rtype: bool
+ """
+ return self.overview in self._window.viewports
+
+ def setOrientationIndicatorVisible(self, visible):
+ """Set the orientation indicator visibility.
+
+ :param bool visible: True to show
+ """
+ visible = bool(visible)
+ if visible != self.isOrientationIndicatorVisible():
+ if visible:
+ self._window.viewports = [self.viewport, self.overview]
+ else:
+ self._window.viewports = [self.viewport]
+
def centerScene(self):
"""Position the center of the scene at the center of rotation."""
self.viewport.resetCamera()
@@ -192,7 +250,7 @@ class Plot3DWidget(qt.QGLWidget):
def _redraw(self, source=None):
"""Viewport listener to require repaint"""
- if not self._updating and self.viewport.dirty:
+ if not self._updating:
self._updating = True # Mark that an update is requested
self.update() # Queued repaint (i.e., asynchronous)
@@ -200,52 +258,18 @@ class Plot3DWidget(qt.QGLWidget):
return qt.QSize(400, 300)
def initializeGL(self):
- # Check if OpenGL2 is available
- versionflags = self.format().openGLVersionFlags()
- self._isOpenGL21 = bool(versionflags & qt.QGLFormat.OpenGL_Version_2_1)
- if not self._isOpenGL21:
- _logger.error(
- '3D rendering is disabled: OpenGL 2.1 not available')
-
- messageBox = qt.QMessageBox(parent=self)
- messageBox.setIcon(qt.QMessageBox.Critical)
- messageBox.setWindowTitle('Error')
- messageBox.setText('3D rendering is disabled.\n\n'
- 'Reason: OpenGL 2.1 is not available.')
- messageBox.addButton(qt.QMessageBox.Ok)
- messageBox.setWindowModality(qt.Qt.WindowModal)
- messageBox.setAttribute(qt.Qt.WA_DeleteOnClose)
- messageBox.show()
+ pass
def paintGL(self):
# In case paintGL is called by the system and not through _redraw,
# Mark as updating.
self._updating = True
- if hasattr(self, 'windowHandle'): # Qt 5
- devicePixelRatio = self.windowHandle().devicePixelRatio()
- if devicePixelRatio != self._devicePixelRatio:
- # Move window from one screen to another one
- self._devicePixelRatio = devicePixelRatio
- # Resize might not be called, so call it explicitly
- self.resizeGL(int(self.width() * devicePixelRatio),
- int(self.height() * devicePixelRatio))
-
- if not self._isOpenGL21:
- # Cannot render scene, just clear the color buffer.
- ox, oy = self.viewport.origin
- w, h = self.viewport.size
- gl.glViewport(ox, oy, w, h)
+ # Update near and far planes only if viewport needs refresh
+ if self.viewport.dirty:
+ self.viewport.adjustCameraDepthExtent()
- gl.glClearColor(*self.viewport.background)
- gl.glClear(gl.GL_COLOR_BUFFER_BIT)
-
- else:
- # Update near and far planes only if viewport needs refresh
- if self.viewport.dirty:
- self.viewport.adjustCameraDepthExtent()
-
- self.window.render(self.context(), self._devicePixelRatio)
+ self._window.render(self.context(), self.getDevicePixelRatio())
if self._firstRender: # TODO remove this ugly hack
self._firstRender = False
@@ -253,8 +277,10 @@ class Plot3DWidget(qt.QGLWidget):
self._updating = False
def resizeGL(self, width, height):
- self.window.size = width, height
- self.viewport.size = width, height
+ width *= self.getDevicePixelRatio()
+ height *= self.getDevicePixelRatio()
+ self._window.size = width, height
+ self.viewport.size = self._window.size
overviewWidth, overviewHeight = self.overview.size
self.overview.origin = width - overviewWidth, height - overviewHeight
@@ -264,27 +290,27 @@ class Plot3DWidget(qt.QGLWidget):
:returns: OpenGL scene RGB rasterization
:rtype: QImage
"""
- if not self._isOpenGL21:
+ if not self.isValid():
_logger.error('OpenGL 2.1 not available, cannot save OpenGL image')
- height, width = self.window.shape
+ height, width = self._window.shape
image = numpy.zeros((height, width, 3), dtype=numpy.uint8)
else:
self.makeCurrent()
- image = self.window.grab(qt.QGLContext.currentContext())
+ image = self._window.grab(self.context())
return convertArrayToQImage(image)
def wheelEvent(self, event):
- xpixel = event.x() * self._devicePixelRatio
- ypixel = event.y() * self._devicePixelRatio
+ xpixel = event.x() * self.getDevicePixelRatio()
+ ypixel = event.y() * self.getDevicePixelRatio()
if hasattr(event, 'delta'): # Qt4
angle = event.delta() / 8.
else: # Qt5
angle = event.angleDelta().y() / 8.
event.accept()
- if angle != 0:
+ if self.eventHandler is not None and angle != 0 and self.isValid():
self.makeCurrent()
self.eventHandler.handleEvent('wheel', xpixel, ypixel, angle)
@@ -315,27 +341,30 @@ class Plot3DWidget(qt.QGLWidget):
_MOUSE_BTNS = {1: 'left', 2: 'right', 4: 'middle'}
def mousePressEvent(self, event):
- xpixel = event.x() * self._devicePixelRatio
- ypixel = event.y() * self._devicePixelRatio
+ xpixel = event.x() * self.getDevicePixelRatio()
+ ypixel = event.y() * self.getDevicePixelRatio()
btn = self._MOUSE_BTNS[event.button()]
event.accept()
- self.makeCurrent()
- self.eventHandler.handleEvent('press', xpixel, ypixel, btn)
+ if self.eventHandler is not None and self.isValid():
+ self.makeCurrent()
+ self.eventHandler.handleEvent('press', xpixel, ypixel, btn)
def mouseMoveEvent(self, event):
- xpixel = event.x() * self._devicePixelRatio
- ypixel = event.y() * self._devicePixelRatio
+ xpixel = event.x() * self.getDevicePixelRatio()
+ ypixel = event.y() * self.getDevicePixelRatio()
event.accept()
- self.makeCurrent()
- self.eventHandler.handleEvent('move', xpixel, ypixel)
+ if self.eventHandler is not None and self.isValid():
+ self.makeCurrent()
+ self.eventHandler.handleEvent('move', xpixel, ypixel)
def mouseReleaseEvent(self, event):
- xpixel = event.x() * self._devicePixelRatio
- ypixel = event.y() * self._devicePixelRatio
+ xpixel = event.x() * self.getDevicePixelRatio()
+ ypixel = event.y() * self.getDevicePixelRatio()
btn = self._MOUSE_BTNS[event.button()]
event.accept()
- self.makeCurrent()
- self.eventHandler.handleEvent('release', xpixel, ypixel, btn)
+ if self.eventHandler is not None and self.isValid():
+ self.makeCurrent()
+ self.eventHandler.handleEvent('release', xpixel, ypixel, btn)
diff --git a/silx/gui/plot3d/Plot3DWindow.py b/silx/gui/plot3d/Plot3DWindow.py
index 4658d38..1bc2738 100644
--- a/silx/gui/plot3d/Plot3DWindow.py
+++ b/silx/gui/plot3d/Plot3DWindow.py
@@ -34,13 +34,13 @@ __date__ = "26/01/2017"
from silx.gui import qt
-from .Plot3DToolBar import Plot3DToolBar
from .Plot3DWidget import Plot3DWidget
-from .ViewpointToolBar import ViewpointToolBar
+from .tools import OutputToolBar, InteractiveModeToolBar
+from .tools import ViewpointToolButton
class Plot3DWindow(qt.QMainWindow):
- """QGLWidget with a 3D viewport and an overview."""
+ """OpenGL widget with a 3D viewport and an overview."""
def __init__(self, parent=None):
super(Plot3DWindow, self).__init__(parent)
@@ -50,9 +50,17 @@ class Plot3DWindow(qt.QMainWindow):
self._plot3D = Plot3DWidget()
self.setCentralWidget(self._plot3D)
- self.addToolBar(
- ViewpointToolBar(parent=self, plot3D=self._plot3D))
- toolbar = Plot3DToolBar(parent=self)
+
+ 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))
+ self.addToolBar(toolbar)
+
+ toolbar = OutputToolBar(parent=self)
toolbar.setPlot3DWidget(self._plot3D)
self.addToolBar(toolbar)
self.addActions(toolbar.actions())
diff --git a/silx/gui/plot3d/SFViewParamTree.py b/silx/gui/plot3d/SFViewParamTree.py
index 38d4e37..8b144df 100644
--- a/silx/gui/plot3d/SFViewParamTree.py
+++ b/silx/gui/plot3d/SFViewParamTree.py
@@ -30,15 +30,18 @@ from __future__ import absolute_import
__authors__ = ["D. N."]
__license__ = "MIT"
-__date__ = "10/01/2017"
+__date__ = "02/10/2017"
import logging
import sys
+import weakref
import numpy
from silx.gui import qt
from silx.gui.icons import getQIcon
+from silx.gui.plot.Colormap import Colormap
+from silx.gui.widgets.FloatEdit import FloatEdit
from .ScalarFieldView import Isosurface
@@ -111,14 +114,20 @@ class SubjectItem(qt.QStandardItem):
value = setValue
super(SubjectItem, self).setData(value, role)
- subject = property(lambda self: self.__subject)
+ @property
+ def subject(self):
+ """The subject this item is observing"""
+ return None if self.__subject is None else self.__subject()
@subject.setter
def subject(self, subject):
if self.__subject is not None:
raise ValueError('Subject already set '
' (subject change not supported).')
- self.__subject = subject
+ if subject is None:
+ self.__subject = None
+ else:
+ self.__subject = weakref.ref(subject)
if subject is not None:
self._init()
self._connectSignals()
@@ -343,6 +352,44 @@ class HighlightColorItem(ColorItem):
return self.subject.getHighlightColor()
+class BoundingBoxItem(SubjectItem):
+ """Bounding box, axes labels and grid visibility item.
+
+ Item is checkable.
+ """
+ itemName = 'Bounding Box'
+
+ def _init(self):
+ visible = self.subject.isBoundingBoxVisible()
+ self.setCheckable(True)
+ self.setCheckState(qt.Qt.Checked if visible else qt.Qt.Unchecked)
+
+ def leftClicked(self):
+ checked = (self.checkState() == qt.Qt.Checked)
+ if checked != self.subject.isBoundingBoxVisible():
+ self.subject.setBoundingBoxVisible(checked)
+
+
+class OrientationIndicatorItem(SubjectItem):
+ """Orientation indicator visibility item.
+
+ Item is checkable.
+ """
+ itemName = 'Axes indicator'
+
+ def _init(self):
+ plot3d = self.subject.getPlot3DWidget()
+ visible = plot3d.isOrientationIndicatorVisible()
+ self.setCheckable(True)
+ self.setCheckState(qt.Qt.Checked if visible else qt.Qt.Unchecked)
+
+ def leftClicked(self):
+ plot3d = self.subject.getPlot3DWidget()
+ checked = (self.checkState() == qt.Qt.Checked)
+ if checked != plot3d.isOrientationIndicatorVisible():
+ plot3d.setOrientationIndicatorVisible(checked)
+
+
class ViewSettingsItem(qt.QStandardItem):
"""Viewport settings"""
@@ -352,7 +399,9 @@ class ViewSettingsItem(qt.QStandardItem):
self.setEditable(False)
- classes = BackgroundColorItem, ForegroundColorItem, HighlightColorItem
+ classes = (BackgroundColorItem, ForegroundColorItem,
+ HighlightColorItem,
+ BoundingBoxItem, OrientationIndicatorItem)
for cls in classes:
titleItem = qt.QStandardItem(cls.itemName)
titleItem.setEditable(False)
@@ -534,8 +583,8 @@ class _IsoLevelSlider(qt.QSlider):
"""Set slider from iso-surface level"""
dataRange = self.subject.parent().getDataRange()
- if dataRange is not None and None not in dataRange:
- width = dataRange[1] - dataRange[0]
+ if dataRange is not None:
+ width = dataRange[-1] - dataRange[0]
if width > 0:
sliderWidth = self.maximum() - self.minimum()
sliderPosition = sliderWidth * (level - dataRange[0]) / width
@@ -548,10 +597,12 @@ class _IsoLevelSlider(qt.QSlider):
def __sliderReleased(self):
value = self.value()
dataRange = self.subject.parent().getDataRange()
- width = dataRange[1] - dataRange[0]
- sliderWidth = self.maximum() - self.minimum()
- level = dataRange[0] + width * value / sliderWidth
- self.subject.setLevel(level)
+ if dataRange is not None:
+ min_, _, max_ = dataRange
+ width = max_ - min_
+ sliderWidth = self.maximum() - self.minimum()
+ level = min_ + width * value / sliderWidth
+ self.subject.setLevel(level)
class IsoSurfaceLevelSlider(IsoSurfaceLevelItem):
@@ -771,7 +822,8 @@ class IsoSurfaceAddRemoveWidget(qt.QWidget):
if dataRange is None:
dataRange = [0, 1]
- sfview.addIsosurface(numpy.mean(dataRange), '#0000FF')
+ sfview.addIsosurface(
+ numpy.mean((dataRange[0], dataRange[-1])), '#0000FF')
def __removeClicked(self):
self.sigViewTask.emit('remove_iso')
@@ -867,30 +919,24 @@ class PlaneMinRangeItem(ColormapBase):
self._setVMin(value)
def _setVMin(self, value):
- cutPlane = self.subject.getCutPlanes()[0]
- colormap = cutPlane.getColormap()
+ colormap = self.subject.getCutPlanes()[0].getColormap()
vMin = value
vMax = colormap.getVMax()
if vMax is not None and value > vMax:
vMin = vMax
vMax = value
- cutPlane.setColormap(name=colormap.getName(),
- norm=colormap.getNorm(),
- vmin=vMin,
- vmax=vMax)
+ colormap.setVRange(vMin, vMax)
def getEditor(self, parent, option, index):
- editor = qt.QLineEdit(parent)
- editor.setValidator(qt.QDoubleValidator())
- return editor
+ return FloatEdit(parent)
def setEditorData(self, editor):
- editor.setText(str(self._pullData()))
+ editor.setValue(self._pullData())
return True
def _setModelData(self, editor):
- value = float(editor.text())
+ value = editor.value()
self._setVMin(value)
return True
@@ -910,29 +956,23 @@ class PlaneMaxRangeItem(ColormapBase):
return self.subject.getCutPlanes()[0].getColormap().getVMax()
def _setVMax(self, value):
- cutPlane = self.subject.getCutPlanes()[0]
- colormap = cutPlane.getColormap()
+ colormap = self.subject.getCutPlanes()[0].getColormap()
vMin = colormap.getVMin()
vMax = value
if vMin is not None and value < vMin:
vMax = vMin
vMin = value
- cutPlane.setColormap(name=colormap.getName(),
- norm=colormap.getNorm(),
- vmin=vMin,
- vmax=vMax)
+ colormap.setVRange(vMin, vMax)
def getEditor(self, parent, option, index):
- editor = qt.QLineEdit(parent)
- editor.setValidator(qt.QDoubleValidator())
- return editor
+ return FloatEdit(parent)
def setEditorData(self, editor):
editor.setText(str(self._pullData()))
return True
def _setModelData(self, editor):
- value = float(editor.text())
+ value = editor.value()
self._setVMax(value)
return True
@@ -1028,7 +1068,8 @@ class PlaneColormapItem(ColormapBase):
listValues = ['gray', 'reversed gray',
'temperature', 'red',
- 'green', 'blue']
+ 'green', 'blue',
+ 'viridis', 'magma', 'inferno', 'plasma']
def getEditor(self, parent, option, index):
editor = qt.QComboBox(parent)
@@ -1038,17 +1079,18 @@ class PlaneColormapItem(ColormapBase):
return editor
def __editorChanged(self, index):
- colorMapName = self.listValues[index]
- colorMap = self.subject.getCutPlanes()[0].getColormap()
- self.subject.getCutPlanes()[0].setColormap(name=colorMapName,
- norm=colorMap.getNorm(),
- vmin=colorMap.getVMin(),
- vmax=colorMap.getVMax())
+ colormapName = self.listValues[index]
+ colormap = self.subject.getCutPlanes()[0].getColormap()
+ colormap.setName(colormapName)
def setEditorData(self, editor):
colormapName = self.subject.getCutPlanes()[0].getColormap().getName()
- index = self.listValues.index(colormapName)
- editor.setCurrentIndex(index)
+ try:
+ index = self.listValues.index(colormapName)
+ except ValueError:
+ _logger.error('Unsupported colormap: %s', colormapName)
+ else:
+ editor.setCurrentIndex(index)
return True
def _setModelData(self, editor):
@@ -1078,22 +1120,18 @@ class PlaneAutoScaleItem(ColormapBase):
def _setAutoScale(self, auto):
view3d = self.subject
- cutPlane = view3d.getCutPlanes()[0]
- colormap = cutPlane.getColormap()
+ colormap = view3d.getCutPlanes()[0].getColormap()
if auto != colormap.isAutoscale():
if auto:
vMin = vMax = None
else:
dataRange = view3d.getDataRange()
- if dataRange is None or None in dataRange:
+ if dataRange is None:
vMin = vMax = None
else:
- vMin, vMax = dataRange
- cutPlane.setColormap(colormap.getName(),
- colormap.getNorm(),
- vMin,
- vMax)
+ vMin, vMax = dataRange[0], dataRange[-1]
+ colormap.setVRange(vMin, vMax)
def _pullData(self):
auto = self.subject.getCutPlanes()[0].getColormap().isAutoscale()
@@ -1111,7 +1149,7 @@ class NormalizationNode(ColormapBase):
Item is a QComboBox.
"""
editable = True
- listValues = ['linear', 'log']
+ listValues = list(Colormap.NORMALIZATIONS)
def getEditor(self, parent, option, index):
editor = qt.QComboBox(parent)
@@ -1129,7 +1167,7 @@ class NormalizationNode(ColormapBase):
vmax=colorMap.getVMax())
def setEditorData(self, editor):
- normalization = self.subject.getCutPlanes()[0].getColormap().getNorm()
+ normalization = self.subject.getCutPlanes()[0].getColormap().getNormalization()
index = self.listValues.index(normalization)
editor.setCurrentIndex(index)
return True
@@ -1139,7 +1177,7 @@ class NormalizationNode(ColormapBase):
return True
def _pullData(self):
- return self.subject.getCutPlanes()[0].getColormap().getNorm()
+ return self.subject.getCutPlanes()[0].getColormap().getNormalization()
class PlaneGroup(SubjectItem):
diff --git a/silx/gui/plot3d/ScalarFieldView.py b/silx/gui/plot3d/ScalarFieldView.py
index 2eb54a3..6a4d9d4 100644
--- a/silx/gui/plot3d/ScalarFieldView.py
+++ b/silx/gui/plot3d/ScalarFieldView.py
@@ -41,15 +41,17 @@ from collections import deque
import numpy
-from silx.gui import qt
+from silx.gui import qt, icons
from silx.gui.plot.Colors import rgba
+from silx.gui.plot.Colormap import Colormap
from silx.math.marchingcubes import MarchingCubes
+from silx.math.combo import min_max
-from .scene import axes, cutplane, function, interaction, primitives, transform
+from .scene import axes, cutplane, interaction, primitives, transform
from . import scene
from .Plot3DWindow import Plot3DWindow
-
+from .tools import InteractiveModeToolBar
_logger = logging.getLogger(__name__)
@@ -245,7 +247,7 @@ class Isosurface(qt.QObject):
self._level = level
self.sigLevelChanged.emit(level)
- if numpy.isnan(self._level):
+ if not numpy.isfinite(self._level):
return
st = time.time()
@@ -265,48 +267,6 @@ class Isosurface(qt.QObject):
self._group.children = [mesh]
-class Colormap(object):
- """Description of a colormap
-
- :param str name: Name of the colormap
- :param str norm: Normalization: 'linear' (default) or 'log'
- :param float vmin:
- Lower bound of the colormap or None for autoscale (default)
- :param float vmax:
- Upper bounds of the colormap or None for autoscale (default)
- """
-
- def __init__(self, name, norm='linear', vmin=None, vmax=None):
- assert name in function.Colormap.COLORMAPS
- self._name = str(name)
-
- assert norm in ('linear', 'log')
- self._norm = str(norm)
-
- self._vmin = float(vmin) if vmin is not None else None
- self._vmax = float(vmax) if vmax is not None else None
-
- def isAutoscale(self):
- """True if both min and max are in autoscale mode"""
- return self._vmin is None or self._vmax is None
-
- def getName(self):
- """Return the name of the colormap (str)"""
- return self._name
-
- def getNorm(self):
- """Return the normalization of the colormap (str)"""
- return self._norm
-
- def getVMin(self):
- """Return the lower bound of the colormap or None"""
- return self._vmin
-
- def getVMax(self):
- """Return the upper bounds of the colormap or None"""
- return self._vmax
-
-
class SelectedRegion(object):
"""Selection of a 3D region aligned with the axis.
@@ -391,7 +351,7 @@ class CutPlane(qt.QObject):
sigPlaneChanged = qt.Signal()
"""Signal emitted when the cut plane has moved"""
- sigColormapChanged = qt.Signal(object)
+ sigColormapChanged = qt.Signal(Colormap)
"""Signal emitted when the colormap has changed
This signal provides the new colormap.
@@ -406,11 +366,7 @@ class CutPlane(qt.QObject):
def __init__(self, sfView):
super(CutPlane, self).__init__(parent=sfView)
- self._colormap = Colormap(
- name='gray', norm='linear', vmin=None, vmax=None)
-
self._dataRange = None
- self._positiveMin = None
self._plane = cutplane.CutPlane(normal=(0, 1, 0))
self._plane.alpha = 1.
@@ -418,6 +374,11 @@ class CutPlane(qt.QObject):
self._plane.addListener(self._planeChanged)
self._plane.plane.addListener(self._planePositionChanged)
+ self._colormap = Colormap(
+ name='gray', normalization='linear', vmin=None, vmax=None)
+ self.getColormap().sigChanged.connect(self._colormapChanged)
+ self._updateSceneColormap()
+
sfView.sigDataChanged.connect(self._sfViewDataChanged)
def _get3DPrimitive(self):
@@ -427,13 +388,15 @@ class CutPlane(qt.QObject):
def _sfViewDataChanged(self):
"""Handle data change in the ScalarFieldView this plane belongs to"""
self._plane.setData(self.sender().getData(), copy=False)
+
+ # Store data range info as 3-tuple of values
self._dataRange = self.sender().getDataRange()
- self._positiveMin = None
+
self.sigDataChanged.emit()
# Update colormap range when autoscale
if self.getColormap().isAutoscale():
- self._updateColormapRange()
+ self._updateSceneColormap()
def _planeChanged(self, source, *args, **kwargs):
"""Handle events from the plane primitive"""
@@ -565,7 +528,7 @@ class CutPlane(qt.QObject):
# self._plane.alpha = alpha
def getColormap(self):
- """Returns the colormap set by :meth:`getColormap`.
+ """Returns the colormap set by :meth:`setColormap`.
:return: The colormap
:rtype: Colormap
@@ -574,25 +537,38 @@ class CutPlane(qt.QObject):
def setColormap(self,
name='gray',
- norm='linear',
+ norm=None,
vmin=None,
vmax=None):
"""Set the colormap to use.
- :param str name: Name of the colormap in
+ By either providing a :class:`Colormap` object or
+ its name, normalization and range.
+
+ :param name: Name of the colormap in
'gray', 'reversed gray', 'temperature', 'red', 'green', 'blue'.
+ Or Colormap object.
+ :type name: str or 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
"""
_logger.debug('setColormap %s %s (%s, %s)',
- name, norm, str(vmin), str(vmax))
+ name, str(norm), str(vmin), str(vmax))
- self._colormap = Colormap(
- name=name, norm=norm, vmin=vmin, vmax=vmax)
+ self._colormap.sigChanged.disconnect(self._colormapChanged)
- self._updateColormapRange()
- self.sigColormapChanged.emit(self.getColormap())
+ if isinstance(name, Colormap): # Use it as it is
+ assert (norm, vmin, vmax) == (None, None, None)
+ self._colormap = name
+ else:
+ if norm is None:
+ norm = 'linear'
+ self._colormap = Colormap(
+ name=name, normalization=norm, vmin=vmin, vmax=vmax)
+
+ self._colormap.sigChanged.connect(self._colormapChanged)
+ self._colormapChanged()
def getColormapEffectiveRange(self):
"""Returns the currently used range of the colormap.
@@ -604,35 +580,29 @@ class CutPlane(qt.QObject):
"""
return self._plane.colormap.range_
- def _updateColormapRange(self):
- """Update the colormap range"""
+ def _updateSceneColormap(self):
+ """Synchronizes scene's colormap with Colormap object"""
colormap = self.getColormap()
-
- self._plane.colormap.name = colormap.getName()
- if colormap.isAutoscale():
- range_ = self._dataRange
- if range_ is None: # No data, use a default range
- range_ = 1., 10.
- else:
- range_ = colormap.getVMin(), colormap.getVMax()
-
- if colormap.getNorm() == 'linear':
- self._plane.colormap.norm = 'linear'
- self._plane.colormap.range_ = range_
-
- else: # Log
- # Make sure range is strictly positive
- if range_[0] <= 0.:
- data = self._plane.getData(copy=False)
- if data is not None:
- if self._positiveMin is None:
- # TODO compute this with the range as a combo operation
- self._positiveMin = numpy.min(data[data > 0.])
- range_ = (self._positiveMin,
- max(range_[1], self._positiveMin))
-
- self._plane.colormap.range_ = range_
- self._plane.colormap.norm = colormap.getNorm()
+ sceneCMap = self._plane.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.norm = colormap.getNormalization()
+ range_ = colormap.getColormapRange(data=self._dataRange)
+ sceneCMap.range_ = range_
+
+ def _colormapChanged(self):
+ """Handle update of Colormap object"""
+ self._updateSceneColormap()
+ # Forward colormap changed event
+ self.sigColormapChanged.emit(self.getColormap())
class _CutPlaneImage(object):
@@ -766,7 +736,7 @@ class ScalarFieldView(Plot3DWindow):
def __init__(self, parent=None):
super(ScalarFieldView, self).__init__(parent)
self._colormap = Colormap(
- name='gray', norm='linear', vmin=None, vmax=None)
+ name='gray', normalization='linear', vmin=None, vmax=None)
self._selectedRange = None
# Store iso-surfaces
@@ -815,7 +785,7 @@ class ScalarFieldView(Plot3DWindow):
self._bbox.children = [self._group]
self.getPlot3DWidget().viewport.scene.children.append(self._bbox)
- self._initInteractionToolBar()
+ self._initPanPlaneAction()
self._updateColors()
@@ -958,81 +928,71 @@ class ScalarFieldView(Plot3DWindow):
raise ValueError('Unknown entry tag {0}.'
''.format(itemId))
- def _initInteractionToolBar(self):
- self._interactionToolbar = qt.QToolBar()
- self._interactionToolbar.setEnabled(False)
-
- group = qt.QActionGroup(self._interactionToolbar)
- group.setExclusive(True)
-
- self._cameraAction = qt.QAction(None)
- self._cameraAction.setText('camera')
- self._cameraAction.setCheckable(True)
- self._cameraAction.setToolTip('Control camera')
- self._cameraAction.setChecked(True)
- group.addAction(self._cameraAction)
-
- self._planeAction = qt.QAction(None)
- self._planeAction.setText('plane')
- self._planeAction.setCheckable(True)
- self._planeAction.setToolTip('Control cutting plane')
- group.addAction(self._planeAction)
- group.triggered.connect(self._interactionChanged)
-
- self._interactionToolbar.addActions(group.actions())
- self.addToolBar(self._interactionToolbar)
+ def _initPanPlaneAction(self):
+ """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.setCheckable(True)
+ self._panPlaneAction.setToolTip('pan the cutting plane')
+ self._panPlaneAction.setEnabled(False)
+
+ self._panPlaneAction.triggered[bool].connect(self._planeActionTriggered)
+ self.getPlot3DWidget().sigInteractiveModeChanged.connect(
+ self._interactiveModeChanged)
+
+ toolbar = self.findChild(InteractiveModeToolBar)
+ if toolbar is not None:
+ toolbar.addAction(self._panPlaneAction)
+
+ def _planeActionTriggered(self, checked=False):
+ self._panPlaneAction.setChecked(True)
+ self.setInteractiveMode('plane')
+
+ def _interactiveModeChanged(self):
+ self._panPlaneAction.setChecked(self.getInteractiveMode() == 'plane')
+ self._updateColors()
def _planeVisibilityChanged(self, visible):
"""Handle visibility events from the plane"""
- if visible != self._interactionToolbar.isEnabled():
+ if visible != self._panPlaneAction.isEnabled():
+ self._panPlaneAction.setEnabled(visible)
if visible:
- self._interactionToolbar.setEnabled(True)
self.setInteractiveMode('plane')
- else:
- self._interactionToolbar.setEnabled(False)
- self.setInteractiveMode('camera')
-
- def _interactionChanged(self, action):
- self.setInteractiveMode(action.text())
+ elif self._panPlaneAction.isChecked():
+ self.setInteractiveMode('rotate')
def setInteractiveMode(self, mode):
"""Choose the current interaction.
- :param str mode: Either plane or camera
+ :param str mode: Either rotate, pan or plane
"""
if mode == self.getInteractiveMode():
return
sceneScale = self.getPlot3DWidget().viewport.scene.transforms[0]
if mode == 'plane':
+ self.getPlot3DWidget().setInteractiveMode(None)
+
self.getPlot3DWidget().eventHandler = \
- interaction.PanPlaneRotateCameraControl(
+ interaction.PanPlaneZoomOnWheelControl(
self.getPlot3DWidget().viewport,
self._cutPlane._get3DPrimitive(),
mode='position',
scaleTransform=sceneScale)
- self._planeAction.setChecked(True)
- elif mode == 'camera':
- self.getPlot3DWidget().eventHandler = interaction.CameraControl(
- self.getPlot3DWidget().viewport, orbitAroundCenter=False,
- mode='position', scaleTransform=sceneScale,
- selectCB=None)
- self._cameraAction.setChecked(True)
else:
- raise ValueError('Unsupported interactive mode %s', str(mode))
+ self.getPlot3DWidget().setInteractiveMode(mode)
self._updateColors()
def getInteractiveMode(self):
"""Returns the current interaction mode, see :meth:`setInteractiveMode`
"""
- if isinstance(self.getPlot3DWidget().eventHandler,
- interaction.PanPlaneRotateCameraControl):
+ if (isinstance(self.getPlot3DWidget().eventHandler,
+ interaction.PanPlaneZoomOnWheelControl) or
+ self.getPlot3DWidget().eventHandler is None):
return 'plane'
- elif isinstance(self.getPlot3DWidget().eventHandler,
- interaction.CameraControl):
- return 'camera'
else:
- raise RuntimeError('Unknown interactive mode')
+ return self.getPlot3DWidget().getInteractiveMode()
# Handle scalar field
@@ -1063,7 +1023,18 @@ class ScalarFieldView(Plot3DWindow):
previousSelectedRegion = self.getSelectedRegion()
self._data = data
- self._dataRange = self._data.min(), self._data.max()
+
+ # 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
if previousSelectedRegion is not None:
# Update selected region to ensure it is clipped to array range
@@ -1094,7 +1065,12 @@ class ScalarFieldView(Plot3DWindow):
return numpy.array(self._data, copy=copy)
def getDataRange(self):
- """Return the range of the data as a 2-tuple (min, max)"""
+ """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
# Transformations
@@ -1135,6 +1111,20 @@ class ScalarFieldView(Plot3DWindow):
# Axes labels
+ def isBoundingBoxVisible(self):
+ """Returns axes labels, grid and bounding box visibility.
+
+ :rtype: bool
+ """
+ return self._bbox.boxVisible
+
+ def setBoundingBoxVisible(self, visible):
+ """Set axes labels, grid and bounding box visibility.
+
+ :param bool visible: True to show axes, False to hide
+ """
+ self._bbox.boxVisible = bool(visible)
+
def setAxesLabels(self, xlabel=None, ylabel=None, zlabel=None):
"""Set the text labels of the axes.
diff --git a/silx/gui/plot3d/__init__.py b/silx/gui/plot3d/__init__.py
index ad45424..af74613 100644
--- a/silx/gui/plot3d/__init__.py
+++ b/silx/gui/plot3d/__init__.py
@@ -25,7 +25,7 @@
"""
This package provides widgets displaying 3D content based on OpenGL.
-It depends on PyOpenGL and QtOpenGL.
+It depends on PyOpenGL and PyQtx.QtOpenGL or PyQt>=5.4.
"""
from __future__ import absolute_import
@@ -34,11 +34,6 @@ __license__ = "MIT"
__date__ = "18/01/2017"
-from .. import qt as _qt
-
-if not _qt.HAS_OPENGL:
- raise ImportError('Qt.QtOpenGL is not available')
-
try:
import OpenGL as _OpenGL
except ImportError:
diff --git a/silx/gui/plot3d/actions/Plot3DAction.py b/silx/gui/plot3d/actions/Plot3DAction.py
new file mode 100644
index 0000000..a1faaea
--- /dev/null
+++ b/silx/gui/plot3d/actions/Plot3DAction.py
@@ -0,0 +1,69 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Base class for QAction attached to a Plot3DWidget."""
+
+from __future__ import absolute_import, division
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "06/09/2017"
+
+
+import logging
+import weakref
+
+from silx.gui import qt
+
+
+_logger = logging.getLogger(__name__)
+
+
+class Plot3DAction(qt.QAction):
+ """QAction associated to a Plot3DWidget
+
+ :param parent: See :class:`QAction`
+ :param Plot3DWidget plot3d: Plot3DWidget the action is associated with
+ """
+
+ def __init__(self, parent, plot3d=None):
+ super(Plot3DAction, self).__init__(parent)
+ self._plot3d = None
+ self.setPlot3DWidget(plot3d)
+
+ def setPlot3DWidget(self, widget):
+ """Set the Plot3DWidget this action is associated with
+
+ :param Plot3DWidget widget: The Plot3DWidget to use
+ """
+ self._plot3d = None if widget is None else weakref.ref(widget)
+
+ def getPlot3DWidget(self):
+ """Return the Plot3DWidget associated to this action.
+
+ If no widget is associated, it returns None.
+
+ :rtype: qt.QWidget
+ """
+ return None if self._plot3d is None else self._plot3d()
diff --git a/silx/gui/plot3d/actions/__init__.py b/silx/gui/plot3d/actions/__init__.py
new file mode 100644
index 0000000..ebc57d2
--- /dev/null
+++ b/silx/gui/plot3d/actions/__init__.py
@@ -0,0 +1,33 @@
+# 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 QAction that can be attached to a plot3DWidget."""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "06/09/2017"
+
+from .Plot3DAction import Plot3DAction
+from . import io
+from . import mode
diff --git a/silx/gui/plot3d/Plot3DActions.py b/silx/gui/plot3d/actions/io.py
index 2ae2750..18f91b4 100644
--- a/silx/gui/plot3d/Plot3DActions.py
+++ b/silx/gui/plot3d/actions/io.py
@@ -22,60 +22,34 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""This module provides QAction that can be attached to a plot3DWidget."""
+"""This module provides Plot3DAction related to input/output.
+
+It provides QAction to copy, save (snapshot and video), print a Plot3DWidget.
+"""
from __future__ import absolute_import, division
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "26/01/2017"
+__date__ = "06/09/2017"
import logging
import os
-import weakref
import numpy
from silx.gui import qt
-from silx.gui.plot.PlotActions import PrintAction as _PrintAction
+from silx.gui.plot.actions.io import PrintAction as _PrintAction
from silx.gui.icons import getQIcon
-from .utils import mng
-from .._utils import convertQImageToArray
+from .Plot3DAction import Plot3DAction
+from ..utils import mng
+from ..._utils import convertQImageToArray
_logger = logging.getLogger(__name__)
-class Plot3DAction(qt.QAction):
- """QAction associated to a Plot3DWidget
-
- :param parent: See :class:`QAction`
- :param Plot3DWidget plot3d: Plot3DWidget the action is associated with
- """
-
- def __init__(self, parent, plot3d=None):
- super(Plot3DAction, self).__init__(parent)
- self._plot3d = None
- self.setPlot3DWidget(plot3d)
-
- def setPlot3DWidget(self, widget):
- """Set the Plot3DWidget this action is associated with
-
- :param Plot3DWidget widget: The Plot3DWidget to use
- """
- self._plot3d = None if widget is None else weakref.ref(widget)
-
- def getPlot3DWidget(self):
- """Return the Plot3DWidget associated to this action.
-
- If no widget is associated, it returns None.
-
- :rtype: qt.QWidget
- """
- return None if self._plot3d is None else self._plot3d()
-
-
class CopyAction(Plot3DAction):
"""QAction to provide copy of a Plot3DWidget
diff --git a/silx/gui/plot3d/actions/mode.py b/silx/gui/plot3d/actions/mode.py
new file mode 100644
index 0000000..a06b9a8
--- /dev/null
+++ b/silx/gui/plot3d/actions/mode.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 Plot3DAction related to interaction modes.
+
+It provides QAction to rotate or pan a Plot3DWidget.
+"""
+
+from __future__ import absolute_import, division
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "06/09/2017"
+
+
+import logging
+
+from silx.gui.icons import getQIcon
+from .Plot3DAction import Plot3DAction
+
+
+_logger = logging.getLogger(__name__)
+
+
+class InteractiveModeAction(Plot3DAction):
+ """Base class for QAction changing interactive mode of a Plot3DWidget
+
+ :param parent: See :class:`QAction`
+ :param str interaction: The interactive mode this action controls
+ :param Plot3DWidget plot3d: Plot3DWidget the action is associated with
+ """
+
+ def __init__(self, parent, interaction, plot3d=None):
+ self._interaction = interaction
+
+ super(InteractiveModeAction, self).__init__(parent, plot3d)
+ self.setCheckable(True)
+ self.triggered[bool].connect(self._triggered)
+
+ def _triggered(self, checked=False):
+ plot3d = self.getPlot3DWidget()
+ if plot3d is None:
+ _logger.error(
+ 'Cannot set %s interaction, no associated Plot3DWidget' %
+ self._interaction)
+ else:
+ plot3d.setInteractiveMode(self._interaction)
+ self.setChecked(True)
+
+ def setPlot3DWidget(self, widget):
+ # Disconnect from previous Plot3DWidget
+ plot3d = self.getPlot3DWidget()
+ if plot3d is not None:
+ plot3d.sigInteractiveModeChanged.disconnect(
+ self._interactiveModeChanged)
+
+ super(InteractiveModeAction, self).setPlot3DWidget(widget)
+
+ # Connect to new Plot3DWidget
+ if widget is None:
+ self.setChecked(False)
+ else:
+ self.setChecked(widget.getInteractiveMode() == self._interaction)
+ widget.sigInteractiveModeChanged.connect(
+ self._interactiveModeChanged)
+
+ # Reuse docstring from super class
+ setPlot3DWidget.__doc__ = Plot3DAction.setPlot3DWidget.__doc__
+
+ def _interactiveModeChanged(self):
+ plot3d = self.getPlot3DWidget()
+ if plot3d is None:
+ _logger.error('Received a signal while there is no widget')
+ else:
+ self.setChecked(plot3d.getInteractiveMode() == self._interaction)
+
+
+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
+ """
+
+ def __init__(self, parent, plot3d=None):
+ super(RotateArcballAction, self).__init__(parent, 'rotate', plot3d)
+
+ self.setIcon(getQIcon('rotate-3d'))
+ self.setText('Rotate')
+ self.setToolTip('Rotate the view')
+
+
+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
+ """
+
+ def __init__(self, parent, plot3d=None):
+ super(PanAction, self).__init__(parent, 'pan', plot3d)
+
+ self.setIcon(getQIcon('pan'))
+ self.setText('Pan')
+ self.setToolTip('Pan the view')
diff --git a/silx/gui/plot3d/scene/axes.py b/silx/gui/plot3d/scene/axes.py
index 528e4f7..520ef3e 100644
--- a/silx/gui/plot3d/scene/axes.py
+++ b/silx/gui/plot3d/scene/axes.py
@@ -52,6 +52,8 @@ class LabelledAxes(primitives.GroupBBox):
self._font = text.Font()
+ self._boxVisibility = True
+
# TODO offset labels from anchor in pixels
self._xlabel = text.Text2D(font=self._font)
@@ -145,6 +147,21 @@ class LabelledAxes(primitives.GroupBBox):
def zlabel(self, text):
self._zlabel.text = text
+ @property
+ def boxVisible(self):
+ """Returns bounding box, axes labels and grid visibility."""
+ return self._boxVisibility
+
+ @boxVisible.setter
+ def boxVisible(self, visible):
+ self._boxVisibility = bool(visible)
+ for child in self._children:
+ if child == self._tickLines:
+ if self._ticksForBounds is not None:
+ child.visible = self._boxVisibility
+ elif child != self._group:
+ child.visible = self._boxVisibility
+
def _updateTicks(self):
"""Check if ticks need update and update them if needed."""
bounds = self._group.bounds(transformed=False, dataBounds=True)
@@ -187,7 +204,7 @@ class LabelledAxes(primitives.GroupBBox):
zcoords[:, 3, 1] += ticklength[1] # Z ticks on YZ plane
self._tickLines.setPositions(coords.reshape(-1, 3))
- self._tickLines.visible = True
+ self._tickLines.visible = self._boxVisibility
# Update labels
color = self.tickColor
diff --git a/silx/gui/plot3d/scene/function.py b/silx/gui/plot3d/scene/function.py
index 80ac820..73cdb72 100644
--- a/silx/gui/plot3d/scene/function.py
+++ b/silx/gui/plot3d/scene/function.py
@@ -35,6 +35,7 @@ import contextlib
import logging
import numpy
+from ... import _glutils
from ..._glutils import gl
from . import event
@@ -296,18 +297,10 @@ class DirectionalLight(event.Notifier, ProgramFunction):
class Colormap(event.Notifier, ProgramFunction):
# TODO use colors for out-of-bound values, for <=0 with log, for nan
- # TODO texture-based colormap
decl = """
- #define CMAP_GRAY 0
- #define CMAP_R_GRAY 1
- #define CMAP_RED 2
- #define CMAP_GREEN 3
- #define CMAP_BLUE 4
- #define CMAP_TEMP 5
-
uniform struct {
- int id;
+ sampler2D texture;
bool isLog;
float min;
float oneOverRange;
@@ -328,60 +321,24 @@ class Colormap(event.Notifier, ProgramFunction):
value = clamp(cmap.oneOverRange * (value - cmap.min), 0.0, 1.0);
}
- if (cmap.id == CMAP_GRAY) {
- return vec4(value, value, value, 1.0);
- }
- else if (cmap.id == CMAP_R_GRAY) {
- float invValue = 1.0 - value;
- return vec4(invValue, invValue, invValue, 1.0);
- }
- else if (cmap.id == CMAP_RED) {
- return vec4(value, 0.0, 0.0, 1.0);
- }
- else if (cmap.id == CMAP_GREEN) {
- return vec4(0.0, value, 0.0, 1.0);
- }
- else if (cmap.id == CMAP_BLUE) {
- return vec4(0.0, 0.0, value, 1.0);
- }
- else if (cmap.id == CMAP_TEMP) {
- //red: 0.5->0.75: 0->1
- //green: 0.->0.25: 0->1; 0.75->1.: 1->0
- //blue: 0.25->0.5: 1->0
- return vec4(
- clamp(4.0 * value - 2.0, 0.0, 1.0),
- 1.0 - clamp(4.0 * abs(value - 0.5) - 1.0, 0.0, 1.0),
- 1.0 - clamp(4.0 * value - 1.0, 0.0, 1.0),
- 1.0);
- }
- else {
- /* Unknown colormap */
- return vec4(0.0, 0.0, 0.0, 1.0);
- }
+ vec4 color = texture2D(cmap.texture, vec2(value, 0.5));
+ return color;
}
"""
call = "colormap"
- _COLORMAPS = {
- 'gray': 0,
- 'reversed gray': 1,
- 'red': 2,
- 'green': 3,
- 'blue': 4,
- 'temperature': 5
- }
-
- COLORMAPS = tuple(_COLORMAPS.keys())
- """Tuple of supported colormap names."""
-
NORMS = 'linear', 'log'
"""Tuple of supported normalizations."""
- def __init__(self, name='gray', norm='linear', range_=(1., 10.)):
+ _COLORMAP_TEXTURE_UNIT = 1
+ """Texture unit to use for storing the colormap"""
+
+ def __init__(self, colormap=None, norm='linear', range_=(1., 10.)):
"""Shader function to apply a colormap to a value.
- :param str name: Name of the colormap.
+ :param colormap: RGB(A) color look-up table (default: gray)
+ :param colormap: numpy.ndarray of numpy.uint8 of dimension Nx3 or Nx4
:param str norm: Normalization to apply: 'linear' (default) or 'log'.
:param range_: Range of value to map to the colormap.
:type range_: 2-tuple of float (begin, end).
@@ -389,24 +346,35 @@ class Colormap(event.Notifier, ProgramFunction):
super(Colormap, self).__init__()
# Init privates to default
- self._name, self._norm, self._range = 'gray', 'linear', (1., 10.)
+ self._colormap, self._norm, self._range = None, 'linear', (1., 10.)
+
+ self._texture = None
+ self._update_texture = True
+
+ if colormap is None:
+ # default colormap
+ colormap = numpy.empty((256, 3), dtype=numpy.uint8)
+ colormap[:] = numpy.arange(256,
+ dtype=numpy.uint8)[:, numpy.newaxis]
# Set to param values through properties to go through asserts
- self.name = name
+ self.colormap = colormap
self.norm = norm
self.range_ = range_
@property
- def name(self):
- """Name of the colormap in use."""
- return self._name
-
- @name.setter
- def name(self, name):
- if name != self._name:
- assert name in self.COLORMAPS
- self._name = name
- self.notify()
+ def colormap(self):
+ """Color look-up table to use."""
+ return numpy.array(self._colormap, copy=True)
+
+ @colormap.setter
+ def colormap(self, colormap):
+ colormap = numpy.array(colormap, copy=True)
+ assert colormap.ndim == 2
+ assert colormap.shape[1] in (3, 4)
+ self._colormap = colormap
+ self._update_texture = True
+ self.notify()
@property
def norm(self):
@@ -459,7 +427,15 @@ class Colormap(event.Notifier, ProgramFunction):
:param GLProgram program: The program to set-up.
It MUST be in use and using this function.
"""
- gl.glUniform1i(program.uniforms['cmap.id'], self._COLORMAPS[self.name])
+ self.prepareGL2(context) # TODO see how to handle
+
+ if self._texture is None: # No colormap
+ return
+
+ self._texture.bind()
+
+ gl.glUniform1i(program.uniforms['cmap.texture'],
+ self._texture.texUnit)
gl.glUniform1i(program.uniforms['cmap.isLog'], self._norm == 'log')
min_, max_ = self.range_
@@ -469,3 +445,23 @@ class Colormap(event.Notifier, ProgramFunction):
gl.glUniform1f(program.uniforms['cmap.min'], min_)
gl.glUniform1f(program.uniforms['cmap.oneOverRange'],
(1. / (max_ - min_)) if max_ != min_ else 0.)
+
+ def prepareGL2(self, context):
+ if self._texture is None or self._update_texture:
+ if self._texture is not None:
+ self._texture.discard()
+
+ colormap = numpy.empty(
+ (16, self._colormap.shape[0], self._colormap.shape[1]),
+ dtype=self._colormap.dtype)
+ colormap[:] = self._colormap
+
+ format_ = gl.GL_RGBA if colormap.shape[-1] == 4 else gl.GL_RGB
+
+ self._texture = _glutils.Texture(
+ format_, colormap, format_,
+ texUnit=self._COLORMAP_TEXTURE_UNIT,
+ minFilter=gl.GL_NEAREST,
+ magFilter=gl.GL_NEAREST,
+ wrap=gl.GL_CLAMP_TO_EDGE)
+ self._update_texture = False
diff --git a/silx/gui/plot3d/scene/interaction.py b/silx/gui/plot3d/scene/interaction.py
index 68bfc13..2911b2c 100644
--- a/silx/gui/plot3d/scene/interaction.py
+++ b/silx/gui/plot3d/scene/interaction.py
@@ -440,6 +440,26 @@ class FocusManager(StateMachine):
# CameraControl ###############################################################
+class RotateCameraControl(FocusManager):
+ """Combine wheel and rotate state machine."""
+ def __init__(self, viewport,
+ orbitAroundCenter=False,
+ mode='center', scaleTransform=None):
+ handlers = (CameraWheel(viewport, mode, scaleTransform),
+ CameraRotate(viewport, orbitAroundCenter, LEFT_BTN))
+ super(RotateCameraControl, self).__init__(handlers)
+
+
+class PanCameraControl(FocusManager):
+ """Combine wheel, selectPan and rotate state machine."""
+ def __init__(self, viewport,
+ mode='center', scaleTransform=None,
+ selectCB=None):
+ handlers = (CameraWheel(viewport, mode, scaleTransform),
+ CameraSelectPan(viewport, LEFT_BTN, selectCB))
+ super(PanCameraControl, self).__init__(handlers)
+
+
class CameraControl(FocusManager):
"""Combine wheel, selectPan and rotate state machine."""
def __init__(self, viewport,
@@ -650,3 +670,12 @@ class PanPlaneRotateCameraControl(FocusManager):
orbitAroundCenter=False,
button=RIGHT_BTN))
super(PanPlaneRotateCameraControl, self).__init__(handlers)
+
+
+class PanPlaneZoomOnWheelControl(FocusManager):
+ """Combine zoom on wheel and pan plane state machines."""
+ def __init__(self, viewport, plane,
+ mode='center', scaleTransform=None):
+ handlers = (CameraWheel(viewport, mode, scaleTransform),
+ PlanePan(viewport, plane, LEFT_BTN))
+ super(PanPlaneZoomOnWheelControl, self).__init__(handlers)
diff --git a/silx/gui/plot3d/scene/primitives.py b/silx/gui/plot3d/scene/primitives.py
index ca2616a..fc38e09 100644
--- a/silx/gui/plot3d/scene/primitives.py
+++ b/silx/gui/plot3d/scene/primitives.py
@@ -292,8 +292,11 @@ class Geometry(core.Elem):
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]] = positions.min(axis=0)[:3]
- self.__bounds[1, :positions.shape[1]] = positions.max(axis=0)[:3]
+ self.__bounds[0, :positions.shape[1]] = \
+ numpy.nanmin(positions, axis=0)[:3]
+ self.__bounds[1, :positions.shape[1]] = \
+ numpy.nanmax(positions, axis=0)[:3]
+ self.__bounds[numpy.isnan(self.__bounds)] = 0. # Avoid NaNs
return self.__bounds.copy()
def prepareGL2(self, ctx):
diff --git a/silx/gui/plot3d/scene/viewport.py b/silx/gui/plot3d/scene/viewport.py
index 83cda43..72e1ea3 100644
--- a/silx/gui/plot3d/scene/viewport.py
+++ b/silx/gui/plot3d/scene/viewport.py
@@ -314,6 +314,9 @@ class Viewport(event.Notifier):
(e.g., if spanning behind the viewpoint with perspective projection).
"""
bounds = self.scene.bounds(transformed=True)
+ if bounds is None:
+ bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)),
+ dtype=numpy.float32)
bounds = self.camera.extrinsic.transformBounds(bounds)
if isinstance(self.camera.intrinsic, transform.Perspective):
@@ -337,7 +340,11 @@ class Viewport(event.Notifier):
It updates the camera position and depth extent.
Camera sight direction and up are not affected.
"""
- self.camera.resetCamera(self.scene.bounds(transformed=True))
+ bounds = self.scene.bounds(transformed=True)
+ if bounds is None:
+ bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)),
+ dtype=numpy.float32)
+ self.camera.resetCamera(bounds)
def orbitCamera(self, direction, angle=1.):
"""Rotate the camera around center of the scene.
@@ -347,6 +354,9 @@ class Viewport(event.Notifier):
:param float angle: he angle in degrees of the rotation.
"""
bounds = self.scene.bounds(transformed=True)
+ if bounds is None:
+ bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)),
+ dtype=numpy.float32)
center = 0.5 * (bounds[0] + bounds[1])
self.camera.orbit(direction, center, angle)
@@ -359,6 +369,9 @@ class Viewport(event.Notifier):
:param float step: The ratio of data to step for each pan.
"""
bounds = self.scene.bounds(transformed=True)
+ if bounds is None:
+ bounds = numpy.array(((0., 0., 0.), (1., 1., 1.)),
+ dtype=numpy.float32)
bounds = self.camera.extrinsic.transformBounds(bounds)
center = 0.5 * (bounds[0] + bounds[1])
ndcCenter = self.camera.intrinsic.transformPoint(
diff --git a/silx/gui/plot3d/scene/window.py b/silx/gui/plot3d/scene/window.py
index ad7e6e5..3c63c7a 100644
--- a/silx/gui/plot3d/scene/window.py
+++ b/silx/gui/plot3d/scene/window.py
@@ -244,6 +244,7 @@ class Window(event.Notifier):
void main(void) {
gl_FragColor = texture2D(texture, textureCoord);
+ gl_FragColor.a = 1.0;
}
""")
@@ -304,12 +305,11 @@ class Window(event.Notifier):
self._viewports.removeListener(self._updated)
self._viewports = event.NotifierList(iterable)
self._viewports.addListener(self._updated)
- self._dirty = True
+ self._updated(self)
def _updated(self, source, *args, **kwargs):
- if source is not self:
- self._dirty = True
- self.notify(*args, **kwargs)
+ self._dirty = True
+ self.notify(*args, **kwargs)
framebufferid = property(lambda self: self._framebufferid,
doc="Framebuffer ID used to perform rendering")
@@ -323,11 +323,12 @@ class Window(event.Notifier):
height, width = self.shape
image = numpy.empty((height, width, 3), dtype=numpy.uint8)
+ previousFramebuffer = gl.glGetInteger(gl.GL_FRAMEBUFFER_BINDING)
gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.framebufferid)
gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1)
gl.glReadPixels(
0, 0, width, height, gl.GL_RGB, gl.GL_UNSIGNED_BYTE, image)
- gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0)
+ gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, previousFramebuffer)
# glReadPixels gives bottom to top,
# while images are stored as top to bottom
diff --git a/silx/gui/plot3d/setup.py b/silx/gui/plot3d/setup.py
index b9d626f..bb6eaa5 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('actions')
config.add_subpackage('scene')
+ config.add_subpackage('tools')
config.add_subpackage('test')
config.add_subpackage('utils')
return config
diff --git a/silx/gui/plot3d/test/__init__.py b/silx/gui/plot3d/test/__init__.py
index 66a2f62..2e8c9f4 100644
--- a/silx/gui/plot3d/test/__init__.py
+++ b/silx/gui/plot3d/test/__init__.py
@@ -56,7 +56,11 @@ def suite():
# Import here to avoid loading modules if tests are disabled
from ..scene import test as test_scene
+ from .testGL import suite as testGLSuite
+ from .testScalarFieldView import suite as testScalarFieldViewSuite
test_suite = unittest.TestSuite()
+ test_suite.addTest(testGLSuite())
test_suite.addTest(test_scene.suite())
+ test_suite.addTest(testScalarFieldViewSuite())
return test_suite
diff --git a/silx/gui/plot3d/test/testGL.py b/silx/gui/plot3d/test/testGL.py
new file mode 100644
index 0000000..70f197f
--- /dev/null
+++ b/silx/gui/plot3d/test/testGL.py
@@ -0,0 +1,84 @@
+# 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 OpenGL"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "10/08/2017"
+
+
+import logging
+import unittest
+
+from silx.gui._glutils import gl, OpenGLWidget
+from silx.gui.test.utils import TestCaseQt
+from silx.gui import qt
+
+
+_logger = logging.getLogger(__name__)
+
+
+class TestOpenGL(TestCaseQt):
+ """Tests of OpenGL widget."""
+
+ class OpenGLWidgetLogger(OpenGLWidget):
+ """Widget logging information of available OpenGL version"""
+
+ def __init__(self):
+ self._dump = False
+ super(TestOpenGL.OpenGLWidgetLogger, self).__init__(version=(1, 0))
+
+ def paintOpenGL(self):
+ """Perform the rendering and logging"""
+ if not self._dump:
+ self._dump = True
+ _logger.info('OpenGL info:')
+ _logger.info('\tQt OpenGL context version: %d.%d', *self.getOpenGLVersion())
+ _logger.info('\tGL_VERSION: %s' % gl.glGetString(gl.GL_VERSION))
+ _logger.info('\tGL_SHADING_LANGUAGE_VERSION: %s' %
+ gl.glGetString(gl.GL_SHADING_LANGUAGE_VERSION))
+ _logger.debug('\tGL_EXTENSIONS: %s' % gl.glGetString(gl.GL_EXTENSIONS))
+
+ gl.glClearColor(1., 1., 1., 1.)
+ gl.glClear(gl.GL_COLOR_BUFFER_BIT)
+
+ def testOpenGL(self):
+ """Log OpenGL version using an OpenGLWidget"""
+ super(TestOpenGL, self).setUp()
+ widget = self.OpenGLWidgetLogger()
+ widget.show()
+ widget.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.qWaitForWindowExposed(widget)
+ widget.close()
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loadTests(TestOpenGL))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot3d/test/testScalarFieldView.py b/silx/gui/plot3d/test/testScalarFieldView.py
new file mode 100644
index 0000000..5ad4051
--- /dev/null
+++ b/silx/gui/plot3d/test/testScalarFieldView.py
@@ -0,0 +1,114 @@
+# 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 ScalarFieldView widget"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "11/07/2017"
+
+
+import logging
+import unittest
+
+import numpy
+
+from silx.test.utils import ParametricTestCase
+from silx.gui.test.utils import TestCaseQt
+from silx.gui import qt
+
+from silx.gui.plot3d.ScalarFieldView import ScalarFieldView
+from silx.gui.plot3d.SFViewParamTree import TreeView
+
+
+_logger = logging.getLogger(__name__)
+
+
+class TestScalarFieldView(TestCaseQt, ParametricTestCase):
+ """Tests of ScalarFieldView widget."""
+
+ def setUp(self):
+ super(TestScalarFieldView, self).setUp()
+ self.widget = ScalarFieldView()
+ self.widget.show()
+
+ # Commented as it slows down the tests
+ # self.qWaitForWindowExposed(self.widget)
+
+ def tearDown(self):
+ self.qapp.processEvents()
+ self.widget.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.widget.close()
+ del self.widget
+ super(TestScalarFieldView, self).tearDown()
+
+ @staticmethod
+ def _buildData(size):
+ """Make a 3D dataset"""
+ coords = numpy.linspace(-10, 10, size)
+ z = coords.reshape(-1, 1, 1)
+ y = coords.reshape(1, -1, 1)
+ x = coords.reshape(1, 1, -1)
+ return numpy.sin(x * y * z) / (x * y * z)
+
+ def testSimple(self):
+ """Set the data and an isosurface"""
+ data = self._buildData(size=32)
+
+ self.widget.setData(data)
+ self.widget.addIsosurface(0.5, (1., 0., 0., 0.5))
+ self.widget.addIsosurface(0.7, qt.QColor('green'))
+ self.qapp.processEvents()
+
+ def testNotFinite(self):
+ """Test with NaN and inf in data set"""
+
+ # Some NaNs and inf
+ data = self._buildData(size=32)
+ data[8, :, :] = numpy.nan
+ data[16, :, :] = numpy.inf
+ data[24, :, :] = - numpy.inf
+
+ self.widget.addIsosurface(0.5, 'red')
+ self.widget.setData(data, copy=True)
+ self.qapp.processEvents()
+ self.widget.setData(None)
+
+ # All NaNs or inf
+ data = numpy.empty((4, 4, 4), dtype=numpy.float32)
+ for value in (numpy.nan, numpy.inf):
+ with self.subTest(value=str(value)):
+ data[:] = value
+ self.widget.setData(data, copy=True)
+ self.qapp.processEvents()
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loadTests(TestScalarFieldView))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot3d/ViewpointToolBar.py b/silx/gui/plot3d/tools/ViewpointTools.py
index d062c1b..1346c1c 100644
--- a/silx/gui/plot3d/ViewpointToolBar.py
+++ b/silx/gui/plot3d/tools/ViewpointTools.py
@@ -28,14 +28,14 @@ from __future__ import absolute_import
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "15/09/2016"
+__date__ = "08/09/2017"
from silx.gui import qt
from silx.gui.icons import getQIcon
-class ViewpointActionGroup(qt.QActionGroup):
+class _ViewpointActionGroup(qt.QActionGroup):
"""ActionGroup of actions to reset the viewpoint.
As for QActionGroup, add group's actions to the widget with:
@@ -57,7 +57,7 @@ class ViewpointActionGroup(qt.QActionGroup):
)
def __init__(self, plot3D, parent=None):
- super(ViewpointActionGroup, self).__init__(parent)
+ super(_ViewpointActionGroup, self).__init__(parent)
self.setExclusive(False)
self._plot3D = plot3D
@@ -66,6 +66,7 @@ class ViewpointActionGroup(qt.QActionGroup):
iconname, text, tooltip = actionInfo
action = qt.QAction(getQIcon(iconname), text, None)
+ action.setIconVisibleInMenu(True)
action.setCheckable(False)
action.setToolTip(tooltip)
self.addAction(action)
@@ -90,7 +91,7 @@ class ViewpointToolBar(qt.QToolBar):
def __init__(self, parent=None, plot3D=None, title='Viewpoint control'):
super(ViewpointToolBar, self).__init__(title, parent)
- self._actionGroup = ViewpointActionGroup(plot3D)
+ self._actionGroup = _ViewpointActionGroup(plot3D)
assert plot3D is not None
self._plot3D = plot3D
self.addActions(self._actionGroup.actions())
@@ -112,3 +113,23 @@ class ViewpointToolBar(qt.QToolBar):
# """Projection combo box listener"""
# self._plot3D.setProjection(
# 'perspective' if text == 'Perspective' else 'orthographic')
+
+
+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):
+ super(ViewpointToolButton, self).__init__(parent)
+
+ self._actionGroup = _ViewpointActionGroup(plot3D)
+
+ menu = qt.QMenu(self)
+ menu.addActions(self._actionGroup.actions())
+ self.setMenu(menu)
+ self.setPopupMode(qt.QToolButton.InstantPopup)
+ self.setIcon(getQIcon('cube'))
+ self.setToolTip('Reset the viewpoint to a defined position')
diff --git a/silx/gui/plot3d/tools/__init__.py b/silx/gui/plot3d/tools/__init__.py
new file mode 100644
index 0000000..e14f604
--- /dev/null
+++ b/silx/gui/plot3d/tools/__init__.py
@@ -0,0 +1,32 @@
+# 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 tool widgets that can be attached to a plot3DWidget."""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "08/09/2017"
+
+from .toolbars import InteractiveModeToolBar, OutputToolBar
+from .ViewpointTools import ViewpointToolBar, ViewpointToolButton
diff --git a/silx/gui/plot3d/Plot3DToolBar.py b/silx/gui/plot3d/tools/toolbars.py
index cf11362..c8be226 100644
--- a/silx/gui/plot3d/Plot3DToolBar.py
+++ b/silx/gui/plot3d/tools/toolbars.py
@@ -22,52 +22,109 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""This module provides a toolbar with tools for a Plot3DWidget.
+"""This module provides toolbars with tools for a Plot3DWidget.
-It provides:
+It provides the following toolbars:
-- Copy
-- Save
-- Print
+- :class:`InteractiveModeToolBar` with:
+ - Set interactive mode to rotation
+ - Set interactive mode to pan
+
+- :class:`OutputToolBar` with:
+ - Copy
+ - Save
+ - Video
+ - Print
"""
from __future__ import absolute_import
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "10/01/2017"
+__date__ = "06/09/2017"
import logging
from silx.gui import qt
-from . import Plot3DActions
+from .. import actions
_logger = logging.getLogger(__name__)
-class Plot3DToolBar(qt.QToolBar):
+class InteractiveModeToolBar(qt.QToolBar):
+ """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__(title, parent)
+
+ self._plot3d = None
+
+ self._rotateAction = actions.mode.RotateArcballAction(parent=self)
+ self.addAction(self._rotateAction)
+
+ self._panAction = actions.mode.PanAction(parent=self)
+ self.addAction(self._panAction)
+
+ 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.getRotateAction().setPlot3DWidget(widget)
+ self.getPanAction().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 getRotateAction(self):
+ """Returns the QAction setting rotate interaction of the Plot3DWidget
+
+ :rtype: qt.QAction
+ """
+ return self._rotateAction
+
+ def getPanAction(self):
+ """Returns the QAction setting pan interaction of the Plot3DWidget
+
+ :rtype: qt.QAction
+ """
+ return self._panAction
+
+
+class OutputToolBar(qt.QToolBar):
"""Toolbar providing icons to copy, save and print the OpenGL scene
:param parent: See :class:`QWidget`
:param str title: Title of the toolbar.
"""
- def __init__(self, parent=None, title='Plot3D'):
- super(Plot3DToolBar, self).__init__(title, parent)
+ def __init__(self, parent=None, title='Plot3D Output'):
+ super(OutputToolBar, self).__init__(title, parent)
self._plot3d = None
- self._copyAction = Plot3DActions.CopyAction(parent=self)
+ self._copyAction = actions.io.CopyAction(parent=self)
self.addAction(self._copyAction)
- self._saveAction = Plot3DActions.SaveAction(parent=self)
+ self._saveAction = actions.io.SaveAction(parent=self)
self.addAction(self._saveAction)
- self._videoAction = Plot3DActions.VideoAction(parent=self)
+ self._videoAction = actions.io.VideoAction(parent=self)
self.addAction(self._videoAction)
- self._printAction = Plot3DActions.PrintAction(parent=self)
+ self._printAction = actions.io.PrintAction(parent=self)
self.addAction(self._printAction)
def setPlot3DWidget(self, widget):
diff --git a/silx/gui/setup.py b/silx/gui/setup.py
index fbe9058..163cabf 100644
--- a/silx/gui/setup.py
+++ b/silx/gui/setup.py
@@ -24,7 +24,7 @@
# ###########################################################################*/
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "16/01/2017"
+__date__ = "04/05/2017"
from numpy.distutils.misc_util import Configuration
diff --git a/silx/gui/test/test_icons.py b/silx/gui/test/test_icons.py
index f363c43..d747761 100644
--- a/silx/gui/test/test_icons.py
+++ b/silx/gui/test/test_icons.py
@@ -26,13 +26,17 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "26/04/2017"
+__date__ = "06/09/2017"
import gc
import unittest
import weakref
+import tempfile
+import shutil
+import os
+import silx.resources
from silx.gui import qt
from silx.gui.test.utils import TestCaseQt
from silx.gui import icons
@@ -41,14 +45,49 @@ from silx.gui import icons
class TestIcons(TestCaseQt):
"""Test to check that icons module."""
+ @classmethod
+ def setUpClass(cls):
+ super(TestIcons, cls).setUpClass()
+
+ cls.tmpDirectory = tempfile.mkdtemp(prefix="resource_")
+ os.mkdir(os.path.join(cls.tmpDirectory, "gui"))
+ destination = os.path.join(cls.tmpDirectory, "gui", "icons")
+ os.mkdir(destination)
+ shutil.copy(silx.resources.resource_filename("gui/icons/zoom-in.png"), destination)
+ shutil.copy(silx.resources.resource_filename("gui/icons/zoom-out.svg"), destination)
+
+ @classmethod
+ def tearDownClass(cls):
+ super(TestIcons, cls).tearDownClass()
+ shutil.rmtree(cls.tmpDirectory)
+
+ def setUp(self):
+ # Store the original configuration
+ self._oldResources = dict(silx.resources._RESOURCE_DIRECTORIES)
+ silx.resources.register_resource_directory("test", "foo.bar", forced_path=self.tmpDirectory)
+ unittest.TestCase.setUp(self)
+
+ def tearDown(self):
+ unittest.TestCase.tearDown(self)
+ # Restiture the original configuration
+ silx.resources._RESOURCE_DIRECTORIES = self._oldResources
+
+ def testIcon(self):
+ icon = icons.getQIcon("silx:gui/icons/zoom-out")
+ self.assertIsNotNone(icon)
+
+ def testPrefix(self):
+ icon = icons.getQIcon("silx:gui/icons/zoom-out")
+ self.assertIsNotNone(icon)
+
def testSvgIcon(self):
if "svg" not in qt.supportedImageFormats():
self.skipTest("SVG not supported")
- icon = icons.getQIcon("test-svg")
+ icon = icons.getQIcon("test:gui/icons/zoom-out")
self.assertIsNotNone(icon)
def testPngIcon(self):
- icon = icons.getQIcon("test-png")
+ icon = icons.getQIcon("test:gui/icons/zoom-in")
self.assertIsNotNone(icon)
def testUnexistingIcon(self):
@@ -99,16 +138,19 @@ class TestAnimatedIcons(TestCaseQt):
icon = icons.MultiImageAnimatedIcon("process-working")
self.assertIsNotNone(icon)
+ def testPrefixedResourceExists(self):
+ icon = icons.MultiImageAnimatedIcon("silx:gui/icons/process-working")
+ self.assertIsNotNone(icon)
+
def testMultiImageIconNotExists(self):
self.assertRaises(ValueError, icons.MultiImageAnimatedIcon, "not-exists")
def suite():
+ loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
test_suite = unittest.TestSuite()
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestIcons))
- test_suite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(TestAnimatedIcons))
+ test_suite.addTest(loadTests(TestIcons))
+ test_suite.addTest(loadTests(TestAnimatedIcons))
return test_suite
diff --git a/silx/gui/test/utils.py b/silx/gui/test/utils.py
index 50cf7bf..19c448a 100644
--- a/silx/gui/test/utils.py
+++ b/silx/gui/test/utils.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
@@ -26,7 +26,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "11/04/2017"
+__date__ = "01/09/2017"
import gc
@@ -35,8 +35,8 @@ import unittest
import time
import functools
import sys
+import os
-logging.basicConfig()
_logger = logging.getLogger(__name__)
from silx.gui import qt
@@ -349,8 +349,36 @@ class TestCaseQt(unittest.TestCase):
return result
+ 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.
-class SignalListener():
+ The screenshot is stored in the directory "./build/test-debug", and
+ the logging system only log the path to this file.
+
+ :param level: Logging level
+ """
+ if not _logger.isEnabledFor(level):
+ return
+ basedir = os.path.abspath(os.path.join("build", "test-debug"))
+ if not os.path.exists(basedir):
+ os.makedirs(basedir)
+ filename = "Screenshot_%s.png" % self.id()
+ filename = os.path.join(basedir, filename)
+
+ if not hasattr(self.qapp, "primaryScreen"):
+ # Qt4
+ winId = qt.QApplication.desktop().winId()
+ pixmap = qt.QPixmap.grabWindow(winId)
+ else:
+ # Qt5
+ screen = self.qapp.primaryScreen()
+ pixmap = screen.grabWindow(0)
+ pixmap.save(filename)
+ _logger.log(level, "Screenshot saved at %s", filename)
+
+
+class SignalListener(object):
"""Util to listen a Qt event and store parameters
"""
diff --git a/silx/gui/widgets/FloatEdit.py b/silx/gui/widgets/FloatEdit.py
new file mode 100644
index 0000000..fd6d8a7
--- /dev/null
+++ b/silx/gui/widgets/FloatEdit.py
@@ -0,0 +1,65 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""Module contains a float editor
+"""
+
+from __future__ import division
+
+__authors__ = ["V.A. Sole", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "02/10/2017"
+
+from .. import qt
+
+
+class FloatEdit(qt.QLineEdit):
+ """Field to edit a float value.
+
+ :param parent: See :class:`QLineEdit`
+ :param float value: The value to set the QLineEdit to.
+ """
+ def __init__(self, parent=None, value=None):
+ qt.QLineEdit.__init__(self, parent)
+ validator = qt.QDoubleValidator(self)
+ self.setValidator(validator)
+ self.setAlignment(qt.Qt.AlignRight)
+ if value is not None:
+ self.setValue(value)
+
+ def value(self):
+ """Return the QLineEdit current value as a float."""
+ text = self.text()
+ value, validated = self.validator().locale().toDouble(text)
+ if not validated:
+ self.setValue(value)
+ return value
+
+ def setValue(self, value):
+ """Set the current value of the LineEdit
+
+ :param float value: The value to set the QLineEdit to.
+ """
+ text = self.validator().locale().toString(value)
+ self.setText(text)
diff --git a/silx/gui/widgets/FrameBrowser.py b/silx/gui/widgets/FrameBrowser.py
index 783a70a..6737e9c 100644
--- a/silx/gui/widgets/FrameBrowser.py
+++ b/silx/gui/widgets/FrameBrowser.py
@@ -43,6 +43,8 @@ class FrameBrowser(qt.QWidget):
"""Frame browser widget, with 4 buttons/icons and a line edit to provide
a way of selecting a frame index in a stack of images.
+ .. image:: img/FrameBrowser.png
+
It can be used in more generic case to select an integer within a range.
:param QWidget parent: Parent widget
@@ -215,6 +217,8 @@ class HorizontalSliderWithBrowser(qt.QAbstractSlider):
"""
Slider widget combining a :class:`QSlider` and a :class:`FrameBrowser`.
+ .. image:: img/HorizontalSliderWithBrowser.png
+
The data model is an integer within a range.
The default value is the default :class:`QSlider` value (0),
diff --git a/silx/gui/widgets/PeriodicTable.py b/silx/gui/widgets/PeriodicTable.py
index 2f1ca78..db71483 100644
--- a/silx/gui/widgets/PeriodicTable.py
+++ b/silx/gui/widgets/PeriodicTable.py
@@ -499,6 +499,8 @@ class _ElementButton(qt.QPushButton):
class PeriodicTable(qt.QWidget):
"""Periodic Table widget
+ .. image:: img/PeriodicTable.png
+
The following example shows how to connect clicking to selection::
from silx.gui import qt
@@ -686,6 +688,8 @@ class PeriodicCombo(qt.QComboBox):
"""
Combo list with all atomic elements of the periodic table
+ .. image:: img/PeriodicCombo.png
+
:param bool detailed: True (default) display element symbol, Z and name.
False display only element symbol and Z.
:param elements: List of items (:class:`PeriodicTableItem` objects) to
@@ -741,6 +745,8 @@ class PeriodicCombo(qt.QComboBox):
class PeriodicList(qt.QTreeWidget):
"""List of atomic elements in a :class:`QTreeView`
+ .. image:: img/PeriodicList.png
+
:param QWidget parent: Parent widget
:param bool detailed: True (default) display element symbol, Z and name.
False display only element symbol and Z.
diff --git a/silx/gui/widgets/PrintGeometryDialog.py b/silx/gui/widgets/PrintGeometryDialog.py
new file mode 100644
index 0000000..0613ce0
--- /dev/null
+++ b/silx/gui/widgets/PrintGeometryDialog.py
@@ -0,0 +1,222 @@
+# 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.
+#
+# ###########################################################################*/
+
+
+from silx.gui import qt
+from silx.gui.widgets.FloatEdit import FloatEdit
+
+
+class PrintGeometryWidget(qt.QWidget):
+ """Widget to specify the size and aspect ratio of an item
+ before sending it to the print preview dialog.
+
+ Use methods :meth:`setPrintGeometry` and :meth:`getPrintGeometry`
+ to interact with the widget.
+ """
+ def __init__(self, parent=None):
+ super(PrintGeometryWidget, self).__init__(parent)
+ self.mainLayout = qt.QGridLayout(self)
+ self.mainLayout.setContentsMargins(0, 0, 0, 0)
+ self.mainLayout.setSpacing(2)
+ hbox = qt.QWidget()
+ hboxLayout = qt.QHBoxLayout(hbox)
+ hboxLayout.setContentsMargins(0, 0, 0, 0)
+ hboxLayout.setSpacing(2)
+ label = qt.QLabel(self)
+ label.setText("Units")
+ label.setAlignment(qt.Qt.AlignCenter)
+ self._pageButton = qt.QRadioButton()
+ self._pageButton.setText("Page")
+ self._inchButton = qt.QRadioButton()
+ self._inchButton.setText("Inches")
+ self._cmButton = qt.QRadioButton()
+ self._cmButton.setText("Centimeters")
+ self._buttonGroup = qt.QButtonGroup(self)
+ self._buttonGroup.addButton(self._pageButton)
+ self._buttonGroup.addButton(self._inchButton)
+ self._buttonGroup.addButton(self._cmButton)
+ self._buttonGroup.setExclusive(True)
+
+ # units
+ self.mainLayout.addWidget(label, 0, 0, 1, 4)
+ hboxLayout.addWidget(self._pageButton)
+ hboxLayout.addWidget(self._inchButton)
+ hboxLayout.addWidget(self._cmButton)
+ self.mainLayout.addWidget(hbox, 1, 0, 1, 4)
+ self._pageButton.setChecked(True)
+
+ # xOffset
+ label = qt.QLabel(self)
+ label.setText("X Offset:")
+ self.mainLayout.addWidget(label, 2, 0)
+ self._xOffset = FloatEdit(self, 0.1)
+ self.mainLayout.addWidget(self._xOffset, 2, 1)
+
+ # yOffset
+ label = qt.QLabel(self)
+ label.setText("Y Offset:")
+ self.mainLayout.addWidget(label, 2, 2)
+ self._yOffset = FloatEdit(self, 0.1)
+ self.mainLayout.addWidget(self._yOffset, 2, 3)
+
+ # width
+ label = qt.QLabel(self)
+ label.setText("Width:")
+ self.mainLayout.addWidget(label, 3, 0)
+ self._width = FloatEdit(self, 0.9)
+ self.mainLayout.addWidget(self._width, 3, 1)
+
+ # height
+ label = qt.QLabel(self)
+ label.setText("Height:")
+ self.mainLayout.addWidget(label, 3, 2)
+ self._height = FloatEdit(self, 0.9)
+ self.mainLayout.addWidget(self._height, 3, 3)
+
+ # aspect ratio
+ self._aspect = qt.QCheckBox(self)
+ self._aspect.setText("Keep screen aspect ratio")
+ self._aspect.setChecked(True)
+ self.mainLayout.addWidget(self._aspect, 4, 1, 1, 2)
+
+ def getPrintGeometry(self):
+ """Return the print geometry dictionary.
+
+ See :meth:`setPrintGeometry` for documentation about the
+ print geometry dictionary."""
+ ddict = {}
+ if self._inchButton.isChecked():
+ ddict['units'] = "inches"
+ elif self._cmButton.isChecked():
+ ddict['units'] = "centimeters"
+ else:
+ ddict['units'] = "page"
+
+ ddict['xOffset'] = self._xOffset.value()
+ ddict['yOffset'] = self._yOffset.value()
+ ddict['width'] = self._width.value()
+ ddict['height'] = self._height.value()
+
+ if self._aspect.isChecked():
+ ddict['keepAspectRatio'] = True
+ else:
+ ddict['keepAspectRatio'] = False
+ return ddict
+
+ def setPrintGeometry(self, geometry=None):
+ """Set the print geometry.
+
+ The geometry parameters must be provided as a dictionary with
+ the following keys:
+
+ - *"xOffset"* (float)
+ - *"yOffset"* (float)
+ - *"width"* (float)
+ - *"height"* (float)
+ - *"units"*: possible values *"page", "inch", "cm"*
+ - *"keepAspectRatio"*: *True* or *False*
+
+ If *units* is *"page"*, the values should be floats in [0, 1.]
+ and are interpreted as a fraction of the page width or height.
+
+ :param dict geometry: Geometry parameters, as a dictionary."""
+ if geometry is None:
+ geometry = {}
+ oldDict = self.getPrintGeometry()
+ for key in ["units", "xOffset", "yOffset",
+ "width", "height", "keepAspectRatio"]:
+ geometry[key] = geometry.get(key, oldDict[key])
+
+ if geometry['units'].lower().startswith("inc"):
+ self._inchButton.setChecked(True)
+ elif geometry['units'].lower().startswith("c"):
+ self._cmButton.setChecked(True)
+ else:
+ self._pageButton.setChecked(True)
+
+ self._xOffset.setText("%s" % float(geometry['xOffset']))
+ self._yOffset.setText("%s" % float(geometry['yOffset']))
+ self._width.setText("%s" % float(geometry['width']))
+ self._height.setText("%s" % float(geometry['height']))
+ if geometry['keepAspectRatio']:
+ self._aspect.setChecked(True)
+ else:
+ self._aspect.setChecked(False)
+
+
+class PrintGeometryDialog(qt.QDialog):
+ """Dialog embedding a :class:`PrintGeometryWidget`.
+
+ Use methods :meth:`setPrintGeometry` and :meth:`getPrintGeometry`
+ to interact with the widget.
+
+ Execute method :meth:`exec_` to run the dialog.
+ The return value of that method is *True* if the geometry was set
+ (*Ok* button clicked) or *False* if the user clicked the *Cancel*
+ button.
+ """
+
+ def __init__(self, parent=None):
+ qt.QDialog.__init__(self, parent)
+ self.setWindowTitle("Set print size preferences")
+ layout = qt.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ self.configurationWidget = PrintGeometryWidget(self)
+ hbox = qt.QWidget(self)
+ hboxLayout = qt.QHBoxLayout(hbox)
+ self.okButton = qt.QPushButton(hbox)
+ self.okButton.setText("Accept")
+ self.okButton.setAutoDefault(False)
+ self.rejectButton = qt.QPushButton(hbox)
+ self.rejectButton.setText("Dismiss")
+ self.rejectButton.setAutoDefault(False)
+ self.okButton.clicked.connect(self.accept)
+ self.rejectButton.clicked.connect(self.reject)
+ hboxLayout.setContentsMargins(0, 0, 0, 0)
+ hboxLayout.setSpacing(2)
+ # hboxLayout.addWidget(qt.HorizontalSpacer(hbox))
+ hboxLayout.addWidget(self.okButton)
+ hboxLayout.addWidget(self.rejectButton)
+ # hboxLayout.addWidget(qt.HorizontalSpacer(hbox))
+ layout.addWidget(self.configurationWidget)
+ layout.addWidget(hbox)
+
+ def setPrintGeometry(self, geometry):
+ """Return the print geometry dictionary.
+
+ See :meth:`PrintGeometryWidget.setPrintGeometry` for documentation on
+ print geometry dictionary.
+
+ :param dict geometry: Print geometry parameters dictionary.
+ """
+ self.configurationWidget.setPrintGeometry(geometry)
+
+ def getPrintGeometry(self):
+ """Return the print geometry dictionary.
+
+ See :meth:`PrintGeometryWidget.setPrintGeometry` for documentation on
+ print geometry dictionary."""
+ return self.configurationWidget.getPrintGeometry()
diff --git a/silx/gui/widgets/PrintPreview.py b/silx/gui/widgets/PrintPreview.py
new file mode 100644
index 0000000..158d6b7
--- /dev/null
+++ b/silx/gui/widgets/PrintPreview.py
@@ -0,0 +1,704 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module implements a print preview dialog.
+
+The dialog provides methods to send images, pixmaps and SVG
+items to the page to be printed.
+
+The user can interactively move and resize the items.
+"""
+import sys
+import logging
+from silx.gui import qt
+
+
+__authors__ = ["V.A. Sole", "P. Knobel"]
+__license__ = "MIT"
+__date__ = "11/07/2017"
+
+
+_logger = logging.getLogger(__name__)
+
+
+class PrintPreviewDialog(qt.QDialog):
+ """Print preview dialog widget.
+ """
+ def __init__(self, parent=None, printer=None):
+
+ qt.QDialog.__init__(self, parent)
+ self.setWindowTitle("Print Preview")
+ self.setModal(False)
+ self.resize(400, 500)
+
+ self.mainLayout = qt.QVBoxLayout(self)
+ self.mainLayout.setContentsMargins(0, 0, 0, 0)
+ self.mainLayout.setSpacing(0)
+
+ self._buildToolbar()
+
+ self.printer = printer
+ # :class:`QPrinter` (paint device that paints on a printer).
+ # :meth:`showEvent` has been reimplemented to enforce printer
+ # setup.
+
+ self.printDialog = None
+ # :class:`QPrintDialog` (dialog for specifying the printer's
+ # configuration)
+
+ self.scene = None
+ # :class:`QGraphicsScene` (surface for managing
+ # 2D graphical items)
+
+ self.page = None
+ # :class:`QGraphicsRectItem` used as white background page on which
+ # to display the print preview.
+
+ self.view = None
+ # :class:`QGraphicsView` widget for displaying :attr:`scene`
+
+ self._svgItems = []
+ # List storing :class:`QSvgRenderer` items to be printed, added in
+ # :meth:`addSvgItem`, cleared in :meth:`_clearAll`.
+ # This ensures that there is a reference pointing to the items,
+ # which ensures they are not destroyed before being printed.
+
+ self._viewScale = 1.0
+ # Zoom level (1.0 is 100%)
+
+ self._toBeCleared = False
+ # Flag indicating that all items must be removed from :attr:`scene`
+ # and from :attr:`_svgItems`.
+ # Set to True after a successful printing. The widget is then hidden,
+ # and it will be cleared the next time it is shown.
+ # Reset to False after :meth:`_clearAll` has done its job.
+
+ def _buildToolbar(self):
+ toolBar = qt.QWidget(self)
+ # a layout for the toolbar
+ toolsLayout = qt.QHBoxLayout(toolBar)
+ toolsLayout.setContentsMargins(0, 0, 0, 0)
+ toolsLayout.setSpacing(0)
+
+ hideBut = qt.QPushButton("Hide", toolBar)
+ hideBut.setToolTip("Hide print preview dialog")
+ hideBut.clicked.connect(self.hide)
+
+ cancelBut = qt.QPushButton("Clear All", toolBar)
+ cancelBut.setToolTip("Remove all items")
+ cancelBut.clicked.connect(self._clearAll)
+
+ removeBut = qt.QPushButton("Remove",
+ toolBar)
+ removeBut.setToolTip("Remove selected item (use left click to select)")
+ removeBut.clicked.connect(self._remove)
+
+ setupBut = qt.QPushButton("Setup", toolBar)
+ setupBut.setToolTip("Select and configure a printer")
+ setupBut.clicked.connect(self.setup)
+
+ printBut = qt.QPushButton("Print", toolBar)
+ printBut.setToolTip("Print page and close print preview")
+ printBut.clicked.connect(self._print)
+
+ zoomPlusBut = qt.QPushButton("Zoom +", toolBar)
+ zoomPlusBut.clicked.connect(self._zoomPlus)
+
+ zoomMinusBut = qt.QPushButton("Zoom -", toolBar)
+ zoomMinusBut.clicked.connect(self._zoomMinus)
+
+ toolsLayout.addWidget(hideBut)
+ toolsLayout.addWidget(printBut)
+ toolsLayout.addWidget(cancelBut)
+ toolsLayout.addWidget(removeBut)
+ toolsLayout.addWidget(setupBut)
+ # toolsLayout.addStretch()
+ # toolsLayout.addWidget(marginLabel)
+ # toolsLayout.addWidget(self.marginSpin)
+ toolsLayout.addStretch()
+ # toolsLayout.addWidget(scaleLabel)
+ # toolsLayout.addWidget(self.scaleCombo)
+ toolsLayout.addWidget(zoomPlusBut)
+ toolsLayout.addWidget(zoomMinusBut)
+ # toolsLayout.addStretch()
+ self.toolBar = toolBar
+ self.mainLayout.addWidget(self.toolBar)
+
+ def _buildStatusBar(self):
+ """Create the status bar used to display the printer name
+ or output file name."""
+ # status bar
+ statusBar = qt.QStatusBar(self)
+ self.targetLabel = qt.QLabel(statusBar)
+ self._updateTargetLabel()
+ statusBar.addWidget(self.targetLabel)
+ self.mainLayout.addWidget(statusBar)
+
+ def _updateTargetLabel(self):
+ """Update printer name or file name shown in the status bar."""
+ if self.printer is None:
+ self.targetLabel.setText("Undefined printer")
+ return
+ if self.printer.outputFileName():
+ self.targetLabel.setText("File:" +
+ self.printer.outputFileName())
+ else:
+ self.targetLabel.setText("Printer:" +
+ self.printer.printerName())
+
+ def _updatePrinter(self):
+ """Resize :attr:`page`, :attr:`scene` and :attr:`view` to :attr:`printer`
+ width and height."""
+ printer = self.printer
+ assert printer is not None, \
+ "_updatePrinter should not be called unless a printer is defined"
+ if self.scene is None:
+ self.scene = qt.QGraphicsScene()
+ self.scene.setBackgroundBrush(qt.QColor(qt.Qt.lightGray))
+ self.scene.setSceneRect(qt.QRectF(0, 0, printer.width(), printer.height()))
+
+ if self.page is None:
+ self.page = qt.QGraphicsRectItem(0, 0, printer.width(), printer.height())
+ self.page.setBrush(qt.QColor(qt.Qt.white))
+ self.scene.addItem(self.page)
+
+ self.scene.setSceneRect(qt.QRectF(0, 0, printer.width(), printer.height()))
+ self.page.setPos(qt.QPointF(0.0, 0.0))
+ self.page.setRect(qt.QRectF(0, 0, printer.width(), printer.height()))
+
+ if self.view is None:
+ self.view = qt.QGraphicsView(self.scene)
+ self.mainLayout.addWidget(self.view)
+ self._buildStatusBar()
+ # self.view.scale(1./self._viewScale, 1./self._viewScale)
+ self.view.fitInView(self.page.rect(), qt.Qt.KeepAspectRatio)
+ self._viewScale = 1.00
+ self._updateTargetLabel()
+
+ # Public methods
+ def addImage(self, image, title=None, comment=None, commentPosition=None):
+ """Add an image to the print preview scene.
+
+ :param QImage image: Image to be added to the scene
+ :param str title: Title shown above (centered) the image
+ :param str comment: Comment displayed below the image
+ :param commentPosition: "CENTER" or "LEFT"
+ """
+ self.addPixmap(qt.QPixmap.fromImage(image),
+ title=title, comment=comment,
+ commentPosition=commentPosition)
+
+ def addPixmap(self, pixmap, title=None, comment=None, commentPosition=None):
+ """Add a pixmap to the print preview scene
+
+ :param QPixmap pixmap: Pixmap to be added to the scene
+ :param str title: Title shown above (centered) the pixmap
+ :param str comment: Comment displayed below the pixmap
+ :param commentPosition: "CENTER" or "LEFT"
+ """
+ if self._toBeCleared:
+ self._clearAll()
+ self.ensurePrinterIsSet()
+ if self.printer is None:
+ _logger.error("printer is not set, cannot add pixmap to page")
+ return
+ if title is None:
+ title = ' ' * 88
+ if comment is None:
+ comment = ' ' * 88
+ if commentPosition is None:
+ commentPosition = "CENTER"
+ if qt.qVersion() < "5.0":
+ rectItem = qt.QGraphicsRectItem(self.page, self.scene)
+ else:
+ rectItem = qt.QGraphicsRectItem(self.page)
+
+ rectItem.setRect(qt.QRectF(1, 1,
+ pixmap.width(), pixmap.height()))
+
+ pen = rectItem.pen()
+ color = qt.QColor(qt.Qt.red)
+ color.setAlpha(1)
+ pen.setColor(color)
+ rectItem.setPen(pen)
+ rectItem.setZValue(1)
+ rectItem.setFlag(qt.QGraphicsItem.ItemIsSelectable, True)
+ rectItem.setFlag(qt.QGraphicsItem.ItemIsMovable, True)
+ rectItem.setFlag(qt.QGraphicsItem.ItemIsFocusable, False)
+
+ rectItemResizeRect = _GraphicsResizeRectItem(rectItem, self.scene)
+ rectItemResizeRect.setZValue(2)
+
+ if qt.qVersion() < "5.0":
+ pixmapItem = qt.QGraphicsPixmapItem(rectItem, self.scene)
+ else:
+ pixmapItem = qt.QGraphicsPixmapItem(rectItem)
+ pixmapItem.setPixmap(pixmap)
+ pixmapItem.setZValue(0)
+
+ # I add the title
+ if qt.qVersion() < "5.0":
+ textItem = qt.QGraphicsTextItem(title, rectItem, self.scene)
+ else:
+ textItem = qt.QGraphicsTextItem(title, rectItem)
+ textItem.setTextInteractionFlags(qt.Qt.TextEditorInteraction)
+ offset = 0.5 * textItem.boundingRect().width()
+ textItem.moveBy(0.5 * pixmap.width() - offset, -20)
+ textItem.setZValue(2)
+
+ # I add the comment
+ if qt.qVersion() < "5.0":
+ commentItem = qt.QGraphicsTextItem(comment, rectItem, self.scene)
+ else:
+ commentItem = qt.QGraphicsTextItem(comment, rectItem)
+ commentItem.setTextInteractionFlags(qt.Qt.TextEditorInteraction)
+ offset = 0.5 * commentItem.boundingRect().width()
+ if commentPosition.upper() == "LEFT":
+ x = 1
+ else:
+ x = 0.5 * pixmap.width() - offset
+ commentItem.moveBy(x, pixmap.height() + 20)
+ commentItem.setZValue(2)
+
+ rectItem.moveBy(20, 40)
+
+ def addSvgItem(self, item, title=None,
+ comment=None, commentPosition=None,
+ viewBox=None):
+ """Add a SVG item to the scene.
+
+ :param QSvgRenderer item: SVG item to be added to the scene.
+ :param str title: Title shown above (centered) the SVG item.
+ :param str comment: Comment displayed below the SVG item.
+ :param str commentPosition: "CENTER" or "LEFT"
+ :param QRectF viewBox: Bounding box for the item on the print page
+ (xOffset, yOffset, width, height). If None, use original
+ item size.
+ """
+ if not qt.HAS_SVG:
+ raise RuntimeError("Missing QtSvg library.")
+ if not isinstance(item, qt.QSvgRenderer):
+ raise TypeError("addSvgItem: QSvgRenderer expected")
+ if self._toBeCleared:
+ self._clearAll()
+ self.ensurePrinterIsSet()
+ if self.printer is None:
+ _logger.error("printer is not set, cannot add SvgItem to page")
+ return
+
+ if title is None:
+ title = 50 * ' '
+ if comment is None:
+ comment = 80 * ' '
+ if commentPosition is None:
+ commentPosition = "CENTER"
+
+ if viewBox is None:
+ if hasattr(item, "_viewBox"):
+ # PyMca compatibility: viewbox attached to item
+ viewBox = item._viewBox
+ else:
+ # try the original item viewbox
+ viewBox = item.viewBoxF()
+
+ svgItem = _GraphicsSvgRectItem(viewBox, self.page)
+ svgItem.setSvgRenderer(item)
+
+ svgItem.setCacheMode(qt.QGraphicsItem.NoCache)
+ svgItem.setZValue(0)
+ svgItem.setFlag(qt.QGraphicsItem.ItemIsSelectable, True)
+ svgItem.setFlag(qt.QGraphicsItem.ItemIsMovable, True)
+ svgItem.setFlag(qt.QGraphicsItem.ItemIsFocusable, False)
+
+ rectItemResizeRect = _GraphicsResizeRectItem(svgItem, self.scene)
+ rectItemResizeRect.setZValue(2)
+
+ self._svgItems.append(item)
+
+ if qt.qVersion() < '5.0':
+ textItem = qt.QGraphicsTextItem(title, svgItem, self.scene)
+ else:
+ textItem = qt.QGraphicsTextItem(title, svgItem)
+ textItem.setTextInteractionFlags(qt.Qt.TextEditorInteraction)
+ title_offset = 0.5 * textItem.boundingRect().width()
+ textItem.setZValue(1)
+ textItem.setFlag(qt.QGraphicsItem.ItemIsMovable, True)
+
+ dummyComment = 80 * "1"
+ if qt.qVersion() < '5.0':
+ commentItem = qt.QGraphicsTextItem(dummyComment, svgItem, self.scene)
+ else:
+ commentItem = qt.QGraphicsTextItem(dummyComment, svgItem)
+ commentItem.setTextInteractionFlags(qt.Qt.TextEditorInteraction)
+ scaleCalculationRect = qt.QRectF(commentItem.boundingRect())
+ scale = svgItem.boundingRect().width() / scaleCalculationRect.width()
+ comment_offset = 0.5 * commentItem.boundingRect().width()
+ if commentPosition.upper() == "LEFT":
+ x = 1
+ else:
+ x = 0.5 * svgItem.boundingRect().width() - comment_offset * scale # fixme: centering
+ commentItem.moveBy(svgItem.boundingRect().x() + x,
+ svgItem.boundingRect().y() + svgItem.boundingRect().height())
+ commentItem.setPlainText(comment)
+ commentItem.setZValue(1)
+
+ commentItem.setFlag(qt.QGraphicsItem.ItemIsMovable, True)
+ if qt.qVersion() < "5.0":
+ commentItem.scale(scale, scale)
+ else:
+ # the correct equivalent would be:
+ # rectItem.setTransform(qt.QTransform.fromScale(scalex, scaley))
+ commentItem.setScale(scale)
+ textItem.moveBy(svgItem.boundingRect().x() +
+ 0.5 * svgItem.boundingRect().width() - title_offset * scale,
+ svgItem.boundingRect().y())
+ if qt.qVersion() < "5.0":
+ textItem.scale(scale, scale)
+ else:
+ # the correct equivalent would be:
+ # rectItem.setTransform(qt.QTransform.fromScale(scalex, scaley))
+ textItem.setScale(scale)
+
+ def setup(self):
+ """Open a print dialog to ensure the :attr:`printer` is set.
+
+ If the setting fails or is cancelled, :attr:`printer` is reset to
+ *None*.
+ """
+ if self.printer is None:
+ self.printer = qt.QPrinter()
+ if self.printDialog is None:
+ self.printDialog = qt.QPrintDialog(self.printer, self)
+ if self.printDialog.exec_():
+ if self.printer.width() <= 0 or self.printer.height() <= 0:
+ self.message = qt.QMessageBox(self)
+ self.message.setIcon(qt.QMessageBox.Critical)
+ self.message.setText("Unknown library error \non printer initialization")
+ self.message.setWindowTitle("Library Error")
+ self.message.setModal(0)
+ self.printer = None
+ return
+ self.printer.setFullPage(True)
+ self._updatePrinter()
+ else:
+ # printer setup cancelled, check for a possible previous configuration
+ if self.page is None:
+ # not initialized
+ self.printer = None
+
+ def ensurePrinterIsSet(self):
+ """If the printer is not already set, try to interactively
+ setup the printer using a QPrintDialog.
+ In case of failure, hide widget and log a warning.
+ """
+ if self.printer is None:
+ self.setup()
+ if self.printer is None:
+ self.hide()
+ _logger.warning("Printer setup failed or was cancelled, " +
+ "but printer is required.")
+
+ def setOutputFileName(self, name):
+ """Set output filename.
+
+ Setting a non-empty name enables printing to file.
+
+ :param str name: File name (path)"""
+ self.printer.setOutputFileName(name)
+
+ # overloaded methods
+ def exec_(self):
+ if self._toBeCleared:
+ self._clearAll()
+ return qt.QDialog.exec_(self)
+
+ def raise_(self):
+ if self._toBeCleared:
+ self._clearAll()
+ return qt.QDialog.raise_(self)
+
+ def showEvent(self, event):
+ """Reimplemented to force printer setup.
+ In case of failure, hide the widget."""
+ if self._toBeCleared:
+ self._clearAll()
+ self.ensurePrinterIsSet()
+
+ return super(PrintPreviewDialog, self).showEvent(event)
+
+ # button callbacks
+ def _print(self):
+ """Do the printing, hide the print preview dialog,
+ set :attr:`_toBeCleared` flag to True to trigger clearing the
+ next time the dialog is shown.
+
+ If the printer is not setup, do it first."""
+ printer = self.printer
+
+ painter = qt.QPainter()
+ if not painter.begin(printer) or printer is None:
+ _logger.error("Cannot initialize printer")
+ return
+ try:
+ self.scene.render(painter, qt.QRectF(0, 0, printer.width(), printer.height()),
+ qt.QRectF(self.page.rect().x(), self.page.rect().y(),
+ self.page.rect().width(), self.page.rect().height()),
+ qt.Qt.KeepAspectRatio)
+ painter.end()
+ self.hide()
+ self.accept()
+ self._toBeCleared = True
+ except: # FIXME
+ painter.end()
+ qt.QMessageBox.critical(self, "ERROR",
+ 'Printing problem:\n %s' % sys.exc_info()[1])
+ _logger.error('printing problem:\n %s' % sys.exc_info()[1])
+ return
+
+ def _zoomPlus(self):
+ self._viewScale *= 1.20
+ self.view.scale(1.20, 1.20)
+
+ def _zoomMinus(self):
+ self._viewScale *= 0.80
+ self.view.scale(0.80, 0.80)
+
+ def _clearAll(self):
+ """
+ Clear the print preview window, remove all items
+ but keep the page.
+ """
+ itemlist = self.scene.items()
+ keep = self.page
+ while len(itemlist) != 1:
+ if itemlist.index(keep) == 0:
+ self.scene.removeItem(itemlist[1])
+ else:
+ self.scene.removeItem(itemlist[0])
+ itemlist = self.scene.items()
+ self._svgItems = []
+ self._toBeCleared = False
+
+ def _remove(self):
+ """Remove selected item in :attr:`scene`.
+ """
+ itemlist = self.scene.items()
+
+ # this loop is not efficient if there are many items ...
+ for item in itemlist:
+ if item.isSelected():
+ self.scene.removeItem(item)
+
+
+class SingletonPrintPreviewDialog(PrintPreviewDialog):
+ """Singleton print preview dialog.
+
+ All widgets in a program that instantiate this class will share
+ a single print preview dialog. This enables sending
+ multiple images to a single page to be printed.
+ """
+ _instance = None
+
+ def __new__(self, *var, **kw):
+ if self._instance is None:
+ self._instance = PrintPreviewDialog(*var, **kw)
+ return self._instance
+
+
+class _GraphicsSvgRectItem(qt.QGraphicsRectItem):
+ """:class:`qt.QGraphicsRectItem` with an attached
+ :class:`qt.QSvgRenderer`, and with a painter redefined to render
+ the SVG item."""
+ def setSvgRenderer(self, renderer):
+ """
+
+ :param QSvgRenderer renderer: svg renderer
+ """
+ self._renderer = renderer
+
+ def paint(self, painter, *var, **kw):
+ self._renderer.render(painter, self.boundingRect())
+
+
+class _GraphicsResizeRectItem(qt.QGraphicsRectItem):
+ """Resizable QGraphicsRectItem."""
+ def __init__(self, parent=None, scene=None, keepratio=True):
+ if qt.qVersion() < '5.0':
+ qt.QGraphicsRectItem.__init__(self, parent, scene)
+ else:
+ qt.QGraphicsRectItem.__init__(self, parent)
+ rect = parent.boundingRect()
+ x = rect.x()
+ y = rect.y()
+ w = rect.width()
+ h = rect.height()
+ self._newRect = None
+ self.keepRatio = keepratio
+ self.setRect(qt.QRectF(x + w - 40, y + h - 40, 40, 40))
+ self.setAcceptHoverEvents(True)
+ pen = qt.QPen()
+ color = qt.QColor(qt.Qt.white)
+ color.setAlpha(0)
+ pen.setColor(color)
+ pen.setStyle(qt.Qt.NoPen)
+ self.setPen(pen)
+ self.setBrush(color)
+ self.setFlag(self.ItemIsMovable, True)
+ self.show()
+
+ def hoverEnterEvent(self, event):
+ if self.parentItem().isSelected():
+ self.parentItem().setSelected(False)
+ if self.keepRatio:
+ self.setCursor(qt.QCursor(qt.Qt.SizeFDiagCursor))
+ else:
+ self.setCursor(qt.QCursor(qt.Qt.SizeAllCursor))
+ self.setBrush(qt.QBrush(qt.Qt.yellow, qt.Qt.SolidPattern))
+ return qt.QGraphicsRectItem.hoverEnterEvent(self, event)
+
+ def hoverLeaveEvent(self, event):
+ self.setCursor(qt.QCursor(qt.Qt.ArrowCursor))
+ pen = qt.QPen()
+ color = qt.QColor(qt.Qt.white)
+ color.setAlpha(0)
+ pen.setColor(color)
+ pen.setStyle(qt.Qt.NoPen)
+ self.setPen(pen)
+ self.setBrush(color)
+ return qt.QGraphicsRectItem.hoverLeaveEvent(self, event)
+
+ def mousePressEvent(self, event):
+ if self._newRect is not None:
+ self._newRect = None
+ self._point0 = self.pos()
+ parent = self.parentItem()
+ scene = self.scene()
+ # following line prevents dragging along the previously selected
+ # item when resizing another one
+ scene.clearSelection()
+ rect = parent.rect()
+ self._x = rect.x()
+ self._y = rect.y()
+ self._w = rect.width()
+ self._h = rect.height()
+ self._ratio = self._w / self._h
+ if qt.qVersion() < "5.0":
+ self._newRect = qt.QGraphicsRectItem(parent, scene)
+ else:
+ self._newRect = qt.QGraphicsRectItem(parent)
+ self._newRect.setRect(qt.QRectF(self._x,
+ self._y,
+ self._w,
+ self._h))
+ qt.QGraphicsRectItem.mousePressEvent(self, event)
+
+ def mouseMoveEvent(self, event):
+ point1 = self.pos()
+ deltax = point1.x() - self._point0.x()
+ deltay = point1.y() - self._point0.y()
+ if self.keepRatio:
+ r1 = (self._w + deltax) / self._w
+ r2 = (self._h + deltay) / self._h
+ if r1 < r2:
+ self._newRect.setRect(qt.QRectF(self._x,
+ self._y,
+ self._w + deltax,
+ (self._w + deltax) / self._ratio))
+ else:
+ self._newRect.setRect(qt.QRectF(self._x,
+ self._y,
+ (self._h + deltay) * self._ratio,
+ self._h + deltay))
+ else:
+ self._newRect.setRect(qt.QRectF(self._x,
+ self._y,
+ self._w + deltax,
+ self._h + deltay))
+ qt.QGraphicsRectItem.mouseMoveEvent(self, event)
+
+ def mouseReleaseEvent(self, event):
+ point1 = self.pos()
+ deltax = point1.x() - self._point0.x()
+ deltay = point1.y() - self._point0.y()
+ self.moveBy(-deltax, -deltay)
+ parent = self.parentItem()
+
+ # deduce scale from rectangle
+ if (qt.qVersion() < "5.0") or self.keepRatio:
+ scalex = self._newRect.rect().width() / self._w
+ scaley = scalex
+ else:
+ scalex = self._newRect.rect().width() / self._w
+ scaley = self._newRect.rect().height() / self._h
+ if qt.qVersion() < "5.0":
+ parent.scale(scalex, scaley)
+ else:
+ # the correct equivalent would be:
+ # rectItem.setTransform(qt.QTransform.fromScale(scalex, scaley))
+ parent.setScale(scalex)
+
+ self.scene().removeItem(self._newRect)
+ self._newRect = None
+ qt.QGraphicsRectItem.mouseReleaseEvent(self, event)
+
+
+def main():
+ """
+ """
+ import sys
+
+ if len(sys.argv) < 2:
+ print("give an image file as parameter please.")
+ sys.exit(1)
+
+ if len(sys.argv) > 2:
+ print("only one parameter please.")
+ sys.exit(1)
+
+ filename = sys.argv[1]
+ w = PrintPreviewDialog()
+ w.resize(400, 500)
+
+ comment = ""
+ for i in range(20):
+ comment += "Line number %d: En un lugar de La Mancha de cuyo nombre ...\n" % i
+
+ if filename[-3:] == "svg":
+ item = qt.QSvgRenderer(filename, w.page)
+ w.addSvgItem(item, title=filename,
+ comment=comment, commentPosition="CENTER")
+ else:
+ w.addPixmap(qt.QPixmap.fromImage(qt.QImage(filename)),
+ title=filename,
+ comment=comment,
+ commentPosition="CENTER")
+ w.addImage(qt.QImage(filename), comment=comment, commentPosition="LEFT")
+
+ sys.exit(w.exec_())
+
+
+if __name__ == '__main__':
+ a = qt.QApplication(sys.argv)
+ main()
+ a.exec_()
diff --git a/silx/gui/widgets/TableWidget.py b/silx/gui/widgets/TableWidget.py
index fad80ee..8167fec 100644
--- a/silx/gui/widgets/TableWidget.py
+++ b/silx/gui/widgets/TableWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2016 European Synchrotron Radiation Facility
+# Copyright (c) 2004-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
@@ -50,7 +50,7 @@ creating the widgets, or later by calling their :meth:`enableCut` and
__authors__ = ["P. Knobel"]
__license__ = "MIT"
-__date__ = "26/01/2017"
+__date__ = "03/07/2017"
import sys
@@ -104,6 +104,8 @@ class CopySelectedCellsAction(qt.QAction):
Put this text into the clipboard.
"""
selected_idx = self.table.selectedIndexes()
+ if not selected_idx:
+ return
selected_idx_tuples = [(idx.row(), idx.column()) for idx in selected_idx]
selected_rows = [idx[0] for idx in selected_idx_tuples]
@@ -334,6 +336,41 @@ class PasteCellsAction(qt.QAction):
return True
+class CopySingleCellAction(qt.QAction):
+ """QAction to copy text from a single cell in a modified
+ :class:`QTableWidget`.
+
+ This action relies on the fact that the text in the last clicked cell
+ are stored in :attr:`_last_cell_clicked` of the modified widget.
+
+ In most cases, :class:`CopySelectedCellsAction` handles single cells,
+ but if the selection mode of the widget has been set to NoSelection
+ it is necessary to use this class instead.
+
+ :param table: :class:`QTableView` to which this action belongs.
+ """
+ def __init__(self, table):
+ if not isinstance(table, qt.QTableView):
+ raise ValueError('CopySingleCellAction must be initialised ' +
+ 'with a QTableWidget.')
+ super(CopySingleCellAction, self).__init__(table)
+ self.setText("Copy cell")
+ self.setToolTip("Copy cell content into the clipboard.")
+ self.triggered.connect(self.copyCellToClipboard)
+ self.table = table
+
+ def copyCellToClipboard(self):
+ """
+ """
+ cell_text = self.table._text_last_cell_clicked
+ if cell_text is None:
+ return
+
+ # put this text into clipboard
+ qapp = qt.QApplication.instance()
+ qapp.clipboard().setText(cell_text)
+
+
class TableWidget(qt.QTableWidget):
""":class:`QTableWidget` with a context menu displaying up to 5 actions:
@@ -350,14 +387,25 @@ class TableWidget(qt.QTableWidget):
overwriting data (no *Undo* action is available). Use :meth:`enablePaste`
and :meth:`enableCut` to activate them.
+ .. image:: img/TableWidget.png
+
:param parent: Parent QWidget
:param bool cut: Enable cut action
:param bool paste: Enable paste action
"""
def __init__(self, parent=None, cut=False, paste=False):
super(TableWidget, self).__init__(parent)
- self.addAction(CopySelectedCellsAction(self))
- self.addAction(CopyAllCellsAction(self))
+ self._text_last_cell_clicked = None
+
+ self.copySelectedCellsAction = CopySelectedCellsAction(self)
+ self.copyAllCellsAction = CopyAllCellsAction(self)
+ self.copySingleCellAction = None
+ self.pasteCellsAction = None
+ self.cutSelectedCellsAction = None
+ self.cutAllCellsAction = None
+
+ self.addAction(self.copySelectedCellsAction)
+ self.addAction(self.copyAllCellsAction)
if cut:
self.enableCut()
if paste:
@@ -365,6 +413,12 @@ class TableWidget(qt.QTableWidget):
self.setContextMenuPolicy(qt.Qt.ActionsContextMenu)
+ def mousePressEvent(self, event):
+ item = self.itemAt(event.pos())
+ if item is not None:
+ self._text_last_cell_clicked = item.text()
+ super(TableWidget, self).mousePressEvent(event)
+
def enablePaste(self):
"""Enable paste action, to paste data from the clipboard into the
table.
@@ -374,7 +428,8 @@ class TableWidget(qt.QTableWidget):
This action can cause data to be overwritten.
There is currently no *Undo* action to retrieve lost data.
"""
- self.addAction(PasteCellsAction(self))
+ self.pasteCellsAction = PasteCellsAction(self)
+ self.addAction(self.pasteCellsAction)
def enableCut(self):
"""Enable cut action.
@@ -383,8 +438,40 @@ class TableWidget(qt.QTableWidget):
This action can cause data to be deleted.
There is currently no *Undo* action to retrieve lost data."""
- self.addAction(CutSelectedCellsAction(self))
- self.addAction(CutAllCellsAction(self))
+ self.cutSelectedCellsAction = CutSelectedCellsAction(self)
+ self.cutAllCellsAction = CutAllCellsAction(self)
+ self.addAction(self.cutSelectedCellsAction)
+ self.addAction(self.cutAllCellsAction)
+
+ def setSelectionMode(self, mode):
+ """Overloaded from QTableWidget to disable cut/copy selection
+ actions in case mode is NoSelection
+
+ :param mode:
+ :return:
+ """
+ if mode == qt.QTableView.NoSelection:
+ self.copySelectedCellsAction.setVisible(False)
+ self.copySelectedCellsAction.setEnabled(False)
+ if self.cutSelectedCellsAction is not None:
+ self.cutSelectedCellsAction.setVisible(False)
+ self.cutSelectedCellsAction.setEnabled(False)
+ if self.copySingleCellAction is None:
+ self.copySingleCellAction = CopySingleCellAction(self)
+ self.insertAction(self.copySelectedCellsAction, # before first action
+ self.copySingleCellAction)
+ self.copySingleCellAction.setVisible(True)
+ self.copySingleCellAction.setEnabled(True)
+ else:
+ self.copySelectedCellsAction.setVisible(True)
+ self.copySelectedCellsAction.setEnabled(True)
+ if self.cutSelectedCellsAction is not None:
+ self.cutSelectedCellsAction.setVisible(True)
+ self.cutSelectedCellsAction.setEnabled(True)
+ if self.copySingleCellAction is not None:
+ self.copySingleCellAction.setVisible(False)
+ self.copySingleCellAction.setEnabled(False)
+ super(TableWidget, self).setSelectionMode(mode)
class TableView(qt.QTableView):
@@ -414,9 +501,24 @@ class TableView(qt.QTableView):
"""
def __init__(self, parent=None, cut=False, paste=False):
super(TableView, self).__init__(parent)
+ self._text_last_cell_clicked = None
+
self.cut = cut
self.paste = paste
+ self.copySelectedCellsAction = None
+ self.copyAllCellsAction = None
+ self.copySingleCellAction = None
+ self.pasteCellsAction = None
+ self.cutSelectedCellsAction = None
+ self.cutAllCellsAction = None
+
+ def mousePressEvent(self, event):
+ qindex = self.indexAt(event.pos())
+ if self.copyAllCellsAction is not None: # model was set
+ self._text_last_cell_clicked = self.model().data(qindex)
+ super(TableView, self).mousePressEvent(event)
+
def setModel(self, model):
"""Set the data model for the table view, activate the actions
and the context menu.
@@ -425,8 +527,10 @@ class TableView(qt.QTableView):
"""
super(TableView, self).setModel(model)
- self.addAction(CopySelectedCellsAction(self))
- self.addAction(CopyAllCellsAction(self))
+ self.copySelectedCellsAction = CopySelectedCellsAction(self)
+ self.copyAllCellsAction = CopyAllCellsAction(self)
+ self.addAction(self.copySelectedCellsAction)
+ self.addAction(self.copyAllCellsAction)
if self.cut:
self.enableCut()
if self.paste:
@@ -443,7 +547,8 @@ class TableView(qt.QTableView):
This action can cause data to be overwritten.
There is currently no *Undo* action to retrieve lost data.
"""
- self.addAction(PasteCellsAction(self))
+ self.pasteCellsAction = PasteCellsAction(self)
+ self.addAction(self.pasteCellsAction)
def enableCut(self):
"""Enable cut action.
@@ -453,8 +558,10 @@ class TableView(qt.QTableView):
This action can cause data to be deleted.
There is currently no *Undo* action to retrieve lost data.
"""
- self.addAction(CutSelectedCellsAction(self))
- self.addAction(CutAllCellsAction(self))
+ self.cutSelectedCellsAction = CutSelectedCellsAction(self)
+ self.cutAllCellsAction = CutAllCellsAction(self)
+ self.addAction(self.cutSelectedCellsAction)
+ self.addAction(self.cutAllCellsAction)
def addAction(self, action):
# ensure the actions are not added multiple times:
@@ -466,6 +573,37 @@ class TableView(qt.QTableView):
return None
super(TableView, self).addAction(action)
+ def setSelectionMode(self, mode):
+ """Overloaded from QTableView to disable cut/copy selection
+ actions in case mode is NoSelection
+
+ :param mode:
+ :return:
+ """
+ if mode == qt.QTableView.NoSelection:
+ self.copySelectedCellsAction.setVisible(False)
+ self.copySelectedCellsAction.setEnabled(False)
+ if self.cutSelectedCellsAction is not None:
+ self.cutSelectedCellsAction.setVisible(False)
+ self.cutSelectedCellsAction.setEnabled(False)
+ if self.copySingleCellAction is None:
+ self.copySingleCellAction = CopySingleCellAction(self)
+ self.insertAction(self.copySelectedCellsAction, # before first action
+ self.copySingleCellAction)
+ self.copySingleCellAction.setVisible(True)
+ self.copySingleCellAction.setEnabled(True)
+ else:
+ self.copySelectedCellsAction.setVisible(True)
+ self.copySelectedCellsAction.setEnabled(True)
+ if self.cutSelectedCellsAction is not None:
+ self.cutSelectedCellsAction.setVisible(True)
+ self.cutSelectedCellsAction.setEnabled(True)
+ if self.copySingleCellAction is not None:
+ self.copySingleCellAction.setVisible(False)
+ self.copySingleCellAction.setEnabled(False)
+ super(TableView, self).setSelectionMode(mode)
+
+
if __name__ == "__main__":
app = qt.QApplication([])
diff --git a/silx/gui/widgets/ThreadPoolPushButton.py b/silx/gui/widgets/ThreadPoolPushButton.py
index 29e831d..4dba488 100644
--- a/silx/gui/widgets/ThreadPoolPushButton.py
+++ b/silx/gui/widgets/ThreadPoolPushButton.py
@@ -102,6 +102,8 @@ class ThreadPoolPushButton(WaitingPushButton):
>>> button.setCallable(math.pow, 2, 16)
>>> button.succeeded.connect(print) # python3
+ .. image:: img/ThreadPoolPushButton.png
+
>>> # Compute a wrong value
>>> import math
>>> button = ThreadPoolPushButton(text="Compute sqrt(-1)")
diff --git a/silx/gui/widgets/WaitingPushButton.py b/silx/gui/widgets/WaitingPushButton.py
index 49ab9b9..499de1a 100644
--- a/silx/gui/widgets/WaitingPushButton.py
+++ b/silx/gui/widgets/WaitingPushButton.py
@@ -40,6 +40,8 @@ class WaitingPushButton(qt.QPushButton):
The component is graphically disabled when it is in waiting. Then we
overwrite the enabled method to dissociate the 2 concepts:
graphically enabled/disabled, and enabled/disabled
+
+ .. image:: img/WaitingPushButton.png
"""
def __init__(self, parent=None, text=None, icon=None):
diff --git a/silx/gui/widgets/test/__init__.py b/silx/gui/widgets/test/__init__.py
index afa0f78..7affc20 100644
--- a/silx/gui/widgets/test/__init__.py
+++ b/silx/gui/widgets/test/__init__.py
@@ -28,10 +28,11 @@ from . import test_periodictable
from . import test_tablewidget
from . import test_threadpoolpushbutton
from . import test_hierarchicaltableview
+from . import test_printpreview
__authors__ = ["V. Valls", "P. Knobel"]
__license__ = "MIT"
-__date__ = "07/04/2017"
+__date__ = "19/07/2017"
def suite():
@@ -40,6 +41,7 @@ def suite():
[test_threadpoolpushbutton.suite(),
test_tablewidget.suite(),
test_periodictable.suite(),
+ test_printpreview.suite(),
test_hierarchicaltableview.suite(),
])
return test_suite
diff --git a/silx/gui/widgets/test/test_printpreview.py b/silx/gui/widgets/test/test_printpreview.py
new file mode 100644
index 0000000..ecb165a
--- /dev/null
+++ b/silx/gui/widgets/test/test_printpreview.py
@@ -0,0 +1,74 @@
+# 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 PrintPreview"""
+
+__authors__ = ["P. Knobel"]
+__license__ = "MIT"
+__date__ = "19/07/2017"
+
+
+import unittest
+from silx.gui.test.utils import TestCaseQt
+from silx.gui.widgets.PrintPreview import PrintPreviewDialog
+from silx.gui import qt
+
+from silx.resources import resource_filename
+
+
+class TestPrintPreview(TestCaseQt):
+ def testShow(self):
+ p = qt.QPrinter()
+ d = PrintPreviewDialog(printer=p)
+ d.show()
+ self.qapp.processEvents()
+
+ def testAddImage(self):
+ p = qt.QPrinter()
+ d = PrintPreviewDialog(printer=p)
+ d.addImage(qt.QImage(resource_filename("gui/icons/clipboard.png")))
+ self.qapp.processEvents()
+
+ def testAddSvg(self):
+ p = qt.QPrinter()
+ d = PrintPreviewDialog(printer=p)
+ d.addSvgItem(qt.QSvgRenderer(resource_filename("gui/icons/clipboard.svg"), d.page))
+ self.qapp.processEvents()
+
+ def testAddPixmap(self):
+ p = qt.QPrinter()
+ d = PrintPreviewDialog(printer=p)
+ d.addPixmap(qt.QPixmap.fromImage(qt.QImage(resource_filename("gui/icons/clipboard.png"))))
+ self.qapp.processEvents()
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(TestPrintPreview))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')