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/_arc_roi.py11
-rw-r--r--silx/gui/plot/items/complex.py84
-rw-r--r--silx/gui/plot/items/core.py36
-rw-r--r--silx/gui/plot/items/histogram.py54
-rw-r--r--silx/gui/plot/items/image.py125
-rw-r--r--silx/gui/plot/items/scatter.py8
6 files changed, 267 insertions, 51 deletions
diff --git a/silx/gui/plot/items/_arc_roi.py b/silx/gui/plot/items/_arc_roi.py
index a22cc3d..23416ec 100644
--- a/silx/gui/plot/items/_arc_roi.py
+++ b/silx/gui/plot/items/_arc_roi.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2018-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2018-2021 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
@@ -29,6 +29,7 @@ __authors__ = ["V. Valls"]
__license__ = "MIT"
__date__ = "28/06/2018"
+import logging
import numpy
from ... import utils
@@ -40,6 +41,9 @@ from ._roi_base import InteractionModeMixIn
from ._roi_base import RoiInteractionMode
+logger = logging.getLogger(__name__)
+
+
class _ArcGeometry:
"""
Non-mutable object to store the geometry of the arc ROI.
@@ -779,8 +783,9 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn):
If `startAngle` is smaller than `endAngle` the rotation is clockwise,
else the rotation is anticlockwise.
"""
- assert innerRadius <= outerRadius
- assert numpy.abs(startAngle - endAngle) <= 2 * numpy.pi
+ if innerRadius > outerRadius:
+ logger.error("inner radius larger than outer radius")
+ innerRadius, outerRadius = outerRadius, innerRadius
center = numpy.array(center)
radius = (innerRadius + outerRadius) * 0.5
weight = outerRadius - innerRadius
diff --git a/silx/gui/plot/items/complex.py b/silx/gui/plot/items/complex.py
index 0e492a0..abb64ad 100644
--- a/silx/gui/plot/items/complex.py
+++ b/silx/gui/plot/items/complex.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2021 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
@@ -184,18 +184,18 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn):
def setComplexMode(self, mode):
changed = super(ImageComplexData, self).setComplexMode(mode)
if changed:
+ self._valueDataChanged()
+
# 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.getComplexMode()]
if colormap is not super(ImageComplexData, self).getColormap():
super(ImageComplexData, self).setColormap(colormap)
- self._setColormappedData(self.getData(copy=False), copy=False)
+ # Send data updated as value returned by getData has changed
+ self._updated(ItemChangedType.DATA)
return changed
def _setAmplitudeRangeInfo(self, max_=None, delta=2):
@@ -263,10 +263,32 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn):
'Image is not complex, converting it to complex to plot it.')
data = numpy.array(data, dtype=numpy.complex64)
- self._dataByModesCache = {}
- self._setColormappedData(self.getData(copy=False), copy=False)
+ # Compute current mode data and set colormap data
+ mode = self.getComplexMode()
+ dataForMode = self.__convertComplexData(data, self.getComplexMode())
+ self._dataByModesCache = {mode: dataForMode}
+
super().setData(data)
+ def _updated(self, event=None, checkVisibility=True):
+ # Synchronizes colormapped data if changed
+ # ItemChangedType.COMPLEX_MODE triggers ItemChangedType.DATA
+ # No need to handle it twice.
+ if event in (ItemChangedType.DATA, ItemChangedType.MASK):
+ # Color-mapped data is NOT the `getValueData` for some modes
+ if self.getComplexMode() in (
+ self.ComplexMode.AMPLITUDE_PHASE,
+ self.ComplexMode.LOG10_AMPLITUDE_PHASE):
+ data = self.getData(copy=False, mode=self.ComplexMode.PHASE)
+ mask = self.getMaskData(copy=False)
+ if mask is not None:
+ data = numpy.copy(data)
+ data[mask != 0] = numpy.nan
+ else:
+ data = self.getValueData(copy=False)
+ self._setColormappedData(data, copy=False)
+ super()._updated(event=event, checkVisibility=checkVisibility)
+
def getComplexData(self, copy=True):
"""Returns the image complex data
@@ -276,6 +298,31 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn):
"""
return super().getData(copy=copy)
+ def __convertComplexData(self, data, mode):
+ """Convert complex data to given mode.
+
+ :param numpy.ndarray data:
+ :param Union[ComplexMode,str] mode:
+ :rtype: numpy.ndarray of float
+ """
+ if mode is self.ComplexMode.PHASE:
+ return numpy.angle(data)
+ elif mode is self.ComplexMode.REAL:
+ return numpy.real(data)
+ elif mode is self.ComplexMode.IMAGINARY:
+ return numpy.imag(data)
+ elif mode in (self.ComplexMode.ABSOLUTE,
+ self.ComplexMode.LOG10_AMPLITUDE_PHASE,
+ self.ComplexMode.AMPLITUDE_PHASE):
+ return numpy.absolute(data)
+ elif mode is self.ComplexMode.SQUARE_AMPLITUDE:
+ return numpy.absolute(data) ** 2
+ else:
+ _logger.error(
+ 'Unsupported conversion mode: %s, fallback to absolute',
+ str(mode))
+ return numpy.absolute(data)
+
def getData(self, copy=True, mode=None):
"""Returns the image data corresponding to (current) mode.
@@ -295,27 +342,8 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn):
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.ComplexMode.PHASE:
- data = numpy.angle(complexData)
- elif mode is self.ComplexMode.REAL:
- data = numpy.real(complexData)
- elif mode is self.ComplexMode.IMAGINARY:
- data = numpy.imag(complexData)
- elif mode in (self.ComplexMode.ABSOLUTE,
- self.ComplexMode.LOG10_AMPLITUDE_PHASE,
- self.ComplexMode.AMPLITUDE_PHASE):
- data = numpy.absolute(complexData)
- elif mode is self.ComplexMode.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
+ self._dataByModesCache[mode] = self.__convertComplexData(
+ self.getComplexData(copy=False), mode)
return numpy.array(self._dataByModesCache[mode], copy=copy)
diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py
index edc6d89..95a65ad 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-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2021 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
@@ -27,7 +27,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "29/01/2019"
+__date__ = "08/12/2020"
import collections
try:
@@ -110,6 +110,9 @@ class ItemChangedType(enum.Enum):
DATA = 'dataChanged'
"""Item's data changed flag"""
+ MASK = 'maskChanged'
+ """Item's mask changed flag"""
+
HIGHLIGHTED = 'highlightedChanged'
"""Item's highlight state changed flag."""
@@ -315,7 +318,7 @@ class Item(qt.QObject):
info = deepcopy(info)
self._info = info
- def getVisibleBounds(self) -> Optional[Tuple[float,float,float,float]]:
+ def getVisibleBounds(self) -> Optional[Tuple[float, float, float, float]]:
"""Returns visible bounds of the item bounding box in the plot area.
:returns:
@@ -503,9 +506,9 @@ class DataItem(Item):
self._boundsChanged(checkVisibility=False)
super().setVisible(visible)
-
# Mix-in classes ##############################################################
+
class ItemMixInBase(object):
"""Base class for Item mix-in"""
@@ -1232,7 +1235,7 @@ class ScatterVisualizationMixIn(ItemMixInBase):
def __init__(self):
self.__visualization = self.Visualization.POINTS
- self.__parameters = dict( # Init parameters to None
+ self.__parameters = dict(# Init parameters to None
(parameter, None) for parameter in self.VisualizationParameter)
self.__parameters[self.VisualizationParameter.BINNED_STATISTIC_FUNCTION] = 'mean'
@@ -1404,8 +1407,8 @@ class PointsBase(DataItem, SymbolMixIn, AlphaMixIn):
elif error.ndim == 1: # N array
newError = numpy.empty((2, len(value)),
dtype=numpy.float64)
- newError[0, :] = error
- newError[1, :] = error
+ newError[0,:] = error
+ newError[1,:] = error
error = newError
elif error.size == 2 * len(value): # 2xN array
@@ -1610,14 +1613,32 @@ class PointsBase(DataItem, SymbolMixIn, AlphaMixIn):
assert len(x) == len(y)
assert x.ndim == y.ndim == 1
+ # Convert complex data
+ if numpy.iscomplexobj(x):
+ _logger.warning(
+ 'Converting x data to absolute value to plot it.')
+ x = numpy.absolute(x)
+ if numpy.iscomplexobj(y):
+ _logger.warning(
+ 'Converting y data to absolute value to plot it.')
+ y = numpy.absolute(y)
+
if xerror is not None:
if isinstance(xerror, abc.Iterable):
xerror = numpy.array(xerror, copy=copy)
+ if numpy.iscomplexobj(xerror):
+ _logger.warning(
+ 'Converting xerror data to absolute value to plot it.')
+ xerror = numpy.absolute(xerror)
else:
xerror = float(xerror)
if yerror is not None:
if isinstance(yerror, abc.Iterable):
yerror = numpy.array(yerror, copy=copy)
+ if numpy.iscomplexobj(yerror):
+ _logger.warning(
+ 'Converting yerror data to absolute value to plot it.')
+ yerror = numpy.absolute(yerror)
else:
yerror = float(yerror)
# TODO checks on xerror, yerror
@@ -1634,6 +1655,7 @@ class PointsBase(DataItem, SymbolMixIn, AlphaMixIn):
class BaselineMixIn(object):
"""Base class for Baseline mix-in"""
+
def __init__(self, baseline=None):
self._baseline = baseline
diff --git a/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py
index 5941cc6..16bbefa 100644
--- a/silx/gui/plot/items/histogram.py
+++ b/silx/gui/plot/items/histogram.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2021 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
@@ -30,6 +30,7 @@ __license__ = "MIT"
__date__ = "28/08/2018"
import logging
+import typing
import numpy
from collections import OrderedDict, namedtuple
@@ -38,8 +39,10 @@ try:
except ImportError: # Python2 support
import collections as abc
+from ....utils.proxy import docstring
from .core import (DataItem, AlphaMixIn, BaselineMixIn, ColorMixIn, FillMixIn,
- LineMixIn, YAxisMixIn, ItemChangedType)
+ LineMixIn, YAxisMixIn, ItemChangedType, Item)
+from ._pick import PickingResult
_logger = logging.getLogger(__name__)
@@ -219,6 +222,53 @@ class Histogram(DataItem, AlphaMixIn, ColorMixIn, FillMixIn,
min(0, numpy.nanmin(values)),
max(0, numpy.nanmax(values)))
+ def __pickFilledHistogram(self, x: float, y: float) -> typing.Optional[PickingResult]:
+ """Picking implementation for filled histogram
+
+ :param x: X position in pixels
+ :param y: Y position in pixels
+ """
+ if not self.isFill():
+ return None
+
+ plot = self.getPlot()
+ if plot is None:
+ return None
+
+ xData, yData = plot.pixelToData(x, y, axis=self.getYAxis())
+ xmin, xmax, ymin, ymax = self.getBounds()
+ if not xmin < xData < xmax or not ymin < yData < ymax:
+ return None # Outside bounding box
+
+ # Check x
+ edges = self.getBinEdgesData(copy=False)
+ index = numpy.searchsorted(edges, (xData,), side='left')[0] - 1
+ # Safe indexing in histogram values
+ index = numpy.clip(index, 0, len(edges) - 2)
+
+ # Check y
+ baseline = self.getBaseline(copy=False)
+ if baseline is None:
+ baseline = 0 # Default value
+
+ value = self.getValueData(copy=False)[index]
+ if ((baseline <= value and baseline <= yData <= value) or
+ (value < baseline and value <= yData <= baseline)):
+ return PickingResult(self, numpy.array([index]))
+ else:
+ return None
+
+ @docstring(DataItem)
+ def pick(self, x, y):
+ if self.isFill():
+ return self.__pickFilledHistogram(x, y)
+ else:
+ result = super().pick(x, y)
+ if result is None:
+ return None
+ else: # Convert from curve indices to histogram indices
+ return PickingResult(self, numpy.unique(result.getIndices() // 2))
+
def getValueData(self, copy=True):
"""The values of the histogram
diff --git a/silx/gui/plot/items/image.py b/silx/gui/plot/items/image.py
index fda4245..0d9c9a4 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-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2021 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
@@ -28,8 +28,7 @@ of the :class:`Plot`.
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "20/10/2017"
-
+__date__ = "08/12/2020"
try:
from collections import abc
@@ -43,7 +42,6 @@ from ....utils.proxy import docstring
from .core import (DataItem, LabelsMixIn, DraggableMixIn, ColormapMixIn,
AlphaMixIn, ItemChangedType)
-
_logger = logging.getLogger(__name__)
@@ -80,8 +78,8 @@ def _convertImageToRgba32(image, copy=True):
if image.shape[-1] == 3:
new_image = numpy.empty((image.shape[0], image.shape[1], 4),
dtype=numpy.uint8)
- new_image[:, :, :3] = image
- new_image[:, :, 3] = 255
+ new_image[:,:,:3] = image
+ new_image[:,:, 3] = 255
return new_image # This is a copy anyway
else:
return numpy.array(image, copy=copy)
@@ -93,7 +91,7 @@ class ImageBase(DataItem, LabelsMixIn, DraggableMixIn, AlphaMixIn):
:param numpy.ndarray data: Initial image data
"""
- def __init__(self, data=None):
+ def __init__(self, data=None, mask=None):
DataItem.__init__(self)
LabelsMixIn.__init__(self)
DraggableMixIn.__init__(self)
@@ -101,7 +99,8 @@ class ImageBase(DataItem, LabelsMixIn, DraggableMixIn, AlphaMixIn):
if data is None:
data = numpy.zeros((0, 0, 4), dtype=numpy.uint8)
self._data = data
-
+ self._mask = mask
+ self.__valueDataCache = None # Store default data
self._origin = (0., 0.)
self._scale = (1., 1.)
@@ -186,13 +185,98 @@ class ImageBase(DataItem, LabelsMixIn, DraggableMixIn, AlphaMixIn):
:param numpy.ndarray data:
"""
+ previousShape = self._data.shape
self._data = data
+ self._valueDataChanged()
self._boundsChanged()
self._updated(ItemChangedType.DATA)
+ if (self.getMaskData(copy=False) is not None and
+ previousShape != self._data.shape):
+ # Data shape changed, so mask shape changes.
+ # Send event, mask is lazily updated in getMaskData
+ self._updated(ItemChangedType.MASK)
+
+ def getMaskData(self, copy=True):
+ """Returns the mask 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._mask is None:
+ return None
+
+ # Update mask if it does not match data shape
+ shape = self.getData(copy=False).shape[:2]
+ if self._mask.shape != shape:
+ # Clip/extend mask to match data
+ newMask = numpy.zeros(shape, dtype=self._mask.dtype)
+ newMask[:self._mask.shape[0], :self._mask.shape[1]] = self._mask[:shape[0], :shape[1]]
+ self._mask = newMask
+
+ return numpy.array(self._mask, copy=copy)
+
+ def setMaskData(self, mask, copy=True):
+ """Set the image data
+
+ :param numpy.ndarray data:
+ :param bool copy: True (Default) to make a copy,
+ False to use as is (do not modify!)
+ """
+ if mask is not None:
+ mask = numpy.array(mask, copy=copy)
+
+ shape = self.getData(copy=False).shape[:2]
+ if mask.shape != shape:
+ _logger.warning("Inconsistent shape between mask and data %s, %s", mask.shape, shape)
+ # Clip/extent is done lazily in getMaskData
+ elif self._mask is None:
+ return # No update
+
+ self._mask = mask
+ self._valueDataChanged()
+ self._updated(ItemChangedType.MASK)
+
+ def _valueDataChanged(self):
+ """Clear cache of default data array"""
+ self.__valueDataCache = None
+
+ def _getValueData(self, copy=True):
+ """Return data used by :meth:`getValueData`
+
+ :param bool copy:
+ :rtype: numpy.ndarray
+ """
+ return self.getData(copy=copy)
+
+ def getValueData(self, copy=True):
+ """Return data (converted to int or float) with mask applied.
+
+ Masked values are set to Not-A-Number.
+ It returns a 2D array of values (int or float).
+
+ :param bool copy:
+ :rtype: numpy.ndarray
+ """
+ if self.__valueDataCache is None:
+ data = self._getValueData(copy=False)
+ mask = self.getMaskData(copy=False)
+ if mask is not None:
+ if numpy.issubdtype(data.dtype, numpy.floating):
+ dtype = data.dtype
+ else:
+ dtype = numpy.float64
+ data = numpy.array(data, dtype=dtype, copy=True)
+ data[mask != 0] = numpy.NaN
+ self.__valueDataCache = data
+ return numpy.array(self.__valueDataCache, copy=copy)
+
def getRgbaImageData(self, copy=True):
"""Get the displayed RGB(A) image
+ :param bool copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
:returns: numpy.ndarray of uint8 of shape (height, width, 4)
"""
raise NotImplementedError('This MUST be implemented in sub-class')
@@ -308,7 +392,7 @@ class ImageData(ImageBase, ColormapMixIn):
alphaImage = self.getAlphaData(copy=False)
if alphaImage is not None:
# Apply transparency
- image[:, :, 3] = image[:, :, 3] * alphaImage
+ image[:,:, 3] = image[:,:, 3] * alphaImage
return image
def getAlternativeImageData(self, copy=True):
@@ -358,7 +442,6 @@ class ImageData(ImageBase, ColormapMixIn):
_logger.warning(
'Converting complex image to absolute value to plot it.')
data = numpy.absolute(data)
- self._setColormappedData(data, copy=False)
if alternative is not None:
alternative = numpy.array(alternative, copy=copy)
@@ -378,6 +461,14 @@ class ImageData(ImageBase, ColormapMixIn):
super().setData(data)
+ def _updated(self, event=None, checkVisibility=True):
+ # Synchronizes colormapped data if changed
+ if event in (ItemChangedType.DATA, ItemChangedType.MASK):
+ self._setColormappedData(
+ self.getValueData(copy=False),
+ copy=False)
+ super()._updated(event=event, checkVisibility=checkVisibility)
+
class ImageRgba(ImageBase):
"""Description of an RGB(A) image"""
@@ -423,6 +514,20 @@ class ImageRgba(ImageBase):
assert data.shape[-1] in (3, 4)
super().setData(data)
+ def _getValueData(self, copy=True):
+ """Compute the intensity of the RGBA image as default data.
+
+ Conversion: https://en.wikipedia.org/wiki/YCbCr#ITU-R_BT.601_conversion
+
+ :param bool copy:
+ """
+ rgba = self.getRgbaImageData(copy=False).astype(numpy.float32)
+ intensity = (rgba[:, :, 0] * 0.299 +
+ rgba[:, :, 1] * 0.587 +
+ rgba[:, :, 2] * 0.114)
+ intensity *= rgba[:, :, 3] / 255.
+ return intensity
+
class MaskImageData(ImageData):
"""Description of an image used as a mask.
diff --git a/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py
index fd7cfae..2d54223 100644
--- a/silx/gui/plot/items/scatter.py
+++ b/silx/gui/plot/items/scatter.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017-2020 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2021 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
@@ -935,6 +935,12 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn):
assert value.ndim == 1
assert len(x) == len(value)
+ # Convert complex data
+ if numpy.iscomplexobj(value):
+ _logger.warning(
+ 'Converting value data to absolute value to plot it.')
+ value = numpy.absolute(value)
+
# Reset triangulation and interpolator
if self.__delaunayFuture is not None:
self.__delaunayFuture.cancel()