Source code for silx.gui.plot.items.image

# /*##########################################################################
#
# Copyright (c) 2017-2023 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
"""This module provides the :class:`ImageData` and :class:`ImageRgba` items
of the :class:`Plot`.
"""

__authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "08/12/2020"

from collections import abc
import logging

import numpy

from ....utils.proxy import docstring
from ....utils.deprecation import deprecated_warning
from .core import (
    DataItem,
    LabelsMixIn,
    DraggableMixIn,
    ColormapMixIn,
    AlphaMixIn,
    ItemChangedType,
)

_logger = logging.getLogger(__name__)


def _convertImageToRgba32(image, copy=True):
    """Convert an RGB or RGBA image to RGBA32.

    It converts from floats in [0, 1], bool, integer and uint in [0, 255]

    If the input image is already an RGBA32 image,
    the returned image shares the same data.

    :param image: Image to convert to
    :type image: numpy.ndarray with 3 dimensions: height, width, color channels
    :param bool copy: True (Default) to get a copy, False, avoid copy if possible
    :return: The image converted to RGBA32 with dimension: (height, width, 4)
    :rtype: numpy.ndarray of uint8
    """
    assert image.ndim == 3
    assert image.shape[-1] in (3, 4)

    # Convert type to uint8
    if image.dtype.name != "uint8":
        if image.dtype.kind == "f":  # Float in [0, 1]
            image = (numpy.clip(image, 0.0, 1.0) * 255).astype(numpy.uint8)
        elif image.dtype.kind == "b":  # boolean
            image = image.astype(numpy.uint8) * 255
        elif image.dtype.kind in ("i", "u"):  # int, uint
            image = numpy.clip(image, 0, 255).astype(numpy.uint8)
        else:
            raise ValueError("Unsupported image dtype: %s", image.dtype.name)
        copy = False  # A copy as already been done, avoid next one

    # Convert RGB to RGBA
    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
        return new_image  # This is a copy anyway
    else:
        return numpy.array(image, copy=copy)


class ImageBase(DataItem, LabelsMixIn, DraggableMixIn, AlphaMixIn):
    """Description of an image

    :param numpy.ndarray data: Initial image data
    """

    def __init__(self, data=None, mask=None):
        DataItem.__init__(self)
        LabelsMixIn.__init__(self)
        DraggableMixIn.__init__(self)
        AlphaMixIn.__init__(self)
        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, 0.0)
        self._scale = (1.0, 1.0)

    def __getitem__(self, item):
        """Compatibility with PyMca and silx <= 0.4.0"""
        deprecated_warning(
            "Attributes",
            "__getitem__",
            since_version="2.0.0",
            replacement="Use ImageBase methods",
        )
        if isinstance(item, slice):
            return [self[index] for index in range(*item.indices(5))]
        elif item == 0:
            return self.getData(copy=False)
        elif item == 1:
            return self.getName()
        elif item == 2:
            info = self.getInfo(copy=False)
            return {} if info is None else info
        elif item == 3:
            return None
        elif item == 4:
            params = {
                "info": self.getInfo(),
                "origin": self.getOrigin(),
                "scale": self.getScale(),
                "z": self.getZValue(),
                "selectable": self.isSelectable(),
                "draggable": self.isDraggable(),
                "colormap": None,
                "xlabel": self.getXLabel(),
                "ylabel": self.getYLabel(),
            }
            return params
        else:
            raise IndexError("Index out of range: %s" % str(item))

    def _isPlotLinear(self, plot):
        """Return True if plot only uses linear scale for both of x and y
        axes."""
        linear = plot.getXAxis().LINEAR
        if plot.getXAxis().getScale() != linear:
            return False
        if plot.getYAxis().getScale() != linear:
            return False
        return True

    def _getBounds(self):
        if self.getData(copy=False).size == 0:  # Empty data
            return None

        height, width = self.getData(copy=False).shape[:2]
        origin = self.getOrigin()
        scale = self.getScale()
        # Taking care of scale might be < 0
        xmin, xmax = origin[0], origin[0] + width * scale[0]
        if xmin > xmax:
            xmin, xmax = xmax, xmin
        # Taking care of scale might be < 0
        ymin, ymax = origin[1], origin[1] + height * scale[1]
        if ymin > ymax:
            ymin, ymax = ymax, ymin

        plot = self.getPlot()
        if plot is not None and not self._isPlotLinear(plot):
            return None
        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

        :param bool copy: True (Default) to get a copy,
                          False to use internal representation (do not modify!)
        :rtype: numpy.ndarray
        """
        return numpy.array(self._data, copy=copy)

    def setData(self, data):
        """Set the image data

        :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")

    def getOrigin(self):
        """Returns the offset from origin at which to display the image.

        :rtype: 2-tuple of float
        """
        return self._origin

    def setOrigin(self, origin):
        """Set the offset from origin at which to display the image.

        :param origin: (ox, oy) Offset from origin
        :type origin: float or 2-tuple of float
        """
        if isinstance(origin, abc.Sequence):
            origin = float(origin[0]), float(origin[1])
        else:  # single value origin
            origin = float(origin), float(origin)
        if origin != self._origin:
            self._origin = origin
            self._boundsChanged()
            self._updated(ItemChangedType.POSITION)

    def getScale(self):
        """Returns the scale of the image in data coordinates.

        :rtype: 2-tuple of float
        """
        return self._scale

    def setScale(self, scale):
        """Set the scale of the image

        :param scale: (sx, sy) Scale of the image
        :type scale: float or 2-tuple of float
        """
        if isinstance(scale, abc.Sequence):
            scale = float(scale[0]), float(scale[1])
        else:  # single value scale
            scale = float(scale), float(scale)

        if scale != self._scale:
            self._scale = scale
            self._boundsChanged()
            self._updated(ItemChangedType.SCALE)


class ImageDataBase(ImageBase, ColormapMixIn):
    """Base class for colormapped 2D data image"""

    def __init__(self):
        ImageBase.__init__(self, numpy.zeros((0, 0), dtype=numpy.float32))
        ColormapMixIn.__init__(self)

    def _getColormapForRendering(self):
        colormap = self.getColormap()
        if colormap.isAutoscale():
            # NOTE: Make sure getColormapRange comes from the original object
            vrange = colormap.getColormapRange(self)
            # Avoid backend to compute autoscale: use item cache
            colormap = colormap.copy()
            colormap.setVRange(*vrange)
        return colormap

    def getRgbaImageData(self, copy=True):
        """Get the displayed RGB(A) image

        :returns: Array of uint8 of shape (height, width, 4)
        :rtype: numpy.ndarray
        """
        return self.getColormap().applyToData(self)

    def setData(self, data, copy=True):
        """Set the image data

        :param numpy.ndarray data: Data array with 2 dimensions (h, w)
        :param bool copy: True (Default) to get a copy,
                          False to use internal representation (do not modify!)
        """
        data = numpy.array(data, copy=copy)
        assert data.ndim == 2
        if data.dtype.kind == "b":
            _logger.warning("Converting boolean image to int8 to plot it.")
            data = numpy.array(data, copy=False, dtype=numpy.int8)
        elif numpy.iscomplexobj(data):
            _logger.warning("Converting complex image to absolute value to plot it.")
            data = numpy.absolute(data)
        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)


[docs] class ImageData(ImageDataBase): """Description of a data image with a colormap""" def __init__(self): ImageDataBase.__init__(self) self._alternativeImage = None self.__alpha = None def _addBackendRenderer(self, backend): """Update backend renderer""" plot = self.getPlot() assert plot is not None if not self._isPlotLinear(plot): # Do not render with non linear scales return None 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) if dataToUse.size == 0: return None # No data to display return backend.addImage( dataToUse, origin=self.getOrigin(), scale=self.getScale(), colormap=self._getColormapForRendering(), alpha=self.getAlpha(), ) def __getitem__(self, item): """Compatibility with PyMca and silx <= 0.4.0""" deprecated_warning( "Attributes", "__getitem__", since_version="2.0.0", replacement="Use ImageData methods", ) if item == 3: return self.getAlternativeImageData(copy=False) params = ImageBase.__getitem__(self, item) if item == 4: params["colormap"] = self.getColormap() return params
[docs] def getRgbaImageData(self, copy=True): """Get the displayed RGB(A) image :returns: Array of uint8 of shape (height, width, 4) :rtype: numpy.ndarray """ alternative = self.getAlternativeImageData(copy=False) if alternative is not None: return _convertImageToRgba32(alternative, copy=copy) else: image = super().getRgbaImageData(copy=copy) alphaImage = self.getAlphaData(copy=False) if alphaImage is not None: # Apply transparency image[:, :, 3] = image[:, :, 3] * alphaImage return image
[docs] def getAlternativeImageData(self, copy=True): """Get the optional RGBA image that is displayed instead of 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._alternativeImage is None: return None else: return numpy.array(self._alternativeImage, copy=copy)
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: 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!) """ data = numpy.array(data, copy=copy) assert data.ndim == 2 if alternative is not None: alternative = numpy.array(alternative, copy=copy) assert alternative.ndim == 3 assert alternative.shape[2] in (3, 4) 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.0, alpha > 1.0)): alpha = numpy.clip(alpha, 0.0, 1.0) self.__alpha = alpha super().setData(data)
[docs] class ImageRgba(ImageBase): """Description of an RGB(A) image""" def __init__(self): ImageBase.__init__(self, numpy.zeros((0, 0, 4), dtype=numpy.uint8)) def _addBackendRenderer(self, backend): """Update backend renderer""" plot = self.getPlot() assert plot is not None if not self._isPlotLinear(plot): # Do not render with non linear scales return None data = self.getData(copy=False) if data.size == 0: return None # No data to display return backend.addImage( data, origin=self.getOrigin(), scale=self.getScale(), colormap=None, alpha=self.getAlpha(), )
[docs] def getRgbaImageData(self, copy=True): """Get the displayed RGB(A) image :returns: numpy.ndarray of uint8 of shape (height, width, 4) """ return _convertImageToRgba32(self.getData(copy=False), copy=copy)
def setData(self, data, copy=True): """Set the image data :param data: RGB(A) image data to set :param bool copy: True (Default) to get a copy, False to use internal representation (do not modify!) """ data = numpy.array(data, copy=copy) if data.ndim != 3: raise ValueError( f"RGB(A) image is expected to be a 3D dataset. Got {data.ndim} dimensions" ) if data.shape[-1] not in (3, 4): raise ValueError( f"RGB(A) image is expected to have 3 or 4 elements as last dimension. Got {data.shape[-1]}" ) 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.0 return intensity
class MaskImageData(ImageData): """Description of an image used as a mask. This class is used to flag mask items. This information is used to improve internal silx widgets. """ pass class ImageStack(ImageData): """Item to store a stack of images and to show it in the plot as one of the images of the stack. The stack is a 3D array ordered this way: `frame id, y, x`. So the first image of the stack can be reached this way: `stack[0, :, :]` """ def __init__(self): ImageData.__init__(self) self.__stack = None """A 3D numpy array (or a mimic one, see ListOfImages)""" self.__stackPosition = None """Displayed position in the cube""" def setStackData(self, stack, position=None, copy=True): """Set the stack data :param stack: A 3D numpy array like :param int position: The position of the displayed image in the stack :param bool copy: True (Default) to get a copy, False to use internal representation (do not modify!) """ if self.__stack is stack: return if copy: stack = numpy.array(stack) assert stack.ndim == 3 self.__stack = stack if position is not None: self.__stackPosition = position if self.__stackPosition is None: self.__stackPosition = 0 self.__updateDisplayedData() def getStackData(self, copy=True): """Get the stored stack array. :param bool copy: True (Default) to get a copy, False to use internal representation (do not modify!) :rtype: A 3D numpy array, or numpy array like """ if copy: return numpy.array(self.__stack) else: return self.__stack def setStackPosition(self, pos): """Set the displayed position on the stack. This function will clamp the stack position according to the real size of the first axis of the stack. :param int pos: A position on the first axis of the stack. """ if self.__stackPosition == pos: return self.__stackPosition = pos self.__updateDisplayedData() def getStackPosition(self): """Get the displayed position of the stack. :rtype: int """ return self.__stackPosition def __updateDisplayedData(self): """Update the displayed frame whenever the stack or the stack position are updated.""" if self.__stack is None or self.__stackPosition is None: empty = numpy.array([]).reshape(0, 0) self.setData(empty, copy=False) return size = len(self.__stack) self.__stackPosition = numpy.clip(self.__stackPosition, 0, size) self.setData(self.__stack[self.__stackPosition], copy=False)