Source code for plotpy.items.image.misc

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

from __future__ import annotations

import sys
from typing import TYPE_CHECKING

import numpy as np
from guidata.dataset import update_dataset
from guidata.utils.misc import assert_interfaces_valid
from qtpy import QtCore as QC

from plotpy import io
from plotpy.config import _
from plotpy.constants import X_BOTTOM, Y_LEFT
from plotpy.coords import axes_to_canvas
from plotpy.interfaces import (
    IBaseImageItem,
    IBasePlotItem,
    IColormapImageItemType,
    ICSImageItemType,
    IExportROIImageItemType,
    IHistDataSource,
    IImageItemType,
    ITrackableItemType,
    IVoiImageItemType,
)
from plotpy.items.image.base import BaseImageItem, RawImageItem
from plotpy.items.image.transform import TrImageItem
from plotpy.mathutils.arrayfuncs import get_nan_range
from plotpy.styles import Histogram2DParam, ImageParam, QuadGridParam

try:
    from plotpy._scaler import _histogram, _scale_quads
    from plotpy.histogram2d import histogram2d, histogram2d_func
except ImportError:
    print(
        ("Module 'plotpy.items.image.base': missing C extension"),
        file=sys.stderr,
    )
    print(
        ("try running: python setup.py build_ext --inplace"),
        file=sys.stderr,
    )
    raise

if TYPE_CHECKING:
    import numpy
    import qwt.plot
    import qwt.scale_map
    from qtpy.QtCore import QPointF, QRectF
    from qtpy.QtGui import QPainter

    from plotpy.interfaces import IItemType
    from plotpy.items import RectangleShape
    from plotpy.plot import BasePlot
    from plotpy.styles.base import ItemParameters


[docs] class QuadGridItem(RawImageItem): """Quad grid item Args: X: X coordinates Y: Y coordinates Z: Z coordinates param: Image parameters X, Y, Z: A structured grid of quadrilaterals, each quad is defined by (X[i], Y[i]), (X[i], Y[i+1]), (X[i+1], Y[i+1]), (X[i+1], Y[i]) """ __implements__ = (IBasePlotItem, IBaseImageItem, IHistDataSource, IVoiImageItemType) def __init__( self, X: np.ndarray, Y: np.ndarray, Z: np.ndarray, param: QuadGridParam | None = None, ) -> None: assert X is not None assert Y is not None assert Z is not None self.X = X self.Y = Y assert X.shape == Y.shape assert Z.shape == X.shape super().__init__(Z, param) self.set_data(Z) self.grid = 1 self.interpolate = (0, 0.5, 0.5) self.param.update_item(self) # ---- BaseImageItem API ---------------------------------------------------
[docs] def get_default_param(self) -> QuadGridParam: """Return instance of the default image param DataSet""" return QuadGridParam(_("Quadrilaterals"))
[docs] def types(self) -> tuple[type[IItemType], ...]: """Returns a group or category for this item. This should be a tuple of class objects inheriting from IItemType Returns: tuple: Tuple of class objects inheriting from IItemType """ return ( IImageItemType, IVoiImageItemType, IColormapImageItemType, ITrackableItemType, )
[docs] def update_bounds(self) -> None: """Update image bounds to fit image shape""" xmin = self.X.min() xmax = self.X.max() ymin = self.Y.min() ymax = self.Y.max() self.bounds = QC.QRectF(xmin, ymin, xmax - xmin, ymax - ymin)
[docs] def set_data( self, data: np.ndarray, X: np.ndarray | None = None, Y: np.ndarray | None = None, lut_range: list[float, float] | None = None, ) -> None: """Set image data Args: data: 2D NumPy array X: X coordinates (Default value = None) Y: Y coordinates (Default value = None) lut_range: LUT range -- tuple (levelmin, levelmax) (Default value = None) """ if lut_range is not None: _min, _max = lut_range else: _min, _max = get_nan_range(data) self.data = data self.histogram_cache = None if X is not None: assert Y is not None self.X = X self.Y = Y self.update_bounds() self.update_border() self.set_lut_range([_min, _max])
[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 """ if self.warn_if_non_linear_scale(painter, canvasRect): return self._offscreen[...] = np.uint32(0) dest = _scale_quads( self.X, self.Y, self.data, src_rect, self._offscreen, dst_rect, self.lut, self.interpolate, self.grid, ) qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3])) painter.drawImage(qrect, self._image, qrect) xl, yt, xr, yb = dest self._offscreen[yt:yb, xl:xr] = 0
[docs] def notify_new_offscreen(self) -> None: """Notify that the offscreen image has changed""" # we always ensure the offscreen is clean before drawing self._offscreen[...] = 0
assert_interfaces_valid(QuadGridItem)
[docs] class Histogram2DItem(BaseImageItem): """2D histogram item Args: X: X coordinates (1-D array) Y: Y coordinates (1-D array) param: Histogram parameters """ __implements__ = (IBasePlotItem, IBaseImageItem, IHistDataSource, IVoiImageItemType) _icon_name = "histogram2d.png" def __init__( self, X: np.ndarray, Y: np.ndarray, param: Histogram2DParam | None = None, Z=None, ) -> None: if param is None: param = ImageParam(_("Image")) self._z = Z # allows set_bins to super().__init__(param=param) # Set by parameters self.nx_bins = 0 self.ny_bins = 0 self.logscale = None # internal use self._x = None self._y = None # Histogram parameters self.histparam = param self.histparam.update_histogram(self) self.set_lut_range([0, 10.0]) self.set_data(X, Y, Z) # ---- BaseImageItem API ---------------------------------------------------
[docs] def get_default_param(self) -> Histogram2DParam: """Return instance of the default image param DataSet""" return Histogram2DParam(_("2D Histogram"))
# ---- Public API -----------------------------------------------------------
[docs] def set_bins(self, NX: int, NY: int) -> None: """Set histogram bins Args: NX: Number of bins along X NY: Number of bins along Y """ self.nx_bins = NX self.ny_bins = NY self.data = np.zeros((self.ny_bins, self.nx_bins), float) if self._z is not None: self.data_tmp = np.zeros((self.ny_bins, self.nx_bins), float)
[docs] def set_data( self, X: np.ndarray, Y: np.ndarray, Z: np.ndarray | None = None ) -> None: """Set histogram data Args: X: X coordinates Y: Y coordinates Z: Z coordinates (Default value = None) """ self._x = X self._y = Y self._z = Z self.bounds = QC.QRectF( QC.QPointF(X.min(), Y.min()), QC.QPointF(X.max(), Y.max()) ) self.update_border()
# ---- QwtPlotItem API ------------------------------------------------------ fill_canvas = True
[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 """ if self.warn_if_non_linear_scale(painter, canvasRect): return computation = self.histparam.computation i1, j1, i2, j2 = src_rect if computation == -1 or self._z is None: self.data[:, :] = 0.0 nmax = histogram2d( self._x, self._y, i1, i2, j1, j2, self.data, self.logscale ) else: self.data_tmp[:, :] = 0.0 if computation in (2, 4): # sum, avg self.data[:, :] = 0.0 elif computation in (1, 5): # min, argmin val = np.inf self.data[:, :] = val elif computation in (0, 6): # max, argmax val = -np.inf self.data[:, :] = val elif computation == 3: self.data[:, :] = 1.0 histogram2d_func( self._x, self._y, self._z, i1, i2, j1, j2, self.data_tmp, self.data, computation, ) if computation in (0, 1, 5, 6): self.data[self.data == val] = np.nan else: self.data[self.data_tmp == 0.0] = np.nan if self.histparam.auto_lut: nmin, nmax = get_nan_range(self.data) self.set_lut_range([nmin, nmax]) self.plot().update_colormap_axis(self) src_rect = (0, 0, self.nx_bins, self.ny_bins) def drawfunc(*args): return BaseImageItem.draw_image(self, *args) if self.fill_canvas: x1, y1, x2, y2 = canvasRect.toAlignedRect().getCoords() drawfunc(painter, canvasRect, src_rect, (x1, y1, x2, y2), xMap, yMap) else: dst_rect = tuple([int(i) for i in dst_rect]) drawfunc(painter, canvasRect, src_rect, dst_rect, xMap, yMap)
# ---- IBasePlotItem API ---------------------------------------------------
[docs] def types(self) -> tuple[type[IItemType], ...]: """Returns a group or category for this item. This should be a tuple of class objects inheriting from IItemType Returns: tuple: Tuple of class objects inheriting from IItemType """ return ( IColormapImageItemType, IImageItemType, ITrackableItemType, IVoiImageItemType, IColormapImageItemType, ICSImageItemType, )
[docs] def update_item_parameters(self) -> None: """Update item parameters (dataset) from object properties""" BaseImageItem.update_item_parameters(self) self.histparam.update_param(self)
[docs] def get_item_parameters(self, itemparams: ItemParameters) -> None: """ Appends datasets to the list of DataSets describing the parameters used to customize apearance of this item Args: itemparams: Item parameters """ BaseImageItem.get_item_parameters(self, itemparams) itemparams.add("Histogram2DParam", self, self.histparam)
[docs] def set_item_parameters(self, itemparams: ItemParameters) -> None: """ Change the appearance of this item according to the parameter set provided Args: itemparams: Item parameters """ update_dataset( self.histparam, itemparams.get("Histogram2DParam"), visible_only=True ) self.histparam = itemparams.get("Histogram2DParam") self.histparam.update_histogram(self) BaseImageItem.set_item_parameters(self, itemparams)
# ---- IBaseImageItem API --------------------------------------------------
[docs] def can_sethistogram(self) -> bool: """ Returns True if this item can be associated with a levels histogram Returns: bool: True if item can be associated with a levels histogram, False otherwise """ return True
[docs] def get_histogram( self, nbins: int, drange: tuple[float, float] | None = None ) -> tuple[np.ndarray, np.ndarray]: """ Return a tuple (hist, bins) where hist is a list of histogram values Args: nbins: number of bins drange: lower and upper range of the bins. If not provided, range is simply (data.min(), data.max()). Values outside the range are ignored. Returns: Tuple (hist, bins) """ if self.data is None: return [0], [0, 1] _min, _max = get_nan_range(self.data) if drange is not None: bins = np.linspace(drange[0], drange[1], nbins + 1) elif self.data.dtype in (np.float64, np.float32): bins = np.unique( np.array(np.linspace(_min, _max, nbins + 1), dtype=self.data.dtype) ) else: bins = np.arange(_min, _max + 2, dtype=self.data.dtype) res2 = np.zeros((bins.size + 1,), np.uint32) _histogram(self.data.flatten(), bins, res2) # toc("histo2") res = res2[1:-1], bins return res
assert_interfaces_valid(Histogram2DItem)
[docs] def assemble_imageitems( items: list[BaseImageItem], src_qrect: QC.QRectF, destw: int, desth: int, align: int | None = None, add_images: bool = False, apply_lut: bool = False, apply_interpolation: bool = False, original_resolution: bool = False, force_interp_mode: str | None = None, force_interp_size: int | None = None, ) -> np.ndarray: """Assemble together image items and return resulting pixel data Args: items: List of image items src_qrect: Source rectangle destw: Destination width desth: Destination height align: Alignment (Default value = None) add_images: Add images (Default value = False) apply_lut: Apply LUT (Default value = False) apply_interpolation: Apply interpolation (Default value = False) original_resolution: Original resolution (Default value = False) force_interp_mode: Force interpolation mode (Default value = None) force_interp_size: Force interpolation size (Default value = None) Returns: Pixel data """ # align width to 'align' bytes if align is not None: print( "plotpy.items.image.assemble_imageitems: since v2.2, " "the `align` option is ignored", file=sys.stderr, ) align = 1 # XXX: Byte alignment is disabled until further notice! aligned_destw = int(align * ((int(destw) + align - 1) / align)) aligned_desth = int(desth * aligned_destw / destw) try: output = np.zeros((aligned_desth, aligned_destw), np.float32) except ValueError: raise MemoryError if not add_images: dst_image = output dst_rect = (0, 0, aligned_destw, aligned_desth) src_rect = list(src_qrect.getCoords()) # The source QRect is generally coming from a rectangle shape which is # adjusted to fit a given ROI on the image. So the rectangular area is # aligned with image pixel edges: to avoid any rounding error, we reduce # the rectangle area size by one half of a pixel, so that the area is now # aligned with the center of image pixels. pixel_width = src_qrect.width() / float(destw) pixel_height = src_qrect.height() / float(desth) src_rect[0] += 0.5 * pixel_width src_rect[1] += 0.5 * pixel_height src_rect[2] -= 0.5 * pixel_width src_rect[3] -= 0.5 * pixel_height # Convert to tuple for compatibility with C extension src_rect = tuple(src_rect) for it in sorted(items, key=lambda obj: obj.z()): if it.isVisible() and src_qrect.intersects(it.boundingRect()): if add_images: dst_image = np.zeros_like(output) it.export_roi( src_rect=src_rect, dst_rect=dst_rect, dst_image=dst_image, apply_lut=apply_lut, apply_interpolation=apply_interpolation, original_resolution=original_resolution, force_interp_mode=force_interp_mode, force_interp_size=force_interp_size, ) if add_images: output += dst_image return output
[docs] def get_plot_qrect(plot: qwt.plot.QwtPlot, p0: QPointF, p1: QPointF) -> QRectF: """Get plot rectangle, in plot coordinates, from two canvas points Args: plot: Plot p0: First point p1: Second point Returns: Plot rectangle """ ax, ay = X_BOTTOM, Y_LEFT p0x, p0y = plot.invTransform(ax, p0.x()), plot.invTransform(ay, p0.y()) p1x, p1y = plot.invTransform(ax, p1.x() + 1), plot.invTransform(ay, p1.y() + 1) return QC.QRectF(p0x, p0y, p1x - p0x, p1y - p0y)
def get_items_in_rectangle( plot: BasePlot, p0: QPointF, p1: QPointF, item_type: type | None = None, intersect: bool = True, ) -> list[BaseImageItem]: """Return items which bounding rectangle intersects (p0, p1) Args: plot: Plot p0: First point p1: Second point item_type: Item type (if None, use `IExportROIImageItemType`) intersect: Intersect (Default value = True) Returns: List of items """ if item_type is None: item_type = IExportROIImageItemType items = plot.get_items(item_type=item_type) src_qrect = get_plot_qrect(plot, p0, p1) if intersect: return [it for it in items if src_qrect.intersects(it.boundingRect())] else: # contains return [it for it in items if src_qrect.contains(it.boundingRect())] def compute_trimageitems_original_size( items: list[TrImageItem], src_w: float, src_h: float, ) -> tuple[float, float]: """Compute `TrImageItem` original size from max dx and dy Args: items: List of image items src_w: Source width (in plot axis units) src_h: Source height (in plot axis units) Returns: Tuple of original size .. note:: The returned size is always positive: when the source rectangle is defined on a reversed axis, ``src_w`` and/or ``src_h`` may be negative. The original (pixel) size is intrinsically positive, independent of axis orientation. """ src_w, src_h = abs(src_w), abs(src_h) trparams = [item.get_transform() for item in items if isinstance(item, TrImageItem)] if trparams: dx_max = max([dx for _x, _y, _angle, dx, _dy, _hf, _vf in trparams]) dy_max = max([dy for _x, _y, _angle, _dx, dy, _hf, _vf in trparams]) return src_w / dx_max, src_h / dy_max return src_w, src_h def compute_image_items_original_size( items: list[BaseImageItem], plot: qwt.plot.QwtPlot, p0: QPointF, p1: QPointF, ) -> tuple[float, float]: """Compute the **native pixel resolution** of a rectangular selection across the given image items. The "Original size" semantics is *original resolution*: the returned size is the number of source pixels that span the selection at the item's native resolution, *independent of axis orientation or scaling*. When the selection is larger than the plotted image, the returned size is consequently larger than the image (the missing area will be padded by the export step). When the selection is smaller, it is smaller in pixels — there is **no** clipping to the image bounding rectangle, so that exporting at "Original size" always preserves the source pixel density. Args: plot: Plot items: List of image items in the selection p0: First canvas point (top-left, in canvas coordinates) p1: Second canvas point (bottom-right, in canvas coordinates) Returns: Tuple ``(width, height)`` in pixels (always positive). When no compatible item is found, falls back to the absolute axis-units size of the selection. """ p0x = plot.invTransform(X_BOTTOM, p0.x()) p0y = plot.invTransform(Y_LEFT, p0.y()) p1x = plot.invTransform(X_BOTTOM, p1.x() + 1) p1y = plot.invTransform(Y_LEFT, p1.y() + 1) sel_x0, sel_x1 = sorted([p0x, p1x]) sel_y0, sel_y1 = sorted([p0y, p1y]) sel_w = sel_x1 - sel_x0 sel_h = sel_y1 - sel_y0 widths: list[float] = [] heights: list[float] = [] for item in items: data = getattr(item, "data", None) if data is None: continue if isinstance(item, TrImageItem): # Use the item's affine transform (handles rotation and shear) get_pix = item.get_pixel_coordinates try: x0p, y0p = get_pix(sel_x0, sel_y0) x1p, y1p = get_pix(sel_x1, sel_y1) except (ValueError, TypeError, IndexError): continue widths.append(abs(x1p - x0p)) heights.append(abs(y1p - y0p)) else: # For ImageItem / XYImageItem: convert the (possibly oversized) # selection to pixels via the item's own pixel density. This # avoids ``XYImageItem.get_pixel_coordinates`` clamping to # integer indices and yields oversized values when the # selection extends beyond the image — consistently with the # historical behavior of ``ImageItem``. brect = item.boundingRect() bw = abs(brect.width()) bh = abs(brect.height()) if bw <= 0 or bh <= 0: continue widths.append(sel_w / bw * data.shape[1]) heights.append(sel_h / bh * data.shape[0]) if widths: return max(widths), max(heights) # Fallback: axis-units size (always positive) _src_x, _src_y, src_w, src_h = get_plot_qrect(plot, p0, p1).getRect() return abs(src_w), abs(src_h) def get_image_from_qrect( plot: BasePlot, p0: QPointF, p1: QPointF, src_size: tuple[float, float] | None = None, adjust_range: str | None = None, item_type: type | None = None, apply_lut: bool = False, apply_interpolation: bool = False, original_resolution: bool = False, add_images: bool = False, force_interp_mode: str | None = None, force_interp_size: int | None = None, ) -> np.ndarray: """Get image pixel data from rectangle area Args: plot: Plot p0: First point (top-left) p1: Second point (bottom-right) src_size: Source size (Default value = None) adjust_range: Adjust range (Default value = None) item_type: Item type (Default value = None) apply_lut: Apply LUT (Default value = False) apply_interpolation: Apply interpolation (Default value = False) original_resolution: Original resolution (Default value = False) add_images: Add images (Default value = False) force_interp_mode: Force interpolation mode (Default value = None) force_interp_size: Force interpolation size (Default value = None) Returns: Image pixel data """ assert adjust_range in (None, "normalize", "original") items = get_items_in_rectangle(plot, p0, p1, item_type=item_type) if not items: raise TypeError(_("There is no supported image item in current plot.")) if src_size is None: destw, desth = compute_image_items_original_size(items, plot, p0, p1) else: # The only benefit to pass the src_size list is to avoid any # rounding error in the transformation computed in `get_plot_qrect` src_w, src_h = src_size destw, desth = compute_trimageitems_original_size(items, src_w, src_h) data = get_image_from_plot( plot, p0, p1, destw=destw, desth=desth, apply_lut=apply_lut, add_images=add_images, apply_interpolation=apply_interpolation, original_resolution=original_resolution, force_interp_mode=force_interp_mode, force_interp_size=force_interp_size, ) if adjust_range is None: return data dtype = None for item in items: if dtype is None or item.data.dtype.itemsize > dtype.itemsize: dtype = item.data.dtype if adjust_range == "normalize": data = io.scale_data_to_dtype(data, dtype=dtype) else: data = np.array(data, dtype=dtype) return data def get_image_in_shape( obj: RectangleShape, norm_range: bool = False, item_type: type | None = None, apply_lut: bool = False, apply_interpolation: bool = False, ) -> np.ndarray: """Get image pixel data from rectangle shape Args: obj: Rectangle shape norm_range: Normalize range (Default value = False) item_type: Item type (Default value = None) apply_lut: Apply LUT (Default value = False) apply_interpolation: Apply interpolation (Default value = False) Returns: Image pixel data """ x0, y0, x1, y1 = obj.get_rect() (x0, x1), (y0, y1) = sorted([x0, x1]), sorted([y0, y1]) xc0, yc0 = axes_to_canvas(obj, x0, y0) xc1, yc1 = axes_to_canvas(obj, x1, y1) adjust_range = "normalize" if norm_range else "original" return get_image_from_qrect( obj.plot(), QC.QPointF(xc0, yc0), QC.QPointF(xc1, yc1), src_size=(x1 - x0, y1 - y0), adjust_range=adjust_range, item_type=item_type, apply_lut=apply_lut, apply_interpolation=apply_interpolation, original_resolution=True, )
[docs] def get_image_from_plot( plot: BasePlot, p0: QPointF, p1: QPointF, destw: int | None = None, desth: int | None = None, add_images: bool = False, apply_lut: bool = False, apply_interpolation: bool = False, original_resolution: bool = False, force_interp_mode: str | None = None, force_interp_size: int | None = None, ) -> numpy.ndarray: """Get image pixel data from plot area Args: plot: Plot p0: First point (top-left) p1: Second point (bottom-right) destw: Destination width (Default value = None) desth: Destination height (Default value = None) add_images: Add superimposed images (instead of replace by the foreground) apply_lut: Apply LUT (Default value = False) apply_interpolation: Apply interpolation (Default value = False) original_resolution: Original resolution (Default value = False) force_interp_mode: Force interpolation mode (Default value = None) force_interp_size: Force interpolation size (Default value = None) Returns: Image pixel data """ if destw is None: destw = p1.x() - p0.x() + 1 if desth is None: desth = p1.y() - p0.y() + 1 items = plot.get_items(item_type=IExportROIImageItemType) qrect = get_plot_qrect(plot, p0, p1) return assemble_imageitems( items, qrect, destw, desth, # align=4, add_images=add_images, apply_lut=apply_lut, apply_interpolation=apply_interpolation, original_resolution=original_resolution, force_interp_mode=force_interp_mode, force_interp_size=force_interp_size, )