diff --git a/examples/garbage_collection.ipynb b/examples/garbage_collection.ipynb
new file mode 100644
index 000000000..85744e6e0
--- /dev/null
+++ b/examples/garbage_collection.ipynb
@@ -0,0 +1,419 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1ef0578e-09e1-45ff-bd34-84472db3885e",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "from fastplotlib import Plot\n",
+ "import numpy as np\n",
+ "import sys\n",
+ "\n",
+ "import weakref\n",
+ "import gc\n",
+ "import os, psutil\n",
+ "process = psutil.Process(os.getpid())"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1bb6bc6f-7786-4d23-9eb1-e30bbc66c798",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "def print_process_ram_mb():\n",
+ " print(process.memory_info().rss / 1024 / 1024)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b376676e-a7fe-4424-9ba6-fde5be03b649",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "print_process_ram_mb()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b23ba640-88ec-40d9-b53c-c8cbb3e39b0b",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "plot = Plot()\n",
+ "plot.show()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "17f46f21-b29d-4dd3-9496-989bbb240f50",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "print_process_ram_mb()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "27627cd4-c363-4eab-a121-f6c8abbbe5ae",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "graphic = \"scatter\""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d9c10edc-169a-4dd2-bd5b-8a1b67baf3a9",
+ "metadata": {},
+ "source": [
+ "### Run the following cells repeatedly to add and remove the graphic"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "e26d392f-6afd-4e89-a685-d618065d3caf",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "if graphic == \"line\":\n",
+ " a = np.random.rand(10_000_000)\n",
+ " g = plot.add_line(a)\n",
+ " \n",
+ "elif graphic == \"heatmap\":\n",
+ " a = np.random.rand(20_000, 20_000)\n",
+ " g = plot.add_heatmap(a)\n",
+ "\n",
+ "elif graphic == \"line_collection\":\n",
+ " a = np.random.rand(500, 50_000)\n",
+ " g = plot.add_line_collection(a)\n",
+ " \n",
+ "elif graphic == \"image\":\n",
+ " a = np.random.rand(7_000, 7_000)\n",
+ " g = plot.add_image(a)\n",
+ "\n",
+ "elif graphic == \"scatter\":\n",
+ " a = np.random.rand(10_000_000, 3)\n",
+ " g = plot.add_scatter(a)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "31e3027a-56cf-4f7b-ba78-aed4f78eef47",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "print_process_ram_mb()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6518795c-98cf-405d-94ab-786ac3b2e1d6",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "g"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "ee2481c9-82e3-4043-85fd-21a0cdf21187",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "plot.auto_scale()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "53170858-ae72-4451-8647-7d5b1f9da75e",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "print(process.memory_info().rss / 1024 / 1024)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "e4e0f73b-c58a-40e7-acf5-07a1f70d2821",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "plot.delete_graphic(plot.graphics[0])"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "47baa487-c66b-4c40-aa11-d819902870e3",
+ "metadata": {},
+ "source": [
+ "If there is no serious system memory leak, this value shouldn't really increase after repeated cycles"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "56c64498-229e-48b7-9fb1-f7c327fff2ae",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "print(process.memory_info().rss / 1024 / 1024)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "dd6a26c1-ea81-469d-ae7a-95839b1f9d5a",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import numpy as np\n",
+ "from wgpu.gui.auto import WgpuCanvas, run\n",
+ "import pygfx as gfx\n",
+ "import subprocess\n",
+ "\n",
+ "canvas = WgpuCanvas()\n",
+ "renderer = gfx.WgpuRenderer(canvas)\n",
+ "scene = gfx.Scene()\n",
+ "camera = gfx.OrthographicCamera(5000, 5000)\n",
+ "camera.position.x = 2048\n",
+ "camera.position.y = 2048\n",
+ "\n",
+ "\n",
+ "def make_image():\n",
+ " data = np.random.rand(4096, 4096).astype(np.float32)\n",
+ "\n",
+ " return gfx.Image(\n",
+ " gfx.Geometry(grid=gfx.Texture(data, dim=2)),\n",
+ " gfx.ImageBasicMaterial(clim=(0, 1)),\n",
+ " )\n",
+ "\n",
+ "\n",
+ "class Graphic:\n",
+ " def __init__(self):\n",
+ " data = np.random.rand(4096, 4096).astype(np.float32)\n",
+ " self.wo = gfx.Image(\n",
+ " gfx.Geometry(grid=gfx.Texture(data, dim=2)),\n",
+ " gfx.ImageBasicMaterial(clim=(0, 1)),\n",
+ " )\n",
+ "\n",
+ "\n",
+ "def draw():\n",
+ " renderer.render(scene, camera)\n",
+ " canvas.request_draw()\n",
+ "\n",
+ "\n",
+ "def print_nvidia(msg):\n",
+ " print(msg)\n",
+ " print(\n",
+ " subprocess.check_output([\"nvidia-smi\", \"--format=csv\", \"--query-gpu=memory.used\"]).decode().split(\"\\n\")[1]\n",
+ " )\n",
+ " print()\n",
+ "\n",
+ "\n",
+ "def add_img(*args):\n",
+ " print_nvidia(\"Before creating image\")\n",
+ " img = make_image()\n",
+ " print_nvidia(\"After creating image\")\n",
+ " scene.add(img)\n",
+ " img.add_event_handler(remove_img, \"click\")\n",
+ " draw()\n",
+ " print_nvidia(\"After add image to scene\")\n",
+ "\n",
+ "\n",
+ "def remove_img(*args):\n",
+ " img = scene.children[0]\n",
+ " scene.remove(img)\n",
+ " draw()\n",
+ " print_nvidia(\"After remove image from scene\")\n",
+ " del img\n",
+ " draw()\n",
+ " print_nvidia(\"After del image\")\n",
+ "\n",
+ "\n",
+ "renderer.add_event_handler(add_img, \"double_click\")\n",
+ "canvas"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2599f430-8b00-4490-9e11-774897be6e77",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import numpy as np\n",
+ "from wgpu.gui.auto import WgpuCanvas, run\n",
+ "import pygfx as gfx\n",
+ "import subprocess\n",
+ "\n",
+ "canvas = WgpuCanvas()\n",
+ "renderer = gfx.WgpuRenderer(canvas)\n",
+ "scene = gfx.Scene()\n",
+ "camera = gfx.OrthographicCamera(5000, 5000)\n",
+ "camera.position.x = 2048\n",
+ "camera.position.y = 2048\n",
+ "\n",
+ "\n",
+ "def make_image():\n",
+ " data = np.random.rand(4096, 4096).astype(np.float32)\n",
+ "\n",
+ " return gfx.Image(\n",
+ " gfx.Geometry(grid=gfx.Texture(data, dim=2)),\n",
+ " gfx.ImageBasicMaterial(clim=(0, 1)),\n",
+ " )"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "3ec10f26-6544-4ad3-80c1-aa34617dc826",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import weakref"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "acc819a3-cd50-4fdd-a0b5-c442d80847e2",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "img = make_image()\n",
+ "img_ref = weakref.ref(img)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "f89da335-3372-486b-b773-9f103d6a9bbd",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "img_ref()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c22904ad-d674-43e6-83bb-7a2f7b277c06",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "del img"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "573566d7-eb91-4690-958c-d00dd495b3e4",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import gc"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "aaef3e89-2bfd-43af-9b8f-824a3f89b85f",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "img_ref()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "3380f35e-fcc9-43f6-80d2-7e9348cd13b4",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "draw()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "a27bf7c7-f3ef-4ae8-8ecf-31507f8c0449",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "print(\n",
+ " subprocess.check_output([\"nvidia-smi\", \"--format=csv\", \"--query-gpu=memory.used\"]).decode().split(\"\\n\")[1]\n",
+ " )"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "e4bf2711-8a83-4d9c-a4f7-f50de7ae1715",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.10.5"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/simple.ipynb b/examples/simple.ipynb
index 2d4961ac3..9ca764283 100644
--- a/examples/simple.ipynb
+++ b/examples/simple.ipynb
@@ -14,7 +14,7 @@
},
{
"cell_type": "code",
- "execution_count": 1,
+ "execution_count": null,
"id": "fb57c3d3-f20d-4d88-9e7a-04b9309bc637",
"metadata": {},
"outputs": [],
@@ -34,52 +34,10 @@
},
{
"cell_type": "code",
- "execution_count": 2,
+ "execution_count": null,
"id": "237823b7-e2c0-4e2f-9ee8-e3fc2b4453c4",
"metadata": {},
- "outputs": [
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "22e188ba0769451ba369b4a5a2d5d313",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "RFBOutputContext()"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "text/html": [
- "

initial snapshot
"
- ],
- "text/plain": [
- ""
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "997ba4e4d8b540ffa3f100c2fde27920",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "JupyterWgpuCanvas()"
- ]
- },
- "execution_count": 2,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"# create a `Plot` instance\n",
"plot = Plot()\n",
@@ -112,7 +70,7 @@
},
{
"cell_type": "code",
- "execution_count": 3,
+ "execution_count": null,
"id": "de816c88-1c4a-4071-8a5e-c46c93671ef5",
"metadata": {},
"outputs": [],
@@ -122,7 +80,7 @@
},
{
"cell_type": "code",
- "execution_count": 4,
+ "execution_count": null,
"id": "09350854-5058-4574-a01d-84d00e276c57",
"metadata": {},
"outputs": [],
@@ -132,7 +90,7 @@
},
{
"cell_type": "code",
- "execution_count": 5,
+ "execution_count": null,
"id": "83b2db1b-2783-4e89-bcf3-66bb6e09e18a",
"metadata": {},
"outputs": [],
@@ -143,7 +101,7 @@
},
{
"cell_type": "code",
- "execution_count": 6,
+ "execution_count": null,
"id": "3e298c1c-7551-4401-ade0-b9af7d2bbe23",
"metadata": {},
"outputs": [],
@@ -161,42 +119,20 @@
},
{
"cell_type": "code",
- "execution_count": 7,
+ "execution_count": null,
"id": "e6ba689c-ff4a-44ef-9663-f2c8755072c4",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "('random-image': ImageGraphic @ 0x7fbb681a1360,)"
- ]
- },
- "execution_count": 7,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"plot.graphics"
]
},
{
"cell_type": "code",
- "execution_count": 8,
+ "execution_count": null,
"id": "5b18f4e3-e13b-46d5-af1f-285c5a7fdc12",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'random-image': ImageGraphic @ 0x7fbb681a1360"
- ]
- },
- "execution_count": 8,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"plot[\"random-image\"]"
]
@@ -211,42 +147,20 @@
},
{
"cell_type": "code",
- "execution_count": 9,
+ "execution_count": null,
"id": "2b5c1321-1fd4-44bc-9433-7439ad3e22cf",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'random-image': ImageGraphic @ 0x7fbb681a1360"
- ]
- },
- "execution_count": 9,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"image_graphic"
]
},
{
"cell_type": "code",
- "execution_count": 10,
+ "execution_count": null,
"id": "b12bf75e-4e93-4930-9146-e96324fdf3f6",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "True"
- ]
- },
- "execution_count": 10,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"image_graphic is plot[\"random-image\"]"
]
@@ -265,52 +179,10 @@
},
{
"cell_type": "code",
- "execution_count": 11,
+ "execution_count": null,
"id": "aadd757f-6379-4f52-a709-46aa57c56216",
"metadata": {},
- "outputs": [
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "543d56b0c4fa4eb18927358bd7d0d2fa",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "RFBOutputContext()"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "text/html": [
- "
initial snapshot
"
- ],
- "text/plain": [
- ""
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "ca2343e7c6294dddb4d71253c5dd0050",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "JupyterWgpuCanvas()"
- ]
- },
- "execution_count": 11,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"# create another `Plot` instance\n",
"plot_v = Plot()\n",
@@ -348,52 +220,10 @@
},
{
"cell_type": "code",
- "execution_count": 12,
+ "execution_count": null,
"id": "86e70b1e-4328-4035-b992-70dff16d2a69",
"metadata": {},
- "outputs": [
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "2fca1a70597f4f2c813245b472557980",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "RFBOutputContext()"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "text/html": [
- "
initial snapshot
"
- ],
- "text/plain": [
- ""
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "655dee7f343d4ad1b9d1dca8bf499fa2",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "JupyterWgpuCanvas()"
- ]
- },
- "execution_count": 12,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"plot_sync = Plot(controller=plot_v.controller)\n",
"\n",
@@ -430,25 +260,10 @@
},
{
"cell_type": "code",
- "execution_count": 13,
+ "execution_count": null,
"id": "ef9743b3-5f81-4b79-9502-fa5fca08e56d",
"metadata": {},
- "outputs": [
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "98e198424c434dfcaf932bdf67e8c1b2",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "VBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 211, 'timestamp': 1673222194.8195093, 'localtime': 1…"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- }
- ],
+ "outputs": [],
"source": [
"VBox([plot_v.show(), plot_sync.show()])"
]
@@ -464,25 +279,10 @@
},
{
"cell_type": "code",
- "execution_count": 14,
+ "execution_count": null,
"id": "11839d95-8ff7-444c-ae13-6b072c3112c5",
"metadata": {},
- "outputs": [
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "f97d3b3fd53040388be0f2527a832b41",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "HBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 236, 'timestamp': 1673222197.978218, 'localtime': 16…"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- }
- ],
+ "outputs": [],
"source": [
"HBox([plot_v.show(), plot_sync.show()])"
]
@@ -507,7 +307,7 @@
},
{
"cell_type": "code",
- "execution_count": 15,
+ "execution_count": null,
"id": "0bcedf83-cbdd-4ec2-b8d5-172aa72a3e04",
"metadata": {},
"outputs": [],
@@ -582,21 +382,10 @@
},
{
"cell_type": "code",
- "execution_count": 16,
+ "execution_count": null,
"id": "8b560151-c258-415c-a20d-3cccd421f44a",
"metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "(1000, 512, 512)"
- ]
- },
- "execution_count": 16,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"movie.shape"
]
@@ -619,39 +408,10 @@
},
{
"cell_type": "code",
- "execution_count": 17,
+ "execution_count": null,
"id": "62166a9f-ab43-45cc-a6db-6d441387e9a5",
"metadata": {},
- "outputs": [
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "716bb151456446c283aa842c008fc805",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "RFBOutputContext()"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "6ef37682df3f409b8d2a7ac36974d7ce",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "VBox(children=(JupyterWgpuCanvas(), IntSlider(value=0, max=999)))"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- }
- ],
+ "outputs": [],
"source": [
"plot_movie = Plot()\n",
"\n",
@@ -710,7 +470,7 @@
},
{
"cell_type": "code",
- "execution_count": 18,
+ "execution_count": null,
"id": "8e8280da-b421-43a5-a1a6-2a196a408e9a",
"metadata": {},
"outputs": [],
@@ -741,52 +501,10 @@
},
{
"cell_type": "code",
- "execution_count": 19,
+ "execution_count": null,
"id": "93a5d1e6-d019-4dd0-a0d1-25d1704ab7a7",
"metadata": {},
- "outputs": [
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "5a78ff5b74a64ba28afcd7844a5bd1de",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "RFBOutputContext()"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "text/html": [
- "
initial snapshot
"
- ],
- "text/plain": [
- ""
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "59f97de592804347a60996be35bcab2c",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "JupyterWgpuCanvas()"
- ]
- },
- "execution_count": 19,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"# Create a plot instance\n",
"plot_l = Plot()\n",
@@ -804,6 +522,48 @@
"plot_l.show()"
]
},
+ {
+ "cell_type": "markdown",
+ "id": "22dde600-0f56-4370-b017-c8f23a6c01aa",
+ "metadata": {},
+ "source": [
+ "### \"stretching\" the camera, useful for large timeseries data\n",
+ "\n",
+ "Set `maintain_aspect = False` on a camera, and then use the right mouse button and move the mouse to stretch and squeeze the view!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2695f023-f6ce-4e26-8f96-4fbed5510d1d",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "plot_l.camera.maintain_aspect = False"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "1651e965-f750-47ac-bf53-c23dae84cc98",
+ "metadata": {},
+ "source": [
+ "### reset the plot area"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "ba50a6ed-0f1b-4795-91dd-a7c3e40b8e3c",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "plot_l.auto_scale(maintain_aspect=True)"
+ ]
+ },
{
"cell_type": "markdown",
"id": "dcd68796-c190-4c3f-8519-d73b98ff6367",
@@ -814,7 +574,7 @@
},
{
"cell_type": "code",
- "execution_count": 20,
+ "execution_count": null,
"id": "cb0d13ed-ef07-46ff-b19e-eeca4c831037",
"metadata": {},
"outputs": [],
@@ -842,7 +602,7 @@
},
{
"cell_type": "code",
- "execution_count": 21,
+ "execution_count": null,
"id": "cfa001f6-c640-4f91-beb0-c19b030e503f",
"metadata": {},
"outputs": [],
@@ -856,32 +616,10 @@
},
{
"cell_type": "code",
- "execution_count": 22,
+ "execution_count": null,
"id": "bb8a0f95-0063-4cd4-a117-e3d62c6e120d",
"metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "FeatureEvent @ 0x7fbacc955060\n",
- "type: colors\n",
- "pick_info: {'index': range(15, 50, 3), 'collection-index': None, 'world_object': , 'new_data': array([[0., 1., 1., 1.],\n",
- " [0., 1., 1., 1.],\n",
- " [0., 1., 1., 1.],\n",
- " [0., 1., 1., 1.],\n",
- " [0., 1., 1., 1.],\n",
- " [0., 1., 1., 1.],\n",
- " [0., 1., 1., 1.],\n",
- " [0., 1., 1., 1.],\n",
- " [0., 1., 1., 1.],\n",
- " [0., 1., 1., 1.],\n",
- " [0., 1., 1., 1.],\n",
- " [0., 1., 1., 1.]], dtype=float32)}\n",
- "\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
"# more complex indexing of colors\n",
"# from point 15 - 30, set every 3rd point as \"cyan\"\n",
@@ -898,7 +636,7 @@
},
{
"cell_type": "code",
- "execution_count": 23,
+ "execution_count": null,
"id": "d1a4314b-5723-43c7-94a0-b4cbb0e44d60",
"metadata": {},
"outputs": [],
@@ -909,7 +647,7 @@
},
{
"cell_type": "code",
- "execution_count": 24,
+ "execution_count": null,
"id": "682db47b-8c7a-4934-9be4-2067e9fb12d5",
"metadata": {},
"outputs": [],
@@ -927,7 +665,7 @@
},
{
"cell_type": "code",
- "execution_count": 25,
+ "execution_count": null,
"id": "fcba75b7-9a1e-4aae-9dec-715f7f7456c3",
"metadata": {},
"outputs": [],
@@ -937,7 +675,7 @@
},
{
"cell_type": "code",
- "execution_count": 26,
+ "execution_count": null,
"id": "763b9943-53a4-4e2a-b47a-4e9e5c9d7be3",
"metadata": {},
"outputs": [],
@@ -955,7 +693,7 @@
},
{
"cell_type": "code",
- "execution_count": 27,
+ "execution_count": null,
"id": "64a20a16-75a5-4772-a849-630ade9be4ff",
"metadata": {},
"outputs": [],
@@ -965,7 +703,7 @@
},
{
"cell_type": "code",
- "execution_count": 28,
+ "execution_count": null,
"id": "fb093046-c94c-4085-86b4-8cd85cb638ff",
"metadata": {},
"outputs": [],
@@ -975,7 +713,7 @@
},
{
"cell_type": "code",
- "execution_count": 29,
+ "execution_count": null,
"id": "f05981c3-c768-4631-ae62-6a8407b20c4c",
"metadata": {},
"outputs": [],
@@ -993,60 +731,10 @@
},
{
"cell_type": "code",
- "execution_count": 30,
+ "execution_count": null,
"id": "9c51229f-13a2-4653-bff3-15d43ddbca7b",
"metadata": {},
- "outputs": [
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "4beed0a67834408aa4549374af4b36d8",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "RFBOutputContext()"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "name": "stderr",
- "output_type": "stream",
- "text": [
- "/home/kushalk/repos/fastplotlib/fastplotlib/layouts/_base.py:214: UserWarning: `center_scene()` not yet implemented for `PerspectiveCamera`\n",
- " warn(\"`center_scene()` not yet implemented for `PerspectiveCamera`\")\n"
- ]
- },
- {
- "data": {
- "text/html": [
- "
initial snapshot
"
- ],
- "text/plain": [
- ""
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "52aa9698dbe2430099c3d88159bcb47b",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "JupyterWgpuCanvas()"
- ]
- },
- "execution_count": 30,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"# just set the camera as \"3d\", the rest is basically the same :D \n",
"plot_l3d = Plot(camera='3d')\n",
@@ -1067,6 +755,18 @@
"plot_l3d.show()"
]
},
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "28eb7014-4773-4a34-8bfc-bd3a46429012",
+ "metadata": {
+ "tags": []
+ },
+ "outputs": [],
+ "source": [
+ "plot_l3d.auto_scale(maintain_aspect=True)"
+ ]
+ },
{
"cell_type": "markdown",
"id": "a202b3d0-2a0b-450a-93d4-76d0a1129d1d",
@@ -1081,7 +781,7 @@
},
{
"cell_type": "code",
- "execution_count": 31,
+ "execution_count": null,
"id": "2ecb2385-8fa4-4239-881c-b754c24aed9f",
"metadata": {},
"outputs": [],
@@ -1093,52 +793,10 @@
},
{
"cell_type": "code",
- "execution_count": 32,
+ "execution_count": null,
"id": "39252df5-9ae5-4132-b97b-2785c5fa92ea",
"metadata": {},
- "outputs": [
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "58954cd128264eb38a4afc87f940194e",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "RFBOutputContext()"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "text/html": [
- "
initial snapshot
"
- ],
- "text/plain": [
- ""
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "3eac2f2a71334813a958c144d22089d4",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "JupyterWgpuCanvas()"
- ]
- },
- "execution_count": 32,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
+ "outputs": [],
"source": [
"# create a random distribution of 10,000 xyz coordinates\n",
"n_points = 10_000\n",
@@ -1185,7 +843,7 @@
},
{
"cell_type": "code",
- "execution_count": 35,
+ "execution_count": null,
"id": "8fa46ec0-8680-44f5-894c-559de3145932",
"metadata": {},
"outputs": [],
@@ -1196,7 +854,7 @@
},
{
"cell_type": "code",
- "execution_count": 36,
+ "execution_count": null,
"id": "e4dc71e4-5144-436f-a464-f2a29eee8f0b",
"metadata": {},
"outputs": [],
@@ -1207,7 +865,7 @@
},
{
"cell_type": "code",
- "execution_count": 37,
+ "execution_count": null,
"id": "5b637a29-cd5e-4011-ab81-3f91490d9ecd",
"metadata": {},
"outputs": [],
@@ -1218,7 +876,7 @@
},
{
"cell_type": "code",
- "execution_count": 38,
+ "execution_count": null,
"id": "a4084fce-78a2-48b3-9a0d-7b57c165c3c1",
"metadata": {},
"outputs": [],
@@ -1229,7 +887,7 @@
},
{
"cell_type": "code",
- "execution_count": 39,
+ "execution_count": null,
"id": "f486083e-7c58-4255-ae1a-3fe5d9bfaeed",
"metadata": {},
"outputs": [],
@@ -1250,25 +908,10 @@
},
{
"cell_type": "code",
- "execution_count": 40,
+ "execution_count": null,
"id": "f404a5ea-633b-43f5-87d1-237017bbca2a",
"metadata": {},
- "outputs": [
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "36ce2e79024a4906aef9530ea238631d",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "VBox(children=(HBox(children=(JupyterWgpuCanvas(frame_feedback={'index': 812, 'timestamp': 1673222184.132576, …"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- }
- ],
+ "outputs": [],
"source": [
"row1 = HBox([plot.show(), plot_v.show(), plot_sync.show()])\n",
"row2 = HBox([plot_l.show(), plot_l3d.show(), plot_s.show()])\n",
@@ -1301,7 +944,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.10.5"
+ "version": "3.11.3"
}
},
"nbformat": 4,
diff --git a/fastplotlib/VERSION b/fastplotlib/VERSION
index 229bb9495..6cb992894 100644
--- a/fastplotlib/VERSION
+++ b/fastplotlib/VERSION
@@ -1 +1 @@
-0.1.0.a9
+0.1.0.a10
\ No newline at end of file
diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py
index 00a83ab4e..309b68d9f 100644
--- a/fastplotlib/graphics/_base.py
+++ b/fastplotlib/graphics/_base.py
@@ -1,4 +1,5 @@
from typing import *
+import weakref
from warnings import warn
import numpy as np
@@ -14,6 +15,11 @@
from dataclasses import dataclass
+# dict that holds all world objects for a given python kernel/session
+# Graphic objects only use proxies to WorldObjects
+WORLD_OBJECTS: Dict[str, WorldObject] = dict() #: {hex id str: WorldObject}
+
+
PYGFX_EVENTS = [
"key_down",
"key_up",
@@ -44,7 +50,8 @@ def __init_subclass__(cls, **kwargs):
class Graphic(BaseGraphic):
def __init__(
- self, name: str = None):
+ self, name: str = None
+ ):
"""
Parameters
@@ -58,10 +65,16 @@ def __init__(
self.registered_callbacks = dict()
self.present = PresentFeature(parent=self)
+ # store hex id str of Graphic instance mem location
+ self.loc: str = hex(id(self))
+
@property
def world_object(self) -> WorldObject:
- """Associated pygfx WorldObject."""
- return self._world_object
+ """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly."""
+ return weakref.proxy(WORLD_OBJECTS[hex(id(self))])
+
+ def _set_world_object(self, wo: WorldObject):
+ WORLD_OBJECTS[hex(id(self))] = wo
@property
def position(self) -> Vector3:
@@ -75,7 +88,7 @@ def visible(self) -> bool:
return self.world_object.visible
@visible.setter
- def visible(self, v) -> bool:
+ def visible(self, v: bool):
"""Access or change the visibility."""
self.world_object.visible = v
@@ -100,6 +113,9 @@ def __repr__(self):
else:
return rval
+ def __del__(self):
+ del WORLD_OBJECTS[self.loc]
+
class Interaction(ABC):
"""Mixin class that makes graphics interactive"""
@@ -159,7 +175,7 @@ def link(
"""
if event_type in PYGFX_EVENTS:
- self.world_object.add_event_handler(self.event_handler, event_type)
+ self.world_object.add_event_handler(self._event_handler, event_type)
# make sure event is valid
elif event_type in self.feature_events:
@@ -216,8 +232,13 @@ def _event_handler(self, event):
# for now we only have line collections so this works
else:
- for i, item in enumerate(self._graphics):
- if item.world_object is event.pick_info["world_object"]:
+ # get index of world object that made this event
+ for i, item in enumerate(self.graphics):
+ wo = WORLD_OBJECTS[item.loc]
+ # we only store hex id of worldobject, but worldobject `pick_info` is always the real object
+ # so if pygfx worldobject triggers an event by itself, such as `click`, etc., this will be
+ # the real world object in the pick_info and not the proxy
+ if wo is event.pick_info["world_object"]:
indices = i
target_info.target._set_feature(feature=target_info.feature, new_data=target_info.new_data, indices=indices)
else:
@@ -264,22 +285,21 @@ class PreviouslyModifiedData:
indices: Any
+COLLECTION_GRAPHICS: dict[str, Graphic] = dict()
+
+
class GraphicCollection(Graphic):
"""Graphic Collection base class"""
def __init__(self, name: str = None):
super(GraphicCollection, self).__init__(name)
- self._graphics: List[Graphic] = list()
-
- @property
- def world_object(self) -> Group:
- """Returns the underling pygfx WorldObject."""
- return self._world_object
+ self._graphics: List[str] = list()
@property
def graphics(self) -> Tuple[Graphic]:
- """returns the Graphics within this collection"""
- return tuple(self._graphics)
+ """The Graphics within this collection. Always returns a proxy to the Graphics."""
+ proxies = [weakref.proxy(COLLECTION_GRAPHICS[loc]) for loc in self._graphics]
+ return tuple(proxies)
def add_graphic(self, graphic: Graphic, reset_index: True):
"""Add a graphic to the collection"""
@@ -289,17 +309,31 @@ def add_graphic(self, graphic: Graphic, reset_index: True):
f"You can only add {self.child_type} to a {self.__class__.__name__}, "
f"you are trying to add a {graphic.__class__.__name__}."
)
- self._graphics.append(graphic)
+
+ loc = hex(id(graphic))
+ COLLECTION_GRAPHICS[loc] = graphic
+
+ self._graphics.append(loc)
if reset_index:
self._reset_index()
self.world_object.add(graphic.world_object)
def remove_graphic(self, graphic: Graphic, reset_index: True):
"""Remove a graphic from the collection"""
- self._graphics.remove(graphic)
+ self._graphics.remove(graphic.loc)
+
if reset_index:
self._reset_index()
- self.world_object.remove(graphic)
+
+ self.world_object.remove(graphic.world_object)
+
+ def __del__(self):
+ self.world_object.clear()
+
+ for loc in self._graphics:
+ del COLLECTION_GRAPHICS[loc]
+
+ super().__del__()
def _reset_index(self):
for new_index, graphic in enumerate(self._graphics):
@@ -312,7 +346,7 @@ def __getitem__(self, key):
if isinstance(key, slice):
key = cleanup_slice(key, upper_bound=len(self))
selection_indices = range(key.start, key.stop, key.step)
- selection = self._graphics[key]
+ selection = self.graphics[key]
# fancy-ish indexing
elif isinstance(key, (tuple, list, np.ndarray)):
@@ -324,7 +358,7 @@ def __getitem__(self, key):
selection = list()
for ix in key:
- selection.append(self._graphics[ix])
+ selection.append(self.graphics[ix])
selection_indices = key
else:
@@ -365,7 +399,7 @@ def __init__(
selection_indices: Union[list, range]
the corresponding indices from the parent GraphicCollection that were selected
"""
- self._parent = parent
+ self._parent = weakref.proxy(parent)
self._selection = selection
self._selection_indices = selection_indices
diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py
index 7177b7bae..da6a177a0 100644
--- a/fastplotlib/graphics/features/_base.py
+++ b/fastplotlib/graphics/features/_base.py
@@ -2,9 +2,10 @@
from inspect import getfullargspec
from warnings import warn
from typing import *
+import weakref
import numpy as np
-from pygfx import Buffer
+from pygfx import Buffer, Texture
supported_dtypes = [
@@ -71,7 +72,7 @@ def __init__(self, parent, data: Any, collection_index: int = None):
if part of a collection, index of this graphic within the collection
"""
- self._parent = parent
+ self._parent = weakref.proxy(parent)
self._data = to_gpu_supported_dtype(data)
@@ -226,7 +227,7 @@ def _update_range(self, key):
@property
@abstractmethod
- def _buffer(self) -> Buffer:
+ def buffer(self) -> Union[Buffer, Texture]:
pass
@property
@@ -238,21 +239,21 @@ def _update_range_indices(self, key):
key = cleanup_slice(key, self._upper_bound)
if isinstance(key, int):
- self._buffer.update_range(key, size=1)
+ self.buffer.update_range(key, size=1)
return
# else if it's a slice obj
if isinstance(key, slice):
if key.step == 1: # we cleaned up the slice obj so step of None becomes 1
# update range according to size using the offset
- self._buffer.update_range(offset=key.start, size=key.stop - key.start)
+ self.buffer.update_range(offset=key.start, size=key.stop - key.start)
else:
step = key.step
# convert slice to indices
ixs = range(key.start, key.stop, step)
for ix in ixs:
- self._buffer.update_range(ix, size=1)
+ self.buffer.update_range(ix, size=1)
else:
raise TypeError("must pass int or slice to update range")
diff --git a/fastplotlib/graphics/features/_colors.py b/fastplotlib/graphics/features/_colors.py
index 7833e0b2c..f8e7c8c3d 100644
--- a/fastplotlib/graphics/features/_colors.py
+++ b/fastplotlib/graphics/features/_colors.py
@@ -7,11 +7,11 @@
class ColorFeature(GraphicFeatureIndexable):
@property
- def _buffer(self):
+ def buffer(self):
return self._parent.world_object.geometry.colors
def __getitem__(self, item):
- return self._buffer.data[item]
+ return self.buffer.data[item]
def __init__(self, parent, colors, n_colors: int, alpha: float = 1.0, collection_index: int = None):
"""
@@ -113,7 +113,7 @@ def __setitem__(self, key, value):
raise ValueError("fancy indexing for colors must be 2-dimension, i.e. [n_datapoints, RGBA]")
# set the user passed data directly
- self._buffer.data[key] = value
+ self.buffer.data[key] = value
# update range
# first slice obj is going to be the indexing so use key[0]
@@ -162,7 +162,7 @@ def __setitem__(self, key, value):
else:
raise ValueError("numpy array passed to color must be of shape (4,) or (n_colors_modify, 4)")
- self._buffer.data[key] = new_colors
+ self.buffer.data[key] = new_colors
self._update_range(key)
self._feature_changed(key, new_colors)
@@ -220,8 +220,8 @@ def __init__(self, parent, cmap: str):
self.name = cmap
def _set(self, cmap_name: str):
- self._parent.world_object.material.map.texture.data[:] = make_colors(256, cmap_name)
- self._parent.world_object.material.map.texture.update_range((0, 0, 0), size=(256, 1, 1))
+ self._parent.world_object.material.map.data[:] = make_colors(256, cmap_name)
+ self._parent.world_object.material.map.update_range((0, 0, 0), size=(256, 1, 1))
self.name = cmap_name
self._feature_changed(key=None, new_data=self.name)
@@ -246,8 +246,8 @@ class HeatmapCmapFeature(ImageCmapFeature):
"""
def _set(self, cmap_name: str):
- self._parent._material.map.texture.data[:] = make_colors(256, cmap_name)
- self._parent._material.map.texture.update_range((0, 0, 0), size=(256, 1, 1))
+ self._parent._material.map.data[:] = make_colors(256, cmap_name)
+ self._parent._material.map.update_range((0, 0, 0), size=(256, 1, 1))
self.name = cmap_name
self._feature_changed(key=None, new_data=self.name)
diff --git a/fastplotlib/graphics/features/_data.py b/fastplotlib/graphics/features/_data.py
index 95e549247..5063b4200 100644
--- a/fastplotlib/graphics/features/_data.py
+++ b/fastplotlib/graphics/features/_data.py
@@ -16,11 +16,11 @@ def __init__(self, parent, data: Any, collection_index: int = None):
super(PointsDataFeature, self).__init__(parent, data, collection_index=collection_index)
@property
- def _buffer(self) -> Buffer:
+ def buffer(self) -> Buffer:
return self._parent.world_object.geometry.positions
def __getitem__(self, item):
- return self._buffer.data[item]
+ return self.buffer.data[item]
def _fix_data(self, data, parent):
graphic_type = parent.__class__.__name__
@@ -54,7 +54,7 @@ def __setitem__(self, key, value):
# otherwise assume that they have the right shape
# numpy will throw errors if it can't broadcast
- self._buffer.data[key] = value
+ self.buffer.data[key] = value
self._update_range(key)
# avoid creating dicts constantly if there are no events to handle
if len(self._event_handlers) > 0:
@@ -87,7 +87,7 @@ def _feature_changed(self, key, new_data):
class ImageDataFeature(GraphicFeatureIndexable):
"""
- Access to the TextureView buffer shown in an ImageGraphic.
+ Access to the Texture buffer shown in an ImageGraphic.
"""
def __init__(self, parent, data: Any):
@@ -97,21 +97,28 @@ def __init__(self, parent, data: Any):
"``[x_dim, y_dim]`` or ``[x_dim, y_dim, rgb]``"
)
- data = to_gpu_supported_dtype(data)
super(ImageDataFeature, self).__init__(parent, data)
@property
- def _buffer(self) -> Texture:
- return self._parent.world_object.geometry.grid.texture
+ def buffer(self) -> Texture:
+ """Texture buffer for the image data"""
+ return self._parent.world_object.geometry.grid
+
+ def update_gpu(self):
+ """Update the GPU with the buffer"""
+ self._update_range(None)
+
+ def __call__(self, *args, **kwargs):
+ return self.buffer.data
def __getitem__(self, item):
- return self._buffer.data[item]
+ return self.buffer.data[item]
def __setitem__(self, key, value):
# make sure float32
value = to_gpu_supported_dtype(value)
- self._buffer.data[key] = value
+ self.buffer.data[key] = value
self._update_range(key)
# avoid creating dicts constantly if there are no events to handle
@@ -119,7 +126,7 @@ def __setitem__(self, key, value):
self._feature_changed(key, value)
def _update_range(self, key):
- self._buffer.update_range((0, 0, 0), size=self._buffer.size)
+ self.buffer.update_range((0, 0, 0), size=self.buffer.size)
def _feature_changed(self, key, new_data):
if key is not None:
@@ -144,12 +151,20 @@ def _feature_changed(self, key, new_data):
class HeatmapDataFeature(ImageDataFeature):
@property
- def _buffer(self) -> List[Texture]:
- return [img.geometry.grid.texture for img in self._parent.world_object.children]
+ def buffer(self) -> List[Texture]:
+ """list of Texture buffer for the image data"""
+ return [img.geometry.grid for img in self._parent.world_object.children]
+
+ def update_gpu(self):
+ """Update the GPU with the buffer"""
+ self._update_range(None)
def __getitem__(self, item):
return self._data[item]
+ def __call__(self, *args, **kwargs):
+ return self.buffer.data
+
def __setitem__(self, key, value):
# make sure supported type, not float64 etc.
value = to_gpu_supported_dtype(value)
@@ -162,7 +177,7 @@ def __setitem__(self, key, value):
self._feature_changed(key, value)
def _update_range(self, key):
- for buffer in self._buffer:
+ for buffer in self.buffer:
buffer.update_range((0, 0, 0), size=buffer.size)
def _feature_changed(self, key, new_data):
diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py
index 854f757f2..835061328 100644
--- a/fastplotlib/graphics/image.py
+++ b/fastplotlib/graphics/image.py
@@ -2,11 +2,12 @@
from math import ceil
from itertools import product
+import numpy as np
import pygfx
-from pygfx.utils import unpack_bitfield
from ._base import Graphic, Interaction, PreviouslyModifiedData
from .features import ImageCmapFeature, ImageDataFeature, HeatmapDataFeature, HeatmapCmapFeature
+from .features._base import to_gpu_supported_dtype
from ..utils import quick_min_max
@@ -23,6 +24,7 @@ def __init__(
vmax: int = None,
cmap: str = 'plasma',
filter: str = "nearest",
+ isolated_buffer: bool = True,
*args,
**kwargs
):
@@ -43,6 +45,10 @@ def __init__(
colormap to use to display the image data, ignored if data is RGB
filter: str, optional, default "nearest"
interpolation filter, one of "nearest" or "linear"
+ isolated_buffer: bool, default True
+ If True, initialize a buffer with the same shape as the input data and then
+ set the data, useful if the data arrays are ready-only such as memmaps.
+ If False, the input array is itself used as the buffer.
args:
additional arguments passed to Graphic
kwargs:
@@ -65,30 +71,48 @@ def __init__(
super().__init__(*args, **kwargs)
- self.data = ImageDataFeature(self, data)
+ data = to_gpu_supported_dtype(data)
+
+ # TODO: we need to organize and do this better
+ if isolated_buffer:
+ # initialize a buffer with the same shape as the input data
+ # we do not directly use the input data array as the buffer
+ # because if the input array is a read-only type, such as
+ # numpy memmaps, we would not be able to change the image data
+ buffer_init = np.zeros(shape=data.shape, dtype=data.dtype)
+ else:
+ buffer_init = data
if (vmin is None) or (vmax is None):
vmin, vmax = quick_min_max(data)
- texture_view = pygfx.Texture(self.data(), dim=2).get_view(filter=filter)
+ texture = pygfx.Texture(buffer_init, dim=2)
- geometry = pygfx.Geometry(grid=texture_view)
+ geometry = pygfx.Geometry(grid=texture)
# if data is RGB
- if self.data().ndim == 3:
+ if data.ndim == 3:
self.cmap = None
- material = pygfx.ImageBasicMaterial(clim=(vmin, vmax))
-
+ material = pygfx.ImageBasicMaterial(clim=(vmin, vmax), map_interpolation=filter)
# if data is just 2D without color information, use colormap LUT
else:
self.cmap = ImageCmapFeature(self, cmap)
- material = pygfx.ImageBasicMaterial(clim=(vmin, vmax), map=self.cmap())
+ material = pygfx.ImageBasicMaterial(clim=(vmin, vmax), map=self.cmap(), map_interpolation=filter)
- self._world_object: pygfx.Image = pygfx.Image(
+ world_object = pygfx.Image(
geometry,
material
)
+ self._set_world_object(world_object)
+
+ self.data = ImageDataFeature(self, data)
+ # TODO: we need to organize and do this better
+ if isolated_buffer:
+ # if the buffer was initialized with zeros
+ # set it with the actual data
+ self.data = data
+
@property
def vmin(self) -> float:
"""Minimum contrast limit."""
@@ -126,21 +150,13 @@ class _ImageTile(pygfx.Image):
"""
Similar to pygfx.Image, only difference is that it contains a few properties to keep track of
row chunk index, column chunk index
-
-
"""
def _wgpu_get_pick_info(self, pick_value):
- tex = self.geometry.grid
- if hasattr(tex, "texture"):
- tex = tex.texture # tex was a view
- # This should match with the shader
- values = unpack_bitfield(pick_value, wobject_id=20, x=22, y=22)
- x = values["x"] / 4194304 * tex.size[0] - 0.5
- y = values["y"] / 4194304 * tex.size[1] - 0.5
- ix, iy = int(x + 0.5), int(y + 0.5)
+ pick_info = super()._wgpu_get_pick_info(pick_value)
+
+ # add row chunk and col chunk index to pick_info dict
return {
- "index": (ix, iy),
- "pixel_coord": (x - ix, y - iy),
+ **pick_info,
"row_chunk_index": self.row_chunk_index,
"col_chunk_index": self.col_chunk_index
}
@@ -176,6 +192,7 @@ def __init__(
cmap: str = 'plasma',
filter: str = "nearest",
chunk_size: int = 8192,
+ isolated_buffer: bool = True,
*args,
**kwargs
):
@@ -198,6 +215,10 @@ def __init__(
interpolation filter, one of "nearest" or "linear"
chunk_size: int, default 8192, max 8192
chunk size for each tile used to make up the heatmap texture
+ isolated_buffer: bool, default True
+ If True, initialize a buffer with the same shape as the input data and then
+ set the data, useful if the data arrays are ready-only such as memmaps.
+ If False, the input array is itself used as the buffer.
args:
additional arguments passed to Graphic
kwargs:
@@ -223,7 +244,17 @@ def __init__(
if chunk_size > 8192:
raise ValueError("Maximum chunk size is 8192")
- self.data = HeatmapDataFeature(self, data)
+ data = to_gpu_supported_dtype(data)
+
+ # TODO: we need to organize and do this better
+ if isolated_buffer:
+ # initialize a buffer with the same shape as the input data
+ # we do not directly use the input data array as the buffer
+ # because if the input array is a read-only type, such as
+ # numpy memmaps, we would not be able to change the image data
+ buffer_init = np.zeros(shape=data.shape, dtype=data.dtype)
+ else:
+ buffer_init = data
row_chunks = range(ceil(data.shape[0] / chunk_size))
col_chunks = range(ceil(data.shape[1] / chunk_size))
@@ -234,13 +265,14 @@ def __init__(
start_ixs = [list(map(lambda c: c * chunk_size, chunk)) for chunk in chunks]
stop_ixs = [list(map(lambda c: c + chunk_size, chunk)) for chunk in start_ixs]
- self._world_object = pygfx.Group()
+ world_object = pygfx.Group()
+ self._set_world_object(world_object)
if (vmin is None) or (vmax is None):
vmin, vmax = quick_min_max(data)
self.cmap = HeatmapCmapFeature(self, cmap)
- self._material = pygfx.ImageBasicMaterial(clim=(vmin, vmax), map=self.cmap())
+ self._material = pygfx.ImageBasicMaterial(clim=(vmin, vmax), map=self.cmap(), map_interpolation=filter)
for start, stop, chunk in zip(start_ixs, stop_ixs, chunks):
row_start, col_start = start
@@ -249,8 +281,8 @@ def __init__(
# x and y positions of the Tile in world space coordinates
y_pos, x_pos = row_start, col_start
- tex_view = pygfx.Texture(data[row_start:row_stop, col_start:col_stop], dim=2).get_view(filter=filter)
- geometry = pygfx.Geometry(grid=tex_view)
+ texture = pygfx.Texture(buffer_init[row_start:row_stop, col_start:col_stop], dim=2)
+ geometry = pygfx.Geometry(grid=texture)
# material = pygfx.ImageBasicMaterial(clim=(0, 1), map=self.cmap())
img = _ImageTile(geometry, self._material)
@@ -264,6 +296,13 @@ def __init__(
self.world_object.add(img)
+ self.data = HeatmapDataFeature(self, buffer_init)
+ # TODO: we need to organize and do this better
+ if isolated_buffer:
+ # if the buffer was initialized with zeros
+ # set it with the actual data
+ self.data = data
+
@property
def vmin(self) -> float:
"""Minimum contrast limit."""
diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py
index 926f5729c..0b1e579bc 100644
--- a/fastplotlib/graphics/line.py
+++ b/fastplotlib/graphics/line.py
@@ -85,12 +85,14 @@ def __init__(
self.thickness = ThicknessFeature(self, thickness)
- self._world_object: pygfx.Line = pygfx.Line(
+ world_object: pygfx.Line = pygfx.Line(
# self.data.feature_data because data is a Buffer
geometry=pygfx.Geometry(positions=self.data(), colors=self.colors()),
material=material(thickness=self.thickness(), vertex_colors=True)
)
+ self._set_world_object(world_object)
+
if z_position is not None:
self.world_object.position.z = z_position
diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py
index 07fc9cad7..3bff6f7c5 100644
--- a/fastplotlib/graphics/line_collection.py
+++ b/fastplotlib/graphics/line_collection.py
@@ -157,7 +157,7 @@ def __init__(
"or must be a str of tuple/list with the same length as the data"
)
- self._world_object = pygfx.Group()
+ self._set_world_object(pygfx.Group())
for i, d in enumerate(data):
if isinstance(z_position, list):
@@ -343,6 +343,6 @@ def __init__(
)
axis_zero = 0
- for i, line in enumerate(self._graphics):
+ for i, line in enumerate(self.graphics):
getattr(line.position, f"set_{separation_axis}")(axis_zero)
axis_zero = axis_zero + line.data()[:, axes[separation_axis]].max() + separation
diff --git a/fastplotlib/graphics/line_slider.py b/fastplotlib/graphics/line_slider.py
index 8755af51a..f19db9cda 100644
--- a/fastplotlib/graphics/line_slider.py
+++ b/fastplotlib/graphics/line_slider.py
@@ -74,7 +74,7 @@ def __init__(
else:
material = pygfx.LineMaterial
- colors_inner = np.repeat([Color("w")], 2, axis=0).astype(np.float32)
+ colors_inner = np.repeat([Color(color)], 2, axis=0).astype(np.float32)
colors_outer = np.repeat([Color([1., 1., 1., 0.25])], 2, axis=0).astype(np.float32)
line_inner = pygfx.Line(
@@ -88,17 +88,19 @@ def __init__(
material=material(thickness=thickness + 4, vertex_colors=True)
)
- self._world_object = pygfx.Group()
+ world_object = pygfx.Group()
- self._world_object.add(line_outer)
- self._world_object.add(line_inner)
+ world_object.add(line_outer)
+ world_object.add(line_inner)
+
+ self._set_world_object(world_object)
self.position.x = x_pos
self.slider = slider
self.slider.observe(self.set_position, "value")
- self.name = name
+ super().__init__(name=name)
def set_position(self, change):
self.position.x = change["new"]
diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py
index 016d1cac9..b53985de0 100644
--- a/fastplotlib/graphics/scatter.py
+++ b/fastplotlib/graphics/scatter.py
@@ -72,9 +72,11 @@ def __init__(
super(ScatterGraphic, self).__init__(*args, **kwargs)
- self._world_object: pygfx.Points = pygfx.Points(
+ world_object = pygfx.Points(
pygfx.Geometry(positions=self.data(), sizes=sizes, colors=self.colors()),
material=pygfx.PointsMaterial(vertex_colors=True, vertex_sizes=True)
)
+ self._set_world_object(world_object)
+
self.world_object.position.z = z_position
diff --git a/fastplotlib/graphics/selectors/__init__.py b/fastplotlib/graphics/selectors/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/fastplotlib/graphics/selectors/linear.py b/fastplotlib/graphics/selectors/linear.py
new file mode 100644
index 000000000..4e9934944
--- /dev/null
+++ b/fastplotlib/graphics/selectors/linear.py
@@ -0,0 +1,152 @@
+from typing import *
+import numpy as np
+from time import time
+
+import pygfx
+from pygfx.linalg import Vector3
+
+from .._base import Graphic, Interaction
+from ..features._base import GraphicFeature, FeatureEvent
+
+
+# positions for indexing the BoxGeometry to set the "width" and "height" of the box
+# hacky but I don't think we can morph meshes in pygfx yet: https://github.com/pygfx/pygfx/issues/346
+x_right = np.array([
+ True, True, True, True, False, False, False, False, False,
+ True, False, True, True, False, True, False, False, True,
+ False, True, True, False, True, False
+])
+
+x_left = np.array([
+ False, False, False, False, True, True, True, True, True,
+ False, True, False, False, True, False, True, True, False,
+ True, False, False, True, False, True
+])
+
+y_top = np.array([
+ False, True, False, True, False, True, False, True, True,
+ True, True, True, False, False, False, False, False, False,
+ True, True, False, False, True, True
+])
+
+y_bottom = np.array([
+ True, False, True, False, True, False, True, False, False,
+ False, False, False, True, True, True, True, True, True,
+ False, False, True, True, False, False
+])
+
+
+class LinearBoundsFeature(GraphicFeature):
+ def __init__(self, parent, bounds: Tuple[int, int]):
+ super(LinearBoundsFeature, self).__init__(parent, data=bounds)
+
+ def _set(self, value):
+ # sets new bounds
+ if not isinstance(value, tuple):
+ raise TypeError(
+ "Bounds must be a tuple in the form of `(min_bound, max_bound)`, "
+ "where `min_bound` and `max_bound` are numeric values."
+ )
+
+ self._parent.fill.geometry.positions.data[x_left, 0] = value[0]
+ self._parent.fill.geometry.positions.data[x_right, 0] = value[1]
+ self._data = (value[0], value[1])
+
+ self._parent.fill.geometry.positions.update_range()
+
+ self._feature_changed(key=None, new_data=value)
+
+ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any):
+ pick_info = {
+ "index": None,
+ "collection-index": self._collection_index,
+ "world_object": self._parent.world_object,
+ "new_data": new_data
+ }
+
+ event_data = FeatureEvent(type="bounds", pick_info=pick_info)
+
+ self._call_event_handlers(event_data)
+
+
+class LinearSelector(Graphic, Interaction):
+ """Linear region selector, for lines or line collections."""
+ feature_events = (
+ "bounds"
+ )
+
+ def __init__(
+ self,
+ bounds: Tuple[int, int],
+ height: int,
+ position: Tuple[int, int],
+ fill_color=(0.1, 0.1, 0.1),
+ edge_color="w",
+ name: str = None
+ ):
+ super(LinearSelector, self).__init__(name=name)
+
+ self._world_object = pygfx.Group()
+
+ self.fill = pygfx.Mesh(
+ pygfx.box_geometry(1, height, 1),
+ pygfx.MeshBasicMaterial(color=fill_color)
+ )
+
+ self.fill.position.set(*position, -2)
+
+ self.fill.add_event_handler(self._move_start, "double_click")
+ self.fill.add_event_handler(self._move, "pointer_move")
+ self.fill.add_event_handler(self._move_end, "click")
+
+ self.world_object.add(self.fill)
+
+ self._move_info = None
+ # self.fill.add_event_handler(
+
+ self.edges = None
+
+ self.bounds = LinearBoundsFeature(self, bounds)
+ self.bounds = bounds
+ self.timer = 0
+
+ # self._plane =
+
+ def _move_start(self, ev):
+ self._move_info = {"last_pos": (ev.x, ev.y)}
+ self.timer = time()
+ print(self._move_info)
+
+ def _move(self, ev):
+ if self._move_info is None:
+ return
+
+ if time() - self.timer > 2:
+ self._move_end(ev)
+ return
+
+ print("moving!")
+ print(ev.x, ev.y)
+
+ last = self._move_info["last_pos"]
+
+ delta = (last[0] - ev.x, last[1] - ev.y)
+
+ self._move_info = {"last_pos": (ev.x, ev.y)}
+
+ # adjust x vals
+ self.bounds = (self.bounds()[0] - delta[0], self.bounds()[1] - delta[0])
+
+ print(self._move_info)
+
+ self.timer = time()
+
+ def _move_end(self, ev):
+ print("move end")
+ self._move_info = None
+
+ def _set_feature(self, feature: str, new_data: Any, indices: Any):
+ pass
+
+ def _reset_feature(self, feature: str):
+ pass
\ No newline at end of file
diff --git a/fastplotlib/graphics/text.py b/fastplotlib/graphics/text.py
index 665c53606..8225bb300 100644
--- a/fastplotlib/graphics/text.py
+++ b/fastplotlib/graphics/text.py
@@ -38,11 +38,13 @@ def __init__(
"""
super(TextGraphic, self).__init__(name=name)
- self._world_object = pygfx.Text(
+ world_object = pygfx.Text(
pygfx.TextGeometry(text=text, font_size=size, screen_space=False),
pygfx.TextMaterial(color=face_color, outline_color=outline_color, outline_thickness=outline_thickness)
)
+ self._set_world_object(world_object)
+
self.world_object.position.set(*position)
self.name = None
diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py
index ce35135c7..70bd6dbaa 100644
--- a/fastplotlib/layouts/_base.py
+++ b/fastplotlib/layouts/_base.py
@@ -1,11 +1,21 @@
+from warnings import warn
+from typing import *
+import weakref
+
import numpy as np
+
from pygfx import Scene, OrthographicCamera, PerspectiveCamera, PanZoomController, OrbitController, \
Viewport, WgpuRenderer
from wgpu.gui.auto import WgpuCanvas
-from warnings import warn
-from ..graphics._base import Graphic
+
+from ..graphics._base import Graphic, GraphicCollection
from ..graphics.line_slider import LineSlider
-from typing import *
+
+
+# dict to store Graphic instances
+# this is the only place where the real references to Graphics are stored in a Python session
+# {hex id str: Graphic}
+GRAPHICS: Dict[str, Graphic] = dict()
class PlotArea:
@@ -60,21 +70,16 @@ def __init__(
self._camera = camera
self._controller = controller
- self.controller.add_default_event_handlers(
+ self.controller.add_camera(self.camera)
+ self.controller.register_events(
self.viewport,
- self.camera
)
- # camera.far and camera.near clipping planes get
- # wonky with setting controller.distance = 0
- if isinstance(self.camera, OrthographicCamera):
- self.controller.distance = 0
- # also set a initial zoom
- self.controller.zoom(0.8 / self.controller.zoom_value)
-
self.renderer.add_event_handler(self.set_viewport_rect, "resize")
- self._graphics: List[Graphic] = list()
+ # list of hex id strings for all graphics managed by this PlotArea
+ # the real Graphic instances are stored in the ``GRAPHICS`` dict
+ self._graphics: List[str] = list()
# hacky workaround for now to exclude from bbox calculations
self._sliders: List[LineSlider] = list()
@@ -129,8 +134,13 @@ def controller(self) -> Union[PanZoomController, OrbitController]:
@property
def graphics(self) -> Tuple[Graphic]:
- """returns the Graphics in the plot area"""
- return tuple(self._graphics)
+ """Graphics in the plot area. Always returns a proxy to the Graphic instances."""
+ proxies = list()
+ for loc in self._graphics:
+ p = weakref.proxy(GRAPHICS[loc])
+ proxies.append(p)
+
+ return tuple(proxies)
def get_rect(self) -> Tuple[float, float, float, float]:
"""allows setting the region occupied by the viewport w.r.t. the parent"""
@@ -141,7 +151,6 @@ def set_viewport_rect(self, *args):
def render(self):
# does not flush
- self.controller.update_camera(self.camera)
self.viewport.render(self.scene, self.camera)
for child in self.children:
@@ -154,7 +163,8 @@ def add_graphic(self, graphic: Graphic, center: bool = True):
Parameters
----------
graphic: Graphic or GraphicCollection
- Add a Graphic or a GraphicCollection to the plot area
+ Add a Graphic or a GraphicCollection to the plot area.
+ Note: this must be a real Graphic instance and not a proxy
center: bool, default True
Center the camera on the newly added Graphic
@@ -170,10 +180,14 @@ def add_graphic(self, graphic: Graphic, center: bool = True):
# TODO: need to refactor LineSlider entirely
if isinstance(graphic, LineSlider):
- self._sliders.append(graphic)
+ self._sliders.append(graphic) # don't manage garbage collection of LineSliders for now
else:
- self._graphics.append(graphic)
+ # store in GRAPHICS dict
+ loc = graphic.loc
+ GRAPHICS[loc] = graphic
+ self._graphics.append(loc) # add hex id string for referencing this graphic instance
+ # add world object to scene
self.scene.add(graphic.world_object)
if center:
@@ -185,23 +199,13 @@ def add_graphic(self, graphic: Graphic, center: bool = True):
def _check_graphic_name_exists(self, name):
graphic_names = list()
- for g in self._graphics:
+ for g in self.graphics:
graphic_names.append(g.name)
if name in graphic_names:
raise ValueError(f"graphics must have unique names, current graphic names are:\n {graphic_names}")
- def _refresh_camera(self):
- self.controller.update_camera(self.camera)
- if sum(self.renderer.logical_size) > 0:
- scene_lsize = self.viewport.rect[2], self.viewport.rect[3]
- else:
- scene_lsize = (1, 1)
-
- self.camera.set_view_size(*scene_lsize)
- self.camera.update_projection_matrix()
-
- def center_graphic(self, graphic: Graphic, zoom: float = 1.3):
+ def center_graphic(self, graphic: Graphic, zoom: float = 1.35):
"""
Center the camera w.r.t. the passed graphic
@@ -214,17 +218,14 @@ def center_graphic(self, graphic: Graphic, zoom: float = 1.3):
zoom the camera after centering
"""
- if not isinstance(self.camera, OrthographicCamera):
- warn("`center_graphic()` not yet implemented for `PerspectiveCamera`")
- return
-
- self._refresh_camera()
- self.controller.show_object(self.camera, graphic.world_object)
+ self.camera.show_object(graphic.world_object)
- self.controller.zoom(zoom)
+ # camera.show_object can cause the camera width and height to increase so apply a zoom to compensate
+ # probably because camera.show_object uses bounding sphere
+ self.camera.zoom = zoom
- def center_scene(self, zoom: float = 1.3):
+ def center_scene(self, zoom: float = 1.35):
"""
Auto-center the scene, does not scale.
@@ -237,15 +238,11 @@ def center_scene(self, zoom: float = 1.3):
if not len(self.scene.children) > 0:
return
- if not isinstance(self.camera, OrthographicCamera):
- warn("`center_scene()` not yet implemented for `PerspectiveCamera`")
- return
-
- self._refresh_camera()
-
- self.controller.show_object(self.camera, self.scene)
+ self.camera.show_object(self.scene)
- self.controller.zoom(zoom)
+ # camera.show_object can cause the camera width and height to increase so apply a zoom to compensate
+ # probably because camera.show_object uses bounding sphere
+ self.camera.zoom = zoom
def auto_scale(self, maintain_aspect: bool = False, zoom: float = 0.8):
"""
@@ -281,14 +278,13 @@ def auto_scale(self, maintain_aspect: bool = False, zoom: float = 0.8):
self.camera.width = width
self.camera.height = height
- # self.controller.distance = 0
-
- self.controller.zoom(zoom / self.controller.zoom_value)
+ self.camera.zoom = zoom
def remove_graphic(self, graphic: Graphic):
"""
- Remove a graphic from the scene. Note: This does not garbage collect the graphic,
- you can add it back to the scene after removing it.
+ Remove a ``Graphic`` from the scene. Note: This does not garbage collect the graphic,
+ you can add it back to the scene after removing it. Use ``delete_graphic()`` to
+ delete and garbage collect a ``Graphic``.
Parameters
----------
@@ -296,15 +292,67 @@ def remove_graphic(self, graphic: Graphic):
The graphic to remove from the scene
"""
+
self.scene.remove(graphic.world_object)
+ def delete_graphic(self, graphic: Graphic):
+ """
+ Delete the graphic, garbage collects and frees GPU VRAM.
+
+ Parameters
+ ----------
+ graphic: Graphic or GraphicCollection
+ The graphic to delete
+
+ """
+
+ # graphic_loc = hex(id(graphic.__repr__.__self__))
+
+ # get location
+ graphic_loc = graphic.loc
+
+ if graphic_loc not in self._graphics:
+ raise KeyError(f"Graphic with following address not found in plot area: {graphic_loc}")
+
+ # remove from scene if necessary
+ if graphic.world_object in self.scene.children:
+ self.scene.remove(graphic.world_object)
+
+ # remove from list of addresses
+ self._graphics.remove(graphic_loc)
+
+ # for GraphicCollection objects
+ # if isinstance(graphic, GraphicCollection):
+ # # clear Group
+ # graphic.world_object.clear()
+ # graphic.clear()
+ # delete all child world objects in the collection
+ # for g in graphic.graphics:
+ # subloc = hex(id(g))
+ # del WORLD_OBJECTS[subloc]
+
+ # get mem location of graphic
+ # loc = hex(id(graphic))
+ # delete world object
+ #del WORLD_OBJECTS[graphic_loc]
+
+ del GRAPHICS[graphic_loc]
+
+ def clear(self):
+ """
+ Clear the Plot or Subplot. Also performs garbage collection, i.e. runs ``delete_graphic`` on all graphics.
+ """
+
+ for g in self.graphics:
+ self.delete_graphic(g)
+
def __getitem__(self, name: str):
- for graphic in self._graphics:
+ for graphic in self.graphics:
if graphic.name == name:
return graphic
graphic_names = list()
- for g in self._graphics:
+ for g in self.graphics:
graphic_names.append(g.name)
raise IndexError(f"no graphic of given name, the current graphics are:\n {graphic_names}")
@@ -322,5 +370,5 @@ def __repr__(self):
return f"{self}\n" \
f" parent: {self.parent}\n" \
f" Graphics:\n" \
- f"\t{newline.join(graphic.__repr__() for graphic in self._graphics)}" \
+ f"\t{newline.join(graphic.__repr__() for graphic in self.graphics)}" \
f"\n"
diff --git a/fastplotlib/layouts/_defaults.py b/fastplotlib/layouts/_defaults.py
index 3c5732613..314774751 100644
--- a/fastplotlib/layouts/_defaults.py
+++ b/fastplotlib/layouts/_defaults.py
@@ -8,9 +8,9 @@
controller_types = {
'2d': pygfx.PanZoomController,
- '3d': pygfx.OrbitOrthoController,
+ '3d': pygfx.OrbitController,
pygfx.OrthographicCamera: pygfx.PanZoomController,
- pygfx.PerspectiveCamera: pygfx.OrbitOrthoController,
+ pygfx.PerspectiveCamera: pygfx.OrbitController,
}
diff --git a/fastplotlib/layouts/_gridplot.py b/fastplotlib/layouts/_gridplot.py
index 3abb4fa63..3f694b007 100644
--- a/fastplotlib/layouts/_gridplot.py
+++ b/fastplotlib/layouts/_gridplot.py
@@ -71,8 +71,9 @@ def __init__(
# create the array representing the views for each subplot in the grid
cameras = np.array([cameras] * self.shape[0] * self.shape[1]).reshape(self.shape)
- if controllers == "sync":
- controllers = np.zeros(self.shape[0] * self.shape[1], dtype=int).reshape(self.shape)
+ if isinstance(controllers, str):
+ if controllers == "sync":
+ controllers = np.zeros(self.shape[0] * self.shape[1], dtype=int).reshape(self.shape)
if controllers is None:
controllers = np.arange(self.shape[0] * self.shape[1]).reshape(self.shape)
@@ -84,11 +85,30 @@ def __init__(
cameras = to_array(cameras)
+ self._controllers = np.empty(shape=cameras.shape, dtype=object)
+
+
if cameras.shape != self.shape:
raise ValueError
- if not np.all(np.sort(np.unique(controllers)) == np.arange(np.unique(controllers).size)):
- raise ValueError("controllers must be consecutive integers")
+ # create controllers if the arguments were integers
+ if np.issubdtype(controllers.dtype, np.integer):
+ if not np.all(np.sort(np.unique(controllers)) == np.arange(np.unique(controllers).size)):
+ raise ValueError("controllers must be consecutive integers")
+
+ for controller in np.unique(controllers):
+ cam = np.unique(cameras[controllers == controller])
+ if cam.size > 1:
+ raise ValueError(
+ f"Controller id: {controller} has been assigned to multiple different camera types")
+
+ self._controllers[controllers == controller] = create_controller(cam[0])
+ # else assume it's a single pygfx.Controller instance or a list of controllers
+ else:
+ if isinstance(controllers, pygfx.Controller):
+ self._controllers = np.array([controllers] * shape[0] * shape[1]).reshape(shape)
+ else:
+ self._controllers = np.array(controllers).reshape(shape)
if canvas is None:
canvas = WgpuCanvas()
@@ -111,18 +131,9 @@ def __init__(
self._subplots: np.ndarray[Subplot] = np.ndarray(shape=(nrows, ncols), dtype=object)
# self.viewports: np.ndarray[Subplot] = np.ndarray(shape=(nrows, ncols), dtype=object)
- self._controllers: List[pygfx.PanZoomController] = [
- pygfx.PanZoomController() for i in range(np.unique(controllers).size)
- ]
-
- self._controllers = np.empty(shape=cameras.shape, dtype=object)
-
- for controller in np.unique(controllers):
- cam = np.unique(cameras[controllers == controller])
- if cam.size > 1:
- raise ValueError(f"Controller id: {controller} has been assigned to multiple different camera types")
-
- self._controllers[controllers == controller] = create_controller(cam[0])
+ # self._controllers: List[pygfx.PanZoomController] = [
+ # pygfx.PanZoomController() for i in range(np.unique(controllers).size)
+ # ]
for i, j in self._get_iterator():
position = (i, j)
diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py
index 41d065648..a5f57451e 100644
--- a/fastplotlib/layouts/_subplot.py
+++ b/fastplotlib/layouts/_subplot.py
@@ -2,10 +2,11 @@
import numpy as np
from math import copysign
from functools import partial
+import weakref
from inspect import signature, getfullargspec
from warnings import warn
-from pygfx import Scene, OrthographicCamera, PanZoomController, OrbitOrthoController, \
+from pygfx import Scene, OrthographicCamera, PanZoomController, OrbitController, \
AxesHelper, GridHelper, WgpuRenderer
from wgpu.gui.auto import WgpuCanvas
@@ -21,7 +22,7 @@ def __init__(
position: Tuple[int, int] = None,
parent_dims: Tuple[int, int] = None,
camera: str = '2d',
- controller: Union[PanZoomController, OrbitOrthoController] = None,
+ controller: Union[PanZoomController, OrbitController] = None,
canvas: WgpuCanvas = None,
renderer: WgpuRenderer = None,
name: str = None,
@@ -112,7 +113,7 @@ def __init__(
if self.name is not None:
self.set_title(self.name)
- def _create_graphic(self, graphic_class, *args, **kwargs):
+ def _create_graphic(self, graphic_class, *args, **kwargs) -> weakref.proxy:
if "center" in kwargs.keys():
center = kwargs.pop("center")
else:
@@ -124,7 +125,8 @@ def _create_graphic(self, graphic_class, *args, **kwargs):
graphic = graphic_class(*args, **kwargs)
self.add_graphic(graphic, center=center)
- return graphic
+ # only return a proxy to the real graphic
+ return weakref.proxy(graphic)
def set_title(self, text: Any):
"""Sets the name of a subplot to 'top' viewport if defined."""
diff --git a/fastplotlib/plot.py b/fastplotlib/plot.py
index 74c35ef6e..89c73a5f2 100644
--- a/fastplotlib/plot.py
+++ b/fastplotlib/plot.py
@@ -10,7 +10,7 @@ def __init__(
canvas: WgpuCanvas = None,
renderer: pygfx.Renderer = None,
camera: str = '2d',
- controller: Union[pygfx.PanZoomController, pygfx.OrbitOrthoController] = None,
+ controller: Union[pygfx.PanZoomController, pygfx.OrbitController] = None,
**kwargs
):
"""
diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py
index d919a88d6..1ba72b322 100644
--- a/fastplotlib/utils/functions.py
+++ b/fastplotlib/utils/functions.py
@@ -68,7 +68,7 @@ def make_colors(n_colors: int, cmap: str, alpha: float = 1.0) -> np.ndarray:
def get_cmap_texture(name: str, alpha: float = 1.0) -> Texture:
cmap = _get_cmap(name)
- return Texture(cmap, dim=1).get_view()
+ return Texture(cmap, dim=1)
def make_colors_dict(labels: iter, cmap: str, **kwargs) -> OrderedDict:
diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py
index 32255629e..32f444a32 100644
--- a/fastplotlib/widgets/image.py
+++ b/fastplotlib/widgets/image.py
@@ -170,11 +170,12 @@ def __init__(
vmin_vmax_sliders: bool = False,
grid_shape: Tuple[int, int] = None,
names: List[str] = None,
+ grid_plot_kwargs: dict = None,
**kwargs
):
"""
- A high level for displaying n-dimensional image data in conjunction with automatically generated sliders for
- navigating through 1-2 selected dimensions within the image data.
+ A high level widget for displaying n-dimensional image data in conjunction with automatically generated
+ sliders for navigating through 1-2 selected dimensions within image data.
Can display a single n-dimensional image array or a grid of n-dimensional images.
@@ -227,6 +228,9 @@ def __init__(
grid_shape: Optional[Tuple[int, int]]
manually provide the shape for a gridplot, otherwise a square gridplot is approximated.
+ grid_plot_kwargs: dict, optional
+ passed to `GridPlot`
+
names: Optional[str]
gives names to the subplots
@@ -471,12 +475,12 @@ def __init__(
if vmin_vmax_sliders:
data_range = np.ptp(minmax)
- data_range_30p = np.ptp(minmax) * 0.3
+ data_range_40p = np.ptp(minmax) * 0.3
minmax_slider = FloatRangeSlider(
value=minmax,
- min=minmax[0] - data_range_30p,
- max=minmax[1] + data_range_30p,
+ min=minmax[0] - data_range_40p,
+ max=minmax[1] + data_range_40p,
step=data_range / 150,
description=f"min-max",
readout=True,
@@ -494,11 +498,15 @@ def __init__(
kwargs["vmin"], kwargs["vmax"] = minmax
frame = self._process_indices(self.data[0], slice_indices=self._current_index)
+ frame = self._process_frame_apply(frame, 0)
self.image_graphics: List[ImageGraphic] = [self.plot.add_image(data=frame, name="image", **kwargs)]
elif self._plot_type == "grid":
- self._plot: GridPlot = GridPlot(shape=grid_shape, controllers="sync")
+ if grid_plot_kwargs is None:
+ grid_plot_kwargs = {"controllers": "sync"}
+
+ self._plot: GridPlot = GridPlot(shape=grid_shape, **grid_plot_kwargs)
self.image_graphics = list()
for data_ix, (d, subplot) in enumerate(zip(self.data, self.plot)):
@@ -513,12 +521,12 @@ def __init__(
if vmin_vmax_sliders:
data_range = np.ptp(minmax)
- data_range_30p = np.ptp(minmax) * 0.4
+ data_range_40p = np.ptp(minmax) * 0.4
minmax_slider = FloatRangeSlider(
value=minmax,
- min=minmax[0] - data_range_30p,
- max=minmax[1] + data_range_30p,
+ min=minmax[0] - data_range_40p,
+ max=minmax[1] + data_range_40p,
step=data_range / 150,
description=f"mm: {name_slider}",
readout=True,
@@ -539,6 +547,7 @@ def __init__(
_kwargs = kwargs
frame = self._process_indices(d, slice_indices=self._current_index)
+ frame = self._process_frame_apply(frame, data_ix)
ig = ImageGraphic(frame, name="image", **_kwargs)
subplot.add_graphic(ig)
subplot.name = name
@@ -767,11 +776,17 @@ def _get_window_indices(self, data_ix, dim, indices_dim):
return indices_dim
def _process_frame_apply(self, array, data_ix) -> np.ndarray:
+ if callable(self.frame_apply):
+ return self.frame_apply(array)
+
if data_ix not in self.frame_apply.keys():
return array
- if self.frame_apply[data_ix] is not None:
+
+ elif self.frame_apply[data_ix] is not None:
return self.frame_apply[data_ix](array)
+ return array
+
def _slider_value_changed(
self,
dimension: str,
@@ -801,6 +816,37 @@ def _set_slider_layout(self, *args):
for mm in self.vmin_vmax_sliders:
mm.layout = Layout(width=f"{w}px")
+ def _get_vmin_vmax_range(self, data: np.ndarray) -> Tuple[int, int]:
+ minmax = quick_min_max(data)
+
+ data_range = np.ptp(minmax)
+ data_range_40p = np.ptp(minmax) * 0.4
+
+ _range = (
+ minmax,
+ data_range,
+ minmax[0] - data_range_40p,
+ minmax[1] + data_range_40p
+ )
+
+ return _range
+
+ def reset_vmin_vmax(self):
+ """
+ Reset the vmin and vmax w.r.t. the currently displayed image(s)
+ """
+ for i, ig in enumerate(self.image_graphics):
+ mm = self._get_vmin_vmax_range(ig.data())
+
+ state = {
+ "value": mm[0],
+ "step": mm[1] / 150,
+ "min": mm[2],
+ "max": mm[3]
+ }
+
+ self.vmin_vmax_sliders[i].set_state(state)
+
def show(self):
"""
Show the widget