diff options
Diffstat (limited to 'silx/gui/plot/items')
-rw-r--r-- | silx/gui/plot/items/__init__.py | 7 | ||||
-rw-r--r-- | silx/gui/plot/items/axis.py | 8 | ||||
-rw-r--r-- | silx/gui/plot/items/complex.py | 356 | ||||
-rw-r--r-- | silx/gui/plot/items/core.py | 95 | ||||
-rw-r--r-- | silx/gui/plot/items/image.py | 7 | ||||
-rw-r--r-- | silx/gui/plot/items/marker.py | 3 |
6 files changed, 451 insertions, 25 deletions
diff --git a/silx/gui/plot/items/__init__.py b/silx/gui/plot/items/__init__.py index bf39c87..e7957ac 100644 --- a/silx/gui/plot/items/__init__.py +++ b/silx/gui/plot/items/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -35,6 +35,7 @@ __date__ = "22/06/2017" from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, # noqa SymbolMixIn, ColorMixIn, YAxisMixIn, FillMixIn, # noqa AlphaMixIn, LineMixIn, ItemChangedType) # noqa +from .complex import ImageComplexData # noqa from .curve import Curve # noqa from .histogram import Histogram # noqa from .image import ImageBase, ImageData, ImageRgba, MaskImageData # noqa @@ -42,3 +43,7 @@ from .shape import Shape # noqa from .scatter import Scatter # noqa from .marker import Marker, XMarker, YMarker # noqa from .axis import Axis, XAxis, YAxis, YRightAxis + +DATA_ITEMS = ImageComplexData, Curve, Histogram, ImageBase, Scatter +"""Classes of items representing data and to consider to compute data bounds. +""" diff --git a/silx/gui/plot/items/axis.py b/silx/gui/plot/items/axis.py index ff36512..d7e6eff 100644 --- a/silx/gui/plot/items/axis.py +++ b/silx/gui/plot/items/axis.py @@ -27,7 +27,7 @@ __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "30/08/2017" +__date__ = "06/12/2017" import logging from ... import qt @@ -66,7 +66,7 @@ class Axis(qt.QObject): """Signal emitted when axis autoscale has changed""" sigLimitsChanged = qt.Signal(float, float) - """Signal emitted when axis autoscale has changed""" + """Signal emitted when axis limits have changed""" def __init__(self, plot): """Constructor @@ -262,7 +262,7 @@ class Axis(qt.QObject): def setLimitsConstraints(self, minPos=None, maxPos=None): """ - Set a constaints on the position of the axes. + Set a constraint on the position of the axes. :param float minPos: Minimum allowed axis value. :param float maxPos: Maximum allowed axis value. @@ -283,7 +283,7 @@ class Axis(qt.QObject): def setRangeConstraints(self, minRange=None, maxRange=None): """ - Set a constaints on the position of the axes. + Set a constraint on the position of the axes. :param float minRange: Minimum allowed left-to-right span across the view diff --git a/silx/gui/plot/items/complex.py b/silx/gui/plot/items/complex.py new file mode 100644 index 0000000..ba57e85 --- /dev/null +++ b/silx/gui/plot/items/complex.py @@ -0,0 +1,356 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides the :class:`ImageComplexData` of the :class:`Plot`. +""" + +from __future__ import absolute_import + +__authors__ = ["Vincent Favre-Nicolin", "T. Vincent"] +__license__ = "MIT" +__date__ = "19/01/2018" + + +import logging +import numpy + +from silx.third_party import enum + +from ..Colormap import Colormap +from .core import ColormapMixIn, ItemChangedType +from .image import ImageBase + + +_logger = logging.getLogger(__name__) + + +# Complex colormap functions + +def _phase2rgb(colormap, data): + """Creates RGBA image with colour-coded phase. + + :param Colormap colormap: The colormap to use + :param numpy.ndarray data: The data to convert + :return: Array of RGBA colors + :rtype: numpy.ndarray + """ + if data.size == 0: + return numpy.zeros((0, 0, 4), dtype=numpy.uint8) + + phase = numpy.angle(data) + return colormap.applyToData(phase) + + +def _complex2rgbalog(phaseColormap, data, amin=0., dlogs=2, smax=None): + """Returns RGBA colors: colour-coded phases and log10(amplitude) in alpha. + + :param Colormap phaseColormap: Colormap to use for the phase + :param numpy.ndarray data: the complex data array to convert to RGBA + :param float amin: the minimum value for the alpha channel + :param float dlogs: amplitude range displayed, in log10 units + :param float smax: + if specified, all values above max will be displayed with an alpha=1 + """ + if data.size == 0: + return numpy.zeros((0, 0, 4), dtype=numpy.uint8) + + rgba = _phase2rgb(phaseColormap, data) + sabs = numpy.absolute(data) + if smax is not None: + sabs[sabs > smax] = smax + a = numpy.log10(sabs + 1e-20) + a -= a.max() - dlogs # display dlogs orders of magnitude + rgba[..., 3] = 255 * (amin + a / dlogs * (1 - amin) * (a > 0)) + return rgba + + +def _complex2rgbalin(phaseColormap, data, gamma=1.0, smax=None): + """Returns RGBA colors: colour-coded phase and linear amplitude in alpha. + + :param Colormap phaseColormap: Colormap to use for the phase + :param numpy.ndarray data: + :param float gamma: Optional exponent gamma applied to the amplitude + :param float smax: + """ + if data.size == 0: + return numpy.zeros((0, 0, 4), dtype=numpy.uint8) + + rgba = _phase2rgb(phaseColormap, data) + a = numpy.absolute(data) + if smax is not None: + a[a > smax] = smax + a /= a.max() + rgba[..., 3] = 255 * a**gamma + return rgba + + +class ImageComplexData(ImageBase, ColormapMixIn): + """Specific plot item to force colormap when using complex colormap. + + This is returning the specific colormap when displaying + colored phase + amplitude. + """ + + class Mode(enum.Enum): + """Identify available display mode for complex""" + ABSOLUTE = 'absolute' + PHASE = 'phase' + REAL = 'real' + IMAGINARY = 'imaginary' + AMPLITUDE_PHASE = 'amplitude_phase' + LOG10_AMPLITUDE_PHASE = 'log10_amplitude_phase' + SQUARE_AMPLITUDE = 'square_amplitude' + + def __init__(self): + ImageBase.__init__(self) + ColormapMixIn.__init__(self) + self._data = numpy.zeros((0, 0), dtype=numpy.complex64) + self._dataByModesCache = {} + self._mode = self.Mode.ABSOLUTE + self._amplitudeRangeInfo = None, 2 + + # Use default from ColormapMixIn + colormap = super(ImageComplexData, self).getColormap() + + phaseColormap = Colormap( + name='hsv', + vmin=-numpy.pi, + vmax=numpy.pi) + phaseColormap.setEditable(False) + + self._colormaps = { # Default colormaps for all modes + self.Mode.ABSOLUTE: colormap, + self.Mode.PHASE: phaseColormap, + self.Mode.REAL: colormap, + self.Mode.IMAGINARY: colormap, + self.Mode.AMPLITUDE_PHASE: phaseColormap, + self.Mode.LOG10_AMPLITUDE_PHASE: phaseColormap, + self.Mode.SQUARE_AMPLITUDE: colormap, + } + + def _addBackendRenderer(self, backend): + """Update backend renderer""" + plot = self.getPlot() + assert plot is not None + if not self._isPlotLinear(plot): + # Do not render with non linear scales + return None + + mode = self.getVisualizationMode() + if mode in (self.Mode.AMPLITUDE_PHASE, + self.Mode.LOG10_AMPLITUDE_PHASE): + # For those modes, compute RGBA image here + colormap = None + data = self.getRgbaImageData(copy=False) + else: + colormap = self.getColormap() + data = self.getData(copy=False) + + if data.size == 0: + return None # No data to display + + return backend.addImage(data, + legend=self.getLegend(), + origin=self.getOrigin(), + scale=self.getScale(), + z=self.getZValue(), + selectable=self.isSelectable(), + draggable=self.isDraggable(), + colormap=colormap, + alpha=self.getAlpha()) + + + def setVisualizationMode(self, mode): + """Set the visualization mode to use. + + :param Mode mode: + """ + assert isinstance(mode, self.Mode) + assert mode in self._colormaps + + if mode != self._mode: + self._mode = mode + + self._updated(ItemChangedType.VISUALIZATION_MODE) + + # Send data updated as value returned by getData has changed + self._updated(ItemChangedType.DATA) + + # Update ColormapMixIn colormap + colormap = self._colormaps[self._mode] + if colormap is not super(ImageComplexData, self).getColormap(): + super(ImageComplexData, self).setColormap(colormap) + + def getVisualizationMode(self): + """Returns the visualization mode in use. + + :rtype: Mode + """ + return self._mode + + def _setAmplitudeRangeInfo(self, max_=None, delta=2): + """Set the amplitude range to display for 'log10_amplitude_phase' mode. + + :param max_: Max of the amplitude range. + If None it autoscales to data max. + :param float delta: Delta range in log10 to display + """ + self._amplitudeRangeInfo = max_, float(delta) + self._updated(ItemChangedType.VISUALIZATION_MODE) + + def _getAmplitudeRangeInfo(self): + """Returns the amplitude range to use for 'log10_amplitude_phase' mode. + + :return: (max, delta), if max is None, then it autoscales to data max + :rtype: 2-tuple""" + return self._amplitudeRangeInfo + + def setColormap(self, colormap, mode=None): + """Set the colormap for this specific mode. + + :param ~silx.gui.plot.Colormap.Colormap colormap: The colormap + :param Mode mode: + If specified, set the colormap of this specific mode. + Default: current mode. + """ + if mode is None: + mode = self.getVisualizationMode() + + self._colormaps[mode] = colormap + if mode is self.getVisualizationMode(): + super(ImageComplexData, self).setColormap(colormap) + else: + self._updated(ItemChangedType.COLORMAP) + + def getColormap(self, mode=None): + """Get the colormap for the (current) mode. + + :param Mode mode: + If specified, get the colormap of this specific mode. + Default: current mode. + :rtype: ~silx.gui.plot.Colormap.Colormap + """ + if mode is None: + mode = self.getVisualizationMode() + + return self._colormaps[mode] + + def setData(self, data, copy=True): + """"Set the image complex data + + :param numpy.ndarray data: 2D array of complex with 2 dimensions (h, w) + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + """ + data = numpy.array(data, copy=copy) + assert data.ndim == 2 + if not numpy.issubdtype(data.dtype, numpy.complexfloating): + _logger.warning( + 'Image is not complex, converting it to complex to plot it.') + data = numpy.array(data, dtype=numpy.complex64) + + self._data = data + self._dataByModesCache = {} + + # TODO hackish data range implementation + if self.isVisible(): + plot = self.getPlot() + if plot is not None: + plot._invalidateDataRange() + + self._updated(ItemChangedType.DATA) + + def getComplexData(self, copy=True): + """Returns the image complex data + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: numpy.ndarray of complex + """ + return numpy.array(self._data, copy=copy) + + def getData(self, copy=True, mode=None): + """Returns the image data corresponding to (current) mode. + + The returned data is always floats, to get the complex data, use + :meth:`getComplexData`. + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :param Mode mode: + If specified, get data corresponding to the mode. + Default: Current mode. + :rtype: numpy.ndarray of float + """ + if mode is None: + mode = self.getVisualizationMode() + + if mode not in self._dataByModesCache: + # Compute data for mode and store it in cache + complexData = self.getComplexData(copy=False) + if mode is self.Mode.PHASE: + data = numpy.angle(complexData) + elif mode is self.Mode.REAL: + data = numpy.real(complexData) + elif mode is self.Mode.IMAGINARY: + data = numpy.imag(complexData) + elif mode in (self.Mode.ABSOLUTE, + self.Mode.LOG10_AMPLITUDE_PHASE, + self.Mode.AMPLITUDE_PHASE): + data = numpy.absolute(complexData) + elif mode is self.Mode.SQUARE_AMPLITUDE: + data = numpy.absolute(complexData) ** 2 + else: + _logger.error( + 'Unsupported conversion mode: %s, fallback to absolute', + str(mode)) + data = numpy.absolute(complexData) + + self._dataByModesCache[mode] = data + + return numpy.array(self._dataByModesCache[mode], copy=copy) + + def getRgbaImageData(self, copy=True, mode=None): + """Get the displayed RGB(A) image for (current) mode + + :param bool copy: Ignored for this class + :param Mode mode: + If specified, get data corresponding to the mode. + Default: Current mode. + :rtype: numpy.ndarray of uint8 of shape (height, width, 4) + """ + if mode is None: + mode = self.getVisualizationMode() + + colormap = self.getColormap(mode=mode) + if mode is self.Mode.AMPLITUDE_PHASE: + data = self.getComplexData(copy=False) + return _complex2rgbalin(colormap, data) + elif mode is self.Mode.LOG10_AMPLITUDE_PHASE: + data = self.getComplexData(copy=False) + max_, delta = self._getAmplitudeRangeInfo() + return _complex2rgbalog(colormap, data, dlogs=delta, smax=max_) + else: + data = self.getData(copy=False, mode=mode) + return colormap.applyToData(data) diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py index 34ac700..bcb6dd1 100644 --- a/silx/gui/plot/items/core.py +++ b/silx/gui/plot/items/core.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -115,6 +115,9 @@ class ItemChangedType(enum.Enum): OVERLAY = 'overlayChanged' """Item's overlay state changed flag.""" + VISUALIZATION_MODE = 'visualizationModeChanged' + """Item's visualization mode changed flag.""" + class Item(qt.QObject): """Description of an item of the plot""" @@ -136,7 +139,7 @@ class Item(qt.QObject): """ def __init__(self): - super(Item, self).__init__() + qt.QObject.__init__(self) self._dirty = True self._plotRef = None self._visible = True @@ -312,7 +315,24 @@ class Item(qt.QObject): # Mix-in classes ############################################################## -class LabelsMixIn(object): +class ItemMixInBase(qt.QObject): + """Base class for Item mix-in""" + + def _updated(self, event=None, checkVisibility=True): + """This is implemented in :class:`Item`. + + Mark the item as dirty (i.e., needing update). + This also triggers Plot.replot. + + :param event: The event to send to :attr:`sigItemChanged` signal. + :param bool checkVisibility: True to only mark as dirty if visible, + False to always mark as dirty. + """ + raise RuntimeError( + "Issue with Mix-In class inheritance order") + + +class LabelsMixIn(ItemMixInBase): """Mix-in class for items with x and y labels Setters are private, otherwise it needs to check the plot @@ -352,7 +372,7 @@ class LabelsMixIn(object): self._ylabel = str(label) -class DraggableMixIn(object): +class DraggableMixIn(ItemMixInBase): """Mix-in class for draggable items""" def __init__(self): @@ -375,7 +395,7 @@ class DraggableMixIn(object): self._draggable = bool(draggable) -class ColormapMixIn(object): +class ColormapMixIn(ItemMixInBase): """Mix-in class for items with colormap""" def __init__(self): @@ -389,7 +409,7 @@ class ColormapMixIn(object): def setColormap(self, colormap): """Set the colormap of this image - :param Colormap colormap: colormap description + :param silx.gui.plot.Colormap.Colormap colormap: colormap description """ if isinstance(colormap, dict): colormap = Colormap._fromDict(colormap) @@ -406,7 +426,7 @@ class ColormapMixIn(object): self._updated(ItemChangedType.COLORMAP) -class SymbolMixIn(object): +class SymbolMixIn(ItemMixInBase): """Mix-in class for items with symbol type""" _DEFAULT_SYMBOL = '' @@ -415,10 +435,49 @@ class SymbolMixIn(object): _DEFAULT_SYMBOL_SIZE = 6.0 """Default marker size of the item""" + _SUPPORTED_SYMBOLS = collections.OrderedDict(( + ('o', 'Circle'), + ('d', 'Diamond'), + ('s', 'Square'), + ('+', 'Plus'), + ('x', 'Cross'), + ('.', 'Point'), + (',', 'Pixel'), + ('', 'None'))) + """Dict of supported symbols""" + def __init__(self): self._symbol = self._DEFAULT_SYMBOL self._symbol_size = self._DEFAULT_SYMBOL_SIZE + @classmethod + def getSupportedSymbols(cls): + """Returns the list of supported symbol names. + + :rtype: tuple of str + """ + return tuple(cls._SUPPORTED_SYMBOLS.keys()) + + @classmethod + def getSupportedSymbolNames(cls): + """Returns the list of supported symbol human-readable names. + + :rtype: tuple of str + """ + return tuple(cls._SUPPORTED_SYMBOLS.values()) + + def getSymbolName(self, symbol=None): + """Returns human-readable name for a symbol. + + :param str symbol: The symbol from which to get the name. + Default: current symbol. + :rtype: str + :raise KeyError: if symbol is not in :meth:`getSupportedSymbols`. + """ + if symbol is None: + symbol = self.getSymbol() + return self._SUPPORTED_SYMBOLS[symbol] + def getSymbol(self): """Return the point marker type. @@ -441,11 +500,19 @@ class SymbolMixIn(object): See :meth:`getSymbol`. - :param str symbol: Marker type + :param str symbol: Marker type or marker name """ - assert symbol in ('o', '.', ',', '+', 'x', 'd', 's', '', None) if symbol is None: symbol = self._DEFAULT_SYMBOL + + elif symbol not in self.getSupportedSymbols(): + for symbolCode, name in self._SUPPORTED_SYMBOLS.items(): + if name.lower() == symbol.lower(): + symbol = symbolCode + break + else: + raise ValueError('Unsupported symbol %s' % str(symbol)) + if symbol != self._symbol: self._symbol = symbol self._updated(ItemChangedType.SYMBOL) @@ -471,7 +538,7 @@ class SymbolMixIn(object): self._updated(ItemChangedType.SYMBOL_SIZE) -class LineMixIn(object): +class LineMixIn(ItemMixInBase): """Mix-in class for item with line""" _DEFAULT_LINEWIDTH = 1. @@ -531,7 +598,7 @@ class LineMixIn(object): self._updated(ItemChangedType.LINE_STYLE) -class ColorMixIn(object): +class ColorMixIn(ItemMixInBase): """Mix-in class for item with color""" _DEFAULT_COLOR = (0., 0., 0., 1.) @@ -570,7 +637,7 @@ class ColorMixIn(object): self._updated(ItemChangedType.COLOR) -class YAxisMixIn(object): +class YAxisMixIn(ItemMixInBase): """Mix-in class for item with yaxis""" _DEFAULT_YAXIS = 'left' @@ -600,7 +667,7 @@ class YAxisMixIn(object): self._updated(ItemChangedType.YAXIS) -class FillMixIn(object): +class FillMixIn(ItemMixInBase): """Mix-in class for item with fill""" def __init__(self): @@ -624,7 +691,7 @@ class FillMixIn(object): self._updated(ItemChangedType.FILL) -class AlphaMixIn(object): +class AlphaMixIn(ItemMixInBase): """Mix-in class for item with opacity""" def __init__(self): diff --git a/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py index acf7bf6..99a916a 100644 --- a/silx/gui/plot/items/image.py +++ b/silx/gui/plot/items/image.py @@ -28,7 +28,7 @@ of the :class:`Plot`. __authors__ = ["T. Vincent"] __license__ = "MIT" -__date__ = "27/06/2017" +__date__ = "20/10/2017" from collections import Sequence @@ -38,7 +38,6 @@ import numpy from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, AlphaMixIn, ItemChangedType) -from ..Colors import applyColormapToData _logger = logging.getLogger(__name__) @@ -62,7 +61,7 @@ def _convertImageToRgba32(image, copy=True): assert image.shape[-1] in (3, 4) # Convert type to uint8 - if image.dtype.name != 'uin8': + if image.dtype.name != 'uint8': if image.dtype.kind == 'f': # Float in [0, 1] image = (numpy.clip(image, 0., 1.) * 255).astype(numpy.uint8) elif image.dtype.kind == 'b': # boolean @@ -334,7 +333,7 @@ class ImageData(ImageBase, ColormapMixIn): _logger.warning( 'Converting boolean image to int8 to plot it.') data = numpy.array(data, copy=False, dtype=numpy.int8) - elif numpy.issubdtype(data.dtype, numpy.complex): + elif numpy.iscomplexobj(data): _logger.warning( 'Converting complex image to absolute value to plot it.') data = numpy.absolute(data) diff --git a/silx/gui/plot/items/marker.py b/silx/gui/plot/items/marker.py index 5f930b7..8f79033 100644 --- a/silx/gui/plot/items/marker.py +++ b/silx/gui/plot/items/marker.py @@ -69,8 +69,7 @@ class _BaseMarker(Item, DraggableMixIn, ColorMixIn): selectable=self.isSelectable(), draggable=self.isDraggable(), symbol=symbol, - constraint=self.getConstraint(), - overlay=self.isOverlay()) + constraint=self.getConstraint()) def isOverlay(self): """Return true if marker is drawn as an overlay. |