summaryrefslogtreecommitdiff
path: root/silx/gui/plot/items
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui/plot/items')
-rw-r--r--silx/gui/plot/items/__init__.py7
-rw-r--r--silx/gui/plot/items/complex.py121
-rw-r--r--silx/gui/plot/items/core.py197
-rw-r--r--silx/gui/plot/items/curve.py6
-rw-r--r--silx/gui/plot/items/image.py72
-rw-r--r--silx/gui/plot/items/marker.py17
-rw-r--r--silx/gui/plot/items/roi.py40
-rw-r--r--silx/gui/plot/items/scatter.py231
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)