# 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_)
[docs]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)
[docs] 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)
[docs] 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()
[docs] 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)
[docs] 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)
[docs] @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')
[docs] 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)
[docs] 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
[docs] 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)
[docs] 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)
[docs] def getOrigin(self):
"""Returns the offset from origin at which to display the image.
:rtype: 2-tuple of float
"""
return self._plotImage.getOrigin()
[docs] 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)
[docs] def getScale(self):
"""Returns the scale of the image in data coordinates.
:rtype: 2-tuple of float
"""
return self._plotImage.getScale()
[docs] 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
[docs] def getXAxis(self):
"""Returns the X axis
:rtype: :class:`.items.Axis`
"""
return self.getPlot().getXAxis()
[docs] def getYAxis(self):
"""Returns an Y axis
:rtype: :class:`.items.Axis`
"""
return self.getPlot().getYAxis(axis='left')
[docs] def getGraphTitle(self):
"""Return the plot main title as a str."""
return self.getPlot().getGraphTitle()
[docs] def setGraphTitle(self, title=""):
"""Set the plot main title.
:param str title: Main title of the plot (default: '')
"""
self.getPlot().setGraphTitle(title)
[docs] 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)
[docs] def isKeepDataAspectRatio(self):
"""Returns whether the plot is keeping data aspect ratio or not."""
return self.getPlot().isKeepDataAspectRatio()