From 2eeea6ec450f1f41a0b3217c34dc33bc71761db2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 12 Apr 2025 02:16:38 -0400 Subject: [PATCH 01/13] add volume graphic, basic stuff works --- docs/source/conf.py | 1 + examples/image_volume/README.rst | 2 + examples/image_volume/image_volume_ray.py | 24 ++ examples/tests/testutils.py | 1 + fastplotlib/graphics/__init__.py | 2 + fastplotlib/graphics/_base.py | 7 - fastplotlib/graphics/features/_image.py | 85 +++++-- .../graphics/features/_image_volume.py | 172 +++++++++++++ fastplotlib/graphics/image.py | 10 +- fastplotlib/graphics/image_volume.py | 229 ++++++++++++++++++ fastplotlib/layouts/_graphic_methods_mixin.py | 56 +++-- fastplotlib/utils/functions.py | 11 +- scripts/generate_add_graphic_methods.py | 17 +- 13 files changed, 563 insertions(+), 54 deletions(-) create mode 100644 examples/image_volume/README.rst create mode 100644 examples/image_volume/image_volume_ray.py create mode 100644 fastplotlib/graphics/features/_image_volume.py create mode 100644 fastplotlib/graphics/image_volume.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 8d17c97ae..3cf2b4e75 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -56,6 +56,7 @@ "subsection_order": ExplicitOrder( [ "../../examples/image", + "../../examples/image_volume", "../../examples/heatmap", "../../examples/image_widget", "../../examples/gridplot", diff --git a/examples/image_volume/README.rst b/examples/image_volume/README.rst new file mode 100644 index 000000000..6c349ebfa --- /dev/null +++ b/examples/image_volume/README.rst @@ -0,0 +1,2 @@ +Image Volume Examples +===================== diff --git a/examples/image_volume/image_volume_ray.py b/examples/image_volume/image_volume_ray.py new file mode 100644 index 000000000..f16a08803 --- /dev/null +++ b/examples/image_volume/image_volume_ray.py @@ -0,0 +1,24 @@ +""" +Volume Ray mode +=============== + +View a volume, uses the fly controller by default so you can fly around the scene using WASD keys and the mouse: +https://docs.pygfx.org/stable/_autosummary/controllers/pygfx.controllers.FlyController.html#pygfx.controllers.FlyController +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio + +voldata = iio.imread("imageio:stent.npz").astype(np.float32) + +fig = fpl.Figure(cameras="3d", size=(700, 560)) + +fig[0, 0].add_image_volume(voldata) + +fig.show() + +fpl.loop.run() diff --git a/examples/tests/testutils.py b/examples/tests/testutils.py index 4c23b3481..546ff120e 100644 --- a/examples/tests/testutils.py +++ b/examples/tests/testutils.py @@ -18,6 +18,7 @@ # examples live in themed sub-folders example_globs = [ "image/*.py", + "image_volume/*.py", "image_widget/*.py", "heatmap/*.py", "scatter/*.py", diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 03f361502..57058fd9c 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -2,6 +2,7 @@ from .line import LineGraphic from .scatter import ScatterGraphic from .image import ImageGraphic +from .image_volume import ImageVolumeGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack @@ -10,6 +11,7 @@ "LineGraphic", "ScatterGraphic", "ImageGraphic", + "ImageVolumeGraphic", "TextGraphic", "LineCollection", "LineStack", diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index e115107b0..924f35164 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -53,13 +53,6 @@ class Graphic: _features: dict[str, type] = dict() def __init_subclass__(cls, **kwargs): - # set the type of the graphic in lower case like "image", "line_collection", etc. - cls.type = ( - cls.__name__.lower() - .replace("graphic", "") - .replace("collection", "_collection") - .replace("stack", "_stack") - ) # set of all features cls._features = { diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index c47a26e6a..ef39476f4 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -13,8 +13,12 @@ ) -# manages an array of 8192x8192 Textures representing chunks of an image 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", @@ -28,13 +32,30 @@ class TextureArray(GraphicFeature): }, ] - def __init__(self, data, isolated_buffer: bool = True): + def __init__(self, data, dim: int, isolated_buffer: bool = True): + """ + + Parameters + ---------- + dim: int, 2 | 3 + whether the data array represents a 2D or 3D texture + + """ + if dim not in (2, 3): + raise ValueError("`dim` must be 2 | 3") + + self._dim = dim + super().__init__() data = self._fix_data(data) shared = pygfx.renderers.wgpu.get_shared() - self._texture_limit_2d = shared.device.limits["max-texture-dimension-2d"] + + if self._dim == 2: + self._texture_size_limit = shared.device.limits["max-texture-dimension-2d"] + else: + self._texture_size_limit = shared.device.limits["max-texture-dimension-3d"] if isolated_buffer: # useful if data is read-only, example: memmaps @@ -47,18 +68,30 @@ def __init__(self, data, isolated_buffer: bool = True): # data start indices for each Texture self._row_indices = np.arange( 0, - ceil(self.value.shape[0] / self._texture_limit_2d) * self._texture_limit_2d, - self._texture_limit_2d, + ceil(self.value.shape[0] / self._texture_size_limit) * self._texture_size_limit, + self._texture_size_limit, ) self._col_indices = np.arange( 0, - ceil(self.value.shape[1] / self._texture_limit_2d) * self._texture_limit_2d, - self._texture_limit_2d, + ceil(self.value.shape[1] / self._texture_size_limit) * self._texture_size_limit, + self._texture_size_limit, ) + shape = [self.row_indices.size, self.col_indices.size] + + if self._dim == 3: + self._zdim_indices = np.arange( + 0, + ceil(self.value.shape[2] / self._texture_size_limit) * self._texture_size_limit, + self._texture_size_limit, + ) + shape += [self.zdim_indices.size] + else: + self._zdim_indices = np.empty(0) + # buffer will be an array of textures self._buffer: np.ndarray[pygfx.Texture] = np.empty( - shape=(self.row_indices.size, self.col_indices.size), dtype=object + shape=shape, dtype=object ) self._iter = None @@ -66,7 +99,7 @@ def __init__(self, data, isolated_buffer: bool = True): # iterate through each chunk of passed `data` # create a pygfx.Texture from this chunk for _, buffer_index, data_slice in self: - texture = pygfx.Texture(self.value[data_slice], dim=2) + texture = pygfx.Texture(self.value[data_slice], dim=self._dim) self.buffer[buffer_index] = texture @@ -99,6 +132,10 @@ def col_indices(self) -> np.ndarray: """ return self._col_indices + @property + def zdim_indices(self) -> np.ndarray: + return self._zdim_indices + @property def shared(self) -> int: return self._shared @@ -114,7 +151,11 @@ def _fix_data(self, data): return data.astype(np.float32) def __iter__(self): - self._iter = product(enumerate(self.row_indices), enumerate(self.col_indices)) + if self._dim == 2: + self._iter = product(enumerate(self.row_indices), enumerate(self.col_indices)) + elif self._dim == 3: + self._iter = product(enumerate(self.row_indices), enumerate(self.col_indices), enumerate(self.zdim_indices)) + return self def __next__(self) -> tuple[pygfx.Texture, tuple[int, int], tuple[slice, slice]]: @@ -128,22 +169,32 @@ def __next__(self) -> tuple[pygfx.Texture, tuple[int, int], tuple[slice, slice]] | 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) + if self._dim == 2: + (chunk_row, data_row_start), (chunk_col, data_col_start) = next(self._iter) + elif self._dim == 3: + (chunk_row, data_row_start), (chunk_col, data_col_start), (chunk_z, data_z_start) = next(self._iter) # indices for to self.buffer for this chunk - chunk_index = (chunk_row, chunk_col) + chunk_index = [chunk_row, chunk_col] + + if self._dim == 3: + chunk_index += [chunk_z] # stop indices of big data array for this chunk - row_stop = min(self.value.shape[0], data_row_start + self._texture_limit_2d) - col_stop = min(self.value.shape[1], data_col_start + self._texture_limit_2d) + row_stop = min(self.value.shape[0], data_row_start + self._texture_size_limit) + col_stop = min(self.value.shape[1], data_col_start + self._texture_size_limit) + if self._dim == 3: + z_stop = min(self.value.shape[2], data_z_start + self._texture_size_limit) # row and column slices that slice the data for this chunk from the big data array - data_slice = (slice(data_row_start, row_stop), slice(data_col_start, col_stop)) + data_slice = [slice(data_row_start, row_stop), slice(data_col_start, col_stop)] + if self._dim == 3: + data_slice += [slice(data_z_start, z_stop)] # texture for this chunk - texture = self.buffer[chunk_index] + texture = self.buffer[tuple(chunk_index)] - return texture, chunk_index, data_slice + return texture, chunk_index, tuple(data_slice) def __getitem__(self, item): return self.value[item] diff --git a/fastplotlib/graphics/features/_image_volume.py b/fastplotlib/graphics/features/_image_volume.py new file mode 100644 index 000000000..7f197a947 --- /dev/null +++ b/fastplotlib/graphics/features/_image_volume.py @@ -0,0 +1,172 @@ +from itertools import product + +from math import ceil + +import numpy as np + +import pygfx +from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance + +from ...utils import ( + make_colors, + get_cmap_texture, +) + + +class TextureArray3D(GraphicFeature): + """ + Manages an array of 3D Textures representing chunks of an image volume. + + 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, isolated_buffer: bool = True): + super().__init__() + + data = self._fix_data(data) + + shared = pygfx.renderers.wgpu.get_shared() + self._texture_limit_3d = shared.device.limits["max-texture-dimension-3d"] + + if isolated_buffer: + # useful if data is read-only, example: memmaps + self._value = np.zeros(data.shape, dtype=data.dtype) + self.value[:] = data[:] + else: + # user's input array is used as the buffer + self._value = data + + # data start indices for each Texture + self._row_indices = np.arange( + 0, + ceil(self.value.shape[0] / self._texture_limit_3d) * self._texture_limit_3d, + self._texture_limit_3d, + ) + self._col_indices = np.arange( + 0, + ceil(self.value.shape[1] / self._texture_limit_3d) * self._texture_limit_3d, + self._texture_limit_3d, + ) + + self._col_indices = np.arange( + 0, + ceil(self.value.shape[1] / self._texture_limit_3d) * self._texture_limit_3d, + self._texture_limit_3d, + ) + + # buffer will be an array of textures + self._buffer: np.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, data_slice in self: + texture = pygfx.Texture(self.value[data_slice], dim=2) + + self.buffer[buffer_index] = texture + + self._shared: int = 0 + + @property + def value(self) -> np.ndarray: + return self._value + + def set_value(self, graphic, value): + self[:] = value + + @property + def buffer(self) -> np.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 + + @property + def shared(self) -> int: + return self._shared + + def _fix_data(self, data): + if data.ndim not in (2, 3): + raise ValueError( + "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]" + ) + + # let's just cast to float32 always + return data.astype(np.float32) + + 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.value.shape[0], data_row_start + self._texture_limit_3d) + col_stop = min(self.value.shape[1], data_col_start + self._texture_limit_3d) + + # row and column slices that slice the data for this chunk from the big data array + data_slice = (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, data_slice + + def __getitem__(self, item): + return self.value[item] + + @block_reentrance + def __setitem__(self, key, value): + self.value[key] = value + + for texture in self.buffer.ravel(): + texture.update_range((0, 0, 0), texture.size) + + event = GraphicFeatureEvent("data", info={"key": key, "value": value}) + self._call_event_handlers(event) + + def __len__(self): + return self.buffer.size \ No newline at end of file diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 5f198c84f..58d64768b 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -101,10 +101,10 @@ def __init__( | 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 + minimum value for color scaling, estimated from data if not provided vmax: int, optional - maximum value for color scaling, calculated from data if not provided + maximum value for color scaling, estimated from data if not provided cmap: str, optional, default "plasma" colormap to use to display the data @@ -129,8 +129,8 @@ def __init__( 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) + # texture array that manages the multiple textures on the GPU that represent this image + self._data = TextureArray(data, dim=2, isolated_buffer=isolated_buffer) if (vmin is None) or (vmax is None): vmin, vmax = quick_min_max(data) @@ -165,7 +165,7 @@ def __init__( ) # iterate through each texture chunk and create - # an _ImageTIle, offset the tile using the data indices + # 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 diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py new file mode 100644 index 000000000..cace29caa --- /dev/null +++ b/fastplotlib/graphics/image_volume.py @@ -0,0 +1,229 @@ +from typing import * + +import pygfx + +from ..utils import quick_min_max +from ._base import Graphic +from .features import ( + TextureArray, + ImageCmap, + ImageVmin, + ImageVmax, + ImageInterpolation, + ImageCmapInterpolation, +) + + +class _VolumeTile(pygfx.Volume): + """ + Similar to pygfx.Volume, only difference is that it modifies the pick_info + by adding the data row start indices that correspond to this chunk of the big Volume + """ + + def __init__( + self, + geometry, + material, + data_slice: tuple[slice, slice, slice], + chunk_index: tuple[int, 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, data_z_start = ( + self.data_slice[0].start, + self.data_slice[1].start, + self.data_slice[2].start, + ) + + # add the actual data row and col start indices + x, y, z = pick_info["index"] + x += data_col_start + y += data_row_start + z += data_z_start + pick_info["index"] = (x, y, z) + + xp, yp, zp = pick_info["voxel_coord"] + xp += data_col_start + yp += data_row_start + zp += data_z_start + pick_info["voxel_coord"] = (xp, yp, zp) + + # 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, slice]: + return self._data_slice + + @property + def chunk_index(self) -> tuple[int, int, int]: + return self._chunk_index + + +class ImageVolumeGraphic(Graphic): + _features = { + "data": TextureArray, + "cmap": ImageCmap, + "vmin": ImageVmin, + "vmax": ImageVmax, + "interpolation": ImageInterpolation, + "cmap_interpolation": ImageCmapInterpolation, + } + + def __init__( + self, + data: Any, + mode: str = "ray", + vmin: int = None, + vmax: int = None, + cmap: str = "plasma", + interpolation: str = "nearest", + cmap_interpolation: str = "linear", + isolated_buffer: bool = True, + **kwargs, + ): + valid_modes = ["basic", "ray", "slice", "iso", "mip", "minip"] + if mode not in valid_modes: + raise ValueError(f"invalid mode specified: {mode}, valid modes are: {valid_modes}") + + super().__init__(**kwargs) + + world_object = pygfx.Group() + + # texture array that manages the textures on the GPU that represent this image volume + self._data = TextureArray(data, dim=3, 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) + + # TODO: I'm assuming RGB volume images aren't supported??? + # 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", + ) + + material_cls = getattr(pygfx, f"Volume{mode.capitalize()}Material") + + # TODO: graphic features for the various material properties + self._material = material_cls( + clim=(self._vmin.value, self._vmax.value), + map=_map, + interpolation=self._interpolation.value, + pick_write=True, + ) + + # iterate through each texture chunk and create + # a _VolumeTile, offset the tile using the data indices + for texture, chunk_index, data_slice in self._data: + # create a _VolumeTile using the texture for this chunk + vol = _VolumeTile( + 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 + data_z_start = data_slice[2].start + + # offset tile position using the indices from the big data array + # that correspond to this chunk + vol.world.x = data_col_start + vol.world.y = data_row_start + vol.world.z = data_z_start + + world_object.add(vol) + + 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""" + return self._cmap.value + + @cmap.setter + def cmap(self, name: str): + 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) + + 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 diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index a753eec73..38a1b2186 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -32,7 +32,7 @@ def add_image( interpolation: str = "nearest", cmap_interpolation: str = "linear", isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageGraphic: """ @@ -45,10 +45,10 @@ def add_image( | 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 + minimum value for color scaling, estimated from data if not provided vmax: int, optional - maximum value for color scaling, calculated from data if not provided + maximum value for color scaling, estimated from data if not provided cmap: str, optional, default "plasma" colormap to use to display the data @@ -78,7 +78,35 @@ def add_image( interpolation, cmap_interpolation, isolated_buffer, - **kwargs, + **kwargs + ) + + def add_image_volume( + self, + data: Any, + mode: str = "ray", + vmin: int = None, + vmax: int = None, + cmap: str = "plasma", + interpolation: str = "nearest", + cmap_interpolation: str = "linear", + isolated_buffer: bool = True, + **kwargs + ) -> ImageVolumeGraphic: + """ + None + """ + return self._create_graphic( + ImageVolumeGraphic, + data, + mode, + vmin, + vmax, + cmap, + interpolation, + cmap_interpolation, + isolated_buffer, + **kwargs ) def add_line_collection( @@ -96,7 +124,7 @@ def add_line_collection( metadatas: Union[Sequence[Any], numpy.ndarray] = None, isolated_buffer: bool = True, kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineCollection: """ @@ -169,7 +197,7 @@ def add_line_collection( metadatas, isolated_buffer, kwargs_lines, - **kwargs, + **kwargs ) def add_line( @@ -183,7 +211,7 @@ def add_line( cmap_transform: Union[numpy.ndarray, Iterable] = None, isolated_buffer: bool = True, size_space: str = "screen", - **kwargs, + **kwargs ) -> LineGraphic: """ @@ -234,7 +262,7 @@ def add_line( cmap_transform, isolated_buffer, size_space, - **kwargs, + **kwargs ) def add_line_stack( @@ -253,7 +281,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineStack: """ @@ -334,7 +362,7 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs, + **kwargs ) def add_scatter( @@ -349,7 +377,7 @@ def add_scatter( sizes: Union[float, numpy.ndarray, Iterable[float]] = 1, uniform_size: bool = False, size_space: str = "screen", - **kwargs, + **kwargs ) -> ScatterGraphic: """ @@ -409,7 +437,7 @@ def add_scatter( sizes, uniform_size, size_space, - **kwargs, + **kwargs ) def add_text( @@ -422,7 +450,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs, + **kwargs ) -> TextGraphic: """ @@ -473,5 +501,5 @@ def add_text( screen_space, offset, anchor, - **kwargs, + **kwargs ) diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index e775288d3..b276ea98b 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -269,20 +269,21 @@ def make_colors_dict(labels: Sequence, cmap: str, **kwargs) -> OrderedDict: def quick_min_max(data: np.ndarray, max_size=1e6) -> tuple[float, float]: """ - Adapted from pyqtgraph.ImageView. - Estimate the min/max values of *data* by subsampling. + Estimate the (min, max) values of data array by subsampling. + + Also supports array-like data types may have a `min` and `max` property that provides a pre-calculated (min, max). Parameters ---------- - data: np.ndarray or array-like with `min` and `max` attributes + data: np.ndarray or array-like max_size : int, optional - largest array size allowed in the subsampled array. Default is 1e6. + subsamples data array to this max size Returns ------- (float, float) - (min, max) + (min, max) estimate """ if hasattr(data, "min") and hasattr(data, "max"): diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphic_methods.py index 533ae77c6..968c68d2a 100644 --- a/scripts/generate_add_graphic_methods.py +++ b/scripts/generate_add_graphic_methods.py @@ -1,5 +1,6 @@ import inspect import pathlib +import re import black @@ -19,6 +20,8 @@ for name, obj in inspect.getmembers(graphics): if inspect.isclass(obj): + if obj.__name__ == "Graphic": + continue # skip the base class modules.append(obj) @@ -49,23 +52,25 @@ def generate_add_graphics_methods(): f.write(" return graphic\n\n") for m in modules: - class_name = m - method_name = class_name.type + cls = m + cls_name = cls.__name__.replace("Graphic", "") + # from https://stackoverflow.com/a/1176023 + method_name = re.sub(r'(? {class_name.__name__}:\n" + f" def add_{method_name}{inspect.signature(cls.__init__)} -> {cls.__name__}:\n" ) f.write(' """\n') - f.write(f" {class_name.__init__.__doc__}\n") + f.write(f" {cls.__init__.__doc__}\n") f.write(' """\n') f.write( - f" return self._create_graphic({class_name.__name__}, {s} **kwargs)\n\n" + f" return self._create_graphic({cls.__name__}, {s} **kwargs)\n\n" ) f.close() From 874cd3c366cdb973cafd0e3e672adafe16609f44 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 12 Apr 2025 02:21:50 -0400 Subject: [PATCH 02/13] remove a useless file I accidentally added --- .../graphics/features/_image_volume.py | 172 ------------------ 1 file changed, 172 deletions(-) delete mode 100644 fastplotlib/graphics/features/_image_volume.py diff --git a/fastplotlib/graphics/features/_image_volume.py b/fastplotlib/graphics/features/_image_volume.py deleted file mode 100644 index 7f197a947..000000000 --- a/fastplotlib/graphics/features/_image_volume.py +++ /dev/null @@ -1,172 +0,0 @@ -from itertools import product - -from math import ceil - -import numpy as np - -import pygfx -from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance - -from ...utils import ( - make_colors, - get_cmap_texture, -) - - -class TextureArray3D(GraphicFeature): - """ - Manages an array of 3D Textures representing chunks of an image volume. - - 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, isolated_buffer: bool = True): - super().__init__() - - data = self._fix_data(data) - - shared = pygfx.renderers.wgpu.get_shared() - self._texture_limit_3d = shared.device.limits["max-texture-dimension-3d"] - - if isolated_buffer: - # useful if data is read-only, example: memmaps - self._value = np.zeros(data.shape, dtype=data.dtype) - self.value[:] = data[:] - else: - # user's input array is used as the buffer - self._value = data - - # data start indices for each Texture - self._row_indices = np.arange( - 0, - ceil(self.value.shape[0] / self._texture_limit_3d) * self._texture_limit_3d, - self._texture_limit_3d, - ) - self._col_indices = np.arange( - 0, - ceil(self.value.shape[1] / self._texture_limit_3d) * self._texture_limit_3d, - self._texture_limit_3d, - ) - - self._col_indices = np.arange( - 0, - ceil(self.value.shape[1] / self._texture_limit_3d) * self._texture_limit_3d, - self._texture_limit_3d, - ) - - # buffer will be an array of textures - self._buffer: np.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, data_slice in self: - texture = pygfx.Texture(self.value[data_slice], dim=2) - - self.buffer[buffer_index] = texture - - self._shared: int = 0 - - @property - def value(self) -> np.ndarray: - return self._value - - def set_value(self, graphic, value): - self[:] = value - - @property - def buffer(self) -> np.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 - - @property - def shared(self) -> int: - return self._shared - - def _fix_data(self, data): - if data.ndim not in (2, 3): - raise ValueError( - "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]" - ) - - # let's just cast to float32 always - return data.astype(np.float32) - - 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.value.shape[0], data_row_start + self._texture_limit_3d) - col_stop = min(self.value.shape[1], data_col_start + self._texture_limit_3d) - - # row and column slices that slice the data for this chunk from the big data array - data_slice = (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, data_slice - - def __getitem__(self, item): - return self.value[item] - - @block_reentrance - def __setitem__(self, key, value): - self.value[key] = value - - for texture in self.buffer.ravel(): - texture.update_range((0, 0, 0), texture.size) - - event = GraphicFeatureEvent("data", info={"key": key, "value": value}) - self._call_event_handlers(event) - - def __len__(self): - return self.buffer.size \ No newline at end of file From b2b9b10b279b90fdbda40e78b1f82887ee769b06 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 12 Apr 2025 02:22:43 -0400 Subject: [PATCH 03/13] black --- fastplotlib/graphics/features/_image.py | 30 +++++++++++++------ fastplotlib/graphics/image_volume.py | 24 ++++++++------- fastplotlib/layouts/_graphic_methods_mixin.py | 28 ++++++++--------- 3 files changed, 48 insertions(+), 34 deletions(-) diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py index ef39476f4..a6e3665a9 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -19,6 +19,7 @@ class TextureArray(GraphicFeature): Creates multiple pygfx.Texture objects based on the GPU's max texture dimension limit. """ + event_info_spec = [ { "dict key": "key", @@ -68,12 +69,14 @@ def __init__(self, data, dim: int, isolated_buffer: bool = True): # data start indices for each Texture self._row_indices = np.arange( 0, - ceil(self.value.shape[0] / self._texture_size_limit) * self._texture_size_limit, + ceil(self.value.shape[0] / self._texture_size_limit) + * self._texture_size_limit, self._texture_size_limit, ) self._col_indices = np.arange( 0, - ceil(self.value.shape[1] / self._texture_size_limit) * self._texture_size_limit, + ceil(self.value.shape[1] / self._texture_size_limit) + * self._texture_size_limit, self._texture_size_limit, ) @@ -82,7 +85,8 @@ def __init__(self, data, dim: int, isolated_buffer: bool = True): if self._dim == 3: self._zdim_indices = np.arange( 0, - ceil(self.value.shape[2] / self._texture_size_limit) * self._texture_size_limit, + ceil(self.value.shape[2] / self._texture_size_limit) + * self._texture_size_limit, self._texture_size_limit, ) shape += [self.zdim_indices.size] @@ -90,9 +94,7 @@ def __init__(self, data, dim: int, isolated_buffer: bool = True): self._zdim_indices = np.empty(0) # buffer will be an array of textures - self._buffer: np.ndarray[pygfx.Texture] = np.empty( - shape=shape, dtype=object - ) + self._buffer: np.ndarray[pygfx.Texture] = np.empty(shape=shape, dtype=object) self._iter = None @@ -152,9 +154,15 @@ def _fix_data(self, data): def __iter__(self): if self._dim == 2: - self._iter = product(enumerate(self.row_indices), enumerate(self.col_indices)) + self._iter = product( + enumerate(self.row_indices), enumerate(self.col_indices) + ) elif self._dim == 3: - self._iter = product(enumerate(self.row_indices), enumerate(self.col_indices), enumerate(self.zdim_indices)) + self._iter = product( + enumerate(self.row_indices), + enumerate(self.col_indices), + enumerate(self.zdim_indices), + ) return self @@ -172,7 +180,11 @@ def __next__(self) -> tuple[pygfx.Texture, tuple[int, int], tuple[slice, slice]] if self._dim == 2: (chunk_row, data_row_start), (chunk_col, data_col_start) = next(self._iter) elif self._dim == 3: - (chunk_row, data_row_start), (chunk_col, data_col_start), (chunk_z, data_z_start) = next(self._iter) + ( + (chunk_row, data_row_start), + (chunk_col, data_col_start), + (chunk_z, data_z_start), + ) = next(self._iter) # indices for to self.buffer for this chunk chunk_index = [chunk_row, chunk_col] diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index cace29caa..0ca5697c1 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -82,20 +82,22 @@ class ImageVolumeGraphic(Graphic): } def __init__( - self, - data: Any, - mode: str = "ray", - vmin: int = None, - vmax: int = None, - cmap: str = "plasma", - interpolation: str = "nearest", - cmap_interpolation: str = "linear", - isolated_buffer: bool = True, - **kwargs, + self, + data: Any, + mode: str = "ray", + vmin: int = None, + vmax: int = None, + cmap: str = "plasma", + interpolation: str = "nearest", + cmap_interpolation: str = "linear", + isolated_buffer: bool = True, + **kwargs, ): valid_modes = ["basic", "ray", "slice", "iso", "mip", "minip"] if mode not in valid_modes: - raise ValueError(f"invalid mode specified: {mode}, valid modes are: {valid_modes}") + raise ValueError( + f"invalid mode specified: {mode}, valid modes are: {valid_modes}" + ) super().__init__(**kwargs) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 38a1b2186..9c14498b1 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -32,7 +32,7 @@ def add_image( interpolation: str = "nearest", cmap_interpolation: str = "linear", isolated_buffer: bool = True, - **kwargs + **kwargs, ) -> ImageGraphic: """ @@ -78,7 +78,7 @@ def add_image( interpolation, cmap_interpolation, isolated_buffer, - **kwargs + **kwargs, ) def add_image_volume( @@ -91,7 +91,7 @@ def add_image_volume( interpolation: str = "nearest", cmap_interpolation: str = "linear", isolated_buffer: bool = True, - **kwargs + **kwargs, ) -> ImageVolumeGraphic: """ None @@ -106,7 +106,7 @@ def add_image_volume( interpolation, cmap_interpolation, isolated_buffer, - **kwargs + **kwargs, ) def add_line_collection( @@ -124,7 +124,7 @@ def add_line_collection( metadatas: Union[Sequence[Any], numpy.ndarray] = None, isolated_buffer: bool = True, kwargs_lines: list[dict] = None, - **kwargs + **kwargs, ) -> LineCollection: """ @@ -197,7 +197,7 @@ def add_line_collection( metadatas, isolated_buffer, kwargs_lines, - **kwargs + **kwargs, ) def add_line( @@ -211,7 +211,7 @@ def add_line( cmap_transform: Union[numpy.ndarray, Iterable] = None, isolated_buffer: bool = True, size_space: str = "screen", - **kwargs + **kwargs, ) -> LineGraphic: """ @@ -262,7 +262,7 @@ def add_line( cmap_transform, isolated_buffer, size_space, - **kwargs + **kwargs, ) def add_line_stack( @@ -281,7 +281,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs + **kwargs, ) -> LineStack: """ @@ -362,7 +362,7 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs + **kwargs, ) def add_scatter( @@ -377,7 +377,7 @@ def add_scatter( sizes: Union[float, numpy.ndarray, Iterable[float]] = 1, uniform_size: bool = False, size_space: str = "screen", - **kwargs + **kwargs, ) -> ScatterGraphic: """ @@ -437,7 +437,7 @@ def add_scatter( sizes, uniform_size, size_space, - **kwargs + **kwargs, ) def add_text( @@ -450,7 +450,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs + **kwargs, ) -> TextGraphic: """ @@ -501,5 +501,5 @@ def add_text( screen_space, offset, anchor, - **kwargs + **kwargs, ) From 6feec31dc24cc4e1fa13676458223805816ab3ec Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 12 Apr 2025 03:11:52 -0400 Subject: [PATCH 04/13] add volume movie --- examples/image_volume/image_volume_4d.py | 83 ++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 examples/image_volume/image_volume_4d.py diff --git a/examples/image_volume/image_volume_4d.py b/examples/image_volume/image_volume_4d.py new file mode 100644 index 000000000..d1ab57290 --- /dev/null +++ b/examples/image_volume/image_volume_4d.py @@ -0,0 +1,83 @@ +""" +Volume movie +============ + +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +from scipy.ndimage import gaussian_filter +import fastplotlib as fpl +from tqdm import tqdm + + +def gen_data(p=1, noise=.5, T=256, framerate=30, firerate=2.,): + if p == 2: + gamma = np.array([1.5, -.55]) + elif p == 1: + gamma = np.array([.9]) + else: + raise + dims = (128, 128, 30) # size of image + sig = (4, 4, 2) # neurons size + bkgrd = 10 + N = 150 # number of neurons + np.random.seed(0) + centers = np.asarray([[np.random.randint(s, x - s) + for x, s in zip(dims, sig)] for i in range(N)]) + Y = np.zeros((T,) + dims, dtype=np.float32) + trueSpikes = np.random.rand(N, T) < firerate / float(framerate) + trueSpikes[:, 0] = 0 + truth = trueSpikes.astype(np.float32) + for i in tqdm(range(2, T)): + if p == 2: + truth[:, i] += gamma[0] * truth[:, i - 1] + gamma[1] * truth[:, i - 2] + else: + truth[:, i] += gamma[0] * truth[:, i - 1] + for i in tqdm(range(N)): + Y[:, centers[i, 0], centers[i, 1], centers[i, 2]] = truth[i] + tmp = np.zeros(dims) + tmp[tuple(np.array(dims)//2)] = 1. + print("gaussing filtering") + z = np.linalg.norm(gaussian_filter(tmp, sig).ravel()) + + print("finishing") + Y = bkgrd + noise * np.random.randn(*Y.shape) + 10 * gaussian_filter(Y, (0,) + sig) / z + + return Y + + +voldata = gen_data() + +fig = fpl.Figure(cameras="3d", controller_types="orbit", size=(700, 560)) + +vmin, vmax = fpl.utils.quick_min_max(voldata) + +volume = fig[0, 0].add_image_volume(voldata[0], vmin=vmin, vmax=vmax, cmap="gnuplot2") + +hlut = fpl.HistogramLUTTool(voldata, volume) + +fig[0, 0].docks["right"].size = 100 +fig[0, 0].docks["right"].controller.enabled = False +fig[0, 0].docks["right"].add_graphic(hlut) +fig[0, 0].docks["right"].auto_scale(maintain_aspect=False) + +fig.show() + + +i = 0 +def update(): + global i + + volume.data = voldata[i] + + i += 1 + if i == voldata.shape[0]: + i = 0 + + +fig.add_animations(update) + +fpl.loop.run() From ac76f6af08c09f4b1997585acce5554b35901970 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 13 Apr 2025 01:04:21 -0400 Subject: [PATCH 05/13] linear interpolation is better --- examples/image_volume/image_volume_4d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/image_volume/image_volume_4d.py b/examples/image_volume/image_volume_4d.py index d1ab57290..208c3a97b 100644 --- a/examples/image_volume/image_volume_4d.py +++ b/examples/image_volume/image_volume_4d.py @@ -55,7 +55,7 @@ def gen_data(p=1, noise=.5, T=256, framerate=30, firerate=2.,): vmin, vmax = fpl.utils.quick_min_max(voldata) -volume = fig[0, 0].add_image_volume(voldata[0], vmin=vmin, vmax=vmax, cmap="gnuplot2") +volume = fig[0, 0].add_image_volume(voldata[0], vmin=vmin, vmax=vmax, interpolation="linear", cmap="gnuplot2") hlut = fpl.HistogramLUTTool(voldata, volume) From 4e616bbef324390cde1887562f3a82762484143f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Jun 2025 01:10:21 -0400 Subject: [PATCH 06/13] cmap shown for volume image in hlut tooL --- fastplotlib/graphics/image.py | 7 ++++--- fastplotlib/tools/_histogram_lut.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index b54e2ef07..78f3158b2 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -201,14 +201,15 @@ def data(self, data): self._data[:] = data @property - def cmap(self) -> str: + def cmap(self) -> str | None: """ - Get or set the colormap + Get or set the colormap for grayscale images. Returns ``None`` if image is RGB(A). For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ """ if self.data.value.ndim > 2: - raise AttributeError("RGB(A) images do not have a colormap property") + return None + return self._cmap.value @cmap.setter diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py index aeb8dd996..d56912003 100644 --- a/fastplotlib/tools/_histogram_lut.py +++ b/fastplotlib/tools/_histogram_lut.py @@ -6,7 +6,7 @@ import pygfx from ..utils import subsample_array -from ..graphics import LineGraphic, ImageGraphic, TextGraphic +from ..graphics import LineGraphic, ImageGraphic, ImageVolumeGraphic, TextGraphic from ..graphics.utils import pause_events from ..graphics._base import Graphic from ..graphics.selectors import LinearRegionSelector @@ -135,7 +135,7 @@ def __init__( self.image_graphic.add_event_handler(self._image_cmap_handler, *ig_events) # colorbar for grayscale images - if self.image_graphic.data.value.ndim != 3: + if self.image_graphic.cmap is not None: self._colorbar: ImageGraphic = self._make_colorbar(edges_flanked) self._colorbar.add_event_handler(self._open_cmap_picker, "click") From 1ed27949c93a9649975b71d62c1644071b569b54 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Jun 2025 03:24:16 -0400 Subject: [PATCH 07/13] volume render modes work --- examples/image_volume/volume_render_modes.py | 62 +++++ fastplotlib/graphics/features/__init__.py | 18 ++ fastplotlib/graphics/features/_volume.py | 264 +++++++++++++++++++ fastplotlib/graphics/image.py | 8 +- fastplotlib/graphics/image_volume.py | 212 +++++++++++++-- 5 files changed, 541 insertions(+), 23 deletions(-) create mode 100644 examples/image_volume/volume_render_modes.py create mode 100644 fastplotlib/graphics/features/_volume.py diff --git a/examples/image_volume/volume_render_modes.py b/examples/image_volume/volume_render_modes.py new file mode 100644 index 000000000..b6424369a --- /dev/null +++ b/examples/image_volume/volume_render_modes.py @@ -0,0 +1,62 @@ +""" +Volume modes +============ + +View a volume using different rendering modes +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +from fastplotlib.ui import EdgeWindow +from fastplotlib.graphics.features import VOLUME_RENDER_MODES +import imageio.v3 as iio +from imgui_bundle import imgui + +voldata = iio.imread("imageio:stent.npz").astype(np.float32) + +fig = fpl.Figure( + cameras="3d", + controller_types="orbit", + size=(700, 560) +) + +fig[0, 0].add_image_volume(voldata, name="vol-img") + + +class GUI(EdgeWindow): + def __init__(self, figure, title="Render options", location="right", size=250): + super().__init__(figure, title=title, location=location, size=size) + + # reference to the graphic for convenience + self.graphic: fpl.ImageVolumeGraphic = self._figure[0, 0]["vol-img"] + + def update(self): + imgui.text("Switch render mode:") + + # add buttons to switch between modes + for mode in VOLUME_RENDER_MODES.keys(): + if imgui.button(mode): + self.graphic.mode = mode + + # add sliders to change iso rendering properties + if self.graphic.mode == "iso": + _, self.graphic.threshold = imgui.slider_float( + "threshold", v=self.graphic.threshold, v_max=255, v_min=1, + ) + _, self.graphic.step_size = imgui.slider_float( + "step_size", v=self.graphic.step_size, v_max=10.0, v_min=0.1, + ) + _, self.graphic.substep_size = imgui.slider_float( + "substep_size", v=self.graphic.substep_size, v_max=10.0, v_min=0.1, + ) + _, self.graphic.emissive = imgui.color_picker3("emissive color", col=self.graphic.emissive.rgb) + +gui = GUI(figure=fig) +fig.add_gui(gui) + +fig.show() + +fpl.loop.run() diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index 18bcf5187..9dad8f4ac 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -16,6 +16,17 @@ ImageInterpolation, ImageCmapInterpolation, ) +from ._volume import ( + VolumeRenderMode, + VolumeIsoThreshold, + VolumeIsoStepSize, + VolumeIsoSubStepSize, + VolumeIsoEmissive, + VolumeIsoShininess, + VolumeSlicePlane, + VOLUME_RENDER_MODES, + create_volume_material_kwargs +) from ._base import ( GraphicFeature, BufferManager, @@ -54,6 +65,13 @@ "ImageVmax", "ImageInterpolation", "ImageCmapInterpolation", + "VolumeRenderMode", + "VolumeIsoThreshold", + "VolumeIsoStepSize", + "VolumeIsoSubStepSize", + "VolumeIsoEmissive", + "VolumeIsoShininess", + "VolumeSlicePlane", "TextData", "FontSize", "TextFaceColor", diff --git a/fastplotlib/graphics/features/_volume.py b/fastplotlib/graphics/features/_volume.py new file mode 100644 index 000000000..3c7a891ff --- /dev/null +++ b/fastplotlib/graphics/features/_volume.py @@ -0,0 +1,264 @@ +import inspect +import re + +import numpy as np +import pygfx + +from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance + +VOLUME_RENDER_MODES: dict[str, pygfx.Material] = {} + +for name, obj in inspect.getmembers(pygfx): + if name == "VolumeBasicMaterial": + # TODO: AFAIK this isn't a real material that can be used?? + continue + if name.startswith("Volume") and name.endswith("Material"): + # name without Volume prefix and Material suffix, and the actual material name in lowercase + # ex: VolumeMipMaterial -> mip; VolumeSomethingElseMaterial -> something_else + short_name = re.sub( + r"(? str: + return self._value + + def _validate(self, value): + if value not in VOLUME_RENDER_MODES.keys(): + raise ValueError( + f"Given render mode: {value} is invalid. Valid render modes are: {VOLUME_RENDER_MODES.keys()}" + ) + + @block_reentrance + def set_value(self, graphic, value: str): + self._validate(value) + + VolumeMaterialCls = VOLUME_RENDER_MODES[value] + + kwargs = create_volume_material_kwargs(graphic, mode=value) + + new_material = VolumeMaterialCls(**kwargs) + # since the world object is a group + for volume_tile in graphic.world_object.children: + volume_tile.material = new_material + + # so we have one place to reference it + graphic._material = new_material + self._value = value + + event = GraphicFeatureEvent(type="mode", info={"value": value}) + self._call_event_handlers(event) + + +class VolumeIsoThreshold(GraphicFeature): + """Isosurface threshold""" + + event_info_spec = [ + { + "dict key": "value", + "type": "float", + "description": "new isosurface threshold", + }, + ] + + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + graphic._material.threshold = value + self._value = graphic._material.threshold + + event = GraphicFeatureEvent(type="threshold", info={"value": value}) + self._call_event_handlers(event) + + +class VolumeIsoStepSize(GraphicFeature): + """Isosurface step_size""" + + event_info_spec = [ + { + "dict key": "value", + "type": "float", + "description": "new isosurface step_size", + }, + ] + + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + graphic._material.step_size = value + self._value = graphic._material.step_size + + event = GraphicFeatureEvent(type="step_size", info={"value": value}) + self._call_event_handlers(event) + + +class VolumeIsoSubStepSize(GraphicFeature): + """Isosurface substep_size""" + + event_info_spec = [ + { + "dict key": "value", + "type": "float", + "description": "new isosurface step_size", + }, + ] + + def __init__(self, value: float): + self._value = value + super().__init__() + + @property + def value(self) -> float: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + graphic._material.substep_size = value + self._value = graphic._material.substep_size + + event = GraphicFeatureEvent(type="step_size", info={"value": value}) + self._call_event_handlers(event) + + +class VolumeIsoEmissive(GraphicFeature): + """Isosurface emissive color""" + + event_info_spec = [ + { + "dict key": "value", + "type": "pygfx.Color", + "description": "new isosurface emissive color", + }, + ] + + def __init__(self, value: pygfx.Color | str | tuple | np.ndarray): + self._value = pygfx.Color(value) + super().__init__() + + @property + def value(self) -> pygfx.Color: + return self._value + + @block_reentrance + def set_value(self, graphic, value: pygfx.Color | str | tuple | np.ndarray): + graphic._material.emissive = value + self._value = graphic._material.emissive + + event = GraphicFeatureEvent(type="emissive", info={"value": value}) + self._call_event_handlers(event) + + +class VolumeIsoShininess(GraphicFeature): + """Isosurface shininess""" + + event_info_spec = [ + { + "dict key": "value", + "type": "int", + "description": "new isosurface shininess", + }, + ] + + def __init__(self, value: int): + self._value = value + super().__init__() + + @property + def value(self) -> int: + return self._value + + @block_reentrance + def set_value(self, graphic, value: float): + graphic._material.shininess = value + self._value = graphic._material.shininess + + event = GraphicFeatureEvent(type="shininess", info={"value": value}) + self._call_event_handlers(event) + + +class VolumeSlicePlane(GraphicFeature): + """Volume plane""" + + event_info_spec = [ + { + "dict key": "value", + "type": "tuple[int, int, int, int]", + "description": "new plane slice", + }, + ] + + def __init__(self, value: tuple[int, int, int, int]): + self._value = value + super().__init__() + + @property + def value(self) -> tuple[int, int, int, int]: + return self._value + + @block_reentrance + def set_value(self, graphic, value: tuple[int, int, int, int]): + graphic._material.plane = value + self._value = graphic._material.plane + + event = GraphicFeatureEvent(type="plane", info={"value": value}) + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 78f3158b2..7d8ea7a56 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -83,8 +83,8 @@ class ImageGraphic(Graphic): def __init__( self, data: Any, - vmin: int = None, - vmax: int = None, + vmin: float = None, + vmax: float = None, cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", @@ -100,10 +100,10 @@ def __init__( 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 + vmin: float, optional minimum value for color scaling, estimated from data if not provided - vmax: int, optional + vmax: float, optional maximum value for color scaling, estimated from data if not provided cmap: str, optional, default "plasma" diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index 0ca5697c1..5aede55af 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -1,5 +1,6 @@ from typing import * +import numpy as np import pygfx from ..utils import quick_min_max @@ -11,6 +12,15 @@ ImageVmax, ImageInterpolation, ImageCmapInterpolation, + VolumeRenderMode, + VolumeIsoThreshold, + VolumeIsoStepSize, + VolumeIsoSubStepSize, + VolumeIsoEmissive, + VolumeIsoShininess, + VolumeSlicePlane, + VOLUME_RENDER_MODES, + create_volume_material_kwargs, ) @@ -85,15 +95,84 @@ def __init__( self, data: Any, mode: str = "ray", - vmin: int = None, - vmax: int = None, + vmin: float = None, + vmax: float = None, cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", + plane: tuple[int, int, int, int] = (0, 0, 1, 0), + threshold: float = 0.5, + step_size: float = 1.0, + substep_size: float = 0.1, + emissive: pygfx.Color = "#000", + shininess: int = 30, isolated_buffer: bool = True, **kwargs, ): - valid_modes = ["basic", "ray", "slice", "iso", "mip", "minip"] + """ + + Parameters + ---------- + data: array-like + array-like, usually numpy.ndarray, must support ``memoryview()``. + Shape must be [n_planes, n_rows, n_cols] for grayscale, or [n_planes, n_rows, n_cols, 3 | 4] for RGB(A) + + mode: str, default "ray" + render mode, one of ["basic", "ray", "slice", "iso", "mip", "minip"] + + vmin: float + lower contrast limit + + vmax: float + upper contrast limit + cmap: str, default "plasma" + colormap for grayscale volumes + + interpolation: str, default "nearest" + interpolation method for sampling pixels + + cmap_interpolation: str, default "linear" + interpolation method for sampling from colormap + + plane: (int, int, int, int), default (0, 0, 1, 0) + Volume slice to display, used only if `mode` = "slice" + + threshold : float, default 0.5 + The threshold texture value at which the surface is rendered. + Used only if `mode` = "iso" + + step_size : float, default 1.0 + The size of the initial ray marching step for the initial surface finding. + Smaller values will result in more accurate surfaces but slower rendering. + Used only if `mode` = "iso" + + substep_size : float, default 0.1 + The size of the raymarching step for the refined surface finding. + Smaller values will result in more accurate surfaces but slower rendering. + Used only if `mode` = "iso" + + emissive : Color, default (0, 0, 0, 1) + The emissive color of the surface. I.e. the color that the object emits + even when not lit by a light source. This color is added to the final + color and unaffected by lighting. The alpha channel is ignored. + Used only if `mode` = "iso" + + shininess : int, default 30 + How shiny the specular highlight is; a higher value gives a sharper highlight. + Used only if `mode` = "iso" + + 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 - useful if the + array is large. + + kwargs + additional keyword arguments passed to :class:`.Graphic` + + """ + + valid_modes = VOLUME_RENDER_MODES.keys() if mode not in valid_modes: raise ValueError( f"invalid mode specified: {mode}, valid modes are: {valid_modes}" @@ -120,21 +199,26 @@ def __init__( self._cmap = ImageCmap(cmap) self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation) - _map = pygfx.TextureMap( + self._texture_map = pygfx.TextureMap( self._cmap.texture, filter=self._cmap_interpolation.value, wrap="clamp-to-edge", ) - material_cls = getattr(pygfx, f"Volume{mode.capitalize()}Material") + self._plane = VolumeSlicePlane(plane) + self._threshold = VolumeIsoThreshold(threshold) + self._step_size = VolumeIsoStepSize(step_size) + self._substep_size = VolumeIsoSubStepSize(substep_size) + self._emissive = VolumeIsoEmissive(emissive) + self._shininess = VolumeIsoShininess(shininess) - # TODO: graphic features for the various material properties - self._material = material_cls( - clim=(self._vmin.value, self._vmax.value), - map=_map, - interpolation=self._interpolation.value, - pick_write=True, - ) + material_kwargs = create_volume_material_kwargs(graphic=self, mode=mode) + + VolumeMaterialCls = VOLUME_RENDER_MODES[mode] + + self._material = VolumeMaterialCls(**material_kwargs) + + self._mode = VolumeRenderMode(mode) # iterate through each texture chunk and create # a _VolumeTile, offset the tile using the data indices @@ -171,9 +255,18 @@ def data(self) -> TextureArray: def data(self, data): self._data[:] = data + @property + def mode(self) -> str: + """Get or set the volume rendering mode""" + return self._mode.value + + @mode.setter + def mode(self, mode: str): + self._mode.set_value(self, mode) + @property def cmap(self) -> str: - """colormap name""" + """Get or set colormap name""" return self._cmap.value @cmap.setter @@ -182,7 +275,7 @@ def cmap(self, name: str): @property def vmin(self) -> float: - """lower contrast limit""" + """Get or set the lower contrast limit""" return self._vmin.value @vmin.setter @@ -191,7 +284,7 @@ def vmin(self, value: float): @property def vmax(self) -> float: - """upper contrast limit""" + """Get or set the upper contrast limit""" return self._vmax.value @vmax.setter @@ -200,7 +293,7 @@ def vmax(self, value: float): @property def interpolation(self) -> str: - """image data interpolation method""" + """Get or set the image data interpolation method""" return self._interpolation.value @interpolation.setter @@ -209,16 +302,97 @@ def interpolation(self, value: str): @property def cmap_interpolation(self) -> str: - """cmap interpolation method""" + """Get or set the cmap interpolation method""" return self._cmap_interpolation.value @cmap_interpolation.setter def cmap_interpolation(self, value: str): self._cmap_interpolation.set_value(self, value) - def reset_vmin_vmax(self): + @property + def plane(self) -> tuple[int, int, int, int]: + """Get or set displayed plane in the volume. Valid only for `slice` render mode.""" + return self._plane.value + + @plane.setter + def plane(self, value: tuple[int, int, int, int]): + if self.mode != "slice": + raise TypeError("`plane` property is only valid for `slice` render mode.") + + self._plane.set_value(self, value) + + @property + def threshold(self) -> float: + """Get or set isosurface threshold, only for `iso` mode""" + return self._threshold.value + + @threshold.setter + def threshold(self, value: float): + if self.mode != "iso": + raise TypeError("`threshold` property is only used for `iso` rendering mode") + + self._threshold.set_value(self, value) + + @property + def step_size(self) -> float: + """Get or set isosurface step_size, only for `iso` mode""" + return self._step_size.value + + @step_size.setter + def step_size(self, value: float): + if self.mode != "iso": + raise TypeError( + "`step_size` property is only used for `iso` rendering mode" + ) + + self._step_size.set_value(self, value) + + @property + def substep_size(self) -> float: + """Get or set isosurface substep_size, only for `iso` mode""" + return self._substep_size.value + + @substep_size.setter + def substep_size(self, value: float): + if self.mode != "iso": + raise TypeError( + "`substep_size` property is only used for `iso` rendering mode" + ) + + self._substep_size.set_value(self, value) + + @property + def emissive(self) -> pygfx.Color: + """Get or set isosurface emissive color, only for `iso` mode. Pass a color, RGBA array or pygfx.Color""" + return self._emissive.value + + @emissive.setter + def emissive(self, value: pygfx.Color | str | tuple | np.ndarray): + if self.mode != "iso": + raise TypeError( + "`emissive` property is only used for `iso` rendering mode" + ) + + self._emissive.set_value(self, value) + + @property + def shininess(self) -> int: + """Get or set isosurface shininess""" + return self._shininess.value + + @shininess.setter + def shininess(self, value: int): + if self.mode != "iso": + raise TypeError( + "`shininess` property is only used for `iso` rendering mode" + ) + + self._shininess.set_value(self, value) + + +def reset_vmin_vmax(self): """ - Reset the vmin, vmax by estimating it from the data + Reset the vmin, vmax by *estimating* it from the data Returns ------- From b0f26ffcf2f90349c60e1eaddbf281b20dfc613d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 4 Jun 2025 03:33:43 -0400 Subject: [PATCH 08/13] example works --- examples/image_volume/volume_render_modes.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/examples/image_volume/volume_render_modes.py b/examples/image_volume/volume_render_modes.py index b6424369a..230cb7e68 100644 --- a/examples/image_volume/volume_render_modes.py +++ b/examples/image_volume/volume_render_modes.py @@ -13,7 +13,7 @@ from fastplotlib.ui import EdgeWindow from fastplotlib.graphics.features import VOLUME_RENDER_MODES import imageio.v3 as iio -from imgui_bundle import imgui +from imgui_bundle import imgui, imgui_ctx voldata = iio.imread("imageio:stent.npz").astype(np.float32) @@ -25,9 +25,17 @@ fig[0, 0].add_image_volume(voldata, name="vol-img") +# add an hlut tool +hlut = fpl.HistogramLUTTool(voldata, fig[0, 0]["vol-img"]) + +fig[0, 0].docks["right"].size = 80 +fig[0, 0].docks["right"].controller.enabled = False +fig[0, 0].docks["right"].add_graphic(hlut) +fig[0, 0].docks["right"].auto_scale(maintain_aspect=False) + class GUI(EdgeWindow): - def __init__(self, figure, title="Render options", location="right", size=250): + def __init__(self, figure, title="Render options", location="right", size=300): super().__init__(figure, title=title, location=location, size=size) # reference to the graphic for convenience From 9d059c713f95fd64d95718d9abdb73c4143c4bab Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 5 Jun 2025 00:51:28 -0400 Subject: [PATCH 09/13] some updates to ImageGraphic for sharing buffesr --- .../{image_volume_ray.py => image_volume_mip.py} | 4 ++-- fastplotlib/graphics/image.py | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) rename examples/image_volume/{image_volume_ray.py => image_volume_mip.py} (80%) diff --git a/examples/image_volume/image_volume_ray.py b/examples/image_volume/image_volume_mip.py similarity index 80% rename from examples/image_volume/image_volume_ray.py rename to examples/image_volume/image_volume_mip.py index f16a08803..21b122e92 100644 --- a/examples/image_volume/image_volume_ray.py +++ b/examples/image_volume/image_volume_mip.py @@ -15,9 +15,9 @@ voldata = iio.imread("imageio:stent.npz").astype(np.float32) -fig = fpl.Figure(cameras="3d", size=(700, 560)) +fig = fpl.Figure(cameras="3d", controller_types="orbit", size=(700, 560)) -fig[0, 0].add_image_volume(voldata) +fig[0, 0].add_image_volume(voldata, mode="iso") fig.show() diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 7d8ea7a56..abee31f52 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -131,11 +131,16 @@ def __init__( world_object = pygfx.Group() - # texture array that manages the multiple textures on the GPU that represent this image - self._data = TextureArray(data, dim=2, isolated_buffer=isolated_buffer) + if isinstance(data, TextureArray): + # share buffer + self._data = data + else: + # create new texture array to manage buffer + # texture array that manages the multiple textures on the GPU that represent this image + self._data = TextureArray(data, dim=2, isolated_buffer=isolated_buffer) if (vmin is None) or (vmax is None): - vmin, vmax = quick_min_max(data) + vmin, vmax = quick_min_max(self.data.value) # other graphic features self._vmin = ImageVmin(vmin) From d27cd318476d03233604a9b20d0bd243d6f1637d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 5 Jun 2025 00:51:54 -0400 Subject: [PATCH 10/13] update volume and add examples --- examples/image_volume/image_volume_mip.py | 8 +- .../image_volume_non_orthogonal_slicing.py | 55 ++++++++++++++ .../image_volume/image_volume_share_buffer.py | 75 +++++++++++++++++++ .../image_volume_slicing_animation.py | 64 ++++++++++++++++ examples/image_volume/volume_render_modes.py | 19 ++++- fastplotlib/graphics/features/_volume.py | 33 +++----- fastplotlib/graphics/image_volume.py | 56 ++++++++------ 7 files changed, 261 insertions(+), 49 deletions(-) create mode 100644 examples/image_volume/image_volume_non_orthogonal_slicing.py create mode 100644 examples/image_volume/image_volume_share_buffer.py create mode 100644 examples/image_volume/image_volume_slicing_animation.py diff --git a/examples/image_volume/image_volume_mip.py b/examples/image_volume/image_volume_mip.py index 21b122e92..def693b7d 100644 --- a/examples/image_volume/image_volume_mip.py +++ b/examples/image_volume/image_volume_mip.py @@ -1,5 +1,5 @@ """ -Volume Ray mode +Volume Mip mode =============== View a volume, uses the fly controller by default so you can fly around the scene using WASD keys and the mouse: @@ -21,4 +21,8 @@ fig.show() -fpl.loop.run() +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_volume/image_volume_non_orthogonal_slicing.py b/examples/image_volume/image_volume_non_orthogonal_slicing.py new file mode 100644 index 000000000..ce88925dc --- /dev/null +++ b/examples/image_volume/image_volume_non_orthogonal_slicing.py @@ -0,0 +1,55 @@ +""" +Volume non-orthogonal slicing +============================= + +Perform non-orthogonal slicing of image volumes. + +For an example with UI sliders see the "Volume modes" example. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio + + +voldata = iio.imread("imageio:stent.npz").astype(np.float32) + +fig = fpl.Figure( + cameras="3d", + controller_types="orbit", + size=(700, 560) +) + +vol = fig[0, 0].add_image_volume(voldata, mode="slice") + +# a plane is defined by ax + by + cz + d = 0 +# the plane property sets (a, b, c, d) +vol.plane = (0, 0.5, 0.5, -70) + +# just a pre-saved camera state to view the plot area +state = { + "position": np.array([-160.0, 105.0, 205.0]), + "rotation": np.array([-0.1, -0.6, -0.07, 0.8]), + "scale": np.array([1., 1., 1.]), + "reference_up": np.array([0., 1., 0.]), + "fov": 50.0, + "width": 128.0, + "height": 128.0, + "depth": 315, + "zoom": 0.75, + "maintain_aspect": True, + "depth_range": None +} + +fig.show() + +fig[0, 0].camera.set_state(state) + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_volume/image_volume_share_buffer.py b/examples/image_volume/image_volume_share_buffer.py new file mode 100644 index 000000000..cc9f07915 --- /dev/null +++ b/examples/image_volume/image_volume_share_buffer.py @@ -0,0 +1,75 @@ +""" +Volume share buffers +==================== + +Share the data buffer between two graphics. This example creates one Graphic using MIP rendering, and another graphic +to display a slice of the volume. We can share the data buffer on the GPU between these graphics. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +from imgui_bundle import imgui +import fastplotlib as fpl +from fastplotlib.ui import EdgeWindow +import imageio.v3 as iio +from skimage.filters import gaussian + + +data = iio.imread("imageio:stent.npz") + + +figure = fpl.Figure( + cameras="3d", + controller_types="orbit", + size=(700, 560), +) + +# MIP rendering is the default `mode` +vol_mip = figure[0, 0].add_image_volume(gaussian(data, sigma=2.0)) + +# make another graphic to show a slice of the volume +vol_slice = figure[0, 0].add_image_volume( + vol_mip.data, # pass the data property from the previous volume so they share the same buffer on the GPU + mode="slice", + plane=(0, -0.5, -0.5, 50), + offset=(150, 0, 0) # place the graphic at x=150 +) + + +class GUI(EdgeWindow): + def __init__(self, figure, title="change data buffer", location="right", size=200): + super().__init__(figure, title=title, location=location, size=size) + self._sigma = 2 + + def update(self): + changed, self._sigma = imgui.slider_int("sigma", v=self._sigma, v_min=0, v_max=5) + + if changed: + vol_mip.data = gaussian(data, sigma=self._sigma) + vol_mip.reset_vmin_vmax() + vol_slice.reset_vmin_vmax() + + imgui.text("Select plane defined by:\nax + by + cz + d = 0") + _, a = imgui.slider_float("a", v=vol_slice.plane[0], v_min=-1, v_max=1.0) + _, b = imgui.slider_float("b", v=vol_slice.plane[1], v_min=-1, v_max=1.0) + _, c = imgui.slider_float("c", v=vol_slice.plane[2], v_min=-1, v_max=1.0) + + largest_dim = max(vol_slice.data.value.shape) + _, d = imgui.slider_float( + "d", v=vol_slice.plane[3], v_min=0, v_max=largest_dim * 2 + ) + + vol_slice.plane = (a, b, c, d) + +gui = GUI(figure) +figure.add_gui(gui) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_volume/image_volume_slicing_animation.py b/examples/image_volume/image_volume_slicing_animation.py new file mode 100644 index 000000000..200a515cb --- /dev/null +++ b/examples/image_volume/image_volume_slicing_animation.py @@ -0,0 +1,64 @@ +""" +Volume non-orthogonal slicing animation +======================================= + +Perform non-orthogonal slicing of image volumes. + +For an example with UI sliders see the "Volume modes" example. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate 8s' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio +import pygfx + + +voldata = iio.imread("imageio:stent.npz").astype(np.float32) + +fig = fpl.Figure( + cameras="3d", + controller_types="orbit", + size=(700, 560) +) + +vol = fig[0, 0].add_image_volume(voldata, mode="slice") + +# a plane is defined by ax + by + cz + d = 0 +# the plane property sets (a, b, c, d) +vol.plane = (0, 0.5, 0.5, -20) + +# just a pre-saved camera state to view the plot area +state = { + "position": np.array([-110.0, 160.0, 240.0]), + "rotation": np.array([-0.25, -0.5, -0.15, 0.85]), + "scale": np.array([1., 1., 1.]), + "reference_up": np.array([0., 1., 0.]), + "fov": 50.0, + "width": 128.0, + "height": 128.0, + "depth": 315, + "zoom": 0.75, + "maintain_aspect": True, + "depth_range": None +} + +def update(): + # increase d by 1 + vol.plane = (0, 0.5, 0.5, vol.plane[-1] - 1) + if vol.plane[-1] < -200: + vol.plane = (0, 0.5, 0.5, -20) + +fig[0, 0].add_animations(update) + +fig.show() + +fig[0, 0].camera.set_state(state) + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_volume/volume_render_modes.py b/examples/image_volume/volume_render_modes.py index 230cb7e68..24da5bffc 100644 --- a/examples/image_volume/volume_render_modes.py +++ b/examples/image_volume/volume_render_modes.py @@ -13,7 +13,7 @@ from fastplotlib.ui import EdgeWindow from fastplotlib.graphics.features import VOLUME_RENDER_MODES import imageio.v3 as iio -from imgui_bundle import imgui, imgui_ctx +from imgui_bundle import imgui voldata = iio.imread("imageio:stent.npz").astype(np.float32) @@ -62,9 +62,24 @@ def update(self): ) _, self.graphic.emissive = imgui.color_picker3("emissive color", col=self.graphic.emissive.rgb) + if self.graphic.mode == "slice": + imgui.text("Select plane defined by:\nax + by + cz + d = 0") + _, a = imgui.slider_float("a", v=self.graphic.plane[0], v_min=-1, v_max=1.0) + _, b = imgui.slider_float("b", v=self.graphic.plane[1], v_min=-1, v_max=1.0) + _, c = imgui.slider_float("c", v=self.graphic.plane[2], v_min=-1, v_max=1.0) + + largest_dim = max(self.graphic.data.value.shape) + _, d = imgui.slider_float("d", v=self.graphic.plane[3], v_min=0, v_max=largest_dim * 2) + + self.graphic.plane = (a, b, c, d) + gui = GUI(figure=fig) fig.add_gui(gui) fig.show() -fpl.loop.run() +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/fastplotlib/graphics/features/_volume.py b/fastplotlib/graphics/features/_volume.py index 3c7a891ff..f31a44100 100644 --- a/fastplotlib/graphics/features/_volume.py +++ b/fastplotlib/graphics/features/_volume.py @@ -1,24 +1,14 @@ -import inspect -import re - import numpy as np import pygfx from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance -VOLUME_RENDER_MODES: dict[str, pygfx.Material] = {} - -for name, obj in inspect.getmembers(pygfx): - if name == "VolumeBasicMaterial": - # TODO: AFAIK this isn't a real material that can be used?? - continue - if name.startswith("Volume") and name.endswith("Material"): - # name without Volume prefix and Material suffix, and the actual material name in lowercase - # ex: VolumeMipMaterial -> mip; VolumeSomethingElseMaterial -> something_else - short_name = re.sub( - r"(? tuple[int, int, int, int]: + def value(self) -> tuple[float, float, float, float]: return self._value @block_reentrance - def set_value(self, graphic, value: tuple[int, int, int, int]): + def set_value(self, graphic, value: tuple[float, float, float, float]): graphic._material.plane = value self._value = graphic._material.plane diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index 5aede55af..72eae5e7e 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -94,17 +94,17 @@ class ImageVolumeGraphic(Graphic): def __init__( self, data: Any, - mode: str = "ray", + mode: str = "mip", vmin: float = None, vmax: float = None, cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", - plane: tuple[int, int, int, int] = (0, 0, 1, 0), + plane: tuple[float, float, float, float] = (0, 0, -1, 0), threshold: float = 0.5, step_size: float = 1.0, substep_size: float = 0.1, - emissive: pygfx.Color = "#000", + emissive: str | tuple | np.ndarray = (0, 0, 0), shininess: int = 30, isolated_buffer: bool = True, **kwargs, @@ -118,13 +118,14 @@ def __init__( Shape must be [n_planes, n_rows, n_cols] for grayscale, or [n_planes, n_rows, n_cols, 3 | 4] for RGB(A) mode: str, default "ray" - render mode, one of ["basic", "ray", "slice", "iso", "mip", "minip"] + render mode, one of "mip", "minip", "iso" or "slice" vmin: float lower contrast limit vmax: float upper contrast limit + cmap: str, default "plasma" colormap for grayscale volumes @@ -134,27 +135,27 @@ def __init__( cmap_interpolation: str, default "linear" interpolation method for sampling from colormap - plane: (int, int, int, int), default (0, 0, 1, 0) - Volume slice to display, used only if `mode` = "slice" + plane: (float, float, float, float), default (0, 0, -1, 0) + Slice volume at this plane. Sets (a, b, c, d) in the equation the defines a plane: ax + by + cz + d = 0. + Used only if `mode` = "slice" threshold : float, default 0.5 The threshold texture value at which the surface is rendered. Used only if `mode` = "iso" step_size : float, default 1.0 - The size of the initial ray marching step for the initial surface finding. - Smaller values will result in more accurate surfaces but slower rendering. + The size of the initial ray marching step for the initial surface finding. Smaller values will result in + more accurate surfaces but slower rendering. Used only if `mode` = "iso" substep_size : float, default 0.1 - The size of the raymarching step for the refined surface finding. - Smaller values will result in more accurate surfaces but slower rendering. + The size of the raymarching step for the refined surface finding. Smaller values will result in more + accurate surfaces but slower rendering. Used only if `mode` = "iso" emissive : Color, default (0, 0, 0, 1) - The emissive color of the surface. I.e. the color that the object emits - even when not lit by a light source. This color is added to the final - color and unaffected by lighting. The alpha channel is ignored. + The emissive color of the surface. I.e. the color that the object emits even when not lit by a light + source. This color is added to the final color and unaffected by lighting. The alpha channel is ignored. Used only if `mode` = "iso" shininess : int, default 30 @@ -162,10 +163,9 @@ def __init__( Used only if `mode` = "iso" 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 - useful if the - array is large. + 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 - useful if thearray is large. kwargs additional keyword arguments passed to :class:`.Graphic` @@ -182,11 +182,16 @@ def __init__( world_object = pygfx.Group() - # texture array that manages the textures on the GPU that represent this image volume - self._data = TextureArray(data, dim=3, isolated_buffer=isolated_buffer) + if isinstance(data, TextureArray): + # share existing buffer + self._data = data + else: + # create new texture array to manage buffer + # texture array that manages the textures on the GPU that represent this image volume + self._data = TextureArray(data, dim=3, isolated_buffer=isolated_buffer) if (vmin is None) or (vmax is None): - vmin, vmax = quick_min_max(data) + vmin, vmax = quick_min_max(self.data.value) # other graphic features self._vmin = ImageVmin(vmin) @@ -205,6 +210,9 @@ def __init__( wrap="clamp-to-edge", ) + print(plane) + + self._plane = VolumeSlicePlane(plane) self._threshold = VolumeIsoThreshold(threshold) self._step_size = VolumeIsoStepSize(step_size) @@ -310,12 +318,12 @@ def cmap_interpolation(self, value: str): self._cmap_interpolation.set_value(self, value) @property - def plane(self) -> tuple[int, int, int, int]: + def plane(self) -> tuple[float, float, float, float]: """Get or set displayed plane in the volume. Valid only for `slice` render mode.""" return self._plane.value @plane.setter - def plane(self, value: tuple[int, int, int, int]): + def plane(self, value: tuple[float, float, float, float]): if self.mode != "slice": raise TypeError("`plane` property is only valid for `slice` render mode.") @@ -390,7 +398,7 @@ def shininess(self, value: int): self._shininess.set_value(self, value) -def reset_vmin_vmax(self): + def reset_vmin_vmax(self): """ Reset the vmin, vmax by *estimating* it from the data @@ -400,6 +408,6 @@ def reset_vmin_vmax(self): """ - vmin, vmax = quick_min_max(self._data.value) + vmin, vmax = quick_min_max(self.data.value) self.vmin = vmin self.vmax = vmax From 98a09f7355108d31fab15705654362beb2c276e5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 5 Jun 2025 00:57:01 -0400 Subject: [PATCH 11/13] update example --- examples/image_volume/image_volume_4d.py | 18 +++++++++--------- examples/image_volume/image_volume_mip.py | 1 + .../image_volume_non_orthogonal_slicing.py | 1 + .../image_volume_slicing_animation.py | 2 +- examples/image_volume/volume_render_modes.py | 3 ++- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/examples/image_volume/image_volume_4d.py b/examples/image_volume/image_volume_4d.py index 208c3a97b..f5da3517f 100644 --- a/examples/image_volume/image_volume_4d.py +++ b/examples/image_volume/image_volume_4d.py @@ -13,13 +13,8 @@ from tqdm import tqdm -def gen_data(p=1, noise=.5, T=256, framerate=30, firerate=2.,): - if p == 2: - gamma = np.array([1.5, -.55]) - elif p == 1: - gamma = np.array([.9]) - else: - raise +def generate_data(p=1, noise=.5, T=256, framerate=30, firerate=2., ): + gamma = np.array([.9]) dims = (128, 128, 30) # size of image sig = (4, 4, 2) # neurons size bkgrd = 10 @@ -49,7 +44,7 @@ def gen_data(p=1, noise=.5, T=256, framerate=30, firerate=2.,): return Y -voldata = gen_data() +voldata = generate_data() fig = fpl.Figure(cameras="3d", controller_types="orbit", size=(700, 560)) @@ -80,4 +75,9 @@ def update(): fig.add_animations(update) -fpl.loop.run() + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/image_volume/image_volume_mip.py b/examples/image_volume/image_volume_mip.py index def693b7d..ddc38c4ea 100644 --- a/examples/image_volume/image_volume_mip.py +++ b/examples/image_volume/image_volume_mip.py @@ -21,6 +21,7 @@ fig.show() + # NOTE: fpl.loop.run() should not be used for interactive sessions # See the "JupyterLab and IPython" section in the user guide if __name__ == "__main__": diff --git a/examples/image_volume/image_volume_non_orthogonal_slicing.py b/examples/image_volume/image_volume_non_orthogonal_slicing.py index ce88925dc..08101cf83 100644 --- a/examples/image_volume/image_volume_non_orthogonal_slicing.py +++ b/examples/image_volume/image_volume_non_orthogonal_slicing.py @@ -48,6 +48,7 @@ fig[0, 0].camera.set_state(state) + # NOTE: fpl.loop.run() should not be used for interactive sessions # See the "JupyterLab and IPython" section in the user guide if __name__ == "__main__": diff --git a/examples/image_volume/image_volume_slicing_animation.py b/examples/image_volume/image_volume_slicing_animation.py index 200a515cb..fb148544f 100644 --- a/examples/image_volume/image_volume_slicing_animation.py +++ b/examples/image_volume/image_volume_slicing_animation.py @@ -13,7 +13,6 @@ import numpy as np import fastplotlib as fpl import imageio.v3 as iio -import pygfx voldata = iio.imread("imageio:stent.npz").astype(np.float32) @@ -57,6 +56,7 @@ def update(): fig[0, 0].camera.set_state(state) + # NOTE: fpl.loop.run() should not be used for interactive sessions # See the "JupyterLab and IPython" section in the user guide if __name__ == "__main__": diff --git a/examples/image_volume/volume_render_modes.py b/examples/image_volume/volume_render_modes.py index 24da5bffc..8ba8c0256 100644 --- a/examples/image_volume/volume_render_modes.py +++ b/examples/image_volume/volume_render_modes.py @@ -2,7 +2,7 @@ Volume modes ============ -View a volume using different rendering modes +View a volume using different rendering modes. """ # test_example = false @@ -78,6 +78,7 @@ def update(self): fig.show() + # NOTE: fpl.loop.run() should not be used for interactive sessions # See the "JupyterLab and IPython" section in the user guide if __name__ == "__main__": From 61be715a2746f27b3a0fd34f109728cddfd2ae74 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 5 Jun 2025 00:57:15 -0400 Subject: [PATCH 12/13] update add_graphics mixin --- fastplotlib/layouts/_graphic_methods_mixin.py | 117 ++++++++++++++---- 1 file changed, 95 insertions(+), 22 deletions(-) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index 1a61be7ff..7a7556970 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -26,13 +26,13 @@ def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic: def add_image( self, data: Any, - vmin: int = None, - vmax: int = None, + vmin: float = None, + vmax: float = None, cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageGraphic: """ @@ -44,10 +44,10 @@ def add_image( 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 + vmin: float, optional minimum value for color scaling, estimated from data if not provided - vmax: int, optional + vmax: float, optional maximum value for color scaling, estimated from data if not provided cmap: str, optional, default "plasma" @@ -80,23 +80,90 @@ def add_image( interpolation, cmap_interpolation, isolated_buffer, - **kwargs, + **kwargs ) def add_image_volume( self, data: Any, - mode: str = "ray", - vmin: int = None, - vmax: int = None, + mode: str = "mip", + vmin: float = None, + vmax: float = None, cmap: str = "plasma", interpolation: str = "nearest", cmap_interpolation: str = "linear", + plane: tuple[float, float, float, float] = (0, 0, -1, 0), + threshold: float = 0.5, + step_size: float = 1.0, + substep_size: float = 0.1, + emissive: str | tuple | numpy.ndarray = (0, 0, 0), + shininess: int = 30, isolated_buffer: bool = True, - **kwargs, + **kwargs ) -> ImageVolumeGraphic: """ - None + + + Parameters + ---------- + data: array-like + array-like, usually numpy.ndarray, must support ``memoryview()``. + Shape must be [n_planes, n_rows, n_cols] for grayscale, or [n_planes, n_rows, n_cols, 3 | 4] for RGB(A) + + mode: str, default "ray" + render mode, one of "mip", "minip", "iso" or "slice" + + vmin: float + lower contrast limit + + vmax: float + upper contrast limit + + cmap: str, default "plasma" + colormap for grayscale volumes + + interpolation: str, default "nearest" + interpolation method for sampling pixels + + cmap_interpolation: str, default "linear" + interpolation method for sampling from colormap + + plane: (float, float, float, float), default (0, 0, -1, 0) + Slice volume at this plane. Sets (a, b, c, d) in the equation the defines a plane: ax + by + cz + d = 0. + Used only if `mode` = "slice" + + threshold : float, default 0.5 + The threshold texture value at which the surface is rendered. + Used only if `mode` = "iso" + + step_size : float, default 1.0 + The size of the initial ray marching step for the initial surface finding. Smaller values will result in + more accurate surfaces but slower rendering. + Used only if `mode` = "iso" + + substep_size : float, default 0.1 + The size of the raymarching step for the refined surface finding. Smaller values will result in more + accurate surfaces but slower rendering. + Used only if `mode` = "iso" + + emissive : Color, default (0, 0, 0, 1) + The emissive color of the surface. I.e. the color that the object emits even when not lit by a light + source. This color is added to the final color and unaffected by lighting. The alpha channel is ignored. + Used only if `mode` = "iso" + + shininess : int, default 30 + How shiny the specular highlight is; a higher value gives a sharper highlight. + Used only if `mode` = "iso" + + 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 - useful if thearray is large. + + kwargs + additional keyword arguments passed to :class:`.Graphic` + + """ return self._create_graphic( ImageVolumeGraphic, @@ -107,8 +174,14 @@ def add_image_volume( cmap, interpolation, cmap_interpolation, + plane, + threshold, + step_size, + substep_size, + emissive, + shininess, isolated_buffer, - **kwargs, + **kwargs ) def add_line_collection( @@ -126,7 +199,7 @@ def add_line_collection( metadatas: Union[Sequence[Any], numpy.ndarray] = None, isolated_buffer: bool = True, kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineCollection: """ @@ -199,7 +272,7 @@ def add_line_collection( metadatas, isolated_buffer, kwargs_lines, - **kwargs, + **kwargs ) def add_line( @@ -213,7 +286,7 @@ def add_line( cmap_transform: Union[numpy.ndarray, Sequence] = None, isolated_buffer: bool = True, size_space: str = "screen", - **kwargs, + **kwargs ) -> LineGraphic: """ @@ -268,7 +341,7 @@ def add_line( cmap_transform, isolated_buffer, size_space, - **kwargs, + **kwargs ) def add_line_stack( @@ -287,7 +360,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineStack: """ @@ -368,7 +441,7 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs, + **kwargs ) def add_scatter( @@ -383,7 +456,7 @@ def add_scatter( sizes: Union[float, numpy.ndarray, Sequence[float]] = 1, uniform_size: bool = False, size_space: str = "screen", - **kwargs, + **kwargs ) -> ScatterGraphic: """ @@ -445,7 +518,7 @@ def add_scatter( sizes, uniform_size, size_space, - **kwargs, + **kwargs ) def add_text( @@ -458,7 +531,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs, + **kwargs ) -> TextGraphic: """ @@ -509,5 +582,5 @@ def add_text( screen_space, offset, anchor, - **kwargs, + **kwargs ) From b6e29cd7bc93691d4517f72fe175585aea69de54 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 5 Jun 2025 00:59:00 -0400 Subject: [PATCH 13/13] bah --- examples/image_volume/volume_render_modes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/image_volume/volume_render_modes.py b/examples/image_volume/volume_render_modes.py index 8ba8c0256..691b75251 100644 --- a/examples/image_volume/volume_render_modes.py +++ b/examples/image_volume/volume_render_modes.py @@ -5,7 +5,7 @@ View a volume using different rendering modes. """ -# test_example = false +# test_example = true # sphinx_gallery_pygfx_docs = 'screenshot' import numpy as np