Source code for plotpy.items.image.masked

# -*- coding: utf-8 -*-

from __future__ import annotations

from typing import TYPE_CHECKING

import guidata.io
import numpy as np
import numpy.ma as ma
from qtpy import QtCore as QC

from plotpy import io
from plotpy._scaler import INTERP_NEAREST, _scale_rect, _scale_xy
from plotpy.config import _
from plotpy.interfaces import (
    IBaseImageItem,
    IBasePlotItem,
    IExportROIImageItemType,
    IHistDataSource,
    IVoiImageItemType,
)
from plotpy.items.image.standard import ImageItem, XYImageItem
from plotpy.styles.image import MaskedImageParam, MaskedXYImageParam

if TYPE_CHECKING:
    import guidata.io
    import qwt.scale_map
    from qtpy.QtCore import QRectF
    from qtpy.QtGui import QPainter

    from plotpy.plot import BasePlot


[docs] class MaskedArea: """Defines masked areas for a masked image item Args: geometry: Geometry of the area ('rectangular' or anything else for circular) x0: X coordinate of first point y0: Y coordinate of first point x1: X coordinate of second point y1: Y coordinate of second point inside: True if the area is inside the geometry, False if it is outside """ def __init__( self, geometry: str | None = None, x0: float | None = None, y0: float | None = None, x1: float | None = None, y1: float | None = None, inside: bool | None = None, ) -> None: self.geometry = geometry self.x0 = x0 self.y0 = y0 self.x1 = x1 self.y1 = y1 self.inside = inside def __eq__(self, other): return ( self.geometry == other.geometry and self.x0 == other.x0 and self.y0 == other.y0 and self.x1 == other.x1 and self.y1 == other.y1 and self.inside == other.inside )
[docs] def serialize( self, writer: guidata.io.HDF5Writer | guidata.io.INIWriter | guidata.io.JSONWriter, ) -> None: """Serialize object to HDF5 writer Args: writer: HDF5, INI or JSON writer """ for name in ("geometry", "inside", "x0", "y0", "x1", "y1"): writer.write(getattr(self, name), name)
[docs] def deserialize( self, reader: guidata.io.HDF5Reader | guidata.io.INIReader | guidata.io.JSONReader, ) -> None: """Deserialize object from HDF5 reader Args: reader: HDF5, INI or JSON reader """ self.geometry = reader.read("geometry") self.inside = reader.read("inside") for name in ("x0", "y0", "x1", "y1"): setattr(self, name, reader.read(name, func=reader.read_float))
class MaskedImageMixin: """Masked image mixin This class is a mixin providing array mask features to image items. It is used by :py:class:`.MaskedImageItem` and :py:class:`.MaskedXYImageItem` classes. Args: mask: 2D masked array """ def __init__(self, mask: ma.MaskedArray | None = None) -> None: self.orig_data: np.ndarray | None = None self._mask: ma.MaskedArray | None = mask self._mask_filename: str | None = None self._masked_areas: list[MaskedArea] = [] # ImageItem and XYImageItem attributes: self.data: ma.MaskedArray | None = None self.param: MaskedImageParam | MaskedXYImageParam | None = None # ---- Pickle methods ------------------------------------------------------- def serialize( self, writer: guidata.io.HDF5Writer | guidata.io.INIWriter | guidata.io.JSONWriter, ) -> None: """Serialize object to HDF5 writer Args: writer: HDF5, INI or JSON writer """ writer.write(self.get_mask_filename(), group_name="mask_fname") writer.write_object_list(self._masked_areas, "masked_areas") def deserialize( self, reader: guidata.io.HDF5Reader | guidata.io.INIReader | guidata.io.JSONReader, ) -> None: """Deserialize object from HDF5 reader Args: reader: HDF5, INI or JSON reader """ mask_fname = reader.read(group_name="mask_fname", func=reader.read_unicode) masked_areas = reader.read_object_list("masked_areas", MaskedArea) if mask_fname: self.set_mask_filename(mask_fname) self.load_mask_data() elif masked_areas and self.data is not None: self.set_masked_areas(masked_areas) self.apply_masked_areas() # ---- Public API ----------------------------------------------------------- def update_mask(self) -> None: """Update mask""" if isinstance(self.data, np.ma.MaskedArray): filling_value = self.param.filling_value if filling_value is None or ( isinstance(filling_value, float) and np.isnan(filling_value) ): # Use a safe default for integer types when filling_value is None or NaN if np.issubdtype(self.data.dtype, np.integer): val = np.array(0, dtype=self.data.dtype) else: val = np.array(np.nan, dtype=self.data.dtype) else: # If filling_value is out of bounds for data dtype, get the default # fill value for that dtype if np.issubdtype(self.data.dtype, np.integer): info = np.iinfo(self.data.dtype) if filling_value < info.min or filling_value > info.max: # Using the default fill value thanks to the get_fill_value() # method of MaskedArray: filling_value = self.data.get_fill_value() val = np.array(filling_value).astype(self.data.dtype) self.data.set_fill_value(val) def set_mask(self, mask: ma.MaskedArray) -> None: """Set image mask Args: mask: 2D masked array """ self.data.mask = mask def get_mask(self) -> ma.MaskedArray | None: """Get image mask Returns: 2D masked array """ return self.data.mask def set_mask_filename(self, fname: str) -> None: """Set mask filename Args: fname: Mask filename There are two ways for pickling mask data of `MaskedImageItem` objects: 1. using the mask filename (as for data itself) 2. using the mask areas (`MaskedArea` instance, see set_mask_areas) When saving objects, the first method is tried and then, if no filename has been defined for mask data, the second method is used. """ self._mask_filename = fname def get_mask_filename(self) -> str: """Get mask filename Returns: Mask filename """ return self._mask_filename def load_mask_data(self) -> None: """Load mask data from file""" data = io.imread(self.get_mask_filename(), to_grayscale=True) self.set_mask(data) self._mask_changed() def set_masked_areas(self, areas: list[MaskedArea]) -> None: """Set masked areas Args: areas: List of masked areas .. seealso:: :py:meth:`.set_mask_filename` """ self._masked_areas = areas def get_masked_areas(self) -> list[MaskedArea]: """Get masked areas Returns: List of masked areas """ return self._masked_areas def add_masked_area( self, geometry: str, x0: float, y0: float, x1: float, y1: float, inside: bool ) -> None: """Add masked area Args: geometry: Area geometry x0: top left x coordinate y0: top left y coordinate x1: bottom right x coordinate y1: bottom right y coordinate inside: Inside or outside """ area = MaskedArea(geometry=geometry, x0=x0, y0=y0, x1=x1, y1=y1, inside=inside) for _area in self._masked_areas: if area == _area: return self._masked_areas.append(area) def _mask_changed(self) -> None: """Emit the :py:data:`.baseplot.BasePlot.SIG_MASK_CHANGED` signal""" plot: BasePlot = self.plot() if plot is not None: plot.SIG_MASK_CHANGED.emit(self) def apply_masked_areas(self) -> None: """Apply masked areas""" for area in self._masked_areas: if area.geometry == "rectangular": self.mask_rectangular_area( area.x0, area.y0, area.x1, area.y1, area.inside, trace=False, do_signal=False, ) else: self.mask_circular_area( area.x0, area.y0, area.x1, area.y1, area.inside, trace=False, do_signal=False, ) self._mask_changed() def mask_all(self) -> None: """Mask all pixels""" self.data.mask = True self._mask_changed() def unmask_all(self) -> None: """Unmask all pixels""" self.data.mask = np.ma.nomask self.set_masked_areas([]) self._mask_changed() def mask_rectangular_area( self, x0: float, y0: float, x1: float, y1: float, inside: bool = True, trace: bool = True, do_signal: bool = True, ) -> None: """Mask rectangular area Args: x0: top left x coordinate y0: top left y coordinate x1: bottom right x coordinate y1: bottom right y coordinate inside: if True (default), mask the inside of the area, otherwise mask the outside trace: if True (default), add the area to the list of masked areas do_signal: if True (default), emit the :py:data:`.baseplot.BasePlot.SIG_MASK_CHANGED` signal """ ix0, iy0, ix1, iy1 = self.get_closest_index_rect(x0, y0, x1, y1) if inside: self.data[iy0:iy1, ix0:ix1] = np.ma.masked else: indexes = np.ones(self.data.shape, dtype=bool) indexes[iy0:iy1, ix0:ix1] = False self.data[indexes] = np.ma.masked if trace: self.add_masked_area("rectangular", x0, y0, x1, y1, inside) if do_signal: self._mask_changed() def mask_circular_area( self, x0: float, y0: float, x1: float, y1: float, inside: bool = True, trace: bool = True, do_signal: bool = True, ) -> None: """Mask circular area (as a circle inscribed in a rectangle) Args: x0: top left x coordinate y0: top left y coordinate x1: bottom right x coordinate y1: bottom right y coordinate inside: if True (default), mask the inside of the area, otherwise mask the outside trace: if True (default), add the area to the list of masked areas do_signal: if True (default), emit the :py:data:`.baseplot.BasePlot.SIG_MASK_CHANGED` signal """ ix0, iy0, ix1, iy1 = self.get_closest_index_rect(x0, y0, x1, y1) xc, yc = 0.5 * (x0 + x1), 0.5 * (y0 + y1) radius = 0.5 * (x1 - x0) xdata, ydata = self.get_x_values(ix0, ix1), self.get_y_values(iy0, iy1) for ix in range(ix0, ix1): for iy in range(iy0, iy1): distance = np.sqrt( (xdata[ix - ix0] - xc) ** 2 + (ydata[iy - iy0] - yc) ** 2 ) if inside: if distance <= radius: self.data[iy, ix] = np.ma.masked elif distance > radius: self.data[iy, ix] = np.ma.masked if not inside: self.mask_rectangular_area(x0, y0, x1, y1, inside, trace=False) if trace: self.add_masked_area("circular", x0, y0, x1, y1, inside) if do_signal: self._mask_changed() def is_mask_visible(self) -> bool: """Return mask visibility Returns: True if mask is visible, False otherwise """ return self.param.show_mask def set_mask_visible(self, state: bool) -> None: """Set mask visibility Args: state: True to show mask, False to hide it """ self.param.show_mask = state plot = self.plot() if plot is not None: plot.replot() def _set_data(self, data: np.ndarray) -> None: """Set image data Args: data: 2D NumPy array """ self.orig_data = data self.data = data.view(np.ma.MaskedArray) self.set_mask(self._mask) self._mask = None # removing reference to this temporary array if self.param.filling_value is None: self.param.filling_value = self.data.get_fill_value() self.update_mask()
[docs] class MaskedImageItem(ImageItem, MaskedImageMixin): """Masked image item Args: data: 2D NumPy array mask: 2D NumPy array param: image parameters """ __implements__ = ( IBasePlotItem, IBaseImageItem, IHistDataSource, IVoiImageItemType, IExportROIImageItemType, ) def __init__( self, data: np.ndarray | None = None, mask: np.ndarray | None = None, param: MaskedImageParam | None = None, ) -> None: self.orig_data = None MaskedImageMixin.__init__(self, mask=mask) ImageItem.__init__(self, data=data, param=param) # ---- BaseImageItem API ---------------------------------------------------
[docs] def get_default_param(self) -> MaskedImageParam: """Return instance of the default MaskedImageParam DataSet""" return MaskedImageParam(_("Image"))
# ---- Pickle methods ------------------------------------------------------- def __reduce__(self) -> tuple: """Reduce object to pickled state""" fname = self.get_filename() if fname is None: fn_or_data = self.data else: fn_or_data = fname state = ( self.param, self.get_lut_range(), fn_or_data, self.z(), self.get_mask_filename(), self.get_masked_areas(), ) res = (self.__class__, (), state) return res def __setstate__(self, state: tuple) -> None: """Set object state from pickled state""" param, lut_range, fn_or_data, z, mask_fname, old_masked_areas = state if old_masked_areas and isinstance(old_masked_areas[0], MaskedArea): masked_areas = old_masked_areas else: # Compatibility with old format masked_areas = [] for geometry, x0, y0, x1, y1, inside in old_masked_areas: area = MaskedArea( geometry=geometry, x0=x0, y0=y0, x1=x1, y1=y1, inside=inside ) masked_areas.append(area) self.param = param if isinstance(fn_or_data, str): self.set_filename(fn_or_data) self.load_data(lut_range) elif fn_or_data is not None: # should happen only with previous API self.set_data(fn_or_data, lut_range=lut_range) self.setZ(z) self.param.update_item(self) if mask_fname is not None: self.set_mask_filename(mask_fname) self.load_mask_data() elif masked_areas and self.data is not None: self.set_masked_areas(masked_areas) self.apply_masked_areas()
[docs] def serialize( self, writer: guidata.io.HDF5Writer | guidata.io.INIWriter | guidata.io.JSONWriter, ) -> None: """Serialize object to HDF5 writer Args: writer: HDF5, INI or JSON writer """ ImageItem.serialize(self, writer) MaskedImageMixin.serialize(self, writer)
[docs] def deserialize( self, reader: guidata.io.HDF5Reader | guidata.io.INIReader | guidata.io.JSONReader, ) -> None: """Deserialize object from HDF5 reader Args: reader: HDF5, INI or JSON reader """ ImageItem.deserialize(self, reader) MaskedImageMixin.deserialize(self, reader)
# ---- BaseImageItem API ----------------------------------------------------
[docs] def draw_image( self, painter: QPainter, canvasRect: QRectF, src_rect: tuple[float, float, float, float], dst_rect: tuple[float, float, float, float], xMap: qwt.scale_map.QwtScaleMap, yMap: qwt.scale_map.QwtScaleMap, ) -> None: """Draw image Args: painter: Painter canvasRect: Canvas rectangle src_rect: Source rectangle dst_rect: Destination rectangle xMap: X axis scale map yMap: Y axis scale map """ ImageItem.draw_image(self, painter, canvasRect, src_rect, dst_rect, xMap, yMap) if self.data is None: return if self.is_mask_visible(): _a, _b, bg, _cmap = self.lut # pylint: disable=too-many-function-args alpha_masked = ( np.uint32(255 * self.param.alpha_masked + 0.5).clip(0, 255) << 24 ) alpha_unmasked = ( np.uint32(255 * self.param.alpha_unmasked + 0.5).clip(0, 255) << 24 ) cmap = np.array( [ np.uint32(0x000000 & 0xFFFFFF) | alpha_unmasked, np.uint32(0xFFFFFF & 0xFFFFFF) | alpha_masked, ], dtype=np.uint32, ) lut = (1, 0, bg, cmap) shown_data = np.ma.getmaskarray(self.data) src2 = self._rescale_src_rect(src_rect) dst_rect = tuple([int(i) for i in dst_rect]) dest = _scale_rect( shown_data, src2, self._offscreen, dst_rect, lut, (INTERP_NEAREST,) ) qrect = QC.QRectF( QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3]) ) painter.drawImage(qrect, self._image, qrect)
# ---- RawImageItem API -----------------------------------------------------
[docs] def set_data( self, data: np.ndarray, lut_range: list[float, float] | None = None ) -> None: """Set image data Args: data: 2D NumPy array lut_range: LUT range -- tuple (levelmin, levelmax) (Default value = None) """ super().set_data(data, lut_range) MaskedImageMixin._set_data(self, data)
[docs] class MaskedXYImageItem(XYImageItem, MaskedImageMixin): """XY masked image item Args: x: 1D NumPy array, must be increasing y: 1D NumPy array, must be increasing data: 2D NumPy array mask: 2D NumPy array param: image parameters """ __implements__ = ( IBasePlotItem, IBaseImageItem, IHistDataSource, IVoiImageItemType, IExportROIImageItemType, ) def __init__( self, x: np.ndarray | None = None, y: np.ndarray | None = None, data: np.ndarray | None = None, mask: np.ndarray | None = None, param: MaskedXYImageParam | None = None, ) -> None: self.orig_data = None MaskedImageMixin.__init__(self, mask=mask) XYImageItem.__init__(self, x=x, y=y, data=data, param=param) # ---- BaseImageItem API ---------------------------------------------------
[docs] def get_default_param(self) -> MaskedXYImageParam: """Return instance of the default MaskedXYImageParam DataSet""" return MaskedXYImageParam(_("Image"))
# ---- Pickle methods ------------------------------------------------------- def __reduce__(self) -> tuple: """Reduce object to pickled state""" fname = self.get_filename() if fname is None: fn_or_data = self.data else: fn_or_data = fname state = ( self.param, self.get_lut_range(), fn_or_data, self.x, self.y, self.z(), self.get_mask_filename(), self.get_masked_areas(), ) res = (self.__class__, (), state) return res def __setstate__(self, state: tuple) -> None: """Set object state from pickled state""" param, lut_range, fn_or_data, x, y, z, mask_fname, old_masked_areas = state if old_masked_areas and isinstance(old_masked_areas[0], MaskedArea): masked_areas = old_masked_areas else: # Compatibility with old format masked_areas = [] for geometry, x0, y0, x1, y1, inside in old_masked_areas: area = MaskedArea( geometry=geometry, x0=x0, y0=y0, x1=x1, y1=y1, inside=inside ) masked_areas.append(area) self.param = param if isinstance(fn_or_data, str): self.set_filename(fn_or_data) self.load_data(lut_range) elif fn_or_data is not None: # should happen only with previous API self.set_data(fn_or_data, lut_range=lut_range) self.set_xy(x, y) self.setZ(z) self.param.update_item(self) if mask_fname is not None: self.set_mask_filename(mask_fname) self.load_mask_data() elif masked_areas and self.data is not None: self.set_masked_areas(masked_areas) self.apply_masked_areas()
[docs] def serialize( self, writer: guidata.io.HDF5Writer | guidata.io.INIWriter | guidata.io.JSONWriter, ) -> None: """Serialize object to HDF5 writer Args: writer: HDF5, INI or JSON writer """ XYImageItem.serialize(self, writer) MaskedImageMixin.serialize(self, writer)
[docs] def deserialize( self, reader: guidata.io.HDF5Reader | guidata.io.INIReader | guidata.io.JSONReader, ) -> None: """Deserialize object from HDF5 reader Args: reader: HDF5, INI or JSON reader """ XYImageItem.deserialize(self, reader) MaskedImageMixin.deserialize(self, reader)
# ---- BaseImageItem API ----------------------------------------------------
[docs] def draw_image( self, painter: QPainter, canvasRect: QRectF, src_rect: tuple[float, float, float, float], dst_rect: tuple[float, float, float, float], xMap: qwt.scale_map.QwtScaleMap, yMap: qwt.scale_map.QwtScaleMap, ) -> None: """Draw image Args: painter: Painter canvasRect: Canvas rectangle src_rect: Source rectangle dst_rect: Destination rectangle xMap: X axis scale map yMap: Y axis scale map """ XYImageItem.draw_image( self, painter, canvasRect, src_rect, dst_rect, xMap, yMap ) if self.data is None: return if self.is_mask_visible(): _a, _b, bg, _cmap = self.lut # pylint: disable=too-many-function-args alpha_masked = ( np.uint32(255 * self.param.alpha_masked + 0.5).clip(0, 255) << 24 ) alpha_unmasked = ( np.uint32(255 * self.param.alpha_unmasked + 0.5).clip(0, 255) << 24 ) cmap = np.array( [ np.uint32(0x000000 & 0xFFFFFF) | alpha_unmasked, np.uint32(0xFFFFFF & 0xFFFFFF) | alpha_masked, ], dtype=np.uint32, ) lut = (1, 0, bg, cmap) shown_data = np.ma.getmaskarray(self.data) xytr = self.x, self.y, src_rect dst_rect = tuple([int(i) for i in dst_rect]) dest = _scale_xy( shown_data, xytr, self._offscreen, dst_rect, lut, (INTERP_NEAREST,) ) qrect = QC.QRectF( QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3]) ) painter.drawImage(qrect, self._image, qrect)
[docs] def set_data( self, data: np.ndarray, lut_range: list[float, float] | None = None ) -> None: """Set image data Args: data: 2D NumPy array lut_range: LUT range -- tuple (levelmin, levelmax) (Default value = None) """ super().set_data(data, lut_range) MaskedImageMixin._set_data(self, data)