import math
from typing import *
import pygfx
from ..utils import quick_min_max
from ._base import Graphic
from .selectors import LinearSelector, LinearRegionSelector, RectangleSelector
from ._features import (
TextureArray,
ImageCmap,
ImageVmin,
ImageVmax,
ImageInterpolation,
ImageCmapInterpolation,
)
class _ImageTile(pygfx.Image):
"""
Similar to pygfx.Image, only difference is that it modifies the pick_info
by adding the data row start indices that correspond to this chunk of the big image
"""
def __init__(
self,
geometry,
material,
data_slice: tuple[slice, slice],
chunk_index: tuple[int, int],
**kwargs,
):
super().__init__(geometry, material, **kwargs)
self._data_slice = data_slice
self._chunk_index = chunk_index
def _wgpu_get_pick_info(self, pick_value):
pick_info = super()._wgpu_get_pick_info(pick_value)
data_row_start, data_col_start = (
self.data_slice[0].start,
self.data_slice[1].start,
)
# add the actual data row and col start indices
x, y = pick_info["index"]
x += data_col_start
y += data_row_start
pick_info["index"] = (x, y)
xp, yp = pick_info["pixel_coord"]
xp += data_col_start
yp += data_row_start
pick_info["pixel_coord"] = (xp, yp)
# add row chunk and col chunk index to pick_info dict
return {
**pick_info,
"data_slice": self.data_slice,
"chunk_index": self.chunk_index,
}
@property
def data_slice(self) -> tuple[slice, slice]:
return self._data_slice
@property
def chunk_index(self) -> tuple[int, int]:
return self._chunk_index
[docs]
class ImageGraphic(Graphic):
_features = {"data", "cmap", "vmin", "vmax", "interpolation", "cmap_interpolation"}
def __init__(
self,
data: Any,
vmin: int = None,
vmax: int = None,
cmap: str = "plasma",
interpolation: str = "nearest",
cmap_interpolation: str = "linear",
isolated_buffer: bool = True,
**kwargs,
):
"""
Create an Image Graphic
Parameters
----------
data: array-like
array-like, usually numpy.ndarray, must support ``memoryview()``
| shape must be ``[n_rows, n_cols]``, ``[n_rows, n_cols, 3]`` for RGB or ``[n_rows, n_cols, 4]`` for RGBA
vmin: int, optional
minimum value for color scaling, calculated from data if not provided
vmax: int, optional
maximum value for color scaling, calculated from data if not provided
cmap: str, optional, default "plasma"
colormap to use to display the data
interpolation: str, optional, default "nearest"
interpolation filter, one of "nearest" or "linear"
cmap_interpolation: str, optional, default "linear"
colormap interpolation method, one of "nearest" or "linear"
isolated_buffer: bool, default True
If True, initialize a buffer with the same shape as the input data and then
set the data, useful if the data arrays are ready-only such as memmaps.
If False, the input array is itself used as the buffer.
kwargs:
additional keyword arguments passed to Graphic
"""
super().__init__(**kwargs)
world_object = pygfx.Group()
# texture array that manages the textures on the GPU for displaying this image
self._data = TextureArray(data, isolated_buffer=isolated_buffer)
if (vmin is None) or (vmax is None):
vmin, vmax = quick_min_max(data)
# other graphic features
self._vmin = ImageVmin(vmin)
self._vmax = ImageVmax(vmax)
self._interpolation = ImageInterpolation(interpolation)
# set map to None for RGB images
if self._data.value.ndim > 2:
self._cmap = None
_map = None
else:
# use TextureMap for grayscale images
self._cmap = ImageCmap(cmap)
self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation)
_map = pygfx.TextureMap(
self._cmap.texture,
filter=self._cmap_interpolation.value,
wrap="clamp-to-edge",
)
# one common material is used for every Texture chunk
self._material = pygfx.ImageBasicMaterial(
clim=(vmin, vmax),
map=_map,
interpolation=self._interpolation.value,
pick_write=True,
)
# iterate through each texture chunk and create
# an _ImageTIle, offset the tile using the data indices
for texture, chunk_index, data_slice in self._data:
# create an ImageTile using the texture for this chunk
img = _ImageTile(
geometry=pygfx.Geometry(grid=texture),
material=self._material,
data_slice=data_slice, # used to parse pick_info
chunk_index=chunk_index,
)
# row and column start index for this chunk
data_row_start = data_slice[0].start
data_col_start = data_slice[1].start
# offset tile position using the indices from the big data array
# that correspond to this chunk
img.world.x = data_col_start
img.world.y = data_row_start
world_object.add(img)
self._set_world_object(world_object)
@property
def data(self) -> TextureArray:
"""Get or set the image data"""
return self._data
@data.setter
def data(self, data):
self._data[:] = data
@property
def cmap(self) -> str:
"""colormap name"""
if self.data.value.ndim > 2:
raise AttributeError("RGB(A) images do not have a colormap property")
return self._cmap.value
@cmap.setter
def cmap(self, name: str):
if self.data.value.ndim > 2:
raise AttributeError("RGB(A) images do not have a colormap property")
self._cmap.set_value(self, name)
@property
def vmin(self) -> float:
"""lower contrast limit"""
return self._vmin.value
@vmin.setter
def vmin(self, value: float):
self._vmin.set_value(self, value)
@property
def vmax(self) -> float:
"""upper contrast limit"""
return self._vmax.value
@vmax.setter
def vmax(self, value: float):
self._vmax.set_value(self, value)
@property
def interpolation(self) -> str:
"""image data interpolation method"""
return self._interpolation.value
@interpolation.setter
def interpolation(self, value: str):
self._interpolation.set_value(self, value)
@property
def cmap_interpolation(self) -> str:
"""cmap interpolation method"""
return self._cmap_interpolation.value
@cmap_interpolation.setter
def cmap_interpolation(self, value: str):
self._cmap_interpolation.set_value(self, value)
[docs]
def reset_vmin_vmax(self):
"""
Reset the vmin, vmax by estimating it from the data
Returns
-------
None
"""
vmin, vmax = quick_min_max(self._data.value)
self.vmin = vmin
self.vmax = vmax
[docs]
def add_linear_selector(
self, selection: int = None, axis: str = "x", padding: float = None, **kwargs
) -> LinearSelector:
"""
Adds a :class:`.LinearSelector`.
Parameters
----------
selection: int, optional
initial position of the selector
padding: float, optional
pad the length of the selector
kwargs:
passed to :class:`.LinearSelector`
Returns
-------
LinearSelector
"""
if axis == "x":
size = self._data.value.shape[0]
center = size / 2
limits = (0, self._data.value.shape[1])
elif axis == "y":
size = self._data.value.shape[1]
center = size / 2
limits = (0, self._data.value.shape[0])
else:
raise ValueError("`axis` must be one of 'x' | 'y'")
# default padding is 25% the height or width of the image
if padding is None:
size *= 1.25
else:
size += padding
if selection is None:
selection = limits[0]
if selection < limits[0] or selection > limits[1]:
raise ValueError(
f"the passed selection: {selection} is beyond the limits: {limits}"
)
selector = LinearSelector(
selection=selection,
limits=limits,
size=size,
center=center,
axis=axis,
parent=self,
**kwargs,
)
self._plot_area.add_graphic(selector, center=False)
# place selector above this graphic
selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1)
return selector
[docs]
def add_linear_region_selector(
self,
selection: tuple[float, float] = None,
axis: str = "x",
padding: float = 0.0,
fill_color=(0, 0, 0.35, 0.2),
**kwargs,
) -> LinearRegionSelector:
"""
Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage,
remove, or delete them from a plot area just like any other ``Graphic``.
Parameters
----------
selection: (float, float)
initial (min, max) of the selection
axis: "x" | "y"
axis the selector can move along
padding: float, default 100.0
Extends the linear selector along the perpendicular axis to make it easier to interact with.
kwargs
passed to ``LinearRegionSelector``
Returns
-------
LinearRegionSelector
linear selection graphic
"""
if axis == "x":
size = self._data.value.shape[0]
center = size / 2
limits = (0, self._data.value.shape[1])
elif axis == "y":
size = self._data.value.shape[1]
center = size / 2
limits = (0, self._data.value.shape[0])
else:
raise ValueError("`axis` must be one of 'x' | 'y'")
# default padding is 25% the height or width of the image
if padding is None:
size *= 1.25
else:
size += padding
if selection is None:
selection = limits[0], int(limits[1] * 0.25)
if padding is None:
size *= 1.25
else:
size += padding
selector = LinearRegionSelector(
selection=selection,
limits=limits,
size=size,
center=center,
axis=axis,
fill_color=fill_color,
parent=self,
**kwargs,
)
self._plot_area.add_graphic(selector, center=False)
# place above this graphic
selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1)
return selector
[docs]
def add_rectangle_selector(
self,
selection: tuple[float, float, float, float] = None,
fill_color=(0, 0, 0.35, 0.2),
**kwargs,
) -> RectangleSelector:
"""
Add a :class:`.RectangleSelector`. Selectors are just ``Graphic`` objects, so you can manage,
remove, or delete them from a plot area just like any other ``Graphic``.
Parameters
----------
selection: (float, float, float, float), optional
initial (xmin, xmax, ymin, ymax) of the selection
"""
# default selection is 25% of the diagonal
if selection is None:
diagonal = math.sqrt(
self._data.value.shape[0] ** 2 + self._data.value.shape[1] ** 2
)
selection = (0, int(diagonal / 4), 0, int(diagonal / 4))
# min/max limits are image shape
# rows are ys, columns are xs
limits = (0, self._data.value.shape[1], 0, self._data.value.shape[0])
selector = RectangleSelector(
selection=selection,
limits=limits,
fill_color=fill_color,
parent=self,
**kwargs,
)
self._plot_area.add_graphic(selector, center=False)
# place above this graphic
selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1)
return selector