diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5fe2f65fe..7c15e3865 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,10 +3,10 @@ name: CI
on:
push:
branches:
- - master
+ - main
pull_request:
branches:
- - master
+ - main
types:
- opened
- reopened
diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml
index d4cfb94d3..40f55d234 100644
--- a/.github/workflows/screenshots.yml
+++ b/.github/workflows/screenshots.yml
@@ -3,7 +3,7 @@ name: Screenshots
on:
pull_request:
branches:
- - master
+ - main
types:
- opened
- reopened
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e76c4c21c..7bf5c69ea 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,6 +1,6 @@
# Contribution guide
-Contributions are welcome!
+Contributions are welcome! :smile:
## Instructions
@@ -18,18 +18,18 @@ cd fastplotlib
pip install -e ".[notebook,docs,tests]
```
-3. Checkout the `master` branch, and then checkout your feature or bug fix branch, and run tests:
+3. Checkout the `main` branch, and then checkout your feature or bug fix branch, and run tests:
> **Warning**
> Do not commit or add any changes from `examples/screenshots` or `examples/diffs`.
-> If you are creating new test examples that generate or change screenshots please post an issue on the repo and we will help you.
+> If you are creating new test examples that generate or change screenshots please post an issue on the repo and we will help you. The screenshots will be generated on github actions servers, which you can then copy into the screenshots dir. :)
```bash
cd fastplotlib
-git checkout master
+git checkout main
-# checkout your new branch from master
+# checkout your new branch from main
git checkout -b my-new-feature-branch
# make your changes
@@ -49,4 +49,4 @@ git commit -m "my new feature"
git push origin my-new-feature-branch
```
-4. Finally make a **draft** PR against the `master` branch. When you think the PR is ready, mark it for review to trigger tests using our CI pipeline. If you need to make changes, please set the PR to a draft when pushing further commits until it's ready for review scion. We will get back to your with any further suggestions!
+4. Finally make a **draft** PR against the `main` branch. When you think the PR is ready, mark it for review to trigger tests using our CI pipeline. If you need to make changes, please set the PR to a draft when pushing further commits until it's ready for review scion. We will get back to your with any further suggestions!
diff --git a/README.md b/README.md
index ae03ea13b..0f9ccf547 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,9 @@
-# fastplotlib
+
+
+
+
+---
+
[](https://github.com/kushalkolar/fastplotlib/actions/workflows/ci.yml)
[](https://badge.fury.io/py/fastplotlib)
[](https://fastplotlib.readthedocs.io/en/latest/?badge=latest)
@@ -9,14 +14,16 @@
[**Examples**](https://github.com/kushalkolar/fastplotlib#examples) |
[**Contributing**](https://github.com/kushalkolar/fastplotlib#heart-contributing)
-A fast plotting library built using the [`pygfx`](https://github.com/pygfx/pygfx) render engine utilizing [Vulkan](https://en.wikipedia.org/wiki/Vulkan), [DX12](https://en.wikipedia.org/wiki/DirectX#DirectX_12), or [Metal](https://developer.apple.com/metal/) via WGPU, so it is very fast! We also aim to be an expressive plotting library that enables rapid prototyping for large scale explorative scientific visualization.
+A fast plotting library built using the [`pygfx`](https://github.com/pygfx/pygfx) rendering engine that can utilize [Vulkan](https://en.wikipedia.org/wiki/Vulkan), [DX12](https://en.wikipedia.org/wiki/DirectX#DirectX_12), or [Metal](https://developer.apple.com/metal/) via WGPU, so it is very fast! We also aim to be an expressive plotting library that enables rapid prototyping for large scale explorative scientific visualization.

-### SciPy Talk
+### SciPy 2023 Talk
[](https://www.youtube.com/watch?v=Q-UJpAqljsU)
+Notebooks from talk: https://github.com/fastplotlib/fastplotlib-scipy2023
+
# Supported frameworks
@@ -30,21 +37,21 @@ A fast plotting library built using the [`pygfx`](https://github.com/pygfx/pygfx
**Notes:**\
:heavy_check_mark: You can use a non-blocking `glfw` canvas from a notebook, as long as you're working locally or have a way to forward the remote graphical desktop (such as X11 forwarding).\
:grey_exclamation: We do not officially support `jupyter notebook` through `jupyter_rfb`, this may change with notebook v7\
-:disappointed: [`jupyter_rfb`](https://github.com/vispy/jupyter_rfb) does not work in collab, for a detailed discussion see: https://github.com/vispy/jupyter_rfb/issues/57
+:disappointed: [`jupyter_rfb`](https://github.com/vispy/jupyter_rfb) does not work in collab yet, see https://github.com/vispy/jupyter_rfb/pull/77
> **Note**
>
-> `fastplotlib` is currently in the **early alpha stage with breaking changes every ~week**, but you're welcome to try it out or contribute! See our [Roadmap for 2023](https://github.com/kushalkolar/fastplotlib/issues/55).
+> `fastplotlib` is currently in the **alpha stage with breaking changes every ~month**, but you're welcome to try it out or contribute! See our [Roadmap](https://github.com/kushalkolar/fastplotlib/issues/55). See this for a discussion on API stability: https://github.com/fastplotlib/fastplotlib/issues/121
# Documentation
http://fastplotlib.readthedocs.io/
-The Quickstart guide is not interactive. We recommend cloning/downloading the repo and trying out the `desktop` or `notebook` examples: https://github.com/kushalkolar/fastplotlib/tree/master/examples
+The Quickstart guide is not interactive. We recommend cloning/downloading the repo and trying out the `desktop` or `notebook` examples: https://github.com/kushalkolar/fastplotlib/tree/main/examples
If someone wants to integrate `pyodide` with `pygfx` we would be able to have live interactive examples! :smiley:
-Questions, ideas? Post an issue or [chat on gitter](https://gitter.im/fastplotlib/community?utm_source=share-link&utm_medium=link&utm_campaign=share-link).
+Questions, issues, ideas? Post an [issue](https://github.com/fastplotlib/fastplotlib/issues) or post on the [discussion forum](https://github.com/fastplotlib/fastplotlib/discussions)!
# Installation
@@ -55,6 +62,8 @@ Install using `pip`.
pip install fastplotlib
```
+**This does not give you `Qt` or `glfw`, you will have to install one of them yourself depending on your preference**.
+
### Notebook
```bash
pip install "fastplotlib[notebook]"
@@ -83,67 +92,43 @@ pip install -e ".[notebook,docs,tests]"
> **Note**
>
-> `fastplotlib` and `pygfx` are fast evolving, you may require the latest `pygfx` and `fastplotlib` from github to use the examples in the master branch.
+> `fastplotlib` and `pygfx` are fast evolving, you may require the latest `pygfx` and `fastplotlib` from github to use the examples in the main branch.
-First clone or download the repo to try the examples
-
-```bash
-git clone https://github.com/kushalkolar/fastplotlib.git
-```
+Note that `fastplotlib` code is basically identical between desktop and notebook usage. The differences are:
+- Running in `Qt` or `glfw` require a `fastplotlib.run()` call (which is really just a `wgpu` `run()` call)
+- Notebooks plots have ipywidget-based toolbars and widgets 😄
### Desktop examples using `glfw` or `Qt`
-```bash
-# most dirs within examples contain example code
-cd examples/desktop
-
-# simplest example
-python image/image_simple.py
-```
-
-### Notebook examples
-
-```bash
-cd examples/notebooks
-jupyter lab
-```
-
-**Start out with `simple.ipynb`.**
+GLFW examples are here. GLFW is a "minimal" desktop framework.
-### Simple image plot
-```python
-import fastplotlib as fpl
-import numpy as np
+https://github.com/fastplotlib/fastplotlib/tree/main/examples/desktop
-plot = fpl.Plot()
+Qt examples are here:
-data = np.random.rand(512, 512)
-plot.add_image(data=data)
+https://github.com/fastplotlib/fastplotlib/tree/main/examples/qt
-plot.show()
+Some of the examples require imageio:
+```
+pip install imageio
```
-
-
-### Fast animations
-```python
-import fastplotlib as fpl
-import numpy as np
-plot = fpl.Plot()
+### Notebook examples
-data = np.random.rand(512, 512)
-image = plot.image(data=data)
+Notebook examples are here:
-def update_data():
- new_data = np.random.rand(512, 512)
- image.data = new_data
+https://github.com/fastplotlib/fastplotlib/tree/main/examples/notebooks
-plot.add_animations(update_data)
+**Start with `simple.ipynb`.**
-plot.show()
+Some of the examples require imageio:
```
+pip install imageio
+```
+
+### Video
-
+You can watch our SciPy 2023 talk if you prefer watching demos: https://github.com/fastplotlib/fastplotlib#scipy-talk
## Graphics drivers
@@ -176,10 +161,10 @@ sudo apt install llvm-dev libturbojpeg* libgl1-mesa-dev libgl1-mesa-glx libglapi
```
### Mac OSX:
-As far as I know, WGPU uses Metal instead of Vulkan on Mac. You will need at least Mac OSX 10.13.
+WGPU uses Metal instead of Vulkan on Mac. You will need at least Mac OSX 10.13. The OS should come with Metal pre-installed so you should be good to go!
# :heart: Contributing
-We welcome contributions! See the contributing guide: https://github.com/kushalkolar/fastplotlib/blob/master/CONTRIBUTING.md
+We welcome contributions! See the contributing guide: https://github.com/kushalkolar/fastplotlib/blob/main/CONTRIBUTING.md
You can also take a look at our [**Roadmap for 2023**](https://github.com/kushalkolar/fastplotlib/issues/55) and [**Issues**](https://github.com/kushalkolar/fastplotlib/issues) for ideas on how to contribute!
diff --git a/docs/source/_static/logo.png b/docs/source/_static/logo.png
index 304fc88bc..1ec5ff88d 100644
--- a/docs/source/_static/logo.png
+++ b/docs/source/_static/logo.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7385f790683edc6c79fb6131e1649bd54d7fefd405cc8b6005ed86ea7dbb8fa6
-size 31759
+oid sha256:56c64203c133ce1cc3f4098ea76ad5ba6b5cff1a5437ce528311dd6caec7cced
+size 137447
diff --git a/docs/source/api/graphic_features/CmapFeature.rst b/docs/source/api/graphic_features/CmapFeature.rst
index 03e3330b7..7cc2f681f 100644
--- a/docs/source/api/graphic_features/CmapFeature.rst
+++ b/docs/source/api/graphic_features/CmapFeature.rst
@@ -21,6 +21,7 @@ Properties
:toctree: CmapFeature_api
CmapFeature.buffer
+ CmapFeature.name
CmapFeature.values
Methods
diff --git a/docs/source/api/graphic_features/HeatmapCmapFeature.rst b/docs/source/api/graphic_features/HeatmapCmapFeature.rst
index 77df37ab0..bac43c9b9 100644
--- a/docs/source/api/graphic_features/HeatmapCmapFeature.rst
+++ b/docs/source/api/graphic_features/HeatmapCmapFeature.rst
@@ -20,6 +20,7 @@ Properties
.. autosummary::
:toctree: HeatmapCmapFeature_api
+ HeatmapCmapFeature.name
HeatmapCmapFeature.vmax
HeatmapCmapFeature.vmin
@@ -32,4 +33,5 @@ Methods
HeatmapCmapFeature.block_events
HeatmapCmapFeature.clear_event_handlers
HeatmapCmapFeature.remove_event_handler
+ HeatmapCmapFeature.reset_vmin_vmax
diff --git a/docs/source/api/graphic_features/ImageCmapFeature.rst b/docs/source/api/graphic_features/ImageCmapFeature.rst
index d2174ff9a..ae65744c7 100644
--- a/docs/source/api/graphic_features/ImageCmapFeature.rst
+++ b/docs/source/api/graphic_features/ImageCmapFeature.rst
@@ -20,6 +20,7 @@ Properties
.. autosummary::
:toctree: ImageCmapFeature_api
+ ImageCmapFeature.name
ImageCmapFeature.vmax
ImageCmapFeature.vmin
@@ -32,4 +33,5 @@ Methods
ImageCmapFeature.block_events
ImageCmapFeature.clear_event_handlers
ImageCmapFeature.remove_event_handler
+ ImageCmapFeature.reset_vmin_vmax
diff --git a/docs/source/api/graphic_features/PointsSizesFeature.rst b/docs/source/api/graphic_features/PointsSizesFeature.rst
new file mode 100644
index 000000000..7915cb09d
--- /dev/null
+++ b/docs/source/api/graphic_features/PointsSizesFeature.rst
@@ -0,0 +1,34 @@
+.. _api.PointsSizesFeature:
+
+PointsSizesFeature
+******************
+
+==================
+PointsSizesFeature
+==================
+.. currentmodule:: fastplotlib.graphics._features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: PointsSizesFeature_api
+
+ PointsSizesFeature
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: PointsSizesFeature_api
+
+ PointsSizesFeature.buffer
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: PointsSizesFeature_api
+
+ PointsSizesFeature.add_event_handler
+ PointsSizesFeature.block_events
+ PointsSizesFeature.clear_event_handlers
+ PointsSizesFeature.remove_event_handler
+
diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst
index aff2aabda..1c4b33392 100644
--- a/docs/source/api/graphic_features/index.rst
+++ b/docs/source/api/graphic_features/index.rst
@@ -9,6 +9,7 @@ Graphic Features
ImageCmapFeature
HeatmapCmapFeature
PointsDataFeature
+ PointsSizesFeature
ImageDataFeature
HeatmapDataFeature
PresentFeature
diff --git a/docs/source/api/graphics/HeatmapGraphic.rst b/docs/source/api/graphics/HeatmapGraphic.rst
index 57466698a..6da6f6531 100644
--- a/docs/source/api/graphics/HeatmapGraphic.rst
+++ b/docs/source/api/graphics/HeatmapGraphic.rst
@@ -38,4 +38,6 @@ Methods
HeatmapGraphic.add_linear_region_selector
HeatmapGraphic.add_linear_selector
HeatmapGraphic.link
+ HeatmapGraphic.reset_feature
+ HeatmapGraphic.set_feature
diff --git a/docs/source/api/graphics/ImageGraphic.rst b/docs/source/api/graphics/ImageGraphic.rst
index 083c72abb..871462701 100644
--- a/docs/source/api/graphics/ImageGraphic.rst
+++ b/docs/source/api/graphics/ImageGraphic.rst
@@ -36,4 +36,6 @@ Methods
ImageGraphic.add_linear_region_selector
ImageGraphic.add_linear_selector
ImageGraphic.link
+ ImageGraphic.reset_feature
+ ImageGraphic.set_feature
diff --git a/docs/source/api/graphics/LineCollection.rst b/docs/source/api/graphics/LineCollection.rst
index 003ad2897..3f67feed9 100644
--- a/docs/source/api/graphics/LineCollection.rst
+++ b/docs/source/api/graphics/LineCollection.rst
@@ -41,4 +41,6 @@ Methods
LineCollection.add_linear_selector
LineCollection.link
LineCollection.remove_graphic
+ LineCollection.reset_feature
+ LineCollection.set_feature
diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst
index 75af2c4fe..4aae4bbee 100644
--- a/docs/source/api/graphics/LineGraphic.rst
+++ b/docs/source/api/graphics/LineGraphic.rst
@@ -36,4 +36,6 @@ Methods
LineGraphic.add_linear_region_selector
LineGraphic.add_linear_selector
LineGraphic.link
+ LineGraphic.reset_feature
+ LineGraphic.set_feature
diff --git a/docs/source/api/graphics/LineStack.rst b/docs/source/api/graphics/LineStack.rst
index 6104d0f74..36ae6808e 100644
--- a/docs/source/api/graphics/LineStack.rst
+++ b/docs/source/api/graphics/LineStack.rst
@@ -41,4 +41,6 @@ Methods
LineStack.add_linear_selector
LineStack.link
LineStack.remove_graphic
+ LineStack.reset_feature
+ LineStack.set_feature
diff --git a/docs/source/api/graphics/TextGraphic.rst b/docs/source/api/graphics/TextGraphic.rst
index c83c108f6..6290dcc2e 100644
--- a/docs/source/api/graphics/TextGraphic.rst
+++ b/docs/source/api/graphics/TextGraphic.rst
@@ -21,10 +21,15 @@ Properties
:toctree: TextGraphic_api
TextGraphic.children
+ TextGraphic.face_color
+ TextGraphic.outline_color
+ TextGraphic.outline_size
TextGraphic.position
TextGraphic.position_x
TextGraphic.position_y
TextGraphic.position_z
+ TextGraphic.text
+ TextGraphic.text_size
TextGraphic.visible
TextGraphic.world_object
@@ -33,10 +38,4 @@ Methods
.. autosummary::
:toctree: TextGraphic_api
- TextGraphic.update_face_color
- TextGraphic.update_outline_color
- TextGraphic.update_outline_size
- TextGraphic.update_position
- TextGraphic.update_size
- TextGraphic.update_text
diff --git a/docs/source/api/selectors/LinearRegionSelector.rst b/docs/source/api/selectors/LinearRegionSelector.rst
index e1824cfc8..ce0d8d9b6 100644
--- a/docs/source/api/selectors/LinearRegionSelector.rst
+++ b/docs/source/api/selectors/LinearRegionSelector.rst
@@ -21,6 +21,7 @@ Properties
:toctree: LinearRegionSelector_api
LinearRegionSelector.children
+ LinearRegionSelector.limits
LinearRegionSelector.position
LinearRegionSelector.position_x
LinearRegionSelector.position_y
@@ -33,7 +34,9 @@ Methods
.. autosummary::
:toctree: LinearRegionSelector_api
+ LinearRegionSelector.add_ipywidget_handler
LinearRegionSelector.get_selected_data
LinearRegionSelector.get_selected_index
LinearRegionSelector.get_selected_indices
+ LinearRegionSelector.make_ipywidget_slider
diff --git a/docs/source/api/selectors/LinearSelector.rst b/docs/source/api/selectors/LinearSelector.rst
index 2c30579f1..4056bcc46 100644
--- a/docs/source/api/selectors/LinearSelector.rst
+++ b/docs/source/api/selectors/LinearSelector.rst
@@ -21,6 +21,7 @@ Properties
:toctree: LinearSelector_api
LinearSelector.children
+ LinearSelector.limits
LinearSelector.position
LinearSelector.position_x
LinearSelector.position_y
@@ -33,6 +34,7 @@ Methods
.. autosummary::
:toctree: LinearSelector_api
+ LinearSelector.add_ipywidget_handler
LinearSelector.get_selected_data
LinearSelector.get_selected_index
LinearSelector.get_selected_indices
diff --git a/docs/source/api/selectors/PolygonSelector.rst b/docs/source/api/selectors/PolygonSelector.rst
new file mode 100644
index 000000000..aaa434dbf
--- /dev/null
+++ b/docs/source/api/selectors/PolygonSelector.rst
@@ -0,0 +1,40 @@
+.. _api.PolygonSelector:
+
+PolygonSelector
+***************
+
+===============
+PolygonSelector
+===============
+.. currentmodule:: fastplotlib
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: PolygonSelector_api
+
+ PolygonSelector
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: PolygonSelector_api
+
+ PolygonSelector.children
+ PolygonSelector.position
+ PolygonSelector.position_x
+ PolygonSelector.position_y
+ PolygonSelector.position_z
+ PolygonSelector.visible
+ PolygonSelector.world_object
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: PolygonSelector_api
+
+ PolygonSelector.get_selected_data
+ PolygonSelector.get_selected_index
+ PolygonSelector.get_selected_indices
+ PolygonSelector.get_vertices
+
diff --git a/docs/source/api/selectors/index.rst b/docs/source/api/selectors/index.rst
index 918944fd8..01c040728 100644
--- a/docs/source/api/selectors/index.rst
+++ b/docs/source/api/selectors/index.rst
@@ -6,4 +6,5 @@ Selectors
LinearSelector
LinearRegionSelector
+ PolygonSelector
Synchronizer
diff --git a/docs/source/api/widgets/ImageWidget.rst b/docs/source/api/widgets/ImageWidget.rst
index 62ec176ce..4e779f20b 100644
--- a/docs/source/api/widgets/ImageWidget.rst
+++ b/docs/source/api/widgets/ImageWidget.rst
@@ -20,6 +20,7 @@ Properties
.. autosummary::
:toctree: ImageWidget_api
+ ImageWidget.cmap
ImageWidget.current_index
ImageWidget.data
ImageWidget.dims_order
@@ -35,6 +36,7 @@ Methods
.. autosummary::
:toctree: ImageWidget_api
+ ImageWidget.close
ImageWidget.reset_vmin_vmax
ImageWidget.set_data
ImageWidget.show
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 77bd6be62..7b33a309e 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -57,6 +57,6 @@
html_theme_options = {
"source_repository": "https://github.com/kushalkolar/fastplotlib",
- "source_branch": "master",
+ "source_branch": "main",
"source_directory": "docs/",
}
diff --git a/docs/source/fastplotlib_banner.xcf b/docs/source/fastplotlib_banner.xcf
deleted file mode 100644
index e632ed0d7..000000000
Binary files a/docs/source/fastplotlib_banner.xcf and /dev/null differ
diff --git a/docs/source/fastplotlib_logo.svg b/docs/source/fastplotlib_logo.svg
new file mode 100644
index 000000000..eb2a738a8
--- /dev/null
+++ b/docs/source/fastplotlib_logo.svg
@@ -0,0 +1,2973 @@
+
+
+
+
diff --git a/docs/source/fastplotlib_logo.xcf b/docs/source/fastplotlib_logo.xcf
deleted file mode 100644
index f80b3e1b5..000000000
Binary files a/docs/source/fastplotlib_logo.xcf and /dev/null differ
diff --git a/examples/desktop/screenshots/gridplot.png b/examples/desktop/screenshots/gridplot.png
index dbc8cd5b2..f2cbb1e7a 100644
--- a/examples/desktop/screenshots/gridplot.png
+++ b/examples/desktop/screenshots/gridplot.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:220c8e26502fec371bab3d860f405d53c32f56ed848a2e27a45074f1bb943acd
-size 351714
+oid sha256:2705c69adab84f7740322b4a66ce33df00001dc7d51624becb8e88204113b028
+size 350236
diff --git a/examples/desktop/screenshots/image_cmap.png b/examples/desktop/screenshots/image_cmap.png
index e2b9e7016..cf3ae8ac0 100644
--- a/examples/desktop/screenshots/image_cmap.png
+++ b/examples/desktop/screenshots/image_cmap.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c2b1c0c3c2df2e897c43d0263d66d05663b7007c43bcb8bdaf1f3857daa65f79
-size 274669
+oid sha256:d9dcf05ca2953103b9960d9159ccb89dc257bf5e5c6d3906eeaaac9f71686439
+size 274882
diff --git a/examples/desktop/screenshots/image_rgb.png b/examples/desktop/screenshots/image_rgb.png
index 2ca90a7fb..5681017c8 100644
--- a/examples/desktop/screenshots/image_rgb.png
+++ b/examples/desktop/screenshots/image_rgb.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:558f81f9e62244b89add9b5a84e58e70219e6a6495c3c9a9ea90ef22e5922c33
-size 319491
+oid sha256:408e31db97278c584f4aaa0039099366fc8feb5693d15ab335205927d067c42a
+size 319585
diff --git a/examples/desktop/screenshots/image_rgbvminvmax.png b/examples/desktop/screenshots/image_rgbvminvmax.png
index e7d32475c..aea5fdf85 100644
--- a/examples/desktop/screenshots/image_rgbvminvmax.png
+++ b/examples/desktop/screenshots/image_rgbvminvmax.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:1d919e3a3b9fb87fd64072f818aad07637fb82c82f95ef188c8eb0362ded2baf
-size 44805
+oid sha256:d5dbe9a837b3503ca45eb83edbec7b1d7b6463093699af6b01b5303978af4b85
+size 44781
diff --git a/examples/desktop/screenshots/image_simple.png b/examples/desktop/screenshots/image_simple.png
index e89c0a6de..5ab073433 100644
--- a/examples/desktop/screenshots/image_simple.png
+++ b/examples/desktop/screenshots/image_simple.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0e46eba536bc4b6904f9df590b8c2e9b73226f22e37e920cf65e4c6720cd6634
-size 272624
+oid sha256:4aa397a120ed1b232c4d56ffd3547ea42c2874aa54bfbdbffebfd34129059ccd
+size 272355
diff --git a/examples/desktop/screenshots/image_vminvmax.png b/examples/desktop/screenshots/image_vminvmax.png
index e7d32475c..aea5fdf85 100644
--- a/examples/desktop/screenshots/image_vminvmax.png
+++ b/examples/desktop/screenshots/image_vminvmax.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:1d919e3a3b9fb87fd64072f818aad07637fb82c82f95ef188c8eb0362ded2baf
-size 44805
+oid sha256:d5dbe9a837b3503ca45eb83edbec7b1d7b6463093699af6b01b5303978af4b85
+size 44781
diff --git a/examples/desktop/screenshots/scatter_size.png b/examples/desktop/screenshots/scatter_size.png
index db637d270..1d0f91f9c 100644
--- a/examples/desktop/screenshots/scatter_size.png
+++ b/examples/desktop/screenshots/scatter_size.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:a4cefd4cf57e54e1ef7883edea54806dfde57939d0a395c5a7758124e41b8beb
-size 63485
+oid sha256:3eb05d8a18aada52a6ab02a0d3d030aab97510aace226cf3967e5c5c1cd3274d
+size 66044
diff --git a/examples/notebooks/image_widget.ipynb b/examples/notebooks/image_widget.ipynb
index 5b7de6145..d8f91c1be 100644
--- a/examples/notebooks/image_widget.ipynb
+++ b/examples/notebooks/image_widget.ipynb
@@ -44,7 +44,6 @@
"source": [
"iw = ImageWidget(\n",
" data=a,\n",
- " vmin_vmax_sliders=True,\n",
" cmap=\"viridis\"\n",
")"
]
@@ -52,10 +51,8 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "8264fd19-661f-4c50-bdb4-d3998ffd5ff5",
- "metadata": {
- "tags": []
- },
+ "id": "cc90aff2-4e56-4020-93d0-94e81f030f45",
+ "metadata": {},
"outputs": [],
"source": [
"iw.show()"
@@ -110,10 +107,9 @@
},
"outputs": [],
"source": [
- "iw = ImageWidget(\n",
+ "iw2 = ImageWidget(\n",
" data=a, \n",
" slider_dims=[\"t\"],\n",
- " vmin_vmax_sliders=True,\n",
" cmap=\"gnuplot2\"\n",
")"
]
@@ -127,7 +123,7 @@
},
"outputs": [],
"source": [
- "iw.show()"
+ "iw2.show()"
]
},
{
@@ -150,7 +146,7 @@
"outputs": [],
"source": [
"# must be in the form of {dim: (func, window_size)}\n",
- "iw.window_funcs = {\"t\": (np.mean, 13)}"
+ "iw2.window_funcs = {\"t\": (np.mean, 13)}"
]
},
{
@@ -163,7 +159,7 @@
"outputs": [],
"source": [
"# change the winow size\n",
- "iw.window_funcs[\"t\"].window_size = 23"
+ "iw2.window_funcs[\"t\"].window_size = 23"
]
},
{
@@ -176,7 +172,7 @@
"outputs": [],
"source": [
"# change the function\n",
- "iw.window_funcs[\"t\"].func = np.max"
+ "iw2.window_funcs[\"t\"].func = np.max"
]
},
{
@@ -189,7 +185,7 @@
"outputs": [],
"source": [
"# or set it again\n",
- "iw.window_funcs = {\"t\": (np.min, 11)}"
+ "iw2.window_funcs = {\"t\": (np.min, 11)}"
]
},
{
@@ -210,7 +206,7 @@
"outputs": [],
"source": [
"new_data = np.random.rand(500, 512, 512)\n",
- "iw.set_data(new_data=new_data)"
+ "iw2.set_data(new_data=new_data)"
]
},
{
@@ -243,11 +239,10 @@
},
"outputs": [],
"source": [
- "iw = ImageWidget(\n",
+ "iw3 = ImageWidget(\n",
" data=data, \n",
" slider_dims=[\"t\"], \n",
" # dims_order=\"txy\", # you can set this manually if dim order is not the usual\n",
- " vmin_vmax_sliders=True,\n",
" names=[\"zero\", \"one\", \"two\", \"three\"],\n",
" window_funcs={\"t\": (np.mean, 5)},\n",
" cmap=\"gnuplot2\", \n",
@@ -271,7 +266,7 @@
},
"outputs": [],
"source": [
- "iw.show()"
+ "iw3.show()"
]
},
{
@@ -291,7 +286,7 @@
},
"outputs": [],
"source": [
- "iw.gridplot[\"two\"]"
+ "iw3.gridplot[\"two\"]"
]
},
{
@@ -311,7 +306,7 @@
},
"outputs": [],
"source": [
- "iw.window_funcs[\"t\"].func = np.max"
+ "iw3.window_funcs[\"t\"].func = np.max"
]
},
{
@@ -334,11 +329,10 @@
"dims = (256, 256, 5, 100)\n",
"data = [np.random.rand(*dims) for i in range(4)]\n",
"\n",
- "iw = ImageWidget(\n",
+ "iw4 = ImageWidget(\n",
" data=data, \n",
" slider_dims=[\"t\", \"z\"], \n",
" dims_order=\"xyzt\", # example of how you can set this for non-standard orders\n",
- " vmin_vmax_sliders=True,\n",
" names=[\"zero\", \"one\", \"two\", \"three\"],\n",
" # window_funcs={\"t\": (np.mean, 5)}, # window functions can be slow when indexing multiple dims\n",
" cmap=\"gnuplot2\", \n",
@@ -354,7 +348,7 @@
},
"outputs": [],
"source": [
- "iw.show()"
+ "iw4.show()"
]
},
{
@@ -374,16 +368,8 @@
},
"outputs": [],
"source": [
- "iw.window_funcs = {\"t\": (np.mean, 11)}"
+ "iw4.window_funcs = {\"t\": (np.mean, 11)}"
]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "3090a7e2-558e-4975-82f4-6a67ae141900",
- "metadata": {},
- "outputs": [],
- "source": []
}
],
"metadata": {
diff --git a/examples/notebooks/linear_selector.ipynb b/examples/notebooks/linear_selector.ipynb
index 9382ffa63..0f81bc36b 100644
--- a/examples/notebooks/linear_selector.ipynb
+++ b/examples/notebooks/linear_selector.ipynb
@@ -57,7 +57,7 @@
"selector3.add_ipywidget_handler(ipywidget_slider3, step=0.1)\n",
"\n",
"plot.auto_scale()\n",
- "plot.show(vbox=[ipywidget_slider])"
+ "plot.show(add_widgets=[ipywidget_slider])"
]
},
{
diff --git a/examples/notebooks/simple.ipynb b/examples/notebooks/simple.ipynb
index 753de5a98..681980d39 100644
--- a/examples/notebooks/simple.ipynb
+++ b/examples/notebooks/simple.ipynb
@@ -110,19 +110,7 @@
"source": [
"**Use the handle on the bottom right corner of the _canvas_ to resize it. You can also pan and zoom using your mouse!**\n",
"\n",
- "By default the origin is on the bottom left, you can click the flip button to flip the y-axis, or use `plot.camera.local.scale_y *= -1`"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "58c1dc0b-9bf0-4ad5-8579-7c10396fc6bc",
- "metadata": {
- "tags": []
- },
- "outputs": [],
- "source": [
- "plot.camera.local.scale_y *= -1"
+ "If an image is in the plot the origin is in the top left. You can click the flip button to flip the y-axis direction, or use `plot.camera.local.scale_y *= -1`"
]
},
{
@@ -450,8 +438,8 @@
"metadata": {},
"outputs": [],
"source": [
- "# close the sidecar\n",
- "plot.sidecar.close()"
+ "# close the plot\n",
+ "plot.close()"
]
},
{
@@ -481,18 +469,6 @@
"plot_rgb.show()"
]
},
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "71eae361-3bbf-4d1f-a903-3615d35b557b",
- "metadata": {
- "tags": []
- },
- "outputs": [],
- "source": [
- "plot_rgb.camera.local.scale_y *= -1"
- ]
- },
{
"cell_type": "markdown",
"id": "7fc66377-00e8-4f32-9671-9cf63f74529f",
@@ -533,8 +509,8 @@
"metadata": {},
"outputs": [],
"source": [
- "# close sidecar\n",
- "plot_rgb.sidecar.close()"
+ "# close plot\n",
+ "plot_rgb.close()"
]
},
{
@@ -632,16 +608,6 @@
"### You can also use `ipywidgets.VBox` and `HBox` to stack plots. See the `gridplot` notebooks for a proper gridplot interface for more automated subplotting"
]
},
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "ef9743b3-5f81-4b79-9502-fa5fca08e56d",
- "metadata": {},
- "outputs": [],
- "source": [
- "VBox([plot_v.show(sidecar=False), plot_sync.show(sidecar=False)])"
- ]
- },
{
"cell_type": "code",
"execution_count": null,
@@ -649,7 +615,7 @@
"metadata": {},
"outputs": [],
"source": [
- "HBox([plot_v.show(sidecar=False), plot_sync.show(sidecar=False)])"
+ "HBox([plot_v.show(), plot_sync.show()])"
]
},
{
@@ -659,8 +625,9 @@
"metadata": {},
"outputs": [],
"source": [
- "# close sidecar\n",
- "plot_v.sidecar.close()"
+ "# close plot\n",
+ "plot_v.close()\n",
+ "plot_sync.close()"
]
},
{
@@ -762,19 +729,7 @@
"\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!\n",
"\n",
- "You can also click the **`1:1`** button to toggle this."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "2695f023-f6ce-4e26-8f96-4fbed5510d1d",
- "metadata": {
- "tags": []
- },
- "outputs": [],
- "source": [
- "plot_l.camera.maintain_aspect = False"
+ "You can also click the **`1:1`** button to toggle this, or use `plot.camera.maintain_aspect`"
]
},
{
@@ -877,7 +832,7 @@
"id": "c29f81f9-601b-49f4-b20c-575c56e58026",
"metadata": {},
"source": [
- "## Graphic _data_ is itself also indexable"
+ "## Graphic _data_ is also indexable"
]
},
{
@@ -1027,8 +982,8 @@
"metadata": {},
"outputs": [],
"source": [
- "# close sidecar\n",
- "plot_l.sidecar.close()"
+ "# close plot\n",
+ "plot_l.close()"
]
},
{
@@ -1099,8 +1054,8 @@
},
"outputs": [],
"source": [
- "# close sidecar\n",
- "plot_l3d.sidecar.close()"
+ "# close plot\n",
+ "plot_l3d.close()"
]
},
{
@@ -1239,31 +1194,8 @@
"metadata": {},
"outputs": [],
"source": [
- "# close sidecar\n",
- "plot_s.sidecar.close()"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "d9e554de-c436-4684-a46a-ce8a33d409ac",
- "metadata": {},
- "source": [
- "### You can combine VBox and HBox to create more complex layouts\n",
- "\n",
- "This just plots everything above in a single nb output"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "f404a5ea-633b-43f5-87d1-237017bbca2a",
- "metadata": {},
- "outputs": [],
- "source": [
- "row1 = HBox([plot.show(sidecar=False), plot_v.show(sidecar=False), plot_sync.show(sidecar=False)])\n",
- "row2 = HBox([plot_l.show(sidecar=False), plot_l3d.show(sidecar=False), plot_s.show(sidecar=False)])\n",
- "\n",
- "VBox([row1, row2])"
+ "# close plot\n",
+ "plot_s.close()"
]
},
{
diff --git a/examples/qt/imagewidget.py b/examples/qt/imagewidget.py
new file mode 100644
index 000000000..ab1a055f1
--- /dev/null
+++ b/examples/qt/imagewidget.py
@@ -0,0 +1,29 @@
+"""
+Use ImageWidget to display one or multiple image sequences
+"""
+import numpy as np
+from PyQt6 import QtWidgets
+import fastplotlib as fpl
+
+# Qt app MUST be instantiated before creating any fpl objects, or any other Qt objects
+app = QtWidgets.QApplication([])
+
+images = np.random.rand(100, 512, 512)
+
+# create image widget, force Qt canvas so it doesn't pick glfw
+iw = fpl.ImageWidget(images, grid_plot_kwargs={"canvas": "qt"})
+iw.show()
+iw.widget.resize(800, 800)
+
+# another image widget with multiple images
+images_list = [np.random.rand(100, 512, 512) for i in range(9)]
+
+iw_mult = fpl.ImageWidget(
+ images_list,
+ grid_plot_kwargs={"canvas": "qt"},
+ cmap="viridis"
+)
+iw_mult.show()
+iw_mult.widget.resize(800, 800)
+
+app.exec()
diff --git a/examples/qt/minimal.py b/examples/qt/minimal.py
new file mode 100644
index 000000000..e4e5f6c2f
--- /dev/null
+++ b/examples/qt/minimal.py
@@ -0,0 +1,35 @@
+"""
+Minimal PyQt example that displays an image. Press "r" key to autoscale
+"""
+from PyQt6 import QtWidgets
+import fastplotlib as fpl
+import imageio.v3 as iio
+
+# Qt app MUST be instantiated before creating any fpl objects, or any other Qt objects
+app = QtWidgets.QApplication([])
+
+img = iio.imread("imageio:astronaut.png")
+
+# force qt canvas, wgpu will sometimes pick glfw by default even if Qt is present
+plot = fpl.Plot(canvas="qt")
+
+plot.add_image(img)
+plot.camera.local.scale *= -1
+
+# must call plot.show() to start rendering loop
+plot.show()
+
+# set QWidget initial size from image width and height
+plot.canvas.resize(*img.shape[:2])
+
+
+def autoscale(ev):
+ if ev.key == "r":
+ plot.auto_scale()
+
+
+# useful if you pan/zoom away from the image
+plot.renderer.add_event_handler(autoscale, "key_down")
+
+# execute Qt app
+app.exec()
diff --git a/examples/qt/video.py b/examples/qt/video.py
new file mode 100644
index 000000000..9fd77a999
--- /dev/null
+++ b/examples/qt/video.py
@@ -0,0 +1,57 @@
+"""
+Use a simple Plot to display a video frame that can be updated using a QSlider
+"""
+from PyQt6 import QtWidgets, QtCore
+import fastplotlib as fpl
+import imageio.v3 as iio
+
+# Qt app MUST be instantiated before creating any fpl objects, or any other Qt objects
+app = QtWidgets.QApplication([])
+
+video = iio.imread("imageio:cockatoo.mp4")
+
+# force qt canvas, wgpu will sometimes pick glfw by default even if Qt is present
+plot = fpl.Plot(canvas="qt")
+
+plot.add_image(video[0], name="video")
+plot.camera.local.scale *= -1
+
+
+def update_frame(ix):
+ plot["video"].data = video[ix]
+ # you can also do plot.graphics[0].data = video[ix]
+
+
+# create a QMainWindow, set the plot canvas as the main widget
+# The canvas does not have to be in a QMainWindow and it does
+# not have to be the central widget, it will work like any QWidget
+main_window = QtWidgets.QMainWindow()
+main_window.setCentralWidget(plot.canvas)
+
+# Create a QSlider for updating frames
+slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
+slider.setMaximum(video.shape[0] - 1)
+slider.setMinimum(0)
+slider.valueChanged.connect(update_frame)
+
+# put slider in a dock
+dock = QtWidgets.QDockWidget()
+dock.setWidget(slider)
+
+# put the dock in the main window
+main_window.addDockWidget(
+ QtCore.Qt.DockWidgetArea.BottomDockWidgetArea,
+ dock
+)
+
+# calling plot.show() is required to start the rendering loop
+plot.show()
+
+# set window size from width and height of video
+main_window.resize(video.shape[2], video.shape[1])
+
+# show the main window
+main_window.show()
+
+# execute Qt app
+app.exec()
diff --git a/fastplotlib/VERSION b/fastplotlib/VERSION
index 9a1d5d93c..2952df5a6 100644
--- a/fastplotlib/VERSION
+++ b/fastplotlib/VERSION
@@ -1 +1 @@
-0.1.0.a13
+0.1.0.a14
diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py
index d145821e4..a0b4881fb 100644
--- a/fastplotlib/graphics/_base.py
+++ b/fastplotlib/graphics/_base.py
@@ -158,6 +158,15 @@ def __eq__(self, other):
return False
+ def _cleanup(self):
+ """
+ Cleans up the graphic in preparation for __del__(), such as removing event handlers from
+ plot renderer, feature event handlers, etc.
+
+ Optionally implemented in subclasses
+ """
+ pass
+
def __del__(self):
del WORLD_OBJECTS[self.loc]
diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/_features/_base.py
index 5616eec19..99ebbf436 100644
--- a/fastplotlib/graphics/_features/_base.py
+++ b/fastplotlib/graphics/_features/_base.py
@@ -191,6 +191,10 @@ def _call_event_handlers(self, event_data: FeatureEvent):
)
func()
+ @abstractmethod
+ def __repr__(self) -> str:
+ pass
+
def cleanup_slice(key: Union[int, slice], upper_bound) -> Union[slice, int]:
"""
diff --git a/fastplotlib/graphics/_features/_colors.py b/fastplotlib/graphics/_features/_colors.py
index 256a5d65f..85014155d 100644
--- a/fastplotlib/graphics/_features/_colors.py
+++ b/fastplotlib/graphics/_features/_colors.py
@@ -234,6 +234,10 @@ def _feature_changed(self, key, new_data):
self._call_event_handlers(event_data)
+ def __repr__(self) -> str:
+ s = f"ColorsFeature for {self._parent}. Call `.colors()` to get values."
+ return s
+
class CmapFeature(ColorFeature):
"""
@@ -270,6 +274,10 @@ def __setitem__(self, key, cmap_name):
self._cmap_name = cmap_name
super(CmapFeature, self).__setitem__(key, colors)
+ @property
+ def name(self) -> str:
+ return self._cmap_name
+
@property
def values(self) -> np.ndarray:
return self._cmap_values
@@ -287,10 +295,18 @@ def values(self, values: np.ndarray):
super(CmapFeature, self).__setitem__(slice(None), colors)
+ def __repr__(self) -> str:
+ s = f"CmapFeature for {self._parent}, to get name or values: `.cmap.name`, `.cmap.values`"
+ return s
+
class ImageCmapFeature(GraphicFeature):
"""
- Colormap for :class:`ImageGraphic`
+ Colormap for :class:`ImageGraphic`.
+
+ .cmap() returns the Texture buffer for the cmap.
+
+ .cmap.name returns the cmap name as a str.
**event pick info:**
@@ -309,7 +325,7 @@ class ImageCmapFeature(GraphicFeature):
def __init__(self, parent, cmap: str):
cmap_texture_view = get_cmap_texture(cmap)
super(ImageCmapFeature, self).__init__(parent, cmap_texture_view)
- self.name = cmap
+ self._name = cmap
def _set(self, cmap_name: str):
if self._parent.data().ndim > 2:
@@ -317,9 +333,13 @@ def _set(self, cmap_name: str):
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._name = cmap_name
- self._feature_changed(key=None, new_data=self.name)
+ self._feature_changed(key=None, new_data=self._name)
+
+ @property
+ def name(self) -> str:
+ return self._name
@property
def vmin(self) -> float:
@@ -359,7 +379,7 @@ def _feature_changed(self, key, new_data):
pick_info = {
"index": None,
"world_object": self._parent.world_object,
- "name": self.name,
+ "name": self._name,
"vmin": self.vmin,
"vmax": self.vmax,
}
@@ -368,6 +388,10 @@ def _feature_changed(self, key, new_data):
self._call_event_handlers(event_data)
+ def __repr__(self) -> str:
+ s = f"ImageCmapFeature for {self._parent}. Use `.cmap.name` to get str name of cmap."
+ return s
+
class HeatmapCmapFeature(ImageCmapFeature):
"""
diff --git a/fastplotlib/graphics/_features/_data.py b/fastplotlib/graphics/_features/_data.py
index 0d22299ed..23e80b470 100644
--- a/fastplotlib/graphics/_features/_data.py
+++ b/fastplotlib/graphics/_features/_data.py
@@ -100,6 +100,10 @@ def _feature_changed(self, key, new_data):
self._call_event_handlers(event_data)
+ def __repr__(self) -> str:
+ s = f"PointsDataFeature for {self._parent}, call `.data()` to get values"
+ return s
+
class ImageDataFeature(GraphicFeatureIndexable):
"""
@@ -164,6 +168,10 @@ def _feature_changed(self, key, new_data):
self._call_event_handlers(event_data)
+ def __repr__(self) -> str:
+ s = f"ImageDataFeature for {self._parent}, call `.data()` to get values"
+ return s
+
class HeatmapDataFeature(ImageDataFeature):
@property
diff --git a/fastplotlib/graphics/_features/_present.py b/fastplotlib/graphics/_features/_present.py
index b0bb627c5..6fbf93b48 100644
--- a/fastplotlib/graphics/_features/_present.py
+++ b/fastplotlib/graphics/_features/_present.py
@@ -66,3 +66,7 @@ def _feature_changed(self, key, new_data):
event_data = FeatureEvent(type="present", pick_info=pick_info)
self._call_event_handlers(event_data)
+
+ def __repr__(self) -> str:
+ s = f"PresentFeature for {self._parent}, call `.present()` to get values"
+ return s
diff --git a/fastplotlib/graphics/_features/_selection_features.py b/fastplotlib/graphics/_features/_selection_features.py
index 5f161562f..9a2696f7c 100644
--- a/fastplotlib/graphics/_features/_selection_features.py
+++ b/fastplotlib/graphics/_features/_selection_features.py
@@ -191,6 +191,10 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any):
self._call_event_handlers(event_data)
+ def __repr__(self) -> str:
+ s = f"LinearSelectionFeature for {self._parent}"
+ return s
+
class LinearRegionSelectionFeature(GraphicFeature):
"""
@@ -313,3 +317,7 @@ def _feature_changed(self, key: Union[int, slice, Tuple[slice]], new_data: Any):
event_data = FeatureEvent(type="selection", pick_info=pick_info)
self._call_event_handlers(event_data)
+
+ def __repr__(self) -> str:
+ s = f"LinearRegionSelectionFeature for {self._parent}"
+ return s
diff --git a/fastplotlib/graphics/_features/_sizes.py b/fastplotlib/graphics/_features/_sizes.py
index 377052918..e951064e4 100644
--- a/fastplotlib/graphics/_features/_sizes.py
+++ b/fastplotlib/graphics/_features/_sizes.py
@@ -105,4 +105,8 @@ def _feature_changed(self, key, new_data):
event_data = FeatureEvent(type="sizes", pick_info=pick_info)
- self._call_event_handlers(event_data)
\ No newline at end of file
+ self._call_event_handlers(event_data)
+
+ def __repr__(self) -> str:
+ s = f"PointsSizesFeature for {self._parent}, call `.sizes()` to get values"
+ return s
diff --git a/fastplotlib/graphics/_features/_thickness.py b/fastplotlib/graphics/_features/_thickness.py
index cae3828b7..f9190f0b1 100644
--- a/fastplotlib/graphics/_features/_thickness.py
+++ b/fastplotlib/graphics/_features/_thickness.py
@@ -40,3 +40,7 @@ def _feature_changed(self, key, new_data):
event_data = FeatureEvent(type="thickness", pick_info=pick_info)
self._call_event_handlers(event_data)
+
+ def __repr__(self) -> str:
+ s = f"ThicknessFeature for {self._parent}, call `.thickness()` to get value"
+ return s
diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py
index 121134de5..12ac9e41d 100644
--- a/fastplotlib/graphics/image.py
+++ b/fastplotlib/graphics/image.py
@@ -149,7 +149,7 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs):
if axis == "x":
offset = self.position_x
# x limits, number of columns
- limits = (offset, data.shape[1])
+ limits = (offset, data.shape[1] - 1)
# size is number of rows + padding
# used by LinearRegionSelector but not LinearSelector
@@ -169,7 +169,7 @@ def _get_linear_selector_init_args(self, padding: float, **kwargs):
else:
offset = self.position_y
# y limits
- limits = (offset, data.shape[0])
+ limits = (offset, data.shape[0] - 1)
# width + padding
# used by LinearRegionSelector but not LinearSelector
diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py
index 062c5ba91..38597a830 100644
--- a/fastplotlib/graphics/line_collection.py
+++ b/fastplotlib/graphics/line_collection.py
@@ -243,6 +243,8 @@ def cmap_values(self, values: Union[np.ndarray, list]):
for i, g in enumerate(self.graphics):
g.colors = colors[i]
+ self._cmap_values = values
+
def add_linear_selector(
self, selection: int = None, padding: float = 50, **kwargs
) -> LinearSelector:
diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py
index 2b1a2aa0d..e892ca32d 100644
--- a/fastplotlib/graphics/selectors/_base_selector.py
+++ b/fastplotlib/graphics/selectors/_base_selector.py
@@ -6,6 +6,8 @@
from pygfx import WorldObject, Line, Mesh, Points
+from .._base import Graphic
+
@dataclass
class MoveInfo:
@@ -31,7 +33,7 @@ class MoveInfo:
# Selector base class
-class BaseSelector:
+class BaseSelector(Graphic):
feature_events = ("selection",)
def __init__(
@@ -42,6 +44,7 @@ def __init__(
hover_responsive: Tuple[WorldObject, ...] = None,
arrow_keys_modifier: str = None,
axis: str = None,
+ name: str = None
):
if edges is None:
edges = tuple()
@@ -83,12 +86,16 @@ def __init__(
# sets to `True` on "pointer_down", sets to `False` on "pointer_up"
self._moving = False #: indicates if the selector is currently being moved
+ self._initial_controller_state: bool = None
+
# used to disable fill area events if the edge is being actively hovered
# otherwise annoying and requires too much accuracy to move just an edge
self._edge_hovered: bool = False
self._pygfx_event = None
+ Graphic.__init__(self, name=name)
+
def get_selected_index(self):
"""Not implemented for this selector"""
raise NotImplementedError
@@ -196,6 +203,8 @@ def _move_start(self, event_source: WorldObject, ev):
self._move_info = MoveInfo(last_position=last_position, source=event_source)
self._moving = True
+ self._initial_controller_state = self._plot_area.controller.enabled
+
def _move(self, ev):
"""
Called on pointer move events
@@ -230,7 +239,9 @@ def _move(self, ev):
# update last position
self._move_info.last_position = world_pos
- self._plot_area.controller.enabled = True
+ # restore the initial controller state
+ # if it was disabled, keep it disabled
+ self._plot_area.controller.enabled = self._initial_controller_state
def _move_graphic(self, delta: np.ndarray):
raise NotImplementedError("Must be implemented in subclass")
@@ -238,7 +249,10 @@ def _move_graphic(self, delta: np.ndarray):
def _move_end(self, ev):
self._move_info = None
self._moving = False
- self._plot_area.controller.enabled = True
+
+ # restore the initial controller state
+ # if it was disabled, keep it disabled
+ self._plot_area.controller.enabled = self._initial_controller_state
def _move_to_pointer(self, ev):
"""
@@ -341,12 +355,10 @@ def _key_up(self, ev):
self._move_info = None
- def __del__(self):
- # clear wo event handlers
- for wo in self._world_objects:
- wo._event_handlers.clear()
-
- # remove renderer event handlers
+ def _cleanup(self):
+ """
+ Cleanup plot renderer event handlers etc.
+ """
self._plot_area.renderer.remove_event_handler(self._move, "pointer_move")
self._plot_area.renderer.remove_event_handler(self._move_end, "pointer_up")
self._plot_area.renderer.remove_event_handler(self._move_to_pointer, "click")
@@ -357,6 +369,10 @@ def __del__(self):
# remove animation func
self._plot_area.remove_animation(self._key_hold)
+ # clear wo event handlers
+ for wo in self._world_objects:
+ wo._event_handlers.clear()
+
if hasattr(self, "feature_events"):
feature_names = getattr(self, "feature_events")
for n in feature_names:
diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py
index c00bebcc7..16ccab1b4 100644
--- a/fastplotlib/graphics/selectors/_linear.py
+++ b/fastplotlib/graphics/selectors/_linear.py
@@ -18,7 +18,7 @@
from ._base_selector import BaseSelector
-class LinearSelector(Graphic, BaseSelector):
+class LinearSelector(BaseSelector):
@property
def limits(self) -> Tuple[float, float]:
return self._limits
@@ -117,9 +117,6 @@ def __init__(
line_data = line_data.astype(np.float32)
- # init Graphic
- Graphic.__init__(self, name=name)
-
if thickness < 1.1:
material = pygfx.LineThinMaterial
else:
@@ -172,6 +169,7 @@ def __init__(
hover_responsive=(line_inner, self.line_outer),
arrow_keys_modifier=arrow_keys_modifier,
axis=axis,
+ name=name,
)
def _setup_ipywidget_slider(self, widget):
@@ -189,8 +187,6 @@ def _setup_ipywidget_slider(self, widget):
# user changes linear selection -> widget changes
self.selection.add_event_handler(self._update_ipywidgets)
- self._plot_area.renderer.add_event_handler(self._set_slider_layout, "resize")
-
self._handled_widgets.append(widget)
def _update_ipywidgets(self, ev):
@@ -214,6 +210,12 @@ def _ipywidget_callback(self, change):
self.selection = change["new"]
+ def _add_plot_area_hook(self, plot_area):
+ super()._add_plot_area_hook(plot_area=plot_area)
+
+ # resize the slider widgets when the canvas is resized
+ self._plot_area.renderer.add_event_handler(self._set_slider_layout, "resize")
+
def _set_slider_layout(self, *args):
w, h = self._plot_area.renderer.logical_size
@@ -375,3 +377,11 @@ def _move_graphic(self, delta: np.ndarray):
self.selection = self.selection() + delta[0]
else:
self.selection = self.selection() + delta[1]
+
+ def _cleanup(self):
+ super()._cleanup()
+
+ for widget in self._handled_widgets:
+ widget.unobserve(self._ipywidget_callback, "value")
+
+ self._plot_area.renderer.remove_event_handler(self._set_slider_layout, "resize")
diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py
index 8579ad6d0..2a7547d5b 100644
--- a/fastplotlib/graphics/selectors/_linear_region.py
+++ b/fastplotlib/graphics/selectors/_linear_region.py
@@ -17,7 +17,7 @@
from .._features._selection_features import LinearRegionSelectionFeature
-class LinearRegionSelector(Graphic, BaseSelector):
+class LinearRegionSelector(BaseSelector):
@property
def limits(self) -> Tuple[float, float]:
return self._limits
@@ -44,6 +44,7 @@ def __init__(
resizable: bool = True,
fill_color=(0, 0, 0.35),
edge_color=(0.8, 0.8, 0),
+ edge_thickness: int = 3,
arrow_keys_modifier: str = "Shift",
name: str = None,
):
@@ -56,6 +57,9 @@ def __init__(
Holding the right mouse button while dragging an edge will force the entire region selector to move. This is
a when using transparent fill areas due to ``pygfx`` picking limitations.
+ **Note:** Events get very weird if the values of bounds, limits and origin are close to zero. If you need
+ a linear selector with small data, we recommend scaling the data and then using the selector.
+
Parameters
----------
bounds: (int, int)
@@ -127,8 +131,6 @@ def __init__(
# f"{limits[0]} != {origin[1]} != {bounds[0]}"
# )
- Graphic.__init__(self, name=name)
-
self.parent = parent
# world object for this will be a group
@@ -170,7 +172,7 @@ def __init__(
left_line = pygfx.Line(
pygfx.Geometry(positions=left_line_data),
- pygfx.LineMaterial(thickness=3, color=edge_color),
+ pygfx.LineMaterial(thickness=edge_thickness, color=edge_color),
)
# position data for the right edge line
@@ -183,7 +185,7 @@ def __init__(
right_line = pygfx.Line(
pygfx.Geometry(positions=right_line_data),
- pygfx.LineMaterial(thickness=3, color=edge_color),
+ pygfx.LineMaterial(thickness=edge_thickness, color=edge_color),
)
self.edges: Tuple[pygfx.Line, pygfx.Line] = (left_line, right_line)
@@ -199,7 +201,7 @@ def __init__(
bottom_line = pygfx.Line(
pygfx.Geometry(positions=bottom_line_data),
- pygfx.LineMaterial(thickness=3, color=edge_color),
+ pygfx.LineMaterial(thickness=edge_thickness, color=edge_color),
)
# position data for the right edge line
@@ -212,7 +214,7 @@ def __init__(
top_line = pygfx.Line(
pygfx.Geometry(positions=top_line_data),
- pygfx.LineMaterial(thickness=3, color=edge_color),
+ pygfx.LineMaterial(thickness=edge_thickness, color=edge_color),
)
self.edges: Tuple[pygfx.Line, pygfx.Line] = (bottom_line, top_line)
@@ -241,6 +243,7 @@ def __init__(
hover_responsive=self.edges,
arrow_keys_modifier=arrow_keys_modifier,
axis=axis,
+ name=name
)
def get_selected_data(
diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py
index 244ad7b66..b347da0f4 100644
--- a/fastplotlib/graphics/selectors/_polygon.py
+++ b/fastplotlib/graphics/selectors/_polygon.py
@@ -8,7 +8,7 @@
from .._base import Graphic
-class PolygonSelector(Graphic, BaseSelector):
+class PolygonSelector(BaseSelector):
def __init__(
self,
edge_color="magenta",
@@ -16,7 +16,6 @@ def __init__(
parent: Graphic = None,
name: str = None,
):
- Graphic.__init__(self, name=name)
self.parent = parent
@@ -31,6 +30,8 @@ def __init__(
self._current_mode = None
+ BaseSelector.__init__(self, name=name)
+
def get_vertices(self) -> np.ndarray:
"""Get the vertices for the polygon"""
vertices = list()
diff --git a/fastplotlib/graphics/selectors/_sync.py b/fastplotlib/graphics/selectors/_sync.py
index 8ba7dfd97..9414a2e20 100644
--- a/fastplotlib/graphics/selectors/_sync.py
+++ b/fastplotlib/graphics/selectors/_sync.py
@@ -39,8 +39,12 @@ def add(self, selector):
def remove(self, selector):
"""remove a selector"""
- self._selectors.remove(selector)
selector.selection.remove_event_handler(self._handle_event)
+ self._selectors.remove(selector)
+
+ def clear(self):
+ for i in range(len(self.selectors)):
+ self.remove(self.selectors[0])
def _handle_event(self, ev):
if self.block_event:
@@ -81,7 +85,4 @@ def _move_selectors(self, source, delta):
s._move_graphic(delta)
def __del__(self):
- for s in self.selectors:
- self.remove(s)
-
- self.selectors.clear()
+ self.clear()
diff --git a/fastplotlib/graphics/text.py b/fastplotlib/graphics/text.py
index 2648e2fa6..a159d9560 100644
--- a/fastplotlib/graphics/text.py
+++ b/fastplotlib/graphics/text.py
@@ -10,11 +10,14 @@ def __init__(
self,
text: str,
position: Tuple[int] = (0, 0, 0),
- size: int = 10,
+ size: int = 14,
face_color: Union[str, np.ndarray] = "w",
outline_color: Union[str, np.ndarray] = "w",
outline_thickness=0,
- name: str = None,
+ screen_space: bool = True,
+ anchor: str = "middle-center",
+ *args,
+ **kwargs
):
"""
Create a text Graphic
@@ -39,15 +42,30 @@ def __init__(
outline_thickness: int, default 0
text outline thickness
+ screen_space: bool = True
+ whether the text is rendered in screen space, in contrast to world space
+
name: str, optional
name of graphic, passed to Graphic
+ anchor: str, default "middle-center"
+ position of the origin of the text
+ a string representing the vertical and horizontal anchors, separated by a dash
+
+ * Vertical values: "top", "middle", "baseline", "bottom"
+ * Horizontal values: "left", "center", "right"
"""
+ super(TextGraphic, self).__init__(*args, **kwargs)
- super(TextGraphic, self).__init__(name=name)
+ self._text = text
world_object = pygfx.Text(
- pygfx.TextGeometry(text=str(text), font_size=size, screen_space=False),
+ pygfx.TextGeometry(
+ text=str(text),
+ font_size=size,
+ screen_space=screen_space,
+ anchor=anchor,
+ ),
pygfx.TextMaterial(
color=face_color,
outline_color=outline_color,
@@ -59,22 +77,71 @@ def __init__(
self.world_object.position = position
- self.name = None
+ @property
+ def text(self):
+ """Returns the text of this graphic."""
+ return self._text
+
+ @text.setter
+ def text(self, text: str):
+ """Set the text of this graphic."""
+ if not isinstance(text, str):
+ raise ValueError("Text must be of type str.")
- def update_text(self, text: str):
- self.world_object.geometry.set_text(text)
+ self._text = text
+ self.world_object.geometry.set_text(self._text)
+
+ @property
+ def text_size(self):
+ """Returns the text size of this graphic."""
+ return self.world_object.geometry.font_size
+
+ @text_size.setter
+ def text_size(self, size: Union[int, float]):
+ """Set the text size of this graphic."""
+ if not (isinstance(size, int) or isinstance(size, float)):
+ raise ValueError("Text size must be of type int or float")
- def update_size(self, size: int):
self.world_object.geometry.font_size = size
- def update_face_color(self, color: Union[str, np.ndarray]):
+ @property
+ def face_color(self):
+ """Returns the face color of this graphic."""
+ return self.world_object.material.color
+
+ @face_color.setter
+ def face_color(self, color: Union[str, np.ndarray]):
+ """Set the face color of this graphic."""
+ if not (isinstance(color, str) or isinstance(color, np.ndarray)):
+ raise ValueError("Face color must be of type str or np.ndarray")
+
+ color = pygfx.Color(color)
+
self.world_object.material.color = color
- def update_outline_size(self, size: int):
+ @property
+ def outline_size(self):
+ """Returns the outline size of this graphic."""
+ return self.world_object.material.outline_thickness
+
+ @outline_size.setter
+ def outline_size(self, size: Union[int, float]):
+ """Set the outline size of this text graphic."""
+ if not (isinstance(size, int) or isinstance(size, float)):
+ raise ValueError("Outline size must be of type int or float")
+
self.world_object.material.outline_thickness = size
- def update_outline_color(self, color: Union[str, np.ndarray]):
+ @property
+ def outline_color(self):
+ """Returns the outline color of this graphic."""
+ return self.world_object.material.outline_color
+
+ @outline_color.setter
+ def outline_color(self, color: Union[str, np.ndarray]):
+ """Set the outline color of this graphic"""
+ if not (isinstance(color, str) or isinstance(color, np.ndarray)):
+ raise ValueError("Outline color must be of type str or np.ndarray")
+
self.world_object.material.outline_color = color
- def update_position(self, pos: Tuple[int, int, int]):
- self.world_object.position.set(*pos)
diff --git a/fastplotlib/layouts/_frame/__init__.py b/fastplotlib/layouts/_frame/__init__.py
new file mode 100644
index 000000000..c34884022
--- /dev/null
+++ b/fastplotlib/layouts/_frame/__init__.py
@@ -0,0 +1 @@
+from ._frame import Frame
diff --git a/fastplotlib/layouts/_frame/_frame.py b/fastplotlib/layouts/_frame/_frame.py
new file mode 100644
index 000000000..abd79759e
--- /dev/null
+++ b/fastplotlib/layouts/_frame/_frame.py
@@ -0,0 +1,184 @@
+import os
+
+from ._toolbar import ToolBar
+
+from ...graphics import ImageGraphic
+
+from .._utils import CANVAS_OPTIONS_AVAILABLE
+
+
+class UnavailableOutputContext:
+ # called when a requested output context is not available
+ # ex: if trying to force jupyter_rfb canvas but jupyter_rfb is not installed
+ def __init__(self, context_name, msg):
+ self.context_name = context_name
+ self.msg = msg
+
+ def __call__(self, *args, **kwargs):
+ raise ModuleNotFoundError(
+ f"The following output context is not available: {self.context_name}\n{self.msg}"
+ )
+
+
+# TODO: potentially put all output context and toolbars in their own module and have this determination done at import
+if CANVAS_OPTIONS_AVAILABLE["jupyter"]:
+ from ._jupyter_output import JupyterOutputContext
+else:
+ JupyterOutputContext = UnavailableOutputContext(
+ "Jupyter",
+ "You must install fastplotlib using the `'notebook'` option to use this context:\n"
+ 'pip install "fastplotlib[notebook]"'
+ )
+
+if CANVAS_OPTIONS_AVAILABLE["qt"]:
+ from ._qt_output import QOutputContext
+else:
+ QtOutput = UnavailableOutputContext(
+ "Qt",
+ "You must install `PyQt6` to use this output context"
+ )
+
+
+class Frame:
+ """
+ Mixin class for Plot and GridPlot that "frames" the plot.
+
+ Gives them their `show()` call that returns the appropriate output context.
+ """
+ def __init__(self):
+ self._output = None
+
+ @property
+ def toolbar(self) -> ToolBar:
+ """ipywidget or QToolbar instance"""
+ return self._output.toolbar
+
+ @property
+ def widget(self):
+ """ipywidget or QWidget that contains this plot"""
+ # @caitlin: this is the same as the output context, but I figure widget is a simpler public name
+ return self._output
+
+ def render(self):
+ """render call implemented in subclass"""
+ raise NotImplemented
+
+ def _autoscale_init(self, maintain_aspect: bool):
+ """autoscale function that is called only during show()"""
+ if hasattr(self, "_subplots"):
+ for subplot in self:
+ if maintain_aspect is None:
+ _maintain_aspect = subplot.camera.maintain_aspect
+ else:
+ _maintain_aspect = maintain_aspect
+ subplot.auto_scale(maintain_aspect=_maintain_aspect, zoom=0.95)
+ else:
+ if maintain_aspect is None:
+ maintain_aspect = self.camera.maintain_aspect
+ self.auto_scale(maintain_aspect=maintain_aspect, zoom=0.95)
+
+ def start_render(self):
+ """start render cycle"""
+ self.canvas.request_draw(self.render)
+ self.canvas.set_logical_size(*self._starting_size)
+
+ def show(
+ self,
+ autoscale: bool = True,
+ maintain_aspect: bool = None,
+ toolbar: bool = True,
+ sidecar: bool = False,
+ sidecar_kwargs: dict = None,
+ add_widgets: list = None,
+ ):
+ """
+ Begins the rendering event loop and shows the plot in the desired output context (jupyter, qt or glfw).
+
+ Parameters
+ ----------
+ autoscale: bool, default ``True``
+ autoscale the Scene
+
+ maintain_aspect: bool, default ``True``
+ maintain aspect ratio
+
+ toolbar: bool, default ``True``
+ show toolbar
+
+ sidecar: bool, default ``True``
+ display plot in a ``jupyterlab-sidecar``, only for jupyter output context
+
+ sidecar_kwargs: dict, default ``None``
+ kwargs for sidecar instance to display plot
+ i.e. title, layout
+
+ add_widgets: list of widgets
+ a list of ipywidgets or QWidget that are vertically stacked below the plot
+
+ Returns
+ -------
+ OutputContext
+ In jupyter, it will display the plot in the output cell or sidecar
+
+ In Qt, it will display the Plot, toolbar, etc. as stacked widget, use `Plot.widget` to access it.
+ """
+
+ # show was already called, return existing output context
+ if self._output is not None:
+ return self._output
+
+ self.start_render()
+
+ if sidecar_kwargs is None:
+ sidecar_kwargs = dict()
+
+ if add_widgets is None:
+ add_widgets = list()
+
+ # flip y axis if ImageGraphics are present
+ if hasattr(self, "_subplots"):
+ for subplot in self:
+ for g in subplot.graphics:
+ if isinstance(g, ImageGraphic):
+ subplot.camera.local.scale_y *= -1
+ break
+ else:
+ for g in self.graphics:
+ if isinstance(g, ImageGraphic):
+ self.camera.local.scale_y *= -1
+ break
+
+ if autoscale:
+ self._autoscale_init(maintain_aspect)
+
+ # used for generating images in docs using nbsphinx
+ if "NB_SNAPSHOT" in os.environ.keys():
+ if os.environ["NB_SNAPSHOT"] == "1":
+ return self.canvas.snapshot()
+
+ # return the appropriate OutputContext based on the current canvas
+ if self.canvas.__class__.__name__ == "JupyterWgpuCanvas":
+ self._output = JupyterOutputContext(
+ frame=self,
+ make_toolbar=toolbar,
+ use_sidecar=sidecar,
+ sidecar_kwargs=sidecar_kwargs,
+ add_widgets=add_widgets,
+ )
+
+ elif self.canvas.__class__.__name__ == "QWgpuCanvas":
+ self._output = QOutputContext(
+ frame=self,
+ make_toolbar=toolbar,
+ add_widgets=add_widgets
+ )
+
+ else: # assume GLFW, the output context is just the canvas
+ self._output = self.canvas
+
+ # return the output context, this call is required for jupyter but not for Qt
+ return self._output
+
+ def close(self):
+ """Close the output context"""
+ self._output.close()
diff --git a/fastplotlib/layouts/_frame/_ipywidget_toolbar.py b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py
new file mode 100644
index 000000000..f27856e61
--- /dev/null
+++ b/fastplotlib/layouts/_frame/_ipywidget_toolbar.py
@@ -0,0 +1,310 @@
+import traceback
+from datetime import datetime
+from itertools import product
+from math import copysign
+from functools import partial
+from typing import *
+
+
+from ipywidgets.widgets import (
+ IntSlider,
+ VBox,
+ HBox,
+ ToggleButton,
+ Dropdown,
+ Layout,
+ Button,
+ BoundedIntText,
+ Play,
+ jslink,
+)
+
+from ...graphics.selectors import PolygonSelector
+from ._toolbar import ToolBar
+
+
+class IpywidgetToolBar(HBox, ToolBar):
+ """Basic toolbar using ipywidgets"""
+ def __init__(self, plot):
+ ToolBar.__init__(self, plot)
+
+ self._auto_scale_button = Button(
+ value=False,
+ disabled=False,
+ icon="expand-arrows-alt",
+ layout=Layout(width="auto"),
+ tooltip="auto-scale scene",
+ )
+ self._center_scene_button = Button(
+ value=False,
+ disabled=False,
+ icon="align-center",
+ layout=Layout(width="auto"),
+ tooltip="auto-center scene",
+ )
+ self._panzoom_controller_button = ToggleButton(
+ value=True,
+ disabled=False,
+ icon="hand-pointer",
+ layout=Layout(width="auto"),
+ tooltip="panzoom controller",
+ )
+ self._maintain_aspect_button = ToggleButton(
+ value=True,
+ disabled=False,
+ description="1:1",
+ layout=Layout(width="auto"),
+ tooltip="maintain aspect",
+ )
+ self._maintain_aspect_button.style.font_weight = "bold"
+
+ self._y_direction_button = Button(
+ value=False,
+ disabled=False,
+ icon="arrow-up",
+ layout=Layout(width="auto"),
+ tooltip="y-axis direction",
+ )
+
+ self._record_button = ToggleButton(
+ value=False,
+ disabled=False,
+ icon="video",
+ layout=Layout(width="auto"),
+ tooltip="record",
+ )
+
+ self._add_polygon_button = Button(
+ value=False,
+ disabled=False,
+ icon="draw-polygon",
+ layout=Layout(width="auto"),
+ tooltip="add PolygonSelector"
+ )
+
+ widgets = [
+ self._auto_scale_button,
+ self._center_scene_button,
+ self._panzoom_controller_button,
+ self._maintain_aspect_button,
+ self._y_direction_button,
+ self._add_polygon_button,
+ self._record_button,
+ ]
+
+ if hasattr(self.plot, "_subplots"):
+ positions = list(product(range(self.plot.shape[0]), range(self.plot.shape[1])))
+ values = list()
+ for pos in positions:
+ if self.plot[pos].name is not None:
+ values.append(self.plot[pos].name)
+ else:
+ values.append(str(pos))
+
+ self._dropdown = Dropdown(
+ options=values,
+ disabled=False,
+ description="Subplots:",
+ layout=Layout(width="200px"),
+ )
+
+ self.plot.renderer.add_event_handler(self.update_current_subplot, "click")
+
+ widgets.append(self._dropdown)
+
+ self._panzoom_controller_button.observe(self.panzoom_handler, "value")
+ self._auto_scale_button.on_click(self.auto_scale_handler)
+ self._center_scene_button.on_click(self.center_scene_handler)
+ self._maintain_aspect_button.observe(self.maintain_aspect_handler, "value")
+ self._y_direction_button.on_click(self.y_direction_handler)
+ self._add_polygon_button.on_click(self.add_polygon)
+ self._record_button.observe(self.record_plot, "value")
+
+ # set initial values for some buttons
+ self._maintain_aspect_button.value = self.current_subplot.camera.maintain_aspect
+
+ if copysign(1, self.current_subplot.camera.local.scale_y) == -1:
+ self._y_direction_button.icon = "arrow-down"
+ else:
+ self._y_direction_button.icon = "arrow-up"
+
+ super().__init__(widgets)
+
+ def _get_subplot_dropdown_value(self) -> str:
+ return self._dropdown.value
+
+ def auto_scale_handler(self, obj):
+ self.current_subplot.auto_scale(maintain_aspect=self.current_subplot.camera.maintain_aspect)
+
+ def center_scene_handler(self, obj):
+ self.current_subplot.center_scene()
+
+ def panzoom_handler(self, obj):
+ self.current_subplot.controller.enabled = self._panzoom_controller_button.value
+
+ def maintain_aspect_handler(self, obj):
+ for camera in self.current_subplot.controller.cameras:
+ camera.maintain_aspect = self._maintain_aspect_button.value
+
+ def y_direction_handler(self, obj):
+ # flip every camera under the same controller
+ for camera in self.current_subplot.controller.cameras:
+ camera.local.scale_y *= -1
+
+ if copysign(1, self.current_subplot.camera.local.scale_y) == -1:
+ self._y_direction_button.icon = "arrow-down"
+ else:
+ self._y_direction_button.icon = "arrow-up"
+
+ def update_current_subplot(self, ev):
+ for subplot in self.plot:
+ pos = subplot.map_screen_to_world((ev.x, ev.y))
+ if pos is not None:
+ # update self.dropdown
+ if subplot.name is None:
+ self._dropdown.value = str(subplot.position)
+ else:
+ self._dropdown.value = subplot.name
+ self._panzoom_controller_button.value = subplot.controller.enabled
+ self._maintain_aspect_button.value = subplot.camera.maintain_aspect
+
+ if copysign(1, subplot.camera.local.scale_y) == -1:
+ self._y_direction_button.icon = "arrow-down"
+ else:
+ self._y_direction_button.icon = "arrow-up"
+
+ def record_plot(self, obj):
+ if self._record_button.value:
+ try:
+ self.plot.record_start(
+ f"./{datetime.now().isoformat(timespec='seconds').replace(':', '_')}.mp4"
+ )
+ except Exception:
+ traceback.print_exc()
+ self._record_button.value = False
+ else:
+ self.plot.record_stop()
+
+ def add_polygon(self, obj):
+ ps = PolygonSelector(edge_width=3, edge_color="magenta")
+ self.current_subplot.add_graphic(ps, center=False)
+
+
+class IpywidgetImageWidgetToolbar(VBox):
+ def __init__(self, iw):
+ """
+ Basic toolbar for a ImageWidget instance.
+
+ Parameters
+ ----------
+ plot:
+ """
+ self.iw = iw
+
+ self.reset_vminvmax_button = Button(
+ value=False,
+ disabled=False,
+ icon="adjust",
+ layout=Layout(width="auto"),
+ tooltip="reset vmin/vmax",
+ )
+
+ self.reset_vminvmax_hlut_button = Button(
+ value=False,
+ icon="adjust",
+ description="reset",
+ layout=Layout(width="auto"),
+ tooltip="reset vmin/vmax and reset histogram using current frame"
+ )
+
+ self.sliders: Dict[str, IntSlider] = dict()
+
+ # only for xy data, no time point slider needed
+ if self.iw.ndim == 2:
+ widgets = [self.reset_vminvmax_button]
+ # for txy, tzxy, etc. data
+ else:
+ for dim in self.iw.slider_dims:
+ slider = IntSlider(
+ min=0,
+ max=self.iw._dims_max_bounds[dim] - 1,
+ step=1,
+ value=0,
+ description=f"dimension: {dim}",
+ orientation="horizontal",
+ )
+
+ slider.observe(partial(self.iw._slider_value_changed, dim), names="value")
+
+ self.sliders[dim] = slider
+
+ self.step_size_setter = BoundedIntText(
+ value=1,
+ min=1,
+ max=self.sliders["t"].max,
+ step=1,
+ description="Step Size:",
+ disabled=False,
+ description_tooltip="set slider step",
+ layout=Layout(width="150px"),
+ )
+ self.speed_text = BoundedIntText(
+ value=100,
+ min=1,
+ max=1_000,
+ step=50,
+ description="Speed",
+ disabled=False,
+ description_tooltip="Playback speed, this is NOT framerate.\nArbitrary units between 1 - 1,000",
+ layout=Layout(width="150px"),
+ )
+ self.play_button = Play(
+ value=0,
+ min=self.sliders["t"].min,
+ max=self.sliders["t"].max,
+ step=self.sliders["t"].step,
+ description="play/pause",
+ disabled=False,
+ )
+ widgets = [
+ self.reset_vminvmax_button,
+ self.reset_vminvmax_hlut_button,
+ self.play_button,
+ self.step_size_setter,
+ self.speed_text
+ ]
+
+ self.play_button.interval = 10
+
+ self.step_size_setter.observe(self._change_stepsize, "value")
+ self.speed_text.observe(self._change_framerate, "value")
+ jslink((self.play_button, "value"), (self.sliders["t"], "value"))
+ jslink((self.play_button, "max"), (self.sliders["t"], "max"))
+
+ self.reset_vminvmax_button.on_click(self._reset_vminvmax)
+ self.reset_vminvmax_hlut_button.on_click(self._reset_vminvmax_frame)
+
+ self.iw.gridplot.renderer.add_event_handler(self._set_slider_layout, "resize")
+
+ # the buttons
+ self.hbox = HBox(widgets)
+
+ super().__init__((self.hbox, *list(self.sliders.values())))
+
+ def _reset_vminvmax(self, obj):
+ self.iw.reset_vmin_vmax()
+
+ def _reset_vminvmax_frame(self, obj):
+ self.iw.reset_vmin_vmax_frame()
+
+ def _change_stepsize(self, obj):
+ self.sliders["t"].step = self.step_size_setter.value
+
+ def _change_framerate(self, change):
+ interval = int(1000 / change["new"])
+ self.play_button.interval = interval
+
+ def _set_slider_layout(self, *args):
+ w, h = self.iw.gridplot.renderer.logical_size
+ for k, v in self.sliders.items():
+ v.layout = Layout(width=f"{w}px")
diff --git a/fastplotlib/layouts/_frame/_jupyter_output.py b/fastplotlib/layouts/_frame/_jupyter_output.py
new file mode 100644
index 000000000..25f5e2a2e
--- /dev/null
+++ b/fastplotlib/layouts/_frame/_jupyter_output.py
@@ -0,0 +1,84 @@
+from typing import *
+
+from ipywidgets import VBox, Widget
+from sidecar import Sidecar
+from IPython.display import display
+
+from ._ipywidget_toolbar import IpywidgetToolBar
+
+
+class JupyterOutputContext(VBox):
+ """
+ Output context to display plots in jupyter. Inherits from ipywidgets.VBox
+
+ Basically vstacks plot canvas, toolbar, and other widgets. Uses sidecar if desired.
+ """
+ def __init__(
+ self,
+ frame,
+ make_toolbar: bool,
+ use_sidecar: bool,
+ sidecar_kwargs: dict,
+ add_widgets: List[Widget],
+ ):
+ """
+
+ Parameters
+ ----------
+ frame:
+ Plot frame for which to generate the output context
+
+ sidecar_kwargs: dict
+ optional kwargs passed to Sidecar
+
+ add_widgets: List[Widget]
+ list of ipywidgets to stack below the plot and toolbar
+ """
+ self.frame = frame
+ self.toolbar = None
+ self.sidecar = None
+
+ # verify they are all valid ipywidgets
+ if False in [isinstance(w, Widget) for w in add_widgets]:
+ raise TypeError(
+ f"add_widgets must be list of ipywidgets, you have passed:\n{add_widgets}"
+ )
+
+ self.use_sidecar = use_sidecar
+
+ if not make_toolbar: # just stack canvas and the additional widgets, if any
+ self.output = (frame.canvas, *add_widgets)
+
+ if make_toolbar: # make toolbar and stack canvas, toolbar, add_widgets
+ self.toolbar = IpywidgetToolBar(frame)
+ self.output = (frame.canvas, self.toolbar, *add_widgets)
+
+ if use_sidecar: # instantiate sidecar if desired
+ self.sidecar = Sidecar(**sidecar_kwargs)
+
+ # stack all of these in the VBox
+ super().__init__(self.output)
+
+ def _repr_mimebundle_(self, *args, **kwargs):
+ """
+ This is what jupyter hook into when this output context instance is returned at the end of a cell.
+ """
+ if self.use_sidecar:
+ with self.sidecar:
+ # TODO: prints all the child widgets in the cell output, will figure out later, sidecar output works
+ return display(VBox(self.output))
+ else:
+ # just display VBox contents in cell output
+ return super()._repr_mimebundle_(*args, **kwargs)
+
+ def close(self):
+ """Closes the output context, cleanup all the stuff"""
+ self.frame.canvas.close()
+
+ if self.toolbar is not None:
+ self.toolbar.close()
+
+ if self.sidecar is not None:
+ self.sidecar.close()
+
+ super().close() # ipywidget VBox cleanup
diff --git a/fastplotlib/layouts/_frame/_qt_output.py b/fastplotlib/layouts/_frame/_qt_output.py
new file mode 100644
index 000000000..b4c7cffd9
--- /dev/null
+++ b/fastplotlib/layouts/_frame/_qt_output.py
@@ -0,0 +1,57 @@
+from PyQt6 import QtWidgets
+
+from ._qt_toolbar import QToolbar
+
+
+class QOutputContext(QtWidgets.QWidget):
+ """
+ Output context to display plots in Qt apps. Inherits from QtWidgets.QWidget
+
+ Basically vstacks plot canvas, toolbar, and other widgets.
+ """
+ def __init__(
+ self,
+ frame,
+ make_toolbar,
+ add_widgets,
+ ):
+ """
+
+ Parameters
+ ----------
+ frame:
+ Plot frame for which to generate the output context
+
+ add_widgets: List[Widget]
+ list of QWidget to stack below the plot and toolbar
+ """
+ # no parent, user can use Plot.widget.setParent(parent) if necessary to embed into other widgets
+ QtWidgets.QWidget.__init__(self, parent=None)
+ self.frame = frame
+ self.toolbar = None
+
+ # vertical layout used to stack plot canvas, toolbar, and add_widgets
+ self.vlayout = QtWidgets.QVBoxLayout(self)
+
+ # add canvas to layout
+ self.vlayout.addWidget(self.frame.canvas)
+
+ if make_toolbar: # make toolbar and add to layout
+ self.toolbar = QToolbar(output_context=self, plot=frame)
+ self.vlayout.addWidget(self.toolbar)
+
+ for w in add_widgets: # add any additional widgets to layout
+ w.setParent(self)
+ self.vlayout.addWidget(w)
+
+ self.setLayout(self.vlayout)
+
+ self.resize(*self.frame._starting_size)
+
+ self.show()
+
+ def close(self):
+ """Cleanup and close the output context"""
+ self.frame.canvas.close()
+ self.toolbar.close()
+ super().close() # QWidget cleanup
diff --git a/fastplotlib/layouts/_frame/_qt_toolbar.py b/fastplotlib/layouts/_frame/_qt_toolbar.py
new file mode 100644
index 000000000..9d4e0b48f
--- /dev/null
+++ b/fastplotlib/layouts/_frame/_qt_toolbar.py
@@ -0,0 +1,235 @@
+from datetime import datetime
+from functools import partial
+from math import copysign
+import traceback
+from typing import *
+
+from PyQt6 import QtWidgets, QtCore
+
+from ...graphics.selectors import PolygonSelector
+from ._toolbar import ToolBar
+from ._qtoolbar_template import Ui_QToolbar
+
+
+class QToolbar(ToolBar, QtWidgets.QWidget): # inheritance order MUST be Toolbar first, QWidget second! Else breaks
+ """Toolbar for Qt context"""
+ def __init__(self, output_context, plot):
+ QtWidgets.QWidget.__init__(self, parent=output_context)
+ ToolBar.__init__(self, plot)
+
+ # initialize UI
+ self.ui = Ui_QToolbar()
+ self.ui.setupUi(self)
+
+ # connect button events
+ self.ui.auto_scale_button.clicked.connect(self.auto_scale_handler)
+ self.ui.center_button.clicked.connect(self.center_scene_handler)
+ self.ui.panzoom_button.toggled.connect(self.panzoom_handler)
+ self.ui.maintain_aspect_button.toggled.connect(self.maintain_aspect_handler)
+ self.ui.y_direction_button.clicked.connect(self.y_direction_handler)
+
+ # the subplot labels that update when a user click on subplots
+ if hasattr(self.plot, "_subplots"):
+ subplot = self.plot[0, 0]
+ # set label from first subplot name
+ if subplot.name is not None:
+ name = subplot.name
+ else:
+ name = str(subplot.position)
+
+ # here we will just use a simple label, not a dropdown like ipywidgets
+ # the dropdown implementation is tedious with Qt
+ self.ui.current_subplot = QtWidgets.QLabel(parent=self)
+ self.ui.current_subplot.setText(name)
+ self.ui.horizontalLayout.addWidget(self.ui.current_subplot)
+
+ # update the subplot label when a subplot is clicked into
+ self.plot.renderer.add_event_handler(self.update_current_subplot, "click")
+
+ self.setMaximumHeight(35)
+
+ # set the initial values for buttons
+ self.ui.maintain_aspect_button.setChecked(self.current_subplot.camera.maintain_aspect)
+ self.ui.panzoom_button.setChecked(self.current_subplot.controller.enabled)
+
+ if copysign(1, self.current_subplot.camera.local.scale_y) == -1:
+ self.ui.y_direction_button.setText("v")
+ else:
+ self.ui.y_direction_button.setText("^")
+
+ def update_current_subplot(self, ev):
+ """update the text label for the current subplot"""
+ for subplot in self.plot:
+ pos = subplot.map_screen_to_world((ev.x, ev.y))
+ if pos is not None:
+ if subplot.name is not None:
+ name = subplot.name
+ else:
+ name = str(subplot.position)
+ self.ui.current_subplot.setText(name)
+
+ # set buttons w.r.t. current subplot
+ self.ui.panzoom_button.setChecked(subplot.controller.enabled)
+ self.ui.maintain_aspect_button.setChecked(subplot.camera.maintain_aspect)
+
+ if copysign(1, subplot.camera.local.scale_y) == -1:
+ self.ui.y_direction_button.setText("v")
+ else:
+ self.ui.y_direction_button.setText("^")
+
+ def _get_subplot_dropdown_value(self) -> str:
+ return self.ui.current_subplot.text()
+
+ def auto_scale_handler(self, *args):
+ self.current_subplot.auto_scale(maintain_aspect=self.current_subplot.camera.maintain_aspect)
+
+ def center_scene_handler(self, *args):
+ self.current_subplot.center_scene()
+
+ def panzoom_handler(self, value: bool):
+ self.current_subplot.controller.enabled = value
+
+ def maintain_aspect_handler(self, value: bool):
+ for camera in self.current_subplot.controller.cameras:
+ camera.maintain_aspect = value
+
+ def y_direction_handler(self, *args):
+ # flip every camera under the same controller
+ for camera in self.current_subplot.controller.cameras:
+ camera.local.scale_y *= -1
+
+ if copysign(1, self.current_subplot.camera.local.scale_y) == -1:
+ self.ui.y_direction_button.setText("v")
+ else:
+ self.ui.y_direction_button.setText("^")
+
+ def record_handler(self, ev):
+ if self.ui.record_button.isChecked():
+ try:
+ self.plot.record_start(
+ f"./{datetime.now().isoformat(timespec='seconds').replace(':', '_')}.mp4"
+ )
+ except Exception:
+ traceback.print_exc()
+ self.ui.record_button.setChecked(False)
+ else:
+ self.plot.record_stop()
+
+ def add_polygon(self, *args):
+ ps = PolygonSelector(edge_width=3, edge_color="mageneta")
+ self.current_subplot.add_graphic(ps, center=False)
+
+
+# TODO: There must be a better way to do this
+# TODO: Check if an interface exists between ipywidgets and Qt
+class SliderInterface:
+ """
+ This exists so that ImageWidget has a common interface for Sliders.
+
+ This interface makes a QSlider behave somewhat like a ipywidget IntSlider, enough for ImageWidget to function.
+ """
+ def __init__(self, qslider):
+ self.qslider = qslider
+
+ @property
+ def value(self) -> int:
+ return self.qslider.value()
+
+ @value.setter
+ def value(self, value: int):
+ self.qslider.setValue(value)
+
+ @property
+ def max(self) -> int:
+ return self.qslider.maximum()
+
+ @max.setter
+ def max(self, value: int):
+ self.qslider.setMaximum(value)
+
+ @property
+ def min(self):
+ return self.qslider.minimum()
+
+ @min.setter
+ def min(self, value: int):
+ self.qslider.setMinimum(value)
+
+
+class QToolbarImageWidget(QtWidgets.QWidget):
+ """Toolbar for ImageWidget"""
+
+ def __init__(self, image_widget):
+ QtWidgets.QWidget.__init__(self)
+
+ # vertical layout
+ self.vlayout = QtWidgets.QVBoxLayout(self)
+
+ self.image_widget = image_widget
+
+ hlayout_buttons = QtWidgets.QHBoxLayout()
+
+ self.reset_vmin_vmax_button = QtWidgets.QPushButton(self)
+ self.reset_vmin_vmax_button.setText("auto-contrast")
+ self.reset_vmin_vmax_button.clicked.connect(self.image_widget.reset_vmin_vmax)
+ hlayout_buttons.addWidget(self.reset_vmin_vmax_button)
+
+ self.reset_vmin_vmax_hlut_button = QtWidgets.QPushButton(self)
+ self.reset_vmin_vmax_hlut_button.setText("reset histogram-lut")
+ self.reset_vmin_vmax_hlut_button.clicked.connect(self.image_widget.reset_vmin_vmax_frame)
+ hlayout_buttons.addWidget(self.reset_vmin_vmax_hlut_button)
+
+ self.vlayout.addLayout(hlayout_buttons)
+
+ self.sliders: Dict[str, SliderInterface] = dict()
+
+ # has time and/or z-volume
+ if self.image_widget.ndim > 2:
+ # create a slider, spinbox and dimension label for each dimension in the ImageWidget
+ for dim in self.image_widget.slider_dims:
+ hlayout = QtWidgets.QHBoxLayout() # horizontal stack for label, slider, spinbox
+
+ # max value for current dimension
+ max_val = self.image_widget._dims_max_bounds[dim] - 1
+
+ # make slider
+ slider = QtWidgets.QSlider(self)
+ slider.setOrientation(QtCore.Qt.Orientation.Horizontal)
+ slider.setMinimum(0)
+ slider.setMaximum(max_val)
+ slider.setValue(0)
+ slider.setSingleStep(1)
+ slider.setPageStep(10)
+
+ # make spinbox
+ spinbox = QtWidgets.QSpinBox(self)
+ spinbox.setMinimum(0)
+ spinbox.setMaximum(max_val)
+ spinbox.setValue(0)
+ spinbox.setSingleStep(1)
+
+ # link slider and spinbox
+ slider.valueChanged.connect(spinbox.setValue)
+ spinbox.valueChanged.connect(slider.setValue)
+
+ # connect slider to change the index within the dimension
+ slider.valueChanged.connect(partial(self.image_widget._slider_value_changed, dim))
+
+ # slider dimension label
+ slider_label = QtWidgets.QLabel(self)
+ slider_label.setText(dim)
+
+ # add the widgets to the horizontal layout
+ hlayout.addWidget(slider_label)
+ hlayout.addWidget(slider)
+ hlayout.addWidget(spinbox)
+
+ # add horizontal layout to the vertical layout
+ self.vlayout.addLayout(hlayout)
+
+ # add to sliders dict for easier access to users
+ self.sliders[dim] = SliderInterface(slider)
+
+ max_height = 35 + (35 * len(self.sliders.keys()))
+
+ self.setMaximumHeight(max_height)
diff --git a/fastplotlib/layouts/_frame/_qtoolbar_template.py b/fastplotlib/layouts/_frame/_qtoolbar_template.py
new file mode 100644
index 000000000..a8a1c6f86
--- /dev/null
+++ b/fastplotlib/layouts/_frame/_qtoolbar_template.py
@@ -0,0 +1,62 @@
+# Form implementation generated from reading ui file 'qtoolbar.ui'
+#
+# Created by: PyQt6 UI code generator 6.5.3
+#
+# WARNING: Any manual changes made to this file will be lost when pyuic6 is
+# run again. Do not edit this file unless you know what you are doing.
+
+
+from PyQt6 import QtCore, QtGui, QtWidgets
+
+
+class Ui_QToolbar(object):
+ def setupUi(self, QToolbar):
+ QToolbar.setObjectName("QToolbar")
+ QToolbar.resize(638, 48)
+ self.horizontalLayout_2 = QtWidgets.QHBoxLayout(QToolbar)
+ self.horizontalLayout_2.setObjectName("horizontalLayout_2")
+ self.horizontalLayout = QtWidgets.QHBoxLayout()
+ self.horizontalLayout.setObjectName("horizontalLayout")
+ self.auto_scale_button = QtWidgets.QPushButton(parent=QToolbar)
+ self.auto_scale_button.setObjectName("auto_scale_button")
+ self.horizontalLayout.addWidget(self.auto_scale_button)
+ self.center_button = QtWidgets.QPushButton(parent=QToolbar)
+ self.center_button.setObjectName("center_button")
+ self.horizontalLayout.addWidget(self.center_button)
+ self.panzoom_button = QtWidgets.QPushButton(parent=QToolbar)
+ self.panzoom_button.setCheckable(True)
+ self.panzoom_button.setObjectName("panzoom_button")
+ self.horizontalLayout.addWidget(self.panzoom_button)
+ self.maintain_aspect_button = QtWidgets.QPushButton(parent=QToolbar)
+ font = QtGui.QFont()
+ font.setBold(True)
+ font.setWeight(75)
+ self.maintain_aspect_button.setFont(font)
+ self.maintain_aspect_button.setCheckable(True)
+ self.maintain_aspect_button.setObjectName("maintain_aspect_button")
+ self.horizontalLayout.addWidget(self.maintain_aspect_button)
+ self.y_direction_button = QtWidgets.QPushButton(parent=QToolbar)
+ self.y_direction_button.setObjectName("y_direction_button")
+ self.horizontalLayout.addWidget(self.y_direction_button)
+ self.add_polygon_button = QtWidgets.QPushButton(parent=QToolbar)
+ self.add_polygon_button.setObjectName("add_polygon_button")
+ self.horizontalLayout.addWidget(self.add_polygon_button)
+ self.record_button = QtWidgets.QPushButton(parent=QToolbar)
+ self.record_button.setCheckable(True)
+ self.record_button.setObjectName("record_button")
+ self.horizontalLayout.addWidget(self.record_button)
+ self.horizontalLayout_2.addLayout(self.horizontalLayout)
+
+ self.retranslateUi(QToolbar)
+ QtCore.QMetaObject.connectSlotsByName(QToolbar)
+
+ def retranslateUi(self, QToolbar):
+ _translate = QtCore.QCoreApplication.translate
+ QToolbar.setWindowTitle(_translate("QToolbar", "Form"))
+ self.auto_scale_button.setText(_translate("QToolbar", "autoscale"))
+ self.center_button.setText(_translate("QToolbar", "center"))
+ self.panzoom_button.setText(_translate("QToolbar", "panzoom"))
+ self.maintain_aspect_button.setText(_translate("QToolbar", "1:1"))
+ self.y_direction_button.setText(_translate("QToolbar", "^"))
+ self.add_polygon_button.setText(_translate("QToolbar", "polygon"))
+ self.record_button.setText(_translate("QToolbar", "record"))
diff --git a/fastplotlib/layouts/_frame/_toolbar.py b/fastplotlib/layouts/_frame/_toolbar.py
new file mode 100644
index 000000000..94410b8ea
--- /dev/null
+++ b/fastplotlib/layouts/_frame/_toolbar.py
@@ -0,0 +1,45 @@
+from fastplotlib.layouts._subplot import Subplot
+
+
+class ToolBar:
+ def __init__(self, plot):
+ self.plot = plot
+
+ def _get_subplot_dropdown_value(self) -> str:
+ raise NotImplemented
+
+ @property
+ def current_subplot(self) -> Subplot:
+ """Returns current subplot"""
+ if hasattr(self.plot, "_subplots"):
+ # parses dropdown or label value as plot name or position
+ current = self._get_subplot_dropdown_value()
+ if current[0] == "(":
+ # str representation of int tuple to tuple of int
+ current = tuple(int(i) for i in current.strip("()").split(","))
+ return self.plot[current]
+ else:
+ return self.plot[current]
+ else:
+ return self.plot
+
+ def panzoom_handler(self, ev):
+ raise NotImplemented
+
+ def maintain_aspect_handler(self, ev):
+ raise NotImplemented
+
+ def y_direction_handler(self, ev):
+ raise NotImplemented
+
+ def auto_scale_handler(self, ev):
+ raise NotImplemented
+
+ def center_scene_handler(self, ev):
+ raise NotImplemented
+
+ def record_handler(self, ev):
+ raise NotImplemented
+
+ def add_polygon(self, ev):
+ raise NotImplemented
diff --git a/fastplotlib/layouts/_frame/qtoolbar.ui b/fastplotlib/layouts/_frame/qtoolbar.ui
new file mode 100644
index 000000000..6c9aadae8
--- /dev/null
+++ b/fastplotlib/layouts/_frame/qtoolbar.ui
@@ -0,0 +1,89 @@
+
+
+ QToolbar
+
+
+
+ 0
+ 0
+ 638
+ 48
+
+
+
+ Form
+
+
+
+
+
+
+
+ autoscale
+
+
+
+
+
+
+ center
+
+
+
+
+
+
+ panzoom
+
+
+ true
+
+
+
+
+
+
+
+ 75
+ true
+
+
+
+ 1:1
+
+
+ true
+
+
+
+
+
+
+ ^
+
+
+
+
+
+
+ polygon
+
+
+
+
+
+
+ record
+
+
+ true
+
+
+
+
+
+
+
+
+
+
diff --git a/fastplotlib/layouts/_gridplot.py b/fastplotlib/layouts/_gridplot.py
index be268fa9a..473196f78 100644
--- a/fastplotlib/layouts/_gridplot.py
+++ b/fastplotlib/layouts/_gridplot.py
@@ -1,21 +1,14 @@
-import traceback
-from datetime import datetime
from itertools import product
import numpy as np
from typing import *
from inspect import getfullargspec
from warnings import warn
-import os
import pygfx
-from wgpu.gui.auto import WgpuCanvas, is_jupyter
-
-if is_jupyter():
- from ipywidgets import HBox, Layout, Button, ToggleButton, VBox, Dropdown, Widget
- from sidecar import Sidecar
- from IPython.display import display
+from wgpu.gui.auto import WgpuCanvas
+from ._frame import Frame
from ._utils import make_canvas_and_renderer
from ._defaults import create_controller
from ._subplot import Subplot
@@ -35,7 +28,7 @@ def to_array(a) -> np.ndarray:
valid_cameras = ["2d", "2d-big", "3d", "3d-big"]
-class GridPlot(RecordMixin):
+class GridPlot(Frame, RecordMixin):
def __init__(
self,
shape: Tuple[int, int],
@@ -82,10 +75,6 @@ def __init__(
"""
self.shape = shape
- self.toolbar = None
- self.sidecar = None
- self.vbox = None
- self.plot_open = False
canvas, renderer = make_canvas_and_renderer(canvas, renderer)
@@ -196,6 +185,7 @@ def __init__(
self._starting_size = size
RecordMixin.__init__(self)
+ Frame.__init__(self)
@property
def canvas(self) -> WgpuCanvas:
@@ -298,121 +288,6 @@ def remove_animation(self, func):
if func in self._animate_funcs_post:
self._animate_funcs_post.remove(func)
- def show(
- self,
- autoscale: bool = True,
- maintain_aspect: bool = None,
- toolbar: bool = True,
- sidecar: bool = True,
- sidecar_kwargs: dict = None,
- vbox: list = None
- ):
- """
- Begins the rendering event loop and returns the canvas
-
- Parameters
- ----------
- autoscale: bool, default ``True``
- autoscale the Scene
-
- maintain_aspect: bool, default ``True``
- maintain aspect ratio
-
- toolbar: bool, default ``True``
- show toolbar
-
- sidecar: bool, default ``True``
- display plot in a ``jupyterlab-sidecar``
-
- sidecar_kwargs: dict, default ``None``
- kwargs for sidecar instance to display plot
- i.e. title, layout
-
- vbox: list, default ``None``
- list of ipywidgets to be displayed with plot
-
- Returns
- -------
- WgpuCanvas
- the canvas
-
- """
-
- self.canvas.request_draw(self.render)
-
- self.canvas.set_logical_size(*self._starting_size)
-
- if autoscale:
- for subplot in self:
- if maintain_aspect is None:
- _maintain_aspect = subplot.camera.maintain_aspect
- else:
- _maintain_aspect = maintain_aspect
- subplot.auto_scale(maintain_aspect=_maintain_aspect, zoom=0.95)
-
- if "NB_SNAPSHOT" in os.environ.keys():
- # used for docs
- if os.environ["NB_SNAPSHOT"] == "1":
- return self.canvas.snapshot()
-
- # check if in jupyter notebook, or if toolbar is False
- if (self.canvas.__class__.__name__ != "JupyterWgpuCanvas") or (not toolbar):
- return self.canvas
-
- if self.toolbar is None:
- self.toolbar = GridPlotToolBar(self)
- self.toolbar.maintain_aspect_button.value = self[
- 0, 0
- ].camera.maintain_aspect
-
- # validate vbox if not None
- if vbox is not None:
- for widget in vbox:
- if not isinstance(widget, Widget):
- raise ValueError(f"Items in vbox must be ipywidgets. Item: {widget} is of type: {type(widget)}")
- self.vbox = VBox(vbox)
-
- if not sidecar:
- if self.vbox is not None:
- return VBox([self.canvas, self.toolbar.widget, self.vbox])
- else:
- return VBox([self.canvas, self.toolbar.widget])
-
- # used when plot.show() is being called again but sidecar has been closed via "x" button
- # need to force new sidecar instance
- # couldn't figure out how to get access to "close" button in order to add observe method on click
- if self.plot_open:
- self.sidecar = None
-
- if self.sidecar is None:
- if sidecar_kwargs is not None:
- self.sidecar = Sidecar(**sidecar_kwargs)
- self.plot_open = True
- else:
- self.sidecar = Sidecar()
- self.plot_open = True
-
- with self.sidecar:
- if self.vbox is not None:
- return display(VBox([self.canvas, self.toolbar.widget, self.vbox]))
- else:
- return display(VBox([self.canvas, self.toolbar.widget]))
-
- def close(self):
- """Close the GridPlot"""
- self.canvas.close()
-
- if self.toolbar is not None:
- self.toolbar.widget.close()
-
- if self.sidecar is not None:
- self.sidecar.close()
-
- if self.vbox is not None:
- self.vbox.close()
-
- self.plot_open = False
-
def clear(self):
"""Clear all Subplots"""
for subplot in self:
@@ -431,152 +306,3 @@ def __next__(self) -> Subplot:
def __repr__(self):
return f"fastplotlib.{self.__class__.__name__} @ {hex(id(self))}\n"
-
-
-class GridPlotToolBar:
- def __init__(self, plot: GridPlot):
- """
- Basic toolbar for a GridPlot instance.
-
- Parameters
- ----------
- plot:
- """
- self.plot = plot
-
- self.autoscale_button = Button(
- value=False,
- disabled=False,
- icon="expand-arrows-alt",
- layout=Layout(width="auto"),
- tooltip="auto-scale scene",
- )
- self.center_scene_button = Button(
- value=False,
- disabled=False,
- icon="align-center",
- layout=Layout(width="auto"),
- tooltip="auto-center scene",
- )
- self.panzoom_controller_button = ToggleButton(
- value=True,
- disabled=False,
- icon="hand-pointer",
- layout=Layout(width="auto"),
- tooltip="panzoom controller",
- )
- self.maintain_aspect_button = ToggleButton(
- value=True,
- disabled=False,
- description="1:1",
- layout=Layout(width="auto"),
- tooltip="maintain aspect",
- )
- self.maintain_aspect_button.style.font_weight = "bold"
- self.flip_camera_button = Button(
- value=False,
- disabled=False,
- icon="arrow-up",
- layout=Layout(width="auto"),
- tooltip="y-axis direction",
- )
-
- self.record_button = ToggleButton(
- value=False,
- disabled=False,
- icon="video",
- layout=Layout(width="auto"),
- tooltip="record",
- )
-
- positions = list(product(range(self.plot.shape[0]), range(self.plot.shape[1])))
- values = list()
- for pos in positions:
- if self.plot[pos].name is not None:
- values.append(self.plot[pos].name)
- else:
- values.append(str(pos))
- self.dropdown = Dropdown(
- options=values,
- disabled=False,
- description="Subplots:",
- layout=Layout(width="200px"),
- )
-
- self.widget = HBox(
- [
- self.autoscale_button,
- self.center_scene_button,
- self.panzoom_controller_button,
- self.maintain_aspect_button,
- self.flip_camera_button,
- self.record_button,
- self.dropdown,
- ]
- )
-
- self.panzoom_controller_button.observe(self.panzoom_control, "value")
- self.autoscale_button.on_click(self.auto_scale)
- self.center_scene_button.on_click(self.center_scene)
- self.maintain_aspect_button.observe(self.maintain_aspect, "value")
- self.flip_camera_button.on_click(self.flip_camera)
- self.record_button.observe(self.record_plot, "value")
-
- self.plot.renderer.add_event_handler(self.update_current_subplot, "click")
-
- @property
- def current_subplot(self) -> Subplot:
- # parses dropdown value as plot name or position
- current = self.dropdown.value
- if current[0] == "(":
- return self.plot[eval(current)]
- else:
- return self.plot[current]
-
- def auto_scale(self, obj):
- current = self.current_subplot
- current.auto_scale(maintain_aspect=current.camera.maintain_aspect)
-
- def center_scene(self, obj):
- current = self.current_subplot
- current.center_scene()
-
- def panzoom_control(self, obj):
- current = self.current_subplot
- current.controller.enabled = self.panzoom_controller_button.value
-
- def maintain_aspect(self, obj):
- current = self.current_subplot
- current.camera.maintain_aspect = self.maintain_aspect_button.value
-
- def flip_camera(self, obj):
- current = self.current_subplot
- current.camera.local.scale_y *= -1
- if current.camera.local.scale_y == -1:
- self.flip_camera_button.icon = "arrow-down"
- else:
- self.flip_camera_button.icon = "arrow-up"
-
- def update_current_subplot(self, ev):
- for subplot in self.plot:
- pos = subplot.map_screen_to_world((ev.x, ev.y))
- if pos is not None:
- # update self.dropdown
- if subplot.name is None:
- self.dropdown.value = str(subplot.position)
- else:
- self.dropdown.value = subplot.name
- self.panzoom_controller_button.value = subplot.controller.enabled
- self.maintain_aspect_button.value = subplot.camera.maintain_aspect
-
- def record_plot(self, obj):
- if self.record_button.value:
- try:
- self.plot.record_start(
- f"./{datetime.now().isoformat(timespec='seconds').replace(':', '_')}.mp4"
- )
- except Exception:
- traceback.print_exc()
- self.record_button.value = False
- else:
- self.plot.record_stop()
diff --git a/fastplotlib/layouts/_plot.py b/fastplotlib/layouts/_plot.py
index 253b6296b..5aa04bb76 100644
--- a/fastplotlib/layouts/_plot.py
+++ b/fastplotlib/layouts/_plot.py
@@ -1,22 +1,14 @@
from typing import *
-from datetime import datetime
-import traceback
-import os
import pygfx
-from wgpu.gui.auto import WgpuCanvas, is_jupyter
-
-if is_jupyter():
- from ipywidgets import HBox, Layout, Button, ToggleButton, VBox, Widget
- from sidecar import Sidecar
- from IPython.display import display
+from wgpu.gui.auto import WgpuCanvas
from ._subplot import Subplot
+from ._frame import Frame
from ._record_mixin import RecordMixin
-from ..graphics.selectors import PolygonSelector
-class Plot(Subplot, RecordMixin):
+class Plot(Subplot, Frame, RecordMixin):
def __init__(
self,
canvas: WgpuCanvas = None,
@@ -62,248 +54,13 @@ def __init__(
**kwargs,
)
RecordMixin.__init__(self)
+ Frame.__init__(self)
self._starting_size = size
- self.toolbar = None
- self.sidecar = None
- self.vbox = None
- self.plot_open = False
-
def render(self):
+ """performs a single render of the plot, not for the user"""
super(Plot, self).render()
self.renderer.flush()
self.canvas.request_draw()
-
- def show(
- self,
- autoscale: bool = True,
- maintain_aspect: bool = None,
- toolbar: bool = True,
- sidecar: bool = True,
- sidecar_kwargs: dict = None,
- vbox: list = None
- ):
- """
- Begins the rendering event loop and returns the canvas
-
- Parameters
- ----------
- autoscale: bool, default ``True``
- autoscale the Scene
-
- maintain_aspect: bool, default ``None``
- maintain aspect ratio, uses ``camera.maintain_aspect`` if ``None``
-
- toolbar: bool, default ``True``
- show toolbar
-
- sidecar: bool, default ``True``
- display the plot in a ``jupyterlab-sidecar``
-
- sidecar_kwargs: dict, default ``None``
- kwargs for sidecar instance to display plot
- i.e. title, layout
-
- vbox: list, default ``None``
- list of ipywidgets to be displayed with plot
-
- Returns
- -------
- WgpuCanvas
- the canvas
-
- """
-
- self.canvas.request_draw(self.render)
-
- self.canvas.set_logical_size(*self._starting_size)
-
- if maintain_aspect is None:
- maintain_aspect = self.camera.maintain_aspect
-
- if autoscale:
- self.auto_scale(maintain_aspect=maintain_aspect, zoom=0.95)
-
- if "NB_SNAPSHOT" in os.environ.keys():
- # used for docs
- if os.environ["NB_SNAPSHOT"] == "1":
- return self.canvas.snapshot()
-
- # check if in jupyter notebook, or if toolbar is False
- if (self.canvas.__class__.__name__ != "JupyterWgpuCanvas") or (not toolbar):
- return self.canvas
-
- if self.toolbar is None:
- self.toolbar = ToolBar(self)
- self.toolbar.maintain_aspect_button.value = maintain_aspect
-
- # validate vbox if not None
- if vbox is not None:
- for widget in vbox:
- if not isinstance(widget, Widget):
- raise ValueError(f"Items in vbox must be ipywidgets. Item: {widget} is of type: {type(widget)}")
- self.vbox = VBox(vbox)
-
- if not sidecar:
- if self.vbox is not None:
- return VBox([self.canvas, self.toolbar.widget, self.vbox])
- else:
- return VBox([self.canvas, self.toolbar.widget])
-
- # used when plot.show() is being called again but sidecar has been closed via "x" button
- # need to force new sidecar instance
- # couldn't figure out how to get access to "close" button in order to add observe method on click
- if self.plot_open:
- self.sidecar = None
-
- if self.sidecar is None:
- if sidecar_kwargs is not None:
- self.sidecar = Sidecar(**sidecar_kwargs)
- self.plot_open = True
- else:
- self.sidecar = Sidecar()
- self.plot_open = True
-
- with self.sidecar:
- if self.vbox is not None:
- return display(VBox([self.canvas, self.toolbar.widget, self.vbox]))
- else:
- return display(VBox([self.canvas, self.toolbar.widget]))
-
- def close(self):
- """Close Plot"""
- self.canvas.close()
-
- if self.toolbar is not None:
- self.toolbar.widget.close()
-
- if self.sidecar is not None:
- self.sidecar.close()
-
- if self.vbox is not None:
- self.vbox.close()
-
- self.plot_open = False
-
-
-class ToolBar:
- def __init__(self, plot: Plot):
- """
- Basic toolbar for a Plot instance.
-
- Parameters
- ----------
- plot: encapsulated plot instance that will be manipulated using the toolbar buttons
- """
- self.plot = plot
-
- self.autoscale_button = Button(
- value=False,
- disabled=False,
- icon="expand-arrows-alt",
- layout=Layout(width="auto"),
- tooltip="auto-scale scene",
- )
- self.center_scene_button = Button(
- value=False,
- disabled=False,
- icon="align-center",
- layout=Layout(width="auto"),
- tooltip="auto-center scene",
- )
- self.panzoom_controller_button = ToggleButton(
- value=True,
- disabled=False,
- icon="hand-pointer",
- layout=Layout(width="auto"),
- tooltip="panzoom controller",
- )
- self.maintain_aspect_button = ToggleButton(
- value=True,
- disabled=False,
- description="1:1",
- layout=Layout(width="auto"),
- tooltip="maintain aspect",
- )
- self.maintain_aspect_button.style.font_weight = "bold"
- self.flip_camera_button = Button(
- value=False,
- disabled=False,
- icon="arrow-up",
- layout=Layout(width="auto"),
- tooltip="flip",
- )
-
- self.add_polygon_button = Button(
- value=False,
- disabled=False,
- icon="draw-polygon",
- layout=Layout(width="auto"),
- tooltip="add PolygonSelector"
- )
-
- self.record_button = ToggleButton(
- value=False,
- disabled=False,
- icon="video",
- layout=Layout(width="auto"),
- tooltip="record",
- )
-
- self.widget = HBox(
- [
- self.autoscale_button,
- self.center_scene_button,
- self.panzoom_controller_button,
- self.maintain_aspect_button,
- self.flip_camera_button,
- self.add_polygon_button,
- self.record_button,
- ]
- )
-
- self.panzoom_controller_button.observe(self.panzoom_control, "value")
- self.autoscale_button.on_click(self.auto_scale)
- self.center_scene_button.on_click(self.center_scene)
- self.maintain_aspect_button.observe(self.maintain_aspect, "value")
- self.flip_camera_button.on_click(self.flip_camera)
- self.add_polygon_button.on_click(self.add_polygon)
- self.record_button.observe(self.record_plot, "value")
-
- def auto_scale(self, obj):
- self.plot.auto_scale(maintain_aspect=self.plot.camera.maintain_aspect)
-
- def center_scene(self, obj):
- self.plot.center_scene()
-
- def panzoom_control(self, obj):
- self.plot.controller.enabled = self.panzoom_controller_button.value
-
- def maintain_aspect(self, obj):
- self.plot.camera.maintain_aspect = self.maintain_aspect_button.value
-
- def flip_camera(self, obj):
- self.plot.camera.local.scale_y *= -1
- if self.plot.camera.local.scale_y == -1:
- self.flip_camera_button.icon = "arrow-down"
- else:
- self.flip_camera_button.icon = "arrow-up"
-
- def add_polygon(self, obj):
- ps = PolygonSelector(edge_width=3, edge_color="magenta")
-
- self.plot.add_graphic(ps, center=False)
-
- def record_plot(self, obj):
- if self.record_button.value:
- try:
- self.plot.record_start(
- f"./{datetime.now().isoformat(timespec='seconds').replace(':', '_')}.mp4"
- )
- except Exception:
- traceback.print_exc()
- self.record_button.value = False
- else:
- self.plot.record_stop()
diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_plot_area.py
similarity index 77%
rename from fastplotlib/layouts/_base.py
rename to fastplotlib/layouts/_plot_area.py
index c5dcb0581..2060850c2 100644
--- a/fastplotlib/layouts/_base.py
+++ b/fastplotlib/layouts/_plot_area.py
@@ -1,5 +1,7 @@
+from inspect import getfullargspec
from typing import *
import weakref
+from warnings import warn
import numpy as np
@@ -92,6 +94,9 @@ def __init__(
self.viewport,
)
+ self._animate_funcs_pre = list()
+ self._animate_funcs_post = list()
+
self.renderer.add_event_handler(self.set_viewport_rect, "resize")
# list of hex id strings for all graphics managed by this PlotArea
@@ -224,12 +229,90 @@ def set_viewport_rect(self, *args):
self.viewport.rect = self.get_rect()
def render(self):
- # does not flush
+ self._call_animate_functions(self._animate_funcs_pre)
+
+ # does not flush, flush must be implemented in user-facing Plot objects
self.viewport.render(self.scene, self.camera)
for child in self.children:
child.render()
+ self._call_animate_functions(self._animate_funcs_post)
+
+ def _call_animate_functions(self, funcs: Iterable[callable]):
+ for fn in funcs:
+ try:
+ args = getfullargspec(fn).args
+
+ if len(args) > 0:
+ if args[0] == "self" and not len(args) > 1:
+ fn()
+ else:
+ fn(self)
+ else:
+ fn()
+ except (ValueError, TypeError):
+ warn(
+ f"Could not resolve argspec of {self.__class__.__name__} animation function: {fn}, "
+ f"calling it without arguments."
+ )
+ fn()
+
+ def add_animations(
+ self,
+ *funcs: Iterable[callable],
+ pre_render: bool = True,
+ post_render: bool = False,
+ ):
+ """
+ Add function(s) that are called on every render cycle.
+ These are called at the Subplot level.
+
+ Parameters
+ ----------
+ *funcs: callable or iterable of callable
+ function(s) that are called on each render cycle
+
+ pre_render: bool, default ``True``, optional keyword-only argument
+ if true, these function(s) are called before a render cycle
+
+ post_render: bool, default ``False``, optional keyword-only argument
+ if true, these function(s) are called after a render cycle
+
+ """
+ for f in funcs:
+ if not callable(f):
+ raise TypeError(
+ f"all positional arguments to add_animations() must be callable types, you have passed a: {type(f)}"
+ )
+ if pre_render:
+ self._animate_funcs_pre += funcs
+ if post_render:
+ self._animate_funcs_post += funcs
+
+ def remove_animation(self, func):
+ """
+ Removes the passed animation function from both pre and post render.
+
+ Parameters
+ ----------
+ func: callable
+ The function to remove, raises a error if it's not registered as a pre or post animation function.
+
+ """
+ if func not in self._animate_funcs_pre and func not in self._animate_funcs_post:
+ raise KeyError(
+ f"The passed function: {func} is not registered as an animation function. These are the animation "
+ f" functions that are currently registered:\n"
+ f"pre: {self._animate_funcs_pre}\n\npost: {self._animate_funcs_post}"
+ )
+
+ if func in self._animate_funcs_pre:
+ self._animate_funcs_pre.remove(func)
+
+ if func in self._animate_funcs_post:
+ self._animate_funcs_post.remove(func)
+
def add_graphic(self, graphic: Graphic, center: bool = True):
"""
Add a Graphic to the scene
@@ -329,12 +412,16 @@ def _add_or_insert_graphic(
else:
self._graphics.append(loc)
+ # now that it's in the dict, just use the weakref
+ graphic = weakref.proxy(graphic)
+
# add world object to scene
self.scene.add(graphic.world_object)
if center:
self.center_graphic(graphic)
+ # if we don't use the weakref above, then the object lingers if a plot hook is used!
if hasattr(graphic, "_add_plot_area_hook"):
graphic._add_plot_area_hook(self)
@@ -385,11 +472,14 @@ def center_scene(self, zoom: float = 1.35):
if not len(self.scene.children) > 0:
return
- self.camera.show_object(self.scene)
+ # scale all cameras associated with this controller
+ # else it looks wonky
+ for camera in self.controller.cameras:
+ camera.show_object(self.scene)
- # 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
+ # 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
+ camera.zoom = zoom
def auto_scale(self, maintain_aspect: bool = False, zoom: float = 0.8):
"""
@@ -413,20 +503,31 @@ def auto_scale(self, maintain_aspect: bool = False, zoom: float = 0.8):
self.center_scene()
if not isinstance(maintain_aspect, bool):
maintain_aspect = False # assume False
- self.camera.maintain_aspect = maintain_aspect
+
+ # scale all cameras associated with this controller else it looks wonky
+ for camera in self.controller.cameras:
+ camera.maintain_aspect = maintain_aspect
if len(self.scene.children) > 0:
width, height, depth = np.ptp(self.scene.get_world_bounding_box(), axis=0)
else:
width, height, depth = (1, 1, 1)
+ # make sure width and height are non-zero
+ if width < 0.01:
+ width = 1
+ if height < 0.01:
+ height = 1
+
for selector in self.selectors:
self.scene.add(selector.world_object)
- self.camera.width = width
- self.camera.height = height
+ # scale all cameras associated with this controller else it looks wonky
+ for camera in self.controller.cameras:
+ camera.width = width
+ camera.height = height
- self.camera.zoom = zoom
+ camera.zoom = zoom
def remove_graphic(self, graphic: Graphic):
"""
@@ -477,6 +578,9 @@ def delete_graphic(self, graphic: Graphic):
# remove from list of addresses
glist.remove(loc)
+ # cleanup
+ graphic._cleanup()
+
if kind == "graphic":
del GRAPHICS[loc]
elif kind == "selector":
@@ -516,6 +620,28 @@ def __getitem__(self, name: str):
f"The current selectors are:\n {selector_names}"
)
+ def __contains__(self, item: Union[str, Graphic]):
+ to_check = [*self.graphics, *self.selectors]
+
+ if isinstance(item, Graphic):
+ if item in to_check:
+ return True
+ else:
+ return False
+
+ elif isinstance(item, str):
+ for graphic in to_check:
+ # only check named graphics
+ if graphic.name is None:
+ continue
+
+ if graphic.name == item:
+ return True
+
+ return False
+
+ raise TypeError("PlotArea `in` operator accepts only `Graphic` or `str` types")
+
def __str__(self):
if self.name is None:
name = "unnamed"
diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py
index a8cd4852b..c178c0fca 100644
--- a/fastplotlib/layouts/_subplot.py
+++ b/fastplotlib/layouts/_subplot.py
@@ -1,6 +1,4 @@
from typing import *
-from inspect import getfullargspec
-from warnings import warn
import numpy as np
@@ -18,7 +16,7 @@
from ..graphics import TextGraphic
from ._utils import make_canvas_and_renderer
-from ._base import PlotArea
+from ._plot_area import PlotArea
from ._defaults import create_camera, create_controller
from .graphic_methods_mixin import GraphicMethodsMixin
@@ -97,9 +95,6 @@ def __init__(
self._grid: GridHelper = GridHelper(size=100, thickness=1)
- self._animate_funcs_pre = list()
- self._animate_funcs_post = list()
-
super(Subplot, self).__init__(
parent=parent,
position=position,
@@ -152,9 +147,9 @@ def set_title(self, text: Any):
text = str(text)
if self._title_graphic is not None:
- self._title_graphic.update_text(text)
+ self._title_graphic.text = text
else:
- tg = TextGraphic(text)
+ tg = TextGraphic(text=text, size=18)
self._title_graphic = tg
self.docks["top"].size = 35
@@ -192,87 +187,6 @@ def get_rect(self):
return rect
- def render(self):
- self._call_animate_functions(self._animate_funcs_pre)
-
- super(Subplot, self).render()
-
- self._call_animate_functions(self._animate_funcs_post)
-
- def _call_animate_functions(self, funcs: Iterable[callable]):
- for fn in funcs:
- try:
- args = getfullargspec(fn).args
-
- if len(args) > 0:
- if args[0] == "self" and not len(args) > 1:
- fn()
- else:
- fn(self)
- else:
- fn()
- except (ValueError, TypeError):
- warn(
- f"Could not resolve argspec of {self.__class__.__name__} animation function: {fn}, "
- f"calling it without arguments."
- )
- fn()
-
- def add_animations(
- self,
- *funcs: Iterable[callable],
- pre_render: bool = True,
- post_render: bool = False,
- ):
- """
- Add function(s) that are called on every render cycle.
- These are called at the Subplot level.
-
- Parameters
- ----------
- *funcs: callable or iterable of callable
- function(s) that are called on each render cycle
-
- pre_render: bool, default ``True``, optional keyword-only argument
- if true, these function(s) are called before a render cycle
-
- post_render: bool, default ``False``, optional keyword-only argument
- if true, these function(s) are called after a render cycle
-
- """
- for f in funcs:
- if not callable(f):
- raise TypeError(
- f"all positional arguments to add_animations() must be callable types, you have passed a: {type(f)}"
- )
- if pre_render:
- self._animate_funcs_pre += funcs
- if post_render:
- self._animate_funcs_post += funcs
-
- def remove_animation(self, func):
- """
- Removes the passed animation function from both pre and post render.
-
- Parameters
- ----------
- func: callable
- The function to remove, raises a error if it's not registered as a pre or post animation function.
-
- """
- if func not in self._animate_funcs_pre and func not in self._animate_funcs_post:
- raise KeyError(
- f"The passed function: {func} is not registered as an animation function. These are the animation "
- f" functions that are currently registered:\n"
- f"pre: {self._animate_funcs_pre}\n\npost: {self._animate_funcs_post}"
- )
-
- if func in self._animate_funcs_pre:
- self._animate_funcs_pre.remove(func)
-
- if func in self._animate_funcs_post:
- self._animate_funcs_post.remove(func)
-
def set_axes_visibility(self, visible: bool):
"""Toggles axes visibility."""
if visible:
diff --git a/fastplotlib/layouts/_utils.py b/fastplotlib/layouts/_utils.py
index ebfe9e306..dd6fbeb50 100644
--- a/fastplotlib/layouts/_utils.py
+++ b/fastplotlib/layouts/_utils.py
@@ -14,6 +14,7 @@
JupyterWgpuCanvas = False
try:
+ import PyQt6
from wgpu.gui.qt import QWgpuCanvas
except ImportError:
QWgpuCanvas = False
@@ -32,6 +33,29 @@
}
+def auto_determine_canvas():
+ try:
+ ip = get_ipython()
+ if ip.has_trait("kernel"):
+ if hasattr(ip.kernel, "app"):
+ if ip.kernel.app.__class__.__name__ == "QApplication":
+ return QWgpuCanvas
+ else:
+ return JupyterWgpuCanvas
+ except NameError:
+ pass
+
+ else:
+ if CANVAS_OPTIONS_AVAILABLE["qt"]:
+ return QWgpuCanvas
+ elif CANVAS_OPTIONS_AVAILABLE["glfw"]:
+ return GlfwWgpuCanvas
+
+ # We go with the wgpu auto guess
+ # for example, offscreen canvas etc.
+ return WgpuCanvas
+
+
def make_canvas_and_renderer(
canvas: Union[str, WgpuCanvas, Texture, None], renderer: [WgpuRenderer, None]
):
@@ -41,7 +65,8 @@ def make_canvas_and_renderer(
"""
if canvas is None:
- canvas = WgpuCanvas()
+ Canvas = auto_determine_canvas()
+ canvas = Canvas()
elif isinstance(canvas, str):
if canvas not in CANVAS_OPTIONS:
diff --git a/fastplotlib/layouts/graphic_methods_mixin.py b/fastplotlib/layouts/graphic_methods_mixin.py
index 760083cb9..b00187df7 100644
--- a/fastplotlib/layouts/graphic_methods_mixin.py
+++ b/fastplotlib/layouts/graphic_methods_mixin.py
@@ -313,7 +313,7 @@ def add_line_stack(self, data: List[numpy.ndarray], z_position: Union[List[float
"""
return self._create_graphic(LineStack, data, z_position, thickness, colors, cmap, separation, separation_axis, name, *args, **kwargs)
- def add_scatter(self, data: numpy.ndarray, sizes: Union[int, numpy.ndarray, list] = 1, colors: numpy.ndarray = 'w', alpha: float = 1.0, cmap: str = None, cmap_values: Union[numpy.ndarray, List] = None, z_position: float = 0.0, *args, **kwargs) -> ScatterGraphic:
+ def add_scatter(self, data: numpy.ndarray, sizes: Union[int, float, numpy.ndarray, list] = 1, colors: numpy.ndarray = 'w', alpha: float = 1.0, cmap: str = None, cmap_values: Union[numpy.ndarray, List] = None, z_position: float = 0.0, *args, **kwargs) -> ScatterGraphic:
"""
Create a Scatter Graphic, 2d or 3d
@@ -368,7 +368,7 @@ def add_scatter(self, data: numpy.ndarray, sizes: Union[int, numpy.ndarray, list
"""
return self._create_graphic(ScatterGraphic, data, sizes, colors, alpha, cmap, cmap_values, z_position, *args, **kwargs)
- def add_text(self, text: str, position: Tuple[int] = (0, 0, 0), size: int = 10, face_color: Union[str, numpy.ndarray] = 'w', outline_color: Union[str, numpy.ndarray] = 'w', outline_thickness=0, name: str = None) -> TextGraphic:
+ def add_text(self, text: str, position: Tuple[int] = (0, 0, 0), size: int = 14, face_color: Union[str, numpy.ndarray] = 'w', outline_color: Union[str, numpy.ndarray] = 'w', outline_thickness=0, screen_space: bool = True, anchor: str = 'middle-center', *args, **kwargs) -> TextGraphic:
"""
Create a text Graphic
@@ -393,10 +393,19 @@ def add_text(self, text: str, position: Tuple[int] = (0, 0, 0), size: int = 10,
outline_thickness: int, default 0
text outline thickness
+ screen_space: bool = True
+ whether the text is rendered in screen space, in contrast to world space
+
name: str, optional
name of graphic, passed to Graphic
+ anchor: str, default "middle-center"
+ position of the origin of the text
+ a string representing the vertical and horizontal anchors, separated by a dash
+
+ * Vertical values: "top", "middle", "baseline", "bottom"
+ * Horizontal values: "left", "center", "right"
"""
- return self._create_graphic(TextGraphic, text, position, size, face_color, outline_color, outline_thickness, name, *args, **kwargs)
+ return self._create_graphic(TextGraphic, text, position, size, face_color, outline_color, outline_thickness, screen_space, anchor, *args, **kwargs)
diff --git a/fastplotlib/widgets/histogram_lut.py b/fastplotlib/widgets/histogram_lut.py
new file mode 100644
index 000000000..0d8ca9f15
--- /dev/null
+++ b/fastplotlib/widgets/histogram_lut.py
@@ -0,0 +1,293 @@
+from typing import *
+import weakref
+
+import numpy as np
+
+from pygfx import Group
+
+from ..graphics import LineGraphic, ImageGraphic, TextGraphic
+from ..graphics._base import Graphic
+from ..graphics.selectors import LinearRegionSelector
+
+
+# TODO: This is a widget, we can think about a BaseWidget class later if necessary
+class HistogramLUT(Graphic):
+ def __init__(
+ self,
+ data: np.ndarray,
+ image_graphic: ImageGraphic,
+ nbins: int = 100,
+ flank_divisor: float = 5.0,
+ **kwargs
+ ):
+ """
+
+ Parameters
+ ----------
+ data
+ image_graphic
+ nbins
+ flank_divisor: float, default 5.0
+ set `np.inf` for no flanks
+ kwargs
+ """
+ super().__init__(**kwargs)
+
+ self._nbins = nbins
+ self._flank_divisor = flank_divisor
+ self._image_graphic = image_graphic
+
+ self._data = weakref.proxy(data)
+
+ self._scale_factor: float = 1.0
+
+ hist, edges, hist_scaled, edges_flanked = self._calculate_histogram(data)
+
+ line_data = np.column_stack([hist_scaled, edges_flanked])
+
+ self.line = LineGraphic(line_data)
+
+ bounds = (edges[0], edges[-1])
+ limits = (edges_flanked[0], edges_flanked[-1])
+ size = 120 # since it's scaled to 100
+ origin = (hist_scaled.max() / 2, 0)
+
+ self.linear_region = LinearRegionSelector(
+ bounds=bounds,
+ limits=limits,
+ size=size,
+ origin=origin,
+ axis="y",
+ edge_thickness=8
+ )
+
+ # there will be a small difference with the histogram edges so this makes them both line up exactly
+ self.linear_region.selection = (image_graphic.cmap.vmin, image_graphic.cmap.vmax)
+
+ self._vmin = self.image_graphic.cmap.vmin
+ self._vmax = self.image_graphic.cmap.vmax
+
+ vmin_str, vmax_str = self._get_vmin_vmax_str()
+
+ self._text_vmin = TextGraphic(
+ text=vmin_str,
+ size=16,
+ position=(0, 0),
+ anchor="top-left",
+ outline_color="black",
+ outline_thickness=1,
+ )
+
+ self._text_vmax = TextGraphic(
+ text=vmax_str,
+ size=16,
+ position=(0, 0),
+ anchor="bottom-left",
+ outline_color="black",
+ outline_thickness=1,
+ )
+
+ widget_wo = Group()
+ widget_wo.add(
+ self.line.world_object,
+ self.linear_region.world_object,
+ self._text_vmin.world_object,
+ self._text_vmax.world_object,
+ )
+
+ self._set_world_object(widget_wo)
+
+ self.world_object.local.scale_x *= -1
+
+ self._text_vmin.position_x = -120
+ self._text_vmin.position_y = self.linear_region.selection()[0]
+
+ self._text_vmax.position_x = -120
+ self._text_vmax.position_y = self.linear_region.selection()[1]
+
+ self.linear_region.selection.add_event_handler(
+ self._linear_region_handler
+ )
+
+ self.image_graphic.cmap.add_event_handler(self._image_cmap_handler)
+
+ def _get_vmin_vmax_str(self) -> Tuple[str, str]:
+ if self.vmin < 0.001 or self.vmin > 99_999:
+ vmin_str = f"{self.vmin:.2e}"
+ else:
+ vmin_str = f"{self.vmin:.2f}"
+
+ if self.vmax < 0.001 or self.vmax > 99_999:
+ vmax_str = f"{self.vmax:.2e}"
+ else:
+ vmax_str = f"{self.vmax:.2f}"
+
+ return vmin_str, vmax_str
+
+ def _add_plot_area_hook(self, plot_area):
+ self._plot_area = plot_area
+ self.linear_region._add_plot_area_hook(plot_area)
+ self.line._add_plot_area_hook(plot_area)
+
+ self._plot_area.auto_scale()
+
+ def _calculate_histogram(self, data):
+ if data.ndim > 2:
+ # subsample to max of 500 x 100 x 100,
+ # np.histogram takes ~30ms with this size on a 8 core Ryzen laptop
+ # dim0 is usually time, allow max of 500 timepoints
+ ss0 = max(1, int(data.shape[0] / 500)) # max to prevent step = 0
+ # allow max of 100 for x and y if ndim > 2
+ ss1 = max(1, int(data.shape[1] / 100))
+ ss2 = max(1, int(data.shape[2] / 100))
+
+ data_ss = data[::ss0, ::ss1, ::ss2]
+
+ hist, edges = np.histogram(data_ss, bins=self._nbins)
+
+ else:
+ # allow max of 1000 x 1000
+ # this takes ~4ms on a 8 core Ryzen laptop
+ ss0 = max(1, int(data.shape[0] / 1_000))
+ ss1 = max(1, int(data.shape[1] / 1_000))
+
+ data_ss = data[::ss0, ::ss1]
+
+ hist, edges = np.histogram(data_ss, bins=self._nbins)
+
+ # used if data ptp <= 10 because event things get weird
+ # with tiny world objects due to floating point error
+ # so if ptp <= 10, scale up by a factor
+ self._scale_factor: int = max(1, 100 * int(10 / data_ss.ptp()))
+
+ edges = edges * self._scale_factor
+
+ bin_width = edges[1] - edges[0]
+
+ flank_nbins = int(self._nbins / self._flank_divisor)
+ flank_size = flank_nbins * bin_width
+
+ flank_left = np.arange(edges[0] - flank_size, edges[0], bin_width)
+ flank_right = np.arange(edges[-1] + bin_width, edges[-1] + flank_size, bin_width)
+
+ edges_flanked = np.concatenate((flank_left, edges, flank_right))
+ np.unique(np.diff(edges_flanked))
+
+ hist_flanked = np.concatenate((np.zeros(flank_nbins), hist, np.zeros(flank_nbins)))
+
+ # scale 0-100 to make it easier to see
+ # float32 data can produce unnecessarily high values
+ hist_scaled = hist_flanked / (hist_flanked.max() / 100)
+
+ if edges_flanked.size > hist_scaled.size:
+ edges_flanked = edges_flanked[:-1]
+
+ return hist, edges, hist_scaled, edges_flanked
+
+ def _linear_region_handler(self, ev):
+ # must use world coordinate values directly from selection()
+ # otherwise the linear region bounds jump to the closest bin edges
+ vmin, vmax = self.linear_region.selection()
+ vmin, vmax = vmin / self._scale_factor, vmax / self._scale_factor
+ self.vmin, self.vmax = vmin, vmax
+
+ def _image_cmap_handler(self, ev):
+ self.vmin, self.vmax = ev.pick_info["vmin"], ev.pick_info["vmax"]
+
+ def _block_events(self, b: bool):
+ self.image_graphic.cmap.block_events(b)
+ self.linear_region.selection.block_events(b)
+
+ @property
+ def vmin(self) -> float:
+ return self._vmin
+
+ @vmin.setter
+ def vmin(self, value: float):
+ self._block_events(True)
+
+ # must use world coordinate values directly from selection()
+ # otherwise the linear region bounds jump to the closest bin edges
+ self.linear_region.selection = (value * self._scale_factor, self.linear_region.selection()[1])
+ self.image_graphic.cmap.vmin = value
+
+ self._block_events(False)
+
+ self._vmin = value
+
+ vmin_str, vmax_str = self._get_vmin_vmax_str()
+ self._text_vmin.position_y = self.linear_region.selection()[0]
+ self._text_vmin.text = vmin_str
+
+ @property
+ def vmax(self) -> float:
+ return self._vmax
+
+ @vmax.setter
+ def vmax(self, value: float):
+ self._block_events(True)
+
+ # must use world coordinate values directly from selection()
+ # otherwise the linear region bounds jump to the closest bin edges
+ self.linear_region.selection = (self.linear_region.selection()[0], value * self._scale_factor)
+ self.image_graphic.cmap.vmax = value
+
+ self._block_events(False)
+
+ self._vmax = value
+
+ vmin_str, vmax_str = self._get_vmin_vmax_str()
+ self._text_vmax.position_y = self.linear_region.selection()[1]
+ self._text_vmax.text = vmax_str
+
+ def set_data(self, data, reset_vmin_vmax: bool = True):
+ hist, edges, hist_scaled, edges_flanked = self._calculate_histogram(data)
+
+ line_data = np.column_stack([hist_scaled, edges_flanked])
+
+ self.line.data = line_data
+
+ bounds = (edges[0], edges[-1])
+ limits = (edges_flanked[0], edges_flanked[-11])
+ origin = (hist_scaled.max() / 2, 0)
+ # self.linear_region.fill.world.position = (*origin, -2)
+
+ if reset_vmin_vmax:
+ # reset according to the new data
+ self.linear_region.limits = limits
+ self.linear_region.selection = bounds
+ else:
+ # don't change the current selection
+ self._block_events(True)
+ self.linear_region.limits = limits
+ self._block_events(False)
+
+ self._data = weakref.proxy(data)
+
+ # reset plotarea dims
+ self._plot_area.auto_scale()
+
+ @property
+ def image_graphic(self) -> ImageGraphic:
+ return self._image_graphic
+
+ @image_graphic.setter
+ def image_graphic(self, graphic):
+ if not isinstance(graphic, ImageGraphic):
+ raise TypeError(
+ f"HistogramLUT can only use ImageGraphic types, you have passed: {type(graphic)}"
+ )
+
+ # cleanup events from current image graphic
+ self._image_graphic.cmap.remove_event_handler(
+ self._image_cmap_handler
+ )
+
+ self._image_graphic = graphic
+
+ self.image_graphic.cmap.add_event_handler(self._image_cmap_handler)
+
+ def _cleanup(self):
+ self.linear_region._cleanup()
+ del self.line
+ del self.linear_region
diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py
index 9dbad277e..a9ebfafb4 100644
--- a/fastplotlib/widgets/image.py
+++ b/fastplotlib/widgets/image.py
@@ -1,28 +1,21 @@
-import weakref
from typing import *
from warnings import warn
-from functools import partial
import numpy as np
-from wgpu.gui.auto import is_jupyter
-from ipywidgets.widgets import (
- IntSlider,
- VBox,
- HBox,
- Layout,
- FloatRangeSlider,
- Button,
- BoundedIntText,
- Play,
- jslink,
-)
-from sidecar import Sidecar
-from IPython.display import display
from ..layouts import GridPlot
from ..graphics import ImageGraphic
-from ..utils import quick_min_max, calculate_gridshape
+from ..utils import calculate_gridshape
+from .histogram_lut import HistogramLUT
+from ..layouts._utils import CANVAS_OPTIONS_AVAILABLE
+
+
+if CANVAS_OPTIONS_AVAILABLE["jupyter"]:
+ from ..layouts._frame._ipywidget_toolbar import IpywidgetImageWidgetToolbar
+
+if CANVAS_OPTIONS_AVAILABLE["qt"]:
+ from ..layouts._frame._qt_toolbar import QToolbarImageWidget
DEFAULT_DIMS_ORDER = {
@@ -103,6 +96,13 @@ def gridplot(self) -> GridPlot:
"""
return self._gridplot
+ @property
+ def widget(self):
+ """
+ Output context, either an ipywidget or QWidget
+ """
+ return self.gridplot.widget
+
@property
def managed_graphics(self) -> List[ImageGraphic]:
"""List of ``ImageWidget`` managed graphics."""
@@ -113,6 +113,34 @@ def managed_graphics(self) -> List[ImageGraphic]:
iw_managed.append(subplot["image_widget_managed"])
return iw_managed
+ @property
+ def cmap(self) -> List[str]:
+ cmaps = list()
+ for g in self.managed_graphics:
+ cmaps.append(g.cmap.name)
+
+ return cmaps
+
+ @cmap.setter
+ def cmap(self, names: Union[str, List[str]]):
+ if isinstance(names, list):
+ if not all([isinstance(n, str) for n in names]):
+ raise TypeError(f"Must pass cmap name as a `str` of list of `str`, you have passed:\n{names}")
+
+ if not len(names) == len(self.managed_graphics):
+ raise IndexError(
+ f"If passing a list of cmap names, the length of the list must be the same as the number of "
+ f"image widget subplots. You have passed: {len(names)} cmap names and have "
+ f"{len(self.managed_graphics)} image widget subplots"
+ )
+
+ for name, g in zip(names, self.managed_graphics):
+ g.cmap = name
+
+ elif isinstance(names, str):
+ for g in self.managed_graphics:
+ g.cmap = names
+
@property
def data(self) -> List[np.ndarray]:
"""data currently displayed in the widget"""
@@ -129,9 +157,9 @@ def dims_order(self) -> List[str]:
return self._dims_order
@property
- def sliders(self) -> Dict[str, IntSlider]:
- """the slider instances used by the widget for indexing the desired dimensions"""
- return self._sliders
+ def sliders(self) -> Dict[str, Any]:
+ """the ipywidget IntSlider or QSlider instances used by the widget for indexing the desired dimensions"""
+ return self._image_widget_toolbar.sliders
@property
def slider_dims(self) -> List[str]:
@@ -194,7 +222,6 @@ def __init__(
slider_dims: Union[str, int, List[Union[str, int]]] = None,
window_funcs: Union[int, Dict[str, int]] = None,
frame_apply: Union[callable, Dict[int, callable]] = None,
- vmin_vmax_sliders: bool = False,
grid_shape: Tuple[int, int] = None,
names: List[str] = None,
grid_plot_kwargs: dict = None,
@@ -266,15 +293,7 @@ def __init__(
"""
- if not is_jupyter():
- raise EnvironmentError(
- "ImageWidget is currently not supported outside of jupyter"
- )
-
self._names = None
- self.toolbar = None
- self.sidecar = None
- self.plot_open = False
if isinstance(data, list):
# verify that it's a list of np.ndarray
@@ -501,13 +520,11 @@ def __init__(
self._window_funcs = None
self.window_funcs = window_funcs
- self._sliders: Dict[str, IntSlider] = dict()
+ self._sliders: Dict[str, Any] = dict()
# current_index stores {dimension_index: slice_index} for every dimension
self._current_index: Dict[str, int] = {sax: 0 for sax in self.slider_dims}
- self.vmin_vmax_sliders: List[FloatRangeSlider] = list()
-
# get max bound for all data arrays for all dimensions
self._dims_max_bounds: Dict[str, int] = {k: np.inf for k in self.slider_dims}
for _dim in list(self._dims_max_bounds.keys()):
@@ -516,40 +533,21 @@ def __init__(
self._dims_max_bounds[_dim], array.shape[order.index(_dim)]
)
+ grid_plot_kwargs_default = {"controllers": "sync"}
if grid_plot_kwargs is None:
- grid_plot_kwargs = {"controllers": "sync"}
+ grid_plot_kwargs = dict()
- self._gridplot: GridPlot = GridPlot(shape=grid_shape, **grid_plot_kwargs)
+ # update the default kwargs with any user-specified kwargs
+ # user specified kwargs will overwrite the defaults
+ grid_plot_kwargs_default.update(grid_plot_kwargs)
- for data_ix, (d, subplot) in enumerate(zip(self.data, self.gridplot)):
- minmax = quick_min_max(self.data[data_ix])
+ self._gridplot: GridPlot = GridPlot(shape=grid_shape, **grid_plot_kwargs_default)
+ for data_ix, (d, subplot) in enumerate(zip(self.data, self.gridplot)):
if self._names is not None:
name = self._names[data_ix]
- name_slider = name
else:
name = None
- name_slider = ""
-
- if vmin_vmax_sliders:
- data_range = np.ptp(minmax)
- data_range_40p = np.ptp(minmax) * 0.4
-
- minmax_slider = FloatRangeSlider(
- value=minmax,
- min=minmax[0] - data_range_40p,
- max=minmax[1] + data_range_40p,
- step=data_range / 150,
- description=f"mm: {name_slider}",
- readout=True,
- readout_format=".3f",
- )
-
- minmax_slider.observe(
- partial(self._vmin_vmax_slider_changed, data_ix), names="value"
- )
-
- self.vmin_vmax_sliders.append(minmax_slider)
frame = self._process_indices(d, slice_indices=self._current_index)
frame = self._process_frame_apply(frame, data_ix)
@@ -558,30 +556,19 @@ def __init__(
subplot.name = name
subplot.set_title(name)
- self.gridplot.renderer.add_event_handler(self._set_slider_layout, "resize")
-
- for sdm in self.slider_dims:
- slider = IntSlider(
- min=0,
- max=self._dims_max_bounds[sdm] - 1,
- step=1,
- value=0,
- description=f"dimension: {sdm}",
- orientation="horizontal",
+ hlut = HistogramLUT(
+ data=d,
+ image_graphic=ig,
+ name="histogram_lut"
)
- slider.observe(partial(self._slider_value_changed, sdm), names="value")
+ subplot.docks["right"].add_graphic(hlut)
+ subplot.docks["right"].size = 80
+ subplot.docks["right"].auto_scale(maintain_aspect=False)
+ subplot.docks["right"].controller.enabled = False
- self._sliders[sdm] = slider
-
- # will change later
- # prevent the slider callback if value is self.current_index is changed programmatically
- self.block_sliders: bool = False
-
- # TODO: So just stack everything vertically for now
- self._vbox_sliders = VBox(
- [*list(self._sliders.values()), *self.vmin_vmax_sliders]
- )
+ self.block_sliders = False
+ self._image_widget_toolbar = None
@property
def window_funcs(self) -> Dict[str, _WindowFunctions]:
@@ -769,68 +756,33 @@ def _process_frame_apply(self, array, data_ix) -> np.ndarray:
return array
- def _slider_value_changed(self, dimension: str, change: dict):
+ def _slider_value_changed(self, dimension: str, change: Union[dict, int]):
if self.block_sliders:
return
- self.current_index = {dimension: change["new"]}
-
- def _vmin_vmax_slider_changed(self, data_ix: int, change: dict):
- vmin, vmax = change["new"]
- self.managed_graphics[data_ix].cmap.vmin = vmin
- self.managed_graphics[data_ix].cmap.vmax = vmax
-
- def _set_slider_layout(self, *args):
- w, h = self.gridplot.renderer.logical_size
- for k, v in self.sliders.items():
- v.layout = Layout(width=f"{w}px")
-
- for mm in self.vmin_vmax_sliders:
- mm.layout = Layout(width=f"{w}px")
+ if isinstance(change, dict):
+ value = change["new"]
+ else:
+ value = change
+ self.current_index = {dimension: value}
- def _get_vmin_vmax_range(self, data: np.ndarray) -> tuple:
+ def reset_vmin_vmax(self):
"""
- Parameters
- ----------
- data
-
- Returns
- -------
- Tuple[Tuple[float, float], float, float, float]
- (min, max), data_range, min - (data_range * 0.4), max + (data_range * 0.4)
+ Reset the vmin and vmax w.r.t. the full data
"""
+ for ig in self.managed_graphics:
+ ig.cmap.reset_vmin_vmax()
- minmax = quick_min_max(data)
-
- data_range = np.ptp(minmax)
- data_range_40p = data_range * 0.4
-
- _range = (
- minmax,
- data_range,
- minmax[0] - data_range_40p,
- minmax[1] + data_range_40p,
- )
-
- return _range
-
- def reset_vmin_vmax(self):
+ def reset_vmin_vmax_frame(self):
"""
- Reset the vmin and vmax w.r.t. the currently displayed image(s)
+ Resets the vmin vmax and HistogramLUT widgets w.r.t. the current data shown in the
+ ImageGraphic instead of the data in the full data array. For example, if a post-processing
+ function is used, the range of values in the ImageGraphic can be very different from the
+ range of values in the full data array.
"""
- for i, ig in enumerate(self.managed_graphics):
- mm = self._get_vmin_vmax_range(ig.data())
-
- if len(self.vmin_vmax_sliders) != 0:
- state = {
- "value": mm[0],
- "step": mm[1] / 150,
- "min": mm[2],
- "max": mm[3],
- }
-
- self.vmin_vmax_sliders[i].set_state(state)
- else:
- ig.cmap.vmin, ig.cmap.vmax = mm[0]
+ for subplot in self.gridplot:
+ hlut = subplot.docks["right"]["histogram_lut"]
+ # set the data using the current image graphic data
+ hlut.set_data(subplot["image_widget_managed"].data())
def set_data(
self,
@@ -905,6 +857,9 @@ def set_data(
if new_array.ndim > 3: # tzxy
max_lengths["z"] = min(max_lengths["z"], new_array.shape[1] - 1)
+ # set histogram widget
+ subplot.docks["right"]["histogram_lut"].set_data(new_array, reset_vmin_vmax=reset_vmin_vmax)
+
# set slider maxes
# TODO: maybe make this stuff a property, like ndims, n_frames etc. and have it set the sliders
for key in self.sliders.keys():
@@ -914,145 +869,30 @@ def set_data(
# force graphics to update
self.current_index = self.current_index
- if reset_vmin_vmax:
- self.reset_vmin_vmax()
+ # if reset_vmin_vmax:
+ # self.reset_vmin_vmax()
- def show(self, toolbar: bool = True, sidecar: bool = True, sidecar_kwargs: dict = None):
+ def show(self, toolbar: bool = True, sidecar: bool = False, sidecar_kwargs: dict = None):
"""
Show the widget
Returns
-------
- VBox
- ``ipywidgets.VBox`` stacking the plotter and sliders in a vertical layout
+ OutputContext
"""
+ if self.gridplot.canvas.__class__.__name__ == "JupyterWgpuCanvas":
+ self._image_widget_toolbar = IpywidgetImageWidgetToolbar(self)
- # don't need to check for jupyter since ImageWidget is only supported within jupyter anyways
- if not toolbar:
- return VBox([self.gridplot.show(toolbar=False), self._vbox_sliders])
-
- if self.toolbar is None:
- self.toolbar = ImageWidgetToolbar(self)
-
- if not sidecar:
- return VBox(
- [
- self.gridplot.show(toolbar=True, sidecar=False, sidecar_kwargs=None),
- self.toolbar.widget,
- self._vbox_sliders,
- ]
- )
-
- if self.plot_open:
- self.sidecar = None
+ elif self.gridplot.canvas.__class__.__name__ == "QWgpuCanvas":
+ self._image_widget_toolbar = QToolbarImageWidget(self)
- if self.sidecar is None:
- if sidecar_kwargs is not None:
- self.sidecar = Sidecar(**sidecar_kwargs)
- self.plot_open = True
- else:
- self.sidecar = Sidecar()
- self.plot_open = True
-
- with self.sidecar:
- return display(VBox(
- [
- self.gridplot.show(toolbar=True, sidecar=False, sidecar_kwargs=None),
- self.toolbar.widget,
- self._vbox_sliders
- ]
- )
- )
+ return self.gridplot.show(
+ toolbar=toolbar,
+ sidecar=sidecar,
+ sidecar_kwargs=sidecar_kwargs,
+ add_widgets=[self._image_widget_toolbar]
+ )
def close(self):
"""Close Widget"""
- self.gridplot.canvas.close()
-
- self._vbox_sliders.close()
-
- if self.toolbar is not None:
- self.toolbar.widget.close()
- self.gridplot.toolbar.widget.close()
-
- if self.sidecar is not None:
- self.sidecar.close()
-
- self.plot_open = False
-
-
-class ImageWidgetToolbar:
- def __init__(self, iw: ImageWidget):
- """
- Basic toolbar for a ImageWidget instance.
-
- Parameters
- ----------
- plot:
- """
- self.iw = iw
- self.plot = iw.gridplot
-
- self.reset_vminvmax_button = Button(
- value=False,
- disabled=False,
- icon="adjust",
- layout=Layout(width="auto"),
- tooltip="reset vmin/vmax",
- )
-
- # only for xy data, no time point slider needed
- if self.iw.ndim == 2:
- self.widget = HBox([self.reset_vminvmax_button])
- # for txy, tzxy, etc. data
- else:
- self.step_size_setter = BoundedIntText(
- value=1,
- min=1,
- max=self.iw.sliders["t"].max,
- step=1,
- description="Step Size:",
- disabled=False,
- description_tooltip="set slider step",
- layout=Layout(width="150px"),
- )
- self.speed_text = BoundedIntText(
- value=100,
- min=1,
- max=1_000,
- step=50,
- description="Speed",
- disabled=False,
- description_tooltip="Playback speed, this is NOT framerate.\nArbitrary units between 1 - 1,000",
- layout=Layout(width="150px"),
- )
- self.play_button = Play(
- value=0,
- min=iw.sliders["t"].min,
- max=iw.sliders["t"].max,
- step=iw.sliders["t"].step,
- description="play/pause",
- disabled=False,
- )
- self.widget = HBox(
- [self.reset_vminvmax_button, self.play_button, self.step_size_setter, self.speed_text]
- )
-
- self.play_button.interval = 10
-
- self.step_size_setter.observe(self._change_stepsize, "value")
- self.speed_text.observe(self._change_framerate, "value")
- jslink((self.play_button, "value"), (self.iw.sliders["t"], "value"))
- jslink((self.play_button, "max"), (self.iw.sliders["t"], "max"))
-
- self.reset_vminvmax_button.on_click(self._reset_vminvmax)
-
- def _reset_vminvmax(self, obj):
- if len(self.iw.vmin_vmax_sliders) != 0:
- self.iw.reset_vmin_vmax()
-
- def _change_stepsize(self, obj):
- self.iw.sliders["t"].step = self.step_size_setter.value
-
- def _change_framerate(self, change):
- interval = int(1000 / change["new"])
- self.play_button.interval = interval
+ self.gridplot.close()
diff --git a/setup.py b/setup.py
index 6557994ef..f7195461c 100644
--- a/setup.py
+++ b/setup.py
@@ -67,7 +67,7 @@
"Programming Language :: Python :: 3",
"Topic :: Scientific/Engineering :: Visualization",
"License :: OSI Approved :: Apache Software License",
-
+ "Intended Audience :: Science/Research",
]
@@ -77,13 +77,14 @@
long_description=readme,
long_description_content_type='text/markdown',
packages=find_packages(),
- url='https://github.com/kushalkolar/fastplotlib',
+ url='https://github.com/fastplotlib/fastplotlib',
license='Apache 2.0',
- author='Kushal Kolar',
+ author='Kushal Kolar, Caitlin Lewis',
author_email='',
- python_requires='>=3.8',
+ python_requires='>=3.9',
install_requires=install_requires,
extras_require=extras_require,
include_package_data=True,
description='A fast plotting library built using the pygfx render engine'
)
+
pFad - Phonifier reborn
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.