From 75cc2c78a115e8f1fd2a3754c33d97d6b08c0ce1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 18 Dec 2022 22:33:47 -0500 Subject: [PATCH 01/11] started graphic attributes, colors work --- fastplotlib/graphics/_graphic_attribute.py | 92 ++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 fastplotlib/graphics/_graphic_attribute.py diff --git a/fastplotlib/graphics/_graphic_attribute.py b/fastplotlib/graphics/_graphic_attribute.py new file mode 100644 index 000000000..efeacdec5 --- /dev/null +++ b/fastplotlib/graphics/_graphic_attribute.py @@ -0,0 +1,92 @@ +from abc import ABC, abstractmethod +from typing import * +from pygfx import Color +import numpy as np + + +class GraphicFeature(ABC): + def __init__(self, parent, data: Any): + self._parent = parent + self._data = data + + @abstractmethod + def __getitem__(self, item): + pass + + @abstractmethod + def __setitem__(self, key, value): + pass + + @abstractmethod + def _update_range(self, key): + pass + + +class ColorFeature(GraphicFeature): + def __init__(self, parent, data): + data = parent.geometry.colors.data + super(ColorFeature, self).__init__(parent, data) + + self._bounds = data.shape[0] + + def __setitem__(self, key, value): + if abs(key.start) > self._bounds or abs(key.stop) > self._bounds: + raise IndexError + + if isinstance(key, slice): + start = key.start + stop = key.stop + step = key.step + if step is None: + step = 1 + + indices = range(start, stop, step) + + elif isinstance(key, int): + indices = [key] + + else: + raise TypeError("Graphic features only support integer and numerical fancy indexing") + + new_data_size = len(indices) + + if not isinstance(value, np.ndarray): + new_colors = np.repeat(np.array([Color(value)]), new_data_size, axis=0) + + elif isinstance(value, np.ndarray): + if value.shape == (4,): + new_colors = value.astype(np.float32) + if new_data_size > 1: + new_colors = np.repeat(np.array([new_colors]), new_data_size, axis=0) + + elif value.shape[1] == 4 and value.ndim == 2: + if not value.shape[0] == new_data_size: + raise ValueError("numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)") + if new_data_size == 1: + new_colors = value.ravel().astype(np.float32) + else: + new_colors = value.astype(np.float32) + + else: + raise ValueError("numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)") + + self._parent.geometry.colors.data[key] = new_colors + + self._update_range(key) + + def _update_range(self, key): + if isinstance(key, int): + self._parent.geometry.colors.update_range(key, size=1) + if key.step is None: + # update range according to size using the offset + self._parent.geometry.colors.update_range(offset=key.start, size=key.stop - key.start) + + else: + step = key.step + ixs = range(key.start, key.stop, step) + # convert slice to indices + for ix in ixs: + self._parent.geometry.colors.update_range(ix, size=1) + + def __getitem__(self, item): + return self._parent.geometry.colors.data[item] \ No newline at end of file From 865cbf0e2403b0e35c8d6953a0de5e9179581e7f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 19 Dec 2022 02:08:00 -0500 Subject: [PATCH 02/11] cleanup ColorFeature --- fastplotlib/graphics/_graphic_attribute.py | 50 ++++++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/fastplotlib/graphics/_graphic_attribute.py b/fastplotlib/graphics/_graphic_attribute.py index efeacdec5..f784e14a6 100644 --- a/fastplotlib/graphics/_graphic_attribute.py +++ b/fastplotlib/graphics/_graphic_attribute.py @@ -27,22 +27,40 @@ def __init__(self, parent, data): data = parent.geometry.colors.data super(ColorFeature, self).__init__(parent, data) - self._bounds = data.shape[0] + self._upper_bound = data.shape[0] def __setitem__(self, key, value): - if abs(key.start) > self._bounds or abs(key.stop) > self._bounds: - raise IndexError - + # parse numerical slice indices if isinstance(key, slice): start = key.start stop = key.stop + step = key.step + for attr in [start, stop, step]: + if attr is None: + continue + if attr < 0: + raise IndexError("Negative indexing not supported.") + + if start is None: + start = 0 + + if stop is None: + stop = self._upper_bound + + elif stop > self._upper_bound: + raise IndexError("Index out of bounds") + step = key.step if step is None: step = 1 - indices = range(start, stop, step) + key = slice(start, stop, step) + indices = range(key.start, key.stop, key.step) + # or single numerical index elif isinstance(key, int): + if key > self._upper_bound: + raise IndexError("Index out of bounds") indices = [key] else: @@ -51,17 +69,31 @@ def __setitem__(self, key, value): new_data_size = len(indices) if not isinstance(value, np.ndarray): - new_colors = np.repeat(np.array([Color(value)]), new_data_size, axis=0) - + color = np.array(Color(value)) # pygfx color parser + # make it of shape [n_colors_modify, 4] + new_colors = np.repeat( + np.array([color]).astype(np.float32), + new_data_size, + axis=0 + ) + + # if already a numpy array elif isinstance(value, np.ndarray): + # if a single color provided as numpy array if value.shape == (4,): new_colors = value.astype(np.float32) + # if there are more than 1 datapoint color to modify if new_data_size > 1: - new_colors = np.repeat(np.array([new_colors]), new_data_size, axis=0) + new_colors = np.repeat( + np.array([new_colors]).astype(np.float32), + new_data_size, + axis=0 + ) elif value.shape[1] == 4 and value.ndim == 2: - if not value.shape[0] == new_data_size: + if value.shape[0] != new_data_size: raise ValueError("numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)") + # if there is a single datapoint to change color of but user has provided shape [1, 4] if new_data_size == 1: new_colors = value.ravel().astype(np.float32) else: From 3012e208c6323ae5a9466b1aacd749724b8d442a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 19 Dec 2022 04:10:21 -0500 Subject: [PATCH 03/11] more color indexing stuff --- fastplotlib/graphics/_graphic_attribute.py | 156 ++++++++++++++++----- 1 file changed, 123 insertions(+), 33 deletions(-) diff --git a/fastplotlib/graphics/_graphic_attribute.py b/fastplotlib/graphics/_graphic_attribute.py index f784e14a6..cb7df8401 100644 --- a/fastplotlib/graphics/_graphic_attribute.py +++ b/fastplotlib/graphics/_graphic_attribute.py @@ -7,7 +7,14 @@ class GraphicFeature(ABC): def __init__(self, parent, data: Any): self._parent = parent - self._data = data + self._data = data.astype(np.float32) + + @property + def data(self) -> Any: + return self._data + + def set_parent(self, parent: Any): + self._parent = parent @abstractmethod def __getitem__(self, item): @@ -22,9 +29,96 @@ def _update_range(self, key): pass +def cleanup_slice(slice_obj: slice, upper_bound) -> slice: + start = slice_obj.start + stop = slice_obj.stop + step = slice_obj.step + for attr in [start, stop, step]: + if attr is None: + continue + if attr < 0: + raise IndexError("Negative indexing not supported.") + + if start is None: + start = 0 + + if stop is None: + stop = upper_bound + + elif stop > upper_bound: + raise IndexError("Index out of bounds") + + step = slice_obj.step + if step is None: + step = 1 + + return slice(start, stop, step) + + class ColorFeature(GraphicFeature): - def __init__(self, parent, data): - data = parent.geometry.colors.data + def __init__(self, parent, colors, n_colors): + """ + ColorFeature + + Parameters + ---------- + parent + + colors: str, array, or iterable + specify colors as a single human readable string, RGBA array, + or an iterable of strings or RGBA arrays + + n_colors: number of colors to hold, if passing in a single str or single RGBA array + """ + # if the color is provided as a numpy array + if isinstance(colors, np.ndarray): + if colors.shape == (4,): # single RGBA array + data = np.repeat( + np.array([colors]), + n_colors, + axis=0 + ) + # else assume it's already a stack of RGBA arrays, keep this directly as the data + elif colors.ndim == 2: + if colors.shape[1] != 4 and colors.shape[0] != n_colors: + raise ValueError( + "Valid array color arguments must be a single RGBA array or a stack of " + "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" + ) + data = colors + else: + raise ValueError( + "Valid array color arguments must be a single RGBA array or a stack of " + "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" + ) + + # if the color is provided as an iterable + elif isinstance(colors, (list, tuple)): + # if iterable of str + if all([isinstance(val, str) for val in colors]): + if not len(list) == n_colors: + raise ValueError( + f"Valid iterable color arguments must be a `tuple` or `list` of `str` " + f"where the length of the iterable is the same as the number of datapoints." + ) + + data = np.vstack([np.array(Color(c)) for c in colors]) + + # if it's a single RGBA array as a tuple/list + elif len(colors) == 4: + c = Color(colors) + data = np.repeat(np.array([c]), n_colors, axis=0) + + else: + raise ValueError( + f"Valid iterable color arguments must be a `tuple` or `list` representing RGBA values or " + f"an iterable of `str` with the same length as the number of datapoints." + ) + else: + # assume it's a single color, use pygfx.Color to parse it + c = Color(colors) + data = np.repeat(np.array([c]), n_colors, axis=0) + super(ColorFeature, self).__init__(parent, data) self._upper_bound = data.shape[0] @@ -32,29 +126,7 @@ def __init__(self, parent, data): def __setitem__(self, key, value): # parse numerical slice indices if isinstance(key, slice): - start = key.start - stop = key.stop - step = key.step - for attr in [start, stop, step]: - if attr is None: - continue - if attr < 0: - raise IndexError("Negative indexing not supported.") - - if start is None: - start = 0 - - if stop is None: - stop = self._upper_bound - - elif stop > self._upper_bound: - raise IndexError("Index out of bounds") - - step = key.step - if step is None: - step = 1 - - key = slice(start, stop, step) + key = cleanup_slice(key, self._upper_bound) indices = range(key.start, key.stop, key.step) # or single numerical index @@ -63,6 +135,24 @@ def __setitem__(self, key, value): raise IndexError("Index out of bounds") indices = [key] + elif isinstance(key, tuple): + if not isinstance(value, (float, int, np.ndarray)): + raise ValueError( + "If using multiple-fancy indexing for color, you can only set numerical" + "values since this sets the RGBA array data directly." + ) + + if len(key) != 2: + raise ValueError("fancy indexing for colors must be 2-dimension, i.e. [n_datapoints, RGBA]") + + # set the user passed data directly + self._parent.world_object.geometry.colors.data[key] = value + + # update range + _key = cleanup_slice(key[0], self._upper_bound) + self._update_range(_key) + return + else: raise TypeError("Graphic features only support integer and numerical fancy indexing") @@ -90,8 +180,8 @@ def __setitem__(self, key, value): axis=0 ) - elif value.shape[1] == 4 and value.ndim == 2: - if value.shape[0] != new_data_size: + elif value.ndim == 2: + if value.shape[1] != 4 and value.shape[0] != new_data_size: raise ValueError("numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)") # if there is a single datapoint to change color of but user has provided shape [1, 4] if new_data_size == 1: @@ -102,23 +192,23 @@ def __setitem__(self, key, value): else: raise ValueError("numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)") - self._parent.geometry.colors.data[key] = new_colors + self._parent.world_object.geometry.colors.data[key] = new_colors self._update_range(key) def _update_range(self, key): if isinstance(key, int): - self._parent.geometry.colors.update_range(key, size=1) + self._parent.world_object.geometry.colors.update_range(key, size=1) if key.step is None: # update range according to size using the offset - self._parent.geometry.colors.update_range(offset=key.start, size=key.stop - key.start) + self._parent.world_object.geometry.colors.update_range(offset=key.start, size=key.stop - key.start) else: step = key.step ixs = range(key.start, key.stop, step) # convert slice to indices for ix in ixs: - self._parent.geometry.colors.update_range(ix, size=1) + self._parent.world_object.geometry.colors.update_range(ix, size=1) def __getitem__(self, item): - return self._parent.geometry.colors.data[item] \ No newline at end of file + return self._parent.world_object.geometry.colors.data[item] \ No newline at end of file From 6904bb40e27215dde29744d8e5b69e99ac37ba0e Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 19 Dec 2022 04:32:44 -0500 Subject: [PATCH 04/11] more organized color feature --- fastplotlib/graphics/_graphic_attribute.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/fastplotlib/graphics/_graphic_attribute.py b/fastplotlib/graphics/_graphic_attribute.py index cb7df8401..7707a096e 100644 --- a/fastplotlib/graphics/_graphic_attribute.py +++ b/fastplotlib/graphics/_graphic_attribute.py @@ -9,13 +9,13 @@ def __init__(self, parent, data: Any): self._parent = parent self._data = data.astype(np.float32) - @property - def data(self) -> Any: - return self._data - def set_parent(self, parent: Any): self._parent = parent + @property + def data(self): + return self._data + @abstractmethod def __getitem__(self, item): pass @@ -28,6 +28,10 @@ def __setitem__(self, key, value): def _update_range(self, key): pass + @abstractmethod + def __repr__(self): + pass + def cleanup_slice(slice_obj: slice, upper_bound) -> slice: start = slice_obj.start @@ -56,7 +60,7 @@ def cleanup_slice(slice_obj: slice, upper_bound) -> slice: class ColorFeature(GraphicFeature): - def __init__(self, parent, colors, n_colors): + def __init__(self, parent, colors, n_colors, alpha: float = 1.0): """ ColorFeature @@ -119,6 +123,9 @@ def __init__(self, parent, colors, n_colors): c = Color(colors) data = np.repeat(np.array([c]), n_colors, axis=0) + if alpha != 1.0: + data[:, -1] = alpha + super(ColorFeature, self).__init__(parent, data) self._upper_bound = data.shape[0] @@ -211,4 +218,7 @@ def _update_range(self, key): self._parent.world_object.geometry.colors.update_range(ix, size=1) def __getitem__(self, item): - return self._parent.world_object.geometry.colors.data[item] \ No newline at end of file + return self._parent.world_object.geometry.colors.data[item] + + def __repr__(self): + return repr(self._parent.world_object.geometry.colors.data) From 5ceb39764c3aece252a1a1ae2cd7dfcfa88b8b18 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 19 Dec 2022 05:37:46 -0500 Subject: [PATCH 05/11] ColorFeature working, updated graphics and base Graphic color handling, updated examples --- examples/scatter.ipynb | 16 ++--- examples/simple.ipynb | 81 +++++++++++++--------- fastplotlib/graphics/_base.py | 57 +++++++-------- fastplotlib/graphics/_graphic_attribute.py | 8 ++- fastplotlib/graphics/histogram.py | 6 +- fastplotlib/graphics/image.py | 2 +- fastplotlib/graphics/line.py | 20 +++--- fastplotlib/graphics/scatter.py | 56 ++++++--------- fastplotlib/utils/functions.py | 8 +-- 9 files changed, 123 insertions(+), 131 deletions(-) diff --git a/examples/scatter.ipynb b/examples/scatter.ipynb index ce028366f..c059af3af 100644 --- a/examples/scatter.ipynb +++ b/examples/scatter.ipynb @@ -32,7 +32,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "f878eb6f385f4045bb2d0e6dc48a585d", + "model_id": "71c25d20922d4f52b35f502f2ac4ceed", "version_major": 2, "version_minor": 0 }, @@ -54,7 +54,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -66,7 +66,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "76163442cbce4eb598f4b44ed08cb12b", + "model_id": "a3e5310b931c45b6980cc02c3b24a19c", "version_major": 2, "version_minor": 0 }, @@ -109,7 +109,8 @@ ")\n", "\n", "# create a random distribution of 100 xyz coordinates\n", - "dims = (1000, 3)\n", + "n_points = 100_000\n", + "dims = (n_points, 3)\n", "\n", "offset = 15\n", "\n", @@ -122,11 +123,10 @@ " ]\n", ")\n", "\n", - "# colors with a numerical mapping for each offset\n", - "colors = np.array(([0] * 1000) + ([1] * 1000) + ([2] * 1000))\n", + "colors = [\"yellow\"] * n_points + [\"cyan\"] * n_points + [\"magenta\"] * n_points\n", "\n", "for subplot in grid_plot:\n", - " subplot.add_scatter(data=cloud, colors=colors, cmap='cool', alpha=0.7, size=3)\n", + " subplot.add_scatter(data=cloud, colors=colors, alpha=0.7, size=5)\n", " \n", " subplot.set_axes_visibility(True)\n", " subplot.set_grid_visibility(True)\n", @@ -141,7 +141,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7fe0b6ab-8b15-4884-80f4-4b298a57df9a", + "id": "b1841355-0872-488b-bd48-70afffeee8f9", "metadata": {}, "outputs": [], "source": [] diff --git a/examples/simple.ipynb b/examples/simple.ipynb index d82b8493b..1b97881a1 100644 --- a/examples/simple.ipynb +++ b/examples/simple.ipynb @@ -29,7 +29,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "1317a9d044c04706aa6ea66e0866ac15", + "model_id": "8ebd5934e45f4b099447b698355a89b7", "version_major": 2, "version_minor": 0 }, @@ -43,7 +43,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -55,7 +55,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "3d4c1377e9f345dd9826130942bece5d", + "model_id": "5bec681c1bbb4418867967d2ca2efb4c", "version_major": 2, "version_minor": 0 }, @@ -99,7 +99,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "536b4be224d9476f96ae854889914cc9", + "model_id": "0c8785fc4b6b481287e11df5ac9ceeaa", "version_major": 2, "version_minor": 0 }, @@ -113,7 +113,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -125,7 +125,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "b01db232b5c54e4abd50e969d3d19c33", + "model_id": "09b69e6d2e9f4c97859fdc087dadfcf8", "version_major": 2, "version_minor": 0 }, @@ -170,14 +170,14 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 5, "id": "86e70b1e-4328-4035-b992-70dff16d2a69", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "ca38f46bfd4c43d1906dde1f9868d5f3", + "model_id": "30f459d71a24493c97e053110c7f40a6", "version_major": 2, "version_minor": 0 }, @@ -191,7 +191,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -203,7 +203,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "caf889a00e3c445ea0ca79bcb97c045f", + "model_id": "3eea13f67b8349539fde8a071687428f", "version_major": 2, "version_minor": 0 }, @@ -211,7 +211,7 @@ "JupyterWgpuCanvas()" ] }, - "execution_count": 4, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -241,19 +241,19 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "id": "ef9743b3-5f81-4b79-9502-fa5fca08e56d", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "ad7aa1a192bd4b4e905e11e7d66f64e8", + "model_id": "4a632639f03a4f07942bea6cc5d1f8a6", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "VBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 107, 'timestamp': 1671240498.6405487, 'localtime': 1…" + "VBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 187, 'timestamp': 1671445864.2326205, 'localtime': 1…" ] }, "metadata": {}, @@ -275,19 +275,19 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 7, "id": "11839d95-8ff7-444c-ae13-6b072c3112c5", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "c71e87e4d7d9489ea7b6eb8ecc52e0e7", + "model_id": "0a10531d62ba4f58a44ea108286d37f6", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "HBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 193, 'timestamp': 1671240501.7765138, 'localtime': 1…" + "HBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 278, 'timestamp': 1671445867.6586423, 'localtime': 1…" ] }, "metadata": {}, @@ -308,14 +308,14 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 8, "id": "d13f71d3-3003-4e11-82bd-2876013671f7", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "8ce8011d8eba472099bf2aab5a91befe", + "model_id": "da3293ab326140e6aab24da9b52227ae", "version_major": 2, "version_minor": 0 }, @@ -329,7 +329,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -341,7 +341,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "0dab1bfc2a2640d0a9c24692e2b87099", + "model_id": "681ffaa75fbc49c883fbeb2bbc4e39a6", "version_major": 2, "version_minor": 0 }, @@ -349,7 +349,7 @@ "JupyterWgpuCanvas()" ] }, - "execution_count": 7, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -370,11 +370,30 @@ "# we can plot multiple things in the same plot\n", "# this is true for any graphic\n", "plot_l.add_line(data=data1, size=1.5, cmap=\"jet\")\n", - "plot_l.add_line(data=data2, size=7, cmap=\"plasma\")\n", + "thick_line = plot_l.add_line(data=data2, size=20, cmap=\"magma\")\n", "\n", "plot_l.show()" ] }, + { + "cell_type": "markdown", + "id": "071bc152-5594-4679-90c8-002ed12b37cf", + "metadata": {}, + "source": [ + "## `LineGraphic` and `ScatterGraphic` colors support fancy indexing!" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "6ae3e740-1ed1-4df6-bfcd-b64a48f45c8d", + "metadata": {}, + "outputs": [], + "source": [ + "# set the color of the first 250 datapoints, with a stepsize of 4\n", + "thick_line.colors[:250:4] = \"cyan\"" + ] + }, { "cell_type": "markdown", "id": "2c90862e-2f2a-451f-a468-0cf6b857e87a", @@ -385,14 +404,14 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 10, "id": "9c51229f-13a2-4653-bff3-15d43ddbca7b", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "c69434bb678e4f16b33af1b1a3a2564e", + "model_id": "e40576a061e54f7d81ba5deb1b72a8bd", "version_major": 2, "version_minor": 0 }, @@ -414,7 +433,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -426,7 +445,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "3868b0eda4e7467a842fb5631cfc0e1a", + "model_id": "02c372d2d7c449878b38ea327fcc014c", "version_major": 2, "version_minor": 0 }, @@ -434,7 +453,7 @@ "JupyterWgpuCanvas()" ] }, - "execution_count": 8, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } @@ -471,19 +490,19 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 11, "id": "f404a5ea-633b-43f5-87d1-237017bbca2a", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "cf987464d78248589bfca940f59d7c87", + "model_id": "9aa7953d785f4103b68b9d7828ab31f6", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "VBox(children=(HBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 46, 'timestamp': 1671240495.5535605, …" + "VBox(children=(HBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 81, 'timestamp': 1671445859.788689, '…" ] }, "metadata": {}, diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 69fb66066..758f09ad3 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -4,56 +4,48 @@ import pygfx from fastplotlib.utils import get_colors, map_labels_to_colors +from ._graphic_attribute import ColorFeature class Graphic: def __init__( self, data, - colors: np.ndarray = None, - colors_length: int = None, + colors: Any = False, + n_colors: int = None, cmap: str = None, alpha: float = 1.0, name: str = None ): + """ + + Parameters + ---------- + data + colors: Any + if ``False``, no color generation is performed, cmap is also ignored. + n_colors + cmap + alpha + name + """ self.data = data.astype(np.float32) self.colors = None self.name = name - # if colors_length is None: - # colors_length = self.data.shape[0] + if n_colors is None: + n_colors = self.data.shape[0] - if colors is not False: - self._set_colors(colors, colors_length, cmap, alpha, ) - - def _set_colors(self, colors, colors_length, cmap, alpha): - if colors_length is None: - colors_length = self.data.shape[0] - - if colors is None and cmap is None: # just white - self.colors = np.vstack([[1., 1., 1., 1.]] * colors_length).astype(np.float32) - - elif (colors is None) and (cmap is not None): - self.colors = get_colors(n_colors=colors_length, cmap=cmap, alpha=alpha) - - elif (colors is not None) and (cmap is None): - # assume it's already an RGBA array - colors = np.array(colors) - if colors.shape == (1, 4) or colors.shape == (4,): - self.colors = np.vstack([colors] * colors_length).astype(np.float32) - elif colors.ndim == 2 and colors.shape[1] == 4 and colors.shape[0] == colors_length: - self.colors = colors.astype(np.float32) - else: - raise ValueError(f"Colors array must have ndim == 2 and shape of [, 4]") + if cmap is not None and colors is not False: + colors = get_colors(n_colors=n_colors, cmap=cmap, alpha=alpha) - elif (colors is not None) and (cmap is not None): - if colors.ndim == 1 and np.issubdtype(colors.dtype, np.integer): - # assume it's a mapping of colors - self.colors = np.array(map_labels_to_colors(colors, cmap, alpha=alpha)).astype(np.float32) + if colors is not False: + self.colors = ColorFeature(parent=self, colors=colors, n_colors=n_colors, alpha=alpha) - else: - raise ValueError("Unknown color format") + @property + def world_object(self) -> pygfx.WorldObject: + return self._world_object @property def children(self) -> pygfx.WorldObject: @@ -67,4 +59,3 @@ def __repr__(self): return f"'{self.name}' fastplotlib.{self.__class__.__name__} @ {hex(id(self))}" else: return f"fastplotlib.{self.__class__.__name__} @ {hex(id(self))}" - diff --git a/fastplotlib/graphics/_graphic_attribute.py b/fastplotlib/graphics/_graphic_attribute.py index 7707a096e..ff9b4d35e 100644 --- a/fastplotlib/graphics/_graphic_attribute.py +++ b/fastplotlib/graphics/_graphic_attribute.py @@ -74,6 +74,10 @@ def __init__(self, parent, colors, n_colors, alpha: float = 1.0): n_colors: number of colors to hold, if passing in a single str or single RGBA array """ + # if provided as a numpy array of str + if isinstance(colors, np.ndarray): + if colors.dtype.kind in ["U", "S"]: + colors = colors.tolist() # if the color is provided as a numpy array if isinstance(colors, np.ndarray): if colors.shape == (4,): # single RGBA array @@ -97,10 +101,10 @@ def __init__(self, parent, colors, n_colors, alpha: float = 1.0): ) # if the color is provided as an iterable - elif isinstance(colors, (list, tuple)): + elif isinstance(colors, (list, tuple, np.ndarray)): # if iterable of str if all([isinstance(val, str) for val in colors]): - if not len(list) == n_colors: + if not len(colors) == n_colors: raise ValueError( f"Valid iterable color arguments must be a `tuple` or `list` of `str` " f"where the length of the iterable is the same as the number of datapoints." diff --git a/fastplotlib/graphics/histogram.py b/fastplotlib/graphics/histogram.py index 2c846a223..d8c71d9e6 100644 --- a/fastplotlib/graphics/histogram.py +++ b/fastplotlib/graphics/histogram.py @@ -1,4 +1,4 @@ -from _warnings import warn +from warnings import warn from typing import Union, Dict import numpy as np @@ -82,9 +82,9 @@ def __init__( data = np.vstack([x_positions_bins, self.hist]) - super(HistogramGraphic, self).__init__(data=data, colors=colors, colors_length=n_bins, **kwargs) + super(HistogramGraphic, self).__init__(data=data, colors=colors, n_colors=n_bins, **kwargs) - self.world_object: pygfx.Group = pygfx.Group() + self._world_object: pygfx.Group = pygfx.Group() for x_val, y_val, bin_center in zip(x_positions_bins, self.hist, self.bin_centers): geometry = pygfx.plane_geometry( diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index ddfc43772..b444ce723 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -67,7 +67,7 @@ def __init__( if (vmin is None) or (vmax is None): vmin, vmax = quick_min_max(data) - self.world_object: pygfx.Image = pygfx.Image( + self._world_object: pygfx.Image = pygfx.Image( pygfx.Geometry(grid=pygfx.Texture(self.data, dim=2)), pygfx.ImageBasicMaterial(clim=(vmin, vmax), map=get_cmap_texture(cmap)) ) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index c2d09bfb4..7f5d53f33 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -11,7 +11,7 @@ def __init__( data: Any, z_position: float = 0.0, size: float = 2.0, - colors: np.ndarray = None, + colors: Union[str, np.ndarray, Iterable] = "w", cmap: str = None, *args, **kwargs @@ -30,7 +30,9 @@ def __init__( size: float, optional thickness of the line - colors: + colors: str, array, or iterable + specify colors as a single human readable string, a single RGBA array, + or an iterable of strings or RGBA arrays cmap: str, optional apply a colormap to the line instead of assigning colors manually @@ -51,8 +53,8 @@ def __init__( self.data = np.ascontiguousarray(self.data) - self.world_object: pygfx.Line = pygfx.Line( - geometry=pygfx.Geometry(positions=self.data, colors=self.colors), + self._world_object: pygfx.Line = pygfx.Line( + geometry=pygfx.Geometry(positions=self.data, colors=self.colors.data), material=material(thickness=size, vertex_colors=True) ) @@ -61,7 +63,7 @@ def __init__( def fix_data(self): # TODO: data should probably be a property of any Graphic?? Or use set_data() and get_data() if self.data.ndim == 1: - self.data = np.dstack([np.arange(self.data.size), self.data])[0] + self.data = np.dstack([np.arange(self.data.size), self.data])[0].astype(np.float32) if self.data.shape[1] != 3: if self.data.shape[1] != 2: @@ -70,7 +72,7 @@ def fix_data(self): # zeros for z zs = np.zeros(self.data.shape[0], dtype=np.float32) - self.data = np.dstack([self.data[:, 0], self.data[:, 1], zs])[0] + self.data = np.dstack([self.data[:, 0], self.data[:, 1], zs])[0].astype(np.float32) def update_data(self, data: np.ndarray): self.data = data.astype(np.float32) @@ -78,9 +80,3 @@ def update_data(self, data: np.ndarray): self.world_object.geometry.positions.data[:] = self.data self.world_object.geometry.positions.update_range() - - def update_colors(self, colors: np.ndarray): - super(LineGraphic, self)._set_colors(colors=colors, colors_length=self.data.shape[0], cmap=None, alpha=None) - - self.world_object.geometry.colors.data[:] = self.colors - self.world_object.geometry.colors.update_range() diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index b097f8c5a..c0c686643 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -7,49 +7,37 @@ class ScatterGraphic(Graphic): - def __init__(self, data: np.ndarray, z_position: float = 0.0, size: int = 1, colors: np.ndarray = None, cmap: str = None, *args, **kwargs): + def __init__(self, data: np.ndarray, z_position: float = 0.0, size: int = 1, colors: np.ndarray = "w", cmap: str = None, *args, **kwargs): super(ScatterGraphic, self).__init__(data, colors=colors, cmap=cmap, *args, **kwargs) - if self.data.ndim == 1: - # assume single 3D point - if not self.data.size == 3: - raise ValueError("If passing single you must specify all coordinates, i.e. x, y and z.") - elif self.data.shape[1] != 3: - if self.data.shape[1] == 2: - - # zeros - zs = np.zeros(self.data.shape[0], dtype=np.float32) - - self.data = np.dstack([self.data[:, 0], self.data[:, 1], zs])[0] - if self.data.shape[1] > 3 or self.data.shape[1] < 1: - raise ValueError("Must pass 2D or 3D data or a single point") + self.fix_data() - self.world_object: pygfx.Group = pygfx.Group() - self.points_objects: List[pygfx.Points] = list() + sizes = np.full(self.data.shape[0], size, dtype=np.float32) - for color in np.unique(self.colors, axis=0): - positions = self._process_positions( - self.data[np.all(self.colors == color, axis=1)] - ) + self._world_object: pygfx.Points = pygfx.Points( + pygfx.Geometry(positions=self.data, sizes=sizes, colors=self.colors.data), + material=pygfx.PointsMaterial(vertex_colors=True, vertex_sizes=True) + ) - points = pygfx.Points( - pygfx.Geometry(positions=positions), - pygfx.PointsMaterial(size=size, color=color) - ) + self.world_object.position.z = z_position - self.world_object.add(points) - self.points_objects.append(points) + def fix_data(self): + # TODO: data should probably be a property of any Graphic?? Or use set_data() and get_data() + if self.data.ndim == 1: + self.data = np.array([self.data]) - self.world_object.position.z = z_position + if self.data.shape[1] != 3: + if self.data.shape[1] != 2: + raise ValueError("Must pass 1D, 2D or 3D data") - def _process_positions(self, positions: np.ndarray): - if positions.ndim == 1: - positions = np.array([positions]) + # zeros for z + zs = np.zeros(self.data.shape[0], dtype=np.float32) - return positions + self.data = np.dstack([self.data[:, 0], self.data[:, 1], zs])[0].astype(np.float32) def update_data(self, data: np.ndarray): - positions = self._process_positions(data).astype(np.float32) + self.data = data + self.fix_data() - self.points_objects[0].geometry.positions.data[:] = positions - self.points_objects[0].geometry.positions.update_range(positions.shape[0]) + self.world_object.geometry.positions.data[:] = self.data + self.world_object.geometry.positions.update_range(self.data.shape[0]) diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index 650cfb053..698d20113 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -30,13 +30,7 @@ def _get_cmap(name: str, alpha: float = 1.0) -> np.ndarray: return cmap.astype(np.float32) -def get_colors( - n_colors: int, - cmap: str, - spacing: str = 'uniform', - alpha: float = 1.0 - ) \ - -> List[Union[np.ndarray, str]]: +def get_colors(n_colors: int, cmap: str, alpha: float = 1.0) -> np.ndarray: cmap = _get_cmap(cmap, alpha) cm_ixs = np.linspace(0, 255, n_colors, dtype=int) return np.take(cmap, cm_ixs, axis=0).astype(np.float32) From 3c9ce6f0473dee9c9931689365f9181d4c643a75 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 19 Dec 2022 06:15:16 -0500 Subject: [PATCH 06/11] update simple.ipynb, better line plot example --- examples/simple.ipynb | 76 +++++++++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/examples/simple.ipynb b/examples/simple.ipynb index 1b97881a1..6abc209aa 100644 --- a/examples/simple.ipynb +++ b/examples/simple.ipynb @@ -29,7 +29,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "8ebd5934e45f4b099447b698355a89b7", + "model_id": "2e88b0ad3e46444ca31895770a829a3b", "version_major": 2, "version_minor": 0 }, @@ -43,7 +43,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -55,7 +55,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "5bec681c1bbb4418867967d2ca2efb4c", + "model_id": "47f3951969584a49973ae236cf981af6", "version_major": 2, "version_minor": 0 }, @@ -99,7 +99,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "0c8785fc4b6b481287e11df5ac9ceeaa", + "model_id": "52528f6c66cb42d1820199dfceeb5e36", "version_major": 2, "version_minor": 0 }, @@ -113,7 +113,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -125,7 +125,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "09b69e6d2e9f4c97859fdc087dadfcf8", + "model_id": "28c771cce3da42d6a60ae0b81b87dbb4", "version_major": 2, "version_minor": 0 }, @@ -177,7 +177,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "30f459d71a24493c97e053110c7f40a6", + "model_id": "6ba438303768433e9520eea699bd92bb", "version_major": 2, "version_minor": 0 }, @@ -191,7 +191,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -203,7 +203,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "3eea13f67b8349539fde8a071687428f", + "model_id": "ad8919bc5ac444c295086b42830d9825", "version_major": 2, "version_minor": 0 }, @@ -248,12 +248,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "4a632639f03a4f07942bea6cc5d1f8a6", + "model_id": "4ab67f4f9c9f406aa208a3824f255c17", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "VBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 187, 'timestamp': 1671445864.2326205, 'localtime': 1…" + "VBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 121, 'timestamp': 1671448418.0740232, 'localtime': 1…" ] }, "metadata": {}, @@ -282,12 +282,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "0a10531d62ba4f58a44ea108286d37f6", + "model_id": "3485b48fded949bfa648110c7238ef8e", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "HBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 278, 'timestamp': 1671445867.6586423, 'localtime': 1…" + "HBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 179, 'timestamp': 1671448422.2939425, 'localtime': 1…" ] }, "metadata": {}, @@ -303,7 +303,7 @@ "id": "e7859338-8162-408b-ac72-37e606057045", "metadata": {}, "source": [ - "### 2D line plot" + "### 2D line plot which also shows the color system used for `LineGraphic` and `ScatterGraphic`" ] }, { @@ -315,7 +315,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "da3293ab326140e6aab24da9b52227ae", + "model_id": "2b022c99355a4667aa36d2dbd981a690", "version_major": 2, "version_minor": 0 }, @@ -329,7 +329,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -341,7 +341,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "681ffaa75fbc49c883fbeb2bbc4e39a6", + "model_id": "641c1dea5561493d9f94cfc06420b547", "version_major": 2, "version_minor": 0 }, @@ -357,20 +357,34 @@ "source": [ "plot_l = Plot()\n", "\n", - "# create data for a sine wave\n", - "xs = np.linspace(0, 30, 500)\n", + "# linspace, create 500 evenly spaced x values from -10 to 10\n", + "xs = np.linspace(-10, 10, 100)\n", + "# sine wave\n", "ys = np.sin(xs)\n", + "sine = np.dstack([xs, ys])[0]\n", "\n", - "data1 = np.dstack([xs, ys])[0]\n", "\n", - "# and cosine wave\n", + "# cosine wave\n", "ys = np.cos(xs) + 5\n", - "data2 = np.dstack([xs, ys])[0]\n", + "cosine = np.dstack([xs, ys])[0]\n", + "\n", + "# ricker wavelet\n", + "a = 0.5\n", + "ys = (2/(np.sqrt(3*a)*(np.pi**0.25))) * (1 - (xs/a)**2) * np.exp(-0.5*(xs/a)**2) * 2 + 10\n", + "ricker = np.dstack([xs, ys])[0]\n", "\n", "# we can plot multiple things in the same plot\n", "# this is true for any graphic\n", - "plot_l.add_line(data=data1, size=1.5, cmap=\"jet\")\n", - "thick_line = plot_l.add_line(data=data2, size=20, cmap=\"magma\")\n", + "\n", + "# plot sine wave, use a single color\n", + "plot_l.add_line(data=sine, size=1.5, colors=\"magenta\")\n", + "\n", + "# you can also use colormaps for lines\n", + "cosine_graphic = plot_l.add_line(data=cosine, size=15, cmap=\"autumn\")\n", + "\n", + "# or a list of colors for each datapoint\n", + "colors = [\"r\"] * 25 + [\"purple\"] * 25 + [\"y\"] * 25 + [\"b\"] * 25\n", + "plot_l.add_line(data=ricker, size=5, colors = colors)\n", "\n", "plot_l.show()" ] @@ -390,8 +404,8 @@ "metadata": {}, "outputs": [], "source": [ - "# set the color of the first 250 datapoints, with a stepsize of 4\n", - "thick_line.colors[:250:4] = \"cyan\"" + "# set the color of the first 250 datapoints, with a stepsize of 3\n", + "cosine_graphic.colors[:50:3] = \"cyan\"" ] }, { @@ -411,7 +425,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "e40576a061e54f7d81ba5deb1b72a8bd", + "model_id": "ef13097bbd594251b5b7fcbe65d69582", "version_major": 2, "version_minor": 0 }, @@ -433,7 +447,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -445,7 +459,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "02c372d2d7c449878b38ea327fcc014c", + "model_id": "2eed61872e45405bb9f92f51ce79c14c", "version_major": 2, "version_minor": 0 }, @@ -497,12 +511,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "9aa7953d785f4103b68b9d7828ab31f6", + "model_id": "80886ffd88e4491cb9a03a04fbf0926c", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "VBox(children=(HBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 81, 'timestamp': 1671445859.788689, '…" + "VBox(children=(HBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 56, 'timestamp': 1671448415.4279587, …" ] }, "metadata": {}, From f7dc0639d385c31af7398b5832d3b582204012ef Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 21 Dec 2022 03:38:57 -0500 Subject: [PATCH 07/11] can directly set graphic features, Graphic has __setattr__, added Graphic.visible --- fastplotlib/graphics/_base.py | 33 ++++++++++++++++++++-- fastplotlib/graphics/_graphic_attribute.py | 30 +++++++++++++------- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 758f09ad3..25f3e47f0 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -1,10 +1,10 @@ -from typing import Any +from typing import * import numpy as np import pygfx from fastplotlib.utils import get_colors, map_labels_to_colors -from ._graphic_attribute import ColorFeature +from ._graphic_attribute import GraphicFeature, ColorFeature class Graphic: @@ -43,10 +43,30 @@ def __init__( if colors is not False: self.colors = ColorFeature(parent=self, colors=colors, n_colors=n_colors, alpha=alpha) + valid_features = ["visible"] + for attr_name in self.__dict__.keys(): + attr = getattr(self, attr_name) + if isinstance(attr, GraphicFeature): + valid_features.append(attr_name) + + self._valid_features = tuple(valid_features) + @property def world_object(self) -> pygfx.WorldObject: return self._world_object + @property + def valid_features(self) -> Tuple[str]: + return self._valid_features + + @property + def visible(self) -> bool: + return self.world_object.visible + + @visible.setter + def visible(self, v): + self.world_object.visible = v + @property def children(self) -> pygfx.WorldObject: return self.world_object.children @@ -54,6 +74,15 @@ def children(self) -> pygfx.WorldObject: def update_data(self, data: Any): pass + def __setattr__(self, key, value): + if hasattr(self, key): + attr = getattr(self, key) + if isinstance(attr, GraphicFeature): + attr._set(value) + return + + super().__setattr__(key, value) + def __repr__(self): if self.name is not None: return f"'{self.name}' fastplotlib.{self.__class__.__name__} @ {hex(id(self))}" diff --git a/fastplotlib/graphics/_graphic_attribute.py b/fastplotlib/graphics/_graphic_attribute.py index ff9b4d35e..10a6f4dac 100644 --- a/fastplotlib/graphics/_graphic_attribute.py +++ b/fastplotlib/graphics/_graphic_attribute.py @@ -1,35 +1,45 @@ from abc import ABC, abstractmethod from typing import * -from pygfx import Color +from pygfx import Color, Scene import numpy as np class GraphicFeature(ABC): def __init__(self, parent, data: Any): self._parent = parent - self._data = data.astype(np.float32) + if isinstance(data, np.ndarray): + data = data.astype(np.float32) - def set_parent(self, parent: Any): - self._parent = parent + self._data = data @property def data(self): return self._data @abstractmethod - def __getitem__(self, item): + def _set(self, value): pass @abstractmethod - def __setitem__(self, key, value): + def __repr__(self): pass + +class GraphicFeatureIndexable(GraphicFeature): + """And indexable Graphic Feature, colors, data, sizes etc.""" + def _set(self, value): + self[:] = value + @abstractmethod - def _update_range(self, key): + def __getitem__(self, item): pass @abstractmethod - def __repr__(self): + def __setitem__(self, key, value): + pass + + @abstractmethod + def _update_range(self, key): pass @@ -59,14 +69,14 @@ def cleanup_slice(slice_obj: slice, upper_bound) -> slice: return slice(start, stop, step) -class ColorFeature(GraphicFeature): +class ColorFeature(GraphicFeatureIndexable): def __init__(self, parent, colors, n_colors, alpha: float = 1.0): """ ColorFeature Parameters ---------- - parent + parent: Graphic or GraphicCollection colors: str, array, or iterable specify colors as a single human readable string, RGBA array, From 71037f0c00218a412e16e95ef34f9eecf19248c3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 21 Dec 2022 03:53:24 -0500 Subject: [PATCH 08/11] added PresentFeature to toggle graphic's presence in the scene --- fastplotlib/graphics/_base.py | 6 +++- fastplotlib/graphics/_graphic_attribute.py | 34 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 25f3e47f0..8c69df6f1 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -4,7 +4,7 @@ import pygfx from fastplotlib.utils import get_colors, map_labels_to_colors -from ._graphic_attribute import GraphicFeature, ColorFeature +from ._graphic_attribute import GraphicFeature, ColorFeature, PresentFeature class Graphic: @@ -43,6 +43,10 @@ def __init__( if colors is not False: self.colors = ColorFeature(parent=self, colors=colors, n_colors=n_colors, alpha=alpha) + # different from visible, toggles the Graphic presence in the Scene + # useful for bbox calculations to ignore these Graphics + self.present = PresentFeature(parent=self) + valid_features = ["visible"] for attr_name in self.__dict__.keys(): attr = getattr(self, attr_name) diff --git a/fastplotlib/graphics/_graphic_attribute.py b/fastplotlib/graphics/_graphic_attribute.py index 10a6f4dac..59989a759 100644 --- a/fastplotlib/graphics/_graphic_attribute.py +++ b/fastplotlib/graphics/_graphic_attribute.py @@ -236,3 +236,37 @@ def __getitem__(self, item): def __repr__(self): return repr(self._parent.world_object.geometry.colors.data) + + +class PresentFeature(GraphicFeature): + """ + Toggles if the object is present in the scene, different from visible \n + Useful for computing bounding boxes from the Scene to only include graphics + that are present + """ + def __init__(self, parent, present: bool = True): + self._scene = None + super(PresentFeature, self).__init__(parent, present) + + def _set(self, present: bool): + i = 0 + while not isinstance(self._scene, Scene): + self._scene = self._parent.world_object.parent + i += 1 + + if i > 100: + raise RecursionError( + "Exceded scene graph depth threshold, cannot find Scene associated with" + "this graphic." + ) + + if present: + if self._parent.world_object not in self._scene.children: + self._scene.add(self._parent.world_object) + + else: + if self._parent.world_object in self._scene.children: + self._scene.remove(self._parent.world_object) + + def __repr__(self): + return repr(self.data) From c0ccb03845f0e00abe6977258e15d1843c10a30a Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 22 Dec 2022 04:49:33 -0500 Subject: [PATCH 09/11] add DataFeature, refactor to update other things, HistogramGraphic is broken because of DataFeature --- examples/gridplot.ipynb | 20 +- examples/gridplot_simple.ipynb | 18 +- examples/lineplot.ipynb | 19 +- examples/scatter.ipynb | 82 +++++-- examples/simple.ipynb | 236 ++++++++++++++---- fastplotlib/graphics/_base.py | 38 ++- fastplotlib/graphics/_graphic_attribute.py | 269 --------------------- fastplotlib/graphics/features/__init__.py | 4 + fastplotlib/graphics/features/_base.py | 118 +++++++++ fastplotlib/graphics/features/_colors.py | 181 ++++++++++++++ fastplotlib/graphics/features/_data.py | 55 +++++ fastplotlib/graphics/features/_present.py | 36 +++ fastplotlib/graphics/features/_sizes.py | 0 fastplotlib/graphics/histogram.py | 2 +- fastplotlib/graphics/image.py | 10 +- fastplotlib/graphics/line.py | 29 +-- fastplotlib/graphics/scatter.py | 39 +-- fastplotlib/utils/functions.py | 33 +++ fastplotlib/widgets/image.py | 2 +- 19 files changed, 752 insertions(+), 439 deletions(-) create mode 100644 fastplotlib/graphics/features/__init__.py create mode 100644 fastplotlib/graphics/features/_base.py create mode 100644 fastplotlib/graphics/features/_colors.py create mode 100644 fastplotlib/graphics/features/_data.py create mode 100644 fastplotlib/graphics/features/_present.py create mode 100644 fastplotlib/graphics/features/_sizes.py diff --git a/examples/gridplot.ipynb b/examples/gridplot.ipynb index d93295bbc..512c362d7 100644 --- a/examples/gridplot.ipynb +++ b/examples/gridplot.ipynb @@ -28,7 +28,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "5d26f20d062b4d3eba78c6fb1a70d228", + "model_id": "77d5643b30ac468f8d26322edab10f2d", "version_major": 2, "version_minor": 0 }, @@ -42,7 +42,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -54,7 +54,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "745191726e1c44cbb338cf087d79728b", + "model_id": "2ff6060dc64545d28c480b0ede36c6d9", "version_major": 2, "version_minor": 0 }, @@ -109,7 +109,7 @@ "def set_random_frame():\n", " for ig in image_graphics:\n", " new_data = np.random.rand(512, 512)\n", - " ig.update_data(data=new_data)\n", + " ig.data = new_data\n", "\n", "# add the animation\n", "grid_plot.add_animations(set_random_frame)\n", @@ -133,10 +133,10 @@ { "data": { "text/plain": [ - "subplot0: Subplot @ 0x7fd08bbeb730\n", + "subplot0: Subplot @ 0x7f3c6012a8c0\n", " parent: None\n", " Graphics:\n", - "\t'image' fastplotlib.ImageGraphic @ 0x7fd08bbebfa0" + "\t'image' fastplotlib.ImageGraphic @ 0x7f3c6012a8f0" ] }, "execution_count": 3, @@ -158,10 +158,10 @@ { "data": { "text/plain": [ - "subplot0: Subplot @ 0x7fd08bbeb730\n", + "subplot0: Subplot @ 0x7f3c6012a8c0\n", " parent: None\n", " Graphics:\n", - "\t'image' fastplotlib.ImageGraphic @ 0x7fd08bbebfa0" + "\t'image' fastplotlib.ImageGraphic @ 0x7f3c6012a8f0" ] }, "execution_count": 4, @@ -192,7 +192,7 @@ { "data": { "text/plain": [ - "'image' fastplotlib.ImageGraphic @ 0x7fd08bbebfa0" + "'image' fastplotlib.ImageGraphic @ 0x7f3c6012a8f0" ] }, "execution_count": 5, @@ -236,7 +236,7 @@ { "cell_type": "code", "execution_count": null, - "id": "2c69b9dc-fb21-4515-a145-4ba0c04cacb1", + "id": "a025b76c-77f8-4aeb-ac33-5bb6d0bb5a9a", "metadata": {}, "outputs": [], "source": [] diff --git a/examples/gridplot_simple.ipynb b/examples/gridplot_simple.ipynb index cf99bac7b..ee8a88983 100644 --- a/examples/gridplot_simple.ipynb +++ b/examples/gridplot_simple.ipynb @@ -28,7 +28,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "f67bb4e00c0442d6b6fbd2eda11e5f9c", + "model_id": "4a46061e2aca46aeb6dd21faef1c3ba3", "version_major": 2, "version_minor": 0 }, @@ -42,7 +42,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -54,7 +54,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "015b67891ce94d569c3f42c67f1c4e16", + "model_id": "5ced1c73cc114f25875aebf367282a5c", "version_major": 2, "version_minor": 0 }, @@ -91,7 +91,7 @@ "def update_data():\n", " for ig in image_graphics:\n", " new_data = np.random.rand(512, 512)\n", - " ig.update_data(data=new_data)\n", + " ig.data = new_data\n", "\n", "# add the animation function\n", "grid_plot.add_animations(update_data)\n", @@ -117,10 +117,10 @@ { "data": { "text/plain": [ - "unnamed: Subplot @ 0x7efdd43e78e0\n", + "unnamed: Subplot @ 0x7fd48d3a96f0\n", " parent: None\n", " Graphics:\n", - "\tfastplotlib.ImageGraphic @ 0x7efdc790beb0" + "\tfastplotlib.ImageGraphic @ 0x7fd486b9fd60" ] }, "execution_count": 3, @@ -151,7 +151,7 @@ { "data": { "text/plain": [ - "[fastplotlib.ImageGraphic @ 0x7efdc7925120]" + "[fastplotlib.ImageGraphic @ 0x7fd486b85f00]" ] }, "execution_count": 4, @@ -209,10 +209,10 @@ { "data": { "text/plain": [ - "top-right-plot: Subplot @ 0x7efdd4222d70\n", + "top-right-plot: Subplot @ 0x7fd486b00a90\n", " parent: None\n", " Graphics:\n", - "\tfastplotlib.ImageGraphic @ 0x7efdc7970070" + "\tfastplotlib.ImageGraphic @ 0x7fd486bd5fc0" ] }, "execution_count": 7, diff --git a/examples/lineplot.ipynb b/examples/lineplot.ipynb index 630fac3cd..7561efe88 100644 --- a/examples/lineplot.ipynb +++ b/examples/lineplot.ipynb @@ -30,7 +30,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "f1faeefaf48a443cbc8a3b37d0c0d076", + "model_id": "68a29ed7dad343ee9191b9887f3ed47b", "version_major": 2, "version_minor": 0 }, @@ -52,7 +52,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -64,7 +64,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "eb81231750b04c149dc8bcd7a12d50c0", + "model_id": "3a918fe2ec1a403294f808032fb4133c", "version_major": 2, "version_minor": 0 }, @@ -127,7 +127,7 @@ " if i == 2:\n", " subplot.camera.scale.y = -1\n", " \n", - " marker = subplot.add_scatter(data=spiral[0], size=10)\n", + " marker = subplot.add_scatter(data=spiral[0], sizes=10)\n", " markers.append(marker)\n", " \n", "marker_index = 0\n", @@ -142,14 +142,11 @@ " if marker_index == spiral.shape[0]:\n", " marker_index = 0\n", " \n", - " new_markers = list()\n", + " # new_markers = list()\n", " for subplot, marker in zip(grid_plot, markers):\n", - " subplot.remove_graphic(marker)\n", - " new_marker = subplot.add_scatter(data=spiral[marker_index], size=15)\n", - " new_markers.append(new_marker)\n", + " pass\n", + " marker.data = spiral[marker_index]\n", " \n", - " markers = new_markers\n", - "\n", "# add `move_marker` to the animations\n", "grid_plot.add_animations(move_marker)\n", "\n", @@ -159,7 +156,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7dbbe4a7-d15c-4a8f-8f51-ac0089870794", + "id": "e388eb93-7a9b-4ae4-91fc-cf32947f63a9", "metadata": {}, "outputs": [], "source": [] diff --git a/examples/scatter.ipynb b/examples/scatter.ipynb index c059af3af..27054aadf 100644 --- a/examples/scatter.ipynb +++ b/examples/scatter.ipynb @@ -12,7 +12,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 14, "id": "9b3041ad-d94e-4b2a-af4d-63bcd19bf6c2", "metadata": { "tags": [] @@ -25,14 +25,14 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 21, "id": "922990b6-24e9-4fa0-977b-6577f9752d84", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "71c25d20922d4f52b35f502f2ac4ceed", + "model_id": "2c91040e84e1425fac42c3e548d58293", "version_major": 2, "version_minor": 0 }, @@ -43,18 +43,10 @@ "metadata": {}, "output_type": "display_data" }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/kushal/Insync/kushalkolar@gmail.com/drive/repos/fastplotlib/fastplotlib/layouts/_base.py:142: UserWarning: `center_scene()` not yet implemented for `PerspectiveCamera`\n", - " warn(\"`center_scene()` not yet implemented for `PerspectiveCamera`\")\n" - ] - }, { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -66,7 +58,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "a3e5310b931c45b6980cc02c3b24a19c", + "model_id": "62af1ff95e37408eaed099aaf6ab72d2", "version_major": 2, "version_minor": 0 }, @@ -74,7 +66,7 @@ "JupyterWgpuCanvas()" ] }, - "execution_count": 2, + "execution_count": 21, "metadata": {}, "output_type": "execute_result" } @@ -108,8 +100,12 @@ " controllers=controllers\n", ")\n", "\n", - "# create a random distribution of 100 xyz coordinates\n", - "n_points = 100_000\n", + "# create a random distribution of 10,000 xyz coordinates\n", + "n_points = 10_000\n", + "\n", + "# if you have a good GPU go for 1.2 million points :D \n", + "# this is multiplied by 3\n", + "n_points = 400_000\n", "dims = (n_points, 3)\n", "\n", "offset = 15\n", @@ -126,7 +122,7 @@ "colors = [\"yellow\"] * n_points + [\"cyan\"] * n_points + [\"magenta\"] * n_points\n", "\n", "for subplot in grid_plot:\n", - " subplot.add_scatter(data=cloud, colors=colors, alpha=0.7, size=5)\n", + " subplot.add_scatter(data=cloud, colors=colors, alpha=0.7, sizes=5)\n", " \n", " subplot.set_axes_visibility(True)\n", " subplot.set_grid_visibility(True)\n", @@ -138,10 +134,60 @@ "grid_plot.show()" ] }, + { + "cell_type": "code", + "execution_count": 22, + "id": "7b912961-f72e-46ef-889f-c03234831059", + "metadata": {}, + "outputs": [], + "source": [ + "grid_plot[0, 1].get_graphics()[0].colors[400_000:600_000] = \"r\"" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "c6085806-c001-4632-ab79-420b4692693a", + "metadata": {}, + "outputs": [], + "source": [ + "grid_plot[0, 1].get_graphics()[0].colors[:100_000:10] = \"blue\"" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "6f416825-df31-4e5d-b66b-07f23b48e7db", + "metadata": {}, + "outputs": [], + "source": [ + "grid_plot[0, 1].get_graphics()[0].colors[800_000:] = \"green\"" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "c0fd611e-73e5-49e6-a25c-9d5b64afa5f4", + "metadata": {}, + "outputs": [], + "source": [ + "grid_plot[0, 1].get_graphics()[0].colors[800_000:, -1] = 0" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "cd390542-3a44-4973-8172-89e5583433bc", + "metadata": {}, + "outputs": [], + "source": [ + "grid_plot[0, 1].get_graphics()[0].data[:400_000] = grid_plot[0, 1].get_graphics()[0].data[800_000:]" + ] + }, { "cell_type": "code", "execution_count": null, - "id": "b1841355-0872-488b-bd48-70afffeee8f9", + "id": "fb49930f-b795-4b41-bbc6-014a27c2f463", "metadata": {}, "outputs": [], "source": [] diff --git a/examples/simple.ipynb b/examples/simple.ipynb index 6abc209aa..089ac8ce1 100644 --- a/examples/simple.ipynb +++ b/examples/simple.ipynb @@ -29,7 +29,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "2e88b0ad3e46444ca31895770a829a3b", + "model_id": "92503c4bae564cf4a27ce8b3d7b1cf4d", "version_major": 2, "version_minor": 0 }, @@ -43,7 +43,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -55,7 +55,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "47f3951969584a49973ae236cf981af6", + "model_id": "246ede3f6ca448a5943d625cf4295eb1", "version_major": 2, "version_minor": 0 }, @@ -99,7 +99,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "52528f6c66cb42d1820199dfceeb5e36", + "model_id": "02a93cf7260f4f4e9d500209c75fe1a1", "version_major": 2, "version_minor": 0 }, @@ -113,7 +113,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -125,7 +125,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "28c771cce3da42d6a60ae0b81b87dbb4", + "model_id": "8cf632b698844607a62f468f3ffbb3f7", "version_major": 2, "version_minor": 0 }, @@ -151,7 +151,7 @@ "# a function to update the image_graphic\n", "def update_data():\n", " new_data = np.random.rand(512, 512)\n", - " image_graphic.update_data(new_data)\n", + " image_graphic.data = new_data\n", "\n", "#add this as an animation function\n", "plot_v.add_animations(update_data)\n", @@ -170,14 +170,14 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 4, "id": "86e70b1e-4328-4035-b992-70dff16d2a69", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "6ba438303768433e9520eea699bd92bb", + "model_id": "060eb107d9364a63ae77316c5c806f55", "version_major": 2, "version_minor": 0 }, @@ -191,7 +191,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -203,7 +203,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "ad8919bc5ac444c295086b42830d9825", + "model_id": "a289907ca50641709e1d6452397e68be", "version_major": 2, "version_minor": 0 }, @@ -211,7 +211,7 @@ "JupyterWgpuCanvas()" ] }, - "execution_count": 5, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -224,7 +224,7 @@ "\n", "def update_data_2():\n", " new_data = np.random.rand(512, 512)\n", - " image_2.update_data(new_data)\n", + " image_2.data = new_data\n", "\n", "plot_sync.add_animations(update_data_2)\n", "\n", @@ -241,19 +241,19 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 5, "id": "ef9743b3-5f81-4b79-9502-fa5fca08e56d", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "4ab67f4f9c9f406aa208a3824f255c17", + "model_id": "b9c22ba4b8f2404bad4d13e413b42e5f", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "VBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 121, 'timestamp': 1671448418.0740232, 'localtime': 1…" + "VBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 49, 'timestamp': 1671701574.7427897, 'localtime': 16…" ] }, "metadata": {}, @@ -275,19 +275,19 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 6, "id": "11839d95-8ff7-444c-ae13-6b072c3112c5", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "3485b48fded949bfa648110c7238ef8e", + "model_id": "91d89f9b21c949f6a98c774b1b860fd5", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "HBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 179, 'timestamp': 1671448422.2939425, 'localtime': 1…" + "HBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 171, 'timestamp': 1671701579.1038444, 'localtime': 1…" ] }, "metadata": {}, @@ -308,14 +308,26 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 1, + "id": "783d0912-8878-4f63-a5d5-d3b59e5a050b", + "metadata": {}, + "outputs": [], + "source": [ + "from fastplotlib import Plot\n", + "from ipywidgets import VBox, HBox\n", + "import numpy as np" + ] + }, + { + "cell_type": "code", + "execution_count": 2, "id": "d13f71d3-3003-4e11-82bd-2876013671f7", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "2b022c99355a4667aa36d2dbd981a690", + "model_id": "ae01ee6507cd43ff9ea3b0e5fe2747b9", "version_major": 2, "version_minor": 0 }, @@ -329,7 +341,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -341,7 +353,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "641c1dea5561493d9f94cfc06420b547", + "model_id": "97a82b5c6fb240fc874d5b05bbef9858", "version_major": 2, "version_minor": 0 }, @@ -349,7 +361,7 @@ "JupyterWgpuCanvas()" ] }, - "execution_count": 8, + "execution_count": 2, "metadata": {}, "output_type": "execute_result" } @@ -380,15 +392,116 @@ "plot_l.add_line(data=sine, size=1.5, colors=\"magenta\")\n", "\n", "# you can also use colormaps for lines\n", - "cosine_graphic = plot_l.add_line(data=cosine, size=15, cmap=\"autumn\")\n", + "cosine_graphic = plot_l.add_line(data=cosine, size=5, cmap=\"autumn\")\n", "\n", "# or a list of colors for each datapoint\n", "colors = [\"r\"] * 25 + [\"purple\"] * 25 + [\"y\"] * 25 + [\"b\"] * 25\n", - "plot_l.add_line(data=ricker, size=5, colors = colors)\n", + "ricker_graphic = plot_l.add_line(data=ricker, size=5, colors = colors)\n", "\n", "plot_l.show()" ] }, + { + "cell_type": "code", + "execution_count": 3, + "id": "d1a4314b-5723-43c7-94a0-b4cbb0e44d60", + "metadata": {}, + "outputs": [], + "source": [ + "cosine_graphic.data[10:50:5, :2] = sine[10:50:5]\n", + "cosine_graphic.data[90:, 1] = 7" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "682db47b-8c7a-4934-9be4-2067e9fb12d5", + "metadata": {}, + "outputs": [], + "source": [ + "cosine_graphic.data[0] = np.array([[-10, 0, 0]])" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "30d9deb2-5581-4dab-bb00-2e1828216b91", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ricker_graphic.present" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "fcba75b7-9a1e-4aae-9dec-715f7f7456c3", + "metadata": {}, + "outputs": [], + "source": [ + "ricker_graphic.present = False" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "0aa2b178-4bb9-4819-a08d-9187ec0e53c0", + "metadata": {}, + "outputs": [], + "source": [ + "def auto_scale(p):\n", + " p.center_scene()\n", + " p.camera.maintain_aspect = False\n", + " width, height, depth = np.ptp(p.scene.get_world_bounding_box(), axis=0)\n", + " p.camera.width = width\n", + " p.camera.height = height\n", + "\n", + " p.controller.distance = 0\n", + " \n", + " p.controller.zoom(0.8 / p.controller.zoom_value)" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "e90815b1-fb89-4062-b3a9-b6370dc06f66", + "metadata": {}, + "outputs": [], + "source": [ + "auto_scale(plot_l)" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "763b9943-53a4-4e2a-b47a-4e9e5c9d7be3", + "metadata": {}, + "outputs": [], + "source": [ + "ricker_graphic.present = True" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "4286113b-7c4b-45fc-a870-1e783e4bd7d0", + "metadata": {}, + "outputs": [], + "source": [ + "auto_scale(plot_l)" + ] + }, { "cell_type": "markdown", "id": "071bc152-5594-4679-90c8-002ed12b37cf", @@ -399,13 +512,47 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 25, "id": "6ae3e740-1ed1-4df6-bfcd-b64a48f45c8d", "metadata": {}, "outputs": [], "source": [ "# set the color of the first 250 datapoints, with a stepsize of 3\n", - "cosine_graphic.colors[:50:3] = \"cyan\"" + "cosine_graphic.colors[15:50:3] = \"cyan\"\n", + "\n", + "cosine_graphic.colors[:5] = \"magenta\"\n", + "cosine_graphic.colors[90:] = \"yellow\"\n", + "cosine_graphic.colors[60] = \"w\"" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "1933c64b-8286-490b-8159-57f6c25a4923", + "metadata": {}, + "outputs": [], + "source": [ + "cosine_graphic.colors[50:, 2] = 1" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "bb4cde02-8b09-4dac-a041-bed2bfa36cb1", + "metadata": {}, + "outputs": [], + "source": [ + "cosine_graphic.colors[50:, -1] = 0.4" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "0212d062-956a-4133-ac4d-937781f505fb", + "metadata": {}, + "outputs": [], + "source": [ + "cosine_graphic.colors = \"r\"" ] }, { @@ -418,14 +565,14 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 29, "id": "9c51229f-13a2-4653-bff3-15d43ddbca7b", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "ef13097bbd594251b5b7fcbe65d69582", + "model_id": "0767e2dc0868414baca5754fb724107f", "version_major": 2, "version_minor": 0 }, @@ -447,7 +594,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -459,7 +606,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "2eed61872e45405bb9f92f51ce79c14c", + "model_id": "09995d983d9c4ca7bc31d820a799a219", "version_major": 2, "version_minor": 0 }, @@ -467,7 +614,7 @@ "JupyterWgpuCanvas()" ] }, - "execution_count": 10, + "execution_count": 29, "metadata": {}, "output_type": "execute_result" } @@ -504,23 +651,20 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 30, "id": "f404a5ea-633b-43f5-87d1-237017bbca2a", "metadata": {}, "outputs": [ { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "80886ffd88e4491cb9a03a04fbf0926c", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(HBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 56, 'timestamp': 1671448415.4279587, …" - ] - }, - "metadata": {}, - "output_type": "display_data" + "ename": "NameError", + "evalue": "name 'plot' is not defined", + "output_type": "error", + "traceback": [ + "\u001B[0;31m---------------------------------------------------------------------------\u001B[0m", + "\u001B[0;31mNameError\u001B[0m Traceback (most recent call last)", + "Input \u001B[0;32mIn [30]\u001B[0m, in \u001B[0;36m\u001B[0;34m()\u001B[0m\n\u001B[0;32m----> 1\u001B[0m row1 \u001B[38;5;241m=\u001B[39m HBox([\u001B[43mplot\u001B[49m\u001B[38;5;241m.\u001B[39mshow(), plot_v\u001B[38;5;241m.\u001B[39mshow(), plot_sync\u001B[38;5;241m.\u001B[39mshow()])\n\u001B[1;32m 2\u001B[0m row2 \u001B[38;5;241m=\u001B[39m HBox([plot_l\u001B[38;5;241m.\u001B[39mshow(), plot_l3d\u001B[38;5;241m.\u001B[39mshow()])\n\u001B[1;32m 4\u001B[0m VBox([row1, row2])\n", + "\u001B[0;31mNameError\u001B[0m: name 'plot' is not defined" + ] } ], "source": [ diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 8c69df6f1..a1a2633b9 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -1,10 +1,9 @@ from typing import * -import numpy as np import pygfx -from fastplotlib.utils import get_colors, map_labels_to_colors -from ._graphic_attribute import GraphicFeature, ColorFeature, PresentFeature +from ..utils import get_colors +from .features import GraphicFeature, DataFeature, ColorFeature, PresentFeature class Graphic: @@ -21,21 +20,35 @@ def __init__( Parameters ---------- - data + data: array-like + data to show in the graphic, must be float32. + Automatically converted to float32 for numpy arrays. + Tensorflow Tensors also work but this is not fully + tested and might not be supported in the future. + colors: Any if ``False``, no color generation is performed, cmap is also ignored. + n_colors - cmap - alpha - name + + cmap: str + name of colormap to use + + alpha: float, optional + alpha value for the colors + + name: str, optional + name this graphic, makes it indexable within plots + """ - self.data = data.astype(np.float32) + # self.data = data.astype(np.float32) + self.data = DataFeature(parent=self, data=data, graphic_name=self.__class__.__name__) self.colors = None self.name = name if n_colors is None: - n_colors = self.data.shape[0] + n_colors = self.data.feature_data.shape[0] if cmap is not None and colors is not False: colors = get_colors(n_colors=n_colors, cmap=cmap, alpha=alpha) @@ -60,7 +73,8 @@ def world_object(self) -> pygfx.WorldObject: return self._world_object @property - def valid_features(self) -> Tuple[str]: + def interact_features(self) -> Tuple[str]: + """The features for this ``Graphic`` that support interaction.""" return self._valid_features @property @@ -69,15 +83,13 @@ def visible(self) -> bool: @visible.setter def visible(self, v): + """Toggle the visibility of this Graphic""" self.world_object.visible = v @property def children(self) -> pygfx.WorldObject: return self.world_object.children - def update_data(self, data: Any): - pass - def __setattr__(self, key, value): if hasattr(self, key): attr = getattr(self, key) diff --git a/fastplotlib/graphics/_graphic_attribute.py b/fastplotlib/graphics/_graphic_attribute.py index 59989a759..b28b04f64 100644 --- a/fastplotlib/graphics/_graphic_attribute.py +++ b/fastplotlib/graphics/_graphic_attribute.py @@ -1,272 +1,3 @@ -from abc import ABC, abstractmethod -from typing import * -from pygfx import Color, Scene -import numpy as np -class GraphicFeature(ABC): - def __init__(self, parent, data: Any): - self._parent = parent - if isinstance(data, np.ndarray): - data = data.astype(np.float32) - self._data = data - - @property - def data(self): - return self._data - - @abstractmethod - def _set(self, value): - pass - - @abstractmethod - def __repr__(self): - pass - - -class GraphicFeatureIndexable(GraphicFeature): - """And indexable Graphic Feature, colors, data, sizes etc.""" - def _set(self, value): - self[:] = value - - @abstractmethod - def __getitem__(self, item): - pass - - @abstractmethod - def __setitem__(self, key, value): - pass - - @abstractmethod - def _update_range(self, key): - pass - - -def cleanup_slice(slice_obj: slice, upper_bound) -> slice: - start = slice_obj.start - stop = slice_obj.stop - step = slice_obj.step - for attr in [start, stop, step]: - if attr is None: - continue - if attr < 0: - raise IndexError("Negative indexing not supported.") - - if start is None: - start = 0 - - if stop is None: - stop = upper_bound - - elif stop > upper_bound: - raise IndexError("Index out of bounds") - - step = slice_obj.step - if step is None: - step = 1 - - return slice(start, stop, step) - - -class ColorFeature(GraphicFeatureIndexable): - def __init__(self, parent, colors, n_colors, alpha: float = 1.0): - """ - ColorFeature - - Parameters - ---------- - parent: Graphic or GraphicCollection - - colors: str, array, or iterable - specify colors as a single human readable string, RGBA array, - or an iterable of strings or RGBA arrays - - n_colors: number of colors to hold, if passing in a single str or single RGBA array - """ - # if provided as a numpy array of str - if isinstance(colors, np.ndarray): - if colors.dtype.kind in ["U", "S"]: - colors = colors.tolist() - # if the color is provided as a numpy array - if isinstance(colors, np.ndarray): - if colors.shape == (4,): # single RGBA array - data = np.repeat( - np.array([colors]), - n_colors, - axis=0 - ) - # else assume it's already a stack of RGBA arrays, keep this directly as the data - elif colors.ndim == 2: - if colors.shape[1] != 4 and colors.shape[0] != n_colors: - raise ValueError( - "Valid array color arguments must be a single RGBA array or a stack of " - "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" - ) - data = colors - else: - raise ValueError( - "Valid array color arguments must be a single RGBA array or a stack of " - "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" - ) - - # if the color is provided as an iterable - elif isinstance(colors, (list, tuple, np.ndarray)): - # if iterable of str - if all([isinstance(val, str) for val in colors]): - if not len(colors) == n_colors: - raise ValueError( - f"Valid iterable color arguments must be a `tuple` or `list` of `str` " - f"where the length of the iterable is the same as the number of datapoints." - ) - - data = np.vstack([np.array(Color(c)) for c in colors]) - - # if it's a single RGBA array as a tuple/list - elif len(colors) == 4: - c = Color(colors) - data = np.repeat(np.array([c]), n_colors, axis=0) - - else: - raise ValueError( - f"Valid iterable color arguments must be a `tuple` or `list` representing RGBA values or " - f"an iterable of `str` with the same length as the number of datapoints." - ) - else: - # assume it's a single color, use pygfx.Color to parse it - c = Color(colors) - data = np.repeat(np.array([c]), n_colors, axis=0) - - if alpha != 1.0: - data[:, -1] = alpha - - super(ColorFeature, self).__init__(parent, data) - - self._upper_bound = data.shape[0] - - def __setitem__(self, key, value): - # parse numerical slice indices - if isinstance(key, slice): - key = cleanup_slice(key, self._upper_bound) - indices = range(key.start, key.stop, key.step) - - # or single numerical index - elif isinstance(key, int): - if key > self._upper_bound: - raise IndexError("Index out of bounds") - indices = [key] - - elif isinstance(key, tuple): - if not isinstance(value, (float, int, np.ndarray)): - raise ValueError( - "If using multiple-fancy indexing for color, you can only set numerical" - "values since this sets the RGBA array data directly." - ) - - if len(key) != 2: - raise ValueError("fancy indexing for colors must be 2-dimension, i.e. [n_datapoints, RGBA]") - - # set the user passed data directly - self._parent.world_object.geometry.colors.data[key] = value - - # update range - _key = cleanup_slice(key[0], self._upper_bound) - self._update_range(_key) - return - - else: - raise TypeError("Graphic features only support integer and numerical fancy indexing") - - new_data_size = len(indices) - - if not isinstance(value, np.ndarray): - color = np.array(Color(value)) # pygfx color parser - # make it of shape [n_colors_modify, 4] - new_colors = np.repeat( - np.array([color]).astype(np.float32), - new_data_size, - axis=0 - ) - - # if already a numpy array - elif isinstance(value, np.ndarray): - # if a single color provided as numpy array - if value.shape == (4,): - new_colors = value.astype(np.float32) - # if there are more than 1 datapoint color to modify - if new_data_size > 1: - new_colors = np.repeat( - np.array([new_colors]).astype(np.float32), - new_data_size, - axis=0 - ) - - elif value.ndim == 2: - if value.shape[1] != 4 and value.shape[0] != new_data_size: - raise ValueError("numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)") - # if there is a single datapoint to change color of but user has provided shape [1, 4] - if new_data_size == 1: - new_colors = value.ravel().astype(np.float32) - else: - new_colors = value.astype(np.float32) - - else: - raise ValueError("numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)") - - self._parent.world_object.geometry.colors.data[key] = new_colors - - self._update_range(key) - - def _update_range(self, key): - if isinstance(key, int): - self._parent.world_object.geometry.colors.update_range(key, size=1) - if key.step is None: - # update range according to size using the offset - self._parent.world_object.geometry.colors.update_range(offset=key.start, size=key.stop - key.start) - - else: - step = key.step - ixs = range(key.start, key.stop, step) - # convert slice to indices - for ix in ixs: - self._parent.world_object.geometry.colors.update_range(ix, size=1) - - def __getitem__(self, item): - return self._parent.world_object.geometry.colors.data[item] - - def __repr__(self): - return repr(self._parent.world_object.geometry.colors.data) - - -class PresentFeature(GraphicFeature): - """ - Toggles if the object is present in the scene, different from visible \n - Useful for computing bounding boxes from the Scene to only include graphics - that are present - """ - def __init__(self, parent, present: bool = True): - self._scene = None - super(PresentFeature, self).__init__(parent, present) - - def _set(self, present: bool): - i = 0 - while not isinstance(self._scene, Scene): - self._scene = self._parent.world_object.parent - i += 1 - - if i > 100: - raise RecursionError( - "Exceded scene graph depth threshold, cannot find Scene associated with" - "this graphic." - ) - - if present: - if self._parent.world_object not in self._scene.children: - self._scene.add(self._parent.world_object) - - else: - if self._parent.world_object in self._scene.children: - self._scene.remove(self._parent.world_object) - - def __repr__(self): - return repr(self.data) diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py new file mode 100644 index 000000000..2c489c94f --- /dev/null +++ b/fastplotlib/graphics/features/__init__.py @@ -0,0 +1,4 @@ +from ._colors import ColorFeature +from ._data import DataFeature +from ._present import PresentFeature +from ._base import GraphicFeature diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py new file mode 100644 index 000000000..ec0fbe0bd --- /dev/null +++ b/fastplotlib/graphics/features/_base.py @@ -0,0 +1,118 @@ +from abc import ABC, abstractmethod +from typing import * + +import numpy as np +from pygfx import Buffer + + +class GraphicFeature(ABC): + def __init__(self, parent, data: Any): + self._parent = parent + if isinstance(data, np.ndarray): + data = data.astype(np.float32) + + self._data = data + + @property + def feature_data(self): + """graphic feature data managed by fastplotlib, do not modify directly""" + return self._data + + @abstractmethod + def _set(self, value): + pass + + @abstractmethod + def __repr__(self): + pass + + +def cleanup_slice(slice_obj: slice, upper_bound) -> slice: + if isinstance(slice_obj, tuple): + if isinstance(slice_obj[0], slice): + slice_obj = slice_obj[0] + else: + raise TypeError("Tuple slicing must have slice object in first position") + + if not isinstance(slice_obj, slice): + raise TypeError("Must pass slice object") + + start = slice_obj.start + stop = slice_obj.stop + step = slice_obj.step + for attr in [start, stop, step]: + if attr is None: + continue + if attr < 0: + raise IndexError("Negative indexing not supported.") + + if start is None: + start = 0 + + if stop is None: + stop = upper_bound + + elif stop > upper_bound: + raise IndexError("Index out of bounds") + + step = slice_obj.step + if step is None: + step = 1 + + return slice(start, stop, step) + + +class GraphicFeatureIndexable(GraphicFeature): + """And indexable Graphic Feature, colors, data, sizes etc.""" + + def _set(self, value): + self[:] = value + + @abstractmethod + def __getitem__(self, item): + pass + + @abstractmethod + def __setitem__(self, key, value): + pass + + @abstractmethod + def _update_range(self, key): + pass + + @property + @abstractmethod + def _buffer(self) -> Buffer: + pass + + @property + def _upper_bound(self) -> int: + return self.feature_data.shape[0] + + def _update_range_indices(self, key): + """Currently used by colors and data""" + if isinstance(key, int): + self._buffer.update_range(key, size=1) + return + + # else assume it's a slice or tuple of slice + # if tuple of slice we only need the first obj + # since the first obj is the datapoint indices + key = cleanup_slice(key, self._upper_bound) + + # else if it's a single slice + 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) + + 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) + 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 new file mode 100644 index 000000000..981796cdb --- /dev/null +++ b/fastplotlib/graphics/features/_colors.py @@ -0,0 +1,181 @@ +import numpy as np + +from ._base import GraphicFeatureIndexable, cleanup_slice +from pygfx import Color + + +class ColorFeature(GraphicFeatureIndexable): + def __init__(self, parent, colors, n_colors, alpha: float = 1.0): + """ + ColorFeature + + Parameters + ---------- + parent: Graphic or GraphicCollection + + colors: str, array, or iterable + specify colors as a single human readable string, RGBA array, + or an iterable of strings or RGBA arrays + + n_colors: number of colors to hold, if passing in a single str or single RGBA array + """ + # if provided as a numpy array of str + if isinstance(colors, np.ndarray): + if colors.dtype.kind in ["U", "S"]: + colors = colors.tolist() + # if the color is provided as a numpy array + if isinstance(colors, np.ndarray): + if colors.shape == (4,): # single RGBA array + data = np.repeat( + np.array([colors]), + n_colors, + axis=0 + ) + # else assume it's already a stack of RGBA arrays, keep this directly as the data + elif colors.ndim == 2: + if colors.shape[1] != 4 and colors.shape[0] != n_colors: + raise ValueError( + "Valid array color arguments must be a single RGBA array or a stack of " + "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" + ) + data = colors + else: + raise ValueError( + "Valid array color arguments must be a single RGBA array or a stack of " + "RGBA arrays for each datapoint in the shape [n_datapoints, 4]" + ) + + # if the color is provided as an iterable + elif isinstance(colors, (list, tuple, np.ndarray)): + # if iterable of str + if all([isinstance(val, str) for val in colors]): + if not len(colors) == n_colors: + raise ValueError( + f"Valid iterable color arguments must be a `tuple` or `list` of `str` " + f"where the length of the iterable is the same as the number of datapoints." + ) + + data = np.vstack([np.array(Color(c)) for c in colors]) + + # if it's a single RGBA array as a tuple/list + elif len(colors) == 4: + c = Color(colors) + data = np.repeat(np.array([c]), n_colors, axis=0) + + else: + raise ValueError( + f"Valid iterable color arguments must be a `tuple` or `list` representing RGBA values or " + f"an iterable of `str` with the same length as the number of datapoints." + ) + else: + # assume it's a single color, use pygfx.Color to parse it + c = Color(colors) + data = np.repeat(np.array([c]), n_colors, axis=0) + + if alpha != 1.0: + data[:, -1] = alpha + + super(ColorFeature, self).__init__(parent, data) + + @property + def _buffer(self): + return self._parent.world_object.geometry.colors + + def __setitem__(self, key, value): + # parse numerical slice indices + if isinstance(key, slice): + _key = cleanup_slice(key, self._upper_bound) + indices = range(_key.start, _key.stop, _key.step) + + # or single numerical index + elif isinstance(key, int): + if key > self._upper_bound: + raise IndexError("Index out of bounds") + indices = [key] + + elif isinstance(key, tuple): + if not isinstance(value, (float, int, np.ndarray)): + raise ValueError( + "If using multiple-fancy indexing for color, you can only set numerical" + "values since this sets the RGBA array data directly." + ) + + if len(key) != 2: + 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 + + # update range + # first slice obj is going to be the indexing so use key[0] + # key[1] is going to be RGBA so get rid of it to pass to _update_range + # _key = cleanup_slice(key[0], self._upper_bound) + self._update_range(key) + return + + else: + raise TypeError("Graphic features only support integer and numerical fancy indexing") + + new_data_size = len(indices) + + if not isinstance(value, np.ndarray): + color = np.array(Color(value)) # pygfx color parser + # make it of shape [n_colors_modify, 4] + new_colors = np.repeat( + np.array([color]).astype(np.float32), + new_data_size, + axis=0 + ) + + # if already a numpy array + elif isinstance(value, np.ndarray): + # if a single color provided as numpy array + if value.shape == (4,): + new_colors = value.astype(np.float32) + # if there are more than 1 datapoint color to modify + if new_data_size > 1: + new_colors = np.repeat( + np.array([new_colors]).astype(np.float32), + new_data_size, + axis=0 + ) + + elif value.ndim == 2: + if value.shape[1] != 4 and value.shape[0] != new_data_size: + raise ValueError("numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)") + # if there is a single datapoint to change color of but user has provided shape [1, 4] + if new_data_size == 1: + new_colors = value.ravel().astype(np.float32) + else: + new_colors = value.astype(np.float32) + + 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._update_range(key) + + def _update_range(self, key): + self._update_range_indices(key) + # if isinstance(key, int): + # self._buffer.update_range(key, size=1) + # return + # + # # else assume it's a 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) + # + # 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) + + def __getitem__(self, item): + return self._buffer.data[item] + + def __repr__(self): + return repr(self._buffer.data) diff --git a/fastplotlib/graphics/features/_data.py b/fastplotlib/graphics/features/_data.py new file mode 100644 index 000000000..178ba8b39 --- /dev/null +++ b/fastplotlib/graphics/features/_data.py @@ -0,0 +1,55 @@ +from ._base import GraphicFeatureIndexable +from pygfx import Buffer +from typing import * +from ...utils import fix_data, to_float32 + + +class DataFeature(GraphicFeatureIndexable): + """ + Access to the buffer data being shown in the graphic. + Supports fancy indexing if the data array also does. + """ + # the correct data buffer is search for in this order + data_buffer_names = ["grid", "positions"] + + def __init__(self, parent, data: Any, graphic_name): + data = fix_data(data, graphic_name=graphic_name) + self.graphic_name = graphic_name + super(DataFeature, self).__init__(parent, data) + + @property + def _buffer(self) -> Buffer: + buffer = getattr(self._parent.world_object.geometry, self._buffer_name) + return buffer + + @property + def _buffer_name(self) -> str: + for buffer_name in self.data_buffer_names: + if hasattr(self._parent.world_object.geometry, buffer_name): + return buffer_name + + def __getitem__(self, item): + return self._buffer.data[item] + + def __setitem__(self, key, value): + if isinstance(key, (slice, int)): + # data must be provided in the right shape + value = fix_data(value, graphic_name=self.graphic_name) + else: + # otherwise just make sure float32 + value = to_float32(value) + self._buffer.data[key] = value + self._update_range(key) + + def _update_range(self, key): + if self._buffer_name == "grid": + self._update_range_grid(key) + elif self._buffer_name == "positions": + self._update_range_indices(key) + + def _update_range_grid(self, key): + # image data + self._buffer.update_range((0, 0, 0), self._buffer.size) + + def __repr__(self): + return repr(self._buffer.data) diff --git a/fastplotlib/graphics/features/_present.py b/fastplotlib/graphics/features/_present.py new file mode 100644 index 000000000..cf2816bcf --- /dev/null +++ b/fastplotlib/graphics/features/_present.py @@ -0,0 +1,36 @@ +from ._base import GraphicFeature +from pygfx import Scene + + +class PresentFeature(GraphicFeature): + """ + Toggles if the object is present in the scene, different from visible \n + Useful for computing bounding boxes from the Scene to only include graphics + that are present + """ + def __init__(self, parent, present: bool = True): + self._scene = None + super(PresentFeature, self).__init__(parent, present) + + def _set(self, present: bool): + i = 0 + while not isinstance(self._scene, Scene): + self._scene = self._parent.world_object.parent + i += 1 + + if i > 100: + raise RecursionError( + "Exceded scene graph depth threshold, cannot find Scene associated with" + "this graphic." + ) + + if present: + if self._parent.world_object not in self._scene.children: + self._scene.add(self._parent.world_object) + + else: + if self._parent.world_object in self._scene.children: + self._scene.remove(self._parent.world_object) + + def __repr__(self): + return repr(self.feature_data) diff --git a/fastplotlib/graphics/features/_sizes.py b/fastplotlib/graphics/features/_sizes.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastplotlib/graphics/histogram.py b/fastplotlib/graphics/histogram.py index d8c71d9e6..546e481ff 100644 --- a/fastplotlib/graphics/histogram.py +++ b/fastplotlib/graphics/histogram.py @@ -20,7 +20,7 @@ def __init__( data: np.ndarray = None, bins: Union[int, str] = 'auto', pre_computed: Dict[str, np.ndarray] = None, - colors: np.ndarray = None, + colors: np.ndarray = "w", draw_scale_factor: float = 100.0, draw_bin_width_scale: float = 1.0, **kwargs diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index b444ce723..77c531c8a 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -68,7 +68,7 @@ def __init__( vmin, vmax = quick_min_max(data) self._world_object: pygfx.Image = pygfx.Image( - pygfx.Geometry(grid=pygfx.Texture(self.data, dim=2)), + pygfx.Geometry(grid=pygfx.Texture(self.data.feature_data, dim=2)), pygfx.ImageBasicMaterial(clim=(vmin, vmax), map=get_cmap_texture(cmap)) ) @@ -79,11 +79,3 @@ def clim(self) -> Tuple[float, float]: @clim.setter def clim(self, levels: Tuple[float, float]): self.world_object.material.clim = levels - - def update_data(self, data: np.ndarray): - self.world_object.geometry.grid.data[:] = data - self.world_object.geometry.grid.update_range((0, 0, 0), self.world_object.geometry.grid.size) - - def update_cmap(self, cmap: str, alpha: float = 1.0): - self.world_object.material.map = get_cmap_texture(name=cmap) - self.world_object.geometry.grid.update_range((0, 0, 0), self.world_object.geometry.grid.size) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 7f5d53f33..edf99e43c 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -42,41 +42,20 @@ def __init__( kwargs passed to Graphic """ - super(LineGraphic, self).__init__(data, colors=colors, cmap=cmap, *args, **kwargs) - self.fix_data() + super(LineGraphic, self).__init__(data, colors=colors, cmap=cmap, *args, **kwargs) if size < 1.1: material = pygfx.LineThinMaterial else: material = pygfx.LineMaterial - self.data = np.ascontiguousarray(self.data) + # self.data = np.ascontiguousarray(self.data) self._world_object: pygfx.Line = pygfx.Line( - geometry=pygfx.Geometry(positions=self.data, colors=self.colors.data), + # self.data.feature_data because data is a Buffer + geometry=pygfx.Geometry(positions=self.data.feature_data, colors=self.colors.feature_data), material=material(thickness=size, vertex_colors=True) ) self.world_object.position.z = z_position - - def fix_data(self): - # TODO: data should probably be a property of any Graphic?? Or use set_data() and get_data() - if self.data.ndim == 1: - self.data = np.dstack([np.arange(self.data.size), self.data])[0].astype(np.float32) - - if self.data.shape[1] != 3: - if self.data.shape[1] != 2: - raise ValueError("Must pass 1D, 2D or 3D data") - - # zeros for z - zs = np.zeros(self.data.shape[0], dtype=np.float32) - - self.data = np.dstack([self.data[:, 0], self.data[:, 1], zs])[0].astype(np.float32) - - def update_data(self, data: np.ndarray): - self.data = data.astype(np.float32) - self.fix_data() - - self.world_object.geometry.positions.data[:] = self.data - self.world_object.geometry.positions.update_range() diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index c0c686643..0ea5a8831 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -1,4 +1,4 @@ -from typing import List +from typing import * import numpy as np import pygfx @@ -7,37 +7,22 @@ class ScatterGraphic(Graphic): - def __init__(self, data: np.ndarray, z_position: float = 0.0, size: int = 1, colors: np.ndarray = "w", cmap: str = None, *args, **kwargs): + def __init__(self, data: np.ndarray, z_position: float = 0.0, sizes: Union[int, np.ndarray, list] = 1, colors: np.ndarray = "w", cmap: str = None, *args, **kwargs): super(ScatterGraphic, self).__init__(data, colors=colors, cmap=cmap, *args, **kwargs) - self.fix_data() - - sizes = np.full(self.data.shape[0], size, dtype=np.float32) + if isinstance(sizes, int): + sizes = np.full(self.data.feature_data.shape[0], sizes, dtype=np.float32) + elif isinstance(sizes, np.ndarray): + if (sizes.ndim != 1) or (sizes.size != self.data.feature_data.shape[0]): + raise ValueError(f"numpy array of `sizes` must be 1 dimensional with " + f"the same length as the number of datapoints") + elif isinstance(sizes, list): + if len(sizes) != self.data.feature_data.shape[0]: + raise ValueError("list of `sizes` must have the same length as the number of datapoints") self._world_object: pygfx.Points = pygfx.Points( - pygfx.Geometry(positions=self.data, sizes=sizes, colors=self.colors.data), + pygfx.Geometry(positions=self.data.feature_data, sizes=sizes, colors=self.colors.feature_data), material=pygfx.PointsMaterial(vertex_colors=True, vertex_sizes=True) ) self.world_object.position.z = z_position - - def fix_data(self): - # TODO: data should probably be a property of any Graphic?? Or use set_data() and get_data() - if self.data.ndim == 1: - self.data = np.array([self.data]) - - if self.data.shape[1] != 3: - if self.data.shape[1] != 2: - raise ValueError("Must pass 1D, 2D or 3D data") - - # zeros for z - zs = np.zeros(self.data.shape[0], dtype=np.float32) - - self.data = np.dstack([self.data[:, 0], self.data[:, 1], zs])[0].astype(np.float32) - - def update_data(self, data: np.ndarray): - self.data = data - self.fix_data() - - self.world_object.geometry.positions.data[:] = self.data - self.world_object.geometry.positions.update_range(self.data.shape[0]) diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index 698d20113..4df784b6e 100644 --- a/fastplotlib/utils/functions.py +++ b/fastplotlib/utils/functions.py @@ -88,3 +88,36 @@ def quick_min_max(data: np.ndarray) -> Tuple[float, float]: data = data[tuple(sl)] return float(np.nanmin(data)), float(np.nanmax(data)) + + +def to_float32(array): + if isinstance(array, np.ndarray): + return array.astype(np.float32, copy=False) + + return array + + +def fix_data(array, graphic_name: str) -> np.ndarray: + """1d or 2d to 3d, cleanup data passed from user before instantiating any Graphic class""" + if graphic_name == "ImageGraphic": + return to_float32(array) + + if array.ndim == 1: + # for scatter if we receive just 3 points in a 1d array, treat it as just a single datapoint + # this is different from fix_data for LineGraphic since there we assume that a 1d array + # is just y-values + if graphic_name == "ScatterGraphic": + array = np.array([array]) + elif graphic_name == "LineGraphic": + array = np.dstack([np.arange(array.size), array])[0].astype(np.float32) + + if array.shape[1] != 3: + if array.shape[1] != 2: + raise ValueError(f"Must pass 1D, 2D or 3D data to {graphic_name}") + + # zeros for z + zs = np.zeros(array.shape[0], dtype=np.float32) + + array = np.dstack([array[:, 0], array[:, 1], zs])[0] + + return array diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 06c62180a..5fba44d56 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -158,7 +158,7 @@ def current_index(self, index: Dict[str, int]): for i, (ig, data) in enumerate(zip(self.image_graphics, self.data)): frame = self._process_indices(data, self._current_index) frame = self._process_frame_apply(frame, i) - ig.update_data(frame) + ig.data = frame def __init__( self, From 78aeb2e58aa7864c56bead79ba5cfecd0387820f Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 22 Dec 2022 06:13:37 -0500 Subject: [PATCH 10/11] features now trigger events, similar format as pygfx events, tested and works! --- examples/simple.ipynb | 208 ++++++++++++++++++---- fastplotlib/graphics/features/_base.py | 86 +++++++-- fastplotlib/graphics/features/_colors.py | 54 +++--- fastplotlib/graphics/features/_data.py | 30 +++- fastplotlib/graphics/features/_present.py | 17 +- 5 files changed, 314 insertions(+), 81 deletions(-) diff --git a/examples/simple.ipynb b/examples/simple.ipynb index 089ac8ce1..08685bba3 100644 --- a/examples/simple.ipynb +++ b/examples/simple.ipynb @@ -29,7 +29,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "92503c4bae564cf4a27ce8b3d7b1cf4d", + "model_id": "d148f92cf3504beca0c872f062aca491", "version_major": 2, "version_minor": 0 }, @@ -43,7 +43,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -55,7 +55,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "246ede3f6ca448a5943d625cf4295eb1", + "model_id": "c70338248e494f519cb0aaa1540c56f1", "version_major": 2, "version_minor": 0 }, @@ -82,6 +82,82 @@ "plot.show()" ] }, + { + "cell_type": "code", + "execution_count": 3, + "id": "048a96b8-99a8-41b6-89c2-40d87f6bc1ab", + "metadata": {}, + "outputs": [], + "source": [ + "from pygfx import Event" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "4fefe695-d1d8-4207-a267-75fa7b94ea1b", + "metadata": {}, + "outputs": [], + "source": [ + "class CustomEvent(Event):\n", + " def __init__(self, *args, **kwargs):\n", + " super().__init__(\"custom-event\", *args, **kwargs)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "c34c4301-26c5-47cf-b2b7-ab2ab1b3f794", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "wo = plot.get_graphics()[0].world_object\n", + "\n", + "wo.add_event_handler(print, \"custom-event\")" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "be290970-750f-44f9-8fcf-32a54ee1f446", + "metadata": {}, + "outputs": [], + "source": [ + "ce = CustomEvent()" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "c4c26e59-d12f-45a1-bbe3-1c00cd0664ce", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "<__main__.CustomEvent at 0x7f481c126140>" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ce" + ] + }, { "cell_type": "markdown", "id": "1cb03f42-1029-4b16-a16b-35447d9e2955", @@ -315,7 +391,8 @@ "source": [ "from fastplotlib import Plot\n", "from ipywidgets import VBox, HBox\n", - "import numpy as np" + "import numpy as np\n", + "from functools import partial" ] }, { @@ -327,7 +404,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "ae01ee6507cd43ff9ea3b0e5fe2747b9", + "model_id": "54d90d0985984fc2ba82c025e53373fe", "version_major": 2, "version_minor": 0 }, @@ -341,7 +418,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -353,7 +430,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "97a82b5c6fb240fc874d5b05bbef9858", + "model_id": "ea25aaecfada4dd9abad5331d2da0e1f", "version_major": 2, "version_minor": 0 }, @@ -404,48 +481,84 @@ { "cell_type": "code", "execution_count": 3, - "id": "d1a4314b-5723-43c7-94a0-b4cbb0e44d60", + "id": "cb0d13ed-ef07-46ff-b19e-eeca4c831037", "metadata": {}, "outputs": [], "source": [ - "cosine_graphic.data[10:50:5, :2] = sine[10:50:5]\n", - "cosine_graphic.data[90:, 1] = 7" + "# fancy indexing of colors\n", + "cosine_graphic.colors[:5] = \"magenta\"\n", + "cosine_graphic.colors[90:] = \"yellow\"\n", + "cosine_graphic.colors[60] = \"w\"" ] }, { "cell_type": "code", - "execution_count": 18, - "id": "682db47b-8c7a-4934-9be4-2067e9fb12d5", + "execution_count": 4, + "id": "cfa001f6-c640-4f91-beb0-c19b030e503f", "metadata": {}, "outputs": [], "source": [ - "cosine_graphic.data[0] = np.array([[-10, 0, 0]])" + "# event handlers on graphic features\n", + "cosine_graphic.colors.add_event_handler(lambda x: print(x))" ] }, { "cell_type": "code", - "execution_count": 19, - "id": "30d9deb2-5581-4dab-bb00-2e1828216b91", + "execution_count": 5, + "id": "bb8a0f95-0063-4cd4-a117-e3d62c6e120d", "metadata": {}, "outputs": [ { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 19, - "metadata": {}, - "output_type": "execute_result" + "name": "stdout", + "output_type": "stream", + "text": [ + "FeatureEvent @ 0x7f60c49444f0\n", + "type: color-changed\n", + "pick_info: {'index': range(15, 50, 3), '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" + ] } ], "source": [ - "ricker_graphic.present" + "# more complex\n", + "cosine_graphic.colors[15:50:3] = \"cyan\"" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "d1a4314b-5723-43c7-94a0-b4cbb0e44d60", + "metadata": {}, + "outputs": [], + "source": [ + "cosine_graphic.data[10:50:5, :2] = sine[10:50:5]\n", + "cosine_graphic.data[90:, 1] = 7" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "682db47b-8c7a-4934-9be4-2067e9fb12d5", + "metadata": {}, + "outputs": [], + "source": [ + "cosine_graphic.data[0] = np.array([[-10, 0, 0]])" ] }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 8, "id": "fcba75b7-9a1e-4aae-9dec-715f7f7456c3", "metadata": {}, "outputs": [], @@ -455,7 +568,17 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 9, + "id": "763b9943-53a4-4e2a-b47a-4e9e5c9d7be3", + "metadata": {}, + "outputs": [], + "source": [ + "ricker_graphic.present = True" + ] + }, + { + "cell_type": "code", + "execution_count": 10, "id": "0aa2b178-4bb9-4819-a08d-9187ec0e53c0", "metadata": {}, "outputs": [], @@ -474,32 +597,32 @@ }, { "cell_type": "code", - "execution_count": 22, - "id": "e90815b1-fb89-4062-b3a9-b6370dc06f66", + "execution_count": 11, + "id": "64a20a16-75a5-4772-a849-630ade9be4ff", "metadata": {}, "outputs": [], "source": [ - "auto_scale(plot_l)" + "ricker_graphic.present.add_event_handler(partial(auto_scale, plot_l))" ] }, { "cell_type": "code", - "execution_count": 23, - "id": "763b9943-53a4-4e2a-b47a-4e9e5c9d7be3", + "execution_count": 12, + "id": "fb093046-c94c-4085-86b4-8cd85cb638ff", "metadata": {}, "outputs": [], "source": [ - "ricker_graphic.present = True" + "ricker_graphic.present = False" ] }, { "cell_type": "code", - "execution_count": 24, - "id": "4286113b-7c4b-45fc-a870-1e783e4bd7d0", + "execution_count": 13, + "id": "f05981c3-c768-4631-ae62-6a8407b20c4c", "metadata": {}, "outputs": [], "source": [ - "auto_scale(plot_l)" + "ricker_graphic.present = True" ] }, { @@ -512,10 +635,21 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 5, "id": "6ae3e740-1ed1-4df6-bfcd-b64a48f45c8d", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "\n", + "\n", + "\n" + ] + } + ], "source": [ "# set the color of the first 250 datapoints, with a stepsize of 3\n", "cosine_graphic.colors[15:50:3] = \"cyan\"\n", diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index ec0fbe0bd..f5c71aedd 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -1,10 +1,31 @@ from abc import ABC, abstractmethod +from inspect import getfullargspec from typing import * import numpy as np from pygfx import Buffer +class FeatureEvent: + """ + type: -, example: "color-changed" + pick_info: dict in the form: + { + "index": indices where feature data was changed, ``range`` object or List[int], + "world_object": world object the feature belongs to, + "new_values": the new values + } + """ + def __init__(self, type: str, pick_info: dict): + self.type = type + self.pick_info = pick_info + + def __repr__(self): + return f"{self.__class__.__name__} @ {hex(id(self))}\n" \ + f"type: {self.type}\n" \ + f"pick_info: {self.pick_info}\n" + + class GraphicFeature(ABC): def __init__(self, parent, data: Any): self._parent = parent @@ -12,6 +33,7 @@ def __init__(self, parent, data: Any): data = data.astype(np.float32) self._data = data + self._event_handlers = list() @property def feature_data(self): @@ -26,20 +48,55 @@ def _set(self, value): def __repr__(self): pass + def add_event_handler(self, handler: callable): + """ + Add an event handler. All added event handlers are called when this feature changes. + The `handler` can optionally accept ``FeatureEvent`` as the first and only argument. + The ``FeatureEvent`` only has two attributes, `type` which denotes the type of event + as a str in the form of "-changed", such as "color-changed". + + Parameters + ---------- + handler: callable + a function to call when this feature changes + + """ + if not callable(handler): + raise TypeError("event handler must be callable") + self._event_handlers.append(handler) + + #TODO: maybe this can be implemented right here in the base class + @abstractmethod + def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): + """Called whenever a feature changes, and it calls all funcs in self._event_handlers""" + pass + + def _call_event_handlers(self, event_data: FeatureEvent): + for func in self._event_handlers: + if len(getfullargspec(func).args) > 0: + func(event_data) + else: + func() -def cleanup_slice(slice_obj: slice, upper_bound) -> slice: - if isinstance(slice_obj, tuple): - if isinstance(slice_obj[0], slice): - slice_obj = slice_obj[0] + +def cleanup_slice(key: Union[int, slice], upper_bound) -> Union[slice, int]: + if isinstance(key, int): + return key + + if isinstance(key, tuple): + # if tuple of slice we only need the first obj + # since the first obj is the datapoint indices + if isinstance(key[0], slice): + key = key[0] else: raise TypeError("Tuple slicing must have slice object in first position") - if not isinstance(slice_obj, slice): - raise TypeError("Must pass slice object") + if not isinstance(key, slice): + raise TypeError("Must pass slice or int object") - start = slice_obj.start - stop = slice_obj.stop - step = slice_obj.step + start = key.start + stop = key.stop + step = key.step for attr in [start, stop, step]: if attr is None: continue @@ -55,7 +112,7 @@ def cleanup_slice(slice_obj: slice, upper_bound) -> slice: elif stop > upper_bound: raise IndexError("Index out of bounds") - step = slice_obj.step + step = key.step if step is None: step = 1 @@ -91,16 +148,13 @@ def _upper_bound(self) -> int: def _update_range_indices(self, key): """Currently used by colors and data""" + key = cleanup_slice(key, self._upper_bound) + if isinstance(key, int): self._buffer.update_range(key, size=1) return - # else assume it's a slice or tuple of slice - # if tuple of slice we only need the first obj - # since the first obj is the datapoint indices - key = cleanup_slice(key, self._upper_bound) - - # else if it's a single slice + # 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 diff --git a/fastplotlib/graphics/features/_colors.py b/fastplotlib/graphics/features/_colors.py index 981796cdb..f45f99040 100644 --- a/fastplotlib/graphics/features/_colors.py +++ b/fastplotlib/graphics/features/_colors.py @@ -1,10 +1,20 @@ import numpy as np -from ._base import GraphicFeatureIndexable, cleanup_slice +from ._base import GraphicFeatureIndexable, cleanup_slice, FeatureEvent from pygfx import Color class ColorFeature(GraphicFeatureIndexable): + @property + def _buffer(self): + return self._parent.world_object.geometry.colors + + def __getitem__(self, item): + return self._buffer.data[item] + + def __repr__(self): + return repr(self._buffer.data) + def __init__(self, parent, colors, n_colors, alpha: float = 1.0): """ ColorFeature @@ -77,10 +87,6 @@ def __init__(self, parent, colors, n_colors, alpha: float = 1.0): super(ColorFeature, self).__init__(parent, data) - @property - def _buffer(self): - return self._parent.world_object.geometry.colors - def __setitem__(self, key, value): # parse numerical slice indices if isinstance(key, slice): @@ -111,6 +117,7 @@ def __setitem__(self, key, value): # key[1] is going to be RGBA so get rid of it to pass to _update_range # _key = cleanup_slice(key[0], self._upper_bound) self._update_range(key) + self._feature_changed(key, value) return else: @@ -155,27 +162,26 @@ def __setitem__(self, key, value): self._buffer.data[key] = new_colors self._update_range(key) + self._feature_changed(key, new_colors) def _update_range(self, key): self._update_range_indices(key) - # if isinstance(key, int): - # self._buffer.update_range(key, size=1) - # return - # - # # else assume it's a 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) - # - # 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) - def __getitem__(self, item): - return self._buffer.data[item] + def _feature_changed(self, key, new_data): + key = cleanup_slice(key, self._upper_bound) + if isinstance(key, int): + indices = [key] + elif isinstance(key, slice): + indices = range(key.start, key.stop, key.step) + else: + raise TypeError("feature changed key must be slice or int") - def __repr__(self): - return repr(self._buffer.data) + pick_info = { + "index": indices, + "world_object": self._parent.world_object, + "new_data": new_data, + } + + event_data = FeatureEvent(type="color-changed", pick_info=pick_info) + + self._call_event_handlers(event_data) diff --git a/fastplotlib/graphics/features/_data.py b/fastplotlib/graphics/features/_data.py index 178ba8b39..6e1feac2a 100644 --- a/fastplotlib/graphics/features/_data.py +++ b/fastplotlib/graphics/features/_data.py @@ -1,4 +1,4 @@ -from ._base import GraphicFeatureIndexable +from ._base import GraphicFeatureIndexable, cleanup_slice, FeatureEvent from pygfx import Buffer from typing import * from ...utils import fix_data, to_float32 @@ -28,6 +28,9 @@ def _buffer_name(self) -> str: if hasattr(self._parent.world_object.geometry, buffer_name): return buffer_name + def __repr__(self): + return repr(self._buffer.data) + def __getitem__(self, item): return self._buffer.data[item] @@ -44,12 +47,33 @@ def __setitem__(self, key, value): def _update_range(self, key): if self._buffer_name == "grid": self._update_range_grid(key) + self._feature_changed(key=None, new_data=None) elif self._buffer_name == "positions": self._update_range_indices(key) + self._feature_changed(key=key, new_data=None) def _update_range_grid(self, key): # image data self._buffer.update_range((0, 0, 0), self._buffer.size) - def __repr__(self): - return repr(self._buffer.data) + def _feature_changed(self, key, new_data): + # for now if key=None that means all data changed, i.e. ImageGraphic + # also for now new data isn't stored for DataFeature + if key is not None: + key = cleanup_slice(key, self._upper_bound) + if isinstance(key, int): + indices = [key] + elif isinstance(key, slice): + indices = range(key.start, key.stop, key.step) + elif key is None: + indices = None + + pick_info = { + "index": indices, + "world_object": self._parent.world_object, + "new_data": new_data + } + + event_data = FeatureEvent(type="data-changed", pick_info=pick_info) + + self._call_event_handlers(event_data) diff --git a/fastplotlib/graphics/features/_present.py b/fastplotlib/graphics/features/_present.py index cf2816bcf..fd98dc32f 100644 --- a/fastplotlib/graphics/features/_present.py +++ b/fastplotlib/graphics/features/_present.py @@ -1,4 +1,4 @@ -from ._base import GraphicFeature +from ._base import GraphicFeature, FeatureEvent from pygfx import Scene @@ -32,5 +32,20 @@ def _set(self, present: bool): if self._parent.world_object in self._scene.children: self._scene.remove(self._parent.world_object) + self._feature_changed(key=None, new_data=present) + def __repr__(self): return repr(self.feature_data) + + def _feature_changed(self, key, new_data): + # this is a non-indexable feature so key=None + + pick_info = { + "index": None, + "world_object": self._parent.world_object, + "new_data": new_data + } + + event_data = FeatureEvent(type="present-changed", pick_info=pick_info) + + self._call_event_handlers(event_data) \ No newline at end of file From 22f72834da5fd3d66205c54b572676ca51ae18e3 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Thu, 22 Dec 2022 06:28:28 -0500 Subject: [PATCH 11/11] warning if event handlers already registered or has weird argspec --- fastplotlib/graphics/features/_base.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index f5c71aedd..c11f11bf3 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod from inspect import getfullargspec +from warnings import warn from typing import * import numpy as np @@ -63,6 +64,11 @@ def add_event_handler(self, handler: callable): """ if not callable(handler): raise TypeError("event handler must be callable") + + if handler in self._event_handlers: + warn(f"Event handler {handler} is already registered.") + return + self._event_handlers.append(handler) #TODO: maybe this can be implemented right here in the base class @@ -73,7 +79,11 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any): def _call_event_handlers(self, event_data: FeatureEvent): for func in self._event_handlers: - if len(getfullargspec(func).args) > 0: + try: + if len(getfullargspec(func).args) > 0: + func(event_data) + except: + warn(f"Event handler {func} has an unresolvable argspec, trying it anyways.") func(event_data) else: func()