summaryrefslogtreecommitdiff
path: root/silx/gui/plot/ComplexImageView.py
diff options
context:
space:
mode:
authorPicca Frédéric-Emmanuel <picca@debian.org>2017-10-07 07:59:01 +0200
committerPicca Frédéric-Emmanuel <picca@debian.org>2017-10-07 07:59:01 +0200
commitbfa4dba15485b4192f8bbe13345e9658c97ecf76 (patch)
treefb9c6e5860881fbde902f7cbdbd41dc4a3a9fb5d /silx/gui/plot/ComplexImageView.py
parentf7bdc2acff3c13a6d632c28c4569690ab106eed7 (diff)
New upstream version 0.6.0+dfsg
Diffstat (limited to 'silx/gui/plot/ComplexImageView.py')
-rw-r--r--silx/gui/plot/ComplexImageView.py670
1 files changed, 670 insertions, 0 deletions
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()