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() pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy