From 1fea4aa75862162d8ddc4e4a08d6011b0850472e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 1 Dec 2022 22:57:14 -0500 Subject: [PATCH 01/21] started implementation of ImageWidget, nothing is tested yet --- fastplotlib/widgets/__init__.py | 0 fastplotlib/widgets/image.py | 94 +++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 fastplotlib/widgets/__init__.py create mode 100644 fastplotlib/widgets/image.py diff --git a/fastplotlib/widgets/__init__.py b/fastplotlib/widgets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py new file mode 100644 index 000000000..605043729 --- /dev/null +++ b/fastplotlib/widgets/image.py @@ -0,0 +1,94 @@ +from ..plot import Plot +from ..layouts import GridPlot +from ..graphics import Image +from ipywidgets.widgets import IntSlider, VBox, HBox +import numpy as np +from typing import * +from warnings import warn + + +DEFAULT_AXES_ORDER = \ + { + 2: "xy", + 3: "txy", + 4: "tzxy", + 5: "tczxy", + } + + +def calc_gridshape(n): + sr = np.sqrt(n) + return ( + np.ceil(sr), + np.round(sr) + ) + + +class ImageWidget: + def __init__( + self, + data: Union[np.ndarray, List[np.ndarray]], + axes_order: str = None, + slider_sync: bool = True, + slider_axes: Union[int, str, dict] = None, + frame_apply: Union[callable, dict] = None, + grid_shape: Tuple[int, int] = None, + ): + # single image + if isinstance(data, np.ndarray): + self.plot_type = Plot + self.data: List[np.ndarray] = [data] + ndim = data[0].ndim + + # list of lists + elif isinstance(data, list): + if all([isinstance(d, np.ndarray) for d in data]): + self.plot_type = GridPlot + + if grid_shape is None: + grid_shape = calc_gridshape(len(data)) + + elif grid_shape[0] * grid_shape[1] < len(data): + grid_shape = calc_gridshape(len(data)) + warn(f"Invalid `grid_shape` passed, setting grid shape to: {grid_shape}") + + _ndim = [d.ndim for d in data] + + if not len(set(_ndim)) == 1: + raise ValueError( + f"Number of dimensions of all data arrays must match, your ndims are: {_ndim}" + ) + + self.data: List[np.ndarray] = data + ndim = data[0].ndim + + else: + raise TypeError( + f"`data` must be of type `numpy.ndarray` representing a single image/image sequence " + f"or a list of `numpy.ndarray` representing a grid of images/image sequences" + ) + + if axes_order is None: + self.axes_order: List[str] = [DEFAULT_AXES_ORDER[ndim] for i in range(len(data))] + + if isinstance(slider_axes, (int)): + self._slider_axes: List[int] = [slider_axes for i in range(len(data))] + + elif isinstance(slider_axes, str): + self._slider_axes: List[int] = [self.axes_order.index(slider_axes)] + + self.sliders: List[IntSlider] = [ + IntSlider( + min=0, + max=data.shape[slider_axes] - 1, + value=0, + step=1, + description=f"slider axis: {slider_axes}" + ) + ] + + def slider_changed(self): + pass + + def show(self): + pass From 1bc65a080ff814da3521b30611f8252fa3c85bad Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 1 Dec 2022 23:41:37 -0500 Subject: [PATCH 02/21] basic stuff finished for single plot with a slider, need to test --- fastplotlib/widgets/image.py | 91 ++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 10 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 605043729..566cf5c21 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -1,7 +1,7 @@ from ..plot import Plot from ..layouts import GridPlot from ..graphics import Image -from ipywidgets.widgets import IntSlider, VBox, HBox +from ipywidgets.widgets import IntSlider, VBox, HBox, Layout import numpy as np from typing import * from warnings import warn @@ -24,6 +24,12 @@ def calc_gridshape(n): ) +def get_indexer(ndim: int, dim_index: int, slice_index: int) -> slice: + dim_index = [slice(None)] * ndim + dim_index[dim_index] = slice_index + return tuple(dim_index) + + class ImageWidget: def __init__( self, @@ -33,6 +39,7 @@ def __init__( slider_axes: Union[int, str, dict] = None, frame_apply: Union[callable, dict] = None, grid_shape: Tuple[int, int] = None, + **kwargs ): # single image if isinstance(data, np.ndarray): @@ -71,21 +78,85 @@ def __init__( if axes_order is None: self.axes_order: List[str] = [DEFAULT_AXES_ORDER[ndim] for i in range(len(data))] - if isinstance(slider_axes, (int)): - self._slider_axes: List[int] = [slider_axes for i in range(len(data))] + # if a single one is provided + if isinstance(slider_axes, (int, str)): + if isinstance(slider_axes, (int)): + self._slider_axes = slider_axes - elif isinstance(slider_axes, str): - self._slider_axes: List[int] = [self.axes_order.index(slider_axes)] + # also if a single one is provided, get the integer dimension index from the axes_oder string + elif isinstance(slider_axes, str): + self._slider_axes = self.axes_order.index(slider_axes) - self.sliders: List[IntSlider] = [ - IntSlider( + self.slider: IntSlider = IntSlider( min=0, - max=data.shape[slider_axes] - 1, + max=data.shape[self._slider_axes] - 1, value=0, step=1, - description=f"slider axis: {slider_axes}" + description=f"slider axis: {self._slider_axes}" + ) + + # individual slider for each data array + elif isinstance(slider_axes, dict): + if not len(slider_axes.keys()) == len(self.data): + raise ValueError( + f"Must provide slider_axes entry for every input `data` array" + ) + + if not isinstance(axes_order, dict): + raise ValueError("Must pass `axes_order` dict if passing a dict of `slider_axes`") + + if not len(axes_order.keys()) == len(self.data): + raise ValueError( + f"Must provide `axes_order` entry for every input `data` array" + ) + + # convert str type desired slider axes to dimension index integers + # matchup to the given axes_order dict + _axes = [ + self.axes_order[array].index(slider_axes[array]) + if isinstance(dim_index, str) + else dim_index + for + array, dim_index in slider_axes.items() + ] + + self.sliders: Dict[IntSlider] = { + array: IntSlider( + min=0, + max=array.shape[dim] -1, + step=1, + value=0, + ) + for array, dim in self.axes_order.items() + } + + if self.plot_type == Plot: + self.plot = Plot() + + slice_index = get_indexer(ndim, self._slider_axes, slice_index=0) + + self.image_graphics: List[Image] = [self.plot.image( + data=data[0][slice_index], + **kwargs + )] + + self.slider.observe( + lambda x: self.image_graphics[0].update_data( + data[0][ + get_indexer(ndim, self._slider_axes, slice_index=x["new"]) + ] + ), + names="value" ) - ] + + self.widget = VBox([self.plot, self.slider]) + + elif self.plot_type == GridPlot: + pass + + def set_frame_slider_width(self): + w, h = self.plot.renderer.logical_size + self.slider.layout = Layout(width=f"{w}px") def slider_changed(self): pass From 6592cc3a6ea3f678f91cdbdf0e73c0626755b562 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 2 Dec 2022 00:12:58 -0500 Subject: [PATCH 03/21] basic image widget works --- fastplotlib/widgets/__init__.py | 1 + fastplotlib/widgets/image.py | 42 ++++++++++++++++++++------------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/fastplotlib/widgets/__init__.py b/fastplotlib/widgets/__init__.py index e69de29bb..553e990bf 100644 --- a/fastplotlib/widgets/__init__.py +++ b/fastplotlib/widgets/__init__.py @@ -0,0 +1 @@ +from .image import ImageWidget diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 566cf5c21..028d3bc53 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -25,9 +25,9 @@ def calc_gridshape(n): def get_indexer(ndim: int, dim_index: int, slice_index: int) -> slice: - dim_index = [slice(None)] * ndim - dim_index[dim_index] = slice_index - return tuple(dim_index) + indexer = [slice(None)] * ndim + indexer[dim_index] = slice_index + return tuple(indexer) class ImageWidget: @@ -43,9 +43,9 @@ def __init__( ): # single image if isinstance(data, np.ndarray): - self.plot_type = Plot - self.data: List[np.ndarray] = [data] - ndim = data[0].ndim + self.plot_type = Plot + self.data: List[np.ndarray] = [data] + ndim = self.data[0].ndim # list of lists elif isinstance(data, list): @@ -67,7 +67,7 @@ def __init__( ) self.data: List[np.ndarray] = data - ndim = data[0].ndim + ndim = self.data[0].ndim else: raise TypeError( @@ -78,6 +78,13 @@ def __init__( if axes_order is None: self.axes_order: List[str] = [DEFAULT_AXES_ORDER[ndim] for i in range(len(data))] + else: + self.axes_order = axes_order + + # assume 0 if slider axes is None + if slider_axes is None: + slider_axes = self.axes_order.index("t") + # if a single one is provided if isinstance(slider_axes, (int, str)): if isinstance(slider_axes, (int)): @@ -114,10 +121,10 @@ def __init__( # matchup to the given axes_order dict _axes = [ self.axes_order[array].index(slider_axes[array]) - if isinstance(dim_index, str) - else dim_index - for - array, dim_index in slider_axes.items() + if isinstance(dim_index, str) + else dim_index + for + array, dim_index in slider_axes.items() ] self.sliders: Dict[IntSlider] = { @@ -136,25 +143,27 @@ def __init__( slice_index = get_indexer(ndim, self._slider_axes, slice_index=0) self.image_graphics: List[Image] = [self.plot.image( - data=data[0][slice_index], + data=self.data[0][slice_index], **kwargs )] self.slider.observe( lambda x: self.image_graphics[0].update_data( - data[0][ + self.data[0][ get_indexer(ndim, self._slider_axes, slice_index=x["new"]) ] ), names="value" ) - self.widget = VBox([self.plot, self.slider]) + self.plot.renderer.add_event_handler(self._set_frame_slider_width, "resize") + + self.widget = VBox([self.plot.canvas, self.slider]) elif self.plot_type == GridPlot: pass - def set_frame_slider_width(self): + def _set_frame_slider_width(self, *args): w, h = self.plot.renderer.logical_size self.slider.layout = Layout(width=f"{w}px") @@ -162,4 +171,5 @@ def slider_changed(self): pass def show(self): - pass + self.plot.show() + return self.widget From 658f29819675c25fc0f51c186725df14ca9aae05 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 2 Dec 2022 13:08:33 -0500 Subject: [PATCH 04/21] docs --- fastplotlib/widgets/image.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 028d3bc53..1a47e53a8 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -31,6 +31,12 @@ def get_indexer(ndim: int, dim_index: int, slice_index: int) -> slice: class ImageWidget: + """ + A high level for displaying n-dimensional image data in conjunction with automatically generated sliders for + navigating through 1-2 selected dimensions within the image data. + + Can display a single n-dimensional image array or a grid of n-dimensional images. + """ def __init__( self, data: Union[np.ndarray, List[np.ndarray]], @@ -41,26 +47,30 @@ def __init__( grid_shape: Tuple[int, int] = None, **kwargs ): - # single image + # if single image array if isinstance(data, np.ndarray): self.plot_type = Plot self.data: List[np.ndarray] = [data] ndim = self.data[0].ndim - # list of lists + # if list of image arrays, list of lists elif isinstance(data, list): + # verify that it's a list of np.ndarray if all([isinstance(d, np.ndarray) for d in data]): self.plot_type = GridPlot if grid_shape is None: grid_shape = calc_gridshape(len(data)) + # verify that user-specified grid shape is large enough for the number of image arrays passed elif grid_shape[0] * grid_shape[1] < len(data): grid_shape = calc_gridshape(len(data)) warn(f"Invalid `grid_shape` passed, setting grid shape to: {grid_shape}") _ndim = [d.ndim for d in data] + # verify that all image arrays have same number of dimensions + # sliders get messy otherwise if not len(set(_ndim)) == 1: raise ValueError( f"Number of dimensions of all data arrays must match, your ndims are: {_ndim}" @@ -75,25 +85,27 @@ def __init__( f"or a list of `numpy.ndarray` representing a grid of images/image sequences" ) + # default axes order if not passed if axes_order is None: self.axes_order: List[str] = [DEFAULT_AXES_ORDER[ndim] for i in range(len(data))] else: self.axes_order = axes_order - # assume 0 if slider axes is None + # make a slider for "t", the time dimension, if slider_axes is not provided if slider_axes is None: slider_axes = self.axes_order.index("t") - # if a single one is provided + # if a single axes is provided for the slider if isinstance(slider_axes, (int, str)): if isinstance(slider_axes, (int)): self._slider_axes = slider_axes - # also if a single one is provided, get the integer dimension index from the axes_oder string + # if a single one is provided but it is a string, get the integer dimension index from the axes_order elif isinstance(slider_axes, str): self._slider_axes = self.axes_order.index(slider_axes) + # a single slider for the desired axis/dimension self.slider: IntSlider = IntSlider( min=0, max=data.shape[self._slider_axes] - 1, @@ -103,6 +115,7 @@ def __init__( ) # individual slider for each data array + # TODO: not tested and fully implemented yet elif isinstance(slider_axes, dict): if not len(slider_axes.keys()) == len(self.data): raise ValueError( @@ -118,7 +131,7 @@ def __init__( ) # convert str type desired slider axes to dimension index integers - # matchup to the given axes_order dict + # match to the given axes_order dict _axes = [ self.axes_order[array].index(slider_axes[array]) if isinstance(dim_index, str) @@ -137,16 +150,20 @@ def __init__( for array, dim in self.axes_order.items() } + # finally create the plot and slider for a single image array if self.plot_type == Plot: self.plot = Plot() + # get slice object for dynamically indexing chosen dimension from `self._slider_axes` slice_index = get_indexer(ndim, self._slider_axes, slice_index=0) + # create image graphic self.image_graphics: List[Image] = [self.plot.image( data=self.data[0][slice_index], **kwargs )] + # update frame w.r.t. slider index self.slider.observe( lambda x: self.image_graphics[0].update_data( self.data[0][ From 5cc91ae74aad3deb3ac5fd3b95732ca382b2d33d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 3 Dec 2022 21:29:19 -0500 Subject: [PATCH 05/21] splitting imagewidget into two classes --- examples/imagewidget.ipynb | 97 ++++++++++++++++++++++++++++++++++++ fastplotlib/widgets/image.py | 45 +++++++++++++++-- 2 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 examples/imagewidget.ipynb diff --git a/examples/imagewidget.ipynb b/examples/imagewidget.ipynb new file mode 100644 index 000000000..852b80080 --- /dev/null +++ b/examples/imagewidget.ipynb @@ -0,0 +1,97 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "111354d5-36ee-4bd5-9376-aaece6eb5b4e", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "8404aaae-ba87-426c-a3cf-f3968640b8e3", + "metadata": {}, + "outputs": [], + "source": [ + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "2253ccaa-670c-4c6a-9e81-48963bd1d964", + "metadata": {}, + "outputs": [], + "source": [ + "from fastplotlib.widgets import ImageWidget\n", + "import numpy as np" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "9775a1f0-34c3-4583-8dcc-00707095150b", + "metadata": {}, + "outputs": [], + "source": [ + "a = np.random.rand(1000, 512, 512)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "8b910f06-2cf4-4363-8b58-71c32d6f9c64", + "metadata": {}, + "outputs": [ + { + "ename": "ValueError", + "evalue": "'t' is not in list", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn [6], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m iw \u001b[38;5;241m=\u001b[39m \u001b[43mImageWidget\u001b[49m\u001b[43m(\u001b[49m\u001b[43ma\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mcmap\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mgnuplot2\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n", + "File \u001b[0;32m~/repos/fastplotlib/fastplotlib/widgets/image.py:98\u001b[0m, in \u001b[0;36mImageWidget.__init__\u001b[0;34m(self, data, axes_order, slider_sync, slider_axes, slice_avg, frame_apply, grid_shape, **kwargs)\u001b[0m\n\u001b[1;32m 96\u001b[0m \u001b[38;5;66;03m# make a slider for \"t\", the time dimension, if slider_axes is not provided\u001b[39;00m\n\u001b[1;32m 97\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m slider_axes \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[0;32m---> 98\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_slider_axes \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43maxes_order\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mindex\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mt\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n\u001b[1;32m 100\u001b[0m \u001b[38;5;66;03m# if a single axes is provided for the slider\u001b[39;00m\n\u001b[1;32m 101\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28misinstance\u001b[39m(slider_axes, (\u001b[38;5;28mint\u001b[39m, \u001b[38;5;28mstr\u001b[39m)):\n", + "\u001b[0;31mValueError\u001b[0m: 't' is not in list" + ] + } + ], + "source": [ + "iw = ImageWidget(a, cmap=\"gnuplot2\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f1be6cd2-9263-4da0-9280-48444dd74c1f", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 1a47e53a8..1fcfa01bc 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -12,7 +12,7 @@ 2: "xy", 3: "txy", 4: "tzxy", - 5: "tczxy", + # 5: "tczxy", # no 5 dim stuff for now } @@ -30,6 +30,14 @@ def get_indexer(ndim: int, dim_index: int, slice_index: int) -> slice: return tuple(indexer) +def is_arraylike(obj) -> bool: + """ + Checks if the object is array-like. + For now just checks if obj has `__getitem__()` + """ + return hasattr(obj, "__getitem__") + + class ImageWidget: """ A high level for displaying n-dimensional image data in conjunction with automatically generated sliders for @@ -40,9 +48,10 @@ class ImageWidget: def __init__( self, data: Union[np.ndarray, List[np.ndarray]], - axes_order: str = None, + axes_order: [str, dict] = None, slider_sync: bool = True, slider_axes: Union[int, str, dict] = None, + slice_avg: Union[int, dict] = None, frame_apply: Union[callable, dict] = None, grid_shape: Tuple[int, int] = None, **kwargs @@ -87,14 +96,14 @@ def __init__( # default axes order if not passed if axes_order is None: - self.axes_order: List[str] = [DEFAULT_AXES_ORDER[ndim] for i in range(len(data))] + self.axes_order: str = [DEFAULT_AXES_ORDER[ndim] for i in range(len(data))] else: self.axes_order = axes_order # make a slider for "t", the time dimension, if slider_axes is not provided if slider_axes is None: - slider_axes = self.axes_order.index("t") + self._slider_axes = self.axes_order.index("t") # if a single axes is provided for the slider if isinstance(slider_axes, (int, str)): @@ -190,3 +199,31 @@ def slider_changed(self): def show(self): self.plot.show() return self.widget + + +class _ImageWidget: + """Single n-dimension image with slider(s)""" + def __init__( + self, + data: np.ndarray, + axes_order: str = None, + slider_axes: Union[str, int, List[Union[str, int]]] = None, + slice_average: Dict[Union[int, str], int] = None, + frame_apply: Union[callable, Dict[Union[int, str], callable]] = None, + **kwargs + ): + if not is_arraylike(data): + raise TypeError( + f"`data` must be an array-like object" + ) + + self.data = data + self.ndim = self.data.ndim + + if axes_order is None: + self.axes_order: str = DEFAULT_AXES_ORDER[self.ndim] + else: + if not type(axes_order) is str: + raise TypeError(f"`axes_order` must be a , you have passed a: <{type(axes_order)}>") + self.axes_order = axes_order + From 7a6a13650d9529334d360ab3cc95a70a9a133b4f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 3 Dec 2022 23:18:00 -0500 Subject: [PATCH 06/21] split ImageWidget into ImageWidgetSingle and later ImageWidgetGrid --- fastplotlib/widgets/image.py | 191 ++++++++++++++++++++++++++++++++++- 1 file changed, 189 insertions(+), 2 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 1fcfa01bc..73da55d57 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -5,7 +5,7 @@ import numpy as np from typing import * from warnings import warn - +from functools import partial DEFAULT_AXES_ORDER = \ { @@ -43,6 +43,40 @@ class ImageWidget: A high level for displaying n-dimensional image data in conjunction with automatically generated sliders for navigating through 1-2 selected dimensions within the image data. + Can display a single n-dimensional image array or a grid of n-dimensional images. + """ + def __new__( + cls, + data: Union[np.ndarray, List[np.ndarray]], + axes_order: [str, dict] = None, + slider_sync: bool = True, + slider_axes: Union[int, str, dict] = None, + slice_avg: Union[int, dict] = None, + frame_apply: Union[callable, dict] = None, + grid_shape: Tuple[int, int] = None, + **kwargs + ): + # if single image array + if isinstance(data, np.ndarray): + return ImageWidgetSingle( + data, + axes_order, + slider_axes, + slice_avg, + frame_apply, + **kwargs + ) + + # if list of image arrays, list of lists + elif isinstance(data, list): + pass + + +class __ImageWidget: + """ + A high level for displaying n-dimensional image data in conjunction with automatically generated sliders for + navigating through 1-2 selected dimensions within the image data. + Can display a single n-dimensional image array or a grid of n-dimensional images. """ def __init__( @@ -201,7 +235,7 @@ def show(self): return self.widget -class _ImageWidget: +class ImageWidgetSingle: """Single n-dimension image with slider(s)""" def __init__( self, @@ -227,3 +261,156 @@ def __init__( raise TypeError(f"`axes_order` must be a , you have passed a: <{type(axes_order)}>") self.axes_order = axes_order + if slider_axes is None: + slider_axes = self.axes_order.index("t") + + if isinstance(slider_axes, (int, str)): + if isinstance(slider_axes, int): + self.slider_axes = [slider_axes] + elif isinstance(slider_axes, str): + if slider_axes not in self.axes_order: + raise ValueError( + f"if `slider_axes` is a , it must be a character found in `axes_order`. " + f"Your `axes_order` is currently: {self.axes_order}." + ) + self.slider_axes = [self.axes_order.index(slider_axes)] + + elif isinstance(slider_axes, list): + self.slider_axes: List[int] = list() + for sax in slider_axes: + if isinstance(sax, int): + self.slider_axes.append(sax) + + # parse the str into a int + elif isinstance(sax, str): + if sax not in self.axes_order: + raise ValueError( + f"if `slider_axes` is a , it must be a character found in `axes_order`. " + f"Your `axes_order` is currently: {self.axes_order}." + ) + self.slider_axes.append( + self.axes_order.index(sax) + ) + + else: + raise TypeError( + "If passing a list for `slider_axes` each element must be either an or " + ) + + else: + raise TypeError(f"`slider_axes` must a , or , you have passed a: {type(slider_axes)}") + + self.plot = Plot() + self.sliders = list() + self.vertical_sliders = list() + self.horizontal_sliders = list() + + # current_index stores {dimension_index: slice_index} for every dimension + self.current_index: Dict[int, int] = {sax: 0 for sax in self.slider_axes} + self.current_indexer = self.get_indexer(self.current_index) + + for sax in self.slider_axes: + if self.axes_order[sax] == "z": + # TODO: once ipywidgets plays nicely with HBox and jupyter-rfb can use vertical + orientation = "horizontal" + else: + orientation = "horizontal" + + slider = IntSlider( + min=0, + max=self.data.shape[sax] - 1, + step=1, + value=0, + description=f"Axis: {self.axes_order[sax]}", + orientation=orientation + ) + + slider.observe( + partial(self.slider_value_changed, sax), + names="value" + ) + + self.sliders.append(slider) + if orientation == "horizontal": + self.horizontal_sliders.append(slider) + elif orientation == "vertical": + self.vertical_sliders.append(slider) + + self.image_graphic: Image = self.plot.image(data=self.data[self.current_indexer]) + + self.plot.renderer.add_event_handler(self.set_slider_layout, "resize") + + # TODO: there is currently an issue with ipywidgets or jupyter-rfb and HBox doesn't work with RFB canvas + # self.widget = None + # hbox = None + # if len(self.vertical_sliders) > 0: + # hbox = HBox(self.vertical_sliders) + # + # if len(self.horizontal_sliders) > 0: + # if hbox is not None: + # self.widget = VBox([ + # HBox([self.plot.canvas, hbox]), + # *self.horizontal_sliders, + # ]) + # + # else: + # self.widget = VBox([self.plot.canvas, *self.horizontal_sliders]) + + # TODO: So just stack everything vertically for now + self.widget = VBox([ + self.plot.canvas, + *self.sliders + ]) + + def get_indexer(self, slice_indices: dict[Union[int, str], int]) -> Tuple[slice]: + """ + Get the slice object to use for dynamically indexing arrays. + + Parameters + ---------- + slice_indices: dict[int, int] + dict in form of {dimension_index: slice_index} + For example if an array has shape [1000, 30, 512, 512] corresponding to [t, z, x, y]: + To get the 100th timepoint and 3rd z-plane pass: + {"t": 100, "z": 3}, or {0: 100, 1: 3} + + Returns + ------- + Tuple[slice] + Tuple of slice objects that can be used to fancy index the array + + """ + indexer = [slice(None)] * self.ndim + + for dim in list(slice_indices.keys()): + if isinstance(dim, str): + dim = self.axes_order.index(dim) + indexer[dim] = slice_indices[dim] + + return tuple(indexer) + + def slider_value_changed( + self, + dimension: int, + change: dict + ): + self.current_index[dimension] = change["new"] + self.current_indexer = self.get_indexer(self.current_index) + + self.image_graphic.update_data( + self.data[self.current_indexer] + ) + + def set_slider_layout(self, *args): + w, h = self.plot.renderer.logical_size + for hs in self.horizontal_sliders: + hs.layout = Layout(width=f"{w}px") + + for vs in self.vertical_sliders: + vs.layout = Layout(height=f"{h}px") + + def show(self): + # start render loop + self.plot.show() + + return self.widget From 7cce91f45bd88f8ee4d28b717bb0f5f6fa741e4b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 4 Dec 2022 01:49:58 -0500 Subject: [PATCH 07/21] combined single and grid ImageWidget into single class --- fastplotlib/widgets/image.py | 491 ++++++++++++++++++++++++++--------- 1 file changed, 371 insertions(+), 120 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 73da55d57..2dee563af 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -71,161 +71,115 @@ def __new__( elif isinstance(data, list): pass + else: + raise TypeError( + f"`data` must be of type `numpy.ndarray` representing a single image/image sequence " + f"or a list of `numpy.ndarray` representing a grid of images/image sequences" + ) -class __ImageWidget: + +class _ImageWidgetGrid: """ A high level for displaying n-dimensional image data in conjunction with automatically generated sliders for navigating through 1-2 selected dimensions within the image data. - Can display a single n-dimensional image array or a grid of n-dimensional images. + Can display a grid of n-dimensional images. """ def __init__( self, - data: Union[np.ndarray, List[np.ndarray]], - axes_order: [str, dict] = None, - slider_sync: bool = True, + data: List[np.ndarray], + axes_order: Union[str, Dict[np.ndarray, str]] = None, slider_axes: Union[int, str, dict] = None, slice_avg: Union[int, dict] = None, frame_apply: Union[callable, dict] = None, grid_shape: Tuple[int, int] = None, **kwargs ): - # if single image array - if isinstance(data, np.ndarray): - self.plot_type = Plot - self.data: List[np.ndarray] = [data] - ndim = self.data[0].ndim - - # if list of image arrays, list of lists - elif isinstance(data, list): - # verify that it's a list of np.ndarray - if all([isinstance(d, np.ndarray) for d in data]): - self.plot_type = GridPlot - - if grid_shape is None: - grid_shape = calc_gridshape(len(data)) + # verify that it's a list of np.ndarray + if all([isinstance(d, np.ndarray) for d in data]): + if grid_shape is None: + grid_shape = calc_gridshape(len(data)) - # verify that user-specified grid shape is large enough for the number of image arrays passed - elif grid_shape[0] * grid_shape[1] < len(data): - grid_shape = calc_gridshape(len(data)) - warn(f"Invalid `grid_shape` passed, setting grid shape to: {grid_shape}") + # verify that user-specified grid shape is large enough for the number of image arrays passed + elif grid_shape[0] * grid_shape[1] < len(data): + grid_shape = calc_gridshape(len(data)) + warn(f"Invalid `grid_shape` passed, setting grid shape to: {grid_shape}") - _ndim = [d.ndim for d in data] + _ndim = [d.ndim for d in data] - # verify that all image arrays have same number of dimensions - # sliders get messy otherwise - if not len(set(_ndim)) == 1: - raise ValueError( - f"Number of dimensions of all data arrays must match, your ndims are: {_ndim}" - ) + # verify that all image arrays have same number of dimensions + # sliders get messy otherwise + if not len(set(_ndim)) == 1: + raise ValueError( + f"Number of dimensions of all data arrays must match, your ndims are: {_ndim}" + ) - self.data: List[np.ndarray] = data - ndim = self.data[0].ndim + self.data: List[np.ndarray] = data + self.ndim = self.data[0].ndim else: raise TypeError( - f"`data` must be of type `numpy.ndarray` representing a single image/image sequence " - f"or a list of `numpy.ndarray` representing a grid of images/image sequences" + f"`data` must be a list of `numpy.ndarray` representing a grid of images/image sequences" ) # default axes order if not passed if axes_order is None: - self.axes_order: str = [DEFAULT_AXES_ORDER[ndim] for i in range(len(data))] + self.axes_order: List[str] = [DEFAULT_AXES_ORDER[self.ndim]] * len(self.data) - else: - self.axes_order = axes_order + elif isinstance(axes_order, str): + self.axes_order: List[str] = [axes_order] * len(self.data) + elif isinstance(axes_order, dict): + self.axes_order: List[str] = [DEFAULT_AXES_ORDER[self.ndim]] * len(self.data) + + # dict of {array: axis_order_str} + for array in list(axes_order.keys()): + # get index of corresponding array in data list + i = self.data.index(array) + # set corresponding axes order from passed `axes_order` dict + self.axes_order[i] = axes_order[array] - # make a slider for "t", the time dimension, if slider_axes is not provided + # by default slider is only made for "t" - time dimension if slider_axes is None: - self._slider_axes = self.axes_order.index("t") + slider_axes = self.axes_order.index("t") - # if a single axes is provided for the slider + # slider for only one of the dimensions if isinstance(slider_axes, (int, str)): - if isinstance(slider_axes, (int)): - self._slider_axes = slider_axes - - # if a single one is provided but it is a string, get the integer dimension index from the axes_order + if isinstance(slider_axes, int): + self.slider_axes = [slider_axes] elif isinstance(slider_axes, str): - self._slider_axes = self.axes_order.index(slider_axes) - - # a single slider for the desired axis/dimension - self.slider: IntSlider = IntSlider( - min=0, - max=data.shape[self._slider_axes] - 1, - value=0, - step=1, - description=f"slider axis: {self._slider_axes}" - ) - - # individual slider for each data array - # TODO: not tested and fully implemented yet - elif isinstance(slider_axes, dict): - if not len(slider_axes.keys()) == len(self.data): - raise ValueError( - f"Must provide slider_axes entry for every input `data` array" - ) - - if not isinstance(axes_order, dict): - raise ValueError("Must pass `axes_order` dict if passing a dict of `slider_axes`") - - if not len(axes_order.keys()) == len(self.data): - raise ValueError( - f"Must provide `axes_order` entry for every input `data` array" - ) - - # convert str type desired slider axes to dimension index integers - # match to the given axes_order dict - _axes = [ - self.axes_order[array].index(slider_axes[array]) - if isinstance(dim_index, str) - else dim_index - for - array, dim_index in slider_axes.items() - ] - - self.sliders: Dict[IntSlider] = { - array: IntSlider( - min=0, - max=array.shape[dim] -1, - step=1, - value=0, - ) - for array, dim in self.axes_order.items() - } - - # finally create the plot and slider for a single image array - if self.plot_type == Plot: - self.plot = Plot() - - # get slice object for dynamically indexing chosen dimension from `self._slider_axes` - slice_index = get_indexer(ndim, self._slider_axes, slice_index=0) + if slider_axes not in self.axes_order: + raise ValueError( + f"if `slider_axes` is a , it must be a character found in `axes_order`. " + f"Your `axes_order` is currently: {self.axes_order}." + ) + self.slider_axes = [self.axes_order.index(slider_axes)] - # create image graphic - self.image_graphics: List[Image] = [self.plot.image( - data=self.data[0][slice_index], - **kwargs - )] - - # update frame w.r.t. slider index - self.slider.observe( - lambda x: self.image_graphics[0].update_data( - self.data[0][ - get_indexer(ndim, self._slider_axes, slice_index=x["new"]) - ] - ), - names="value" - ) + # multiple sliders, one for each dimension + elif isinstance(slider_axes, list): + self.slider_axes: List[int] = list() + for sax in slider_axes: + if isinstance(sax, int): + self.slider_axes.append(sax) - self.plot.renderer.add_event_handler(self._set_frame_slider_width, "resize") + # parse the str into a int + elif isinstance(sax, str): + if sax not in self.axes_order: + raise ValueError( + f"if `slider_axes` is a , it must be a character found in `axes_order`. " + f"Your `axes_order` is currently: {self.axes_order}." + ) + self.slider_axes.append( + self.axes_order.index(sax) + ) - self.widget = VBox([self.plot.canvas, self.slider]) + else: + raise TypeError( + "If passing a list for `slider_axes` each element must be either an or " + ) - elif self.plot_type == GridPlot: - pass + else: + raise TypeError(f"`slider_axes` must a , or , you have passed a: {type(slider_axes)}") - def _set_frame_slider_width(self, *args): - w, h = self.plot.renderer.logical_size - self.slider.layout = Layout(width=f"{w}px") def slider_changed(self): pass @@ -242,7 +196,7 @@ def __init__( data: np.ndarray, axes_order: str = None, slider_axes: Union[str, int, List[Union[str, int]]] = None, - slice_average: Dict[Union[int, str], int] = None, + slice_avg: Dict[Union[int, str], int] = None, frame_apply: Union[callable, Dict[Union[int, str], callable]] = None, **kwargs ): @@ -261,9 +215,11 @@ def __init__( raise TypeError(f"`axes_order` must be a , you have passed a: <{type(axes_order)}>") self.axes_order = axes_order + # by default slider is only made for "t" - time dimension if slider_axes is None: slider_axes = self.axes_order.index("t") + # slider for only one of the dimensions if isinstance(slider_axes, (int, str)): if isinstance(slider_axes, int): self.slider_axes = [slider_axes] @@ -275,6 +231,7 @@ def __init__( ) self.slider_axes = [self.axes_order.index(slider_axes)] + # multiple sliders, one for each dimension elif isinstance(slider_axes, list): self.slider_axes: List[int] = list() for sax in slider_axes: @@ -414,3 +371,297 @@ def show(self): self.plot.show() return self.widget + + +class ImageWidgetGrid: + """Single n-dimension image with slider(s)""" + def __init__( + self, + data: np.ndarray, + axes_order: str = None, + slider_axes: Union[str, int, List[Union[str, int]]] = None, + slice_avg: Dict[Union[int, str], int] = None, + frame_apply: Union[callable, Dict[Union[int, str], callable]] = None, + grid_shape: Tuple[int, int] = None, + **kwargs + ): + if isinstance(data, list): + # verify that it's a list of np.ndarray + if all([is_arraylike(d) for d in data]): + if grid_shape is None: + grid_shape = calc_gridshape(len(data)) + + # verify that user-specified grid shape is large enough for the number of image arrays passed + elif grid_shape[0] * grid_shape[1] < len(data): + grid_shape = calc_gridshape(len(data)) + warn(f"Invalid `grid_shape` passed, setting grid shape to: {grid_shape}") + + _ndim = [d.ndim for d in data] + + # verify that all image arrays have same number of dimensions + # sliders get messy otherwise + if not len(set(_ndim)) == 1: + raise ValueError( + f"Number of dimensions of all data arrays must match, your ndims are: {_ndim}" + ) + + self.data: List[np.ndarray] = data + self.ndim = self.data[0].ndim # all ndim must be same + + self.plot_type = "grid" + + else: + raise TypeError( + f"`data` must be a list of `numpy.ndarray` representing a grid of images/image sequences" + ) + + elif is_arraylike(data): + self.data = [data] + self.ndim = self.data[0].ndim + + self.plot_type = "single" + else: + raise TypeError( + f"`data` must be of type `numpy.ndarray` representing a single image/image sequence " + f"or a list of `numpy.ndarray` representing a grid of images/image sequences" + ) + + # default axes order if not passed + if axes_order is None: + self.axes_order: List[str] = [DEFAULT_AXES_ORDER[self.ndim]] * len(self.data) + + elif isinstance(axes_order, str): + self.axes_order: List[str] = [axes_order] * len(self.data) + elif isinstance(axes_order, dict): + self.axes_order: List[str] = [DEFAULT_AXES_ORDER[self.ndim]] * len(self.data) + + # dict of {array: axis_order_str} + for array in list(axes_order.keys()): + # get index of corresponding array in data list + try: + i = self.data.index(array) + except Exception: + raise TypeError( + f"`axes_order` must be a or , " + f"with the same array object(s) passed to `data`" + ) + # set corresponding axes order from passed `axes_order` dict + if not set(axes_order[array]) == set(DEFAULT_AXES_ORDER[self.ndim]): + raise ValueError( + f"Invalid axis order passed for one of your arrays, " + f"valid axis order for given number of dimensions " + f"can only contain the following characters: " + f"{DEFAULT_AXES_ORDER[self.ndim]}" + ) + self.axes_order[i] = axes_order[array] + else: + raise TypeError(f"`axes_order` must be a or , you have passed a: <{type(axes_order)}>") + + ao = np.array([sorted(v) for v in self.axes_order]) + + if not np.all(ao == ao[0]): + raise ValueError( + f"`axes_order` for all arrays must contain the same combination of dimensions, your `axes_order` are: " + f"{self.axes_order}" + ) + + # by default slider is only made for "t" - time dimension + if slider_axes is None: + slider_axes = "t" + + # slider for only one of the dimensions + if isinstance(slider_axes, (int, str)): + # if numerical dimension is specified + if isinstance(slider_axes, int): + ao = np.array([v for v in self.axes_order]) + if not np.all(ao == ao[0]): + raise ValueError( + f"`axes_order` for all arrays must be identical if passing in a `slider_axes` argument. " + f"Pass in a argument if the `axes_order` are different for each array." + ) + self.slider_axes: List[str] = [self.axes_order[0][slider_axes]] + + # if dimension specified by str + elif isinstance(slider_axes, str): + if slider_axes not in self.axes_order[0]: + raise ValueError( + f"if `slider_axes` is a , it must be a character found in `axes_order`. " + f"Your `axes_order` characters are: {set(self.axes_order[0])}." + ) + self.slider_axes: List[str] = [slider_axes] + + # multiple sliders, one for each dimension + elif isinstance(slider_axes, list): + self.slider_axes: List[str] = list() + + for sax in slider_axes: + if isinstance(sax, int): + ao = np.array([v for v in self.axes_order]) + if not np.all(ao == ao[0]): + raise ValueError( + f"`axes_order` for all arrays must be identical if passing in a `slider_axes` argument. " + f"Pass in a argument if the `axes_order` are different for each array." + ) + # parse int to a str + self.slider_axes.append(self.axes_order[0][sax]) + + elif isinstance(sax, str): + if sax not in self.axes_order[0]: + raise ValueError( + f"if `slider_axes` is a , it must be a character found in `axes_order`. " + f"Your `axes_order` characters are: {set(self.axes_order[0])}." + ) + self.slider_axes.append(sax) + + else: + raise TypeError( + "If passing a list for `slider_axes` each element must be either an or " + ) + + else: + raise TypeError(f"`slider_axes` must a , or , you have passed a: {type(slider_axes)}") + + self.sliders = list() + self.vertical_sliders = list() + self.horizontal_sliders = list() + + # current_index stores {dimension_index: slice_index} for every dimension + self.current_index: Dict[str, int] = {sax: 0 for sax in self.slider_axes} + + if self.plot_type == "single": + self.plot: Plot = Plot() + + frame = self.get_2d_slice(data[0], slice_indices=self.current_index) + + self.image_graphics: List[Image] = self.plot.image(data=frame, **kwargs) + + elif self.plot_type == "grid": + self.plot: GridPlot = GridPlot(shape=grid_shape, controllers="sync") + + for d, subplot in zip(self.data, self.plot): + self.image_graphics = list() + frame = self.get_2d_slice(d, slice_indices=self.current_index) + ig = Image(frame, **kwargs) + subplot.add_graphic(ig) + self.image_graphics.append(ig) + + self.plot.renderer.add_event_handler(self.set_slider_layout, "resize") + + # get max bound for all sliders using the max index for that dim from all arrays + slider_axes_max = {k: np.inf for k in self.slider_axes} + for axis in list(slider_axes_max.keys()): + for array, order in zip(self.data, self.axes_order): + slider_axes_max[axis] = min(slider_axes_max[axis], array.shape[order.index(axis)]) + + for sax in self.slider_axes: + if sax == "z": + # TODO: once ipywidgets plays nicely with HBox and jupyter-rfb can use vertical + # orientation = "vertical" + orientation = "horizontal" + else: + orientation = "horizontal" + + slider = IntSlider( + min=0, + max=slider_axes_max[sax], + step=1, + value=0, + description=f"Axis: {sax}", + orientation=orientation + ) + + slider.observe( + partial(self.slider_value_changed, sax), + names="value" + ) + + self.sliders.append(slider) + if orientation == "horizontal": + self.horizontal_sliders.append(slider) + elif orientation == "vertical": + self.vertical_sliders.append(slider) + + # TODO: there is currently an issue with ipywidgets or jupyter-rfb and HBox doesn't work with RFB canvas + # self.widget = None + # hbox = None + # if len(self.vertical_sliders) > 0: + # hbox = HBox(self.vertical_sliders) + # + # if len(self.horizontal_sliders) > 0: + # if hbox is not None: + # self.widget = VBox([ + # HBox([self.plot.canvas, hbox]), + # *self.horizontal_sliders, + # ]) + # + # else: + # self.widget = VBox([self.plot.canvas, *self.horizontal_sliders]) + + # TODO: So just stack everything vertically for now + self.widget = VBox([ + self.plot.canvas, + *self.sliders + ]) + + def get_2d_slice( + self, array: np.ndarray, + slice_indices: dict[Union[int, str], int] + ) -> np.ndarray: + """ + Get the 2D array from the given slice indices. + + Parameters + ---------- + array: np.ndarray + array-like to get a 2D slice from + + slice_indices: dict[int, int] + dict in form of {dimension_index: slice_index} + For example if an array has shape [1000, 30, 512, 512] corresponding to [t, z, x, y]: + To get the 100th timepoint and 3rd z-plane pass: + {"t": 100, "z": 3}, or {0: 100, 1: 3} + + Returns + ------- + np.ndarray + array-like, 2D slice + + Examples + -------- + img = get_2d_slice(a, {"t": 50, "z": 4}) + # img is a 2d plane at time index 50 and z-plane 4 + + """ + indexer = [slice(None)] * self.ndim + + for dim in list(slice_indices.keys()): + if isinstance(dim, str): + dim = self.axes_order.index(dim) + indexer[dim] = slice_indices[dim] + + return array[tuple(indexer)] + + def slider_value_changed( + self, + dimension: int, + change: dict + ): + self.current_index[dimension] = change["new"] + + for ig, data in zip(self.image_graphics, self.data): + frame = self.get_2d_slice(data, self.current_index) + ig.update_data(frame) + + def set_slider_layout(self, *args): + w, h = self.plot.renderer.logical_size + for hs in self.horizontal_sliders: + hs.layout = Layout(width=f"{w}px") + + for vs in self.vertical_sliders: + vs.layout = Layout(height=f"{h}px") + + def show(self): + # start render loop + self.plot.show() + + return self.widget From 56a9b1c6c3e65b27bcc72516ba26a3360b68811c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 4 Dec 2022 03:18:44 -0500 Subject: [PATCH 08/21] simple and grid image widget works, tested with simple args, need to try the crazier combinations --- examples/imagewidget.ipynb | 108 ++++++++-- fastplotlib/widgets/image.py | 393 ++++------------------------------- 2 files changed, 133 insertions(+), 368 deletions(-) diff --git a/examples/imagewidget.ipynb b/examples/imagewidget.ipynb index 852b80080..4f56cf473 100644 --- a/examples/imagewidget.ipynb +++ b/examples/imagewidget.ipynb @@ -27,7 +27,7 @@ "metadata": {}, "outputs": [], "source": [ - "from fastplotlib.widgets import ImageWidget\n", + "from fastplotlib.widgets.image import ImageWidget\n", "import numpy as np" ] }, @@ -38,37 +38,117 @@ "metadata": {}, "outputs": [], "source": [ - "a = np.random.rand(1000, 512, 512)" + "a = np.random.rand(100, 5, 512, 512)\n", + "b = np.random.rand(100, 5, 512, 512)\n", + "c = np.random.rand(100, 5, 512, 512)\n", + "d = np.random.rand(100, 5, 512, 512)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "f1ade87e-c5bf-4258-9e5a-89d5cd41f348", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "3783d46da0c2448a82e7209ccf48b0c8", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "RFBOutputContext()" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "iw = ImageWidget(a, slider_axes=[0, 1], cmap=\"gnuplot2\")" ] }, { "cell_type": "code", "execution_count": 6, + "id": "4ad670f9-53d7-4499-9d50-5ae2ef838f25", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "91eb46fd92e8431ea22b78bfd687d0b7", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "VBox(children=(JupyterWgpuCanvas(), IntSlider(value=0, description='Axis: t', max=99), IntSlider(value=0, desc…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "iw.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 7, "id": "8b910f06-2cf4-4363-8b58-71c32d6f9c64", "metadata": {}, "outputs": [ { - "ename": "ValueError", - "evalue": "'t' is not in list", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[0;32mIn [6], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m iw \u001b[38;5;241m=\u001b[39m \u001b[43mImageWidget\u001b[49m\u001b[43m(\u001b[49m\u001b[43ma\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mcmap\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mgnuplot2\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n", - "File \u001b[0;32m~/repos/fastplotlib/fastplotlib/widgets/image.py:98\u001b[0m, in \u001b[0;36mImageWidget.__init__\u001b[0;34m(self, data, axes_order, slider_sync, slider_axes, slice_avg, frame_apply, grid_shape, **kwargs)\u001b[0m\n\u001b[1;32m 96\u001b[0m \u001b[38;5;66;03m# make a slider for \"t\", the time dimension, if slider_axes is not provided\u001b[39;00m\n\u001b[1;32m 97\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m slider_axes \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[0;32m---> 98\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39m_slider_axes \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43maxes_order\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mindex\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mt\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n\u001b[1;32m 100\u001b[0m \u001b[38;5;66;03m# if a single axes is provided for the slider\u001b[39;00m\n\u001b[1;32m 101\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28misinstance\u001b[39m(slider_axes, (\u001b[38;5;28mint\u001b[39m, \u001b[38;5;28mstr\u001b[39m)):\n", - "\u001b[0;31mValueError\u001b[0m: 't' is not in list" - ] + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "9194bd04719b4665bfc33e912474659b", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "RFBOutputContext()" + ] + }, + "metadata": {}, + "output_type": "display_data" } ], "source": [ - "iw = ImageWidget(a, cmap=\"gnuplot2\")" + "iw = ImageWidget([a, b, c, d], slider_axes=[\"t\", \"z\"], axes_order=\"tzxy\", cmap=\"gnuplot2\")" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "id": "f1be6cd2-9263-4da0-9280-48444dd74c1f", "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "c803b14974eb45c6b2a17be83faccc39", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "VBox(children=(JupyterWgpuCanvas(), IntSlider(value=0, description='Axis: t', max=99), IntSlider(value=0, desc…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "iw.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2f39df8c-5248-471e-a05d-5cf667da138e", + "metadata": {}, "outputs": [], "source": [] } diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 2dee563af..cf0bd8ecb 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -19,17 +19,11 @@ def calc_gridshape(n): sr = np.sqrt(n) return ( - np.ceil(sr), - np.round(sr) + int(np.ceil(sr)), + int(np.round(sr)) ) -def get_indexer(ndim: int, dim_index: int, slice_index: int) -> slice: - indexer = [slice(None)] * ndim - indexer[dim_index] = slice_index - return tuple(indexer) - - def is_arraylike(obj) -> bool: """ Checks if the object is array-like. @@ -45,336 +39,6 @@ class ImageWidget: Can display a single n-dimensional image array or a grid of n-dimensional images. """ - def __new__( - cls, - data: Union[np.ndarray, List[np.ndarray]], - axes_order: [str, dict] = None, - slider_sync: bool = True, - slider_axes: Union[int, str, dict] = None, - slice_avg: Union[int, dict] = None, - frame_apply: Union[callable, dict] = None, - grid_shape: Tuple[int, int] = None, - **kwargs - ): - # if single image array - if isinstance(data, np.ndarray): - return ImageWidgetSingle( - data, - axes_order, - slider_axes, - slice_avg, - frame_apply, - **kwargs - ) - - # if list of image arrays, list of lists - elif isinstance(data, list): - pass - - else: - raise TypeError( - f"`data` must be of type `numpy.ndarray` representing a single image/image sequence " - f"or a list of `numpy.ndarray` representing a grid of images/image sequences" - ) - - -class _ImageWidgetGrid: - """ - A high level for displaying n-dimensional image data in conjunction with automatically generated sliders for - navigating through 1-2 selected dimensions within the image data. - - Can display a grid of n-dimensional images. - """ - def __init__( - self, - data: List[np.ndarray], - axes_order: Union[str, Dict[np.ndarray, str]] = None, - slider_axes: Union[int, str, dict] = None, - slice_avg: Union[int, dict] = None, - frame_apply: Union[callable, dict] = None, - grid_shape: Tuple[int, int] = None, - **kwargs - ): - # verify that it's a list of np.ndarray - if all([isinstance(d, np.ndarray) for d in data]): - if grid_shape is None: - grid_shape = calc_gridshape(len(data)) - - # verify that user-specified grid shape is large enough for the number of image arrays passed - elif grid_shape[0] * grid_shape[1] < len(data): - grid_shape = calc_gridshape(len(data)) - warn(f"Invalid `grid_shape` passed, setting grid shape to: {grid_shape}") - - _ndim = [d.ndim for d in data] - - # verify that all image arrays have same number of dimensions - # sliders get messy otherwise - if not len(set(_ndim)) == 1: - raise ValueError( - f"Number of dimensions of all data arrays must match, your ndims are: {_ndim}" - ) - - self.data: List[np.ndarray] = data - self.ndim = self.data[0].ndim - - else: - raise TypeError( - f"`data` must be a list of `numpy.ndarray` representing a grid of images/image sequences" - ) - - # default axes order if not passed - if axes_order is None: - self.axes_order: List[str] = [DEFAULT_AXES_ORDER[self.ndim]] * len(self.data) - - elif isinstance(axes_order, str): - self.axes_order: List[str] = [axes_order] * len(self.data) - elif isinstance(axes_order, dict): - self.axes_order: List[str] = [DEFAULT_AXES_ORDER[self.ndim]] * len(self.data) - - # dict of {array: axis_order_str} - for array in list(axes_order.keys()): - # get index of corresponding array in data list - i = self.data.index(array) - # set corresponding axes order from passed `axes_order` dict - self.axes_order[i] = axes_order[array] - - # by default slider is only made for "t" - time dimension - if slider_axes is None: - slider_axes = self.axes_order.index("t") - - # slider for only one of the dimensions - if isinstance(slider_axes, (int, str)): - if isinstance(slider_axes, int): - self.slider_axes = [slider_axes] - elif isinstance(slider_axes, str): - if slider_axes not in self.axes_order: - raise ValueError( - f"if `slider_axes` is a , it must be a character found in `axes_order`. " - f"Your `axes_order` is currently: {self.axes_order}." - ) - self.slider_axes = [self.axes_order.index(slider_axes)] - - # multiple sliders, one for each dimension - elif isinstance(slider_axes, list): - self.slider_axes: List[int] = list() - for sax in slider_axes: - if isinstance(sax, int): - self.slider_axes.append(sax) - - # parse the str into a int - elif isinstance(sax, str): - if sax not in self.axes_order: - raise ValueError( - f"if `slider_axes` is a , it must be a character found in `axes_order`. " - f"Your `axes_order` is currently: {self.axes_order}." - ) - self.slider_axes.append( - self.axes_order.index(sax) - ) - - else: - raise TypeError( - "If passing a list for `slider_axes` each element must be either an or " - ) - - else: - raise TypeError(f"`slider_axes` must a , or , you have passed a: {type(slider_axes)}") - - - def slider_changed(self): - pass - - def show(self): - self.plot.show() - return self.widget - - -class ImageWidgetSingle: - """Single n-dimension image with slider(s)""" - def __init__( - self, - data: np.ndarray, - axes_order: str = None, - slider_axes: Union[str, int, List[Union[str, int]]] = None, - slice_avg: Dict[Union[int, str], int] = None, - frame_apply: Union[callable, Dict[Union[int, str], callable]] = None, - **kwargs - ): - if not is_arraylike(data): - raise TypeError( - f"`data` must be an array-like object" - ) - - self.data = data - self.ndim = self.data.ndim - - if axes_order is None: - self.axes_order: str = DEFAULT_AXES_ORDER[self.ndim] - else: - if not type(axes_order) is str: - raise TypeError(f"`axes_order` must be a , you have passed a: <{type(axes_order)}>") - self.axes_order = axes_order - - # by default slider is only made for "t" - time dimension - if slider_axes is None: - slider_axes = self.axes_order.index("t") - - # slider for only one of the dimensions - if isinstance(slider_axes, (int, str)): - if isinstance(slider_axes, int): - self.slider_axes = [slider_axes] - elif isinstance(slider_axes, str): - if slider_axes not in self.axes_order: - raise ValueError( - f"if `slider_axes` is a , it must be a character found in `axes_order`. " - f"Your `axes_order` is currently: {self.axes_order}." - ) - self.slider_axes = [self.axes_order.index(slider_axes)] - - # multiple sliders, one for each dimension - elif isinstance(slider_axes, list): - self.slider_axes: List[int] = list() - for sax in slider_axes: - if isinstance(sax, int): - self.slider_axes.append(sax) - - # parse the str into a int - elif isinstance(sax, str): - if sax not in self.axes_order: - raise ValueError( - f"if `slider_axes` is a , it must be a character found in `axes_order`. " - f"Your `axes_order` is currently: {self.axes_order}." - ) - self.slider_axes.append( - self.axes_order.index(sax) - ) - - else: - raise TypeError( - "If passing a list for `slider_axes` each element must be either an or " - ) - - else: - raise TypeError(f"`slider_axes` must a , or , you have passed a: {type(slider_axes)}") - - self.plot = Plot() - self.sliders = list() - self.vertical_sliders = list() - self.horizontal_sliders = list() - - # current_index stores {dimension_index: slice_index} for every dimension - self.current_index: Dict[int, int] = {sax: 0 for sax in self.slider_axes} - self.current_indexer = self.get_indexer(self.current_index) - - for sax in self.slider_axes: - if self.axes_order[sax] == "z": - # TODO: once ipywidgets plays nicely with HBox and jupyter-rfb can use vertical - orientation = "horizontal" - else: - orientation = "horizontal" - - slider = IntSlider( - min=0, - max=self.data.shape[sax] - 1, - step=1, - value=0, - description=f"Axis: {self.axes_order[sax]}", - orientation=orientation - ) - - slider.observe( - partial(self.slider_value_changed, sax), - names="value" - ) - - self.sliders.append(slider) - if orientation == "horizontal": - self.horizontal_sliders.append(slider) - elif orientation == "vertical": - self.vertical_sliders.append(slider) - - self.image_graphic: Image = self.plot.image(data=self.data[self.current_indexer]) - - self.plot.renderer.add_event_handler(self.set_slider_layout, "resize") - - # TODO: there is currently an issue with ipywidgets or jupyter-rfb and HBox doesn't work with RFB canvas - # self.widget = None - # hbox = None - # if len(self.vertical_sliders) > 0: - # hbox = HBox(self.vertical_sliders) - # - # if len(self.horizontal_sliders) > 0: - # if hbox is not None: - # self.widget = VBox([ - # HBox([self.plot.canvas, hbox]), - # *self.horizontal_sliders, - # ]) - # - # else: - # self.widget = VBox([self.plot.canvas, *self.horizontal_sliders]) - - # TODO: So just stack everything vertically for now - self.widget = VBox([ - self.plot.canvas, - *self.sliders - ]) - - def get_indexer(self, slice_indices: dict[Union[int, str], int]) -> Tuple[slice]: - """ - Get the slice object to use for dynamically indexing arrays. - - Parameters - ---------- - slice_indices: dict[int, int] - dict in form of {dimension_index: slice_index} - For example if an array has shape [1000, 30, 512, 512] corresponding to [t, z, x, y]: - To get the 100th timepoint and 3rd z-plane pass: - {"t": 100, "z": 3}, or {0: 100, 1: 3} - - Returns - ------- - Tuple[slice] - Tuple of slice objects that can be used to fancy index the array - - """ - indexer = [slice(None)] * self.ndim - - for dim in list(slice_indices.keys()): - if isinstance(dim, str): - dim = self.axes_order.index(dim) - indexer[dim] = slice_indices[dim] - - return tuple(indexer) - - def slider_value_changed( - self, - dimension: int, - change: dict - ): - self.current_index[dimension] = change["new"] - self.current_indexer = self.get_indexer(self.current_index) - - self.image_graphic.update_data( - self.data[self.current_indexer] - ) - - def set_slider_layout(self, *args): - w, h = self.plot.renderer.logical_size - for hs in self.horizontal_sliders: - hs.layout = Layout(width=f"{w}px") - - for vs in self.vertical_sliders: - vs.layout = Layout(height=f"{h}px") - - def show(self): - # start render loop - self.plot.show() - - return self.widget - - -class ImageWidgetGrid: - """Single n-dimension image with slider(s)""" def __init__( self, data: np.ndarray, @@ -439,7 +103,15 @@ def __init__( for array in list(axes_order.keys()): # get index of corresponding array in data list try: - i = self.data.index(array) + data_ix = None + for i in range(len(self.data)): + if self.data[i] is array: + data_ix = i + break + if data_ix is None: + raise ValueError( + f"Given `array` not found in `self.data`" + ) except Exception: raise TypeError( f"`axes_order` must be a or , " @@ -453,7 +125,7 @@ def __init__( f"can only contain the following characters: " f"{DEFAULT_AXES_ORDER[self.ndim]}" ) - self.axes_order[i] = axes_order[array] + self.axes_order[data_ix] = axes_order[array] else: raise TypeError(f"`axes_order` must be a or , you have passed a: <{type(axes_order)}>") @@ -530,16 +202,15 @@ def __init__( if self.plot_type == "single": self.plot: Plot = Plot() + frame = self.get_2d_slice(self.data[0], slice_indices=self.current_index) - frame = self.get_2d_slice(data[0], slice_indices=self.current_index) - - self.image_graphics: List[Image] = self.plot.image(data=frame, **kwargs) + self.image_graphics: List[Image] = [self.plot.image(data=frame, **kwargs)] elif self.plot_type == "grid": self.plot: GridPlot = GridPlot(shape=grid_shape, controllers="sync") + self.image_graphics = list() for d, subplot in zip(self.data, self.plot): - self.image_graphics = list() frame = self.get_2d_slice(d, slice_indices=self.current_index) ig = Image(frame, **kwargs) subplot.add_graphic(ig) @@ -563,7 +234,7 @@ def __init__( slider = IntSlider( min=0, - max=slider_axes_max[sax], + max=slider_axes_max[sax] - 1, step=1, value=0, description=f"Axis: {sax}", @@ -581,6 +252,12 @@ def __init__( elif orientation == "vertical": self.vertical_sliders.append(slider) + # TODO: So just stack everything vertically for now + self.widget = VBox([ + self.plot.canvas, + *self.sliders + ]) + # TODO: there is currently an issue with ipywidgets or jupyter-rfb and HBox doesn't work with RFB canvas # self.widget = None # hbox = None @@ -597,14 +274,9 @@ def __init__( # else: # self.widget = VBox([self.plot.canvas, *self.horizontal_sliders]) - # TODO: So just stack everything vertically for now - self.widget = VBox([ - self.plot.canvas, - *self.sliders - ]) - def get_2d_slice( - self, array: np.ndarray, + self, + array: np.ndarray, slice_indices: dict[Union[int, str], int] ) -> np.ndarray: """ @@ -636,8 +308,21 @@ def get_2d_slice( for dim in list(slice_indices.keys()): if isinstance(dim, str): - dim = self.axes_order.index(dim) - indexer[dim] = slice_indices[dim] + data_ix = None + for i in range(len(self.data)): + if self.data[i] is array: + data_ix = i + break + if data_ix is None: + raise ValueError( + f"Given `array` not found in `self.data`" + ) + # get axes order for that specific array + numerical_dim = self.axes_order[data_ix].index(dim) + else: + numerical_dim = dim + + indexer[numerical_dim] = slice_indices[dim] return array[tuple(indexer)] From 79f445230d5674cfa78bbe6bb96f1e01595a0a72 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 4 Dec 2022 03:26:06 -0500 Subject: [PATCH 09/21] catch another user error --- fastplotlib/widgets/image.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index cf0bd8ecb..e7573220c 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -129,6 +129,12 @@ def __init__( else: raise TypeError(f"`axes_order` must be a or , you have passed a: <{type(axes_order)}>") + if not len(self.axes_order[0]) == self.ndim: + raise ValueError( + f"Number of axes specified by `axes_order`: {len(self.axes_order[0])} does not" + f" match number of dimensions in the `data`: {self.ndim}" + ) + ao = np.array([sorted(v) for v in self.axes_order]) if not np.all(ao == ao[0]): From e4f6b12e2e674951220a3d0d0760e2239893a80a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 4 Dec 2022 03:48:36 -0500 Subject: [PATCH 10/21] fix type annotation --- fastplotlib/widgets/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index e7573220c..e20d2a29d 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -42,7 +42,7 @@ class ImageWidget: def __init__( self, data: np.ndarray, - axes_order: str = None, + axes_order: Union[str, Dict[np.ndarray, str]] = None, slider_axes: Union[str, int, List[Union[str, int]]] = None, slice_avg: Dict[Union[int, str], int] = None, frame_apply: Union[callable, Dict[Union[int, str], callable]] = None, From 5028ce799e63d30bc18774d27b4b407be5701929 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 4 Dec 2022 18:00:38 -0500 Subject: [PATCH 11/21] docstrings, started slice_avg implementation --- fastplotlib/widgets/image.py | 70 +++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index e20d2a29d..2a7033165 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -33,22 +33,60 @@ def is_arraylike(obj) -> bool: class ImageWidget: - """ - A high level for displaying n-dimensional image data in conjunction with automatically generated sliders for - navigating through 1-2 selected dimensions within the image data. - - Can display a single n-dimensional image array or a grid of n-dimensional images. - """ def __init__( self, - data: np.ndarray, + data: Union[np.ndarray, List[np.ndarray]], axes_order: Union[str, Dict[np.ndarray, str]] = None, slider_axes: Union[str, int, List[Union[str, int]]] = None, - slice_avg: Dict[Union[int, str], int] = None, + slice_avg: Union[int, Dict[Union[int, str], int]] = None, frame_apply: Union[callable, Dict[Union[int, str], callable]] = None, grid_shape: Tuple[int, int] = None, **kwargs ): + """ + A high level for displaying n-dimensional image data in conjunction with automatically generated sliders for + navigating through 1-2 selected dimensions within the image data. + + Can display a single n-dimensional image array or a grid of n-dimensional images. + + Default axes orders: + + ======= ========== + n_dims axes order + ======= ========== + 2 "xy" + 3 "txy" + 4 "tzxy" + ======= ========== + + Parameters + ---------- + data: Union[np.ndarray, List[np.ndarray] + array-like or a list of array-like + + axes_order: Optional[Union[str, Dict[np.ndarray, str]]] + | a single ``str`` if ``data`` is a single array or if List[data] all have the same axes order + | dict mapping of ``{array: axis_order}`` if specific arrays have a non-default axes order. + + slider_axes: Optional[Union[str, int, List[Union[str, int]]]] + | The axes/dimensions for which to create a slider + | can be a single ``str`` such as "t", "z" etc. or a numerical ``int`` that indexes the desired dimension + | can also be a list of ``str`` or ``int`` if multiple sliders are desired for multiple dimensions + + slice_avg: Dict[Union[int, str], int] + | average one or more dimensions using a given window + | dict mapping of ``{dimension: window_size}`` + | dimension/axes can be specified using ``str`` such as "t", "z" etc. or ``int`` that indexes the dimension + | if window_size is not an odd number, adds 1 + | use ``window_size = 0`` to disable averaging for a dimension, example: ``{"t": 5, "z": 0}`` + + frame_apply + grid_shape: Optional[Tuple[int, int]] + manually provide the shape for a gridplot, otherwise a square gridplot is approximated. + + kwargs: Any + passed to fastplotlib.graphics.Image + """ if isinstance(data, list): # verify that it's a list of np.ndarray if all([is_arraylike(d) for d in data]): @@ -172,6 +210,18 @@ def __init__( elif isinstance(slider_axes, list): self.slider_axes: List[str] = list() + if slice_avg is not None: + if not isinstance(slice_avg, dict): + raise TypeError( + f"`slice_avg` must be a if multiple `slider_axes` are provided. You must specify the " + f"window for each dimension." + ) + if not isinstance(frame_apply, dict): + raise TypeError( + f"`frame_apply` must be a if multiple `slider_axes` are provided. You must specify a " + f"function for each dimension." + ) + for sax in slider_axes: if isinstance(sax, int): ao = np.array([v for v in self.axes_order]) @@ -208,6 +258,10 @@ def __init__( if self.plot_type == "single": self.plot: Plot = Plot() + + if slice_avg is not None: + pass + frame = self.get_2d_slice(self.data[0], slice_indices=self.current_index) self.image_graphics: List[Image] = [self.plot.image(data=frame, **kwargs)] From 5ef32ebe902630c5e4942a0ba97160ecaf819c79 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 4 Dec 2022 23:44:38 -0500 Subject: [PATCH 12/21] slice averaging on single and multiple dimensions works perfectly --- fastplotlib/widgets/image.py | 137 +++++++++++++++++++++++++++++------ 1 file changed, 114 insertions(+), 23 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 2a7033165..fc12a0d6e 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -38,7 +38,7 @@ def __init__( data: Union[np.ndarray, List[np.ndarray]], axes_order: Union[str, Dict[np.ndarray, str]] = None, slider_axes: Union[str, int, List[Union[str, int]]] = None, - slice_avg: Union[int, Dict[Union[int, str], int]] = None, + slice_avg: Union[int, Dict[str, int]] = None, frame_apply: Union[callable, Dict[Union[int, str], callable]] = None, grid_shape: Tuple[int, int] = None, **kwargs @@ -75,10 +75,11 @@ def __init__( slice_avg: Dict[Union[int, str], int] | average one or more dimensions using a given window - | dict mapping of ``{dimension: window_size}`` + | if a slider exists for only one dimension this can be an ``int``. + | if multiple sliders exist, then it must be a `dict`` mapping in the form of: ``{dimension: window_size}`` | dimension/axes can be specified using ``str`` such as "t", "z" etc. or ``int`` that indexes the dimension | if window_size is not an odd number, adds 1 - | use ``window_size = 0`` to disable averaging for a dimension, example: ``{"t": 5, "z": 0}`` + | use ``None`` to disable averaging for a dimension, example: ``{"t": 5, "z": None}`` frame_apply grid_shape: Optional[Tuple[int, int]] @@ -210,17 +211,17 @@ def __init__( elif isinstance(slider_axes, list): self.slider_axes: List[str] = list() - if slice_avg is not None: - if not isinstance(slice_avg, dict): - raise TypeError( - f"`slice_avg` must be a if multiple `slider_axes` are provided. You must specify the " - f"window for each dimension." - ) - if not isinstance(frame_apply, dict): - raise TypeError( - f"`frame_apply` must be a if multiple `slider_axes` are provided. You must specify a " - f"function for each dimension." - ) + # make sure slice_avg and frame_apply are dicts if multiple sliders are desired + if (not isinstance(slice_avg, dict)) and (slice_avg is not None): + raise TypeError( + f"`slice_avg` must be a if multiple `slider_axes` are provided. You must specify the " + f"window for each dimension." + ) + if (not isinstance(frame_apply, dict)) and (frame_apply is not None): + raise TypeError( + f"`frame_apply` must be a if multiple `slider_axes` are provided. You must specify a " + f"function for each dimension." + ) for sax in slider_axes: if isinstance(sax, int): @@ -249,6 +250,9 @@ def __init__( else: raise TypeError(f"`slider_axes` must a , or , you have passed a: {type(slider_axes)}") + self._slice_avg = None + self.slice_avg = slice_avg + self.sliders = list() self.vertical_sliders = list() self.horizontal_sliders = list() @@ -256,6 +260,12 @@ def __init__( # current_index stores {dimension_index: slice_index} for every dimension self.current_index: Dict[str, int] = {sax: 0 for sax in self.slider_axes} + # get max bound for all data arrays for all dimensions + self.axes_max_bounds: Dict[str, int] = {k: np.inf for k in self.slider_axes} + for axis in list(self.axes_max_bounds.keys()): + for array, order in zip(self.data, self.axes_order): + self.axes_max_bounds[axis] = min(self.axes_max_bounds[axis], array.shape[order.index(axis)]) + if self.plot_type == "single": self.plot: Plot = Plot() @@ -278,12 +288,6 @@ def __init__( self.plot.renderer.add_event_handler(self.set_slider_layout, "resize") - # get max bound for all sliders using the max index for that dim from all arrays - slider_axes_max = {k: np.inf for k in self.slider_axes} - for axis in list(slider_axes_max.keys()): - for array, order in zip(self.data, self.axes_order): - slider_axes_max[axis] = min(slider_axes_max[axis], array.shape[order.index(axis)]) - for sax in self.slider_axes: if sax == "z": # TODO: once ipywidgets plays nicely with HBox and jupyter-rfb can use vertical @@ -294,7 +298,7 @@ def __init__( slider = IntSlider( min=0, - max=slider_axes_max[sax] - 1, + max=self.axes_max_bounds[sax] - 1, step=1, value=0, description=f"Axis: {sax}", @@ -334,6 +338,47 @@ def __init__( # else: # self.widget = VBox([self.plot.canvas, *self.horizontal_sliders]) + @property + def slice_avg(self) -> Union[int, Dict[str, int]]: + return self._slice_avg + + @slice_avg.setter + def slice_avg(self, sa: Union[int, Dict[str, int]]): + if sa is None: + self._slice_avg = None + return + + # for a single dim + elif isinstance(sa, int): + if sa < 3: + self._slice_avg = None + warn(f"Invalid ``slice_avg`` value, setting ``slice_avg = None``. Valid values are integers >= 3.") + return + if sa % 2 == 0: + self._slice_avg = sa + 1 + else: + self._slice_avg = sa + # for multiple dims + elif isinstance(sa, dict): + self._slice_avg = dict() + for k in list(sa.keys()): + if sa[k] is None: + self._slice_avg[k] = None + elif (sa[k] < 3): + warn( + f"Invalid ``slice_avg`` value, setting ``slice_avg = None``. Valid values are integers >= 3." + ) + self._slice_avg[k] = None + elif sa[k] % 2 == 0: + self._slice_avg[k] = sa[k] + 1 + else: + self._slice_avg[k] = sa[k] + else: + raise TypeError( + f"`slice_avg` must be of type `int` if using a single slider or a dict if using multiple sliders. " + f"You have passed a {type(sa)}. See the docstring." + ) + def get_2d_slice( self, array: np.ndarray, @@ -366,6 +411,7 @@ def get_2d_slice( """ indexer = [slice(None)] * self.ndim + numerical_dims = list() for dim in list(slice_indices.keys()): if isinstance(dim, str): data_ix = None @@ -382,9 +428,54 @@ def get_2d_slice( else: numerical_dim = dim - indexer[numerical_dim] = slice_indices[dim] + indices_dim = slice_indices[dim] + + # takes care of averaging if it was specified + indices_dim = self._process_dim_index(data_ix, numerical_dim, indices_dim) - return array[tuple(indexer)] + # set the indices for this dimension + indexer[numerical_dim] = indices_dim + + numerical_dims.append(numerical_dim) + + if self.slice_avg is not None: + a = array + for i, dim in enumerate(sorted(numerical_dims)): + dim = dim - i # since we loose a dimension every iteration + _indexer = [slice(None)] * (self.ndim - i) + _indexer[dim] = indexer[dim + i] + if isinstance(_indexer[dim], int): + a = a[tuple(_indexer)] + else: + a = np.mean(a[tuple(_indexer)], axis=dim) + return a + else: + return array[tuple(indexer)] + + def _process_dim_index(self, data_ix, dim, indices_dim): + if self.slice_avg is None: + return indices_dim + + else: + ix = indices_dim + + # if there is only a single dimension for averaging + if isinstance(self.slice_avg, int): + sa = self.slice_avg + dim_str = self.axes_order[0][dim] + + # if there are multiple dims to average, get the avg for the current dim in the loop + elif isinstance(self.slice_avg, dict): + dim_str = self.axes_order[data_ix][dim] + sa = self.slice_avg[dim_str] + if (sa == 0) or (sa is None): + return indices_dim + + hw = int((sa - 1) / 2) # half-window size + # get the max bound for that dimension + max_bound = self.axes_max_bounds[dim_str] + indices_dim = range(max(0, ix - hw), min(max_bound, ix + hw)) + return indices_dim def slider_value_changed( self, From eca659926d3708887a03c8e4cfd2dbdfaf89f46d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 8 Dec 2022 01:00:19 -0500 Subject: [PATCH 13/21] is_array() checks for and attr, better error messages --- fastplotlib/widgets/image.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index fc12a0d6e..f224a6a9c 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -29,7 +29,15 @@ def is_arraylike(obj) -> bool: Checks if the object is array-like. For now just checks if obj has `__getitem__()` """ - return hasattr(obj, "__getitem__") + for attr in [ + "__getitem__", + "shape", + "ndim" + ]: + if not hasattr(obj, attr): + return False + + return True class ImageWidget: @@ -115,7 +123,8 @@ def __init__( else: raise TypeError( - f"`data` must be a list of `numpy.ndarray` representing a grid of images/image sequences" + f"If passing a list to `data` all elements must be an " + f"array-like type representing an n-dimensional image" ) elif is_arraylike(data): @@ -125,8 +134,8 @@ def __init__( self.plot_type = "single" else: raise TypeError( - f"`data` must be of type `numpy.ndarray` representing a single image/image sequence " - f"or a list of `numpy.ndarray` representing a grid of images/image sequences" + f"`data` must be an array-like type representing an n-dimensional image " + f"or a list of array-like representing a grid of n-dimensional images" ) # default axes order if not passed From 0e3cf16517d2c44d7a40b96f3069e1c863e6a49c Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 9 Dec 2022 22:46:34 -0500 Subject: [PATCH 14/21] rename axis -> dims --- fastplotlib/widgets/image.py | 157 ++++++++++++++++++----------------- 1 file changed, 80 insertions(+), 77 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index f224a6a9c..b7d90197a 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -7,7 +7,7 @@ from warnings import warn from functools import partial -DEFAULT_AXES_ORDER = \ +DEFAULT_DIMS_ORDER = \ { 2: "xy", 3: "txy", @@ -44,8 +44,8 @@ class ImageWidget: def __init__( self, data: Union[np.ndarray, List[np.ndarray]], - axes_order: Union[str, Dict[np.ndarray, str]] = None, - slider_axes: Union[str, int, List[Union[str, int]]] = None, + dims_order: Union[str, Dict[np.ndarray, str]] = None, + slider_dims: Union[str, int, List[Union[str, int]]] = None, slice_avg: Union[int, Dict[str, int]] = None, frame_apply: Union[callable, Dict[Union[int, str], callable]] = None, grid_shape: Tuple[int, int] = None, @@ -57,10 +57,10 @@ def __init__( Can display a single n-dimensional image array or a grid of n-dimensional images. - Default axes orders: + Default dimension orders: ======= ========== - n_dims axes order + n_dims dims order ======= ========== 2 "xy" 3 "txy" @@ -72,14 +72,17 @@ def __init__( data: Union[np.ndarray, List[np.ndarray] array-like or a list of array-like - axes_order: Optional[Union[str, Dict[np.ndarray, str]]] - | a single ``str`` if ``data`` is a single array or if List[data] all have the same axes order - | dict mapping of ``{array: axis_order}`` if specific arrays have a non-default axes order. + dims_order: Optional[Union[str, Dict[np.ndarray, str]]] + | ``str`` or a dict mapping to indicate dimension order + | a single ``str`` if ``data`` is a single array, or a list of arrays with the same dimension order + | ``dict`` mapping of ``{array: axis_order}`` if specific arrays have a non-default axes order. + | examples: "xyt", "tzxy" - slider_axes: Optional[Union[str, int, List[Union[str, int]]]] - | The axes/dimensions for which to create a slider - | can be a single ``str`` such as "t", "z" etc. or a numerical ``int`` that indexes the desired dimension + slider_dims: Optional[Union[str, int, List[Union[str, int]]]] + | The dimensions for which to create a slider + | can be a single ``str`` such as **"t"**, **"z"** or a numerical ``int`` that indexes the desired dimension | can also be a list of ``str`` or ``int`` if multiple sliders are desired for multiple dimensions + | examples: "t", ["t", "z"] slice_avg: Dict[Union[int, str], int] | average one or more dimensions using a given window @@ -138,17 +141,17 @@ def __init__( f"or a list of array-like representing a grid of n-dimensional images" ) - # default axes order if not passed - if axes_order is None: - self.axes_order: List[str] = [DEFAULT_AXES_ORDER[self.ndim]] * len(self.data) + # default dims order if not passed + if dims_order is None: + self.dims_order: List[str] = [DEFAULT_DIMS_ORDER[self.ndim]] * len(self.data) - elif isinstance(axes_order, str): - self.axes_order: List[str] = [axes_order] * len(self.data) - elif isinstance(axes_order, dict): - self.axes_order: List[str] = [DEFAULT_AXES_ORDER[self.ndim]] * len(self.data) + elif isinstance(dims_order, str): + self.dims_order: List[str] = [dims_order] * len(self.data) + elif isinstance(dims_order, dict): + self.dims_order: List[str] = [DEFAULT_DIMS_ORDER[self.ndim]] * len(self.data) - # dict of {array: axis_order_str} - for array in list(axes_order.keys()): + # dict of {array: dims_order_str} + for array in list(dims_order.keys()): # get index of corresponding array in data list try: data_ix = None @@ -162,102 +165,102 @@ def __init__( ) except Exception: raise TypeError( - f"`axes_order` must be a or , " + f"`dims_order` must be a or , " f"with the same array object(s) passed to `data`" ) - # set corresponding axes order from passed `axes_order` dict - if not set(axes_order[array]) == set(DEFAULT_AXES_ORDER[self.ndim]): + # set corresponding dims order from passed `dims_order` dict + if not set(dims_order[array]) == set(DEFAULT_DIMS_ORDER[self.ndim]): raise ValueError( - f"Invalid axis order passed for one of your arrays, " - f"valid axis order for given number of dimensions " + f"Invalid `dims_order` passed for one of your arrays, " + f"valid `dims_order` for given number of dimensions " f"can only contain the following characters: " - f"{DEFAULT_AXES_ORDER[self.ndim]}" + f"{DEFAULT_DIMS_ORDER[self.ndim]}" ) - self.axes_order[data_ix] = axes_order[array] + self.dims_order[data_ix] = dims_order[array] else: - raise TypeError(f"`axes_order` must be a or , you have passed a: <{type(axes_order)}>") + raise TypeError(f"`dims_order` must be a or , you have passed a: <{type(dims_order)}>") - if not len(self.axes_order[0]) == self.ndim: + if not len(self.dims_order[0]) == self.ndim: raise ValueError( - f"Number of axes specified by `axes_order`: {len(self.axes_order[0])} does not" + f"Number of dims specified by `dims_order`: {len(self.dims_order[0])} does not" f" match number of dimensions in the `data`: {self.ndim}" ) - ao = np.array([sorted(v) for v in self.axes_order]) + ao = np.array([sorted(v) for v in self.dims_order]) if not np.all(ao == ao[0]): raise ValueError( - f"`axes_order` for all arrays must contain the same combination of dimensions, your `axes_order` are: " - f"{self.axes_order}" + f"`dims_order` for all arrays must contain the same combination of dimensions, your `dims_order` are: " + f"{self.dims_order}" ) # by default slider is only made for "t" - time dimension - if slider_axes is None: - slider_axes = "t" + if slider_dims is None: + slider_dims = "t" # slider for only one of the dimensions - if isinstance(slider_axes, (int, str)): + if isinstance(slider_dims, (int, str)): # if numerical dimension is specified - if isinstance(slider_axes, int): - ao = np.array([v for v in self.axes_order]) + if isinstance(slider_dims, int): + ao = np.array([v for v in self.dims_order]) if not np.all(ao == ao[0]): raise ValueError( - f"`axes_order` for all arrays must be identical if passing in a `slider_axes` argument. " - f"Pass in a argument if the `axes_order` are different for each array." + f"`dims_order` for all arrays must be identical if passing in a `slider_dims` argument. " + f"Pass in a argument if the `dims_order` are different for each array." ) - self.slider_axes: List[str] = [self.axes_order[0][slider_axes]] + self.slider_dims: List[str] = [self.dims_order[0][slider_dims]] # if dimension specified by str - elif isinstance(slider_axes, str): - if slider_axes not in self.axes_order[0]: + elif isinstance(slider_dims, str): + if slider_dims not in self.dims_order[0]: raise ValueError( - f"if `slider_axes` is a , it must be a character found in `axes_order`. " - f"Your `axes_order` characters are: {set(self.axes_order[0])}." + f"if `slider_dims` is a , it must be a character found in `dims_order`. " + f"Your `dims_order` characters are: {set(self.dims_order[0])}." ) - self.slider_axes: List[str] = [slider_axes] + self.slider_dims: List[str] = [slider_dims] # multiple sliders, one for each dimension - elif isinstance(slider_axes, list): - self.slider_axes: List[str] = list() + elif isinstance(slider_dims, list): + self.slider_dims: List[str] = list() # make sure slice_avg and frame_apply are dicts if multiple sliders are desired if (not isinstance(slice_avg, dict)) and (slice_avg is not None): raise TypeError( - f"`slice_avg` must be a if multiple `slider_axes` are provided. You must specify the " + f"`slice_avg` must be a if multiple `slider_dims` are provided. You must specify the " f"window for each dimension." ) if (not isinstance(frame_apply, dict)) and (frame_apply is not None): raise TypeError( - f"`frame_apply` must be a if multiple `slider_axes` are provided. You must specify a " + f"`frame_apply` must be a if multiple `slider_dims` are provided. You must specify a " f"function for each dimension." ) - for sax in slider_axes: - if isinstance(sax, int): - ao = np.array([v for v in self.axes_order]) + for sdm in slider_dims: + if isinstance(sdm, int): + ao = np.array([v for v in self.dims_order]) if not np.all(ao == ao[0]): raise ValueError( - f"`axes_order` for all arrays must be identical if passing in a `slider_axes` argument. " - f"Pass in a argument if the `axes_order` are different for each array." + f"`dims_order` for all arrays must be identical if passing in a `slider_dims` argument. " + f"Pass in a argument if the `dims_order` are different for each array." ) # parse int to a str - self.slider_axes.append(self.axes_order[0][sax]) + self.slider_dims.append(self.dims_order[0][sdm]) - elif isinstance(sax, str): - if sax not in self.axes_order[0]: + elif isinstance(sdm, str): + if sdm not in self.dims_order[0]: raise ValueError( - f"if `slider_axes` is a , it must be a character found in `axes_order`. " - f"Your `axes_order` characters are: {set(self.axes_order[0])}." + f"if `slider_dims` is a , it must be a character found in `dims_order`. " + f"Your `dims_order` characters are: {set(self.dims_order[0])}." ) - self.slider_axes.append(sax) + self.slider_dims.append(sdm) else: raise TypeError( - "If passing a list for `slider_axes` each element must be either an or " + "If passing a list for `slider_dims` each element must be either an or " ) else: - raise TypeError(f"`slider_axes` must a , or , you have passed a: {type(slider_axes)}") + raise TypeError(f"`slider_dims` must a , or , you have passed a: {type(slider_dims)}") self._slice_avg = None self.slice_avg = slice_avg @@ -267,13 +270,13 @@ def __init__( self.horizontal_sliders = list() # current_index stores {dimension_index: slice_index} for every dimension - self.current_index: Dict[str, int] = {sax: 0 for sax in self.slider_axes} + self.current_index: Dict[str, int] = {sax: 0 for sax in self.slider_dims} # get max bound for all data arrays for all dimensions - self.axes_max_bounds: Dict[str, int] = {k: np.inf for k in self.slider_axes} - for axis in list(self.axes_max_bounds.keys()): - for array, order in zip(self.data, self.axes_order): - self.axes_max_bounds[axis] = min(self.axes_max_bounds[axis], array.shape[order.index(axis)]) + self.dims_max_bounds: Dict[str, int] = {k: np.inf for k in self.slider_dims} + for _dim in list(self.dims_max_bounds.keys()): + for array, order in zip(self.data, self.dims_order): + self.dims_max_bounds[_dim] = min(self.dims_max_bounds[_dim], array.shape[order.index(_dim)]) if self.plot_type == "single": self.plot: Plot = Plot() @@ -297,8 +300,8 @@ def __init__( self.plot.renderer.add_event_handler(self.set_slider_layout, "resize") - for sax in self.slider_axes: - if sax == "z": + for sdm in self.slider_dims: + if sdm == "z": # TODO: once ipywidgets plays nicely with HBox and jupyter-rfb can use vertical # orientation = "vertical" orientation = "horizontal" @@ -307,15 +310,15 @@ def __init__( slider = IntSlider( min=0, - max=self.axes_max_bounds[sax] - 1, + max=self.dims_max_bounds[sdm] - 1, step=1, value=0, - description=f"Axis: {sax}", + description=f"dimension: {sdm}", orientation=orientation ) slider.observe( - partial(self.slider_value_changed, sax), + partial(self.slider_value_changed, sdm), names="value" ) @@ -433,7 +436,7 @@ def get_2d_slice( f"Given `array` not found in `self.data`" ) # get axes order for that specific array - numerical_dim = self.axes_order[data_ix].index(dim) + numerical_dim = self.dims_order[data_ix].index(dim) else: numerical_dim = dim @@ -471,18 +474,18 @@ def _process_dim_index(self, data_ix, dim, indices_dim): # if there is only a single dimension for averaging if isinstance(self.slice_avg, int): sa = self.slice_avg - dim_str = self.axes_order[0][dim] + dim_str = self.dims_order[0][dim] # if there are multiple dims to average, get the avg for the current dim in the loop elif isinstance(self.slice_avg, dict): - dim_str = self.axes_order[data_ix][dim] + dim_str = self.dims_order[data_ix][dim] sa = self.slice_avg[dim_str] if (sa == 0) or (sa is None): return indices_dim hw = int((sa - 1) / 2) # half-window size # get the max bound for that dimension - max_bound = self.axes_max_bounds[dim_str] + max_bound = self.dims_max_bounds[dim_str] indices_dim = range(max(0, ix - hw), min(max_bound, ix + hw)) return indices_dim From 570c076edbab5ef149ab07dbd2f90526bf3567d6 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 9 Dec 2022 23:32:47 -0500 Subject: [PATCH 15/21] make most imagewidget methods private, most attributes as read-only properties --- fastplotlib/widgets/image.py | 171 ++++++++++++++++++++++++----------- 1 file changed, 118 insertions(+), 53 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index b7d90197a..a7c1bfe7d 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -16,7 +16,7 @@ } -def calc_gridshape(n): +def _calc_gridshape(n): sr = np.sqrt(n) return ( int(np.ceil(sr)), @@ -24,7 +24,7 @@ def calc_gridshape(n): ) -def is_arraylike(obj) -> bool: +def _is_arraylike(obj) -> bool: """ Checks if the object is array-like. For now just checks if obj has `__getitem__()` @@ -41,6 +41,66 @@ def is_arraylike(obj) -> bool: class ImageWidget: + @property + def plot(self) -> Union[Plot, GridPlot]: + """ + The plotter used by the ImageWidget. Either a simple ``Plot`` or ``GridPlot``. + """ + return self._plot + + @property + def data(self) -> List[np.ndarray]: + """data currently displayed in the widget""" + return self._data + + @property + def ndim(self) -> int: + """number of dimensions in the image data displayed in the widget""" + return self._ndim + + @property + def dims_order(self) -> List[str]: + """dimension order of the data displayed in the widget""" + return self._dims_order + + @property + def sliders(self) -> List[IntSlider]: + """the slider instances used by the widget for indexing the desired dimensions""" + return self._sliders + + @property + def slider_dims(self) -> List[str]: + """the dimensions that the sliders index""" + return self._slider_dims + + @property + def current_index(self) -> Dict[str, int]: + return self._current_index + + @current_index.setter + def current_index(self, index: Dict[str, int]): + """ + Set the current index + + Parameters + ---------- + index: Dict[str, int] + | ``dict`` for indexing each dimension, provide a ``dict`` with indices for all dimensions used by sliders + or only a subset of dimensions used by the sliders. + | example: if you have sliders for dims "t" and "z", you can pass either ``{"t": 10}`` to index to position + 10 on dimension "t" or ``{"t": 5, "z": 20}`` to index to position 5 on dimension "t" and position 20 on + dimension "z" simultaneously. + """ + if not set(index.keys()).issubset(set(self._current_index.keys())): + raise KeyError( + f"All dimension keys for setting `current_index` must be present in the widget sliders. " + f"The dimensions currently used for sliders are: {list(self.current_index.keys())}" + ) + self._current_index.update(index) + for ig, data in zip(self.image_graphics, self.data): + frame = self._get_2d_slice(data, self._current_index) + ig.update_data(frame) + def __init__( self, data: Union[np.ndarray, List[np.ndarray]], @@ -75,14 +135,15 @@ def __init__( dims_order: Optional[Union[str, Dict[np.ndarray, str]]] | ``str`` or a dict mapping to indicate dimension order | a single ``str`` if ``data`` is a single array, or a list of arrays with the same dimension order + | examples: ``"xyt"``, ``"tzxy"`` | ``dict`` mapping of ``{array: axis_order}`` if specific arrays have a non-default axes order. - | examples: "xyt", "tzxy" + | examples: ``{some_array: "tzxy", another_array: "xytz"}`` slider_dims: Optional[Union[str, int, List[Union[str, int]]]] | The dimensions for which to create a slider | can be a single ``str`` such as **"t"**, **"z"** or a numerical ``int`` that indexes the desired dimension | can also be a list of ``str`` or ``int`` if multiple sliders are desired for multiple dimensions - | examples: "t", ["t", "z"] + | examples: ``"t"``, ``["t", "z"]`` slice_avg: Dict[Union[int, str], int] | average one or more dimensions using a given window @@ -101,13 +162,13 @@ def __init__( """ if isinstance(data, list): # verify that it's a list of np.ndarray - if all([is_arraylike(d) for d in data]): + if all([_is_arraylike(d) for d in data]): if grid_shape is None: - grid_shape = calc_gridshape(len(data)) + grid_shape = _calc_gridshape(len(data)) # verify that user-specified grid shape is large enough for the number of image arrays passed elif grid_shape[0] * grid_shape[1] < len(data): - grid_shape = calc_gridshape(len(data)) + grid_shape = _calc_gridshape(len(data)) warn(f"Invalid `grid_shape` passed, setting grid shape to: {grid_shape}") _ndim = [d.ndim for d in data] @@ -119,10 +180,10 @@ def __init__( f"Number of dimensions of all data arrays must match, your ndims are: {_ndim}" ) - self.data: List[np.ndarray] = data - self.ndim = self.data[0].ndim # all ndim must be same + self._data: List[np.ndarray] = data + self._ndim = self.data[0].ndim # all ndim must be same - self.plot_type = "grid" + self._plot_type = "grid" else: raise TypeError( @@ -130,11 +191,11 @@ def __init__( f"array-like type representing an n-dimensional image" ) - elif is_arraylike(data): - self.data = [data] - self.ndim = self.data[0].ndim + elif _is_arraylike(data): + self._data = [data] + self._ndim = self.data[0].ndim - self.plot_type = "single" + self._plot_type = "single" else: raise TypeError( f"`data` must be an array-like type representing an n-dimensional image " @@ -143,12 +204,12 @@ def __init__( # default dims order if not passed if dims_order is None: - self.dims_order: List[str] = [DEFAULT_DIMS_ORDER[self.ndim]] * len(self.data) + self._dims_order: List[str] = [DEFAULT_DIMS_ORDER[self.ndim]] * len(self.data) elif isinstance(dims_order, str): - self.dims_order: List[str] = [dims_order] * len(self.data) + self._dims_order: List[str] = [dims_order] * len(self.data) elif isinstance(dims_order, dict): - self.dims_order: List[str] = [DEFAULT_DIMS_ORDER[self.ndim]] * len(self.data) + self._dims_order: List[str] = [DEFAULT_DIMS_ORDER[self.ndim]] * len(self.data) # dict of {array: dims_order_str} for array in list(dims_order.keys()): @@ -208,7 +269,7 @@ def __init__( f"`dims_order` for all arrays must be identical if passing in a `slider_dims` argument. " f"Pass in a argument if the `dims_order` are different for each array." ) - self.slider_dims: List[str] = [self.dims_order[0][slider_dims]] + self._slider_dims: List[str] = [self.dims_order[0][slider_dims]] # if dimension specified by str elif isinstance(slider_dims, str): @@ -217,11 +278,11 @@ def __init__( f"if `slider_dims` is a , it must be a character found in `dims_order`. " f"Your `dims_order` characters are: {set(self.dims_order[0])}." ) - self.slider_dims: List[str] = [slider_dims] + self._slider_dims: List[str] = [slider_dims] # multiple sliders, one for each dimension elif isinstance(slider_dims, list): - self.slider_dims: List[str] = list() + self._slider_dims: List[str] = list() # make sure slice_avg and frame_apply are dicts if multiple sliders are desired if (not isinstance(slice_avg, dict)) and (slice_avg is not None): @@ -265,44 +326,44 @@ def __init__( self._slice_avg = None self.slice_avg = slice_avg - self.sliders = list() - self.vertical_sliders = list() - self.horizontal_sliders = list() + self._sliders: List[IntSlider] = list() + self._vertical_sliders = list() + self._horizontal_sliders = list() # current_index stores {dimension_index: slice_index} for every dimension - self.current_index: Dict[str, int] = {sax: 0 for sax in self.slider_dims} + self._current_index: Dict[str, int] = {sax: 0 for sax in self.slider_dims} # get max bound for all data arrays for all dimensions - self.dims_max_bounds: Dict[str, int] = {k: np.inf for k in self.slider_dims} - for _dim in list(self.dims_max_bounds.keys()): + self._dims_max_bounds: Dict[str, int] = {k: np.inf for k in self.slider_dims} + for _dim in list(self._dims_max_bounds.keys()): for array, order in zip(self.data, self.dims_order): - self.dims_max_bounds[_dim] = min(self.dims_max_bounds[_dim], array.shape[order.index(_dim)]) + self._dims_max_bounds[_dim] = min(self._dims_max_bounds[_dim], array.shape[order.index(_dim)]) - if self.plot_type == "single": - self.plot: Plot = Plot() + if self._plot_type == "single": + self._plot: Plot = Plot() if slice_avg is not None: pass - frame = self.get_2d_slice(self.data[0], slice_indices=self.current_index) + frame = self._get_2d_slice(self.data[0], slice_indices=self._current_index) self.image_graphics: List[Image] = [self.plot.image(data=frame, **kwargs)] - elif self.plot_type == "grid": - self.plot: GridPlot = GridPlot(shape=grid_shape, controllers="sync") + elif self._plot_type == "grid": + self._plot: GridPlot = GridPlot(shape=grid_shape, controllers="sync") self.image_graphics = list() for d, subplot in zip(self.data, self.plot): - frame = self.get_2d_slice(d, slice_indices=self.current_index) + frame = self._get_2d_slice(d, slice_indices=self._current_index) ig = Image(frame, **kwargs) subplot.add_graphic(ig) self.image_graphics.append(ig) - self.plot.renderer.add_event_handler(self.set_slider_layout, "resize") + self.plot.renderer.add_event_handler(self._set_slider_layout, "resize") for sdm in self.slider_dims: if sdm == "z": - # TODO: once ipywidgets plays nicely with HBox and jupyter-rfb can use vertical + # TODO: once ipywidgets plays nicely with HBox and jupyter-rfb, use vertical # orientation = "vertical" orientation = "horizontal" else: @@ -310,7 +371,7 @@ def __init__( slider = IntSlider( min=0, - max=self.dims_max_bounds[sdm] - 1, + max=self._dims_max_bounds[sdm] - 1, step=1, value=0, description=f"dimension: {sdm}", @@ -318,20 +379,20 @@ def __init__( ) slider.observe( - partial(self.slider_value_changed, sdm), + partial(self._slider_value_changed, sdm), names="value" ) - self.sliders.append(slider) + self._sliders.append(slider) if orientation == "horizontal": - self.horizontal_sliders.append(slider) + self._horizontal_sliders.append(slider) elif orientation == "vertical": - self.vertical_sliders.append(slider) + self._vertical_sliders.append(slider) # TODO: So just stack everything vertically for now self.widget = VBox([ self.plot.canvas, - *self.sliders + *self._sliders ]) # TODO: there is currently an issue with ipywidgets or jupyter-rfb and HBox doesn't work with RFB canvas @@ -391,7 +452,7 @@ def slice_avg(self, sa: Union[int, Dict[str, int]]): f"You have passed a {type(sa)}. See the docstring." ) - def get_2d_slice( + def _get_2d_slice( self, array: np.ndarray, slice_indices: dict[Union[int, str], int] @@ -485,30 +546,34 @@ def _process_dim_index(self, data_ix, dim, indices_dim): hw = int((sa - 1) / 2) # half-window size # get the max bound for that dimension - max_bound = self.dims_max_bounds[dim_str] + max_bound = self._dims_max_bounds[dim_str] indices_dim = range(max(0, ix - hw), min(max_bound, ix + hw)) return indices_dim - def slider_value_changed( + def _slider_value_changed( self, - dimension: int, + dimension: str, change: dict ): - self.current_index[dimension] = change["new"] + self.current_index = {dimension: change["new"]} - for ig, data in zip(self.image_graphics, self.data): - frame = self.get_2d_slice(data, self.current_index) - ig.update_data(frame) - - def set_slider_layout(self, *args): + def _set_slider_layout(self, *args): w, h = self.plot.renderer.logical_size - for hs in self.horizontal_sliders: + for hs in self._horizontal_sliders: hs.layout = Layout(width=f"{w}px") - for vs in self.vertical_sliders: + for vs in self._vertical_sliders: vs.layout = Layout(height=f"{h}px") def show(self): + """ + Show the widget + + Returns + ------- + VBox + ``ipywidgets.VBox`` stacking the plotter and sliders in a vertical layout + """ # start render loop self.plot.show() From 4764b84a2357d41c00ea5ed5f7eb18b129967e40 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 10 Dec 2022 01:33:38 -0500 Subject: [PATCH 16/21] quick_min_max() returns pre-computed min max if int or float, imagewidget does auto vmin vmax --- fastplotlib/utils.py | 8 +++++++- fastplotlib/widgets/image.py | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/fastplotlib/utils.py b/fastplotlib/utils.py index 9cd7dc6a2..650cfb053 100644 --- a/fastplotlib/utils.py +++ b/fastplotlib/utils.py @@ -78,9 +78,15 @@ def map_labels_to_colors(labels: iter, cmap: str, **kwargs) -> list: def quick_min_max(data: np.ndarray) -> Tuple[float, float]: - # from pyqtgraph.ImageView + # adapted from pyqtgraph.ImageView # Estimate the min/max values of *data* by subsampling. # Returns [(min, max), ...] with one item per channel + + if hasattr(data, "min") and hasattr(data, "max"): + # if value is pre-computed + if isinstance(data.min, (float, int)) and isinstance(data.max, (float, int)): + return data.min, data.max + while data.size > 1e6: ax = np.argmax(data.shape) sl = [slice(None)] * data.ndim diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index a7c1bfe7d..c8bcfd6fd 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -1,12 +1,14 @@ from ..plot import Plot from ..layouts import GridPlot from ..graphics import Image +from ..utils import quick_min_max from ipywidgets.widgets import IntSlider, VBox, HBox, Layout import numpy as np from typing import * from warnings import warn from functools import partial + DEFAULT_DIMS_ORDER = \ { 2: "xy", @@ -345,6 +347,9 @@ def __init__( if slice_avg is not None: pass + if ("vmin" not in kwargs.keys()) or ("vmax" not in kwargs.keys()): + kwargs["vmin"], kwargs["vmax"] = quick_min_max(self.data[0]) + frame = self._get_2d_slice(self.data[0], slice_indices=self._current_index) self.image_graphics: List[Image] = [self.plot.image(data=frame, **kwargs)] From afc0378c282c8a718b1b4deed9688678203ff0f1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 10 Dec 2022 17:10:43 -0500 Subject: [PATCH 17/21] vmin vmax for gridplot --- fastplotlib/widgets/image.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index c8bcfd6fd..fa7153080 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -344,9 +344,6 @@ def __init__( if self._plot_type == "single": self._plot: Plot = Plot() - if slice_avg is not None: - pass - if ("vmin" not in kwargs.keys()) or ("vmax" not in kwargs.keys()): kwargs["vmin"], kwargs["vmax"] = quick_min_max(self.data[0]) @@ -359,6 +356,9 @@ def __init__( self.image_graphics = list() for d, subplot in zip(self.data, self.plot): + if ("vmin" not in kwargs.keys()) or ("vmax" not in kwargs.keys()): + kwargs["vmin"], kwargs["vmax"] = quick_min_max(self.data[0]) + frame = self._get_2d_slice(d, slice_indices=self._current_index) ig = Image(frame, **kwargs) subplot.add_graphic(ig) From b1e922c60d909aac1db8c58515cb3e33333f00c8 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 11 Dec 2022 01:28:07 -0500 Subject: [PATCH 18/21] update Image -> ImageGraphic --- fastplotlib/widgets/image.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index fa7153080..7295584af 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -1,6 +1,6 @@ from ..plot import Plot from ..layouts import GridPlot -from ..graphics import Image +from ..graphics import ImageGraphic from ..utils import quick_min_max from ipywidgets.widgets import IntSlider, VBox, HBox, Layout import numpy as np @@ -349,7 +349,7 @@ def __init__( frame = self._get_2d_slice(self.data[0], slice_indices=self._current_index) - self.image_graphics: List[Image] = [self.plot.image(data=frame, **kwargs)] + self.image_graphics: List[ImageGraphic] = [self.plot.image(data=frame, **kwargs)] elif self._plot_type == "grid": self._plot: GridPlot = GridPlot(shape=grid_shape, controllers="sync") @@ -360,7 +360,7 @@ def __init__( kwargs["vmin"], kwargs["vmax"] = quick_min_max(self.data[0]) frame = self._get_2d_slice(d, slice_indices=self._current_index) - ig = Image(frame, **kwargs) + ig = ImageGraphic(frame, **kwargs) subplot.add_graphic(ig) self.image_graphics.append(ig) From e6c5d3a4cca27c53d40ebd44e8808ab309fa0012 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 11 Dec 2022 04:43:50 -0500 Subject: [PATCH 19/21] refactor, window_funcs now works very well, slow with multiple dims but it's a numpy thing --- fastplotlib/widgets/image.py | 338 ++++++++++++++++++++++++----------- 1 file changed, 234 insertions(+), 104 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 7295584af..8b040325e 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -42,6 +42,44 @@ def _is_arraylike(obj) -> bool: return True +class _WindowFunctions: + def __init__(self, func: callable, window_size: int): + self.func = func + + self._window_size = 0 + self.window_size = window_size + + @property + def window_size(self) -> int: + return self._window_size + + @window_size.setter + def window_size(self, ws: int): + if ws is None: + self._window_size = None + return + + if not isinstance(ws, int): + raise TypeError("window size must be an int") + + if ws < 3: + warn( + f"Invalid 'window size' value for function: {self.func}, " + f"setting 'window size' = None for this function. " + f"Valid values are integers >= 3." + ) + self.window_size = None + return + + if ws % 2 == 0: + ws += 1 + + self._window_size = ws + + def __repr__(self): + return f"func: {self.func}, window_size: {self.window_size}" + + class ImageWidget: @property def plot(self) -> Union[Plot, GridPlot]: @@ -66,7 +104,7 @@ def dims_order(self) -> List[str]: return self._dims_order @property - def sliders(self) -> List[IntSlider]: + def sliders(self) -> Dict[str, IntSlider]: """the slider instances used by the widget for indexing the desired dimensions""" return self._sliders @@ -98,18 +136,36 @@ def current_index(self, index: Dict[str, int]): f"All dimension keys for setting `current_index` must be present in the widget sliders. " f"The dimensions currently used for sliders are: {list(self.current_index.keys())}" ) + + for k, val in index.items(): + if not isinstance(val, int): + raise TypeError("Indices for all dimensions must be int") + if val < 0: + raise IndexError("negative indexing is not supported for ImageWidget") + if val > self._dims_max_bounds[k]: + raise IndexError(f"index {val} is out of bounds for dimension '{k}' " + f"which has a max bound of: {self._dims_max_bounds[k]}") + self._current_index.update(index) - for ig, data in zip(self.image_graphics, self.data): - frame = self._get_2d_slice(data, self._current_index) + + # can make a callback_block decorator later + self.block_sliders = True + for k in index.keys(): + self.sliders[k].value = index[k] + self.block_sliders = False + + for i, (ig, data) in enumerate(zip(self.image_graphics, self.data)): + frame = self._process_indices(data, self._current_index) + frame = self._process_frame_apply(frame, i) ig.update_data(frame) def __init__( self, data: Union[np.ndarray, List[np.ndarray]], - dims_order: Union[str, Dict[np.ndarray, str]] = None, + dims_order: Union[str, Dict[int, str]] = None, slider_dims: Union[str, int, List[Union[str, int]]] = None, - slice_avg: Union[int, Dict[str, int]] = None, - frame_apply: Union[callable, Dict[Union[int, str], callable]] = None, + window_funcs: Union[int, Dict[str, int]] = None, + frame_apply: Union[callable, Dict[int, callable]] = None, grid_shape: Tuple[int, int] = None, **kwargs ): @@ -138,8 +194,9 @@ def __init__( | ``str`` or a dict mapping to indicate dimension order | a single ``str`` if ``data`` is a single array, or a list of arrays with the same dimension order | examples: ``"xyt"``, ``"tzxy"`` - | ``dict`` mapping of ``{array: axis_order}`` if specific arrays have a non-default axes order. - | examples: ``{some_array: "tzxy", another_array: "xytz"}`` + | ``dict`` mapping of ``{array_index: axis_order}`` if specific arrays have a non-default axes order. + | "array_index" is the position of the corresponding array in the data list. + | examples: ``{array_index: "tzxy", another_array_index: "xytz"}`` slider_dims: Optional[Union[str, int, List[Union[str, int]]]] | The dimensions for which to create a slider @@ -147,7 +204,7 @@ def __init__( | can also be a list of ``str`` or ``int`` if multiple sliders are desired for multiple dimensions | examples: ``"t"``, ``["t", "z"]`` - slice_avg: Dict[Union[int, str], int] + window_funcs: Dict[Union[int, str], int] | average one or more dimensions using a given window | if a slider exists for only one dimension this can be an ``int``. | if multiple sliders exist, then it must be a `dict`` mapping in the form of: ``{dimension: window_size}`` @@ -155,7 +212,15 @@ def __init__( | if window_size is not an odd number, adds 1 | use ``None`` to disable averaging for a dimension, example: ``{"t": 5, "z": None}`` - frame_apply + frame_apply: Union[callable, Dict[int, callable]] + | apply a function to slices of the array before displaying the frame + | pass a single function or a dict of functions to apply to each array individually + | examples: ``{array_index: to_grayscale}``, ``{0: to_grayscale, 2: threshold_img}`` + | "array_index" is the position of the corresponding array in the data list. + | if `window_funcs` is used, then this function is applied after `window_funcs` + | this function must be a callable that returns a 2D array + | example use case: converting an RGB frame from video to a 2D grayscale frame + grid_shape: Optional[Tuple[int, int]] manually provide the shape for a gridplot, otherwise a square gridplot is approximated. @@ -205,43 +270,47 @@ def __init__( ) # default dims order if not passed - if dims_order is None: - self._dims_order: List[str] = [DEFAULT_DIMS_ORDER[self.ndim]] * len(self.data) - - elif isinstance(dims_order, str): - self._dims_order: List[str] = [dims_order] * len(self.data) - elif isinstance(dims_order, dict): - self._dims_order: List[str] = [DEFAULT_DIMS_ORDER[self.ndim]] * len(self.data) - - # dict of {array: dims_order_str} - for array in list(dims_order.keys()): - # get index of corresponding array in data list - try: - data_ix = None - for i in range(len(self.data)): - if self.data[i] is array: - data_ix = i - break - if data_ix is None: - raise ValueError( - f"Given `array` not found in `self.data`" - ) - except Exception: - raise TypeError( - f"`dims_order` must be a or , " - f"with the same array object(s) passed to `data`" - ) - # set corresponding dims order from passed `dims_order` dict - if not set(dims_order[array]) == set(DEFAULT_DIMS_ORDER[self.ndim]): + # updated later if passed + self._dims_order: List[str] = [DEFAULT_DIMS_ORDER[self.ndim]] * len(self.data) + + if dims_order is not None: + if isinstance(dims_order, str): + dims_order = dims_order.lower() + if len(dims_order) != self.ndim: raise ValueError( - f"Invalid `dims_order` passed for one of your arrays, " - f"valid `dims_order` for given number of dimensions " - f"can only contain the following characters: " - f"{DEFAULT_DIMS_ORDER[self.ndim]}" + f"number of dims '{len(dims_order)} passed to `dims_order` " + f"does not match ndim '{self.ndim}' of data" ) - self.dims_order[data_ix] = dims_order[array] - else: - raise TypeError(f"`dims_order` must be a or , you have passed a: <{type(dims_order)}>") + self._dims_order: List[str] = [dims_order] * len(self.data) + elif isinstance(dims_order, dict): + self._dims_order: List[str] = [DEFAULT_DIMS_ORDER[self.ndim]] * len(self.data) + + # dict of {array_ix: dims_order_str} + for data_ix in list(dims_order.keys()): + if not isinstance(data_ix, int): + raise TypeError("`dims_oder` dict keys must be ") + if len(dims_order[data_ix]) != self.ndim: + raise ValueError( + f"number of dims '{len(dims_order)} passed to `dims_order` " + f"does not match ndim '{self.ndim}' of data" + ) + _do = dims_order[data_ix].lower() + # make sure the same dims are present + if not set(_do) == set(DEFAULT_DIMS_ORDER[self.ndim]): + raise ValueError( + f"Invalid `dims_order` passed for one of your arrays, " + f"valid `dims_order` for given number of dimensions " + f"can only contain the following characters: " + f"{DEFAULT_DIMS_ORDER[self.ndim]}" + ) + try: + self.dims_order[data_ix] = _do + except Exception: + raise IndexError( + f"index {data_ix} out of bounds for `dims_order`, the bounds are 0 - {len(self.data)}" + ) + else: + raise TypeError(f"`dims_order` must be a or , you have passed a: <{type(dims_order)}>") if not len(self.dims_order[0]) == self.ndim: raise ValueError( @@ -286,10 +355,10 @@ def __init__( elif isinstance(slider_dims, list): self._slider_dims: List[str] = list() - # make sure slice_avg and frame_apply are dicts if multiple sliders are desired - if (not isinstance(slice_avg, dict)) and (slice_avg is not None): + # make sure window_funcs and frame_apply are dicts if multiple sliders are desired + if (not isinstance(window_funcs, dict)) and (window_funcs is not None): raise TypeError( - f"`slice_avg` must be a if multiple `slider_dims` are provided. You must specify the " + f"`window_funcs` must be a if multiple `slider_dims` are provided. You must specify the " f"window for each dimension." ) if (not isinstance(frame_apply, dict)) and (frame_apply is not None): @@ -325,10 +394,34 @@ def __init__( else: raise TypeError(f"`slider_dims` must a , or , you have passed a: {type(slider_dims)}") - self._slice_avg = None - self.slice_avg = slice_avg + self.frame_apply: Dict[int, callable] = dict() - self._sliders: List[IntSlider] = list() + if frame_apply is not None: + if callable(frame_apply): + self.frame_apply = {0: frame_apply} + + elif isinstance(frame_apply, dict): + self.frame_apply: Dict[int, callable] = dict.fromkeys(list(range(len(self.data)))) + + # dict of {array: dims_order_str} + for data_ix in list(frame_apply.keys()): + if not isinstance(data_ix, int): + raise TypeError("`frame_apply` dict keys must be ") + try: + self.frame_apply[data_ix] = frame_apply[data_ix] + except Exception: + raise IndexError( + f"key index {data_ix} out of bounds for `frame_apply`, the bounds are 0 - {len(self.data)}" + ) + else: + raise TypeError( + f"`frame_apply` must be a callable or , " + f"you have passed a: <{type(frame_apply)}>") + + self._window_funcs = None + self.window_funcs = window_funcs + + self._sliders: Dict[str, IntSlider] = dict() self._vertical_sliders = list() self._horizontal_sliders = list() @@ -347,7 +440,7 @@ def __init__( if ("vmin" not in kwargs.keys()) or ("vmax" not in kwargs.keys()): kwargs["vmin"], kwargs["vmax"] = quick_min_max(self.data[0]) - frame = self._get_2d_slice(self.data[0], slice_indices=self._current_index) + frame = self._process_indices(self.data[0], slice_indices=self._current_index) self.image_graphics: List[ImageGraphic] = [self.plot.image(data=frame, **kwargs)] @@ -359,7 +452,7 @@ def __init__( if ("vmin" not in kwargs.keys()) or ("vmax" not in kwargs.keys()): kwargs["vmin"], kwargs["vmax"] = quick_min_max(self.data[0]) - frame = self._get_2d_slice(d, slice_indices=self._current_index) + frame = self._process_indices(d, slice_indices=self._current_index) ig = ImageGraphic(frame, **kwargs) subplot.add_graphic(ig) self.image_graphics.append(ig) @@ -388,16 +481,20 @@ def __init__( names="value" ) - self._sliders.append(slider) + self._sliders[sdm] = slider if orientation == "horizontal": self._horizontal_sliders.append(slider) elif orientation == "vertical": self._vertical_sliders.append(slider) + # will change later + # prevent the slider callback if value is self.current_index is changed programmatically + self.block_sliders: bool = False + # TODO: So just stack everything vertically for now self.widget = VBox([ self.plot.canvas, - *self._sliders + *list(self._sliders.values()) ]) # TODO: there is currently an issue with ipywidgets or jupyter-rfb and HBox doesn't work with RFB canvas @@ -417,53 +514,70 @@ def __init__( # self.widget = VBox([self.plot.canvas, *self.horizontal_sliders]) @property - def slice_avg(self) -> Union[int, Dict[str, int]]: - return self._slice_avg + def window_funcs(self) -> Dict[str, _WindowFunctions]: + return self._window_funcs - @slice_avg.setter - def slice_avg(self, sa: Union[int, Dict[str, int]]): + @window_funcs.setter + def window_funcs(self, sa: Union[int, Dict[str, int]]): if sa is None: - self._slice_avg = None + self._window_funcs = None return # for a single dim - elif isinstance(sa, int): - if sa < 3: - self._slice_avg = None - warn(f"Invalid ``slice_avg`` value, setting ``slice_avg = None``. Valid values are integers >= 3.") - return - if sa % 2 == 0: - self._slice_avg = sa + 1 - else: - self._slice_avg = sa + elif isinstance(sa, tuple): + if len(self.slider_dims) > 1: + raise TypeError( + "Must pass dict argument to window_funcs if using multiple sliders. See the docstring." + ) + if not callable(sa[0]) or not isinstance(sa[1], int): + raise TypeError( + "Tuple argument to `window_funcs` must be in the form of (func, window_size). See the docstring." + ) + + dim_str = self.slider_dims[0] + self._window_funcs = dict() + self._window_funcs[dim_str] = _WindowFunctions(*sa) + # for multiple dims elif isinstance(sa, dict): - self._slice_avg = dict() + if not all([isinstance(_sa, tuple) or (_sa is None) for _sa in sa.values()]): + raise TypeError( + "dict argument to `window_funcs` must be in the form of: " + "`{dimension: (func, window_size)}`. " + "See the docstring." + ) + for v in sa.values(): + if v is not None: + if not callable(v[0]) or not (isinstance(v[1], int) or v[1] is None): + raise TypeError( + "dict argument to `window_funcs` must be in the form of: " + "`{dimension: (func, window_size)}`. " + "See the docstring." + ) + + if not isinstance(self._window_funcs, dict): + self._window_funcs = dict() + for k in list(sa.keys()): if sa[k] is None: - self._slice_avg[k] = None - elif (sa[k] < 3): - warn( - f"Invalid ``slice_avg`` value, setting ``slice_avg = None``. Valid values are integers >= 3." - ) - self._slice_avg[k] = None - elif sa[k] % 2 == 0: - self._slice_avg[k] = sa[k] + 1 + self._window_funcs[k] = None else: - self._slice_avg[k] = sa[k] + self._window_funcs[k] = _WindowFunctions(*sa[k]) + else: raise TypeError( - f"`slice_avg` must be of type `int` if using a single slider or a dict if using multiple sliders. " + f"`window_funcs` must be of type `int` if using a single slider or a dict if using multiple sliders. " f"You have passed a {type(sa)}. See the docstring." ) - def _get_2d_slice( + def _process_indices( self, array: np.ndarray, slice_indices: dict[Union[int, str], int] ) -> np.ndarray: """ - Get the 2D array from the given slice indices. + Get the 2D array from the given slice indices. If not returning a 2D slice (such as due to window_funcs) + then `frame_apply` must take this output and return a 2D array Parameters ---------- @@ -481,11 +595,6 @@ def _get_2d_slice( np.ndarray array-like, 2D slice - Examples - -------- - img = get_2d_slice(a, {"t": 50, "z": 4}) - # img is a 2d plane at time index 50 and z-plane 4 - """ indexer = [slice(None)] * self.ndim @@ -509,57 +618,78 @@ def _get_2d_slice( indices_dim = slice_indices[dim] # takes care of averaging if it was specified - indices_dim = self._process_dim_index(data_ix, numerical_dim, indices_dim) + indices_dim = self._get_window_indices(data_ix, numerical_dim, indices_dim) # set the indices for this dimension indexer[numerical_dim] = indices_dim numerical_dims.append(numerical_dim) - if self.slice_avg is not None: + # apply indexing to the array + # use window function is given for this dimension + if self.window_funcs is not None: a = array for i, dim in enumerate(sorted(numerical_dims)): + dim_str = self.dims_order[data_ix][dim] dim = dim - i # since we loose a dimension every iteration _indexer = [slice(None)] * (self.ndim - i) _indexer[dim] = indexer[dim + i] + + # if the indexer is an int, this dim has no window func if isinstance(_indexer[dim], int): a = a[tuple(_indexer)] else: - a = np.mean(a[tuple(_indexer)], axis=dim) + # if the indices are from `self._get_window_indices` + func = self.window_funcs[dim_str].func + window = a[tuple(_indexer)] + a = func(window, axis=dim) + # a = np.mean(a[tuple(_indexer)], axis=dim) return a else: return array[tuple(indexer)] - def _process_dim_index(self, data_ix, dim, indices_dim): - if self.slice_avg is None: + def _get_window_indices(self, data_ix, dim, indices_dim): + if self.window_funcs is None: return indices_dim else: ix = indices_dim - # if there is only a single dimension for averaging - if isinstance(self.slice_avg, int): - sa = self.slice_avg - dim_str = self.dims_order[0][dim] + dim_str = self.dims_order[data_ix][dim] - # if there are multiple dims to average, get the avg for the current dim in the loop - elif isinstance(self.slice_avg, dict): - dim_str = self.dims_order[data_ix][dim] - sa = self.slice_avg[dim_str] - if (sa == 0) or (sa is None): - return indices_dim + # if no window stuff specified for this dim + if dim_str not in self.window_funcs.keys(): + return indices_dim + + # if window stuff is set to None for this dim + # example: {"t": None} + if self.window_funcs[dim_str] is None: + return indices_dim + + window_size = self.window_funcs[dim_str].window_size - hw = int((sa - 1) / 2) # half-window size + if (window_size == 0) or (window_size is None): + return indices_dim + + half_window = int((window_size - 1) / 2) # half-window size # get the max bound for that dimension max_bound = self._dims_max_bounds[dim_str] - indices_dim = range(max(0, ix - hw), min(max_bound, ix + hw)) + indices_dim = range(max(0, ix - half_window), min(max_bound, ix + half_window)) return indices_dim + def _process_frame_apply(self, array, data_ix) -> np.ndarray: + if data_ix not in self.frame_apply.keys(): + return array + if self.frame_apply[data_ix] is not None: + return self.frame_apply[data_ix](array) + def _slider_value_changed( self, dimension: str, change: dict ): + if self.block_sliders: + return self.current_index = {dimension: change["new"]} def _set_slider_layout(self, *args): From 64faffdd4eb4d1d1104f9415253aa2e11462eb5f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 11 Dec 2022 05:17:14 -0500 Subject: [PATCH 20/21] vminmax works, also added names for subplots, everything works --- fastplotlib/widgets/image.py | 94 ++++++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 5 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 8b040325e..c79da680a 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -2,7 +2,7 @@ from ..layouts import GridPlot from ..graphics import ImageGraphic from ..utils import quick_min_max -from ipywidgets.widgets import IntSlider, VBox, HBox, Layout +from ipywidgets.widgets import IntSlider, VBox, HBox, Layout, FloatRangeSlider import numpy as np from typing import * from warnings import warn @@ -166,7 +166,9 @@ def __init__( slider_dims: Union[str, int, List[Union[str, int]]] = None, window_funcs: Union[int, Dict[str, int]] = None, frame_apply: Union[callable, Dict[int, callable]] = None, + vmin_vmax_sliders: bool = False, grid_shape: Tuple[int, int] = None, + names: List[str] = None, **kwargs ): """ @@ -224,9 +226,14 @@ def __init__( grid_shape: Optional[Tuple[int, int]] manually provide the shape for a gridplot, otherwise a square gridplot is approximated. + names: Optional[str] + gives names to the subplots + kwargs: Any passed to fastplotlib.graphics.Image """ + self._names = None + if isinstance(data, list): # verify that it's a list of np.ndarray if all([_is_arraylike(d) for d in data]): @@ -250,6 +257,16 @@ def __init__( self._data: List[np.ndarray] = data self._ndim = self.data[0].ndim # all ndim must be same + if names is not None: + if not all([isinstance(n, str) for n in names]): + raise TypeError("optinal argument `names` must be a list of str") + + if len(names) != len(self.data): + raise ValueError( + "number of `names` for subplots must be same as the number of data arrays" + ) + self._names = names + self._plot_type = "grid" else: @@ -428,6 +445,8 @@ def __init__( # current_index stores {dimension_index: slice_index} for every dimension self._current_index: Dict[str, int] = {sax: 0 for sax in self.slider_dims} + self.vmin_vmax_sliders: List[FloatRangeSlider] = list() + # get max bound for all data arrays for all dimensions self._dims_max_bounds: Dict[str, int] = {k: np.inf for k in self.slider_dims} for _dim in list(self._dims_max_bounds.keys()): @@ -437,8 +456,31 @@ def __init__( if self._plot_type == "single": self._plot: Plot = Plot() + minmax = quick_min_max(self.data[0]) + + if vmin_vmax_sliders: + data_range = np.ptp(minmax) + data_range_30p = np.ptp(minmax) * 0.3 + + minmax_slider = FloatRangeSlider( + value=minmax, + min=minmax[0] - data_range_30p, + max=minmax[1] + data_range_30p, + step=data_range / 150, + description=f"min-max", + readout = True, + readout_format = '.3f', + ) + + minmax_slider.observe( + partial(self._vmin_vmax_slider_changed, 0), + names="value" + ) + + self.vmin_vmax_sliders.append(minmax_slider) + if ("vmin" not in kwargs.keys()) or ("vmax" not in kwargs.keys()): - kwargs["vmin"], kwargs["vmax"] = quick_min_max(self.data[0]) + kwargs["vmin"], kwargs["vmax"] = minmax frame = self._process_indices(self.data[0], slice_indices=self._current_index) @@ -448,13 +490,44 @@ def __init__( self._plot: GridPlot = GridPlot(shape=grid_shape, controllers="sync") self.image_graphics = list() - for d, subplot in zip(self.data, self.plot): + for i, (d, subplot) in enumerate(zip(self.data, self.plot)): + minmax = quick_min_max(self.data[0]) + + if self._names is not None: + name = self._names[i] + name_slider = name + else: + name = None + name_slider = "" + + if vmin_vmax_sliders: + data_range = np.ptp(minmax) + data_range_30p = np.ptp(minmax) * 0.4 + + minmax_slider = FloatRangeSlider( + value=minmax, + min=minmax[0] - data_range_30p, + max=minmax[1] + data_range_30p, + step=data_range / 150, + description=f"mm ['{name_slider}']", + readout=True, + readout_format='.3f', + ) + + minmax_slider.observe( + partial(self._vmin_vmax_slider_changed, i), + names="value" + ) + + self.vmin_vmax_sliders.append(minmax_slider) + if ("vmin" not in kwargs.keys()) or ("vmax" not in kwargs.keys()): - kwargs["vmin"], kwargs["vmax"] = quick_min_max(self.data[0]) + kwargs["vmin"], kwargs["vmax"] = minmax frame = self._process_indices(d, slice_indices=self._current_index) ig = ImageGraphic(frame, **kwargs) subplot.add_graphic(ig) + subplot.name = name self.image_graphics.append(ig) self.plot.renderer.add_event_handler(self._set_slider_layout, "resize") @@ -494,7 +567,8 @@ def __init__( # TODO: So just stack everything vertically for now self.widget = VBox([ self.plot.canvas, - *list(self._sliders.values()) + *list(self._sliders.values()), + *self.vmin_vmax_sliders ]) # TODO: there is currently an issue with ipywidgets or jupyter-rfb and HBox doesn't work with RFB canvas @@ -692,6 +766,13 @@ def _slider_value_changed( return self.current_index = {dimension: change["new"]} + def _vmin_vmax_slider_changed( + self, + data_ix: int, + change: dict + ): + self.image_graphics[data_ix].clim = change["new"] + def _set_slider_layout(self, *args): w, h = self.plot.renderer.logical_size for hs in self._horizontal_sliders: @@ -700,6 +781,9 @@ def _set_slider_layout(self, *args): for vs in self._vertical_sliders: vs.layout = Layout(height=f"{h}px") + for mm in self.vmin_vmax_sliders: + mm.layout = Layout(width=f"{w}px") + def show(self): """ Show the widget From 8fc63b3abc9ec9a9b4d8fd8d8991c7f7291177b8 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 11 Dec 2022 05:29:48 -0500 Subject: [PATCH 21/21] proper-ish image widget example --- examples/image_widget.ipynb | 393 ++++++++++++++++++++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 examples/image_widget.ipynb diff --git a/examples/image_widget.ipynb b/examples/image_widget.ipynb new file mode 100644 index 000000000..8a88b3b67 --- /dev/null +++ b/examples/image_widget.ipynb @@ -0,0 +1,393 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "04f453ca-d0bc-411f-b2a6-d38294dd0a26", + "metadata": {}, + "outputs": [], + "source": [ + "from fastplotlib.widgets import ImageWidget\n", + "import numpy as np" + ] + }, + { + "cell_type": "markdown", + "id": "e933771b-f172-4fa9-b2f8-129723efb808", + "metadata": {}, + "source": [ + "# Single image sequence" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "ea87f9a6-437f-41f6-8739-c957fb04bdbf", + "metadata": {}, + "outputs": [], + "source": [ + "a = np.random.rand(500, 512, 512)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "8b7a6066-ff69-4bee-bae6-160fb4038393", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "6d575ba7671047ca88c36606344714fa", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "RFBOutputContext()" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "iw = ImageWidget(\n", + " data=a, \n", + " slider_dims=[\"t\"],\n", + " vmin_vmax_sliders=True,\n", + " cmap=\"gnuplot2\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "3d4cb44e-2c71-4bff-aeed-b2129f34d724", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "8de187407b7746168c8d20a428d8712e", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "VBox(children=(JupyterWgpuCanvas(), IntSlider(value=0, description='dimension: t', max=499), FloatRangeSlider(…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "iw.show()" + ] + }, + { + "cell_type": "markdown", + "id": "9908103c-c35c-4f33-ada1-0fc357c3fd5e", + "metadata": {}, + "source": [ + "### Play with setting different window functions\n", + "\n", + "These can also be given as kwargs to `ImageWidget` during instantiation" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "f278b26a-1b71-4e76-9cc7-efaddbd7b122", + "metadata": {}, + "outputs": [], + "source": [ + "# must be in the form of {dim: (func, window_size)}\n", + "iw.window_funcs = {\"t\": (np.mean, 13)}" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cb4d4b7c-919f-41c0-b1cc-b4496473d760", + "metadata": {}, + "outputs": [], + "source": [ + "# change the winow size\n", + "iw.window_funcs[\"t\"].window_size = 23" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "2eea6432-4d38-4d42-ab75-f6aa1bab36f4", + "metadata": {}, + "outputs": [], + "source": [ + "# change the function\n", + "iw.window_funcs[\"t\"].func = np.max" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "afa2436f-2741-49d6-87f6-7a91a343fe0e", + "metadata": {}, + "outputs": [], + "source": [ + "# or set it again\n", + "iw.window_funcs = {\"t\": (np.min, 11)}" + ] + }, + { + "cell_type": "markdown", + "id": "aca22179-1b1f-4c51-97bf-ce2d7044e451", + "metadata": {}, + "source": [ + "# Gridplot of txy data" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "882162eb-c873-42df-a945-d5e05ad141c9", + "metadata": {}, + "outputs": [], + "source": [ + "dims = (100, 512, 512)\n", + "data = [np.random.rand(*dims) for i in range(4)]" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "bf9f92b6-38ad-4d78-b88c-a32d473b6462", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "005bcbc7755748cfaf0644e28beb3b0e", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "RFBOutputContext()" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "iw = ImageWidget(\n", + " data=data, \n", + " slider_dims=[\"t\"], \n", + " # dims_order=\"txy\", # you can set this manually if dim order is not the usual\n", + " vmin_vmax_sliders=True,\n", + " names=[\"zero\", \"one\", \"two\", \"three\"],\n", + " window_funcs={\"t\": (np.mean, 5)},\n", + " cmap=\"gnuplot2\", \n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "0721dc40-677e-431d-94c6-da59606199cb", + "metadata": {}, + "source": [ + "### pan-zoom controllers are all synced in a `ImageWidget`" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "403dde31-981a-46fb-b005-1bcef19c4f2c", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "2b0a10be5d5b43b5a08f51a9d8f9b1dc", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "VBox(children=(JupyterWgpuCanvas(), IntSlider(value=0, description='dimension: t', max=99), FloatRangeSlider(v…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "iw.show()" + ] + }, + { + "cell_type": "markdown", + "id": "82545214-13c4-475e-87da-962117085834", + "metadata": {}, + "source": [ + "### Index the subplots using the names given to `ImageWidget`" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "b59d95e2-9092-4915-beef-01661d164781", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "two: Subplot @ 0x7f91486a7a00\n", + " parent: None\n", + " Graphics:\n", + "\tfastplotlib.ImageGraphic @ 0x7f914881ceb0" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "iw.plot[\"two\"]" + ] + }, + { + "cell_type": "markdown", + "id": "dc727d1a-681e-4cbf-bfb2-898ceb31cbe0", + "metadata": {}, + "source": [ + "### change window functions just like before" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "a8f070db-da11-4062-95aa-f19b96351ee8", + "metadata": {}, + "outputs": [], + "source": [ + "iw.window_funcs[\"t\"].func = np.max" + ] + }, + { + "cell_type": "markdown", + "id": "3e89c10f-6e34-4d63-9805-88403d487432", + "metadata": {}, + "source": [ + "## Gridplot of volumetric data" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "b1587410-a08e-484c-8795-195a413d6374", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "a2e4d723405345e0a7bd7b005330d018", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "RFBOutputContext()" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "dims = (256, 256, 5, 100)\n", + "data = [np.random.rand(*dims) for i in range(4)]\n", + "\n", + "iw = ImageWidget(\n", + " data=data, \n", + " slider_dims=[\"t\", \"z\"], \n", + " dims_order=\"xyzt\", # example of how you can set this for non-standard orders\n", + " vmin_vmax_sliders=True,\n", + " names=[\"zero\", \"one\", \"two\", \"three\"],\n", + " # window_funcs={\"t\": (np.mean, 5)}, # window functions can be slow when indexing multiple dims\n", + " cmap=\"gnuplot2\", \n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "3ccea6c6-9580-4720-bce8-a5507cf867a3", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "78a4ed0f59734124a7f3ee23e373e64a", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "VBox(children=(JupyterWgpuCanvas(), IntSlider(value=0, description='dimension: t', max=99), IntSlider(value=0,…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "iw.show()" + ] + }, + { + "cell_type": "markdown", + "id": "2382809c-4c7d-4da4-9955-71d316dee46a", + "metadata": {}, + "source": [ + "### window functions, can be slow when you have \"t\" and \"z\"" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "fd4433a9-2add-417c-a618-5891371efae0", + "metadata": {}, + "outputs": [], + "source": [ + "iw.window_funcs = {\"t\": (np.mean, 11)}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3090a7e2-558e-4975-82f4-6a67ae141900", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}