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/complex.py | 121 | ||||
-rw-r--r-- | silx/gui/plot/items/core.py | 197 | ||||
-rw-r--r-- | silx/gui/plot/items/curve.py | 6 | ||||
-rw-r--r-- | silx/gui/plot/items/image.py | 72 | ||||
-rw-r--r-- | silx/gui/plot/items/marker.py | 17 | ||||
-rw-r--r-- | silx/gui/plot/items/roi.py | 40 | ||||
-rw-r--r-- | silx/gui/plot/items/scatter.py | 231 |
8 files changed, 565 insertions, 126 deletions
diff --git a/silx/gui/plot/items/__init__.py b/silx/gui/plot/items/__init__.py index f829f78..f3a36db 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-2018 European Synchrotron Radiation Facility +# Copyright (c) 2017-2019 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 @@ -34,14 +34,15 @@ __date__ = "22/06/2017" from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, # noqa SymbolMixIn, ColorMixIn, YAxisMixIn, FillMixIn, # noqa - AlphaMixIn, LineMixIn, ItemChangedType) # noqa + AlphaMixIn, LineMixIn, ScatterVisualizationMixIn, # noqa + ComplexMixIn, ItemChangedType, PointsBase) # noqa from .complex import ImageComplexData # noqa from .curve import Curve, CurveStyle # noqa from .histogram import Histogram # 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 .marker import MarkerBase, Marker, XMarker, YMarker # noqa from .axis import Axis, XAxis, YAxis, YRightAxis DATA_ITEMS = ImageComplexData, Curve, Histogram, ImageBase, Scatter diff --git a/silx/gui/plot/items/complex.py b/silx/gui/plot/items/complex.py index 7fffd77..3869a05 100644 --- a/silx/gui/plot/items/complex.py +++ b/silx/gui/plot/items/complex.py @@ -33,12 +33,13 @@ __date__ = "14/06/2018" import logging -import enum import numpy +from ....utils.proxy import docstring +from ....utils.deprecation import deprecated from ...colors import Colormap -from .core import ColormapMixIn, ItemChangedType +from .core import ColormapMixIn, ComplexMixIn, ItemChangedType from .image import ImageBase @@ -105,29 +106,19 @@ def _complex2rgbalin(phaseColormap, data, gamma=1.0, smax=None): return rgba -class ImageComplexData(ImageBase, ColormapMixIn): +class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): """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) + ComplexMixIn.__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 @@ -139,13 +130,13 @@ class ImageComplexData(ImageBase, ColormapMixIn): vmax=numpy.pi) 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, + self.ComplexMode.ABSOLUTE: colormap, + self.ComplexMode.PHASE: phaseColormap, + self.ComplexMode.REAL: colormap, + self.ComplexMode.IMAGINARY: colormap, + self.ComplexMode.AMPLITUDE_PHASE: phaseColormap, + self.ComplexMode.LOG10_AMPLITUDE_PHASE: phaseColormap, + self.ComplexMode.SQUARE_AMPLITUDE: colormap, } def _addBackendRenderer(self, backend): @@ -156,9 +147,9 @@ class ImageComplexData(ImageBase, ColormapMixIn): # Do not render with non linear scales return None - mode = self.getVisualizationMode() - if mode in (self.Mode.AMPLITUDE_PHASE, - self.Mode.LOG10_AMPLITUDE_PHASE): + mode = self.getComplexMode() + if mode in (self.ComplexMode.AMPLITUDE_PHASE, + self.ComplexMode.LOG10_AMPLITUDE_PHASE): # For those modes, compute RGBA image here colormap = None data = self.getRgbaImageData(copy=False) @@ -179,33 +170,21 @@ class ImageComplexData(ImageBase, ColormapMixIn): 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 - + @docstring(ComplexMixIn) + def setComplexMode(self, mode): + changed = super(ImageComplexData, self).setComplexMode(mode) + if changed: + # Backward compatibility 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] + colormap = self._colormaps[self.getComplexMode()] 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 + return changed def _setAmplitudeRangeInfo(self, max_=None, delta=2): """Set the amplitude range to display for 'log10_amplitude_phase' mode. @@ -228,15 +207,17 @@ class ImageComplexData(ImageBase, ColormapMixIn): """Set the colormap for this specific mode. :param ~silx.gui.colors.Colormap colormap: The colormap - :param Mode mode: + :param Union[ComplexMode,str] mode: If specified, set the colormap of this specific mode. Default: current mode. """ if mode is None: - mode = self.getVisualizationMode() + mode = self.getComplexMode() + else: + mode = self.ComplexMode.from_value(mode) self._colormaps[mode] = colormap - if mode is self.getVisualizationMode(): + if mode is self.getComplexMode(): super(ImageComplexData, self).setColormap(colormap) else: self._updated(ItemChangedType.COLORMAP) @@ -244,13 +225,15 @@ class ImageComplexData(ImageBase, ColormapMixIn): def getColormap(self, mode=None): """Get the colormap for the (current) mode. - :param Mode mode: + :param Union[ComplexMode,str] mode: If specified, get the colormap of this specific mode. Default: current mode. :rtype: ~silx.gui.colors.Colormap """ if mode is None: - mode = self.getVisualizationMode() + mode = self.getComplexMode() + else: + mode = self.ComplexMode.from_value(mode) return self._colormaps[mode] @@ -296,28 +279,30 @@ class ImageComplexData(ImageBase, ColormapMixIn): :param bool copy: True (Default) to get a copy, False to use internal representation (do not modify!) - :param Mode mode: + :param Union[ComplexMode,str] mode: If specified, get data corresponding to the mode. Default: Current mode. :rtype: numpy.ndarray of float """ if mode is None: - mode = self.getVisualizationMode() + mode = self.getComplexMode() + else: + mode = self.ComplexMode.from_value(mode) 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: + if mode is self.ComplexMode.PHASE: data = numpy.angle(complexData) - elif mode is self.Mode.REAL: + elif mode is self.ComplexMode.REAL: data = numpy.real(complexData) - elif mode is self.Mode.IMAGINARY: + elif mode is self.ComplexMode.IMAGINARY: data = numpy.imag(complexData) - elif mode in (self.Mode.ABSOLUTE, - self.Mode.LOG10_AMPLITUDE_PHASE, - self.Mode.AMPLITUDE_PHASE): + elif mode in (self.ComplexMode.ABSOLUTE, + self.ComplexMode.LOG10_AMPLITUDE_PHASE, + self.ComplexMode.AMPLITUDE_PHASE): data = numpy.absolute(complexData) - elif mode is self.Mode.SQUARE_AMPLITUDE: + elif mode is self.ComplexMode.SQUARE_AMPLITUDE: data = numpy.absolute(complexData) ** 2 else: _logger.error( @@ -333,22 +318,36 @@ class ImageComplexData(ImageBase, ColormapMixIn): """Get the displayed RGB(A) image for (current) mode :param bool copy: Ignored for this class - :param Mode mode: + :param Union[ComplexMode,str] 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() + mode = self.getComplexMode() + else: + mode = self.ComplexMode.from_value(mode) colormap = self.getColormap(mode=mode) - if mode is self.Mode.AMPLITUDE_PHASE: + if mode is self.ComplexMode.AMPLITUDE_PHASE: data = self.getComplexData(copy=False) return _complex2rgbalin(colormap, data) - elif mode is self.Mode.LOG10_AMPLITUDE_PHASE: + elif mode is self.ComplexMode.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) + + # Backward compatibility + + Mode = ComplexMixIn.ComplexMode + + @deprecated(replacement='setComplexMode', since_version='0.11.0') + def setVisualizationMode(self, mode): + return self.setComplexMode(mode) + + @deprecated(replacement='getComplexMode', since_version='0.11.0') + def getVisualizationMode(self): + return self.getComplexMode() diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py index bf3b719..e7342b0 100644 --- a/silx/gui/plot/items/core.py +++ b/silx/gui/plot/items/core.py @@ -30,6 +30,10 @@ __license__ = "MIT" __date__ = "29/01/2019" import collections +try: + from collections import abc +except ImportError: # Python2 support + import collections as abc from copy import deepcopy import logging import enum @@ -39,6 +43,7 @@ import weakref import numpy import six +from ....utils.enum import Enum as _Enum from ... import qt from ... import colors from ...colors import Colormap @@ -128,6 +133,9 @@ class ItemChangedType(enum.Enum): VISUALIZATION_MODE = 'visualizationModeChanged' """Item's visualization mode changed flag.""" + COMPLEX_MODE = 'complexModeChanged' + """Item's complex data visualization mode changed flag.""" + class Item(qt.QObject): """Description of an item of the plot""" @@ -404,6 +412,14 @@ class DraggableMixIn(ItemMixInBase): """ self._draggable = bool(draggable) + def drag(self, from_, to): + """Perform a drag of the item. + + :param List[float] from_: (x, y) previous position in data coordinates + :param List[float] to: (x, y) current position in data coordinates + """ + raise NotImplementedError("Must be implemented in subclass") + class ColormapMixIn(ItemMixInBase): """Mix-in class for items with colormap""" @@ -757,7 +773,164 @@ class AlphaMixIn(ItemMixInBase): self._updated(ItemChangedType.ALPHA) -class Points(Item, SymbolMixIn, AlphaMixIn): +class ComplexMixIn(ItemMixInBase): + """Mix-in class for complex data mode""" + + _SUPPORTED_COMPLEX_MODES = None + """Override to only support a subset of all ComplexMode""" + + class ComplexMode(_Enum): + """Identify available display mode for complex""" + ABSOLUTE = 'amplitude' + PHASE = 'phase' + REAL = 'real' + IMAGINARY = 'imaginary' + AMPLITUDE_PHASE = 'amplitude_phase' + LOG10_AMPLITUDE_PHASE = 'log10_amplitude_phase' + SQUARE_AMPLITUDE = 'square_amplitude' + + def __init__(self): + self.__complex_mode = self.ComplexMode.ABSOLUTE + + def getComplexMode(self): + """Returns the current complex visualization mode. + + :rtype: ComplexMode + """ + return self.__complex_mode + + def setComplexMode(self, mode): + """Set the complex visualization mode. + + :param ComplexMode mode: The visualization mode in: + 'real', 'imaginary', 'phase', 'amplitude' + :return: True if value was set, False if is was already set + :rtype: bool + """ + mode = self.ComplexMode.from_value(mode) + assert mode in self.supportedComplexModes() + + if mode != self.__complex_mode: + self.__complex_mode = mode + self._updated(ItemChangedType.COMPLEX_MODE) + return True + else: + return False + + def _convertComplexData(self, data, mode=None): + """Convert complex data to the specific mode. + + :param Union[ComplexMode,None] mode: + The kind of value to compute. + If None (the default), the current complex mode is used. + :return: The converted dataset + :rtype: Union[numpy.ndarray[float],None] + """ + if data is None: + return None + + if mode is None: + mode = self.getComplexMode() + + if mode is self.ComplexMode.REAL: + return numpy.real(data) + elif mode is self.ComplexMode.IMAGINARY: + return numpy.imag(data) + elif mode is self.ComplexMode.ABSOLUTE: + return numpy.absolute(data) + elif mode is self.ComplexMode.PHASE: + return numpy.angle(data) + elif mode is self.ComplexMode.SQUARE_AMPLITUDE: + return numpy.absolute(data) ** 2 + else: + raise ValueError('Unsupported conversion mode: %s', str(mode)) + + @classmethod + def supportedComplexModes(cls): + """Returns the list of supported complex visualization modes. + + See :class:`ComplexMode` and :meth:`setComplexMode`. + + :rtype: List[ComplexMode] + """ + if cls._SUPPORTED_COMPLEX_MODES is None: + return cls.ComplexMode.members() + else: + return cls._SUPPORTED_COMPLEX_MODES + + +class ScatterVisualizationMixIn(ItemMixInBase): + """Mix-in class for scatter plot visualization modes""" + + _SUPPORTED_SCATTER_VISUALIZATION = None + """Allows to override supported Visualizations""" + + @enum.unique + class Visualization(_Enum): + """Different modes of scatter plot visualizations""" + + POINTS = 'points' + """Display scatter plot as a point cloud""" + + LINES = 'lines' + """Display scatter plot as a wireframe. + + This is based on Delaunay triangulation + """ + + SOLID = 'solid' + """Display scatter plot as a set of filled triangles. + + This is based on Delaunay triangulation + """ + + def __init__(self): + self.__visualization = self.Visualization.POINTS + + @classmethod + def supportedVisualizations(cls): + """Returns the list of supported scatter visualization modes. + + See :meth:`setVisualization` + + :rtype: List[Visualization] + """ + if cls._SUPPORTED_SCATTER_VISUALIZATION is None: + return cls.Visualization.members() + else: + return cls._SUPPORTED_SCATTER_VISUALIZATION + + def setVisualization(self, mode): + """Set the scatter plot visualization mode to use. + + See :class:`Visualization` for all possible values, + and :meth:`supportedVisualizations` for supported ones. + + :param Union[str,Visualization] mode: + The visualization mode to use. + :return: True if value was set, False if is was already set + :rtype: bool + """ + mode = self.Visualization.from_value(mode) + assert mode in self.supportedVisualizations() + + if mode != self.__visualization: + self.__visualization = mode + + self._updated(ItemChangedType.VISUALIZATION_MODE) + return True + else: + return False + + def getVisualization(self): + """Returns the scatter plot visualization mode in use. + + :rtype: Visualization + """ + return self.__visualization + + +class PointsBase(Item, SymbolMixIn, AlphaMixIn): """Base class for :class:`Curve` and :class:`Scatter`""" # note: _logFilterData must be overloaded if you overload # getData to change its signature @@ -906,8 +1079,7 @@ class Points(Item, SymbolMixIn, AlphaMixIn): if (xPositive, yPositive) not in self._boundsCache: # use the getData class method because instance method can be # overloaded to return additional arrays - data = Points.getData(self, copy=False, - displayed=True) + data = PointsBase.getData(self, copy=False, displayed=True) if len(data) == 5: # hack to avoid duplicating caching mechanism in Scatter # (happens when cached data is used, caching done using @@ -916,12 +1088,15 @@ class Points(Item, SymbolMixIn, AlphaMixIn): else: x, y, _xerror, _yerror = data - self._boundsCache[(xPositive, yPositive)] = ( - numpy.nanmin(x), - numpy.nanmax(x), - numpy.nanmin(y), - numpy.nanmax(y) - ) + with warnings.catch_warnings(): + warnings.simplefilter('ignore', category=RuntimeWarning) + # Ignore All-NaN slice encountered + self._boundsCache[(xPositive, yPositive)] = ( + numpy.nanmin(x), + numpy.nanmax(x), + numpy.nanmin(y), + numpy.nanmax(y) + ) return self._boundsCache[(xPositive, yPositive)] def _getCachedData(self): @@ -1026,12 +1201,12 @@ class Points(Item, SymbolMixIn, AlphaMixIn): assert x.ndim == y.ndim == 1 if xerror is not None: - if isinstance(xerror, collections.Iterable): + if isinstance(xerror, abc.Iterable): xerror = numpy.array(xerror, copy=copy) else: xerror = float(xerror) if yerror is not None: - if isinstance(yerror, collections.Iterable): + if isinstance(yerror, abc.Iterable): yerror = numpy.array(yerror, copy=copy) else: yerror = float(yerror) diff --git a/silx/gui/plot/items/curve.py b/silx/gui/plot/items/curve.py index 79def55..439af33 100644 --- a/silx/gui/plot/items/curve.py +++ b/silx/gui/plot/items/curve.py @@ -37,7 +37,7 @@ import six from ....utils.deprecation import deprecated from ... import colors -from .core import (Points, LabelsMixIn, ColorMixIn, YAxisMixIn, +from .core import (PointsBase, LabelsMixIn, ColorMixIn, YAxisMixIn, FillMixIn, LineMixIn, SymbolMixIn, ItemChangedType) @@ -151,7 +151,7 @@ class CurveStyle(object): return False -class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn): +class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn): """Description of a curve""" _DEFAULT_Z_LAYER = 1 @@ -170,7 +170,7 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn): """Default highlight style of the item""" def __init__(self): - Points.__init__(self) + PointsBase.__init__(self) ColorMixIn.__init__(self) YAxisMixIn.__init__(self) FillMixIn.__init__(self) diff --git a/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py index 99a916a..d74f4d3 100644 --- a/silx/gui/plot/items/image.py +++ b/silx/gui/plot/items/image.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017 European Synchrotron Radiation Facility +# Copyright (c) 2017-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -31,11 +31,15 @@ __license__ = "MIT" __date__ = "20/10/2017" -from collections import Sequence +try: + from collections import abc +except ImportError: # Python2 support + import collections as abc import logging import numpy +from ....utils.proxy import docstring from .core import (Item, LabelsMixIn, DraggableMixIn, ColormapMixIn, AlphaMixIn, ItemChangedType) @@ -170,6 +174,12 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn): else: return xmin, xmax, ymin, ymax + @docstring(DraggableMixIn) + def drag(self, from_, to): + origin = self.getOrigin() + self.setOrigin((origin[0] + to[0] - from_[0], + origin[1] + to[1] - from_[1])) + def getData(self, copy=True): """Returns the image data @@ -199,7 +209,7 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn): :param origin: (ox, oy) Offset from origin :type origin: float or 2-tuple of float """ - if isinstance(origin, Sequence): + if isinstance(origin, abc.Sequence): origin = float(origin[0]), float(origin[1]) else: # single value origin origin = float(origin), float(origin) @@ -227,7 +237,7 @@ class ImageBase(Item, LabelsMixIn, DraggableMixIn, AlphaMixIn): :param scale: (sx, sy) Scale of the image :type scale: float or 2-tuple of float """ - if isinstance(scale, Sequence): + if isinstance(scale, abc.Sequence): scale = float(scale[0]), float(scale[1]) else: # single value scale scale = float(scale), float(scale) @@ -252,6 +262,7 @@ class ImageData(ImageBase, ColormapMixIn): ColormapMixIn.__init__(self) self._data = numpy.zeros((0, 0), dtype=numpy.float32) self._alternativeImage = None + self.__alpha = None def _addBackendRenderer(self, backend): """Update backend renderer""" @@ -261,8 +272,9 @@ class ImageData(ImageBase, ColormapMixIn): # Do not render with non linear scales return None - if self.getAlternativeImageData(copy=False) is not None: - dataToUse = self.getAlternativeImageData(copy=False) + if (self.getAlternativeImageData(copy=False) is not None or + self.getAlphaData(copy=False) is not None): + dataToUse = self.getRgbaImageData(copy=False) else: dataToUse = self.getData(copy=False) @@ -293,37 +305,56 @@ class ImageData(ImageBase, ColormapMixIn): def getRgbaImageData(self, copy=True): """Get the displayed RGB(A) image - :returns: numpy.ndarray of uint8 of shape (height, width, 4) + :returns: Array of uint8 of shape (height, width, 4) + :rtype: numpy.ndarray """ - if self._alternativeImage is not None: - return _convertImageToRgba32( - self.getAlternativeImageData(copy=False), copy=copy) + alternative = self.getAlternativeImageData(copy=False) + if alternative is not None: + return _convertImageToRgba32(alternative, copy=copy) else: # Apply colormap, in this case an new array is always returned colormap = self.getColormap() image = colormap.applyToData(self.getData(copy=False)) + alphaImage = self.getAlphaData(copy=False) + if alphaImage is not None: + # Apply transparency + image[:, :, 3] = image[:, :, 3] * alphaImage return image def getAlternativeImageData(self, copy=True): """Get the optional RGBA image that is displayed instead of the data - :param copy: True (Default) to get a copy, - False to use internal representation (do not modify!) - :returns: None or numpy.ndarray - :rtype: numpy.ndarray or None + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: Union[None,numpy.ndarray] """ if self._alternativeImage is None: return None else: return numpy.array(self._alternativeImage, copy=copy) - def setData(self, data, alternative=None, copy=True): + def getAlphaData(self, copy=True): + """Get the optional transparency image applied on the data + + :param bool copy: True (Default) to get a copy, + False to use internal representation (do not modify!) + :rtype: Union[None,numpy.ndarray] + """ + if self.__alpha is None: + return None + else: + return numpy.array(self.__alpha, copy=copy) + + def setData(self, data, alternative=None, alpha=None, copy=True): """"Set the image data and optionally an alternative RGB(A) representation :param numpy.ndarray data: Data array with 2 dimensions (h, w) :param alternative: RGB(A) image to display instead of data, shape: (h, w, 3 or 4) - :type alternative: None or numpy.ndarray + :type alternative: Union[None,numpy.ndarray] + :param alpha: An array of transparency value in [0, 1] to use for + display with shape: (h, w) + :type alpha: Union[None,numpy.ndarray] :param bool copy: True (Default) to get a copy, False to use internal representation (do not modify!) """ @@ -346,6 +377,15 @@ class ImageData(ImageBase, ColormapMixIn): assert alternative.shape[:2] == data.shape[:2] self._alternativeImage = alternative + if alpha is not None: + alpha = numpy.array(alpha, copy=copy) + assert alpha.shape == data.shape + if alpha.dtype.kind != 'f': + alpha = alpha.astype(numpy.float32) + if numpy.any(numpy.logical_or(alpha < 0., alpha > 1.)): + alpha = numpy.clip(alpha, 0., 1.) + self.__alpha = alpha + # TODO hackish data range implementation if self.isVisible(): plot = self.getPlot() diff --git a/silx/gui/plot/items/marker.py b/silx/gui/plot/items/marker.py index 09767a5..80ca0b6 100644 --- a/silx/gui/plot/items/marker.py +++ b/silx/gui/plot/items/marker.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# Copyright (c) 2017-2019 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -32,6 +32,7 @@ __date__ = "06/03/2017" import logging +from ....utils.proxy import docstring from .core import (Item, DraggableMixIn, ColorMixIn, LineMixIn, SymbolMixIn, ItemChangedType) @@ -39,7 +40,7 @@ from .core import (Item, DraggableMixIn, ColorMixIn, LineMixIn, SymbolMixIn, _logger = logging.getLogger(__name__) -class _BaseMarker(Item, DraggableMixIn, ColorMixIn): +class MarkerBase(Item, DraggableMixIn, ColorMixIn): """Base class for markers""" _DEFAULT_COLOR = (0., 0., 0., 1.) @@ -75,6 +76,10 @@ class _BaseMarker(Item, DraggableMixIn, ColorMixIn): """Update backend renderer""" raise NotImplementedError() + @docstring(DraggableMixIn) + def drag(self, from_, to): + self.setPosition(to[0], to[1]) + def isOverlay(self): """Return true if marker is drawn as an overlay. @@ -166,14 +171,14 @@ class _BaseMarker(Item, DraggableMixIn, ColorMixIn): return args -class Marker(_BaseMarker, SymbolMixIn): +class Marker(MarkerBase, SymbolMixIn): """Description of a marker""" _DEFAULT_SYMBOL = '+' """Default symbol of the marker""" def __init__(self): - _BaseMarker.__init__(self) + MarkerBase.__init__(self) SymbolMixIn.__init__(self) self._x = 0. @@ -204,11 +209,11 @@ class Marker(_BaseMarker, SymbolMixIn): return x, self.getYPosition() -class _LineMarker(_BaseMarker, LineMixIn): +class _LineMarker(MarkerBase, LineMixIn): """Base class for line markers""" def __init__(self): - _BaseMarker.__init__(self) + MarkerBase.__init__(self) LineMixIn.__init__(self) def _addBackendRenderer(self, backend): diff --git a/silx/gui/plot/items/roi.py b/silx/gui/plot/items/roi.py index 0169439..65831be 100644 --- a/silx/gui/plot/items/roi.py +++ b/silx/gui/plot/items/roi.py @@ -73,6 +73,7 @@ class RegionOfInterest(qt.QObject): self._label = '' self._labelItem = None self._editable = False + self._visible = True def __del__(self): # Clean-up plot items @@ -176,6 +177,34 @@ class RegionOfInterest(qt.QObject): # This can be avoided once marker.setDraggable is public self._createPlotItems() + def isVisible(self): + """Returns whether the ROI is visible in the plot. + + .. note:: + This does not take into account whether or not the plot + widget itself is visible (unlike :meth:`QWidget.isVisible` which + checks the visibility of all its parent widgets up to the window) + + :rtype: bool + """ + return self._visible + + def setVisible(self, visible): + """Set whether the plot items associated with this ROI are + visible in the plot. + + :param bool visible: True to show the ROI in the plot, False to + hide it. + """ + visible = bool(visible) + if self._visible == visible: + return + self._visible = visible + if self._labelItem is not None: + self._labelItem.setVisible(visible) + for item in self._items + self._editAnchors: + item.setVisible(visible) + def _getControlPoints(self): """Returns the current ROI control points. @@ -292,12 +321,14 @@ class RegionOfInterest(qt.QObject): if self._labelItem is not None: self._labelItem._setLegend(legendPrefix + "label") plot._add(self._labelItem) + self._labelItem.setVisible(self.isVisible()) self._items = WeakList() plotItems = self._createShapeItems(controlPoints) for item in plotItems: item._setLegend(legendPrefix + str(itemIndex)) plot._add(item) + item.setVisible(self.isVisible()) self._items.append(item) itemIndex += 1 @@ -309,6 +340,7 @@ class RegionOfInterest(qt.QObject): for index, item in enumerate(plotItems): item._setLegend(legendPrefix + str(itemIndex)) item.setColor(color) + item.setVisible(self.isVisible()) plot._add(item) item.sigItemChanged.connect(functools.partial( self._controlPointAnchorChanged, index)) @@ -512,10 +544,10 @@ class LineROI(RegionOfInterest, items.LineMixIn): return controlPoints def setEndPoints(self, startPoint, endPoint): - """Set this line location using the endding points + """Set this line location using the ending points :param numpy.ndarray startPoint: Staring bounding point of the line - :param numpy.ndarray endPoint: Endding bounding point of the line + :param numpy.ndarray endPoint: Ending bounding point of the line """ assert(startPoint.shape == (2,) and endPoint.shape == (2,)) shapePoints = numpy.array([startPoint, endPoint]) @@ -1261,13 +1293,13 @@ class ArcROI(RegionOfInterest, items.LineMixIn): def getGeometry(self): """Returns a tuple containing the geometry of this ROI - It is a symetric fonction of :meth:`setGeometry`. + It is a symmetric function of :meth:`setGeometry`. If `startAngle` is smaller than `endAngle` the rotation is clockwise, else the rotation is anticlockwise. :rtype: Tuple[numpy.ndarray,float,float,float,float] - :raise ValueError: In case the ROI can't be representaed as section of + :raise ValueError: In case the ROI can't be represented as section of a circle """ geometry = self._getInternalGeometry() diff --git a/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py index 707dd3d..b2f087b 100644 --- a/silx/gui/plot/items/scatter.py +++ b/silx/gui/plot/items/scatter.py @@ -31,26 +31,79 @@ __date__ = "29/03/2017" import logging - +import threading import numpy -from .core import Points, ColormapMixIn +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor, CancelledError + +from ....utils.weakref import WeakList +from .._utils.delaunay import delaunay +from .core import PointsBase, ColormapMixIn, ScatterVisualizationMixIn +from .axis import Axis _logger = logging.getLogger(__name__) -class Scatter(Points, ColormapMixIn): +class _GreedyThreadPoolExecutor(ThreadPoolExecutor): + """:class:`ThreadPoolExecutor` with an extra :meth:`submit_greedy` method. + """ + + def __init__(self, *args, **kwargs): + super(_GreedyThreadPoolExecutor, self).__init__(*args, **kwargs) + self.__futures = defaultdict(WeakList) + self.__lock = threading.RLock() + + def submit_greedy(self, queue, fn, *args, **kwargs): + """Same as :meth:`submit` but cancel previous tasks in given queue. + + This means that when a new task is submitted for a given queue, + all other pending tasks of that queue are cancelled. + + :param queue: Identifier of the queue. This must be hashable. + :param callable fn: The callable to call with provided extra arguments + :return: Future corresponding to this task + :rtype: concurrent.futures.Future + """ + with self.__lock: + # Cancel previous tasks in given queue + for future in self.__futures.pop(queue, []): + if not future.done(): + future.cancel() + + future = super(_GreedyThreadPoolExecutor, self).submit( + fn, *args, **kwargs) + self.__futures[queue].append(future) + + return future + + +class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): """Description of a scatter""" _DEFAULT_SELECTABLE = True """Default selectable state for scatter plots""" + _SUPPORTED_SCATTER_VISUALIZATION = ( + ScatterVisualizationMixIn.Visualization.POINTS, + ScatterVisualizationMixIn.Visualization.SOLID) + """Overrides supported Visualizations""" + def __init__(self): - Points.__init__(self) + PointsBase.__init__(self) ColormapMixIn.__init__(self) + ScatterVisualizationMixIn.__init__(self) self._value = () self.__alpha = None + # Cache Delaunay triangulation future object + self.__delaunayFuture = None + # Cache interpolator future object + self.__interpolatorFuture = None + self.__executor = None + + # Cache triangles: x, y, indices + self.__cacheTriangles = None, None, None def _addBackendRenderer(self, backend): """Update backend renderer""" @@ -58,28 +111,154 @@ class Scatter(Points, ColormapMixIn): xFiltered, yFiltered, valueFiltered, xerror, yerror = self.getData( copy=False, displayed=True) + # Remove not finite numbers (this includes filtered out x, y <= 0) + mask = numpy.logical_and(numpy.isfinite(xFiltered), numpy.isfinite(yFiltered)) + xFiltered = xFiltered[mask] + yFiltered = yFiltered[mask] + if len(xFiltered) == 0: return None # No data to display, do not add renderer to backend + # Compute colors cmap = self.getColormap() rgbacolors = cmap.applyToData(self._value) if self.__alpha is not None: rgbacolors[:, -1] = (rgbacolors[:, -1] * self.__alpha).astype(numpy.uint8) - return backend.addCurve(xFiltered, yFiltered, self.getLegend(), - color=rgbacolors, - symbol=self.getSymbol(), - linewidth=0, - linestyle="", - yaxis='left', - xerror=xerror, - yerror=yerror, - z=self.getZValue(), - selectable=self.isSelectable(), - fill=False, - alpha=self.getAlpha(), - symbolsize=self.getSymbolSize()) + # Apply mask to colors + rgbacolors = rgbacolors[mask] + + if self.getVisualization() is self.Visualization.POINTS: + return backend.addCurve(xFiltered, yFiltered, self.getLegend(), + color=rgbacolors, + symbol=self.getSymbol(), + linewidth=0, + linestyle="", + yaxis='left', + xerror=xerror, + yerror=yerror, + z=self.getZValue(), + selectable=self.isSelectable(), + fill=False, + alpha=self.getAlpha(), + symbolsize=self.getSymbolSize()) + + else: # 'solid' + plot = self.getPlot() + if (plot is None or + plot.getXAxis().getScale() != Axis.LINEAR or + plot.getYAxis().getScale() != Axis.LINEAR): + # Solid visualization is not available with log scaled axes + return None + + triangulation = self._getDelaunay().result() + if triangulation is None: + return None + else: + triangles = triangulation.simplices.astype(numpy.int32) + return backend.addTriangles(xFiltered, + yFiltered, + triangles, + legend=self.getLegend(), + color=rgbacolors, + z=self.getZValue(), + selectable=self.isSelectable(), + alpha=self.getAlpha()) + + def __getExecutor(self): + """Returns async greedy executor + + :rtype: _GreedyThreadPoolExecutor + """ + if self.__executor is None: + self.__executor = _GreedyThreadPoolExecutor(max_workers=2) + return self.__executor + + def _getDelaunay(self): + """Returns a :class:`Future` which result is the Delaunay object. + + :rtype: concurrent.futures.Future + """ + if self.__delaunayFuture is None or self.__delaunayFuture.cancelled(): + # Need to init a new delaunay + x, y = self.getData(copy=False)[:2] + # Remove not finite points + mask = numpy.logical_and(numpy.isfinite(x), numpy.isfinite(y)) + + self.__delaunayFuture = self.__getExecutor().submit_greedy( + 'delaunay', delaunay, x[mask], y[mask]) + + return self.__delaunayFuture + + @staticmethod + def __initInterpolator(delaunayFuture, values): + """Returns an interpolator for the given data points + + :param concurrent.futures.Future delaunayFuture: + Future object which result is a Delaunay object + :param numpy.ndarray values: The data value of valid points. + :rtype: Union[callable,None] + """ + # Wait for Delaunay to complete + try: + triangulation = delaunayFuture.result() + except CancelledError: + triangulation = None + + if triangulation is None: + interpolator = None # Error case + else: + # Lazy-loading of interpolator + try: + from scipy.interpolate import LinearNDInterpolator + except ImportError: + LinearNDInterpolator = None + + if LinearNDInterpolator is not None: + interpolator = LinearNDInterpolator(triangulation, values) + + # First call takes a while, do it here + interpolator([(0., 0.)]) + + else: + # Fallback using matplotlib interpolator + import matplotlib.tri + + x, y = triangulation.points.T + tri = matplotlib.tri.Triangulation( + x, y, triangles=triangulation.simplices) + mplInterpolator = matplotlib.tri.LinearTriInterpolator( + tri, values) + + # Wrap interpolator to have same API as scipy's one + def interpolator(points): + return mplInterpolator(*points.T) + + return interpolator + + def _getInterpolator(self): + """Returns a :class:`Future` which result is the interpolator. + + The interpolator is a callable taking an array Nx2 of points + as a single argument. + The :class:`Future` result is None in case the interpolator cannot + be initialized. + + :rtype: concurrent.futures.Future + """ + if (self.__interpolatorFuture is None or + self.__interpolatorFuture.cancelled()): + # Need to init a new interpolator + x, y, values = self.getData(copy=False)[:3] + # Remove not finite points + mask = numpy.logical_and(numpy.isfinite(x), numpy.isfinite(y)) + x, y, values = x[mask], y[mask], values[mask] + + self.__interpolatorFuture = self.__getExecutor().submit_greedy( + 'interpolator', + self.__initInterpolator, self._getDelaunay(), values) + return self.__interpolatorFuture def _logFilterData(self, xPositive, yPositive): """Filter out values with x or y <= 0 on log axes @@ -89,7 +268,7 @@ class Scatter(Points, ColormapMixIn): :return: The filtered arrays or unchanged object if not filtering needed :rtype: (x, y, value, xerror, yerror) """ - # overloaded from Points to filter also value. + # overloaded from PointsBase to filter also value. value = self.getValueData(copy=False) if xPositive or yPositive: @@ -100,7 +279,7 @@ class Scatter(Points, ColormapMixIn): value = numpy.array(value, copy=True, dtype=numpy.float) value[clipped] = numpy.nan - x, y, xerror, yerror = Points._logFilterData(self, xPositive, yPositive) + x, y, xerror, yerror = PointsBase._logFilterData(self, xPositive, yPositive) return x, y, value, xerror, yerror @@ -146,7 +325,7 @@ class Scatter(Points, ColormapMixIn): self.getXErrorData(copy), self.getYErrorData(copy)) - # reimplemented from Points to handle `value` + # reimplemented from PointsBase to handle `value` def setData(self, x, y, value, xerror=None, yerror=None, alpha=None, copy=True): """Set the data of the scatter. @@ -171,6 +350,14 @@ class Scatter(Points, ColormapMixIn): assert value.ndim == 1 assert len(x) == len(value) + # Reset triangulation and interpolator + if self.__delaunayFuture is not None: + self.__delaunayFuture.cancel() + self.__delaunayFuture = None + if self.__interpolatorFuture is not None: + self.__interpolatorFuture.cancel() + self.__interpolatorFuture = None + self._value = value if alpha is not None: @@ -183,8 +370,8 @@ class Scatter(Points, ColormapMixIn): if numpy.any(numpy.logical_or(alpha < 0., alpha > 1.)): alpha = numpy.clip(alpha, 0., 1.) self.__alpha = alpha - + # set x, y, xerror, yerror # call self._updated + plot._invalidateDataRange() - Points.setData(self, x, y, xerror, yerror, copy) + PointsBase.setData(self, x, y, xerror, yerror, copy) |