# -*- coding: utf-8 -*-
#
# Licensed under the terms of the BSD 3-Clause
# (see plotpy/LICENSE for details)
from __future__ import annotations
import os
import os.path as osp
from typing import TYPE_CHECKING, Any
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 qtpy import QtGui as QG
from qwt import QwtLinearScaleEngine, QwtPlotItem
from plotpy import io
from plotpy._scaler import (
INTERP_AA,
INTERP_LINEAR,
INTERP_NEAREST,
_histogram,
_scale_rect,
)
from plotpy.config import _
from plotpy.constants import LUT_MAX, LUT_SIZE, LUTAlpha
from plotpy.coords import pixelround
from plotpy.interfaces import (
IBaseImageItem,
IBasePlotItem,
IColormapImageItemType,
ICSImageItemType,
IExportROIImageItemType,
IHistDataSource,
IImageItemType,
ISerializableType,
ITrackableItemType,
IVoiImageItemType,
)
# do not import rectangleshape from plotpy.items directly
from plotpy.items.shape.rectangle import RectangleShape
from plotpy.lutrange import lut_range_threshold
from plotpy.mathutils.arrayfuncs import get_nan_range
from plotpy.mathutils.colormap import FULLRANGE, get_cmap
from plotpy.styles.image import RawImageParam
if TYPE_CHECKING:
import guidata.io
import qwt.color_map
import qwt.scale_map
from qtpy.QtCore import QPointF, QRectF
from qtpy.QtGui import QColor, QPainter
from plotpy.interfaces import IItemType
from plotpy.plot import BasePlot
from plotpy.styles.base import ItemParameters
from plotpy.widgets.colormap.widget import EditableColormap
[docs]
class BaseImageItem(QwtPlotItem):
"""Base class for image items
Args:
data: Image data
param: Image parameters
"""
__implements__ = (
IBasePlotItem,
IBaseImageItem,
IHistDataSource,
IVoiImageItemType,
ICSImageItemType,
IExportROIImageItemType,
)
_can_select = True
_can_resize = False
_can_move = False
_can_rotate = False
_readonly = False
_private = False
_icon_name = "image.png"
def __init__(
self, data: np.ndarray | None = None, param: Any | None = None
) -> None:
super().__init__()
self.bg_qcolor = QG.QColor()
self.bounds = QC.QRectF()
# BaseImageItem needs:
# param.background
# param.alpha_function
# param.alpha
# param.colormap
if param is None:
param = self.get_default_param()
self.param = param
self.selected = False
self.data: np.ndarray | None = None
self.min = 0.0
self.max = 1.0
self.cmap_table = None
self.cmap = None
self.colormap_axis = None
self._image: QG.QImage | None = None
self._offscreen = np.array((1, 1), np.uint32)
# Linear interpolation is the default interpolation algorithm:
# it's almost as fast as 'nearest pixel' method but far smoother
self.interpolate = None
self.set_interpolation(INTERP_LINEAR)
x1, y1 = self.bounds.left(), self.bounds.top()
x2, y2 = self.bounds.right(), self.bounds.bottom()
self.border_rect = RectangleShape(x1, y1, x2, y2)
self.border_rect.set_style("plot", "shape/imageborder")
# A, B, Background, Colormap
self.lut = (1.0, 0.0, None, np.zeros((LUT_SIZE,), np.uint32))
self.set_lut_range((0.0, 255.0))
self.setItemAttribute(QwtPlotItem.AutoScale)
self.setItemAttribute(QwtPlotItem.Legend, True)
self._filename = None # The file this image comes from
self.histogram_cache = None
# Z-axis logarithmic scale support
self._log_data: np.ndarray | None = None
self._lin_lut_range: tuple[float, float] | None = None
self._is_zaxis_log = False
if data is not None:
self.set_data(data)
self.param.update_item(self)
# ---- Public API ----------------------------------------------------------
[docs]
def get_default_param(self) -> Any:
"""Return instance of the default image param DataSet"""
raise NotImplementedError
[docs]
def set_filename(self, fname: str) -> None:
"""Set the filename of the image
Args:
fname: Filename
"""
self._filename = fname
[docs]
def get_filename(self) -> str | None:
"""Get the filename of the image
Returns:
Filename
"""
fname = self._filename
if fname is not None and not osp.isfile(fname):
other_try = osp.join(os.getcwd(), osp.basename(fname))
if osp.isfile(other_try):
self.set_filename(other_try)
fname = other_try
return fname
[docs]
def get_filter(self, filterobj, filterparam):
"""Provides a filter object over this image's content"""
raise NotImplementedError
[docs]
def get_pixel_coordinates(self, xplot: float, yplot: float) -> tuple[float, float]:
"""Get pixel coordinates from plot coordinates
Args:
xplot: X plot coordinate
yplot: Y plot coordinate
Returns:
Pixel coordinates
"""
return xplot, yplot
[docs]
def get_plot_coordinates(self, xpixel: float, ypixel: float) -> tuple[float, float]:
"""Get plot coordinates from pixel coordinates
Args:
xpixel: X pixel coordinate
ypixel: Y pixel coordinate
Returns:
Plot coordinates
"""
return xpixel, ypixel
[docs]
def get_closest_indexes(
self, x: float, y: float, corner: str | None = None
) -> tuple[int, int]:
"""Get closest image pixel indexes to the given coordinates
Args:
x: X coordinate
y: Y coordinate
corner: None (not a corner), 'TL' (top-left corner),
'BR' (bottom-right corner)
Returns:
Closest image pixel indexes
"""
x, y = self.get_pixel_coordinates(x, y)
i_max = self.data.shape[1] - 1
j_max = self.data.shape[0] - 1
if corner == "BR":
i_max += 1
j_max += 1
i = max([0, min([i_max, int(pixelround(x, corner))])])
j = max([0, min([j_max, int(pixelround(y, corner))])])
return i, j
[docs]
def get_closest_index_rect(
self, x0: float, y0: float, x1: float, y1: float, avoid_empty: bool = True
) -> tuple[int, int, int, int]:
"""Get closest image rectangular pixel area index bounds, optionally
avoid returning an empty rectangular area (return at least 1x1 pixel area)
Args:
x0: X coordinate of first point
y0: Y coordinate of first point
x1: X coordinate of second point
y1: Y coordinate of second point
avoid_empty: True to avoid returning an empty rectangular area.
Defaults to True.
Returns:
Closest image rectangular pixel area index bounds compatible with
direct array slicing (e.g., data[iy0:iy1, ix0:ix1])
.. note::
Avoid returning empty rectangular area (return 1x1 pixel area instead).
Handle reversed/not-reversed Y-axis orientation.
Indices are compatible with direct array slicing and match get_data
behavior.
"""
ix0, iy0 = self.get_closest_indexes(x0, y0, corner="TL")
# Use TL to avoid double +1 addition
ix1, iy1 = self.get_closest_indexes(x1, y1, corner="TL")
if ix0 > ix1:
ix1, ix0 = ix0, ix1
if iy0 > iy1:
iy1, iy0 = iy0, iy1
# Add +1 to bottom-right indices for slice compatibility (matches get_data)
ix1 = min(ix1 + 1, self.data.shape[1])
iy1 = min(iy1 + 1, self.data.shape[0])
if ix0 == ix1 and avoid_empty:
ix1 = min(ix1 + 1, self.data.shape[1])
if iy0 == iy1 and avoid_empty:
iy1 = min(iy1 + 1, self.data.shape[0])
return ix0, iy0, ix1, iy1
[docs]
def align_rectangular_shape(self, shape: RectangleShape) -> None:
"""Align rectangular shape to image pixels
Args:
shape: Shape to align
"""
ix0, iy0, ix1, iy1 = self.get_closest_index_rect(*shape.get_rect())
x0, y0 = self.get_plot_coordinates(ix0, iy0)
x1, y1 = self.get_plot_coordinates(ix1, iy1)
shape.set_rect(x0, y0, x1, y1)
[docs]
def get_closest_pixel_indexes(self, x: float, y: float) -> tuple[int, int]:
"""Get closest pixel indexes
Args:
x: X coordinate
y: Y coordinate
Returns:
Closest pixel indexes
.. note::
Instead of returning indexes of an image pixel like the method
'get_closest_indexes', this method returns the indexes of the
closest pixel which is not necessarily on the image itself
(i.e. indexes may be outside image index bounds: negative or
superior than the image dimension)
.. note::
This is *not* the same as retrieving the canvas pixel coordinates
(which depends on the zoom level)
"""
x, y = self.get_pixel_coordinates(x, y)
i = int(pixelround(x))
j = int(pixelround(y))
return i, j
[docs]
def get_x_values(self, i0: int, i1: int) -> np.ndarray:
"""Get X values from pixel indexes
Args:
i0: First index
i1: Second index
Returns:
X values corresponding to the given pixel indexes
"""
return np.arange(i0, i1)
[docs]
def get_y_values(self, j0: int, j1: int) -> np.ndarray:
"""Get Y values from pixel indexes
Args:
j0: First index
j1: Second index
Returns:
Y values corresponding to the given pixel indexes
"""
return np.arange(j0, j1)
[docs]
def get_r_values(self, i0, i1, j0, j1, flag_circle=False):
"""Get radial values from pixel indexes
Args:
i0: First index
i1: Second index
j0: Third index
j1: Fourth index
flag_circle: Flag circle (Default value = False)
Returns:
Radial values corresponding to the given pixel indexes
"""
return self.get_x_values(i0, i1)
def _recompute_log_data(self) -> None:
"""Refresh the cached log10 data from the current ``self.data``.
Used both when toggling the Z-axis log scale on and when the underlying
data is replaced (e.g. via :meth:`set_data`) while the log scale is
already active.
"""
self._log_data = np.array(np.log10(self.data.clip(1)), dtype=np.float64)
[docs]
def set_data(
self, data: np.ndarray, lut_range: tuple[float, float] | None = None
) -> None:
"""Set image data
Args:
data: 2D NumPy array
lut_range: LUT range -- tuple (levelmin, levelmax) (Default value = None)
"""
self.data = data
self.histogram_cache = None
self.update_bounds()
self.update_border()
# Refresh the cached log10 data when log scale is active, otherwise the
# display would keep using the previous (now stale) log data.
if self.get_zaxis_log_state():
self._recompute_log_data()
if not self.param.keep_lut_range:
if lut_range is not None:
_min, _max = lut_range
elif self.get_zaxis_log_state():
_min, _max = get_nan_range(self._log_data)
else:
_min, _max = get_nan_range(data)
self.set_lut_range((_min, _max))
[docs]
def get_data(
self, x0: float, y0: float, x1: float | None = None, y1: float | None = None
) -> float | tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Get image data
Args:
x0: X coordinate of first point
y0: Y coordinate of first point
x1: X coordinate of second point (Default value = None)
y1: Y coordinate of second point (Default value = None)
Returns:
Image level, or image levels in rectangular area (x0,y0,x1,y1)
as a tuple (x, y, data) of arrays
"""
i0, j0 = self.get_closest_indexes(x0, y0)
if x1 is None or y1 is None:
return self.data[j0, i0]
else:
i1, j1 = self.get_closest_indexes(x1, y1)
i1 += 1
j1 += 1
return (
self.get_x_values(i0, i1),
self.get_y_values(j0, j1),
self.data[j0:j1, i0:i1],
)
[docs]
def get_closest_coordinates(self, x: float, y: float) -> tuple[float, float]:
"""
Get the closest coordinates to the given point
Args:
x: X coordinate
y: Y coordinate
Returns:
tuple[float, float]: Closest coordinates
"""
return self.get_closest_indexes(x, y)
[docs]
def get_coordinates_label(self, x: float, y: float) -> str:
"""
Get the coordinates label for the given coordinates
Args:
x: X coordinate
y: Y coordinate
Returns:
str: Coordinates label
"""
title = self.title().text()
z = self.get_data(x, y)
zstr = "--" if z is np.ma.masked else f"{z:g}"
return f"{title}:<br>x = {int(x):d}<br>y = {int(y):d}<br>z = {zstr}"
[docs]
def set_background_color(self, qcolor: QColor | str) -> None:
"""Set background color
Args:
qcolor: Background color
"""
# mask = np.uint32(255*self.param.alpha+0.5).clip(0,255) << 24
self.bg_qcolor = qcolor
a, b, _bg, cmap = self.lut
if qcolor is None:
self.lut = (a, b, None, cmap)
else:
self.lut = (a, b, np.uint32(QG.QColor(qcolor).rgb() & 0xFFFFFF), cmap)
[docs]
def set_color_map(
self, name_or_table: str | EditableColormap, invert: bool | None = None
) -> None:
"""Set colormap
Args:
name_or_table: Colormap name or colormap
invert: True to invert colormap, False otherwise (Default value = None,
i.e. do not change the default behavior)
"""
if name_or_table is self.cmap_table:
# This avoids rebuilding the LUT all the time
return
if isinstance(name_or_table, str):
table = get_cmap(name_or_table)
else:
table = name_or_table
if invert is not None:
table.invert = invert
self.cmap_table = table
self.cmap = table.colorTable(FULLRANGE)
cmap_a = self.lut[3]
alpha = self.param.alpha
alpha_function = self.param.alpha_function
for i in range(LUT_SIZE):
if alpha_function == LUTAlpha.NONE.value:
pix_alpha = 1.0
elif alpha_function == LUTAlpha.CONSTANT.value:
pix_alpha = alpha
else:
x = i / float(LUT_SIZE - 1)
if alpha_function == LUTAlpha.LINEAR.value:
# Linear alpha function
pix_alpha = alpha * x
elif alpha_function == LUTAlpha.SIGMOID.value:
# Sigmoid alpha function
pix_alpha = alpha / (1 + np.exp(-10 * x))
elif alpha_function == LUTAlpha.TANH.value:
# Hyperbolic tangent alpha function
pix_alpha = alpha * np.tanh(5 * x)
elif alpha_function == LUTAlpha.STEP.value:
# Fully transparent lowest value and `alpha` transparent elsewhere
pix_alpha = alpha if x > 0 else 0
else:
raise ValueError(f"Invalid alpha function {alpha_function}")
# pylint: disable=too-many-function-args
alpha_channel = max(min(np.uint32(255 * pix_alpha + 0.5), 255), 0) << 24
cmap_a[i] = (
np.uint32((table.rgb(FULLRANGE, i / LUT_MAX)) & 0xFFFFFF)
| alpha_channel
)
plot = self.plot()
if plot:
plot.update_colormap_axis(self)
[docs]
def get_color_map(self) -> EditableColormap | None:
"""Get colormap
Returns:
Colormap
"""
return self.cmap_table
[docs]
def set_interpolation(self, interp_mode: int, size: int | None = None) -> None:
"""Set interpolation mode
Args:
interp_mode: INTERP_NEAREST, INTERP_LINEAR, INTERP_AA
size: (for anti-aliasing only) AA matrix size (Default value = None)
"""
if interp_mode in (INTERP_NEAREST, INTERP_LINEAR):
self.interpolate = (interp_mode,)
if interp_mode == INTERP_AA:
aa = np.ones((size, size), self.data.dtype)
self.interpolate = (interp_mode, aa)
[docs]
def get_interpolation(self) -> tuple[int] | tuple[int, np.ndarray]:
"""Get interpolation mode"""
return self.interpolate
[docs]
def set_lut_range(self, lut_range: tuple[float, float]) -> None:
"""
Set the current active lut range
Args:
lut_range: Lut range, tuple(min, max)
Example:
>>> item.set_lut_range((0.0, 1.0))
"""
self.min, self.max = lut_range
_a, _b, bg, cmap = self.lut
if self.max == self.min:
self.lut = (LUT_MAX, self.min, bg, cmap)
else:
fmin, fmax = float(self.min), float(self.max) # avoid overflows
self.lut = (
LUT_MAX / (fmax - fmin),
-LUT_MAX * fmin / (fmax - fmin),
bg,
cmap,
)
[docs]
def get_lut_range(self) -> tuple[float, float]:
"""Get the current active lut range
Returns:
tuple[float, float]: Lut range, tuple(min, max)
"""
return self.min, self.max
[docs]
def set_lut_threshold(self, threshold: float) -> None:
"""Set lut threshold value, eliminating the given percent of the histogram
Args:
threshold: Lut threshold value
"""
self.set_lut_range(lut_range_threshold(self, 256, threshold))
[docs]
def get_lut_range_full(self) -> tuple[float, float]:
"""Return full dynamic range
Returns:
tuple[float, float]: Lut range, tuple(min, max)
"""
return get_nan_range(self.data)
# ---- Z-axis logarithmic scale --------------------------------------------
[docs]
def get_zaxis_log_state(self) -> bool:
"""Return True if Z-axis is in logarithmic scale"""
return self._is_zaxis_log
[docs]
def set_zaxis_log_state(self, state: bool) -> None:
"""Set Z-axis logarithmic scale state
Args:
state: True to enable logarithmic scale, False otherwise
"""
self._is_zaxis_log = state
plot = self.plot()
if state:
self._lin_lut_range = self.get_lut_range()
if self._log_data is None:
self._recompute_log_data()
self.set_lut_range(get_nan_range(self._log_data))
dtype = self._log_data.dtype
else:
self._log_data = None
self.set_lut_range(self._lin_lut_range)
dtype = self.data.dtype
if self.interpolate[0] == INTERP_AA:
self.interpolate = (INTERP_AA, self.interpolate[1].astype(dtype))
if plot is not None:
plot.update_colormap_axis(self)
[docs]
def get_lut_range_max(self) -> tuple[float, float]:
"""Get maximum range for this dataset
Returns:
tuple[float, float]: Lut range, tuple(min, max)
"""
kind = self.data.dtype.kind
if kind in np.typecodes["AllFloat"]:
info = np.finfo(self.data.dtype)
else:
info = np.iinfo(self.data.dtype)
return info.min, info.max
[docs]
def update_bounds(self) -> None:
"""Update image bounds to fit image shape"""
if self.data is None:
return
self.bounds = QC.QRectF(0, 0, self.data.shape[1], self.data.shape[0])
[docs]
def update_border(self) -> None:
"""Update image border rectangle to fit image shape"""
bounds = self.boundingRect().getCoords()
self.border_rect.set_rect(*bounds)
[docs]
def draw_border(
self,
painter: QPainter,
xMap: qwt.scale_map.QwtScaleMap,
yMap: qwt.scale_map.QwtScaleMap,
canvasRect: QRectF,
) -> None:
"""Draw image border rectangle
Args:
painter: Painter
xMap: X axis scale map
yMap: Y axis scale map
canvasRect: Canvas rectangle
"""
self.border_rect.draw(painter, xMap, yMap, canvasRect)
[docs]
def warn_if_non_linear_scale(self, painter: QPainter, canvasRect: QRectF) -> bool:
"""Warn if non-linear scale
Args:
painter: Painter
canvasRect: Canvas rectangle
Returns:
True if non-linear scale
"""
plot: BasePlot = self.plot()
if not isinstance(
plot.axisScaleEngine(plot.X_BOTTOM), QwtLinearScaleEngine
) or not isinstance(plot.axisScaleEngine(plot.Y_LEFT), QwtLinearScaleEngine):
painter.setPen(QC.Qt.red)
painter.drawText(
canvasRect,
QC.Qt.AlignCenter,
_("Non-linear scales are not supported for image items"),
)
return True
return False
[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
dest = _scale_rect(
self.data, src_rect, self._offscreen, dst_rect, self.lut, self.interpolate
)
qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3]))
painter.drawImage(qrect, self._image, qrect)
[docs]
def export_roi(
self,
src_rect: tuple[float, float, float, float],
dst_rect: tuple[float, float, float, float],
dst_image: np.ndarray,
apply_lut: bool = False,
apply_interpolation: bool = False,
original_resolution: bool = False,
force_interp_mode: str | None = None,
force_interp_size: int | None = None,
) -> None:
"""
Export a rectangular area of the image to another image
Args:
src_rect: Source rectangle
dst_rect: Destination rectangle
dst_image: Destination image
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)
"""
if apply_lut:
a, b, _bg, _cmap = self.lut
else:
a, b = 1.0, 0.0
interp = self.interpolate if apply_interpolation else (INTERP_NEAREST,)
_scale_rect(self.data, src_rect, dst_image, dst_rect, (a, b, None), interp)
# ---- QwtPlotItem API -----------------------------------------------------
[docs]
def draw(
self,
painter: QPainter,
xMap: qwt.scale_map.QwtScaleMap,
yMap: qwt.scale_map.QwtScaleMap,
canvasRect: QRectF,
) -> None:
"""Draw the item
Args:
painter: Painter
xMap: X axis scale map
yMap: Y axis scale map
canvasRect: Canvas rectangle
"""
x1, y1, x2, y2 = canvasRect.getCoords()
i1, i2 = xMap.invTransform(x1), xMap.invTransform(x2)
j1, j2 = yMap.invTransform(y1), yMap.invTransform(y2)
xl, yt, xr, yb = self.boundingRect().getCoords()
dest = (
xMap.transform(xl),
yMap.transform(yt),
xMap.transform(xr) + 1,
yMap.transform(yb) + 1,
)
W = int(canvasRect.right())
H = int(canvasRect.bottom())
if self._offscreen.shape != (H, W):
self._offscreen = np.empty((H, W), np.uint32)
self._image = QG.QImage(self._offscreen, W, H, QG.QImage.Format_ARGB32)
self._image.ndarray = self._offscreen
self.notify_new_offscreen()
self.draw_image(painter, canvasRect, (i1, j1, i2, j2), dest, xMap, yMap)
self.draw_border(painter, xMap, yMap, canvasRect)
[docs]
def boundingRect(self) -> QRectF:
"""Return the bounding rectangle of the data
Returns:
Bounding rectangle of the data
"""
return self.bounds
[docs]
def notify_new_offscreen(self) -> None:
"""Notify that the offscreen image has changed"""
# callback for those derived classes who need it
pass
[docs]
def setVisible(self, enable: bool) -> None:
"""Set item visibility
Args:
enable: True if item is visible, False otherwise
"""
if not enable:
self.unselect() # when hiding item, unselect it
if enable:
self.border_rect.show()
else:
self.border_rect.hide()
QwtPlotItem.setVisible(self, enable)
# ---- 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 (
IImageItemType,
IVoiImageItemType,
IColormapImageItemType,
ITrackableItemType,
ICSImageItemType,
IExportROIImageItemType,
)
[docs]
def set_readonly(self, state: bool) -> None:
"""Set object readonly state
Args:
state: True if object is readonly, False otherwise
"""
self._readonly = state
[docs]
def is_readonly(self) -> bool:
"""Return object readonly state
Returns:
bool: True if object is readonly, False otherwise
"""
return self._readonly
[docs]
def set_private(self, state: bool) -> None:
"""Set object as private
Args:
state: True if object is private, False otherwise
"""
self._private = state
[docs]
def is_private(self) -> bool:
"""Return True if object is private
Returns:
bool: True if object is private, False otherwise
"""
return self._private
[docs]
def select(self) -> None:
"""
Select the object and eventually change its appearance to highlight the
fact that it's selected
"""
self.selected = True
self.border_rect.select()
[docs]
def unselect(self) -> None:
"""
Unselect the object and eventually restore its original appearance to
highlight the fact that it's not selected anymore
"""
self.selected = False
self.border_rect.unselect()
[docs]
def is_empty(self) -> bool:
"""Return True if the item is empty
Returns:
True if the item is empty, False otherwise
"""
return self.data is None or self.data.size == 0
[docs]
def get_icon_name(self) -> str:
"""Return the icon name
Returns:
Icon name
"""
return self._icon_name
[docs]
def set_icon_name(self, icon_name: str) -> None:
"""Set the icon name
Args:
icon_name: Icon name
"""
self._icon_name = icon_name
[docs]
def set_selectable(self, state: bool) -> None:
"""Set item selectable state
Args:
state: True if item is selectable, False otherwise
"""
self._can_select = state
[docs]
def set_resizable(self, state: bool) -> None:
"""Set item resizable state
(or any action triggered when moving an handle, e.g. rotation)
Args:
state: True if item is resizable, False otherwise
"""
self._can_resize = state
[docs]
def set_movable(self, state: bool) -> None:
"""Set item movable state
Args:
state: True if item is movable, False otherwise
"""
self._can_move = state
[docs]
def set_rotatable(self, state: bool) -> None:
"""Set item rotatable state
Args:
state: True if item is rotatable, False otherwise
"""
self._can_rotate = state
[docs]
def can_select(self) -> bool:
"""
Returns True if this item can be selected
Returns:
bool: True if item can be selected, False otherwise
"""
return self._can_select
[docs]
def can_resize(self) -> bool:
"""
Returns True if this item can be resized
Returns:
bool: True if item can be resized, False otherwise
"""
return self._can_resize and not getattr(self.param, "lock_position", False)
[docs]
def can_move(self) -> bool:
"""
Returns True if this item can be moved
Returns:
bool: True if item can be moved, False otherwise
"""
return self._can_move and not getattr(self.param, "lock_position", False)
[docs]
def can_rotate(self) -> bool:
"""
Returns True if this item can be rotated
Returns:
bool: True if item can be rotated, False otherwise
"""
return self._can_rotate and not getattr(self.param, "lock_position", False)
[docs]
def hit_test(self, pos: QPointF) -> tuple[float, float, bool, None]:
"""Return a tuple (distance, attach point, inside, other_object)
Args:
pos: Position
Returns:
tuple: Tuple with four elements: (distance, attach point, inside,
other_object).
Description of the returned values:
* distance: distance in pixels (canvas coordinates) to the closest
attach point
* attach point: handle of the attach point
* inside: True if the mouse button has been clicked inside the object
* other_object: if not None, reference of the object which will be
considered as hit instead of self
"""
plot = self.plot()
ax = self.xAxis()
ay = self.yAxis()
return self.border_rect.poly_hit_test(plot, ax, ay, pos)
[docs]
def update_item_parameters(self) -> None:
"""Update item parameters (dataset) from object properties"""
pass
[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
"""
itemparams.add("ShapeParam", self, self.border_rect.shapeparam)
[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
"""
self.border_rect.set_item_parameters(itemparams)
[docs]
def move_local_point_to(self, handle: int, pos: QPointF, ctrl: bool = None) -> None:
"""Move a handle as returned by hit_test to the new position
Args:
handle: Handle
pos: Position
ctrl: True if <Ctrl> button is being pressed, False otherwise
"""
pass
[docs]
def move_local_shape(self, old_pos: QPointF, new_pos: QPointF) -> None:
"""Translate the shape such that old_pos becomes new_pos in canvas coordinates
Args:
old_pos: Old position
new_pos: New position
"""
pass
[docs]
def move_with_selection(self, delta_x: float, delta_y: float) -> None:
"""Translate the item together with other selected items
Args:
delta_x: Translation in plot coordinates along x-axis
delta_y: Translation in plot coordinates along y-axis
"""
pass
[docs]
def resize_with_selection(self, zoom_dx, zoom_dy):
"""
Resize the shape together with other selected items
zoom_dx, zoom_dy : zoom factor for dx and dy
"""
pass
[docs]
def rotate_with_selection(self, angle):
"""
Rotate the shape together with other selected items
angle : rotation angle
"""
pass
# ---- 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 False
[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]
if self.histogram_cache is None or nbins != self.histogram_cache[0].shape[0]:
if True:
# Note: np.histogram does not accept data with NaN
res = np.histogram(
self.data[~np.isnan(self.data)], bins=nbins, range=drange
)
else:
# TODO: _histogram is faster, but caching is buggy in this version
_min, _max = get_nan_range(self.data)
if 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)
res = res2[1:-1], bins
self.histogram_cache = res
else:
res = self.histogram_cache
return res
def __process_cross_section(self, ydata, apply_lut):
if apply_lut:
a, b, _bg, _cmap = self.lut
return (ydata * a + b).clip(0, LUT_MAX)
else:
return ydata
[docs]
def get_xsection(self, y0: float | int, apply_lut: bool = False) -> np.ndarray:
"""Return cross section along x-axis at y=y0
Args:
y0: Y0
apply_lut: Apply lut (Default value = False)
Returns:
Cross section along x-axis at y=y0
"""
_ix, iy = self.get_closest_indexes(0, y0)
return (
self.get_x_values(0, self.data.shape[1]),
self.__process_cross_section(self.data[iy, :], apply_lut),
)
[docs]
def get_ysection(self, x0: float | int, apply_lut: bool = False) -> np.ndarray:
"""Return cross section along y-axis at x=x0
Args:
x0: X0
apply_lut: Apply lut (Default value = False)
Returns:
Cross section along y-axis at x=x0
"""
ix, _iy = self.get_closest_indexes(x0, 0)
return (
self.get_y_values(0, self.data.shape[0]),
self.__process_cross_section(self.data[:, ix], apply_lut),
)
[docs]
def get_average_xsection(
self, x0: float, y0: float, x1: float, y1: float, apply_lut: bool = False
) -> np.ndarray:
"""Return average cross section along x-axis for the given rectangle
Args:
x0: X0 of top left corner
y0: Y0 of top left corner
x1: X1 of bottom right corner
y1: Y1 of bottom right corner
apply_lut: Apply lut (Default value = False)
Returns:
Average cross section along x-axis
"""
ix0, iy0, ix1, iy1 = self.get_closest_index_rect(
x0, y0, x1, y1, avoid_empty=False
)
ydata = self.data[iy0:iy1, ix0:ix1]
if ydata.size == 0:
return np.array([]), np.array([])
ydata = ydata.mean(axis=0)
return (
self.get_x_values(ix0, ix1),
self.__process_cross_section(ydata, apply_lut),
)
[docs]
def get_average_ysection(
self, x0: float, y0: float, x1: float, y1: float, apply_lut: bool = False
) -> np.ndarray:
"""Return average cross section along y-axis
Args:
x0: X0 of top left corner
y0: Y0 of top left corner
x1: X1 of bottom right corner
y1: Y1 of bottom right corner
apply_lut: Apply lut (Default value = False)
Returns:
Average cross section along y-axis
"""
ix0, iy0, ix1, iy1 = self.get_closest_index_rect(
x0, y0, x1, y1, avoid_empty=False
)
ydata = self.data[iy0:iy1, ix0:ix1]
if ydata.size == 0:
return np.array([]), np.array([])
ydata = ydata.mean(axis=1)
return (
self.get_y_values(iy0, iy1),
self.__process_cross_section(ydata, apply_lut),
)
assert_interfaces_valid(BaseImageItem)
# ==============================================================================
# Raw Image item (image item without scale)
# ==============================================================================
[docs]
class RawImageItem(BaseImageItem):
"""
Construct a simple image item
* data: 2D NumPy array
* param: image parameters
(:py:class:`.styles.RawImageParam` instance)
"""
__implements__ = (
IBasePlotItem,
IBaseImageItem,
IHistDataSource,
IVoiImageItemType,
ISerializableType,
)
# ---- BaseImageItem API ---------------------------------------------------
[docs]
def get_default_param(self) -> RawImageParam:
"""Return instance of the default image param DataSet
Returns:
RawImageParam: Default image param DataSet
"""
return RawImageParam(_("Image"))
# ---- Serialization methods -----------------------------------------------
def __reduce__(self) -> tuple:
"""Return state information for pickling"""
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()
res = (self.__class__, (), state)
return res
def __setstate__(self, state: tuple) -> None:
"""Restore state information for pickling"""
param, lut_range, fn_or_data, z = state
self.param = param
if isinstance(fn_or_data, str):
self.set_filename(fn_or_data)
self.load_data()
elif fn_or_data is not None: # should happen only with previous API
self.set_data(fn_or_data)
self.set_lut_range(lut_range)
self.setZ(z)
self.param.update_item(self)
[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
"""
fname = self.get_filename()
load_from_fname = fname is not None
data = None if load_from_fname else self.data
writer.write(load_from_fname, group_name="load_from_fname")
writer.write(fname, group_name="fname")
writer.write(data, group_name="Zdata")
writer.write(self.get_lut_range(), group_name="lut_range")
writer.write(self.z(), group_name="z")
self.param.update_param(self)
writer.write(self.param, group_name="imageparam")
[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
"""
lut_range = reader.read(group_name="lut_range")
if reader.read(group_name="load_from_fname"):
name = reader.read(group_name="fname", func=reader.read_unicode)
self.set_filename(name)
self.load_data()
else:
data = reader.read(group_name="Zdata", func=reader.read_array)
self.set_data(data)
self.set_lut_range(lut_range)
self.setZ(reader.read("z"))
self.param = self.get_default_param()
reader.read("imageparam", instance=self.param)
self.param.update_item(self)
# ---- Public API ----------------------------------------------------------
[docs]
def load_data(self, lut_range: list[float, float] | None = None) -> None:
"""Load data from item filename attribute
and eventually apply specified lut_range
Args:
lut_range: Lut range (Default value = None)
"""
data = io.imread(self.get_filename(), to_grayscale=True)
self.set_data(data, lut_range=lut_range)
# ---- 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 (
IImageItemType,
IVoiImageItemType,
IColormapImageItemType,
ITrackableItemType,
ICSImageItemType,
ISerializableType,
IExportROIImageItemType,
)
[docs]
def update_item_parameters(self) -> None:
"""Update item parameters (dataset) from object properties"""
self.param.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)
self.update_item_parameters()
itemparams.add("ImageParam", self, self.param)
[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.param, itemparams.get("ImageParam"), visible_only=True)
self.param.update_item(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
assert_interfaces_valid(RawImageItem)