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 ce028366f..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": "f878eb6f385f4045bb2d0e6dc48a585d", + "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": "76163442cbce4eb598f4b44ed08cb12b", + "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,13 @@ " controllers=controllers\n", ")\n", "\n", - "# create a random distribution of 100 xyz coordinates\n", - "dims = (1000, 3)\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", "\n", @@ -122,11 +119,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, 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": "7fe0b6ab-8b15-4884-80f4-4b298a57df9a", + "id": "fb49930f-b795-4b41-bbc6-014a27c2f463", "metadata": {}, "outputs": [], "source": [] diff --git a/examples/simple.ipynb b/examples/simple.ipynb index d82b8493b..08685bba3 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": "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": "3d4c1377e9f345dd9826130942bece5d", + "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", @@ -99,7 +175,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "536b4be224d9476f96ae854889914cc9", + "model_id": "02a93cf7260f4f4e9d500209c75fe1a1", "version_major": 2, "version_minor": 0 }, @@ -113,7 +189,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -125,7 +201,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "b01db232b5c54e4abd50e969d3d19c33", + "model_id": "8cf632b698844607a62f468f3ffbb3f7", "version_major": 2, "version_minor": 0 }, @@ -151,7 +227,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", @@ -177,7 +253,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "ca38f46bfd4c43d1906dde1f9868d5f3", + "model_id": "060eb107d9364a63ae77316c5c806f55", "version_major": 2, "version_minor": 0 }, @@ -191,7 +267,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -203,7 +279,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "caf889a00e3c445ea0ca79bcb97c045f", + "model_id": "a289907ca50641709e1d6452397e68be", "version_major": 2, "version_minor": 0 }, @@ -224,7 +300,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", @@ -248,12 +324,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "ad7aa1a192bd4b4e905e11e7d66f64e8", + "model_id": "b9c22ba4b8f2404bad4d13e413b42e5f", "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': 49, 'timestamp': 1671701574.7427897, 'localtime': 16…" ] }, "metadata": {}, @@ -282,12 +358,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "c71e87e4d7d9489ea7b6eb8ecc52e0e7", + "model_id": "91d89f9b21c949f6a98c774b1b860fd5", "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': 171, 'timestamp': 1671701579.1038444, 'localtime': 1…" ] }, "metadata": {}, @@ -303,19 +379,32 @@ "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`" ] }, { "cell_type": "code", - "execution_count": 7, + "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\n", + "from functools import partial" + ] + }, + { + "cell_type": "code", + "execution_count": 2, "id": "d13f71d3-3003-4e11-82bd-2876013671f7", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "8ce8011d8eba472099bf2aab5a91befe", + "model_id": "54d90d0985984fc2ba82c025e53373fe", "version_major": 2, "version_minor": 0 }, @@ -329,7 +418,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -341,7 +430,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "0dab1bfc2a2640d0a9c24692e2b87099", + "model_id": "ea25aaecfada4dd9abad5331d2da0e1f", "version_major": 2, "version_minor": 0 }, @@ -349,7 +438,7 @@ "JupyterWgpuCanvas()" ] }, - "execution_count": 7, + "execution_count": 2, "metadata": {}, "output_type": "execute_result" } @@ -357,24 +446,249 @@ "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", - "plot_l.add_line(data=data2, size=7, cmap=\"plasma\")\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=5, cmap=\"autumn\")\n", + "\n", + "# or a list of colors for each datapoint\n", + "colors = [\"r\"] * 25 + [\"purple\"] * 25 + [\"y\"] * 25 + [\"b\"] * 25\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": "cb0d13ed-ef07-46ff-b19e-eeca4c831037", + "metadata": {}, + "outputs": [], + "source": [ + "# 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": 4, + "id": "cfa001f6-c640-4f91-beb0-c19b030e503f", + "metadata": {}, + "outputs": [], + "source": [ + "# event handlers on graphic features\n", + "cosine_graphic.colors.add_event_handler(lambda x: print(x))" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "bb8a0f95-0063-4cd4-a117-e3d62c6e120d", + "metadata": {}, + "outputs": [ + { + "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": [ + "# 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": 8, + "id": "fcba75b7-9a1e-4aae-9dec-715f7f7456c3", + "metadata": {}, + "outputs": [], + "source": [ + "ricker_graphic.present = False" + ] + }, + { + "cell_type": "code", + "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": [], + "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": 11, + "id": "64a20a16-75a5-4772-a849-630ade9be4ff", + "metadata": {}, + "outputs": [], + "source": [ + "ricker_graphic.present.add_event_handler(partial(auto_scale, plot_l))" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "fb093046-c94c-4085-86b4-8cd85cb638ff", + "metadata": {}, + "outputs": [], + "source": [ + "ricker_graphic.present = False" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "f05981c3-c768-4631-ae62-6a8407b20c4c", + "metadata": {}, + "outputs": [], + "source": [ + "ricker_graphic.present = True" + ] + }, + { + "cell_type": "markdown", + "id": "071bc152-5594-4679-90c8-002ed12b37cf", + "metadata": {}, + "source": [ + "## `LineGraphic` and `ScatterGraphic` colors support fancy indexing!" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "6ae3e740-1ed1-4df6-bfcd-b64a48f45c8d", + "metadata": {}, + "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", + "\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\"" + ] + }, { "cell_type": "markdown", "id": "2c90862e-2f2a-451f-a468-0cf6b857e87a", @@ -385,14 +699,14 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 29, "id": "9c51229f-13a2-4653-bff3-15d43ddbca7b", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "c69434bb678e4f16b33af1b1a3a2564e", + "model_id": "0767e2dc0868414baca5754fb724107f", "version_major": 2, "version_minor": 0 }, @@ -414,7 +728,7 @@ { "data": { "text/html": [ - "
initial snapshot
" + "
initial snapshot
" ], "text/plain": [ "" @@ -426,7 +740,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "3868b0eda4e7467a842fb5631cfc0e1a", + "model_id": "09995d983d9c4ca7bc31d820a799a219", "version_major": 2, "version_minor": 0 }, @@ -434,7 +748,7 @@ "JupyterWgpuCanvas()" ] }, - "execution_count": 8, + "execution_count": 29, "metadata": {}, "output_type": "execute_result" } @@ -471,23 +785,20 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 30, "id": "f404a5ea-633b-43f5-87d1-237017bbca2a", "metadata": {}, "outputs": [ { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "cf987464d78248589bfca940f59d7c87", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(HBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 46, 'timestamp': 1671240495.5535605, …" - ] - }, - "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 69fb66066..a1a2633b9 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -1,70 +1,106 @@ -from typing import Any +from typing import * -import numpy as np import pygfx -from fastplotlib.utils import get_colors, map_labels_to_colors +from ..utils import get_colors +from .features import GraphicFeature, DataFeature, ColorFeature, PresentFeature 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 ): - self.data = data.astype(np.float32) + """ + + Parameters + ---------- + 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: 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 = DataFeature(parent=self, data=data, graphic_name=self.__class__.__name__) 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.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) if colors is not False: - self._set_colors(colors, colors_length, cmap, alpha, ) + self.colors = ColorFeature(parent=self, colors=colors, n_colors=n_colors, alpha=alpha) - def _set_colors(self, colors, colors_length, cmap, alpha): - if colors_length is None: - colors_length = self.data.shape[0] + # different from visible, toggles the Graphic presence in the Scene + # useful for bbox calculations to ignore these Graphics + self.present = PresentFeature(parent=self) - if colors is None and cmap is None: # just white - self.colors = np.vstack([[1., 1., 1., 1.]] * colors_length).astype(np.float32) + valid_features = ["visible"] + for attr_name in self.__dict__.keys(): + attr = getattr(self, attr_name) + if isinstance(attr, GraphicFeature): + valid_features.append(attr_name) - elif (colors is None) and (cmap is not None): - self.colors = get_colors(n_colors=colors_length, cmap=cmap, alpha=alpha) + self._valid_features = tuple(valid_features) - 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]") + @property + def world_object(self) -> pygfx.WorldObject: + return self._world_object - 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) + @property + def interact_features(self) -> Tuple[str]: + """The features for this ``Graphic`` that support interaction.""" + return self._valid_features - else: - raise ValueError("Unknown color format") + @property + def visible(self) -> bool: + return self.world_object.visible + + @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) + 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))}" else: return f"fastplotlib.{self.__class__.__name__} @ {hex(id(self))}" - diff --git a/fastplotlib/graphics/_graphic_attribute.py b/fastplotlib/graphics/_graphic_attribute.py new file mode 100644 index 000000000..b28b04f64 --- /dev/null +++ b/fastplotlib/graphics/_graphic_attribute.py @@ -0,0 +1,3 @@ + + + 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..c11f11bf3 --- /dev/null +++ b/fastplotlib/graphics/features/_base.py @@ -0,0 +1,182 @@ +from abc import ABC, abstractmethod +from inspect import getfullargspec +from warnings import warn +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 + if isinstance(data, np.ndarray): + data = data.astype(np.float32) + + self._data = data + self._event_handlers = list() + + @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 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") + + 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 + @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: + 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() + + +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(key, slice): + raise TypeError("Must pass slice or int object") + + 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 = upper_bound + + elif stop > upper_bound: + raise IndexError("Index out of bounds") + + step = key.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""" + key = cleanup_slice(key, self._upper_bound) + + if isinstance(key, int): + self._buffer.update_range(key, size=1) + return + + # else if it's a slice obj + if isinstance(key, slice): + if key.step == 1: # we cleaned up the slice obj so step of None becomes 1 + # update range according to size using the offset + self._buffer.update_range(offset=key.start, size=key.stop - key.start) + + 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..f45f99040 --- /dev/null +++ b/fastplotlib/graphics/features/_colors.py @@ -0,0 +1,187 @@ +import numpy as np + +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 + + 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) + + 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) + self._feature_changed(key, value) + 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) + self._feature_changed(key, new_colors) + + def _update_range(self, key): + self._update_range_indices(key) + + 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") + + 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 new file mode 100644 index 000000000..6e1feac2a --- /dev/null +++ b/fastplotlib/graphics/features/_data.py @@ -0,0 +1,79 @@ +from ._base import GraphicFeatureIndexable, cleanup_slice, FeatureEvent +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 __repr__(self): + return repr(self._buffer.data) + + 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) + 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 _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 new file mode 100644 index 000000000..fd98dc32f --- /dev/null +++ b/fastplotlib/graphics/features/_present.py @@ -0,0 +1,51 @@ +from ._base import GraphicFeature, FeatureEvent +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) + + 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 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 2c846a223..546e481ff 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 @@ -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 @@ -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..77c531c8a 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -67,8 +67,8 @@ def __init__( if (vmin is None) or (vmax is None): vmin, vmax = quick_min_max(data) - self.world_object: pygfx.Image = pygfx.Image( - pygfx.Geometry(grid=pygfx.Texture(self.data, dim=2)), + self._world_object: pygfx.Image = pygfx.Image( + 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 c2d09bfb4..edf99e43c 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 @@ -40,47 +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), + self._world_object: pygfx.Line = pygfx.Line( + # 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] - - 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] - - 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() - - 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..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,49 +7,22 @@ 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, 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) - 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.world_object: pygfx.Group = pygfx.Group() - self.points_objects: List[pygfx.Points] = list() - - for color in np.unique(self.colors, axis=0): - positions = self._process_positions( - self.data[np.all(self.colors == color, axis=1)] - ) - - points = pygfx.Points( - pygfx.Geometry(positions=positions), - pygfx.PointsMaterial(size=size, color=color) - ) - - self.world_object.add(points) - self.points_objects.append(points) + 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.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 _process_positions(self, positions: np.ndarray): - if positions.ndim == 1: - positions = np.array([positions]) - - return positions - - def update_data(self, data: np.ndarray): - positions = self._process_positions(data).astype(np.float32) - - self.points_objects[0].geometry.positions.data[:] = positions - self.points_objects[0].geometry.positions.update_range(positions.shape[0]) diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py index 650cfb053..4df784b6e 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) @@ -94,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,