diff options
Diffstat (limited to 'silx/gui/plot/items')
-rw-r--r-- | silx/gui/plot/items/_arc_roi.py | 11 | ||||
-rw-r--r-- | silx/gui/plot/items/complex.py | 84 | ||||
-rw-r--r-- | silx/gui/plot/items/core.py | 36 | ||||
-rw-r--r-- | silx/gui/plot/items/histogram.py | 54 | ||||
-rw-r--r-- | silx/gui/plot/items/image.py | 125 | ||||
-rw-r--r-- | silx/gui/plot/items/scatter.py | 8 |
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() |