From a12ff01bc9755183c4c19162829571228c507b0d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 5 Mar 2023 06:49:09 -0500 Subject: [PATCH 01/26] linear region selector with basic functionality but wonky control --- fastplotlib/graphics/selectors/__init__.py | 0 fastplotlib/graphics/selectors/linear.py | 152 +++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 fastplotlib/graphics/selectors/__init__.py create mode 100644 fastplotlib/graphics/selectors/linear.py diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/graphics/selectors/linear.py b/fastplotlib/graphics/selectors/linear.py new file mode 100644 index 000000000..4e9934944 --- /dev/null +++ b/fastplotlib/graphics/selectors/linear.py @@ -0,0 +1,152 @@ +from typing import * +import numpy as np +from time import time + +import pygfx +from pygfx.linalg import Vector3 + +from .._base import Graphic, Interaction +from ..features._base import GraphicFeature, FeatureEvent + + +# positions for indexing the BoxGeometry to set the "width" and "height" of the box +# hacky but I don't think we can morph meshes in pygfx yet: https://github.com/pygfx/pygfx/issues/346 +x_right = np.array([ + True, True, True, True, False, False, False, False, False, + True, False, True, True, False, True, False, False, True, + False, True, True, False, True, False +]) + +x_left = np.array([ + False, False, False, False, True, True, True, True, True, + False, True, False, False, True, False, True, True, False, + True, False, False, True, False, True +]) + +y_top = np.array([ + False, True, False, True, False, True, False, True, True, + True, True, True, False, False, False, False, False, False, + True, True, False, False, True, True +]) + +y_bottom = np.array([ + True, False, True, False, True, False, True, False, False, + False, False, False, True, True, True, True, True, True, + False, False, True, True, False, False +]) + + +class LinearBoundsFeature(GraphicFeature): + def __init__(self, parent, bounds: Tuple[int, int]): + super(LinearBoundsFeature, self).__init__(parent, data=bounds) + + def _set(self, value): + # sets new bounds + if not isinstance(value, tuple): + raise TypeError( + "Bounds must be a tuple in the form of `(min_bound, max_bound)`, " + "where `min_bound` and `max_bound` are numeric values." + ) + + self._parent.fill.geometry.positions.data[x_left, 0] = value[0] + self._parent.fill.geometry.positions.data[x_right, 0] = value[1] + self._data = (value[0], value[1]) + + self._parent.fill.geometry.positions.update_range() + + self._feature_changed(key=None, new_data=value) + + def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): + pick_info = { + "index": None, + "collection-index": self._collection_index, + "world_object": self._parent.world_object, + "new_data": new_data + } + + event_data = FeatureEvent(type="bounds", pick_info=pick_info) + + self._call_event_handlers(event_data) + + +class LinearSelector(Graphic, Interaction): + """Linear region selector, for lines or line collections.""" + feature_events = ( + "bounds" + ) + + def __init__( + self, + bounds: Tuple[int, int], + height: int, + position: Tuple[int, int], + fill_color=(0.1, 0.1, 0.1), + edge_color="w", + name: str = None + ): + super(LinearSelector, self).__init__(name=name) + + self._world_object = pygfx.Group() + + self.fill = pygfx.Mesh( + pygfx.box_geometry(1, height, 1), + pygfx.MeshBasicMaterial(color=fill_color) + ) + + self.fill.position.set(*position, -2) + + self.fill.add_event_handler(self._move_start, "double_click") + self.fill.add_event_handler(self._move, "pointer_move") + self.fill.add_event_handler(self._move_end, "click") + + self.world_object.add(self.fill) + + self._move_info = None + # self.fill.add_event_handler( + + self.edges = None + + self.bounds = LinearBoundsFeature(self, bounds) + self.bounds = bounds + self.timer = 0 + + # self._plane = + + def _move_start(self, ev): + self._move_info = {"last_pos": (ev.x, ev.y)} + self.timer = time() + print(self._move_info) + + def _move(self, ev): + if self._move_info is None: + return + + if time() - self.timer > 2: + self._move_end(ev) + return + + print("moving!") + print(ev.x, ev.y) + + last = self._move_info["last_pos"] + + delta = (last[0] - ev.x, last[1] - ev.y) + + self._move_info = {"last_pos": (ev.x, ev.y)} + + # adjust x vals + self.bounds = (self.bounds()[0] - delta[0], self.bounds()[1] - delta[0]) + + print(self._move_info) + + self.timer = time() + + def _move_end(self, ev): + print("move end") + self._move_info = None + + def _set_feature(self, feature: str, new_data: Any, indices: Any): + pass + + def _reset_feature(self, feature: str): + pass \ No newline at end of file From 8553dc8c1938223a64fe02bc1463d0f5b5bd2b9e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 7 Mar 2023 23:48:53 -0500 Subject: [PATCH 02/26] fix frame_apply. new: grid_plot_kwargs, reset_vmin_vmax() --- fastplotlib/widgets/image.py | 54 ++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 32255629e..cbae36826 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -170,6 +170,7 @@ def __init__( vmin_vmax_sliders: bool = False, grid_shape: Tuple[int, int] = None, names: List[str] = None, + grid_plot_kwargs: dict = None, **kwargs ): """ @@ -471,12 +472,12 @@ def __init__( if vmin_vmax_sliders: data_range = np.ptp(minmax) - data_range_30p = np.ptp(minmax) * 0.3 + data_range_40p = np.ptp(minmax) * 0.3 minmax_slider = FloatRangeSlider( value=minmax, - min=minmax[0] - data_range_30p, - max=minmax[1] + data_range_30p, + min=minmax[0] - data_range_40p, + max=minmax[1] + data_range_40p, step=data_range / 150, description=f"min-max", readout=True, @@ -494,11 +495,15 @@ def __init__( kwargs["vmin"], kwargs["vmax"] = minmax frame = self._process_indices(self.data[0], slice_indices=self._current_index) + frame = self._process_frame_apply(frame, 0) self.image_graphics: List[ImageGraphic] = [self.plot.add_image(data=frame, name="image", **kwargs)] elif self._plot_type == "grid": - self._plot: GridPlot = GridPlot(shape=grid_shape, controllers="sync") + if grid_plot_kwargs is None: + grid_plot_kwargs = {"controllers": "sync"} + + self._plot: GridPlot = GridPlot(shape=grid_shape, **grid_plot_kwargs) self.image_graphics = list() for data_ix, (d, subplot) in enumerate(zip(self.data, self.plot)): @@ -513,12 +518,12 @@ def __init__( if vmin_vmax_sliders: data_range = np.ptp(minmax) - data_range_30p = np.ptp(minmax) * 0.4 + data_range_40p = np.ptp(minmax) * 0.4 minmax_slider = FloatRangeSlider( value=minmax, - min=minmax[0] - data_range_30p, - max=minmax[1] + data_range_30p, + min=minmax[0] - data_range_40p, + max=minmax[1] + data_range_40p, step=data_range / 150, description=f"mm: {name_slider}", readout=True, @@ -539,6 +544,7 @@ def __init__( _kwargs = kwargs frame = self._process_indices(d, slice_indices=self._current_index) + frame = self._process_frame_apply(frame, data_ix) ig = ImageGraphic(frame, name="image", **_kwargs) subplot.add_graphic(ig) subplot.name = name @@ -767,11 +773,17 @@ def _get_window_indices(self, data_ix, dim, indices_dim): return indices_dim def _process_frame_apply(self, array, data_ix) -> np.ndarray: + if callable(self.frame_apply): + return self.frame_apply(array) + if data_ix not in self.frame_apply.keys(): return array - if self.frame_apply[data_ix] is not None: + + elif self.frame_apply[data_ix] is not None: return self.frame_apply[data_ix](array) + return array + def _slider_value_changed( self, dimension: str, @@ -801,6 +813,32 @@ def _set_slider_layout(self, *args): for mm in self.vmin_vmax_sliders: mm.layout = Layout(width=f"{w}px") + def _get_vmin_vmax_range(self, data: np.ndarray) -> Tuple[int, int]: + minmax = quick_min_max(data) + + data_range = np.ptp(minmax) + data_range_40p = np.ptp(minmax) * 0.4 + + _range = ( + minmax, + data_range, + minmax[0] - data_range_40p, + minmax[1] + data_range_40p + ) + + return _range + + def reset_vmin_vmax(self): + """ + Reset the vmin and vmax w.r.t. the currently displayed image(s) + """ + for i, ig in enumerate(self.image_graphics): + mm = self._get_vmin_vmax_range(ig.data()) + self.vmin_vmax_sliders[i].min = mm[2] + self.vmin_vmax_sliders[i].max = mm[3] + self.vmin_vmax_sliders[i].step = mm[1] / 150 + self.vmin_vmax_sliders[i].value = mm[0] + def show(self): """ Show the widget From 72bdc8825c9a7a23a129183d5b275e9ad158c351 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 8 Mar 2023 02:21:37 -0500 Subject: [PATCH 03/26] add grid_plot_kwargs to ImageWidget docstring --- fastplotlib/widgets/image.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index cbae36826..b84375e01 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -228,6 +228,9 @@ def __init__( grid_shape: Optional[Tuple[int, int]] manually provide the shape for a gridplot, otherwise a square gridplot is approximated. + grid_plot_kwargs: dict, optional + passed to `GridPlot` + names: Optional[str] gives names to the subplots From 516142986114ee8834aa50ad39903790055ae350 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 8 Mar 2023 02:22:30 -0500 Subject: [PATCH 04/26] better init of controllers for GridPlot, now allows controller objects or ints for controllers arg --- fastplotlib/layouts/_gridplot.py | 42 ++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/fastplotlib/layouts/_gridplot.py b/fastplotlib/layouts/_gridplot.py index 3abb4fa63..1ea21e9a1 100644 --- a/fastplotlib/layouts/_gridplot.py +++ b/fastplotlib/layouts/_gridplot.py @@ -71,8 +71,9 @@ def __init__( # create the array representing the views for each subplot in the grid cameras = np.array([cameras] * self.shape[0] * self.shape[1]).reshape(self.shape) - if controllers == "sync": - controllers = np.zeros(self.shape[0] * self.shape[1], dtype=int).reshape(self.shape) + if isinstance(controllers, str): + if controllers == "sync": + controllers = np.zeros(self.shape[0] * self.shape[1], dtype=int).reshape(self.shape) if controllers is None: controllers = np.arange(self.shape[0] * self.shape[1]).reshape(self.shape) @@ -82,13 +83,31 @@ def __init__( if controllers.shape != self.shape: raise ValueError + self._controllers = np.empty(shape=cameras.shape, dtype=object) + cameras = to_array(cameras) if cameras.shape != self.shape: raise ValueError - if not np.all(np.sort(np.unique(controllers)) == np.arange(np.unique(controllers).size)): - raise ValueError("controllers must be consecutive integers") + # create controllers if the arguments were integers + if np.issubdtype(controllers.dtype, np.integer): + if not np.all(np.sort(np.unique(controllers)) == np.arange(np.unique(controllers).size)): + raise ValueError("controllers must be consecutive integers") + + for controller in np.unique(controllers): + cam = np.unique(cameras[controllers == controller]) + if cam.size > 1: + raise ValueError( + f"Controller id: {controller} has been assigned to multiple different camera types") + + self._controllers[controllers == controller] = create_controller(cam[0]) + # else assume it's a single pygfx.Controller instance or a list of controllers + else: + if isinstance(controllers, pygfx.Controller): + self._controllers = np.array([controllers] * shape[0] * shape[1]).reshape(shape) + else: + self._controllers = np.array(controllers).reshape(shape) if canvas is None: canvas = WgpuCanvas() @@ -111,18 +130,9 @@ def __init__( self._subplots: np.ndarray[Subplot] = np.ndarray(shape=(nrows, ncols), dtype=object) # self.viewports: np.ndarray[Subplot] = np.ndarray(shape=(nrows, ncols), dtype=object) - self._controllers: List[pygfx.PanZoomController] = [ - pygfx.PanZoomController() for i in range(np.unique(controllers).size) - ] - - self._controllers = np.empty(shape=cameras.shape, dtype=object) - - for controller in np.unique(controllers): - cam = np.unique(cameras[controllers == controller]) - if cam.size > 1: - raise ValueError(f"Controller id: {controller} has been assigned to multiple different camera types") - - self._controllers[controllers == controller] = create_controller(cam[0]) + # self._controllers: List[pygfx.PanZoomController] = [ + # pygfx.PanZoomController() for i in range(np.unique(controllers).size) + # ] for i, j in self._get_iterator(): position = (i, j) From 216216ae5c318d9a4f7c267fdce30fff3e89e309 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 9 Mar 2023 01:38:23 -0500 Subject: [PATCH 05/26] make feature buffer public, make isolated buffer optional for Image and Heatmap --- fastplotlib/graphics/features/_base.py | 10 ++--- fastplotlib/graphics/features/_colors.py | 8 ++-- fastplotlib/graphics/features/_data.py | 31 ++++++++----- fastplotlib/graphics/image.py | 57 +++++++++++++++++++++--- 4 files changed, 80 insertions(+), 26 deletions(-) diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index 7177b7bae..80029180e 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -4,7 +4,7 @@ from typing import * import numpy as np -from pygfx import Buffer +from pygfx import Buffer, Texture supported_dtypes = [ @@ -226,7 +226,7 @@ def _update_range(self, key): @property @abstractmethod - def _buffer(self) -> Buffer: + def buffer(self) -> Union[Buffer, Texture]: pass @property @@ -238,21 +238,21 @@ def _update_range_indices(self, key): key = cleanup_slice(key, self._upper_bound) if isinstance(key, int): - self._buffer.update_range(key, size=1) + self.buffer.update_range(key, size=1) return # else if it's a slice obj if isinstance(key, slice): if key.step == 1: # we cleaned up the slice obj so step of None becomes 1 # update range according to size using the offset - self._buffer.update_range(offset=key.start, size=key.stop - key.start) + self.buffer.update_range(offset=key.start, size=key.stop - key.start) else: step = key.step # convert slice to indices ixs = range(key.start, key.stop, step) for ix in ixs: - self._buffer.update_range(ix, size=1) + self.buffer.update_range(ix, size=1) else: raise TypeError("must pass int or slice to update range") diff --git a/fastplotlib/graphics/features/_colors.py b/fastplotlib/graphics/features/_colors.py index 7833e0b2c..a5147b95e 100644 --- a/fastplotlib/graphics/features/_colors.py +++ b/fastplotlib/graphics/features/_colors.py @@ -7,11 +7,11 @@ class ColorFeature(GraphicFeatureIndexable): @property - def _buffer(self): + def buffer(self): return self._parent.world_object.geometry.colors def __getitem__(self, item): - return self._buffer.data[item] + return self.buffer.data[item] def __init__(self, parent, colors, n_colors: int, alpha: float = 1.0, collection_index: int = None): """ @@ -113,7 +113,7 @@ def __setitem__(self, key, value): raise ValueError("fancy indexing for colors must be 2-dimension, i.e. [n_datapoints, RGBA]") # set the user passed data directly - self._buffer.data[key] = value + self.buffer.data[key] = value # update range # first slice obj is going to be the indexing so use key[0] @@ -162,7 +162,7 @@ def __setitem__(self, key, value): else: raise ValueError("numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)") - self._buffer.data[key] = new_colors + self.buffer.data[key] = new_colors self._update_range(key) self._feature_changed(key, new_colors) diff --git a/fastplotlib/graphics/features/_data.py b/fastplotlib/graphics/features/_data.py index 95e549247..8e9599fa6 100644 --- a/fastplotlib/graphics/features/_data.py +++ b/fastplotlib/graphics/features/_data.py @@ -1,7 +1,7 @@ from typing import * import numpy as np -from pygfx import Buffer, Texture +from pygfx import Buffer, Texture, TextureView from ._base import GraphicFeatureIndexable, cleanup_slice, FeatureEvent, to_gpu_supported_dtype @@ -16,11 +16,11 @@ def __init__(self, parent, data: Any, collection_index: int = None): super(PointsDataFeature, self).__init__(parent, data, collection_index=collection_index) @property - def _buffer(self) -> Buffer: + def buffer(self) -> Buffer: return self._parent.world_object.geometry.positions def __getitem__(self, item): - return self._buffer.data[item] + return self.buffer.data[item] def _fix_data(self, data, parent): graphic_type = parent.__class__.__name__ @@ -54,7 +54,7 @@ def __setitem__(self, key, value): # otherwise assume that they have the right shape # numpy will throw errors if it can't broadcast - self._buffer.data[key] = value + self.buffer.data[key] = value self._update_range(key) # avoid creating dicts constantly if there are no events to handle if len(self._event_handlers) > 0: @@ -97,21 +97,25 @@ def __init__(self, parent, data: Any): "``[x_dim, y_dim]`` or ``[x_dim, y_dim, rgb]``" ) - data = to_gpu_supported_dtype(data) super(ImageDataFeature, self).__init__(parent, data) @property - def _buffer(self) -> Texture: + def buffer(self) -> Texture: + """Texture buffer for the image data""" return self._parent.world_object.geometry.grid.texture + def update_gpu(self): + """Update the GPU with the buffer""" + self._update_range(None) + def __getitem__(self, item): - return self._buffer.data[item] + return self.buffer.data[item] def __setitem__(self, key, value): # make sure float32 value = to_gpu_supported_dtype(value) - self._buffer.data[key] = value + self.buffer.data[key] = value self._update_range(key) # avoid creating dicts constantly if there are no events to handle @@ -119,7 +123,7 @@ def __setitem__(self, key, value): self._feature_changed(key, value) def _update_range(self, key): - self._buffer.update_range((0, 0, 0), size=self._buffer.size) + self.buffer.update_range((0, 0, 0), size=self.buffer.size) def _feature_changed(self, key, new_data): if key is not None: @@ -144,9 +148,14 @@ def _feature_changed(self, key, new_data): class HeatmapDataFeature(ImageDataFeature): @property - def _buffer(self) -> List[Texture]: + def buffer(self) -> List[Texture]: + """list of Texture buffer for the image data""" return [img.geometry.grid.texture for img in self._parent.world_object.children] + def update_gpu(self): + """Update the GPU with the buffer""" + self._update_range(None) + def __getitem__(self, item): return self._data[item] @@ -162,7 +171,7 @@ def __setitem__(self, key, value): self._feature_changed(key, value) def _update_range(self, key): - for buffer in self._buffer: + for buffer in self.buffer: buffer.update_range((0, 0, 0), size=buffer.size) def _feature_changed(self, key, new_data): diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 854f757f2..83cae3de8 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -2,11 +2,13 @@ from math import ceil from itertools import product +import numpy as np import pygfx from pygfx.utils import unpack_bitfield from ._base import Graphic, Interaction, PreviouslyModifiedData from .features import ImageCmapFeature, ImageDataFeature, HeatmapDataFeature, HeatmapCmapFeature +from .features._base import to_gpu_supported_dtype from ..utils import quick_min_max @@ -23,6 +25,7 @@ def __init__( vmax: int = None, cmap: str = 'plasma', filter: str = "nearest", + isolated_buffer: bool = True, *args, **kwargs ): @@ -43,6 +46,10 @@ def __init__( colormap to use to display the image data, ignored if data is RGB filter: str, optional, default "nearest" interpolation filter, one of "nearest" or "linear" + isolated_buffer: bool, default True + If True, initialize a buffer with the same shape as the input data and then + set the data, useful if the data arrays are ready-only such as memmaps. + If False, the input array is itself used as the buffer. args: additional arguments passed to Graphic kwargs: @@ -65,20 +72,29 @@ def __init__( super().__init__(*args, **kwargs) - self.data = ImageDataFeature(self, data) + data = to_gpu_supported_dtype(data) + + # TODO: we need to organize and do this better + if isolated_buffer: + # initialize a buffer with the same shape as the input data + # we do not directly use the input data array as the buffer + # because if the input array is a read-only type, such as + # numpy memmaps, we would not be able to change the image data + buffer_init = np.zeros(shape=data.shape, dtype=data.dtype) + else: + buffer_init = data if (vmin is None) or (vmax is None): vmin, vmax = quick_min_max(data) - texture_view = pygfx.Texture(self.data(), dim=2).get_view(filter=filter) + texture_view = pygfx.Texture(buffer_init, dim=2).get_view(filter=filter) geometry = pygfx.Geometry(grid=texture_view) # if data is RGB - if self.data().ndim == 3: + if data.ndim == 3: self.cmap = None material = pygfx.ImageBasicMaterial(clim=(vmin, vmax)) - # if data is just 2D without color information, use colormap LUT else: self.cmap = ImageCmapFeature(self, cmap) @@ -89,6 +105,13 @@ def __init__( material ) + self.data = ImageDataFeature(self, data) + # TODO: we need to organize and do this better + if isolated_buffer: + # if the buffer was initialized with zeros + # set it with the actual data + self.data = data + @property def vmin(self) -> float: """Minimum contrast limit.""" @@ -176,6 +199,7 @@ def __init__( cmap: str = 'plasma', filter: str = "nearest", chunk_size: int = 8192, + isolated_buffer: bool = True, *args, **kwargs ): @@ -198,6 +222,10 @@ def __init__( interpolation filter, one of "nearest" or "linear" chunk_size: int, default 8192, max 8192 chunk size for each tile used to make up the heatmap texture + isolated_buffer: bool, default True + If True, initialize a buffer with the same shape as the input data and then + set the data, useful if the data arrays are ready-only such as memmaps. + If False, the input array is itself used as the buffer. args: additional arguments passed to Graphic kwargs: @@ -223,7 +251,17 @@ def __init__( if chunk_size > 8192: raise ValueError("Maximum chunk size is 8192") - self.data = HeatmapDataFeature(self, data) + data = to_gpu_supported_dtype(data) + + # TODO: we need to organize and do this better + if isolated_buffer: + # initialize a buffer with the same shape as the input data + # we do not directly use the input data array as the buffer + # because if the input array is a read-only type, such as + # numpy memmaps, we would not be able to change the image data + buffer_init = np.zeros(shape=data.shape, dtype=data.dtype) + else: + buffer_init = data row_chunks = range(ceil(data.shape[0] / chunk_size)) col_chunks = range(ceil(data.shape[1] / chunk_size)) @@ -249,7 +287,7 @@ def __init__( # x and y positions of the Tile in world space coordinates y_pos, x_pos = row_start, col_start - tex_view = pygfx.Texture(data[row_start:row_stop, col_start:col_stop], dim=2).get_view(filter=filter) + tex_view = pygfx.Texture(buffer_init[row_start:row_stop, col_start:col_stop], dim=2).get_view(filter=filter) geometry = pygfx.Geometry(grid=tex_view) # material = pygfx.ImageBasicMaterial(clim=(0, 1), map=self.cmap()) @@ -264,6 +302,13 @@ def __init__( self.world_object.add(img) + self.data = HeatmapDataFeature(self, buffer_init) + # TODO: we need to organize and do this better + if isolated_buffer: + # if the buffer was initialized with zeros + # set it with the actual data + self.data = data + @property def vmin(self) -> float: """Minimum contrast limit.""" From 172a86d5cdf9568abf48cb2006547f349254a07e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 9 Mar 2023 01:43:45 -0500 Subject: [PATCH 06/26] bugfix, event handler is private --- fastplotlib/graphics/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 00a83ab4e..255a2fec7 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -159,7 +159,7 @@ def link( """ if event_type in PYGFX_EVENTS: - self.world_object.add_event_handler(self.event_handler, event_type) + self.world_object.add_event_handler(self._event_handler, event_type) # make sure event is valid elif event_type in self.feature_events: From 350f359d54cda7e95a4703f225dcfb80fdbe0ce7 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 9 Mar 2023 02:31:02 -0500 Subject: [PATCH 07/26] temporary workaround for image and heatmap data buffers --- fastplotlib/graphics/features/_data.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/fastplotlib/graphics/features/_data.py b/fastplotlib/graphics/features/_data.py index 8e9599fa6..3d877bace 100644 --- a/fastplotlib/graphics/features/_data.py +++ b/fastplotlib/graphics/features/_data.py @@ -108,6 +108,9 @@ def update_gpu(self): """Update the GPU with the buffer""" self._update_range(None) + def __call__(self, *args, **kwargs): + return self.buffer.data + def __getitem__(self, item): return self.buffer.data[item] @@ -159,6 +162,9 @@ def update_gpu(self): def __getitem__(self, item): return self._data[item] + def __call__(self, *args, **kwargs): + return self.buffer.data + def __setitem__(self, key, value): # make sure supported type, not float64 etc. value = to_gpu_supported_dtype(value) From 5c39f2eced599e21858f2961da3f46431f7ae5e0 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sat, 25 Mar 2023 17:20:31 -0400 Subject: [PATCH 08/26] fix docstring typo --- fastplotlib/widgets/image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index b84375e01..fe5e0d73a 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -174,8 +174,8 @@ def __init__( **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. + A high level widget for displaying n-dimensional image data in conjunction with automatically generated + sliders for navigating through 1-2 selected dimensions within image data. Can display a single n-dimensional image array or a grid of n-dimensional images. From 8221a944ab5b5e104a7ff08c8b663c6f6920c13b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 29 Mar 2023 18:18:37 -0400 Subject: [PATCH 09/26] fixes #155 --- fastplotlib/widgets/image.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index fe5e0d73a..32f444a32 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -837,10 +837,15 @@ def reset_vmin_vmax(self): """ for i, ig in enumerate(self.image_graphics): mm = self._get_vmin_vmax_range(ig.data()) - self.vmin_vmax_sliders[i].min = mm[2] - self.vmin_vmax_sliders[i].max = mm[3] - self.vmin_vmax_sliders[i].step = mm[1] / 150 - self.vmin_vmax_sliders[i].value = mm[0] + + state = { + "value": mm[0], + "step": mm[1] / 150, + "min": mm[2], + "max": mm[3] + } + + self.vmin_vmax_sliders[i].set_state(state) def show(self): """ From 6d4141b35fce00b12f3d450e87f6fd0c4d933aa1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 30 Mar 2023 13:47:50 -0400 Subject: [PATCH 10/26] bugfix gridplot camears as list --- fastplotlib/layouts/_gridplot.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fastplotlib/layouts/_gridplot.py b/fastplotlib/layouts/_gridplot.py index 1ea21e9a1..3f694b007 100644 --- a/fastplotlib/layouts/_gridplot.py +++ b/fastplotlib/layouts/_gridplot.py @@ -83,9 +83,10 @@ def __init__( if controllers.shape != self.shape: raise ValueError + cameras = to_array(cameras) + self._controllers = np.empty(shape=cameras.shape, dtype=object) - cameras = to_array(cameras) if cameras.shape != self.shape: raise ValueError From 8ffab35afed159132fc7ca93c35f8cf13af388d1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 2 Apr 2023 23:38:42 -0400 Subject: [PATCH 11/26] proper garbage collection of WorldObject, implemented and works for ImageGraphic --- fastplotlib/graphics/_base.py | 25 ++++++++++++++------- fastplotlib/graphics/image.py | 4 +++- fastplotlib/layouts/_base.py | 41 ++++++++++++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 255a2fec7..ea293dfdf 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -1,4 +1,5 @@ from typing import * +import weakref from warnings import warn import numpy as np @@ -14,6 +15,11 @@ from dataclasses import dataclass +# dict that holds all world objects for a given python kernel/session +# Graphic objects only use proxies to WorldObjects +WORLD_OBJECTS: Dict[str, WorldObject] = dict() #: {hex id str: WorldObject} + + PYGFX_EVENTS = [ "key_down", "key_up", @@ -58,10 +64,15 @@ def __init__( self.registered_callbacks = dict() self.present = PresentFeature(parent=self) + self._world_objects = WORLD_OBJECTS + @property def world_object(self) -> WorldObject: - """Associated pygfx WorldObject.""" - return self._world_object + """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly.""" + return weakref.proxy(self._world_objects[hex(id(self))]) + + def _set_world_object(self, wo: WorldObject): + self._world_objects[hex(id(self))] = wo @property def position(self) -> Vector3: @@ -75,7 +86,7 @@ def visible(self) -> bool: return self.world_object.visible @visible.setter - def visible(self, v) -> bool: + def visible(self, v: bool): """Access or change the visibility.""" self.world_object.visible = v @@ -100,6 +111,9 @@ def __repr__(self): else: return rval + def __del__(self): + del self._world_objects[hex(id(self))] + class Interaction(ABC): """Mixin class that makes graphics interactive""" @@ -271,11 +285,6 @@ def __init__(self, name: str = None): super(GraphicCollection, self).__init__(name) self._graphics: List[Graphic] = list() - @property - def world_object(self) -> Group: - """Returns the underling pygfx WorldObject.""" - return self._world_object - @property def graphics(self) -> Tuple[Graphic]: """returns the Graphics within this collection""" diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 83cae3de8..91da9687d 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -100,11 +100,13 @@ def __init__( self.cmap = ImageCmapFeature(self, cmap) material = pygfx.ImageBasicMaterial(clim=(vmin, vmax), map=self.cmap()) - self._world_object: pygfx.Image = pygfx.Image( + world_object = pygfx.Image( geometry, material ) + self._set_world_object(world_object) + self.data = ImageDataFeature(self, data) # TODO: we need to organize and do this better if isolated_buffer: diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index ce35135c7..c064a90eb 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_base.py @@ -3,7 +3,7 @@ Viewport, WgpuRenderer from wgpu.gui.auto import WgpuCanvas from warnings import warn -from ..graphics._base import Graphic +from ..graphics._base import Graphic, WORLD_OBJECTS from ..graphics.line_slider import LineSlider from typing import * @@ -287,8 +287,9 @@ def auto_scale(self, maintain_aspect: bool = False, zoom: float = 0.8): def remove_graphic(self, graphic: Graphic): """ - Remove a graphic from the scene. Note: This does not garbage collect the graphic, - you can add it back to the scene after removing it. + Remove a ``Graphic`` from the scene. Note: This does not garbage collect the graphic, + you can add it back to the scene after removing it. Use ``delete_graphic()`` to + delete and garbage collect a ``Graphic``. Parameters ---------- @@ -296,8 +297,42 @@ def remove_graphic(self, graphic: Graphic): The graphic to remove from the scene """ + self.scene.remove(graphic.world_object) + def delete_graphic(self, graphic: Graphic): + """ + Delete the graphic, garbage collects and frees GPU VRAM. + + Parameters + ---------- + graphic: Graphic or GraphicCollection + The graphic to delete + + """ + + if graphic not in self._graphics: + raise KeyError + + if graphic.world_object in self.scene.children: + self.scene.remove(graphic.world_object) + + self._graphics.remove(graphic) + + # delete associated world object to free GPU VRAM + loc = hex(id(graphic)) + del WORLD_OBJECTS[loc] + + del graphic + + def clear(self): + """ + Clear the Plot or Subplot. Also performs garbage collection, i.e. runs ``delete_graphic`` on all graphics. + """ + + for g in self._graphics: + self.delete_graphic(g) + def __getitem__(self, name: str): for graphic in self._graphics: if graphic.name == name: From 7e087db3ed6cd71eecc3b9943d4ee8c5474844c2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Apr 2023 01:21:10 -0400 Subject: [PATCH 12/26] gc works for Line, LineCollection, Scatter not tested, regular RAM is not gc, WIP --- fastplotlib/graphics/line.py | 4 +++- fastplotlib/graphics/line_collection.py | 2 +- fastplotlib/graphics/scatter.py | 4 +++- fastplotlib/layouts/_base.py | 14 ++++++++++++-- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 926f5729c..0b1e579bc 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -85,12 +85,14 @@ def __init__( self.thickness = ThicknessFeature(self, thickness) - self._world_object: pygfx.Line = pygfx.Line( + world_object: pygfx.Line = pygfx.Line( # self.data.feature_data because data is a Buffer geometry=pygfx.Geometry(positions=self.data(), colors=self.colors()), material=material(thickness=self.thickness(), vertex_colors=True) ) + self._set_world_object(world_object) + if z_position is not None: self.world_object.position.z = z_position diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 07fc9cad7..ac27f5b4d 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -157,7 +157,7 @@ def __init__( "or must be a str of tuple/list with the same length as the data" ) - self._world_object = pygfx.Group() + self._set_world_object(pygfx.Group()) for i, d in enumerate(data): if isinstance(z_position, list): diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 016d1cac9..b53985de0 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -72,9 +72,11 @@ def __init__( super(ScatterGraphic, self).__init__(*args, **kwargs) - self._world_object: pygfx.Points = pygfx.Points( + world_object = pygfx.Points( pygfx.Geometry(positions=self.data(), sizes=sizes, colors=self.colors()), material=pygfx.PointsMaterial(vertex_colors=True, vertex_sizes=True) ) + self._set_world_object(world_object) + self.world_object.position.z = z_position diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index c064a90eb..5ed9b19f3 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_base.py @@ -3,7 +3,7 @@ Viewport, WgpuRenderer from wgpu.gui.auto import WgpuCanvas from warnings import warn -from ..graphics._base import Graphic, WORLD_OBJECTS +from ..graphics._base import Graphic, GraphicCollection, WORLD_OBJECTS from ..graphics.line_slider import LineSlider from typing import * @@ -319,8 +319,18 @@ def delete_graphic(self, graphic: Graphic): self._graphics.remove(graphic) - # delete associated world object to free GPU VRAM + # for GraphicCollection objects + if isinstance(graphic, GraphicCollection): + # clear Group + graphic.world_object.clear() + # delete all child world objects in the collection + for g in graphic.graphics: + subloc = hex(id(g)) + del WORLD_OBJECTS[subloc] + + # get mem location of graphic loc = hex(id(graphic)) + # delete world object del WORLD_OBJECTS[loc] del graphic From 9713a4c2c64b82084b2949b50bd3ff8abda3de57 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Apr 2023 01:49:29 -0400 Subject: [PATCH 13/26] nb to explore garbage collection --- examples/garbage_collection.ipynb | 345 ++++++++++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 examples/garbage_collection.ipynb diff --git a/examples/garbage_collection.ipynb b/examples/garbage_collection.ipynb new file mode 100644 index 000000000..2b746fda0 --- /dev/null +++ b/examples/garbage_collection.ipynb @@ -0,0 +1,345 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "1ef0578e-09e1-45ff-bd34-84472db3885e", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from fastplotlib import Plot\n", + "import numpy as np\n", + "import sys\n", + "\n", + "import weakref\n", + "import gc\n", + "import os, psutil\n", + "process = psutil.Process(os.getpid())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1bb6bc6f-7786-4d23-9eb1-e30bbc66c798", + "metadata": {}, + "outputs": [], + "source": [ + "print(process.memory_info().rss / 1024 / 1024)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b23ba640-88ec-40d9-b53c-c8cbb3e39b0b", + "metadata": {}, + "outputs": [], + "source": [ + "plot = Plot()\n", + "\n", + "# a = np.random.rand(5_000_000)\n", + "# plot.add_line(a)\n", + "\n", + "\n", + "plot.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17f46f21-b29d-4dd3-9496-989bbb240f50", + "metadata": {}, + "outputs": [], + "source": [ + "print(process.memory_info().rss / 1024 / 1024)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e26d392f-6afd-4e89-a685-d618065d3caf", + "metadata": {}, + "outputs": [], + "source": [ + "a = np.random.rand(1_000, 5_000)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31e3027a-56cf-4f7b-ba78-aed4f78eef47", + "metadata": {}, + "outputs": [], + "source": [ + "print(process.memory_info().rss / 1024 / 1024)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9cf2ceaa-5b01-4e12-be39-f267cd355833", + "metadata": {}, + "outputs": [], + "source": [ + "plot.add_line_stack(a)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "53170858-ae72-4451-8647-7d5b1f9da75e", + "metadata": {}, + "outputs": [], + "source": [ + "print(process.memory_info().rss / 1024 / 1024)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ee2481c9-82e3-4043-85fd-21a0cdf21187", + "metadata": {}, + "outputs": [], + "source": [ + "plot.auto_scale()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e4e0f73b-c58a-40e7-acf5-07a1f70d2821", + "metadata": {}, + "outputs": [], + "source": [ + "plot.delete_graphic(plot.graphics[0])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "56c64498-229e-48b7-9fb1-f7c327fff2ae", + "metadata": {}, + "outputs": [], + "source": [ + "print(process.memory_info().rss / 1024 / 1024)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dd6a26c1-ea81-469d-ae7a-95839b1f9d5a", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from wgpu.gui.auto import WgpuCanvas, run\n", + "import pygfx as gfx\n", + "import subprocess\n", + "\n", + "canvas = WgpuCanvas()\n", + "renderer = gfx.WgpuRenderer(canvas)\n", + "scene = gfx.Scene()\n", + "camera = gfx.OrthographicCamera(5000, 5000)\n", + "camera.position.x = 2048\n", + "camera.position.y = 2048\n", + "\n", + "\n", + "def make_image():\n", + " data = np.random.rand(4096, 4096).astype(np.float32)\n", + "\n", + " return gfx.Image(\n", + " gfx.Geometry(grid=gfx.Texture(data, dim=2)),\n", + " gfx.ImageBasicMaterial(clim=(0, 1)),\n", + " )\n", + "\n", + "\n", + "class Graphic:\n", + " def __init__(self):\n", + " data = np.random.rand(4096, 4096).astype(np.float32)\n", + " self.wo = gfx.Image(\n", + " gfx.Geometry(grid=gfx.Texture(data, dim=2)),\n", + " gfx.ImageBasicMaterial(clim=(0, 1)),\n", + " )\n", + "\n", + "\n", + "def draw():\n", + " renderer.render(scene, camera)\n", + " canvas.request_draw()\n", + "\n", + "\n", + "def print_nvidia(msg):\n", + " print(msg)\n", + " print(\n", + " subprocess.check_output([\"nvidia-smi\", \"--format=csv\", \"--query-gpu=memory.used\"]).decode().split(\"\\n\")[1]\n", + " )\n", + " print()\n", + "\n", + "\n", + "def add_img(*args):\n", + " print_nvidia(\"Before creating image\")\n", + " img = make_image()\n", + " print_nvidia(\"After creating image\")\n", + " scene.add(img)\n", + " img.add_event_handler(remove_img, \"click\")\n", + " draw()\n", + " print_nvidia(\"After add image to scene\")\n", + "\n", + "\n", + "def remove_img(*args):\n", + " img = scene.children[0]\n", + " scene.remove(img)\n", + " draw()\n", + " print_nvidia(\"After remove image from scene\")\n", + " del img\n", + " draw()\n", + " print_nvidia(\"After del image\")\n", + "\n", + "\n", + "renderer.add_event_handler(add_img, \"double_click\")\n", + "canvas" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2599f430-8b00-4490-9e11-774897be6e77", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from wgpu.gui.auto import WgpuCanvas, run\n", + "import pygfx as gfx\n", + "import subprocess\n", + "\n", + "canvas = WgpuCanvas()\n", + "renderer = gfx.WgpuRenderer(canvas)\n", + "scene = gfx.Scene()\n", + "camera = gfx.OrthographicCamera(5000, 5000)\n", + "camera.position.x = 2048\n", + "camera.position.y = 2048\n", + "\n", + "\n", + "def make_image():\n", + " data = np.random.rand(4096, 4096).astype(np.float32)\n", + "\n", + " return gfx.Image(\n", + " gfx.Geometry(grid=gfx.Texture(data, dim=2)),\n", + " gfx.ImageBasicMaterial(clim=(0, 1)),\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3ec10f26-6544-4ad3-80c1-aa34617dc826", + "metadata": {}, + "outputs": [], + "source": [ + "import weakref" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "acc819a3-cd50-4fdd-a0b5-c442d80847e2", + "metadata": {}, + "outputs": [], + "source": [ + "img = make_image()\n", + "img_ref = weakref.ref(img)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f89da335-3372-486b-b773-9f103d6a9bbd", + "metadata": {}, + "outputs": [], + "source": [ + "img_ref()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c22904ad-d674-43e6-83bb-7a2f7b277c06", + "metadata": {}, + "outputs": [], + "source": [ + "del img" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "573566d7-eb91-4690-958c-d00dd495b3e4", + "metadata": {}, + "outputs": [], + "source": [ + "import gc" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aaef3e89-2bfd-43af-9b8f-824a3f89b85f", + "metadata": {}, + "outputs": [], + "source": [ + "img_ref()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3380f35e-fcc9-43f6-80d2-7e9348cd13b4", + "metadata": {}, + "outputs": [], + "source": [ + "draw()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a27bf7c7-f3ef-4ae8-8ecf-31507f8c0449", + "metadata": {}, + "outputs": [], + "source": [ + "print(\n", + " subprocess.check_output([\"nvidia-smi\", \"--format=csv\", \"--query-gpu=memory.used\"]).decode().split(\"\\n\")[1]\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e4bf2711-8a83-4d9c-a4f7-f50de7ae1715", + "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 +} From 247b44b6d44bfd72c04776c177c5e9a5bdafe105 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Apr 2023 13:55:29 -0400 Subject: [PATCH 14/26] heatmap deletion frees up GPU VRAM --- fastplotlib/graphics/image.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 91da9687d..cb4cf1587 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -274,7 +274,8 @@ def __init__( start_ixs = [list(map(lambda c: c * chunk_size, chunk)) for chunk in chunks] stop_ixs = [list(map(lambda c: c + chunk_size, chunk)) for chunk in start_ixs] - self._world_object = pygfx.Group() + world_object = pygfx.Group() + self._set_world_object(world_object) if (vmin is None) or (vmax is None): vmin, vmax = quick_min_max(data) From 5e9f5446aa4465b4cb615a59390be2edd12d5c9d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Apr 2023 23:31:30 -0400 Subject: [PATCH 15/26] GPU VRAM and system RAM freed for all graphics --- fastplotlib/graphics/_base.py | 42 +++++++++---- fastplotlib/graphics/features/_base.py | 3 +- fastplotlib/graphics/text.py | 4 +- fastplotlib/layouts/_base.py | 83 ++++++++++++++++++-------- fastplotlib/layouts/_subplot.py | 6 +- 5 files changed, 97 insertions(+), 41 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index ea293dfdf..de126804f 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -50,7 +50,8 @@ def __init_subclass__(cls, **kwargs): class Graphic(BaseGraphic): def __init__( - self, name: str = None): + self, name: str = None + ): """ Parameters @@ -64,15 +65,16 @@ def __init__( self.registered_callbacks = dict() self.present = PresentFeature(parent=self) - self._world_objects = WORLD_OBJECTS + # store hex id str of Graphic instance mem location + self.loc: str = hex(id(self)) @property def world_object(self) -> WorldObject: """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly.""" - return weakref.proxy(self._world_objects[hex(id(self))]) + return weakref.proxy(WORLD_OBJECTS[hex(id(self))]) def _set_world_object(self, wo: WorldObject): - self._world_objects[hex(id(self))] = wo + WORLD_OBJECTS[hex(id(self))] = wo @property def position(self) -> Vector3: @@ -112,7 +114,7 @@ def __repr__(self): return rval def __del__(self): - del self._world_objects[hex(id(self))] + del WORLD_OBJECTS[self.loc] class Interaction(ABC): @@ -278,17 +280,21 @@ class PreviouslyModifiedData: indices: Any +COLLECTION_GRAPHICS: dict[str, Graphic] = dict() + + class GraphicCollection(Graphic): """Graphic Collection base class""" def __init__(self, name: str = None): super(GraphicCollection, self).__init__(name) - self._graphics: List[Graphic] = list() + self._graphics: List[str] = list() @property def graphics(self) -> Tuple[Graphic]: - """returns the Graphics within this collection""" - return tuple(self._graphics) + """The Graphics within this collection. Always returns a proxy to the Graphics.""" + proxies = [weakref.proxy(COLLECTION_GRAPHICS[loc]) for loc in self._graphics] + return tuple(proxies) def add_graphic(self, graphic: Graphic, reset_index: True): """Add a graphic to the collection""" @@ -298,7 +304,11 @@ def add_graphic(self, graphic: Graphic, reset_index: True): f"You can only add {self.child_type} to a {self.__class__.__name__}, " f"you are trying to add a {graphic.__class__.__name__}." ) - self._graphics.append(graphic) + + loc = hex(id(graphic)) + COLLECTION_GRAPHICS[loc] = graphic + + self._graphics.append(loc) if reset_index: self._reset_index() self.world_object.add(graphic.world_object) @@ -306,9 +316,19 @@ def add_graphic(self, graphic: Graphic, reset_index: True): def remove_graphic(self, graphic: Graphic, reset_index: True): """Remove a graphic from the collection""" self._graphics.remove(graphic) + if reset_index: self._reset_index() - self.world_object.remove(graphic) + + self.world_object.remove(graphic.world_object) + + def __del__(self): + self.world_object.clear() + + for loc in self._graphics: + del COLLECTION_GRAPHICS[loc] + + super().__del__() def _reset_index(self): for new_index, graphic in enumerate(self._graphics): @@ -374,7 +394,7 @@ def __init__( selection_indices: Union[list, range] the corresponding indices from the parent GraphicCollection that were selected """ - self._parent = parent + self._parent = weakref.proxy(parent) self._selection = selection self._selection_indices = selection_indices diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index 80029180e..da6a177a0 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -2,6 +2,7 @@ from inspect import getfullargspec from warnings import warn from typing import * +import weakref import numpy as np from pygfx import Buffer, Texture @@ -71,7 +72,7 @@ def __init__(self, parent, data: Any, collection_index: int = None): if part of a collection, index of this graphic within the collection """ - self._parent = parent + self._parent = weakref.proxy(parent) self._data = to_gpu_supported_dtype(data) diff --git a/fastplotlib/graphics/text.py b/fastplotlib/graphics/text.py index 665c53606..8225bb300 100644 --- a/fastplotlib/graphics/text.py +++ b/fastplotlib/graphics/text.py @@ -38,11 +38,13 @@ def __init__( """ super(TextGraphic, self).__init__(name=name) - self._world_object = pygfx.Text( + world_object = pygfx.Text( pygfx.TextGeometry(text=text, font_size=size, screen_space=False), pygfx.TextMaterial(color=face_color, outline_color=outline_color, outline_thickness=outline_thickness) ) + self._set_world_object(world_object) + self.world_object.position.set(*position) self.name = None diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index 5ed9b19f3..de352e4f7 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_base.py @@ -1,11 +1,21 @@ +from warnings import warn +from typing import * +import weakref + import numpy as np + from pygfx import Scene, OrthographicCamera, PerspectiveCamera, PanZoomController, OrbitController, \ Viewport, WgpuRenderer from wgpu.gui.auto import WgpuCanvas -from warnings import warn -from ..graphics._base import Graphic, GraphicCollection, WORLD_OBJECTS + +from ..graphics._base import Graphic, GraphicCollection from ..graphics.line_slider import LineSlider -from typing import * + + +# dict to store Graphic instances +# this is the only place where the real references to Graphics are stored in a Python session +# {hex id str: Graphic} +GRAPHICS: Dict[str, Graphic] = dict() class PlotArea: @@ -74,7 +84,9 @@ def __init__( self.renderer.add_event_handler(self.set_viewport_rect, "resize") - self._graphics: List[Graphic] = list() + # list of hex id strings for all graphics managed by this PlotArea + # the real Graphic instances are stored in the ``GRAPHICS`` dict + self._graphics: List[str] = list() # hacky workaround for now to exclude from bbox calculations self._sliders: List[LineSlider] = list() @@ -129,8 +141,13 @@ def controller(self) -> Union[PanZoomController, OrbitController]: @property def graphics(self) -> Tuple[Graphic]: - """returns the Graphics in the plot area""" - return tuple(self._graphics) + """Graphics in the plot area. Always returns a proxy to the Graphic instances.""" + proxies = list() + for loc in self._graphics: + p = weakref.proxy(GRAPHICS[loc]) + proxies.append(p) + + return tuple(proxies) def get_rect(self) -> Tuple[float, float, float, float]: """allows setting the region occupied by the viewport w.r.t. the parent""" @@ -154,7 +171,8 @@ def add_graphic(self, graphic: Graphic, center: bool = True): Parameters ---------- graphic: Graphic or GraphicCollection - Add a Graphic or a GraphicCollection to the plot area + Add a Graphic or a GraphicCollection to the plot area. + Note: this must be a real Graphic instance and not a proxy center: bool, default True Center the camera on the newly added Graphic @@ -168,12 +186,17 @@ def add_graphic(self, graphic: Graphic, center: bool = True): if graphic.name is not None: # skip for those that have no name self._check_graphic_name_exists(graphic.name) + # store in GRAPHICS dict + loc = graphic.loc + GRAPHICS[loc] = graphic + # TODO: need to refactor LineSlider entirely if isinstance(graphic, LineSlider): - self._sliders.append(graphic) + self._sliders.append(graphic) # don't manage garbage collection of LineSliders for now else: - self._graphics.append(graphic) + self._graphics.append(loc) # add hex id string for referencing this graphic instance + # add world object to scene self.scene.add(graphic.world_object) if center: @@ -185,7 +208,7 @@ def add_graphic(self, graphic: Graphic, center: bool = True): def _check_graphic_name_exists(self, name): graphic_names = list() - for g in self._graphics: + for g in self.graphics: graphic_names.append(g.name) if name in graphic_names: @@ -311,45 +334,53 @@ def delete_graphic(self, graphic: Graphic): """ - if graphic not in self._graphics: - raise KeyError + # graphic_loc = hex(id(graphic.__repr__.__self__)) + + # get location + graphic_loc = graphic.loc + + if graphic_loc not in self._graphics: + raise KeyError(f"Graphic with following address not found in plot area: {graphic_loc}") + # remove from scene if necessary if graphic.world_object in self.scene.children: self.scene.remove(graphic.world_object) - self._graphics.remove(graphic) + # remove from list of addresses + self._graphics.remove(graphic_loc) # for GraphicCollection objects - if isinstance(graphic, GraphicCollection): - # clear Group - graphic.world_object.clear() + # if isinstance(graphic, GraphicCollection): + # # clear Group + # graphic.world_object.clear() + # graphic.clear() # delete all child world objects in the collection - for g in graphic.graphics: - subloc = hex(id(g)) - del WORLD_OBJECTS[subloc] + # for g in graphic.graphics: + # subloc = hex(id(g)) + # del WORLD_OBJECTS[subloc] # get mem location of graphic - loc = hex(id(graphic)) + # loc = hex(id(graphic)) # delete world object - del WORLD_OBJECTS[loc] + #del WORLD_OBJECTS[graphic_loc] - del graphic + del GRAPHICS[graphic_loc] def clear(self): """ Clear the Plot or Subplot. Also performs garbage collection, i.e. runs ``delete_graphic`` on all graphics. """ - for g in self._graphics: + for g in self.graphics: self.delete_graphic(g) def __getitem__(self, name: str): - for graphic in self._graphics: + for graphic in self.graphics: if graphic.name == name: return graphic graphic_names = list() - for g in self._graphics: + for g in self.graphics: graphic_names.append(g.name) raise IndexError(f"no graphic of given name, the current graphics are:\n {graphic_names}") @@ -367,5 +398,5 @@ def __repr__(self): return f"{self}\n" \ f" parent: {self.parent}\n" \ f" Graphics:\n" \ - f"\t{newline.join(graphic.__repr__() for graphic in self._graphics)}" \ + f"\t{newline.join(graphic.__repr__() for graphic in self.graphics)}" \ f"\n" diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index 41d065648..7bb1f0540 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -2,6 +2,7 @@ import numpy as np from math import copysign from functools import partial +import weakref from inspect import signature, getfullargspec from warnings import warn @@ -112,7 +113,7 @@ def __init__( if self.name is not None: self.set_title(self.name) - def _create_graphic(self, graphic_class, *args, **kwargs): + def _create_graphic(self, graphic_class, *args, **kwargs) -> weakref.proxy: if "center" in kwargs.keys(): center = kwargs.pop("center") else: @@ -124,7 +125,8 @@ def _create_graphic(self, graphic_class, *args, **kwargs): graphic = graphic_class(*args, **kwargs) self.add_graphic(graphic, center=center) - return graphic + # only return a proxy to the real graphic + return weakref.proxy(graphic) def set_title(self, text: Any): """Sets the name of a subplot to 'top' viewport if defined.""" From a05229c0db23a1acdb33b66d22a260ef0c0acbdf Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Apr 2023 23:35:29 -0400 Subject: [PATCH 16/26] update gc nb --- examples/garbage_collection.ipynb | 100 +++++++++++++++++++++++------- 1 file changed, 78 insertions(+), 22 deletions(-) diff --git a/examples/garbage_collection.ipynb b/examples/garbage_collection.ipynb index 2b746fda0..e3f0250f0 100644 --- a/examples/garbage_collection.ipynb +++ b/examples/garbage_collection.ipynb @@ -23,25 +23,37 @@ "cell_type": "code", "execution_count": null, "id": "1bb6bc6f-7786-4d23-9eb1-e30bbc66c798", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "print(process.memory_info().rss / 1024 / 1024)" + "def print_process_ram_mb():\n", + " print(process.memory_info().rss / 1024 / 1024)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b376676e-a7fe-4424-9ba6-fde5be03b649", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "print_process_ram_mb()" ] }, { "cell_type": "code", "execution_count": null, "id": "b23ba640-88ec-40d9-b53c-c8cbb3e39b0b", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "plot = Plot()\n", - "\n", - "# a = np.random.rand(5_000_000)\n", - "# plot.add_line(a)\n", - "\n", - "\n", "plot.show()" ] }, @@ -49,67 +61,109 @@ "cell_type": "code", "execution_count": null, "id": "17f46f21-b29d-4dd3-9496-989bbb240f50", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "print(process.memory_info().rss / 1024 / 1024)" + "print_process_ram_mb()" ] }, { "cell_type": "code", "execution_count": null, "id": "e26d392f-6afd-4e89-a685-d618065d3caf", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "a = np.random.rand(1_000, 5_000)" + "# for line\n", + "# a = np.random.rand(10_000_000)\n", + "\n", + "# for heatmap\n", + "# a = np.random.rand(20_000, 20_000)\n", + "\n", + "# for line collection\n", + "# a = np.random.rand(500, 50_000)\n", + "\n", + "# for image\n", + "# a = np.random.rand(7_000, 7_000)\n", + "\n", + "# for scatter\n", + "a = np.random.rand(10_000_000, 3)" ] }, { "cell_type": "code", "execution_count": null, "id": "31e3027a-56cf-4f7b-ba78-aed4f78eef47", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "print(process.memory_info().rss / 1024 / 1024)" + "print_process_ram_mb()" ] }, { "cell_type": "code", "execution_count": null, "id": "9cf2ceaa-5b01-4e12-be39-f267cd355833", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "plot.add_line_stack(a)" + "# g = plot.add_line_collection(a)\n", + "# g = plot.add_heatmap(a)\n", + "# g = plot.add_line(a)\n", + "g = plot.add_scatter(a)" ] }, { "cell_type": "code", "execution_count": null, - "id": "53170858-ae72-4451-8647-7d5b1f9da75e", - "metadata": {}, + "id": "6518795c-98cf-405d-94ab-786ac3b2e1d6", + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "print(process.memory_info().rss / 1024 / 1024)" + "g" ] }, { "cell_type": "code", "execution_count": null, "id": "ee2481c9-82e3-4043-85fd-21a0cdf21187", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "plot.auto_scale()" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "53170858-ae72-4451-8647-7d5b1f9da75e", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "print(process.memory_info().rss / 1024 / 1024)" + ] + }, { "cell_type": "code", "execution_count": null, "id": "e4e0f73b-c58a-40e7-acf5-07a1f70d2821", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "plot.delete_graphic(plot.graphics[0])" @@ -119,7 +173,9 @@ "cell_type": "code", "execution_count": null, "id": "56c64498-229e-48b7-9fb1-f7c327fff2ae", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "print(process.memory_info().rss / 1024 / 1024)" From 1f54d31b9f4d53b9b11f27dcda61c4a2a2d7cd54 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 4 Apr 2023 00:14:15 -0400 Subject: [PATCH 17/26] line slider works, not gc but not necessary for now --- fastplotlib/graphics/line_slider.py | 12 +++++++----- fastplotlib/layouts/_base.py | 7 +++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/fastplotlib/graphics/line_slider.py b/fastplotlib/graphics/line_slider.py index 8755af51a..f19db9cda 100644 --- a/fastplotlib/graphics/line_slider.py +++ b/fastplotlib/graphics/line_slider.py @@ -74,7 +74,7 @@ def __init__( else: material = pygfx.LineMaterial - colors_inner = np.repeat([Color("w")], 2, axis=0).astype(np.float32) + colors_inner = np.repeat([Color(color)], 2, axis=0).astype(np.float32) colors_outer = np.repeat([Color([1., 1., 1., 0.25])], 2, axis=0).astype(np.float32) line_inner = pygfx.Line( @@ -88,17 +88,19 @@ def __init__( material=material(thickness=thickness + 4, vertex_colors=True) ) - self._world_object = pygfx.Group() + world_object = pygfx.Group() - self._world_object.add(line_outer) - self._world_object.add(line_inner) + world_object.add(line_outer) + world_object.add(line_inner) + + self._set_world_object(world_object) self.position.x = x_pos self.slider = slider self.slider.observe(self.set_position, "value") - self.name = name + super().__init__(name=name) def set_position(self, change): self.position.x = change["new"] diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index de352e4f7..c98c010ea 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_base.py @@ -186,14 +186,13 @@ def add_graphic(self, graphic: Graphic, center: bool = True): if graphic.name is not None: # skip for those that have no name self._check_graphic_name_exists(graphic.name) - # store in GRAPHICS dict - loc = graphic.loc - GRAPHICS[loc] = graphic - # TODO: need to refactor LineSlider entirely if isinstance(graphic, LineSlider): self._sliders.append(graphic) # don't manage garbage collection of LineSliders for now else: + # store in GRAPHICS dict + loc = graphic.loc + GRAPHICS[loc] = graphic self._graphics.append(loc) # add hex id string for referencing this graphic instance # add world object to scene From add412f3020fff95080a1b2b798d73541fb6c6bd Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 4 Apr 2023 00:14:28 -0400 Subject: [PATCH 18/26] update gc nb --- examples/garbage_collection.ipynb | 62 ++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 22 deletions(-) diff --git a/examples/garbage_collection.ipynb b/examples/garbage_collection.ipynb index e3f0250f0..85744e6e0 100644 --- a/examples/garbage_collection.ipynb +++ b/examples/garbage_collection.ipynb @@ -72,53 +72,63 @@ { "cell_type": "code", "execution_count": null, - "id": "e26d392f-6afd-4e89-a685-d618065d3caf", + "id": "27627cd4-c363-4eab-a121-f6c8abbbe5ae", "metadata": { "tags": [] }, "outputs": [], "source": [ - "# for line\n", - "# a = np.random.rand(10_000_000)\n", - "\n", - "# for heatmap\n", - "# a = np.random.rand(20_000, 20_000)\n", - "\n", - "# for line collection\n", - "# a = np.random.rand(500, 50_000)\n", - "\n", - "# for image\n", - "# a = np.random.rand(7_000, 7_000)\n", - "\n", - "# for scatter\n", - "a = np.random.rand(10_000_000, 3)" + "graphic = \"scatter\"" + ] + }, + { + "cell_type": "markdown", + "id": "d9c10edc-169a-4dd2-bd5b-8a1b67baf3a9", + "metadata": {}, + "source": [ + "### Run the following cells repeatedly to add and remove the graphic" ] }, { "cell_type": "code", "execution_count": null, - "id": "31e3027a-56cf-4f7b-ba78-aed4f78eef47", + "id": "e26d392f-6afd-4e89-a685-d618065d3caf", "metadata": { "tags": [] }, "outputs": [], "source": [ - "print_process_ram_mb()" + "if graphic == \"line\":\n", + " a = np.random.rand(10_000_000)\n", + " g = plot.add_line(a)\n", + " \n", + "elif graphic == \"heatmap\":\n", + " a = np.random.rand(20_000, 20_000)\n", + " g = plot.add_heatmap(a)\n", + "\n", + "elif graphic == \"line_collection\":\n", + " a = np.random.rand(500, 50_000)\n", + " g = plot.add_line_collection(a)\n", + " \n", + "elif graphic == \"image\":\n", + " a = np.random.rand(7_000, 7_000)\n", + " g = plot.add_image(a)\n", + "\n", + "elif graphic == \"scatter\":\n", + " a = np.random.rand(10_000_000, 3)\n", + " g = plot.add_scatter(a)" ] }, { "cell_type": "code", "execution_count": null, - "id": "9cf2ceaa-5b01-4e12-be39-f267cd355833", + "id": "31e3027a-56cf-4f7b-ba78-aed4f78eef47", "metadata": { "tags": [] }, "outputs": [], "source": [ - "# g = plot.add_line_collection(a)\n", - "# g = plot.add_heatmap(a)\n", - "# g = plot.add_line(a)\n", - "g = plot.add_scatter(a)" + "print_process_ram_mb()" ] }, { @@ -169,6 +179,14 @@ "plot.delete_graphic(plot.graphics[0])" ] }, + { + "cell_type": "markdown", + "id": "47baa487-c66b-4c40-aa11-d819902870e3", + "metadata": {}, + "source": [ + "If there is no serious system memory leak, this value shouldn't really increase after repeated cycles" + ] + }, { "cell_type": "code", "execution_count": null, From d97a99d9c35376a0b8763edc6dd8f8ef23b46ab5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 5 Apr 2023 14:56:13 -0400 Subject: [PATCH 19/26] bugfix --- fastplotlib/graphics/line_collection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index ac27f5b4d..3bff6f7c5 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -343,6 +343,6 @@ def __init__( ) axis_zero = 0 - for i, line in enumerate(self._graphics): + for i, line in enumerate(self.graphics): getattr(line.position, f"set_{separation_axis}")(axis_zero) axis_zero = axis_zero + line.data()[:, axes[separation_axis]].max() + separation From 5a084c0ed3d8d5ebbe69a2b2ecbf4a8d95919bf0 Mon Sep 17 00:00:00 2001 From: Caitlin Lewis Date: Wed, 5 Apr 2023 15:14:20 -0400 Subject: [PATCH 20/26] fix bug in graphic collection --- fastplotlib/graphics/_base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index de126804f..65f72167d 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -315,7 +315,7 @@ def add_graphic(self, graphic: Graphic, reset_index: True): def remove_graphic(self, graphic: Graphic, reset_index: True): """Remove a graphic from the collection""" - self._graphics.remove(graphic) + self._graphics.remove(graphic.loc) if reset_index: self._reset_index() @@ -341,7 +341,7 @@ def __getitem__(self, key): if isinstance(key, slice): key = cleanup_slice(key, upper_bound=len(self)) selection_indices = range(key.start, key.stop, key.step) - selection = self._graphics[key] + selection = self.graphics[key] # fancy-ish indexing elif isinstance(key, (tuple, list, np.ndarray)): @@ -353,7 +353,7 @@ def __getitem__(self, key): selection = list() for ix in key: - selection.append(self._graphics[ix]) + selection.append(self.graphics[ix]) selection_indices = key else: From 0cbe36802431c2928394fd33004804d50018a87d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 11 Apr 2023 02:21:58 -0400 Subject: [PATCH 21/26] bugfix pygfx events triggered by worldobject --- fastplotlib/graphics/_base.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 65f72167d..309b68d9f 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -232,8 +232,13 @@ def _event_handler(self, event): # for now we only have line collections so this works else: - for i, item in enumerate(self._graphics): - if item.world_object is event.pick_info["world_object"]: + # get index of world object that made this event + for i, item in enumerate(self.graphics): + wo = WORLD_OBJECTS[item.loc] + # we only store hex id of worldobject, but worldobject `pick_info` is always the real object + # so if pygfx worldobject triggers an event by itself, such as `click`, etc., this will be + # the real world object in the pick_info and not the proxy + if wo is event.pick_info["world_object"]: indices = i target_info.target._set_feature(feature=target_info.feature, new_data=target_info.new_data, indices=indices) else: From 40813d53292523a020db5e3cf9e0b6088ab65b88 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 14 Apr 2023 01:16:53 -0400 Subject: [PATCH 22/26] updated w.r.t. controllers, cameras, and Texture refactor. tested examples and works --- fastplotlib/graphics/features/_colors.py | 8 ++-- fastplotlib/graphics/features/_data.py | 8 ++-- fastplotlib/graphics/image.py | 31 +++++--------- fastplotlib/layouts/_base.py | 53 ++++++------------------ fastplotlib/layouts/_defaults.py | 4 +- fastplotlib/layouts/_subplot.py | 4 +- fastplotlib/plot.py | 2 +- fastplotlib/utils/functions.py | 2 +- 8 files changed, 38 insertions(+), 74 deletions(-) diff --git a/fastplotlib/graphics/features/_colors.py b/fastplotlib/graphics/features/_colors.py index a5147b95e..f8e7c8c3d 100644 --- a/fastplotlib/graphics/features/_colors.py +++ b/fastplotlib/graphics/features/_colors.py @@ -220,8 +220,8 @@ def __init__(self, parent, cmap: str): self.name = cmap def _set(self, cmap_name: str): - self._parent.world_object.material.map.texture.data[:] = make_colors(256, cmap_name) - self._parent.world_object.material.map.texture.update_range((0, 0, 0), size=(256, 1, 1)) + self._parent.world_object.material.map.data[:] = make_colors(256, cmap_name) + self._parent.world_object.material.map.update_range((0, 0, 0), size=(256, 1, 1)) self.name = cmap_name self._feature_changed(key=None, new_data=self.name) @@ -246,8 +246,8 @@ class HeatmapCmapFeature(ImageCmapFeature): """ def _set(self, cmap_name: str): - self._parent._material.map.texture.data[:] = make_colors(256, cmap_name) - self._parent._material.map.texture.update_range((0, 0, 0), size=(256, 1, 1)) + self._parent._material.map.data[:] = make_colors(256, cmap_name) + self._parent._material.map.update_range((0, 0, 0), size=(256, 1, 1)) self.name = cmap_name self._feature_changed(key=None, new_data=self.name) diff --git a/fastplotlib/graphics/features/_data.py b/fastplotlib/graphics/features/_data.py index 3d877bace..5063b4200 100644 --- a/fastplotlib/graphics/features/_data.py +++ b/fastplotlib/graphics/features/_data.py @@ -1,7 +1,7 @@ from typing import * import numpy as np -from pygfx import Buffer, Texture, TextureView +from pygfx import Buffer, Texture from ._base import GraphicFeatureIndexable, cleanup_slice, FeatureEvent, to_gpu_supported_dtype @@ -87,7 +87,7 @@ def _feature_changed(self, key, new_data): class ImageDataFeature(GraphicFeatureIndexable): """ - Access to the TextureView buffer shown in an ImageGraphic. + Access to the Texture buffer shown in an ImageGraphic. """ def __init__(self, parent, data: Any): @@ -102,7 +102,7 @@ def __init__(self, parent, data: Any): @property def buffer(self) -> Texture: """Texture buffer for the image data""" - return self._parent.world_object.geometry.grid.texture + return self._parent.world_object.geometry.grid def update_gpu(self): """Update the GPU with the buffer""" @@ -153,7 +153,7 @@ class HeatmapDataFeature(ImageDataFeature): @property def buffer(self) -> List[Texture]: """list of Texture buffer for the image data""" - return [img.geometry.grid.texture for img in self._parent.world_object.children] + return [img.geometry.grid for img in self._parent.world_object.children] def update_gpu(self): """Update the GPU with the buffer""" diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index cb4cf1587..835061328 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -4,7 +4,6 @@ import numpy as np import pygfx -from pygfx.utils import unpack_bitfield from ._base import Graphic, Interaction, PreviouslyModifiedData from .features import ImageCmapFeature, ImageDataFeature, HeatmapDataFeature, HeatmapCmapFeature @@ -87,18 +86,18 @@ def __init__( if (vmin is None) or (vmax is None): vmin, vmax = quick_min_max(data) - texture_view = pygfx.Texture(buffer_init, dim=2).get_view(filter=filter) + texture = pygfx.Texture(buffer_init, dim=2) - geometry = pygfx.Geometry(grid=texture_view) + geometry = pygfx.Geometry(grid=texture) # if data is RGB if data.ndim == 3: self.cmap = None - material = pygfx.ImageBasicMaterial(clim=(vmin, vmax)) + material = pygfx.ImageBasicMaterial(clim=(vmin, vmax), map_interpolation=filter) # if data is just 2D without color information, use colormap LUT else: self.cmap = ImageCmapFeature(self, cmap) - material = pygfx.ImageBasicMaterial(clim=(vmin, vmax), map=self.cmap()) + material = pygfx.ImageBasicMaterial(clim=(vmin, vmax), map=self.cmap(), map_interpolation=filter) world_object = pygfx.Image( geometry, @@ -151,21 +150,13 @@ class _ImageTile(pygfx.Image): """ Similar to pygfx.Image, only difference is that it contains a few properties to keep track of row chunk index, column chunk index - - """ def _wgpu_get_pick_info(self, pick_value): - tex = self.geometry.grid - if hasattr(tex, "texture"): - tex = tex.texture # tex was a view - # This should match with the shader - values = unpack_bitfield(pick_value, wobject_id=20, x=22, y=22) - x = values["x"] / 4194304 * tex.size[0] - 0.5 - y = values["y"] / 4194304 * tex.size[1] - 0.5 - ix, iy = int(x + 0.5), int(y + 0.5) + pick_info = super()._wgpu_get_pick_info(pick_value) + + # add row chunk and col chunk index to pick_info dict return { - "index": (ix, iy), - "pixel_coord": (x - ix, y - iy), + **pick_info, "row_chunk_index": self.row_chunk_index, "col_chunk_index": self.col_chunk_index } @@ -281,7 +272,7 @@ def __init__( vmin, vmax = quick_min_max(data) self.cmap = HeatmapCmapFeature(self, cmap) - self._material = pygfx.ImageBasicMaterial(clim=(vmin, vmax), map=self.cmap()) + self._material = pygfx.ImageBasicMaterial(clim=(vmin, vmax), map=self.cmap(), map_interpolation=filter) for start, stop, chunk in zip(start_ixs, stop_ixs, chunks): row_start, col_start = start @@ -290,8 +281,8 @@ def __init__( # x and y positions of the Tile in world space coordinates y_pos, x_pos = row_start, col_start - tex_view = pygfx.Texture(buffer_init[row_start:row_stop, col_start:col_stop], dim=2).get_view(filter=filter) - geometry = pygfx.Geometry(grid=tex_view) + texture = pygfx.Texture(buffer_init[row_start:row_stop, col_start:col_stop], dim=2) + geometry = pygfx.Geometry(grid=texture) # material = pygfx.ImageBasicMaterial(clim=(0, 1), map=self.cmap()) img = _ImageTile(geometry, self._material) diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index c98c010ea..70bd6dbaa 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_base.py @@ -70,18 +70,11 @@ def __init__( self._camera = camera self._controller = controller - self.controller.add_default_event_handlers( + self.controller.add_camera(self.camera) + self.controller.register_events( self.viewport, - self.camera ) - # camera.far and camera.near clipping planes get - # wonky with setting controller.distance = 0 - if isinstance(self.camera, OrthographicCamera): - self.controller.distance = 0 - # also set a initial zoom - self.controller.zoom(0.8 / self.controller.zoom_value) - self.renderer.add_event_handler(self.set_viewport_rect, "resize") # list of hex id strings for all graphics managed by this PlotArea @@ -158,7 +151,6 @@ def set_viewport_rect(self, *args): def render(self): # does not flush - self.controller.update_camera(self.camera) self.viewport.render(self.scene, self.camera) for child in self.children: @@ -213,17 +205,7 @@ def _check_graphic_name_exists(self, name): if name in graphic_names: raise ValueError(f"graphics must have unique names, current graphic names are:\n {graphic_names}") - def _refresh_camera(self): - self.controller.update_camera(self.camera) - if sum(self.renderer.logical_size) > 0: - scene_lsize = self.viewport.rect[2], self.viewport.rect[3] - else: - scene_lsize = (1, 1) - - self.camera.set_view_size(*scene_lsize) - self.camera.update_projection_matrix() - - def center_graphic(self, graphic: Graphic, zoom: float = 1.3): + def center_graphic(self, graphic: Graphic, zoom: float = 1.35): """ Center the camera w.r.t. the passed graphic @@ -236,17 +218,14 @@ def center_graphic(self, graphic: Graphic, zoom: float = 1.3): zoom the camera after centering """ - if not isinstance(self.camera, OrthographicCamera): - warn("`center_graphic()` not yet implemented for `PerspectiveCamera`") - return - self._refresh_camera() + self.camera.show_object(graphic.world_object) - self.controller.show_object(self.camera, graphic.world_object) + # camera.show_object can cause the camera width and height to increase so apply a zoom to compensate + # probably because camera.show_object uses bounding sphere + self.camera.zoom = zoom - self.controller.zoom(zoom) - - def center_scene(self, zoom: float = 1.3): + def center_scene(self, zoom: float = 1.35): """ Auto-center the scene, does not scale. @@ -259,15 +238,11 @@ def center_scene(self, zoom: float = 1.3): if not len(self.scene.children) > 0: return - if not isinstance(self.camera, OrthographicCamera): - warn("`center_scene()` not yet implemented for `PerspectiveCamera`") - return - - self._refresh_camera() + self.camera.show_object(self.scene) - self.controller.show_object(self.camera, self.scene) - - self.controller.zoom(zoom) + # camera.show_object can cause the camera width and height to increase so apply a zoom to compensate + # probably because camera.show_object uses bounding sphere + self.camera.zoom = zoom def auto_scale(self, maintain_aspect: bool = False, zoom: float = 0.8): """ @@ -303,9 +278,7 @@ def auto_scale(self, maintain_aspect: bool = False, zoom: float = 0.8): self.camera.width = width self.camera.height = height - # self.controller.distance = 0 - - self.controller.zoom(zoom / self.controller.zoom_value) + self.camera.zoom = zoom def remove_graphic(self, graphic: Graphic): """ diff --git a/fastplotlib/layouts/_defaults.py b/fastplotlib/layouts/_defaults.py index 3c5732613..314774751 100644 --- a/fastplotlib/layouts/_defaults.py +++ b/fastplotlib/layouts/_defaults.py @@ -8,9 +8,9 @@ controller_types = { '2d': pygfx.PanZoomController, - '3d': pygfx.OrbitOrthoController, + '3d': pygfx.OrbitController, pygfx.OrthographicCamera: pygfx.PanZoomController, - pygfx.PerspectiveCamera: pygfx.OrbitOrthoController, + pygfx.PerspectiveCamera: pygfx.OrbitController, } diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index 7bb1f0540..a5f57451e 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -6,7 +6,7 @@ from inspect import signature, getfullargspec from warnings import warn -from pygfx import Scene, OrthographicCamera, PanZoomController, OrbitOrthoController, \ +from pygfx import Scene, OrthographicCamera, PanZoomController, OrbitController, \ AxesHelper, GridHelper, WgpuRenderer from wgpu.gui.auto import WgpuCanvas @@ -22,7 +22,7 @@ def __init__( position: Tuple[int, int] = None, parent_dims: Tuple[int, int] = None, camera: str = '2d', - controller: Union[PanZoomController, OrbitOrthoController] = None, + controller: Union[PanZoomController, OrbitController] = None, canvas: WgpuCanvas = None, renderer: WgpuRenderer = None, name: str = None, diff --git a/fastplotlib/plot.py b/fastplotlib/plot.py index 74c35ef6e..89c73a5f2 100644 --- a/fastplotlib/plot.py +++ b/fastplotlib/plot.py @@ -10,7 +10,7 @@ def __init__( canvas: WgpuCanvas = None, renderer: pygfx.Renderer = None, camera: str = '2d', - controller: Union[pygfx.PanZoomController, pygfx.OrbitOrthoController] = None, + controller: Union[pygfx.PanZoomController, pygfx.OrbitController] = None, **kwargs ): """ diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index d919a88d6..1ba72b322 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -68,7 +68,7 @@ def make_colors(n_colors: int, cmap: str, alpha: float = 1.0) -> np.ndarray: def get_cmap_texture(name: str, alpha: float = 1.0) -> Texture: cmap = _get_cmap(name) - return Texture(cmap, dim=1).get_view() + return Texture(cmap, dim=1) def make_colors_dict(labels: iter, cmap: str, **kwargs) -> OrderedDict: From 7fcb819d821b6f3fbb03f32c269fd9eeb6ef3b9b Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 14 Apr 2023 01:17:11 -0400 Subject: [PATCH 23/26] update simple.ipynb --- examples/simple.ipynb | 575 ++++++++---------------------------------- 1 file changed, 109 insertions(+), 466 deletions(-) diff --git a/examples/simple.ipynb b/examples/simple.ipynb index 2d4961ac3..9ca764283 100644 --- a/examples/simple.ipynb +++ b/examples/simple.ipynb @@ -14,7 +14,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "fb57c3d3-f20d-4d88-9e7a-04b9309bc637", "metadata": {}, "outputs": [], @@ -34,52 +34,10 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "237823b7-e2c0-4e2f-9ee8-e3fc2b4453c4", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "22e188ba0769451ba369b4a5a2d5d313", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
initial snapshot
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "997ba4e4d8b540ffa3f100c2fde27920", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "JupyterWgpuCanvas()" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# create a `Plot` instance\n", "plot = Plot()\n", @@ -112,7 +70,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "de816c88-1c4a-4071-8a5e-c46c93671ef5", "metadata": {}, "outputs": [], @@ -122,7 +80,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "09350854-5058-4574-a01d-84d00e276c57", "metadata": {}, "outputs": [], @@ -132,7 +90,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "83b2db1b-2783-4e89-bcf3-66bb6e09e18a", "metadata": {}, "outputs": [], @@ -143,7 +101,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "3e298c1c-7551-4401-ade0-b9af7d2bbe23", "metadata": {}, "outputs": [], @@ -161,42 +119,20 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "e6ba689c-ff4a-44ef-9663-f2c8755072c4", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "('random-image': ImageGraphic @ 0x7fbb681a1360,)" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "plot.graphics" ] }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "5b18f4e3-e13b-46d5-af1f-285c5a7fdc12", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'random-image': ImageGraphic @ 0x7fbb681a1360" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "plot[\"random-image\"]" ] @@ -211,42 +147,20 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "2b5c1321-1fd4-44bc-9433-7439ad3e22cf", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'random-image': ImageGraphic @ 0x7fbb681a1360" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "image_graphic" ] }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "b12bf75e-4e93-4930-9146-e96324fdf3f6", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "image_graphic is plot[\"random-image\"]" ] @@ -265,52 +179,10 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "aadd757f-6379-4f52-a709-46aa57c56216", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "543d56b0c4fa4eb18927358bd7d0d2fa", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
initial snapshot
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "ca2343e7c6294dddb4d71253c5dd0050", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "JupyterWgpuCanvas()" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# create another `Plot` instance\n", "plot_v = Plot()\n", @@ -348,52 +220,10 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "id": "86e70b1e-4328-4035-b992-70dff16d2a69", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "2fca1a70597f4f2c813245b472557980", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
initial snapshot
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "655dee7f343d4ad1b9d1dca8bf499fa2", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "JupyterWgpuCanvas()" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "plot_sync = Plot(controller=plot_v.controller)\n", "\n", @@ -430,25 +260,10 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "id": "ef9743b3-5f81-4b79-9502-fa5fca08e56d", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "98e198424c434dfcaf932bdf67e8c1b2", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 211, 'timestamp': 1673222194.8195093, 'localtime': 1…" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "VBox([plot_v.show(), plot_sync.show()])" ] @@ -464,25 +279,10 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "id": "11839d95-8ff7-444c-ae13-6b072c3112c5", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "f97d3b3fd53040388be0f2527a832b41", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "HBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 236, 'timestamp': 1673222197.978218, 'localtime': 16…" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "HBox([plot_v.show(), plot_sync.show()])" ] @@ -507,7 +307,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "id": "0bcedf83-cbdd-4ec2-b8d5-172aa72a3e04", "metadata": {}, "outputs": [], @@ -582,21 +382,10 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "id": "8b560151-c258-415c-a20d-3cccd421f44a", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "(1000, 512, 512)" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "movie.shape" ] @@ -619,39 +408,10 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "id": "62166a9f-ab43-45cc-a6db-6d441387e9a5", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "716bb151456446c283aa842c008fc805", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "6ef37682df3f409b8d2a7ac36974d7ce", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(JupyterWgpuCanvas(), IntSlider(value=0, max=999)))" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "plot_movie = Plot()\n", "\n", @@ -710,7 +470,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": null, "id": "8e8280da-b421-43a5-a1a6-2a196a408e9a", "metadata": {}, "outputs": [], @@ -741,52 +501,10 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": null, "id": "93a5d1e6-d019-4dd0-a0d1-25d1704ab7a7", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "5a78ff5b74a64ba28afcd7844a5bd1de", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
initial snapshot
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "59f97de592804347a60996be35bcab2c", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "JupyterWgpuCanvas()" - ] - }, - "execution_count": 19, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# Create a plot instance\n", "plot_l = Plot()\n", @@ -804,6 +522,48 @@ "plot_l.show()" ] }, + { + "cell_type": "markdown", + "id": "22dde600-0f56-4370-b017-c8f23a6c01aa", + "metadata": {}, + "source": [ + "### \"stretching\" the camera, useful for large timeseries data\n", + "\n", + "Set `maintain_aspect = False` on a camera, and then use the right mouse button and move the mouse to stretch and squeeze the view!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2695f023-f6ce-4e26-8f96-4fbed5510d1d", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_l.camera.maintain_aspect = False" + ] + }, + { + "cell_type": "markdown", + "id": "1651e965-f750-47ac-bf53-c23dae84cc98", + "metadata": {}, + "source": [ + "### reset the plot area" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ba50a6ed-0f1b-4795-91dd-a7c3e40b8e3c", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_l.auto_scale(maintain_aspect=True)" + ] + }, { "cell_type": "markdown", "id": "dcd68796-c190-4c3f-8519-d73b98ff6367", @@ -814,7 +574,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": null, "id": "cb0d13ed-ef07-46ff-b19e-eeca4c831037", "metadata": {}, "outputs": [], @@ -842,7 +602,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": null, "id": "cfa001f6-c640-4f91-beb0-c19b030e503f", "metadata": {}, "outputs": [], @@ -856,32 +616,10 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": null, "id": "bb8a0f95-0063-4cd4-a117-e3d62c6e120d", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "FeatureEvent @ 0x7fbacc955060\n", - "type: colors\n", - "pick_info: {'index': range(15, 50, 3), 'collection-index': None, 'world_object': , 'new_data': array([[0., 1., 1., 1.],\n", - " [0., 1., 1., 1.],\n", - " [0., 1., 1., 1.],\n", - " [0., 1., 1., 1.],\n", - " [0., 1., 1., 1.],\n", - " [0., 1., 1., 1.],\n", - " [0., 1., 1., 1.],\n", - " [0., 1., 1., 1.],\n", - " [0., 1., 1., 1.],\n", - " [0., 1., 1., 1.],\n", - " [0., 1., 1., 1.],\n", - " [0., 1., 1., 1.]], dtype=float32)}\n", - "\n" - ] - } - ], + "outputs": [], "source": [ "# more complex indexing of colors\n", "# from point 15 - 30, set every 3rd point as \"cyan\"\n", @@ -898,7 +636,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": null, "id": "d1a4314b-5723-43c7-94a0-b4cbb0e44d60", "metadata": {}, "outputs": [], @@ -909,7 +647,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": null, "id": "682db47b-8c7a-4934-9be4-2067e9fb12d5", "metadata": {}, "outputs": [], @@ -927,7 +665,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": null, "id": "fcba75b7-9a1e-4aae-9dec-715f7f7456c3", "metadata": {}, "outputs": [], @@ -937,7 +675,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": null, "id": "763b9943-53a4-4e2a-b47a-4e9e5c9d7be3", "metadata": {}, "outputs": [], @@ -955,7 +693,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": null, "id": "64a20a16-75a5-4772-a849-630ade9be4ff", "metadata": {}, "outputs": [], @@ -965,7 +703,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": null, "id": "fb093046-c94c-4085-86b4-8cd85cb638ff", "metadata": {}, "outputs": [], @@ -975,7 +713,7 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": null, "id": "f05981c3-c768-4631-ae62-6a8407b20c4c", "metadata": {}, "outputs": [], @@ -993,60 +731,10 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": null, "id": "9c51229f-13a2-4653-bff3-15d43ddbca7b", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "4beed0a67834408aa4549374af4b36d8", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/kushalk/repos/fastplotlib/fastplotlib/layouts/_base.py:214: UserWarning: `center_scene()` not yet implemented for `PerspectiveCamera`\n", - " warn(\"`center_scene()` not yet implemented for `PerspectiveCamera`\")\n" - ] - }, - { - "data": { - "text/html": [ - "
initial snapshot
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "52aa9698dbe2430099c3d88159bcb47b", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "JupyterWgpuCanvas()" - ] - }, - "execution_count": 30, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# just set the camera as \"3d\", the rest is basically the same :D \n", "plot_l3d = Plot(camera='3d')\n", @@ -1067,6 +755,18 @@ "plot_l3d.show()" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "28eb7014-4773-4a34-8bfc-bd3a46429012", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "plot_l3d.auto_scale(maintain_aspect=True)" + ] + }, { "cell_type": "markdown", "id": "a202b3d0-2a0b-450a-93d4-76d0a1129d1d", @@ -1081,7 +781,7 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": null, "id": "2ecb2385-8fa4-4239-881c-b754c24aed9f", "metadata": {}, "outputs": [], @@ -1093,52 +793,10 @@ }, { "cell_type": "code", - "execution_count": 32, + "execution_count": null, "id": "39252df5-9ae5-4132-b97b-2785c5fa92ea", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "58954cd128264eb38a4afc87f940194e", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
initial snapshot
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "3eac2f2a71334813a958c144d22089d4", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "JupyterWgpuCanvas()" - ] - }, - "execution_count": 32, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# create a random distribution of 10,000 xyz coordinates\n", "n_points = 10_000\n", @@ -1185,7 +843,7 @@ }, { "cell_type": "code", - "execution_count": 35, + "execution_count": null, "id": "8fa46ec0-8680-44f5-894c-559de3145932", "metadata": {}, "outputs": [], @@ -1196,7 +854,7 @@ }, { "cell_type": "code", - "execution_count": 36, + "execution_count": null, "id": "e4dc71e4-5144-436f-a464-f2a29eee8f0b", "metadata": {}, "outputs": [], @@ -1207,7 +865,7 @@ }, { "cell_type": "code", - "execution_count": 37, + "execution_count": null, "id": "5b637a29-cd5e-4011-ab81-3f91490d9ecd", "metadata": {}, "outputs": [], @@ -1218,7 +876,7 @@ }, { "cell_type": "code", - "execution_count": 38, + "execution_count": null, "id": "a4084fce-78a2-48b3-9a0d-7b57c165c3c1", "metadata": {}, "outputs": [], @@ -1229,7 +887,7 @@ }, { "cell_type": "code", - "execution_count": 39, + "execution_count": null, "id": "f486083e-7c58-4255-ae1a-3fe5d9bfaeed", "metadata": {}, "outputs": [], @@ -1250,25 +908,10 @@ }, { "cell_type": "code", - "execution_count": 40, + "execution_count": null, "id": "f404a5ea-633b-43f5-87d1-237017bbca2a", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "36ce2e79024a4906aef9530ea238631d", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(HBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 812, 'timestamp': 1673222184.132576, …" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "row1 = HBox([plot.show(), plot_v.show(), plot_sync.show()])\n", "row2 = HBox([plot_l.show(), plot_l3d.show(), plot_s.show()])\n", @@ -1301,7 +944,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.5" + "version": "3.11.3" } }, "nbformat": 4, From 66fd98c79f77a08cd03d1855160eb5b079e1da95 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Fri, 14 Apr 2023 01:30:33 -0400 Subject: [PATCH 24/26] bump VERSION --- fastplotlib/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/VERSION b/fastplotlib/VERSION index 229bb9495..6cb992894 100644 --- a/fastplotlib/VERSION +++ b/fastplotlib/VERSION @@ -1 +1 @@ -0.1.0.a9 +0.1.0.a10 \ No newline at end of file From 70570596a51b3ce72e9c5bae8c5c36a28480daa5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 5 Mar 2023 06:49:09 -0500 Subject: [PATCH 25/26] linear region selector with basic functionality but wonky control --- fastplotlib/graphics/selectors/__init__.py | 0 fastplotlib/graphics/selectors/linear.py | 152 +++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 fastplotlib/graphics/selectors/__init__.py create mode 100644 fastplotlib/graphics/selectors/linear.py diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/graphics/selectors/linear.py b/fastplotlib/graphics/selectors/linear.py new file mode 100644 index 000000000..4e9934944 --- /dev/null +++ b/fastplotlib/graphics/selectors/linear.py @@ -0,0 +1,152 @@ +from typing import * +import numpy as np +from time import time + +import pygfx +from pygfx.linalg import Vector3 + +from .._base import Graphic, Interaction +from ..features._base import GraphicFeature, FeatureEvent + + +# positions for indexing the BoxGeometry to set the "width" and "height" of the box +# hacky but I don't think we can morph meshes in pygfx yet: https://github.com/pygfx/pygfx/issues/346 +x_right = np.array([ + True, True, True, True, False, False, False, False, False, + True, False, True, True, False, True, False, False, True, + False, True, True, False, True, False +]) + +x_left = np.array([ + False, False, False, False, True, True, True, True, True, + False, True, False, False, True, False, True, True, False, + True, False, False, True, False, True +]) + +y_top = np.array([ + False, True, False, True, False, True, False, True, True, + True, True, True, False, False, False, False, False, False, + True, True, False, False, True, True +]) + +y_bottom = np.array([ + True, False, True, False, True, False, True, False, False, + False, False, False, True, True, True, True, True, True, + False, False, True, True, False, False +]) + + +class LinearBoundsFeature(GraphicFeature): + def __init__(self, parent, bounds: Tuple[int, int]): + super(LinearBoundsFeature, self).__init__(parent, data=bounds) + + def _set(self, value): + # sets new bounds + if not isinstance(value, tuple): + raise TypeError( + "Bounds must be a tuple in the form of `(min_bound, max_bound)`, " + "where `min_bound` and `max_bound` are numeric values." + ) + + self._parent.fill.geometry.positions.data[x_left, 0] = value[0] + self._parent.fill.geometry.positions.data[x_right, 0] = value[1] + self._data = (value[0], value[1]) + + self._parent.fill.geometry.positions.update_range() + + self._feature_changed(key=None, new_data=value) + + def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): + pick_info = { + "index": None, + "collection-index": self._collection_index, + "world_object": self._parent.world_object, + "new_data": new_data + } + + event_data = FeatureEvent(type="bounds", pick_info=pick_info) + + self._call_event_handlers(event_data) + + +class LinearSelector(Graphic, Interaction): + """Linear region selector, for lines or line collections.""" + feature_events = ( + "bounds" + ) + + def __init__( + self, + bounds: Tuple[int, int], + height: int, + position: Tuple[int, int], + fill_color=(0.1, 0.1, 0.1), + edge_color="w", + name: str = None + ): + super(LinearSelector, self).__init__(name=name) + + self._world_object = pygfx.Group() + + self.fill = pygfx.Mesh( + pygfx.box_geometry(1, height, 1), + pygfx.MeshBasicMaterial(color=fill_color) + ) + + self.fill.position.set(*position, -2) + + self.fill.add_event_handler(self._move_start, "double_click") + self.fill.add_event_handler(self._move, "pointer_move") + self.fill.add_event_handler(self._move_end, "click") + + self.world_object.add(self.fill) + + self._move_info = None + # self.fill.add_event_handler( + + self.edges = None + + self.bounds = LinearBoundsFeature(self, bounds) + self.bounds = bounds + self.timer = 0 + + # self._plane = + + def _move_start(self, ev): + self._move_info = {"last_pos": (ev.x, ev.y)} + self.timer = time() + print(self._move_info) + + def _move(self, ev): + if self._move_info is None: + return + + if time() - self.timer > 2: + self._move_end(ev) + return + + print("moving!") + print(ev.x, ev.y) + + last = self._move_info["last_pos"] + + delta = (last[0] - ev.x, last[1] - ev.y) + + self._move_info = {"last_pos": (ev.x, ev.y)} + + # adjust x vals + self.bounds = (self.bounds()[0] - delta[0], self.bounds()[1] - delta[0]) + + print(self._move_info) + + self.timer = time() + + def _move_end(self, ev): + print("move end") + self._move_info = None + + def _set_feature(self, feature: str, new_data: Any, indices: Any): + pass + + def _reset_feature(self, feature: str): + pass \ No newline at end of file From ab222599a21f4e8ab539d4f2a4944fc43b5f01f8 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 5 Mar 2023 06:49:09 -0500 Subject: [PATCH 26/26] linear region selector with basic functionality but wonky control --- fastplotlib/graphics/selectors/__init__.py | 0 fastplotlib/graphics/selectors/linear.py | 152 +++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 fastplotlib/graphics/selectors/__init__.py create mode 100644 fastplotlib/graphics/selectors/linear.py diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/graphics/selectors/linear.py b/fastplotlib/graphics/selectors/linear.py new file mode 100644 index 000000000..4e9934944 --- /dev/null +++ b/fastplotlib/graphics/selectors/linear.py @@ -0,0 +1,152 @@ +from typing import * +import numpy as np +from time import time + +import pygfx +from pygfx.linalg import Vector3 + +from .._base import Graphic, Interaction +from ..features._base import GraphicFeature, FeatureEvent + + +# positions for indexing the BoxGeometry to set the "width" and "height" of the box +# hacky but I don't think we can morph meshes in pygfx yet: https://github.com/pygfx/pygfx/issues/346 +x_right = np.array([ + True, True, True, True, False, False, False, False, False, + True, False, True, True, False, True, False, False, True, + False, True, True, False, True, False +]) + +x_left = np.array([ + False, False, False, False, True, True, True, True, True, + False, True, False, False, True, False, True, True, False, + True, False, False, True, False, True +]) + +y_top = np.array([ + False, True, False, True, False, True, False, True, True, + True, True, True, False, False, False, False, False, False, + True, True, False, False, True, True +]) + +y_bottom = np.array([ + True, False, True, False, True, False, True, False, False, + False, False, False, True, True, True, True, True, True, + False, False, True, True, False, False +]) + + +class LinearBoundsFeature(GraphicFeature): + def __init__(self, parent, bounds: Tuple[int, int]): + super(LinearBoundsFeature, self).__init__(parent, data=bounds) + + def _set(self, value): + # sets new bounds + if not isinstance(value, tuple): + raise TypeError( + "Bounds must be a tuple in the form of `(min_bound, max_bound)`, " + "where `min_bound` and `max_bound` are numeric values." + ) + + self._parent.fill.geometry.positions.data[x_left, 0] = value[0] + self._parent.fill.geometry.positions.data[x_right, 0] = value[1] + self._data = (value[0], value[1]) + + self._parent.fill.geometry.positions.update_range() + + self._feature_changed(key=None, new_data=value) + + def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): + pick_info = { + "index": None, + "collection-index": self._collection_index, + "world_object": self._parent.world_object, + "new_data": new_data + } + + event_data = FeatureEvent(type="bounds", pick_info=pick_info) + + self._call_event_handlers(event_data) + + +class LinearSelector(Graphic, Interaction): + """Linear region selector, for lines or line collections.""" + feature_events = ( + "bounds" + ) + + def __init__( + self, + bounds: Tuple[int, int], + height: int, + position: Tuple[int, int], + fill_color=(0.1, 0.1, 0.1), + edge_color="w", + name: str = None + ): + super(LinearSelector, self).__init__(name=name) + + self._world_object = pygfx.Group() + + self.fill = pygfx.Mesh( + pygfx.box_geometry(1, height, 1), + pygfx.MeshBasicMaterial(color=fill_color) + ) + + self.fill.position.set(*position, -2) + + self.fill.add_event_handler(self._move_start, "double_click") + self.fill.add_event_handler(self._move, "pointer_move") + self.fill.add_event_handler(self._move_end, "click") + + self.world_object.add(self.fill) + + self._move_info = None + # self.fill.add_event_handler( + + self.edges = None + + self.bounds = LinearBoundsFeature(self, bounds) + self.bounds = bounds + self.timer = 0 + + # self._plane = + + def _move_start(self, ev): + self._move_info = {"last_pos": (ev.x, ev.y)} + self.timer = time() + print(self._move_info) + + def _move(self, ev): + if self._move_info is None: + return + + if time() - self.timer > 2: + self._move_end(ev) + return + + print("moving!") + print(ev.x, ev.y) + + last = self._move_info["last_pos"] + + delta = (last[0] - ev.x, last[1] - ev.y) + + self._move_info = {"last_pos": (ev.x, ev.y)} + + # adjust x vals + self.bounds = (self.bounds()[0] - delta[0], self.bounds()[1] - delta[0]) + + print(self._move_info) + + self.timer = time() + + def _move_end(self, ev): + print("move end") + self._move_info = None + + def _set_feature(self, feature: str, new_data: Any, indices: Any): + pass + + def _reset_feature(self, feature: str): + pass \ No newline at end of file