diff --git a/docs/source/conf.py b/docs/source/conf.py index e4ff72237..5f3ed4d41 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -57,6 +57,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_4d.py b/examples/image_volume/image_volume_4d.py new file mode 100644 index 000000000..f5da3517f --- /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 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 + 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 = generate_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, interpolation="linear", 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) + + +# 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 new file mode 100644 index 000000000..ddc38c4ea --- /dev/null +++ b/examples/image_volume/image_volume_mip.py @@ -0,0 +1,29 @@ +""" +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: +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", controller_types="orbit", size=(700, 560)) + +fig[0, 0].add_image_volume(voldata, mode="iso") + +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__": + 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..08101cf83 --- /dev/null +++ b/examples/image_volume/image_volume_non_orthogonal_slicing.py @@ -0,0 +1,56 @@ +""" +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..fb148544f --- /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 + + +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 new file mode 100644 index 000000000..691b75251 --- /dev/null +++ b/examples/image_volume/volume_render_modes.py @@ -0,0 +1,86 @@ +""" +Volume modes +============ + +View a volume using different rendering modes. +""" + +# test_example = true +# 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") + +# 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=300): + 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) + + 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() + + +# 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/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 b458a8c48..a3bbc1b5f 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 @@ -11,6 +12,7 @@ "LineGraphic", "ScatterGraphic", "ImageGraphic", + "ImageVolumeGraphic", "TextGraphic", "LineCollection", "LineStack", diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index bc3486696..e38837a5d 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/__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/_image.py b/fastplotlib/graphics/features/_image.py index c47a26e6a..a6e3665a9 100644 --- a/fastplotlib/graphics/features/_image.py +++ b/fastplotlib/graphics/features/_image.py @@ -13,8 +13,13 @@ ) -# 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 +33,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,26 +69,39 @@ 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 - ) + self._buffer: np.ndarray[pygfx.Texture] = np.empty(shape=shape, 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) + texture = pygfx.Texture(self.value[data_slice], dim=self._dim) self.buffer[buffer_index] = texture @@ -99,6 +134,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 +153,17 @@ 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 +177,36 @@ 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/_volume.py b/fastplotlib/graphics/features/_volume.py new file mode 100644 index 000000000..f31a44100 --- /dev/null +++ b/fastplotlib/graphics/features/_volume.py @@ -0,0 +1,255 @@ +import numpy as np +import pygfx + +from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance + +VOLUME_RENDER_MODES = { + "mip": pygfx.VolumeMipMaterial, + "minip": pygfx.VolumeMinipMaterial, + "iso": pygfx.VolumeIsoMaterial, + "slice": pygfx.VolumeSliceMaterial, +} + + +def create_volume_material_kwargs(graphic, mode: str): + kwargs = { + "clim": (graphic.vmin, graphic.vmax), + "map": graphic._texture_map, + "interpolation": graphic.interpolation, + "pick_write": True, + } + + if mode == "iso": + more_kwargs = {attr: getattr(graphic, attr) + for attr in [ + "threshold", + "step_size", + "substep_size", + "emissive", + "shininess", + ] + } + + elif mode == "slice": + more_kwargs = {"plane": graphic.plane} + print(more_kwargs) + else: + more_kwargs = {} + + kwargs.update(more_kwargs) + return kwargs + + +class VolumeRenderMode(GraphicFeature): + """Volume rendering mode, controls world object material""" + + event_info_spec = [ + { + "dict key": "value", + "type": "str", + "description": "volume rendering mode that has been set", + }, + ] + + def __init__(self, value: str): + self._validate(value) + self._value = value + super().__init__() + + @property + def value(self) -> 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[float, float, float, float]", + "description": "new plane slice", + }, + ] + + def __init__(self, value: tuple[float, float, float, float]): + self._value = value + super().__init__() + + @property + def value(self) -> tuple[float, float, float, float]: + return self._value + + @block_reentrance + def set_value(self, graphic, value: tuple[float, float, float, float]): + 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 957607fe1..abee31f52 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,11 +100,11 @@ 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 - minimum value for color scaling, calculated from data if not provided + vmin: float, optional + 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 + vmax: float, optional + maximum value for color scaling, estimated from data if not provided cmap: str, optional, default "plasma" colormap to use to display the data. For supported colormaps see the @@ -131,11 +131,16 @@ 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) + 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) @@ -167,7 +172,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 @@ -201,14 +206,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/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py new file mode 100644 index 000000000..72eae5e7e --- /dev/null +++ b/fastplotlib/graphics/image_volume.py @@ -0,0 +1,413 @@ +from typing import * + +import numpy as np +import pygfx + +from ..utils import quick_min_max +from ._base import Graphic +from .features import ( + TextureArray, + ImageCmap, + ImageVmin, + ImageVmax, + ImageInterpolation, + ImageCmapInterpolation, + VolumeRenderMode, + VolumeIsoThreshold, + VolumeIsoStepSize, + VolumeIsoSubStepSize, + VolumeIsoEmissive, + VolumeIsoShininess, + VolumeSlicePlane, + VOLUME_RENDER_MODES, + create_volume_material_kwargs, +) + + +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 = "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 | np.ndarray = (0, 0, 0), + shininess: int = 30, + isolated_buffer: bool = True, + **kwargs, + ): + """ + + 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` + + """ + + valid_modes = VOLUME_RENDER_MODES.keys() + 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() + + 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(self.data.value) + + # 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) + + self._texture_map = pygfx.TextureMap( + self._cmap.texture, + filter=self._cmap_interpolation.value, + wrap="clamp-to-edge", + ) + + print(plane) + + + 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) + + 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 + 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 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: + """Get or set colormap name""" + return self._cmap.value + + @cmap.setter + def cmap(self, name: str): + self._cmap.set_value(self, name) + + @property + def vmin(self) -> float: + """Get or set the 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: + """Get or set the 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: + """Get or set the 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: + """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) + + @property + 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[float, float, float, float]): + 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 + + 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 cb9cd04c0..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,11 +44,11 @@ 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 - minimum value for color scaling, calculated from data if not provided + vmin: float, optional + 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 + vmax: float, optional + maximum value for color scaling, estimated from data if not provided cmap: str, optional, default "plasma" colormap to use to display the data. For supported colormaps see the @@ -80,7 +80,108 @@ def add_image( interpolation, cmap_interpolation, isolated_buffer, - **kwargs, + **kwargs + ) + + def add_image_volume( + self, + data: Any, + 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 + ) -> ImageVolumeGraphic: + """ + + + 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, + data, + mode, + vmin, + vmax, + cmap, + interpolation, + cmap_interpolation, + plane, + threshold, + step_size, + substep_size, + emissive, + shininess, + isolated_buffer, + **kwargs ) def add_line_collection( @@ -98,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: """ @@ -171,7 +272,7 @@ def add_line_collection( metadatas, isolated_buffer, kwargs_lines, - **kwargs, + **kwargs ) def add_line( @@ -185,7 +286,7 @@ def add_line( cmap_transform: Union[numpy.ndarray, Sequence] = None, isolated_buffer: bool = True, size_space: str = "screen", - **kwargs, + **kwargs ) -> LineGraphic: """ @@ -240,7 +341,7 @@ def add_line( cmap_transform, isolated_buffer, size_space, - **kwargs, + **kwargs ) def add_line_stack( @@ -259,7 +360,7 @@ def add_line_stack( separation: float = 10.0, separation_axis: str = "y", kwargs_lines: list[dict] = None, - **kwargs, + **kwargs ) -> LineStack: """ @@ -340,7 +441,7 @@ def add_line_stack( separation, separation_axis, kwargs_lines, - **kwargs, + **kwargs ) def add_scatter( @@ -355,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: """ @@ -417,7 +518,7 @@ def add_scatter( sizes, uniform_size, size_space, - **kwargs, + **kwargs ) def add_text( @@ -430,7 +531,7 @@ def add_text( screen_space: bool = True, offset: tuple[float] = (0, 0, 0), anchor: str = "middle-center", - **kwargs, + **kwargs ) -> TextGraphic: """ @@ -481,5 +582,5 @@ def add_text( screen_space, offset, anchor, - **kwargs, + **kwargs ) 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") diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index a1d6d476a..a839ed9d0 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -273,15 +273,15 @@ def quick_min_max(data: np.ndarray, max_size=1e6) -> tuple[float, float]: 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 85e0be669..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) @@ -50,10 +53,9 @@ def generate_add_graphics_methods(): for m in modules: cls = m - if cls.__name__ == "Graphic": - # skip base class - continue - method_name = cls.type + cls_name = cls.__name__.replace("Graphic", "") + # from https://stackoverflow.com/a/1176023 + method_name = re.sub(r'(?