Source code for fastplotlib.graphics.features._image

from itertools import product
from math import ceil
from typing import Literal
from warnings import warn

import cmap as cmap_lib
import numpy as np
from numpy.typing import NDArray

import wgpu
import pygfx

from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance

from .utils import get_element_format_from_numpy_array
from ...utils import ColorspacesRGB, ColorspacesYUV, ColorRange
from ...utils.types import ColormapLike, TupleYUV


[docs] class TextureArray(GraphicFeature): """ Manages an array of Textures representing chunks of an image. Creates multiple pygfx.Texture objects based on the GPU's max texture dimension limit. """ event_info_spec = [ { "dict key": "key", "type": "slice, index, numpy-like fancy index", "description": "key at which image data was sliced/fancy indexed", }, { "dict key": "value", "type": "np.ndarray | float", "description": "new data values", }, ] def __init__( self, data, property_name: str = "data", cpu_buffer: bool = True, usage: wgpu.TextureUsage = 0, colorspace: ColorspacesRGB = ColorspacesRGB.srgb, ): super().__init__(property_name=property_name) self._colorspace = ColorspacesRGB(colorspace) self._cpu_buffer = cpu_buffer data = self._check_data(data, colorspace, cpu_buffer) self._shape = data.shape shared = pygfx.renderers.wgpu.get_shared() self._texture_limit_2d = shared.device.limits["max-texture-dimension-2d"] if cpu_buffer: # create a local buffer self._value = np.empty(data.shape, dtype=data.dtype) self.value[:] = data[:] else: self._value = None # auto-determine format, adapted from pygfx.Texture element_format = get_element_format_from_numpy_array(data) if element_format is None: raise ValueError( f"Unsupported dtype/format for texture data: {data.dtype}" ) if data.ndim == 3: nchannels = data.shape[-1] else: nchannels = 1 format_ = (f"{nchannels}x" + element_format).lstrip("1x") self._shape = data.shape # data start indices for each Texture self._row_indices = np.arange( 0, ceil(self.shape[0] / self._texture_limit_2d) * self._texture_limit_2d, self._texture_limit_2d, ) self._col_indices = np.arange( 0, ceil(self.shape[1] / self._texture_limit_2d) * self._texture_limit_2d, self._texture_limit_2d, ) # buffer will be an array of textures self._buffer: NDArray[pygfx.Texture] = np.empty( shape=(self.row_indices.size, self.col_indices.size), dtype=object ) self._iter = None # iterate through each chunk of passed `data` # create a pygfx.Texture from this chunk for _, buffer_index, slicer in self: if cpu_buffer: # texture gets the data directly texture = pygfx.Texture( self.value[slicer], dim=2, colorspace=colorspace, usage=usage ) else: # we only supply the size w, h = data[slicer].shape[1], data[slicer].shape[0] texture = pygfx.Texture( size=(w, h, 1), dim=2, colorspace=colorspace, format=format_, usage=usage, ) # send the initial data texture.send_data((0, 0, 0), data[slicer]) self.buffer[buffer_index] = texture @property def colorspace( self, ) -> ColorspacesRGB: """Colorspace, read only""" return self._colorspace @property def cpu_buffer(self) -> bool: """whether or not a cpu buffer exists for this TextureArray""" return self._cpu_buffer @property def shape(self) -> tuple[int, int] | tuple[int, int, int]: """ the shape of the represented data, [n_rows, n_cols] or [n_rows, n_cols, 3 | 4] """ return self._shape @property def value(self) -> np.ndarray | None: """array buffer if Texture has a cpu buffer, otherwise None""" return self._value
[docs] def set_value(self, graphic, value: np.ndarray): if not self.cpu_buffer: if isinstance(value, np.ndarray): # if cpu_buffer is False, we directly send data to the GPU if value.shape != self.shape: raise ValueError( f"new data shape must be the same as the original data array if `cpu_buffer=False`" f"original data shape was: {self.shape}, data passed is of shape: {value.shape}" ) for texture, buffer_index, slicer in self: chunk = value[slicer] texture.send_data((0, 0, 0), chunk) else: # set the cpu buffer, it will be marked for upload self[:] = value
@property def buffer(self) -> NDArray[pygfx.Texture]: return self._buffer @property def row_indices(self) -> np.ndarray: """ row indices that are used to chunk the big data array into individual Textures on the GPU """ return self._row_indices @property def col_indices(self) -> np.ndarray: """ column indices that are used to chunk the big data array into individual Textures on the GPU """ return self._col_indices def _check_data(self, data, colorspace, cpu_buffer): # make sure data ndim is valid for the given colorspace if data.ndim not in (2, 3): raise ValueError( "the image data must be 2D with or without an RGB(A) dimension, i.e. " "it must be of shape [rows, cols], [rows, cols, 3] or [rows, cols, 4]" ) if data.ndim == 3 and not cpu_buffer: # wgpu only supports rgba, it does not support rgb if data.shape[-1] != 4: raise ValueError( "if the colorspace is 'srgb', 'tex-srgb', or 'physical' and `cpu_buffer=False`" "the image data MUST be RGBA, with shape [rows, cols, 4]. WGPU does not support " "rgb textures. You must either supply full a RGBA array with `cpu_buffer=False` or " "use `cpu_buffer=True` which supports RGB arrays." ) if data.itemsize == 8: warn(f"casting {data.dtype} array to float32") return data.astype(np.float32) return data def __iter__(self): self._iter = product(enumerate(self.row_indices), enumerate(self.col_indices)) return self def __next__(self) -> tuple[pygfx.Texture, tuple[int, int], tuple[slice, slice]]: """ Iterate through each Texture within the texture array Returns ------- Texture, tuple[int, int], tuple[slice, slice] | Texture: pygfx.Texture | tuple[int, int]: chunk index, i.e corresponding index of ``self.buffer`` array | tuple[slice, slice]: data slice of big array in this chunk and Texture """ (chunk_row, data_row_start), (chunk_col, data_col_start) = next(self._iter) # indices for to self.buffer for this chunk chunk_index = (chunk_row, chunk_col) # stop indices of big data array for this chunk row_stop = min(self.shape[0], data_row_start + self._texture_limit_2d) col_stop = min(self.shape[1], data_col_start + self._texture_limit_2d) # row and column slices that slice the data for this chunk from the big data array slicer = (slice(data_row_start, row_stop), slice(data_col_start, col_stop)) # texture for this chunk texture = self.buffer[chunk_index] return texture, chunk_index, slicer def __getitem__(self, item): if not self.cpu_buffer: return None return self.value[item] @block_reentrance def __setitem__(self, key, value): if not self.cpu_buffer: raise BufferError( f"setting slices or specific elements of texture data is only supported when `cpu_buffer=True`." f"'unbuffered' textures only support setting the full data entirely, " f"i.e. you must do: graphic.data = new_arr, you cannot do: graphic.data[indices] = new_arr, unless " f"`cpu_buffer=True`" ) self.value[key] = value for texture in self.buffer.ravel(): texture.update_range((0, 0, 0), texture.size) event = GraphicFeatureEvent( self._property_name, info={"key": key, "value": value} ) self._call_event_handlers(event) def __len__(self): return self.buffer.size
[docs] class TextureYUV(GraphicFeature): """ Manages a YUV texture, no chunking, no local buffer """ event_info_spec = [ { "dict key": "key", "type": "slice, index, numpy-like fancy index", "description": "key at which image data was sliced/fancy indexed", }, { "dict key": "value", "type": "np.ndarray | float", "description": "new data values", }, ] def __init__( self, data: TupleYUV, property_name: str = "data", colorspace: ColorspacesYUV = ColorspacesYUV.yuv420p, colorrrange: ColorRange = ColorRange.limited, ): super().__init__(property_name=property_name) self._colorspace = ColorspacesYUV(colorspace) self._colorrange = ColorRange(colorrrange) self._check_data(data) self._data = data shared = pygfx.renderers.wgpu.get_shared() limit = shared.device.limits["max-texture-dimension-2d"] if data[0].shape[0] > limit or data[0].shape[1] > limit: raise ValueError( f"YUV colorspaces Images currently don't support dimensions that exceed the device's " f"max-texture-dimension-2d. For now you must manually tile individual Images to use a YUV colorspace." ) self._allocate_texture(data) self._send_data(data) @property def cpu_buffer(self) -> Literal[False]: return False @property def texture(self) -> pygfx.Texture: return self._texture @property def colorspace(self) -> ColorspacesYUV: return self._colorspace @property def colorrange(self) -> ColorRange: return self._colorrange def _allocate_texture(self, data: TupleYUV): """Create a new pygfx.Texture""" self._h, self._w = data[0].shape if self.colorspace == ColorspacesYUV.yuv420p: depth = 2 else: depth = 3 self._texture = pygfx.Texture( size=(self._w, self._h, depth), dim=2, colorspace=self.colorspace.value, colorrange=self.colorrange.value, format="r8unorm", usage=wgpu.TextureUsage.COPY_DST, ) def _send_data(self, data): """send the data to the GPU""" y, u, v = data self._texture.send_data((0, 0, 0), y) if self.colorspace == ColorspacesYUV.yuv420p: self._texture.send_data((0, 0, 1), u) self._texture.send_data((self._w // 2, 0, 1), v) else: self._texture.send_data((0, 0, 1), u) self._texture.send_data((0, 0, 2), v) @property def value(self) -> None: """this is bufferless""" return None
[docs] def set_value(self, graphic, value: TupleYUV): self._check_data(value) y, u, v = value if y.shape[0] != self._h or y.shape[1] != self._w: self._allocate_texture(value) graphic.geometry.grid = self._texture self._send_data(value)
def _check_data(self, data: TupleYUV): err = f"must provide a tuple/list of np.ndarray of type np.uint8 representing YUV components." if not isinstance(data, (tuple, list)): raise TypeError(err + f"\nYou provided: {data}") if not len(data) == 3: raise TypeError(err + f"\nYou provided data of len: {len(data)}") if not all([isinstance(a, np.ndarray) for a in data]): raise TypeError(err + f"\nYou provided types: {[type(d) for d in data]}") types = [a.dtype for a in data] if not all([t == np.uint8 for t in types]): raise TypeError(err + f"\nYou provided data of types: {types}") if self.colorspace == ColorspacesYUV.yuv420p: err += ( f"For {self.colorspace} UV channels must be 4x smaller than Y. " f"You provided shapes: {tuple(d.shape for d in data)}" ) shapes = tuple(np.asarray(d.shape) for d in data) expected_uv_shape = shapes[0] // 2 if (shapes[1] != expected_uv_shape).all() or ( shapes[2] != expected_uv_shape ).all(): raise ValueError(err) else: err += ( f"For {self.colorspace} UV channels must be the same size as Y" f"You provided shapes: {tuple(d.shape for d in data)}" ) if data[0].shape != data[1].shape or data[0].shape != data[2].shape: raise ValueError(err)
[docs] class ImageVmin(GraphicFeature): """lower contrast limit""" event_info_spec = [ { "dict key": "value", "type": "float", "description": "new vmin value", }, ] def __init__(self, value: float, property_name: str = "vmin"): self._value = value super().__init__(property_name=property_name) @property def value(self) -> float: return self._value @block_reentrance def set_value(self, graphic, value: float): vmax = graphic._material.clim[1] graphic._material.clim = (value, vmax) self._value = value event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event)
[docs] class ImageVmax(GraphicFeature): """upper contrast limit""" event_info_spec = [ { "dict key": "value", "type": "float", "description": "new vmax value", }, ] def __init__(self, value: float, property_name: str = "vmax"): self._value = value super().__init__(property_name=property_name) @property def value(self) -> float: return self._value @block_reentrance def set_value(self, graphic, value: float): vmin = graphic._material.clim[0] graphic._material.clim = (vmin, value) self._value = value event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event)
[docs] class ImageGamma(GraphicFeature): """gamma correction applied to the image""" event_info_spec = [ { "dict key": "value", "type": "float", "description": "new gamma value", }, ] def __init__(self, value: float, property_name: str = "gamma"): self._value = value super().__init__(property_name=property_name) @property def value(self) -> float: return self._value @block_reentrance def set_value(self, graphic, value: float): graphic._material.gamma = value self._value = value event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event)
[docs] class ImageCmap(GraphicFeature): """colormap for texture""" event_info_spec = [ { "dict key": "value", "type": "str", "description": "new cmap name", }, ] def __init__(self, value: str, property_name: str = "cmap"): self._value = cmap_lib.Colormap(value) super().__init__(property_name=property_name) @property def value(self) -> cmap_lib.Colormap: return self._value @block_reentrance def set_value(self, graphic, value: ColormapLike | cmap_lib.Colormap): self._value = cmap_lib.Colormap(value) # get the new TextureMap _map = self._value.to_pygfx() # set the cmap interpolation from the current value on the graphic _map.min_filter = graphic._cmap_interpolation.value _map.mag_filter = graphic._cmap_interpolation.value _map.mipmap_filter = graphic._cmap_interpolation.value # set the wrap mode we use for images _map.wrap_s = "clamp-to-edge" _map.wrap_t = "clamp-to-edge" # set new TextureMap on the graphic graphic._material.map = _map graphic._material.map.texture.update_range((0, 0, 0), size=(256, 1, 1)) event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event)
[docs] class ImageInterpolation(GraphicFeature): """Image interpolation method""" event_info_spec = [ { "dict key": "value", "type": "str", "description": "new interpolation method, nearest | linear", }, ] def __init__(self, value: str, property_name: str = "interpolation"): self._validate(value) self._value = value super().__init__(property_name=property_name) def _validate(self, value): if value not in ["nearest", "linear"]: raise ValueError("`interpolation` must be one of 'nearest' or 'linear'") @property def value(self) -> str: return self._value @block_reentrance def set_value(self, graphic, value: str): self._validate(value) graphic._material.interpolation = value self._value = value event = GraphicFeatureEvent(type="interpolation", info={"value": value}) self._call_event_handlers(event)
[docs] class ImageCmapInterpolation(GraphicFeature): """Image cmap interpolation method""" event_info_spec = [ { "dict key": "value", "type": "str", "description": "new cmap interpolatio method, nearest | linear", }, ] def __init__(self, value: str, property_name: str = "cmap_interpolation"): self._validate(value) self._value = value super().__init__(property_name=property_name) def _validate(self, value): if value not in ["nearest", "linear"]: raise ValueError( "`cmap_interpolation` must be one of 'nearest' or 'linear'" ) @property def value(self) -> str: return self._value @block_reentrance def set_value(self, graphic, value: str): self._validate(value) # common material for all image tiles graphic._material.map.min_filter = value graphic._material.map.mag_filter = value self._value = value event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) self._call_event_handlers(event)